From 82b6980a59458e71ff4f2bccc8766c26ea20d4a8 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:44:46 +0800 Subject: [PATCH] feat: Second pass aligning interactions with IntelliJ IDEA (#23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Second pass aligning interactions with IntelliJ IDEA Result tool windows - Next/Previous Occurrence (⌥⌘↓/↑) across Find, Usages, Problems, TODO, Inspection and Hierarchy, with an "N of M" status hint - Find: reuse the unpinned tab, Pin Tab, source preview, group by file or directory, exclude moves to the next result, Rerun - Usages: tabs with pinning, exclude and preview - Problems, TODO, Inspection: no autoscroll by default, severity-first sorting, context menu with quick fixes and copy description - Trees: F4/⌘↓ jump to source; speed search Home/End and arrow handling Find in Files, bookmarks, navigation - Replace mode: Enter replaces the selection, ⇧⌥Enter replaces all, multi-select - Pin Window toggle, highlighted read-only preview, search/replace history (⌥↓), Alt mnemonics, search context filter (comments / string literals) - Mnemonic bookmarks (⌥F3, ⌃⇧digit, ⌃digit), ⌘F3 bookmarks popup, descriptions, ⌘-click gutter toggles a bookmark - Select In popup (⌥F1); Recent Locations double-click and Delete; Search Everywhere remembers the query per tab Code insight and refactoring - Extract/Introduce continue with in-place rename and name suggestions; expression and occurrence choosers with live highlighting - Safe Delete confirms first; numbered Refactor This - Quick Documentation history, in-popup links and a Documentation tool window; Quick Definition switches between targets and shows the whole declaration - Goto chooser lists symbol names and can pin to the Find window - Parameter Info Tab/⇧Tab, intention preview, Reformat dialog and summary, inlay hint ⌘-click and context menu - Live templates: Enter moves to the next field, two-step Esc, ⌘J as a lookup, language surrounders with mnemonics - File Structure popup as a filterable tree; Show Usages filter and preview; call hierarchy jumps to call sites Editor - Paste re-indents each line by default; Move Statement moves whole statements in and out of blocks and re-indents - Type over closing quotes of existing strings; ⌘D/⌘⌫ cover folded regions; ⌥⌫ deletes the preceding word - ⌃G / ⌃⌘G whole-word rules and wrap hint; double-modifier caret cloning - Visual-column block selection, nested Collapse All, recursive folding, fold selection toggle, Go to Matching Brace, gutter drag selects lines, sticky line and error stripe clicks, Insert toggles overwrite mode Run, VCS, workbench - Gutter icons show the last test result; Modify Run Configuration; test tree hides passed tests, context menu and assertion diff - Stack frames resolve by fully qualified class name; context run chooser - Inline Smart Step Into - Git Log multi-select filters, "me" and date range; favourite branches, Fetch and tags in the branch popup; stash Keep index and Unstash options - Terminal tab titles follow the shell; Distraction Free persists; gear menu; sticky balloons move to the notifications tool window after a while * fix(vcs): Label restored local changes as yours after Smart Checkout; name the branch checked out Conflicts from restoring stashed changes show the user's uncommitted changes on the left like other operations, and the checkout notification names the new branch instead of the previous one. * fix: Close the merge editor on Apply; confirm before terminating a running Run tab Like IDEA, applying a merge result closes its tab, and closing a Run tab whose process still runs asks before terminating it (with a Settings toggle and "Do not ask again"). * fix(terminal): Don't start a new session in the background after closing the last tab The hidden Terminal window stays mounted, so it reopened a session as soon as the last tab closed. It now opens the Local tab only once the window is shown again, as IDEA does. * fix(vcs): Start the merge result from a three-way merge; close the editor on Apply The result pane now begins with non-conflicting changes merged and each conflict showing the base text, resolved from the side panes, instead of raw conflict markers. Apply now reports success, so the tab closes. fix(search): Match File Structure filter on word starts * fix: Limit Smart Step Into to the paused line; drop usages hints on callbacks; show full intention titles Adapters that list every remaining call in the function (debugpy) made Smart Step Into fall back to a chooser that offered calls from later lines. Targets are now narrowed to the calls on the paused line, so they are picked in place. Anonymous functions and callbacks no longer get a 'no usages' hint, and intention titles are no longer cut short when there is room for them. * fix: Undo / Redo from the Edit menu and Find Action use the editor's history They ran the webview's native undo, which misses programmatic edits such as templates, completion and refactorings, so nothing happened in the editor. --- crates/ide-core/src/editor_settings.rs | 4 +- crates/ide-core/src/workflow_settings.rs | 4 +- crates/ide-language/src/lib.rs | 5 +- crates/ide-language/src/syntax/literals.rs | 59 +++ crates/ide-language/src/syntax/mod.rs | 8 + crates/ide-language/src/syntax/tests.rs | 17 + crates/ide-lsp/src/convert.rs | 56 ++- crates/ide-lsp/src/lib.rs | 12 +- crates/ide-lsp/src/types.rs | 13 + crates/ide-lsp/tests/mock_server.rs | 5 + crates/ide-search/benches/search.rs | 1 + crates/ide-search/src/engine.rs | 56 ++- crates/ide-search/src/lib.rs | 72 +++ crates/ide-search/src/query.rs | 1 + crates/ide-search/tests/replace.rs | 1 + crates/ide-search/tests/search.rs | 73 +++ crates/ide-vcs/src/history.rs | 35 +- crates/ide-vcs/src/lib.rs | 31 +- crates/ide-vcs/src/ops.rs | 32 ++ crates/ide-vcs/src/write.rs | 26 +- crates/ide-vcs/tests/history_ops.rs | 2 + crates/ide-vcs/tests/repo.rs | 81 +++- src-tauri/src/commands/lsp.rs | 15 + src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/search.rs | 30 +- src-tauri/src/commands/vcs.rs | 14 +- src/bindings.ts | 39 +- src/editor/api.ts | 18 + src/editor/core/actions.ts | 38 +- src/editor/core/cm/caretCommands.ts | 42 +- src/editor/core/cm/columnMode.ts | 24 +- src/editor/core/cm/editing.test.ts | 4 + src/editor/core/cm/editing.ts | 457 +++++++++++++++--- src/editor/core/cm/folding.ts | 106 +++- src/editor/core/cm/gutterDrag.ts | 72 +++ src/editor/core/cm/ideaParity.test.ts | 331 +++++++++++++ src/editor/core/cm/lanes.ts | 42 +- src/editor/core/cm/languages.ts | 15 + src/editor/core/cm/overwrite.ts | 43 ++ src/editor/core/cm/prefs.ts | 95 +++- src/editor/core/cm/preview.ts | 31 ++ src/editor/core/cm/setup.ts | 6 + src/editor/core/cm/smartKeys.ts | 33 +- src/editor/core/cm/stickyScroll.ts | 36 +- src/editor/core/controller.test.ts | 12 + src/editor/core/controller.ts | 4 + src/editor/core/index.ts | 6 +- src/editor/core/model/camelHumps.ts | 13 + src/editor/lsp/Popups.module.css | 122 ++++- src/editor/lsp/Popups.test.tsx | 187 +++++++ src/editor/lsp/Popups.tsx | 388 ++++++++++++--- src/editor/lsp/actions.ts | 5 +- src/editor/lsp/codeLens.test.ts | 20 + src/editor/lsp/codeLens.ts | 5 +- src/editor/lsp/completion.ts | 21 +- src/editor/lsp/docs.ts | 163 +++++++ src/editor/lsp/features.test.ts | 15 + src/editor/lsp/files.ts | 29 +- src/editor/lsp/format.test.ts | 28 ++ src/editor/lsp/format.ts | 109 ++++- src/editor/lsp/hover.ts | 143 ++++-- src/editor/lsp/index.ts | 20 +- src/editor/lsp/inlay.test.ts | 69 +++ src/editor/lsp/inlay.ts | 69 ++- src/editor/lsp/inplaceRename.test.ts | 39 +- src/editor/lsp/inplaceRename.ts | 53 +- src/editor/lsp/intentions.test.ts | 64 +++ src/editor/lsp/intentions.ts | 52 +- src/editor/lsp/markdown.ts | 54 ++- src/editor/lsp/navigation.ts | 27 +- src/editor/lsp/popupStore.ts | 88 +++- src/editor/lsp/signature.test.ts | 54 ++- src/editor/lsp/signature.ts | 113 ++++- src/editor/lsp/syntaxFallback.test.ts | 3 +- src/editor/lsp/tempHighlight.ts | 58 +++ src/editor/lsp/theme.ts | 15 + src/editor/lsp/typeInfo.ts | 6 +- src/editor/lsp/workspaceEdit.test.ts | 31 +- src/editor/lsp/workspaceEdit.ts | 72 ++- src/editor/refactor/introduce.ts | 47 +- src/editor/refactor/refactorThis.test.ts | 88 +++- src/editor/refactor/refactorThis.ts | 35 +- src/editor/refactor/safeDelete.test.ts | 53 +- src/editor/refactor/safeDelete.ts | 115 ++++- src/editor/refactor/serverIntroduce.test.ts | 57 +++ src/editor/refactor/serverIntroduce.ts | 327 +++++++++++++ src/features/analysis/Analysis.module.css | 46 ++ src/features/analysis/InspectionView.tsx | 410 ++++++++++++---- src/features/analysis/analysis.test.ts | 17 + src/features/analysis/index.ts | 3 +- src/features/analysis/inspect.ts | 17 + .../app/__tests__/editCommand.test.ts | 23 + src/features/app/appActions.ts | 17 +- src/features/code-insight/HierarchyView.tsx | 78 ++- .../code-insight/ProblemsView.module.css | 10 + src/features/code-insight/ProblemsView.tsx | 262 ++++++++-- .../code-insight/UsagesPopup.module.css | 44 ++ src/features/code-insight/UsagesPopup.tsx | 259 ++++++++-- .../code-insight/UsagesView.module.css | 16 + src/features/code-insight/UsagesView.tsx | 290 +++++++++-- src/features/code-insight/hierarchy.test.ts | 92 ++++ src/features/code-insight/hierarchy.ts | 74 ++- src/features/code-insight/index.ts | 17 +- src/features/code-insight/ipc.ts | 11 +- src/features/code-insight/problemsStore.ts | 24 + .../code-insight/usageResults.test.ts | 21 +- src/features/code-insight/usages.ts | 83 +++- .../code-insight/usagesPopup.test.tsx | 86 ++++ src/features/code-insight/views.test.ts | 34 +- .../debugger/__tests__/editor.test.ts | 15 +- .../debugger/__tests__/smartStep.test.ts | 135 ++++++ src/features/debugger/actions.ts | 10 +- .../debugger/editor/breakpointGutter.ts | 14 + .../debugger/editor/editor.module.css | 12 + src/features/debugger/editor/smartStep.ts | 262 ++++++++++ src/features/debugger/index.ts | 4 +- src/features/debugger/ui/Overlays.tsx | 4 +- src/features/debugger/ui/popoverStore.ts | 19 +- src/features/project-tree/HeaderActions.tsx | 4 +- src/features/project-tree/actions.ts | 55 ++- src/features/project-tree/selectIn.test.ts | 44 ++ src/features/run/RunView.tsx | 6 +- src/features/run/TestTreeView.test.tsx | 86 ++++ src/features/run/TestTreeView.tsx | 112 ++++- src/features/run/context.ts | 36 +- src/features/run/contextRun.test.ts | 58 ++- src/features/run/contextRun.ts | 36 +- src/features/run/dialogs.tsx | 19 + src/features/run/java.test.ts | 2 +- src/features/run/links.test.ts | 22 +- src/features/run/links.ts | 29 +- src/features/run/navigate.test.ts | 62 +++ src/features/run/navigate.ts | 69 ++- src/features/run/producers.test.ts | 19 + src/features/run/producers.ts | 35 +- src/features/run/runGutter.ts | 94 ++-- src/features/run/session.test.tsx | 27 +- src/features/run/store.ts | 42 +- src/features/run/targets.ts | 4 +- src/features/run/testing/comparison.test.ts | 55 +++ src/features/run/testing/comparison.ts | 54 +++ src/features/run/testing/jvm.ts | 8 +- src/features/run/testing/parsers.test.ts | 4 +- src/features/run/testing/rerun.test.ts | 34 +- src/features/run/testing/rerun.ts | 24 +- src/features/run/testing/states.test.ts | 60 +++ src/features/run/testing/states.ts | 101 ++++ src/features/run/testing/types.ts | 4 +- .../search/__tests__/bookmarkPopups.test.tsx | 88 ++++ .../search/__tests__/bookmarks.test.ts | 47 ++ .../search/__tests__/codePreview.test.tsx | 41 ++ .../search/__tests__/everywhereModel.test.ts | 26 + .../search/__tests__/fileStructure.test.tsx | 69 +++ .../search/__tests__/findPopup.test.tsx | 153 ++++++ .../search/__tests__/findScopes.test.ts | 44 +- .../search/__tests__/findWindow.test.ts | 69 +++ src/features/search/__tests__/outline.test.ts | 34 +- src/features/search/__tests__/recent.test.ts | 18 +- .../bookmarks/BookmarkPopups.module.css | 131 +++++ .../search/bookmarks/BookmarkPopups.tsx | 251 ++++++++++ .../search/bookmarks/BookmarksView.module.css | 15 + .../search/bookmarks/BookmarksView.tsx | 46 +- src/features/search/bookmarks/gutter.ts | 115 +++-- src/features/search/bookmarks/store.ts | 133 ++++- src/features/search/common/locations.ts | 11 + src/features/search/common/tracking.ts | 22 +- src/features/search/everywhere/store.ts | 9 +- src/features/search/find/FindInFiles.tsx | 209 ++++++-- .../search/find/FindToolWindow.module.css | 10 + src/features/search/find/FindToolWindow.tsx | 270 ++++++++--- src/features/search/find/parts.module.css | 11 +- src/features/search/find/parts.tsx | 124 +++-- src/features/search/find/rowSelection.ts | 52 ++ src/features/search/find/store.ts | 63 ++- src/features/search/find/windowStore.ts | 71 ++- src/features/search/index.ts | 142 +++++- src/features/search/recent/RecentPopups.tsx | 24 +- .../search/structure/StructureView.module.css | 13 + .../search/structure/StructureView.tsx | 189 +++++--- src/features/search/structure/outline.ts | 73 +-- src/features/search/structure/store.ts | 20 +- .../settings/pages/editor/general.tsx | 1 + src/features/settings/pages/execution.tsx | 1 + .../templates/__tests__/editor.test.ts | 89 +++- .../templates/__tests__/surrounders.test.ts | 40 ++ .../templates/__tests__/views.test.tsx | 36 ++ src/features/templates/editor.ts | 189 ++++++-- src/features/templates/live/builtins.ts | 130 +---- src/features/templates/live/surrounders.ts | 228 +++++++++ .../templates/popup/Chooser.module.css | 14 + src/features/templates/popup/Chooser.tsx | 42 +- src/features/templates/popup/store.ts | 8 + src/features/terminal/TerminalView.tsx | 39 +- src/features/terminal/session.ts | 13 +- src/features/terminal/store.ts | 28 +- src/features/terminal/terminal.test.ts | 30 +- src/features/todo/TodoView.module.css | 10 + src/features/todo/TodoView.test.tsx | 11 +- src/features/todo/TodoView.tsx | 153 ++++-- src/features/todo/index.ts | 3 +- src/features/todo/ipc.ts | 9 +- src/features/vcs/BranchWidget.module.css | 27 ++ src/features/vcs/BranchWidget.tsx | 105 +++- src/features/vcs/CommitView.tsx | 3 +- src/features/vcs/GitLog.tsx | 111 +++-- src/features/vcs/MergeEditor.tsx | 117 ++--- src/features/vcs/__tests__/branches.test.ts | 55 ++- src/features/vcs/__tests__/conflicts.test.ts | 26 +- src/features/vcs/__tests__/logFilters.test.ts | 90 ++++ .../vcs/__tests__/projectSwitch.test.ts | 4 +- src/features/vcs/actions.tsx | 14 +- src/features/vcs/branchMenu.ts | 21 +- src/features/vcs/branchOps.ts | 2 + src/features/vcs/branches.ts | 78 +-- src/features/vcs/changeOps.tsx | 205 +++++++- src/features/vcs/common.module.css | 6 + src/features/vcs/conflicts.ts | 47 +- src/features/vcs/dialogs.tsx | 159 ++++++ src/features/vcs/logStore.ts | 80 ++- src/platform/editorSettings.ts | 4 +- src/platform/keymap.test.ts | 73 ++- src/platform/keymap.ts | 122 +++-- src/platform/keymaps/ideaParity.test.ts | 12 + src/platform/keymaps/intellij-macos.ts | 43 +- src/platform/keymaps/intellij-windows.ts | 43 +- src/platform/keymaps/vscode-macos.ts | 35 ++ src/platform/keymaps/vscode-windows.ts | 2 + src/platform/locales/text/zh.json | 69 ++- src/platform/workflowSettings.ts | 4 +- src/ui/List.tsx | 8 +- src/ui/SourcePreview.module.css | 55 +++ src/ui/SourcePreview.tsx | 111 +++++ src/ui/Tree.test.tsx | 83 +++- src/ui/Tree.tsx | 115 ++++- src/ui/expui/general/autoscrollFromSource.svg | 5 + .../general/autoscrollFromSource_dark.svg | 5 + src/ui/expui/general/autoscrollToSource.svg | 5 + .../expui/general/autoscrollToSource_dark.svg | 5 + src/ui/expui/gutter/runError.svg | 8 + src/ui/expui/gutter/runError_dark.svg | 8 + src/ui/expui/gutter/runSuccess.svg | 7 + src/ui/expui/gutter/runSuccess_dark.svg | 7 + src/ui/icons.tsx | 6 + src/ui/index.ts | 1 + src/ui/openidea/vcs/notFavorite.svg | 3 + src/ui/openidea/vcs/notFavorite_dark.svg | 3 + src/ui/openidea/vcs/tag.svg | 4 + src/ui/openidea/vcs/tag_dark.svg | 4 + src/workbench/Notifications.tsx | 8 +- src/workbench/StatusBar.module.css | 7 + src/workbench/StatusBar.tsx | 13 +- src/workbench/TitleBar.test.tsx | 46 ++ src/workbench/TitleBar.tsx | 91 +++- src/workbench/Workbench.test.tsx | 13 + src/workbench/activate.ts | 3 +- src/workbench/contributions.ts | 79 ++- src/workbench/distractionFree.test.tsx | 11 +- src/workbench/distractionFree.ts | 28 +- src/workbench/notificationStore.ts | 14 +- src/workbench/theme.ts | 28 +- src/workbench/toolwindows/actions.ts | 40 +- src/workbench/toolwindows/occurrences.test.ts | 54 +++ 262 files changed, 13085 insertions(+), 1596 deletions(-) create mode 100644 crates/ide-language/src/syntax/literals.rs create mode 100644 src/editor/core/cm/gutterDrag.ts create mode 100644 src/editor/core/cm/ideaParity.test.ts create mode 100644 src/editor/core/cm/overwrite.ts create mode 100644 src/editor/core/cm/preview.ts create mode 100644 src/editor/lsp/Popups.test.tsx create mode 100644 src/editor/lsp/codeLens.test.ts create mode 100644 src/editor/lsp/docs.ts create mode 100644 src/editor/lsp/format.test.ts create mode 100644 src/editor/lsp/inlay.test.ts create mode 100644 src/editor/lsp/intentions.test.ts create mode 100644 src/editor/lsp/tempHighlight.ts create mode 100644 src/editor/refactor/serverIntroduce.test.ts create mode 100644 src/editor/refactor/serverIntroduce.ts create mode 100644 src/features/app/__tests__/editCommand.test.ts create mode 100644 src/features/code-insight/hierarchy.test.ts create mode 100644 src/features/code-insight/usagesPopup.test.tsx create mode 100644 src/features/debugger/__tests__/smartStep.test.ts create mode 100644 src/features/debugger/editor/smartStep.ts create mode 100644 src/features/project-tree/selectIn.test.ts create mode 100644 src/features/run/TestTreeView.test.tsx create mode 100644 src/features/run/navigate.test.ts create mode 100644 src/features/run/testing/comparison.test.ts create mode 100644 src/features/run/testing/comparison.ts create mode 100644 src/features/run/testing/states.test.ts create mode 100644 src/features/run/testing/states.ts create mode 100644 src/features/search/__tests__/bookmarkPopups.test.tsx create mode 100644 src/features/search/__tests__/codePreview.test.tsx create mode 100644 src/features/search/__tests__/fileStructure.test.tsx create mode 100644 src/features/search/__tests__/findPopup.test.tsx create mode 100644 src/features/search/__tests__/findWindow.test.ts create mode 100644 src/features/search/bookmarks/BookmarkPopups.module.css create mode 100644 src/features/search/bookmarks/BookmarkPopups.tsx create mode 100644 src/features/search/find/rowSelection.ts create mode 100644 src/features/templates/__tests__/surrounders.test.ts create mode 100644 src/features/templates/live/surrounders.ts create mode 100644 src/features/vcs/__tests__/logFilters.test.ts create mode 100644 src/ui/SourcePreview.module.css create mode 100644 src/ui/SourcePreview.tsx create mode 100644 src/ui/expui/general/autoscrollFromSource.svg create mode 100644 src/ui/expui/general/autoscrollFromSource_dark.svg create mode 100644 src/ui/expui/general/autoscrollToSource.svg create mode 100644 src/ui/expui/general/autoscrollToSource_dark.svg create mode 100644 src/ui/expui/gutter/runError.svg create mode 100644 src/ui/expui/gutter/runError_dark.svg create mode 100644 src/ui/expui/gutter/runSuccess.svg create mode 100644 src/ui/expui/gutter/runSuccess_dark.svg create mode 100644 src/ui/openidea/vcs/notFavorite.svg create mode 100644 src/ui/openidea/vcs/notFavorite_dark.svg create mode 100644 src/ui/openidea/vcs/tag.svg create mode 100644 src/ui/openidea/vcs/tag_dark.svg create mode 100644 src/workbench/TitleBar.test.tsx create mode 100644 src/workbench/toolwindows/occurrences.test.ts diff --git a/crates/ide-core/src/editor_settings.rs b/crates/ide-core/src/editor_settings.rs index b6f879a4..8bf44084 100644 --- a/crates/ide-core/src/editor_settings.rs +++ b/crates/ide-core/src/editor_settings.rs @@ -39,7 +39,7 @@ impl Default for EditorGeneralSettings { caret_scroll: "minimize".into(), soft_wrap_files: "*.md; *.txt; *.rst; *.adoc".into(), soft_wrap_indent: true, - scroll_past_end: true, + scroll_past_end: false, strip_trailing_scope: "modified".into(), keep_trailing_on_caret_line: true, remove_trailing_blank_lines: false, @@ -227,7 +227,7 @@ impl Default for SmartKeysSettings { pair_quotes: true, surround_selection: true, tab_out_of_pair: true, - reformat_on_paste: "indentBlock".into(), + reformat_on_paste: "indentEachLine".into(), smart_indent: true, backspace_unindent: "autoindent".into(), } diff --git a/crates/ide-core/src/workflow_settings.rs b/crates/ide-core/src/workflow_settings.rs index c574b84f..03053995 100644 --- a/crates/ide-core/src/workflow_settings.rs +++ b/crates/ide-core/src/workflow_settings.rs @@ -212,6 +212,8 @@ impl Default for PythonDebuggerSettings { #[serde(default, rename_all = "camelCase")] pub struct RunSettings { pub confirm_rerun: bool, + /// Ask before closing a Run tab whose process is still running. + pub confirm_terminate: bool, pub show_on_start: bool, /// Grace period between interrupt and kill when stopping a process. pub termination_timeout_ms: u32, @@ -219,7 +221,7 @@ pub struct RunSettings { impl Default for RunSettings { fn default() -> Self { - Self { confirm_rerun: true, show_on_start: true, termination_timeout_ms: 3000 } + Self { confirm_rerun: true, confirm_terminate: true, show_on_start: true, termination_timeout_ms: 3000 } } } diff --git a/crates/ide-language/src/lib.rs b/crates/ide-language/src/lib.rs index 1320e490..8807d99a 100644 --- a/crates/ide-language/src/lib.rs +++ b/crates/ide-language/src/lib.rs @@ -13,8 +13,9 @@ mod registry; mod syntax; pub use registry::LanguageRegistry; pub use syntax::{ - CSS_CLASS, CSS_ID, CSS_VARIABLE, FileReference, MAX_CACHED_TREES, MAX_PARSE_BYTES, ParsedTree, Syntax, SyntaxCache, - SyntaxCacheStats, SyntaxHighlightKind, SyntaxHighlights, TreeError, WebReference, WebReferenceKind, + CSS_CLASS, CSS_ID, CSS_VARIABLE, FileReference, Literal, LiteralKind, MAX_CACHED_TREES, MAX_PARSE_BYTES, + ParsedTree, Syntax, SyntaxCache, SyntaxCacheStats, SyntaxHighlightKind, SyntaxHighlights, TreeError, WebReference, + WebReferenceKind, }; /// Parsed form of `languages/.toml`. Backend-only; the UI gets [`LanguageInfo`]. diff --git a/crates/ide-language/src/syntax/literals.rs b/crates/ide-language/src/syntax/literals.rs new file mode 100644 index 00000000..78031fdd --- /dev/null +++ b/crates/ide-language/src/syntax/literals.rs @@ -0,0 +1,59 @@ +//! Comments and string literals of a text, for Find in Files' search context +//! filter ("In comments", "Except string literals", …). + +use super::folding::is_comment; +use tree_sitter::{Node, Tree}; + +/// What a [`Literal`] range is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LiteralKind { + Comment, + String, +} + +/// A comment or string literal: byte range of the outermost node. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Literal { + pub start: usize, + pub end: usize, + pub kind: LiteralKind, +} + +fn kind_of(node: &Node<'_>) -> Option { + if !node.is_named() { + return None; + } + let kind = node.kind(); + if is_comment(kind) { + Some(LiteralKind::Comment) + } else if kind.contains("string") || matches!(kind, "char_literal" | "character_literal" | "rune_literal") { + Some(LiteralKind::String) + } else { + None + } +} + +/// Comments and string literals in document order; nested ones (a string in a +/// comment, interpolations) are covered by the outermost. +pub(crate) fn literals(tree: &Tree) -> Vec { + let mut out = Vec::new(); + let mut cursor = tree.walk(); + loop { + let node = cursor.node(); + let kind = kind_of(&node); + if let Some(kind) = kind { + out.push(Literal { start: node.start_byte(), end: node.end_byte(), kind }); + } + if kind.is_none() && cursor.goto_first_child() { + continue; + } + loop { + if cursor.goto_next_sibling() { + break; + } + if !cursor.goto_parent() { + return out; + } + } + } +} diff --git a/crates/ide-language/src/syntax/mod.rs b/crates/ide-language/src/syntax/mod.rs index bd24fc77..879cbb64 100644 --- a/crates/ide-language/src/syntax/mod.rs +++ b/crates/ide-language/src/syntax/mod.rs @@ -5,6 +5,7 @@ mod folding; mod grammars; mod highlight; pub(crate) mod lines; +mod literals; mod outline; mod selection; mod web; @@ -14,6 +15,7 @@ pub use cache::{MAX_CACHED_TREES, SyntaxCache, SyntaxCacheStats}; use grammars::{SPECS, Spec}; pub use highlight::{SyntaxHighlightKind, SyntaxHighlights}; use ide_core::{LanguageId, OutlineItem, Pos, Range}; +pub use literals::{Literal, LiteralKind}; use std::cell::RefCell; use std::collections::HashMap; use std::sync::OnceLock; @@ -227,6 +229,12 @@ impl Syntax { }) } + /// Comments and string literals of `text` (byte ranges, document order); + /// `None` without a grammar for `lang` or for texts too large to parse. + pub fn literals(&self, lang: &LanguageId, text: &str) -> Option> { + self.analyze(lang, text, |_, tree, _| Some(literals::literals(tree))) + } + /// Syntax-aware selection ranges for IDEA "Extend Selection" (⌥↑), innermost first. pub fn selection_ranges(&self, lang: &LanguageId, text: &str, offset_utf16: u32) -> Vec { match self.analyze(lang, text, |_, tree, text| Some(selection::selection_ranges(tree, text, offset_utf16))) { diff --git a/crates/ide-language/src/syntax/tests.rs b/crates/ide-language/src/syntax/tests.rs index f61c8590..293fe341 100644 --- a/crates/ide-language/src/syntax/tests.rs +++ b/crates/ide-language/src/syntax/tests.rs @@ -1170,3 +1170,20 @@ fn web_file_references_in_attributes_and_stylesheets() { let first = &syntax().web_file_references(&lang("html"), html)[0]; assert_eq!((first.range.start.col, first.range.end.col), (12, 21)); } + +#[test] +fn literals_are_outermost_comments_and_strings() { + let src = "// note \"q\"\nfn main() { let s = \"a // b\"; /* c */ let c = 'x'; }\n"; + let found: Vec<(&str, LiteralKind)> = + syntax().literals(&lang("rust"), src).unwrap().iter().map(|l| (&src[l.start..l.end], l.kind)).collect(); + assert_eq!( + found, + vec![ + ("// note \"q\"", LiteralKind::Comment), + ("\"a // b\"", LiteralKind::String), + ("/* c */", LiteralKind::Comment), + ("'x'", LiteralKind::String), + ] + ); + assert!(syntax().literals(&lang("plaintext"), "// x").is_none()); +} diff --git a/crates/ide-lsp/src/convert.rs b/crates/ide-lsp/src/convert.rs index 8f1fc841..34646c6a 100644 --- a/crates/ide-lsp/src/convert.rs +++ b/crates/ide-lsp/src/convert.rs @@ -733,19 +733,34 @@ pub fn signature_help(conv_enc: Encoding, h: lsp::SignatureHelp) -> Option InlayHint { - let label = match h.label { - lsp::InlayHintLabel::String(s) => s, - lsp::InlayHintLabel::LabelParts(parts) => parts.into_iter().map(|p| p.value).collect(), + let tooltip_text = |t: lsp::InlayHintTooltip| match t { + lsp::InlayHintTooltip::String(s) => s, + lsp::InlayHintTooltip::MarkupContent(m) => m.value, + }; + let (label, parts) = match h.label { + lsp::InlayHintLabel::String(s) => (s, Vec::new()), + lsp::InlayHintLabel::LabelParts(parts) => { + let label = parts.iter().map(|p| p.value.as_str()).collect(); + let parts = parts + .into_iter() + .map(|p| InlayLabelPart { + value: p.value, + tooltip: p.tooltip.map(|t| match t { + lsp::InlayHintLabelPartTooltip::String(s) => s, + lsp::InlayHintLabelPartTooltip::MarkupContent(m) => m.value, + }), + location: p.location.as_ref().and_then(|l| conv.location(l)), + }) + .collect(); + (label, parts) + } }; let kind = match h.kind { Some(lsp::InlayHintKind::TYPE) => InlayKind::Type, Some(lsp::InlayHintKind::PARAMETER) => InlayKind::Parameter, _ => InlayKind::Other, }; - let tooltip = h.tooltip.map(|t| match t { - lsp::InlayHintTooltip::String(s) => s, - lsp::InlayHintTooltip::MarkupContent(m) => m.value, - }); + let tooltip = h.tooltip.map(tooltip_text); InlayHint { position: conv.pos(path, h.position), label, @@ -753,6 +768,7 @@ pub fn inlay_hint(conv: &Conv, path: &Path, h: lsp::InlayHint) -> InlayHint { padding_left: h.padding_left.unwrap_or(false), padding_right: h.padding_right.unwrap_or(false), tooltip, + parts, } } @@ -1132,6 +1148,32 @@ mod tests { assert_eq!(h.label, ": Vec"); assert_eq!(h.kind, InlayKind::Type); assert!(h.padding_left && !h.padding_right); + assert_eq!(h.parts.len(), 2); + assert!(h.parts.iter().all(|p| p.location.is_none())); + } + + #[cfg(unix)] + #[test] + fn inlay_label_parts_keep_locations() { + let conv = Conv::utf16(); + let h: lsp::InlayHint = serde_json::from_value(json!({ + "position": {"line": 0, "character": 3}, + "label": [ + {"value": ": "}, + {"value": "Foo", "tooltip": "struct Foo", + "location": {"uri": "file:///p/foo.rs", "range": {"start": {"line": 4, "character": 7}, "end": {"line": 4, "character": 10}}}} + ] + })) + .unwrap(); + let h = inlay_hint(&conv, Path::new("/a"), h); + assert_eq!(h.label, ": Foo"); + let part = &h.parts[1]; + assert_eq!(part.value, "Foo"); + assert_eq!(part.tooltip.as_deref(), Some("struct Foo")); + let loc = part.location.as_ref().unwrap(); + assert_eq!(loc.path, PathBuf::from("/p/foo.rs")); + assert_eq!(loc.range.start, p(4, 7)); + assert_eq!(h.kind, InlayKind::Other); } #[cfg(unix)] diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 14f0ed79..5e7994ea 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -1478,8 +1478,9 @@ impl LspManager { let t = &targets[0]; let items: Option> = self.call(t, "textDocument/prepareCallHierarchy", snap.pos_params(t, pos), None, DEFAULT_TIMEOUT).await?; + let items = items.filter(|i| !i.is_empty()).ok_or_else(no_hierarchy_target)?; let mut out = Vec::new(); - for item in items.unwrap_or_default() { + for item in items { let params = json!({ "item": item }); match direction { HierarchyDirection::Incoming => { @@ -1520,12 +1521,13 @@ impl LspManager { // and lsp-types mistypes `tags` (jdtls sends an array). let items: Option> = self.call(t, "textDocument/prepareTypeHierarchy", snap.pos_params(t, pos), None, DEFAULT_TIMEOUT).await?; + let items = items.filter(|i| !i.is_empty()).ok_or_else(no_hierarchy_target)?; let method = match direction { HierarchyDirection::Incoming => "typeHierarchy/supertypes", HierarchyDirection::Outgoing => "typeHierarchy/subtypes", }; let mut out = Vec::new(); - for item in items.unwrap_or_default() { + for item in items { let related: Option> = self.call(t, method, json!({ "item": item }), None, LONG_TIMEOUT).await?; let conv = self.conv(t.enc()); @@ -1781,6 +1783,12 @@ fn source_actions_only(only: &[String]) -> bool { !only.is_empty() && only.iter().all(|k| k == "source" || k.starts_with("source.")) } +/// Preparing a call / type hierarchy found no symbol at the position: the UI +/// then retries at the enclosing declaration. +fn no_hierarchy_target() -> IdeError { + IdeError::not_found("No symbol to show the hierarchy of at the caret") +} + #[cfg(test)] mod send_check { use super::*; diff --git a/crates/ide-lsp/src/types.rs b/crates/ide-lsp/src/types.rs index 5fe534d4..140a8282 100644 --- a/crates/ide-lsp/src/types.rs +++ b/crates/ide-lsp/src/types.rs @@ -195,6 +195,19 @@ pub struct InlayHint { pub padding_left: bool, pub padding_right: bool, pub tooltip: Option, + /// The label's parts when the server split it (empty for a plain label); + /// their values concatenate to `label`. + pub parts: Vec, +} + +/// One part of an inlay hint label, e.g. a type name that navigates to its declaration. +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct InlayLabelPart { + pub value: String, + pub tooltip: Option, + /// Declaration the part stands for (⌘-click navigates there). + pub location: Option, } /// Decoded semantic tokens (absolute positions) with the server legend applied. diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 8853ce80..562466f2 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -806,6 +806,11 @@ async fn navigation_rename_format_and_decorations() { assert_eq!(hints[0].label, "x:"); assert_eq!(hints[0].kind, InlayKind::Parameter); + // Nothing to prepare at the position: NotFound, so the UI can retry at the enclosing declaration. + s.respond("textDocument/prepareCallHierarchy", json!(null)); + let err = h.mgr.call_hierarchy(doc, Pos::new(0, 1), HierarchyDirection::Incoming).await.unwrap_err(); + assert_eq!(err.kind, ide_core::error::ErrorKind::NotFound); + let item = json!({ "name": "foo", "kind": 12, "uri": uri, "range": r(0,0,0,11), "selectionRange": r(0,3,0,6), "data": "id" }); s.respond("textDocument/prepareCallHierarchy", json!([item])); s.respond("callHierarchy/incomingCalls", json!([{ "from": { "name": "main", "kind": 12, "uri": uri, "range": r(1,0,1,6), "selectionRange": r(1,0,1,3) }, "fromRanges": [r(1,0,1,3)] }])); diff --git a/crates/ide-search/benches/search.rs b/crates/ide-search/benches/search.rs index 3a8d373c..7f24e8c1 100644 --- a/crates/ide-search/benches/search.rs +++ b/crates/ide-search/benches/search.rs @@ -35,6 +35,7 @@ fn query(pattern: &str, is_regex: bool) -> SearchQuery { exclude: vec![], scope: SearchScope::Project, max_matches: 0, + context: Default::default(), } } diff --git a/crates/ide-search/src/engine.rs b/crates/ide-search/src/engine.rs index 11fef045..eeca9988 100644 --- a/crates/ide-search/src/engine.rs +++ b/crates/ide-search/src/engine.rs @@ -4,7 +4,10 @@ use crate::iter::for_each_match; use crate::line::line_match; use crate::mask::FileMask; use crate::query::{Compiled, line_terminator}; -use crate::{FileMatches, LineMatch, Overlay, PathFilter, SearchScope, SearchSummary}; +use crate::{ + FileMatches, LineMatch, LiteralKind, LiteralRange, Literals, Overlay, PathFilter, SearchContext, SearchScope, + SearchSummary, +}; use grep_regex::RegexMatcher; use grep_searcher::{BinaryDetection, Searcher, SearcherBuilder, Sink, SinkMatch}; use ide_core::{CancellationToken, IdeError, IdeResult}; @@ -25,6 +28,8 @@ pub(crate) struct Request<'a> { pub compiled: Compiled, pub mask: FileMask, pub filter: Option>, + pub context: SearchContext, + pub literals: Option>, pub max_matches: u32, pub overlay: Overlay<'a>, pub cancel: &'a CancellationToken, @@ -86,6 +91,8 @@ struct Ctx<'a> { root: &'a Path, mask: &'a FileMask, filter: Option>, + context: SearchContext, + literals: Option>, overlay: Overlay<'a>, on_file: &'a (dyn Fn(FileMatches) + Sync), ctl: Control<'a>, @@ -98,6 +105,8 @@ pub(crate) fn run(req: Request<'_>) -> IdeResult { root: req.root, mask: &req.mask, filter: req.filter, + context: req.context, + literals: if req.context == SearchContext::Any { None } else { req.literals }, overlay: req.overlay, on_file: req.on_file, ctl: Control { @@ -225,6 +234,25 @@ impl Ctx<'_> { if !self.mask.matches(&rel) || self.filter.is_some_and(|keep| !keep(&rel)) { return; } + // The search context needs the whole text to find comments and string literals. + let in_context = match self.literals { + Some(literals) => { + let text = match (self.overlay)(path) { + Some(text) => text, + // Unreadable or not UTF-8 (binary): nothing to classify. + None => match std::fs::read_to_string(path) { + Ok(text) => text, + Err(_) => return, + }, + }; + let ranges = literals(path, &text); + if ranges.is_none() && self.context.needs_literals() { + return; + } + Some((text, ranges.unwrap_or_default())) + } + None => None, + }; let mut sink = FileSink { matcher, ctl: &self.ctl, @@ -234,12 +262,16 @@ impl Ctx<'_> { ticks: 0, found: Vec::new(), ranges: Vec::new(), + context: in_context.as_ref().map(|(_, ranges)| (self.context, ranges.as_slice())), }; - let result = match (self.overlay)(path) { - Some(text) => searcher.search_slice(matcher, text.as_bytes(), &mut sink), - None => match std::fs::File::open(path) { - Ok(file) => searcher.search_reader(matcher, StopReader { inner: file, ctl: &self.ctl }, &mut sink), - Err(err) => Err(err), + let result = match &in_context { + Some((text, _)) => searcher.search_slice(matcher, text.as_bytes(), &mut sink), + None => match (self.overlay)(path) { + Some(text) => searcher.search_slice(matcher, text.as_bytes(), &mut sink), + None => match std::fs::File::open(path) { + Ok(file) => searcher.search_reader(matcher, StopReader { inner: file, ctl: &self.ctl }, &mut sink), + Err(err) => Err(err), + }, }, }; if let Err(err) = result { @@ -288,6 +320,14 @@ struct FileSink<'a> { /// Scratch buffers reused across lines. found: Vec<(usize, usize)>, ranges: Vec<(usize, usize)>, + /// Search context filter and the file's comments / string literals. + context: Option<(SearchContext, &'a [LiteralRange])>, +} + +/// Kind of the comment or string literal containing byte `pos` (`literals` sorted). +fn literal_at(literals: &[LiteralRange], pos: usize) -> Option { + let i = literals.partition_point(|l| l.start <= pos); + literals[..i].last().filter(|l| pos < l.end).map(|l| l.kind) } impl Sink for FileSink<'_> { @@ -311,6 +351,10 @@ impl Sink for FileSink<'_> { for_each_match(self.matcher, &buffer[..range.end], range.start, |mm| { found.push((mm.start() - range.start, mm.end() - range.start)); }); + if let Some((context, literals)) = self.context { + let block_start = usize::try_from(m.absolute_byte_offset()).unwrap_or(usize::MAX); + found.retain(|&(a, _)| context.accepts(literal_at(literals, block_start.saturating_add(a)))); + } let mut next = found.iter().copied().peekable(); let mut line_start = 0; diff --git a/crates/ide-search/src/lib.rs b/crates/ide-search/src/lib.rs index ba0cfe75..0e8b8fcf 100644 --- a/crates/ide-search/src/lib.rs +++ b/crates/ide-search/src/lib.rs @@ -41,8 +41,64 @@ pub struct SearchQuery { pub scope: SearchScope, /// Stop after this many matches overall (0 = 10 000). pub max_matches: u32, + /// Where in the code a match may lie (IDEA's search context filter). + #[serde(default)] + #[specta(optional)] + pub context: SearchContext, } +/// IDEA's search context: matches anywhere, only in, or outside comments and +/// string literals. Files whose language has no syntax tree contain neither. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum SearchContext { + #[default] + Any, + InComments, + InStringLiterals, + ExceptComments, + ExceptStringLiterals, + ExceptCommentsAndStringLiterals, +} + +/// What a [`LiteralRange`] is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LiteralKind { + Comment, + String, +} + +/// Byte range of a comment or string literal in a file's text. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LiteralRange { + pub start: usize, + pub end: usize, + pub kind: LiteralKind, +} + +impl SearchContext { + /// Whether a match starting in `kind` (`None`: plain code) is kept. + pub fn accepts(self, kind: Option) -> bool { + match self { + Self::Any => true, + Self::InComments => kind == Some(LiteralKind::Comment), + Self::InStringLiterals => kind == Some(LiteralKind::String), + Self::ExceptComments => kind != Some(LiteralKind::Comment), + Self::ExceptStringLiterals => kind != Some(LiteralKind::String), + Self::ExceptCommentsAndStringLiterals => kind.is_none(), + } + } + + /// Only matches inside comments / string literals: files without any are skipped. + fn needs_literals(self) -> bool { + matches!(self, Self::InComments | Self::InStringLiterals) + } +} + +/// Comments and string literals of a file's text (sorted, non-overlapping), +/// or `None` when its language has no syntax tree. +pub type Literals<'a> = &'a (dyn Fn(&Path, &str) -> Option> + Sync); + #[derive(Clone, Debug, Serialize, Deserialize, Type)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum SearchScope { @@ -131,6 +187,20 @@ pub fn search_filtered( overlay: Overlay<'_>, cancel: &CancellationToken, on_file: &(dyn Fn(FileMatches) + Sync), +) -> IdeResult { + search_in_context(root, query, filter, None, overlay, cancel, on_file) +} + +/// [`search_filtered`] honouring [`SearchQuery::context`] with the comments and +/// string literals `literals` finds; without it every match is plain code. +pub fn search_in_context( + root: &Path, + query: &SearchQuery, + filter: Option>, + literals: Option>, + overlay: Overlay<'_>, + cancel: &CancellationToken, + on_file: &(dyn Fn(FileMatches) + Sync), ) -> IdeResult { let mask = FileMask::new(&query.include, &query.exclude)?; if query.pattern.is_empty() { @@ -142,6 +212,8 @@ pub fn search_filtered( compiled: query::compile(query)?, mask, filter, + context: query.context, + literals, max_matches: query.max_matches, overlay, cancel, diff --git a/crates/ide-search/src/query.rs b/crates/ide-search/src/query.rs index e13cb4c4..cedc370d 100644 --- a/crates/ide-search/src/query.rs +++ b/crates/ide-search/src/query.rs @@ -97,6 +97,7 @@ mod tests { exclude: vec![], scope: SearchScope::Project, max_matches: 0, + context: Default::default(), } } diff --git a/crates/ide-search/tests/replace.rs b/crates/ide-search/tests/replace.rs index ecd0ea69..2fb7945b 100644 --- a/crates/ide-search/tests/replace.rs +++ b/crates/ide-search/tests/replace.rs @@ -17,6 +17,7 @@ fn query(pattern: &str) -> SearchQuery { exclude: vec![], scope: SearchScope::Project, max_matches: 0, + context: Default::default(), } } diff --git a/crates/ide-search/tests/search.rs b/crates/ide-search/tests/search.rs index a088748a..c37cab14 100644 --- a/crates/ide-search/tests/search.rs +++ b/crates/ide-search/tests/search.rs @@ -27,6 +27,7 @@ fn query(pattern: &str) -> SearchQuery { exclude: vec![], scope: SearchScope::Project, max_matches: 0, + context: Default::default(), } } @@ -548,3 +549,75 @@ fn file_set_scope_applies_the_path_filter() { let (all, _) = run(p.path(), &q); assert_eq!(paths(&all), vec!["README.md", "src/main/java/App.java", "src/test/java/AppTest.java"]); } + +/// Test classifier: `//` to end of line is a comment, `"…"` a string (only in `.rs` files). +fn toy_literals(path: &Path, text: &str) -> Option> { + use ide_search::{LiteralKind, LiteralRange}; + if path.extension().is_none_or(|e| e != "rs") { + return None; + } + let mut out = Vec::new(); + let bytes = text.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i..].starts_with(b"//") { + let end = memchr_newline(bytes, i); + out.push(LiteralRange { start: i, end, kind: LiteralKind::Comment }); + i = end; + } else if bytes[i] == b'"' { + let end = bytes[i + 1..].iter().position(|&b| b == b'"').map_or(bytes.len(), |p| i + p + 2); + out.push(LiteralRange { start: i, end, kind: LiteralKind::String }); + i = end; + } else { + i += 1; + } + } + Some(out) +} + +fn memchr_newline(bytes: &[u8], from: usize) -> usize { + bytes[from..].iter().position(|&b| b == b'\n').map_or(bytes.len(), |p| from + p) +} + +/// `(rel_path, line, ranges)` of every matched line. +type Row = (String, u32, Vec<(u32, u32)>); + +fn run_context(root: &Path, context: ide_search::SearchContext) -> Vec { + let out = Mutex::new(Vec::new()); + let q = SearchQuery { context, ..query("foo") }; + ide_search::search_in_context(root, &q, None, Some(&toy_literals), &|_| None, &CancellationToken::new(), &|f| { + out.lock().unwrap().push(f) + }) + .unwrap(); + let mut rows: Vec<_> = out + .into_inner() + .unwrap() + .into_iter() + .flat_map(|f| f.matches.into_iter().map(move |m| (f.rel_path.clone(), m.line, m.ranges))) + .collect(); + rows.sort(); + rows +} + +#[test] +fn search_context_filters_comments_and_strings() { + use ide_search::SearchContext as C; + let p = project(&[("a.rs", b"foo(\"foo\"); // foo\nfoo\n"), ("b.txt", b"foo // foo\n")]); + let rows = |c| run_context(p.path(), c); + let s = |r: &str, l: u32, ranges: &[(u32, u32)]| (r.to_owned(), l, ranges.to_vec()); + assert_eq!( + rows(C::Any), + vec![s("a.rs", 0, &[(0, 3), (5, 8), (15, 18)]), s("a.rs", 1, &[(0, 3)]), s("b.txt", 0, &[(0, 3), (7, 10)])] + ); + assert_eq!(rows(C::InComments), vec![s("a.rs", 0, &[(15, 18)])]); + assert_eq!(rows(C::InStringLiterals), vec![s("a.rs", 0, &[(5, 8)])]); + // Files without a syntax tree have no comments or strings. + assert_eq!( + rows(C::ExceptComments), + vec![s("a.rs", 0, &[(0, 3), (5, 8)]), s("a.rs", 1, &[(0, 3)]), s("b.txt", 0, &[(0, 3), (7, 10)])] + ); + assert_eq!( + rows(C::ExceptCommentsAndStringLiterals), + vec![s("a.rs", 0, &[(0, 3)]), s("a.rs", 1, &[(0, 3)]), s("b.txt", 0, &[(0, 3), (7, 10)])] + ); +} diff --git a/crates/ide-vcs/src/history.rs b/crates/ide-vcs/src/history.rs index 9c4965df..6c007448 100644 --- a/crates/ide-vcs/src/history.rs +++ b/crates/ide-vcs/src/history.rs @@ -53,7 +53,8 @@ impl TextFilter { pub(crate) fn log(r: &Repo, q: &LogQuery) -> IdeResult> { let repo = r.gix()?; let range = q.range.as_deref().map(str::trim).filter(|s| !s.is_empty()); - let tips = if range.is_some() { Vec::new() } else { tips(&repo, q.branch.as_deref())? }; + let revs: Vec<&str> = q.branch.iter().chain(&q.branches).map(|b| b.trim()).filter(|b| !b.is_empty()).collect(); + let tips = if range.is_some() { Vec::new() } else { tips(&repo, &revs)? }; if tips.is_empty() && range.is_none() { return Ok(Vec::new()); } @@ -65,7 +66,8 @@ pub(crate) fn log(r: &Repo, q: &LogQuery) -> IdeResult> { } None => None, }; - let author = q.author.as_deref().map(str::to_lowercase).filter(|s| !s.is_empty()); + let authors: Vec = + q.author.iter().chain(&q.authors).map(|a| a.trim().to_lowercase()).filter(|a| !a.is_empty()).collect(); let text = q.text.as_deref().map(|t| TextFilter::new(t, q.text_regex, q.text_match_case)).transpose()?.flatten(); let limit = if q.limit == 0 { usize::MAX } else { q.limit as usize }; let mut to_skip = q.skip as usize; @@ -105,11 +107,11 @@ pub(crate) fn log(r: &Repo, q: &LogQuery) -> IdeResult> { let sig = commit.author().map_err(gx)?; let name = to_string(sig.name.trim().as_bstr()); let email = to_string(sig.email.trim().as_bstr()); - if let Some(needle) = author.as_deref() - && !name.to_lowercase().contains(needle) - && !email.to_lowercase().contains(needle) - { - continue; + if !authors.is_empty() { + let (name, email) = (name.to_lowercase(), email.to_lowercase()); + if !authors.iter().any(|a| name.contains(a.as_str()) || email.contains(a.as_str())) { + continue; + } } let message = to_string(commit.message_raw_sloppy()); if let Some(filter) = text.as_ref() @@ -163,14 +165,19 @@ fn range_ids(r: &Repo, range: &str, path: Option<&str>) -> IdeResult) -> IdeResult> { - if let Some(rev) = branch.filter(|b| !b.trim().is_empty()) { - let id = resolve(repo, rev)?; - let commit = repo.find_object(id).map_err(gx)?.peel_to_commit().map_err(gx)?; - return Ok(vec![commit.id]); - } +/// Starting points of the walk: the given revisions, or HEAD + all branches. +fn tips(repo: &gix::Repository, revs: &[&str]) -> IdeResult> { let mut tips = Vec::new(); + if !revs.is_empty() { + for rev in revs { + let id = resolve(repo, rev)?; + let commit = repo.find_object(id).map_err(gx)?.peel_to_commit().map_err(gx)?; + if !tips.contains(&commit.id) { + tips.push(commit.id); + } + } + return Ok(tips); + } if let Ok(id) = repo.head_id() { tips.push(id.detach()); } diff --git a/crates/ide-vcs/src/lib.rs b/crates/ide-vcs/src/lib.rs index 8cb4d85c..6194db2a 100644 --- a/crates/ide-vcs/src/lib.rs +++ b/crates/ide-vcs/src/lib.rs @@ -109,6 +109,12 @@ pub struct LogQuery { /// `text` matches case-sensitively (Log filter `Cc`). #[serde(default)] pub text_match_case: bool, + /// More starting revisions next to `branch` (Log Branch filter with several branches). + #[serde(default)] + pub branches: Vec, + /// More author needles next to `author`; a commit matches any of them (Log User filter). + #[serde(default)] + pub authors: Vec, } #[derive(Clone, Debug, Serialize, Deserialize, Type)] @@ -359,6 +365,10 @@ pub struct MergeVersions { pub ours: Option, pub theirs: Option, pub sides: MergeSides, + /// Three-way merge of the versions above: non-conflicting changes merged, each + /// conflict left as a diff3 block (yours / base / theirs). `None` when a side is + /// missing (deleted on one side). + pub merged: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] @@ -599,11 +609,18 @@ impl Repo { pub fn rebase(&self, onto: &str) -> IdeResult<()> { write::rebase(self, onto) } - pub fn stash_push(&self, message: Option<&str>, include_untracked: bool) -> IdeResult<()> { - write::stash_push(self, message, include_untracked) + /// `keep_index`: changes already staged stay in the index (and the working tree). + pub fn stash_push(&self, message: Option<&str>, include_untracked: bool, keep_index: bool) -> IdeResult<()> { + write::stash_push(self, message, include_untracked, keep_index) } - pub fn stash_apply(&self, index: u32, pop: bool) -> IdeResult<()> { - write::stash_apply(self, index, pop) + /// `reinstate_index`: also restore what was staged when the stash was made. + pub fn stash_apply(&self, index: u32, pop: bool, reinstate_index: bool) -> IdeResult<()> { + write::stash_apply(self, index, pop, reinstate_index) + } + /// Creates and checks out `branch` at the commit the stash was made on, applies the + /// stash there (index included) and drops it. + pub fn stash_branch(&self, index: u32, branch: &str) -> IdeResult<()> { + write::stash_branch(self, index, branch) } /// Removes `stash@{index}` without applying it. pub fn stash_drop(&self, index: u32) -> IdeResult<()> { @@ -786,7 +803,11 @@ impl Repo { 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 }) + let merged = match (&ours, &theirs) { + (Some(o), Some(t)) => Some(ops::merge_file(self, o, base.as_deref().unwrap_or(""), t)?), + _ => None, + }; + Ok(MergeVersions { base, ours, theirs, sides, merged }) } /// For conflict resolution: (base, ours, theirs) versions of a conflicted file. pub fn conflict_versions(&self, path: &Path) -> IdeResult<(Option, Option, Option)> { diff --git a/crates/ide-vcs/src/ops.rs b/crates/ide-vcs/src/ops.rs index 892a2ef6..24f25b0b 100644 --- a/crates/ide-vcs/src/ops.rs +++ b/crates/ide-vcs/src/ops.rs @@ -56,11 +56,43 @@ pub(crate) fn merge_sides(r: &Repo) -> IdeResult { } Some("cherry-pick") => sides.commit = read("CHERRY_PICK_HEAD").map(short), Some("revert") => sides.commit = read("REVERT_HEAD").map(short), + // Conflicts outside an operation come from restoring stashed changes (Smart + // Checkout, update with autostash): stage 2 is the new HEAD, stage 3 the user's. + None if !crate::write::unmerged_paths(r)?.is_empty() => { + sides.operation = Some("unstash".into()); + sides.reversed = true; + } _ => {} } Ok(sides) } +/// Three-way merge of file contents (`git merge-file --diff3`): changes that don't +/// conflict are merged, each conflict is left as a diff3 block. +pub(crate) fn merge_file(r: &Repo, ours: &str, base: &str, theirs: &str) -> IdeResult { + struct TempDir(std::path::PathBuf); + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let dir = TempDir(r.git_dir.join(format!("openidea-merge-{}", std::process::id()))); + std::fs::create_dir_all(&dir.0)?; + let file = |name: &str, text: &str| -> IdeResult { + let path = dir.0.join(name); + std::fs::write(&path, text)?; + Ok(path.to_string_lossy().into_owned()) + }; + let (o, b, t) = (file("ours", ours)?, file("base", base)?, file("theirs", theirs)?); + let args = ["merge-file", "-p", "--diff3", "-L", "yours", "-L", "base", "-L", "theirs", &o, &b, &t]; + let out = cli::run_raw(r.root(), &args, None)?; + // The exit status is the number of conflicts; only a negative one is a failure. + match out.code { + Some(code) if code >= 0 => Ok(String::from_utf8_lossy(&out.stdout).into_owned()), + code => Err(cli::failure(&args, &out.stderr, &out.stdout, code)), + } +} + /// The single branch (local first, then remote) pointing at `commit`. fn branch_at(r: &Repo, commit: &str) -> Option { if commit.starts_with('-') { diff --git a/crates/ide-vcs/src/write.rs b/crates/ide-vcs/src/write.rs index 943808cb..d34e3eb5 100644 --- a/crates/ide-vcs/src/write.rs +++ b/crates/ide-vcs/src/write.rs @@ -598,20 +598,40 @@ pub(crate) fn rebase(r: &Repo, onto: &str) -> IdeResult<()> { run(r.root(), &["rebase", "--autostash", check_rev(onto)?], None).map(|_| ()) } -pub(crate) fn stash_push(r: &Repo, message: Option<&str>, include_untracked: bool) -> IdeResult<()> { +pub(crate) fn stash_push(r: &Repo, message: Option<&str>, include_untracked: bool, keep_index: bool) -> IdeResult<()> { let mut args = vec!["stash", "push", "-q"]; if include_untracked { args.push("--include-untracked"); } + if keep_index { + args.push("--keep-index"); + } if let Some(m) = message.filter(|m| !m.trim().is_empty()) { args.extend(["-m", m]); } run(r.root(), &args, None).map(|_| ()) } -pub(crate) fn stash_apply(r: &Repo, index: u32, pop: bool) -> IdeResult<()> { +pub(crate) fn stash_apply(r: &Repo, index: u32, pop: bool, reinstate_index: bool) -> IdeResult<()> { let spec = format!("stash@{{{index}}}"); - run(r.root(), &["stash", if pop { "pop" } else { "apply" }, "-q", &spec], None).map(|_| ()) + let mut args = vec!["stash", if pop { "pop" } else { "apply" }, "-q"]; + if reinstate_index { + args.push("--index"); + } + args.push(&spec); + run(r.root(), &args, None).map(|_| ()) +} + +pub(crate) fn stash_branch(r: &Repo, index: u32, branch: &str) -> IdeResult<()> { + let branch = branch.trim(); + let valid = !branch.is_empty() + && !branch.starts_with('-') + && cli::run_raw(r.root(), &["check-ref-format", "--branch", branch], None)?.code == Some(0); + if !valid { + return Err(IdeError::invalid(format!("'{branch}' is not a valid branch name"))); + } + let spec = format!("stash@{{{index}}}"); + run(r.root(), &["stash", "branch", branch, &spec], None).map(|_| ()) } pub(crate) fn stash_drop(r: &Repo, index: u32) -> IdeResult<()> { diff --git a/crates/ide-vcs/tests/history_ops.rs b/crates/ide-vcs/tests/history_ops.rs index f29c1d02..7116a98c 100644 --- a/crates/ide-vcs/tests/history_ops.rs +++ b/crates/ide-vcs/tests/history_ops.rs @@ -122,6 +122,8 @@ fn merge_versions_put_the_users_work_on_the_left() { 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])); + // The merged text puts the user's side first, whatever git's stage order. + assert_eq!(v.merged.as_deref(), Some("<<<<<<< yours\ntopic\n||||||| base\nbase\n=======\nmain\n>>>>>>> theirs\n")); let (cancel, out) = quiet(); repo.abort_operation(&cancel, &out).unwrap(); diff --git a/crates/ide-vcs/tests/repo.rs b/crates/ide-vcs/tests/repo.rs index cc198bed..40f6a95b 100644 --- a/crates/ide-vcs/tests/repo.rs +++ b/crates/ide-vcs/tests/repo.rs @@ -615,11 +615,11 @@ fn stash_push_list_apply_pop() { let repo = fx.repo(); fx.write("s.txt", "change 1\n"); - repo.stash_push(Some("first stash"), false).unwrap(); + repo.stash_push(Some("first stash"), false, false).unwrap(); assert_eq!(fx.read("s.txt"), "base\n"); fx.write("s.txt", "change 2\n"); fx.write("u.txt", "untracked\n"); - repo.stash_push(None, true).unwrap(); + repo.stash_push(None, true, false).unwrap(); assert!(!fx.path("u.txt").exists()); let stashes = repo.stashes().unwrap(); @@ -629,11 +629,11 @@ fn stash_push_list_apply_pop() { assert!(stashes[1].message.contains("first stash"), "{stashes:?}"); assert!(stashes[0].time_ms > 0.0); - repo.stash_apply(1, false).unwrap(); + repo.stash_apply(1, false, false).unwrap(); assert_eq!(fx.read("s.txt"), "change 1\n"); assert_eq!(repo.stashes().unwrap().len(), 2); repo.discard(&[fx.path("s.txt")]).unwrap(); - repo.stash_apply(0, true).unwrap(); + repo.stash_apply(0, true, false).unwrap(); assert_eq!(fx.read("s.txt"), "change 2\n"); assert!(fx.path("u.txt").exists()); assert_eq!(repo.stashes().unwrap().len(), 1); @@ -642,6 +642,72 @@ fn stash_push_list_apply_pop() { assert!(repo.stash_drop(0).is_err()); } +#[test] +fn stash_keep_index_reinstate_index_and_branch() { + let fx = Fixture::new(); + fx.write("a.txt", "a\n"); + fx.write("b.txt", "b\n"); + fx.commit_all("base"); + let repo = fx.repo(); + let staged = |fx: &Fixture| fx.git(&["diff", "--cached", "--name-only"]).trim().to_string(); + + fx.write("a.txt", "a staged\n"); + fx.git(&["add", "a.txt"]); + fx.write("b.txt", "b unstaged\n"); + repo.stash_push(Some("keep"), false, true).unwrap(); + // Keep index: the staged change stays, the unstaged one is stashed. + assert_eq!(fx.read("a.txt"), "a staged\n"); + assert_eq!(fx.read("b.txt"), "b\n"); + assert_eq!(staged(&fx), "a.txt"); + + fx.git(&["reset", "-q", "--hard"]); + repo.stash_apply(0, false, true).unwrap(); + // Reinstate index: what was staged is staged again. + assert_eq!(staged(&fx), "a.txt"); + assert_eq!(fx.read("b.txt"), "b unstaged\n"); + fx.git(&["reset", "-q", "--hard"]); + repo.stash_apply(0, false, false).unwrap(); + assert_eq!(staged(&fx), ""); + + fx.git(&["reset", "-q", "--hard"]); + assert!(repo.stash_branch(0, "-bad").is_err()); + repo.stash_branch(0, "from-stash").unwrap(); + assert_eq!(fx.git(&["rev-parse", "--abbrev-ref", "HEAD"]).trim(), "from-stash"); + assert_eq!(fx.read("b.txt"), "b unstaged\n"); + assert!(repo.stashes().unwrap().is_empty()); +} + +#[test] +fn log_filters_several_branches_and_authors() { + let fx = Fixture::new(); + fx.write("a.txt", "1\n"); + let c1 = fx.commit_all("one"); + fx.git(&["checkout", "-q", "-b", "left"]); + fx.write("l.txt", "l\n"); + let left = fx.commit_all("left"); + fx.git(&["checkout", "-q", "-b", "right", &c1]); + fx.write("r.txt", "r\n"); + fx.git(&["add", "-A"]); + fx.git(&["-c", "user.name=Someone Else", "-c", "user.email=else@corp.test", "commit", "-q", "-m", "right"]); + let right = fx.git(&["rev-parse", "HEAD"]).trim().to_string(); + fx.git(&["checkout", "-q", "-b", "third", &c1]); + fx.write("t.txt", "t\n"); + let third = fx.commit_all("third"); + let repo = fx.repo(); + let ids = |q: LogQuery| repo.log(&q).unwrap().into_iter().map(|c| c.id).collect::>(); + + let two = ids(LogQuery { branch: Some("left".into()), branches: vec!["right".into()], ..Default::default() }); + assert!(two.contains(&left) && two.contains(&right) && two.contains(&c1)); + assert!(!two.contains(&third)); + let only = ids(LogQuery { branches: vec!["third".into()], ..Default::default() }); + assert_eq!(only, vec![third.clone(), c1.clone()]); + + let everyone = ids(LogQuery { authors: vec!["else@corp".into(), "nobody".into()], ..Default::default() }); + assert_eq!(everyone, vec![right.clone()]); + let both = ids(LogQuery { author: Some("ELSE".into()), authors: vec!["test@".into()], ..Default::default() }); + assert!(both.contains(&right) && both.len() > 1); +} + #[test] fn log_range_lists_outgoing_commits() { let (fx, _bare) = with_remote(); @@ -802,6 +868,11 @@ fn smart_and_force_checkout_with_local_changes_in_the_way() { 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"); + // The stashed edit is the user's side of the merge, the checked-out branch the other. + let v = repo.merge_versions(&fx.path("f.txt")).unwrap(); + assert_eq!(v.sides.operation.as_deref(), Some("unstash")); + assert!(v.sides.reversed); + assert_eq!(v.ours.as_deref(), Some("1\nmine\n3\n4\n5\n")); // Force discards the change in the way. fx.git(&["reset", "-q", "--hard"]); @@ -1066,7 +1137,7 @@ fn errors_carry_git_messages() { assert!(!err.message.is_empty()); let err = repo.stage(&[PathBuf::from("../outside")]).unwrap_err(); assert_eq!(err.kind, ErrorKind::InvalidInput); - assert!(repo.stash_apply(5, false).is_err()); + assert!(repo.stash_apply(5, false, false).is_err()); } #[test] diff --git a/src-tauri/src/commands/lsp.rs b/src-tauri/src/commands/lsp.rs index 1a22b9f3..75e3859b 100644 --- a/src-tauri/src/commands/lsp.rs +++ b/src-tauri/src/commands/lsp.rs @@ -156,6 +156,21 @@ pub async fn lsp_code_action_query( result } +/// Resolves a code action's edit without applying it (intention preview). +#[tauri::command] +#[specta::specta] +pub async fn lsp_resolve_code_action( + window: Window, + state: State<'_, AppState>, + doc: DocId, + action: CodeAction, +) -> IdeResult { + if action.edit.is_some() { + return Ok(action); + } + project!(window, state).lsp.resolve_code_action(doc, action).await +} + /// Resolves (if needed), applies the edit, then runs the command of a code action. #[tauri::command] #[specta::specta] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index c3d02530..da71eee9 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -123,6 +123,7 @@ pub fn builder() -> tauri_specta::Builder { lsp::lsp_workspace_symbols, lsp::lsp_code_actions, lsp::lsp_code_action_query, + lsp::lsp_resolve_code_action, lsp::lsp_apply_code_action, lsp::lsp_prepare_rename, lsp::lsp_rename, diff --git a/src-tauri/src/commands/search.rs b/src-tauri/src/commands/search.rs index e59e501e..3b5d3265 100644 --- a/src-tauri/src/commands/search.rs +++ b/src-tauri/src/commands/search.rs @@ -40,6 +40,8 @@ pub async fn search_start( let cancel = state.begin_request(request); let documents = project.documents.clone(); let root = project.root.clone(); + let languages = state.languages.clone(); + let syntax = state.syntax(); let events = on_event.clone(); let summary = tauri::async_runtime::spawn_blocking(move || { let filter = |rel: &str| match set { @@ -47,9 +49,23 @@ pub async fn search_start( Some(ProjectFileSet::Test) => roots.is_test(rel), Some(ProjectFileSet::All) | None => true, }; - ide_search::search_filtered(&root, &query, Some(&filter), &|p| documents.text_of(p), &cancel, &|file| { - let _ = events.send(SearchEvent::File { file }); - }) + // Comments and string literals for the search context filter. + let literals = |path: &Path, text: &str| { + let language = languages.detect(path, text.lines().next()); + let found = syntax.literals(&language, text)?; + Some(found.into_iter().map(literal_range).collect()) + }; + ide_search::search_in_context( + &root, + &query, + Some(&filter), + Some(&literals), + &|p| documents.text_of(p), + &cancel, + &|file| { + let _ = events.send(SearchEvent::File { file }); + }, + ) }) .await .map_err(|e| ide_core::IdeError::internal(e.to_string()))?; @@ -116,6 +132,14 @@ pub async fn replace_preview( .map_err(|e| ide_core::IdeError::internal(e.to_string()))? } +fn literal_range(l: ide_language::Literal) -> ide_search::LiteralRange { + let kind = match l.kind { + ide_language::LiteralKind::Comment => ide_search::LiteralKind::Comment, + ide_language::LiteralKind::String => ide_search::LiteralKind::String, + }; + ide_search::LiteralRange { start: l.start, end: l.end, kind } +} + struct DocumentsHost<'a>(&'a Documents); impl ReplaceHost for DocumentsHost<'_> { diff --git a/src-tauri/src/commands/vcs.rs b/src-tauri/src/commands/vcs.rs index 1dbc9baf..5dcd0057 100644 --- a/src-tauri/src/commands/vcs.rs +++ b/src-tauri/src/commands/vcs.rs @@ -299,11 +299,12 @@ pub async fn vcs_stash_push( repo: Option, message: Option, include_untracked: bool, + keep_index: bool, ) -> IdeResult<()> { let project = state.project(window.label())?; blocking(move || { project.with_repo_recorded(repo.as_deref(), Some("Git stash"), |r| { - r.stash_push(message.as_deref(), include_untracked) + r.stash_push(message.as_deref(), include_untracked, keep_index) }) }) .await @@ -317,10 +318,17 @@ pub async fn vcs_stash_apply( repo: Option, index: u32, pop: bool, + reinstate_index: bool, + branch: Option, ) -> IdeResult<()> { let project = state.project(window.label())?; - blocking(move || project.with_repo_recorded(repo.as_deref(), Some("Git unstash"), |r| r.stash_apply(index, pop))) - .await + blocking(move || { + project.with_repo_recorded(repo.as_deref(), Some("Git unstash"), |r| match branch.as_deref() { + Some(name) => r.stash_branch(index, name), + None => r.stash_apply(index, pop, reinstate_index), + }) + }) + .await } #[tauri::command] diff --git a/src/bindings.ts b/src/bindings.ts index 2d57f0ac..5ede6f18 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -240,6 +240,8 @@ export const commands = { lspCodeActions: (doc: DocId, range: Range, only: string[]) => typedError(__TAURI_INVOKE("lsp_code_actions", { doc, range, only })), /** Cancellable code actions with the kinds the servers advertise (Refactor This, refactoring availability). */ lspCodeActionQuery: (request: RequestId, doc: DocId, range: Range, only: string[]) => typedError(__TAURI_INVOKE("lsp_code_action_query", { request, doc, range, only })), + /** Resolves a code action's edit without applying it (intention preview). */ + lspResolveCodeAction: (doc: DocId, action: CodeAction) => typedError(__TAURI_INVOKE("lsp_resolve_code_action", { doc, action })), /** Resolves (if needed), applies the edit, then runs the command of a code action. */ lspApplyCodeAction: (doc: DocId, action: CodeAction) => typedError(__TAURI_INVOKE("lsp_apply_code_action", { doc, action })), lspPrepareRename: (doc: DocId, pos: Pos) => typedError<{ @@ -309,8 +311,8 @@ export const commands = { vcsRenameBranch: (repo: string | null, from: string, to: string) => typedError(__TAURI_INVOKE("vcs_rename_branch", { repo, from, to })), vcsMerge: (repo: string | null, branch: string) => typedError(__TAURI_INVOKE("vcs_merge", { repo, branch })), vcsRebase: (repo: string | null, onto: string) => typedError(__TAURI_INVOKE("vcs_rebase", { repo, onto })), - vcsStashPush: (repo: string | null, message: string | null, includeUntracked: boolean) => typedError(__TAURI_INVOKE("vcs_stash_push", { repo, message, includeUntracked })), - vcsStashApply: (repo: string | null, index: number, pop: boolean) => typedError(__TAURI_INVOKE("vcs_stash_apply", { repo, index, pop })), + vcsStashPush: (repo: string | null, message: string | null, includeUntracked: boolean, keepIndex: boolean) => typedError(__TAURI_INVOKE("vcs_stash_push", { repo, message, includeUntracked, keepIndex })), + vcsStashApply: (repo: string | null, index: number, pop: boolean, reinstateIndex: boolean, branch: string | null) => typedError(__TAURI_INVOKE("vcs_stash_apply", { repo, index, pop, reinstateIndex, branch })), 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 })), @@ -2393,6 +2395,11 @@ export type InlayHint = { paddingLeft: boolean, paddingRight: boolean, tooltip: string | null, + /** + * The label's parts when the server split it (empty for a plain label); + * their values concatenate to `label`. + */ + parts: InlayLabelPart[], }; /** Editor › Inlay Hints. */ @@ -2408,6 +2415,14 @@ export type InlayHintsSettings = { export type InlayKind = "type" | "parameter" | "other"; +/** One part of an inlay hint label, e.g. a type name that navigates to its declaration. */ +export type InlayLabelPart = { + value: string, + tooltip: string | null, + /** Declaration the part stands for (⌘-click navigates there). */ + location: Location | null, +}; + export type InspectEvent = /** Files about to be inspected. */ { type: "started"; total: number } | @@ -2741,6 +2756,10 @@ export type LogQuery = { textRegex?: boolean, /** `text` matches case-sensitively (Log filter `Cc`). */ textMatchCase?: boolean, + /** More starting revisions next to `branch` (Log Branch filter with several branches). */ + branches?: string[], + /** More author needles next to `author`; a commit matches any of them (Log User filter). */ + authors?: string[], }; export type LspRefreshKind = "inlayHints" | "semanticTokens" | "codeLens"; @@ -2888,6 +2907,12 @@ export type MergeVersions = { ours: string | null, theirs: string | null, sides: MergeSides, + /** + * Three-way merge of the versions above: non-conflicting changes merged, each + * conflict left as a diff3 block (yours / base / theirs). `None` when a side is + * missing (deleted on one side). + */ + merged: string | null, }; /** Per-function totals (a recursive function's samples count once in `total`). */ @@ -3503,6 +3528,8 @@ export type RunInfo = { /** Run/Debug configuration behaviour. */ export type RunSettings = { confirmRerun?: boolean, + /** Ask before closing a Run tab whose process is still running. */ + confirmTerminate?: boolean, showOnStart?: boolean, /** Grace period between interrupt and kill when stopping a process. */ terminationTimeoutMs?: number, @@ -3557,6 +3584,12 @@ export type SdkKind = "python" | "node" | /** Maven homes. */ "maven"; +/** + * IDEA's search context: matches anywhere, only in, or outside comments and + * string literals. Files whose language has no syntax tree contain neither. + */ +export type SearchContext = "any" | "inComments" | "inStringLiterals" | "exceptComments" | "exceptStringLiterals" | "exceptCommentsAndStringLiterals"; + export type SearchEvent = { type: "file"; file: FileMatches } | { type: "done"; summary: SearchSummary }; export type SearchQuery = { @@ -3573,6 +3606,8 @@ export type SearchQuery = { scope: SearchScope, /** Stop after this many matches overall (0 = 10 000). */ maxMatches: number, + /** Where in the code a match may lie (IDEA's search context filter). */ + context?: SearchContext, }; export type SearchScope = { kind: "project" } | { kind: "directory"; path: IpcPath } | { kind: "files"; paths: IpcPath[] } | diff --git a/src/editor/api.ts b/src/editor/api.ts index 6a08c48b..67bdbb2e 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -445,3 +445,21 @@ export function registerLanguageMode(desc: LanguageDescription): () => void { export function _languageMode(name: string): LanguageDescription | undefined { return languageModes.get(name); } + +// --- read-only code previews ------------------------------------------------------ + +let codePreviewProvider: ((path: string) => Promise) | null = null; + +/** + * Extensions for a read-only CodeMirror view showing `path` like an editor tab + * (editor font and colours, line numbers, syntax highlighting): previews + * outside editor tabs such as Find in Files. Empty before editor/core starts. + */ +export function codePreviewExtensions(path: string): Promise { + return codePreviewProvider ? codePreviewProvider(path) : Promise.resolve([]); +} + +/** @internal editor/core only. */ +export function _provideCodePreview(provider: ((path: string) => Promise) | null) { + codePreviewProvider = provider; +} diff --git a/src/editor/core/actions.ts b/src/editor/core/actions.ts index 8371a8a2..29d11845 100644 --- a/src/editor/core/actions.ts +++ b/src/editor/core/actions.ts @@ -1,21 +1,22 @@ /** Editor actions (ids from keymaps/intellij-macos.ts). */ import { EditorSelection, type EditorState } from "@codemirror/state"; import type { EditorView } from "@codemirror/view"; -import { addCursorAbove, addCursorBelow, deleteLine, moveLineDown, moveLineUp, toggleBlockComment } from "@codemirror/commands"; -import { foldAll, syntaxTree, toggleFold, unfoldAll } from "@codemirror/language"; +import { addCursorAbove, addCursorBelow, moveLineDown, moveLineUp, toggleBlockComment } from "@codemirror/commands"; +import { syntaxTree, toggleFold } 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"; import { - addCaretNextOccurrence, addCaretsToLineEnds, commentLine, completeStatement, + deleteLine, duplicateLineOrSelection, joinLines, moveStatementDown, moveStatementUp, + nextOccurrence, selectAllOccurrences, startNewLine, startNewLineBefore, @@ -25,9 +26,10 @@ import { import { emptyStack, extendSelection, shrinkSelection, type SelectionStack, type SimpleRange } from "./model/selectionStack"; import type { EditorSlot } from "./model/slot"; import { findAgain, openFindBar } from "./view/FindBar"; -import { foldAtCaret, foldSelection, unfoldAtCaret } from "./cm/folding"; +import { collapseAll, collapseRecursively, expandAll, expandRecursively, foldAtCaret, foldSelection, unfoldAtCaret } from "./cm/folding"; import { reverseLines, sortLines } from "./cm/lines"; -import { cursorCodeBlockEnd, cursorCodeBlockStart, scrollToCenter, splitLine } from "./cm/caretCommands"; +import { cursorCodeBlockEnd, cursorCodeBlockStart, cursorMatchBrace, scrollToCenter, splitLine } from "./cm/caretCommands"; +import { isOverwrite, toggleOverwrite } from "./cm/overwrite"; import { isColumnMode, toggleColumnMode } from "./cm/columnMode"; import { pasteAsPlainText, pasteFromHistory, unwrapAtCaret } from "./extras"; import { cyclicExpandWord } from "./wordCompletion"; @@ -289,6 +291,11 @@ function copyReference(view: EditorView, slot: EditorSlot) { void executeAction("editor.showHint", { text: `Copied reference: ${reference}` }); } +/** ⌃G; when nothing more is found, a hint first and the search from the top on the next press (IDEA). */ +function addNextOccurrence(view: EditorView) { + if (nextOccurrence(view) === "notFound") void executeAction("editor.showHint", { text: "Not found, press again to search from the top" }); +} + // --------------------------------------------------------------------------------- export function registerEditorActions(): () => void { @@ -341,9 +348,11 @@ export function registerEditorActions(): () => void { toggled: () => activeSlot()?.model.softWrapOn() ?? false, run: () => activeSlot()?.model.toggleSoftWrap(), }, - text("code.foldAll", "Collapse All", (v) => foldAll(v), { category: "Folding", icon: "collapse-all" }), - text("code.unfoldAll", "Expand All", (v) => unfoldAll(v), { category: "Folding", icon: "expand-all" }), - text("code.addCaretNextOccurrence", "Add Selection for Next Occurrence", (v) => addCaretNextOccurrence(v), { category: "Edit" }), + text("code.foldAll", "Collapse All", (v) => collapseAll(v), { category: "Folding", icon: "collapse-all" }), + text("code.unfoldAll", "Expand All", (v) => expandAll(v), { category: "Folding", icon: "expand-all" }), + text("code.collapseRecursively", "Collapse Recursively", (v) => collapseRecursively(v), { category: "Folding", keywords: ["fold"] }), + text("code.expandRecursively", "Expand Recursively", (v) => expandRecursively(v), { category: "Folding", keywords: ["unfold"] }), + text("code.addCaretNextOccurrence", "Add Selection for Next Occurrence", (v) => addNextOccurrence(v), { category: "Edit" }), text("code.selectAllOccurrences", "Select All Occurrences", (v) => selectAllOccurrences(v), { category: "Edit" }), text("code.completeStatement", "Complete Current Statement", (v, slot) => completeCurrentStatement(v, slot)), text("code.startNewLine", "Start New Line", (v) => startNewLine(v)), @@ -354,6 +363,19 @@ export function registerEditorActions(): () => void { 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("editor.matchBrace", "Move Caret to Matching Brace", (v) => cursorMatchBrace(v), { + category: "Editor Actions", + // ⌃M is Show Commit Message History in the commit window. + keyWhen: "editorTextFocus", + }), + text("editor.toggleInsertState", "Toggle Insert/Overwrite", (v) => toggleOverwrite(v), { + category: "Editor Actions", + keywords: ["overwrite mode", "overtype"], + toggled: () => { + const view = activeSlot()?.view; + return view ? isOverwrite(view.state) : false; + }, + }), 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" }), diff --git a/src/editor/core/cm/caretCommands.ts b/src/editor/core/cm/caretCommands.ts index 11dd8d38..7fd9b742 100644 --- a/src/editor/core/cm/caretCommands.ts +++ b/src/editor/core/cm/caretCommands.ts @@ -1,8 +1,8 @@ -/** IDEA caret commands that CodeMirror has no equivalent for: Split Line, Scroll to Center, code block navigation. */ +/** IDEA caret commands that CodeMirror has no equivalent for: Split Line, Scroll to Center, code block and brace 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"; +import { matchBrackets, syntaxTree } from "@codemirror/language"; /** Split Line: break the line at each caret, indented, leaving the caret before the break. */ export const splitLine: Command = (view) => { @@ -90,3 +90,41 @@ function moveToBlock(find: (state: EditorState, pos: number) => number | null): export const cursorCodeBlockStart = moveToBlock(codeBlockStart); export const cursorCodeBlockEnd = moveToBlock(codeBlockEnd); + +/** + * Where Go to Matching Brace (⌃M) goes from `pos`: from a brace next to the + * caret to its match; elsewhere to the unclosed opening brace before the caret. + */ +export function matchBraceTarget(state: EditorState, pos: number): number | null { + const found = + matchBrackets(state, pos, -1) || + matchBrackets(state, pos, 1) || + (pos > 0 && matchBrackets(state, pos - 1, 1)) || + (pos < state.doc.length && matchBrackets(state, pos + 1, -1)); + if (found && found.end) return found.start.from === pos ? found.end.to : found.end.from; + const tree = syntaxTree(state); + let depth = 0; + for (let p = pos - 1; p >= 0; p--) { + const ch = state.sliceDoc(p, p + 1); + const open = "([{".includes(ch); + if (!open && !")]}".includes(ch)) continue; + if (tree.length && NOT_CODE.test(tree.resolveInner(p, 1).name)) continue; + if (!open) depth++; + else if (depth-- === 0) return p; + } + return null; +} + +export const cursorMatchBrace: Command = (view) => { + const { state } = view; + let moved = false; + const ranges = state.selection.ranges.map((r) => { + const target = matchBraceTarget(state, r.head); + if (target == null) return r; + moved = true; + return EditorSelection.cursor(target); + }); + if (!moved) return false; + view.dispatch({ selection: EditorSelection.create(ranges, state.selection.mainIndex), scrollIntoView: true, userEvent: "select.navigate" }); + return true; +}; diff --git a/src/editor/core/cm/columnMode.ts b/src/editor/core/cm/columnMode.ts index 2308c582..97945b33 100644 --- a/src/editor/core/cm/columnMode.ts +++ b/src/editor/core/cm/columnMode.ts @@ -2,9 +2,10 @@ * Edit › Column Selection Mode (⇧⌘8): while on, a plain mouse drag and * ⇧+arrows make rectangular (block) selections, one range per line. */ -import { EditorSelection, Prec, StateEffect, StateField, type EditorState, type Extension } from "@codemirror/state"; +import { EditorSelection, Prec, StateEffect, StateField, countColumn, findColumn, type EditorState, type Extension } from "@codemirror/state"; import { EditorView, keymap, rectangularSelection } from "@codemirror/view"; +/** A block between two visual columns (tabs expanded) of two lines. */ interface Block { anchorLine: number; anchorCol: number; @@ -43,18 +44,30 @@ export function toggleColumnMode(view: EditorView): boolean { return true; } -/** One range per line from the anchor column to the head column (clamped to each line). */ +/** One range per line from the anchor column to the head column (visual columns, clamped to each line). */ export function blockSelection(state: EditorState, b: Block): EditorSelection { const ranges = []; const step = b.headLine >= b.anchorLine ? 1 : -1; + const at = (text: string, col: number) => findColumn(text, col, state.tabSize); for (let n = b.anchorLine; ; n += step) { const line = state.doc.line(n); - ranges.push(EditorSelection.range(line.from + Math.min(b.anchorCol, line.length), line.from + Math.min(b.headCol, line.length))); + ranges.push(EditorSelection.range(line.from + at(line.text, b.anchorCol), line.from + at(line.text, b.headCol))); if (n === b.headLine) break; } return EditorSelection.create(ranges, ranges.length - 1); } +/** The visual column one character left / right of `col` (a tab is one step; past the line end, one column). */ +export function stepColumn(text: string, col: number, d: number, tabSize: number): number { + if (!d) return col; + const width = countColumn(text, tabSize); + if (d > 0 ? col >= width : col > width) return Math.max(0, col + d); + const pos = findColumn(text, col, tabSize); + const at = countColumn(text, tabSize, pos); + if (d > 0) return at > col ? at : countColumn(text, tabSize, pos + 1); + return pos === 0 ? 0 : countColumn(text, tabSize, pos - 1); +} + /** ⇧+arrow in column mode: moves the block's head by a line / column. */ export function extendBlock(view: EditorView, dLine: number, dCol: number): boolean { const state = view.state; @@ -65,10 +78,11 @@ export function extendBlock(view: EditorView, dLine: number, dCol: number): bool const main = state.selection.main; const a = state.doc.lineAt(main.anchor); const h = state.doc.lineAt(main.head); - b = { anchorLine: a.number, anchorCol: main.anchor - a.from, headLine: h.number, headCol: main.head - h.from }; + const col = (text: string, offset: number) => countColumn(text, state.tabSize, offset); + b = { anchorLine: a.number, anchorCol: col(a.text, main.anchor - a.from), headLine: h.number, headCol: col(h.text, main.head - h.from) }; } const headLine = Math.max(1, Math.min(state.doc.lines, b.headLine + dLine)); - const next = { ...b, headLine, headCol: Math.max(0, b.headCol + dCol) }; + const next = { ...b, headLine, headCol: stepColumn(state.doc.line(b.headLine).text, b.headCol, dCol, state.tabSize) }; view.dispatch({ selection: blockSelection(state, next), effects: setBlock.of(next), diff --git a/src/editor/core/cm/editing.test.ts b/src/editor/core/cm/editing.test.ts index 0272acc5..10c13654 100644 --- a/src/editor/core/cm/editing.test.ts +++ b/src/editor/core/cm/editing.test.ts @@ -151,6 +151,10 @@ describe("multiple carets", () => { expect(show(v)).toBe("[a] [a] a"); expect(v.state.selection.mainIndex).toBe(1); unselectOccurrence(v); + // A single selection collapses to its caret. + expect(show(v)).toBe("[a] a a"); + expect(unselectOccurrence(v)).toBe(true); + expect(show(v)).toBe("a| a a"); expect(unselectOccurrence(v)).toBe(false); }); diff --git a/src/editor/core/cm/editing.ts b/src/editor/core/cm/editing.ts index 8d9a70df..8f29b380 100644 --- a/src/editor/core/cm/editing.ts +++ b/src/editor/core/cm/editing.ts @@ -1,21 +1,34 @@ /** * IDEA editing commands on top of @codemirror/commands. */ -import { EditorSelection, type ChangeSpec, type EditorState, type SelectionRange, type Transaction, type TransactionSpec } from "@codemirror/state"; +import { + EditorSelection, + StateEffect, + StateField, + countColumn, + type ChangeSpec, + type EditorState, + type SelectionRange, + type Transaction, + type TransactionSpec, +} from "@codemirror/state"; import { EditorView, type Command } from "@codemirror/view"; -import { syntaxTree, getIndentUnit, indentString, getIndentation } from "@codemirror/language"; +import { foldedRanges, forceParsing, syntaxTree, getIndentUnit, indentString, getIndentation } from "@codemirror/language"; import { copyLineDown, + deleteLine as deleteDocLine, insertNewlineAndIndent, moveLineDown, moveLineUp, } from "@codemirror/commands"; -import { selectNextOccurrence, selectSelectionMatches } from "@codemirror/search"; -import { humpLeft, humpRight } from "../model/camelHumps"; +import { SearchCursor } from "@codemirror/search"; +import { humpLeft, humpRight, wordStartLeft } from "../model/camelHumps"; + +type Node = ReturnType["resolveInner"]>; // --- CamelHumps ------------------------------------------------------------- -function humpTarget(state: EditorState, pos: number, forward: boolean): number { +function humpTarget(state: EditorState, pos: number, forward: boolean, camel = true): number { const line = state.doc.lineAt(pos); const col = pos - line.from; if (forward) { @@ -23,7 +36,7 @@ function humpTarget(state: EditorState, pos: number, forward: boolean): number { return line.from + humpRight(line.text, col); } if (col <= 0) return Math.max(0, line.from - 1); - return line.from + humpLeft(line.text, col); + return line.from + (camel ? humpLeft(line.text, col) : wordStartLeft(line.text, col)); } function moveHump(forward: boolean, extend: boolean): Command { @@ -46,12 +59,12 @@ export const cursorHumpRight = moveHump(true, false); export const selectHumpLeft = moveHump(false, true); export const selectHumpRight = moveHump(true, true); -function deleteHump(forward: boolean): Command { +function deleteHump(forward: boolean, camel = true): Command { return (view) => { if (view.state.readOnly) return false; const tr = view.state.changeByRange((r) => { if (!r.empty) return { changes: { from: r.from, to: r.to }, range: EditorSelection.cursor(r.from) }; - const target = humpTarget(view.state, r.head, forward); + const target = humpTarget(view.state, r.head, forward, camel); const from = Math.min(target, r.head); const to = Math.max(target, r.head); return { changes: { from, to }, range: EditorSelection.cursor(from) }; @@ -63,14 +76,65 @@ function deleteHump(forward: boolean): Command { export const deleteHumpBackward = deleteHump(false); export const deleteHumpForward = deleteHump(true); +/** ⌥⌫ / Ctrl+Backspace without CamelHumps: the whitespace before the caret and the word (or punctuation run) before it. */ +export const deleteWordBackward = deleteHump(false, false); // --- lines / selections ------------------------------------------------------- +/** + * The whole lines `[from, to]` touches, grown to the visual lines they are + * part of: a folded region's lines go together with the line it is shown on. + */ +export function visualLineSpan(state: EditorState, from: number, to: number): { from: number; to: number } { + let a = state.doc.lineAt(from).from; + let b = state.doc.lineAt(to).to; + const folded = foldedRanges(state); + for (let changed = true; changed; ) { + changed = false; + folded.between(a, b, (f, t) => { + const na = Math.min(a, state.doc.lineAt(f).from); + const nb = Math.max(b, state.doc.lineAt(t).to); + if (na !== a || nb !== b) { + a = na; + b = nb; + changed = true; + } + }); + } + return { from: a, to: b }; +} + +/** Visual line spans of the selection ranges, merged; `null` when no fold is involved. */ +function foldedLineSpans(state: EditorState): Array<{ from: number; to: number }> | null { + const spans: Array<{ from: number; to: number }> = []; + let folds = false; + for (const r of state.selection.ranges) { + const end = r.to > r.from && state.doc.lineAt(r.to).from === r.to ? r.to - 1 : r.to; + const span = visualLineSpan(state, r.from, end); + if (span.from !== state.doc.lineAt(r.from).from || span.to !== state.doc.lineAt(end).to) folds = true; + const last = spans[spans.length - 1]; + if (last && span.from <= last.to + 1) last.to = Math.max(last.to, span.to); + else spans.push(span); + } + return folds ? spans : null; +} + /** ⌘D: duplicate the line (caret moves to the copy) or the selection (copy selected). */ export const duplicateLineOrSelection: Command = (view) => { const { state } = view; if (state.readOnly) return false; - if (state.selection.ranges.every((r) => r.empty)) return copyLineDown(view); + if (state.selection.ranges.every((r) => r.empty)) { + const spans = foldedLineSpans(state); + if (!spans) return copyLineDown(view); + // On a folded region's line the whole folded block is duplicated. + const changes = state.changes(spans.map((s) => ({ from: s.to, insert: state.lineBreak + state.sliceDoc(s.from, s.to) }))); + const ranges = state.selection.ranges.map((r) => { + const s = spans.find((sp) => sp.from <= r.head && r.head <= sp.to)!; + return EditorSelection.cursor(changes.mapPos(r.head, -1) + s.to - s.from + state.lineBreak.length); + }); + view.dispatch({ changes, selection: EditorSelection.create(ranges, state.selection.mainIndex), scrollIntoView: true, userEvent: "input.duplicate" }); + return true; + } const tr = state.changeByRange((r) => { if (r.empty) { const line = state.doc.lineAt(r.head); @@ -89,18 +153,139 @@ export const duplicateLineOrSelection: Command = (view) => { return true; }; -/** ⌃G: add the next occurrence of the word / selection as a caret. */ -export const addCaretNextOccurrence: Command = (view) => selectNextOccurrence(view); +/** ⌘⌫: Delete Line — on a folded region's line, every line of the folded block. */ +export const deleteLine: Command = (view) => { + const { state } = view; + if (state.readOnly) return false; + const spans = foldedLineSpans(state); + if (!spans) return deleteDocLine(view); + const len = state.doc.length; + const changes = state.changes( + spans.map((s) => (s.to < len ? { from: s.from, to: s.to + 1 } : { from: Math.max(0, s.from - 1), to: s.to })), + ); + const doc = changes.apply(state.doc); + const ranges = state.selection.ranges.map((r) => { + const col = r.head - state.doc.lineAt(r.head).from; + const line = doc.lineAt(changes.mapPos(r.head, -1)); + return EditorSelection.cursor(line.from + Math.min(col, line.length)); + }); + view.dispatch({ changes, selection: EditorSelection.create(ranges, state.selection.mainIndex), scrollIntoView: true, userEvent: "delete.line" }); + return true; +}; -/** ⌃⌘G: select every occurrence of the word / selection. */ +// --- occurrences -------------------------------------------------------------- + +interface OccurrenceSearch { + /** The sequence started from a bare caret: occurrences match whole words only. */ + wholeWords: boolean; + /** The last search found nothing: the next one starts from the top. */ + notFound: boolean; +} + +const setOccurrenceSearch = StateEffect.define(); + +/** State of a run of Add Selection for Next Occurrence; any other selection change or edit ends it. */ +export const occurrenceSearch = StateField.define({ + create: () => null, + update(value, tr) { + for (const e of tr.effects) if (e.is(setOccurrenceSearch)) return e.value; + return tr.docChanged || tr.selection ? null : value; + }, +}); + +const IDENT = /[\p{L}\p{N}_$]/u; +const identAt = (state: EditorState, pos: number) => pos >= 0 && pos < state.doc.length && IDENT.test(state.sliceDoc(pos, pos + 1)); + +/** The identifier at or right before `pos`. */ +export function identifierAt(state: EditorState, pos: number): { from: number; to: number } | null { + let from = pos; + let to = pos; + while (from > 0 && identAt(state, from - 1)) from--; + while (to < state.doc.length && identAt(state, to)) to++; + return from < to ? { from, to } : null; +} + +function isWholeWord(state: EditorState, from: number, to: number): boolean { + if (identAt(state, from - 1) && identAt(state, from)) return false; + return !(identAt(state, to) && identAt(state, to - 1)); +} + +/** Case-sensitive matches of `text` from `from` on (whole words only when asked). */ +function* matches(state: EditorState, text: string, from: number, wholeWords: boolean) { + const cursor = new SearchCursor(state.doc, text, from); + for (cursor.next(); !cursor.done; cursor.next()) { + const { from: a, to: b } = cursor.value; + if (!wholeWords || isWholeWord(state, a, b)) yield { from: a, to: b }; + } +} + +/** A selection of `m` with the head on the same side as in `like`. */ +const rangeLike = (like: SelectionRange, m: { from: number; to: number }) => + like.head === like.from && like.from !== like.to ? EditorSelection.range(m.to, m.from) : EditorSelection.range(m.from, m.to); + +/** + * ⌃G: Add Selection for Next Occurrence. From a bare caret it selects the + * word (and the run then matches whole words); with a selection it adds the + * next match after the last added one. Nothing found reports `notFound` (the + * caller hints); the next invocation then searches from the top. + */ +export function nextOccurrence(view: EditorView): "selected" | "added" | "notFound" | "none" { + const { state } = view; + const main = state.selection.main; + if (main.empty) { + const word = identifierAt(state, main.head); + if (!word) return "none"; + view.dispatch({ + selection: state.selection.replaceRange(EditorSelection.range(word.from, word.to)), + effects: setOccurrenceSearch.of({ wholeWords: true, notFound: false }), + scrollIntoView: true, + userEvent: "select", + }); + return "selected"; + } + const run = state.field(occurrenceSearch, false) ?? null; + const wholeWords = !!run?.wholeWords; + const text = state.sliceDoc(main.from, main.to); + const hit = matches(state, text, run?.notFound ? 0 : main.to, wholeWords).next().value; + if (!hit) { + view.dispatch({ effects: setOccurrenceSearch.of({ wholeWords, notFound: true }) }); + return "notFound"; + } + if (state.selection.ranges.some((r) => r.from === hit.from && r.to === hit.to)) { + view.dispatch({ effects: setOccurrenceSearch.of({ wholeWords, notFound: !!run?.notFound }) }); + return "none"; + } + view.dispatch({ + selection: state.selection.addRange(rangeLike(main, hit)), + effects: [setOccurrenceSearch.of({ wholeWords, notFound: false }), EditorView.scrollIntoView(hit.from)], + userEvent: "select", + }); + return "added"; +} + +export const addCaretNextOccurrence: Command = (view) => nextOccurrence(view) !== "none"; + +/** ⌃⌘G: select every occurrence of the selection, or of the word at the caret (whole words). */ export const selectAllOccurrences: Command = (view) => { const { state } = view; - if (state.selection.main.empty) { - const word = state.wordAt(state.selection.main.head); + let main = state.selection.main; + let wholeWords = false; + if (main.empty) { + const word = identifierAt(state, main.head); if (!word) return false; - view.dispatch({ selection: EditorSelection.single(word.from, word.to) }); + main = EditorSelection.range(word.from, word.to); + wholeWords = true; } - return selectSelectionMatches(view); + const text = state.sliceDoc(main.from, main.to); + const ranges: SelectionRange[] = []; + let mainIndex = 0; + for (const m of matches(state, text, 0, wholeWords)) { + if (m.from === main.from) mainIndex = ranges.length; + ranges.push(rangeLike(main, m)); + } + if (!ranges.length) return false; + view.dispatch({ selection: EditorSelection.create(ranges, mainIndex), userEvent: "select" }); + return true; }; // --- move statement ----------------------------------------------------------- @@ -110,65 +295,192 @@ interface LineSpan { to: number; // line end (excl. break) } -function wholeLines(state: EditorState, from: number, to: number): LineSpan { - return { from: state.doc.lineAt(from).from, to: state.doc.lineAt(to).to }; -} +/** Nodes whose children are statements / members (plus the top node). */ +const CONTAINER = + /^(Program|Script|Module|SourceFile|Chunk|Document|StyleSheet|JsonText|Object|Array|BlockMapping|BlockSequence|Suite|CompoundStatement|DeclarationList|FieldDeclarationList|StatementList)$|Block$|Body$/; +/** Function / class-like nodes: Move Statement never carries code into or out of them. */ +const GUARD = /Function|Method|Class|Interface|Constructor|Enum|Struct|Trait|Impl|Lambda|Arrow|Closure|Func/; +const NOT_GUARD = /Body|Block|Call|Name|Type|Keyword|Modifier|List|Param|Signature|Invocation|Reference|Access/; -/** The statement-like syntax node starting on the caret's line, as whole lines. */ -function statementAt(state: EditorState, pos: number): { span: LineSpan; node: { from: number; to: number; parent: unknown } } | null { - const line = state.doc.lineAt(pos); +const isContainer = (n: Node | null) => !n || !n.parent || CONTAINER.test(n.type.name); +const isGuard = (n: Node) => GUARD.test(n.type.name) && !NOT_GUARD.test(n.type.name); + +function firstToken(state: EditorState, lineNo: number): number | null { + const line = state.doc.line(lineNo); const indent = /^\s*/.exec(line.text)![0].length; - const start = line.from + indent; - if (start >= line.to) return null; - let node = syntaxTree(state).resolveInner(start, 1); - // Climb to the outermost node that starts at the line's first token. - while (node.parent && node.parent.from === start && node.parent.parent) node = node.parent; - if (node.from !== start) return null; - const span = wholeLines(state, node.from, node.to); - if (state.doc.lineAt(node.to).to !== span.to) return null; - return { span, node }; + return indent === line.length ? null : line.from + indent; } -function siblingStatement(state: EditorState, pos: number, up: boolean): LineSpan | null { - const cur = statementAt(state, pos); - if (!cur) return null; - const tree = syntaxTree(state); - let node = tree.resolveInner(cur.node.from, 1); - while (node.parent && node.parent.from === cur.node.from && node.parent.parent) node = node.parent; - let sib = up ? node.prevSibling : node.nextSibling; - // Skip punctuation / comments-only siblings on the same line. - while (sib && state.doc.lineAt(sib.from).number === state.doc.lineAt(node.from).number) sib = up ? sib.prevSibling : sib.nextSibling; - if (!sib) return null; - // Don't move a statement past its block's braces. - if (/^[{}()[\]]$/.test(state.sliceDoc(sib.from, sib.to))) return null; - const span = wholeLines(state, sib.from, sib.to); - if (state.doc.lineAt(sib.from).from !== span.from) return null; - return span; +/** The statement (or member, comment, block-closing brace) that starts at the first token of a line. */ +function statementStartingAt(state: EditorState, lineNo: number): Node | null { + const pos = firstToken(state, lineNo); + if (pos === null) return null; + for (let n: Node | null = syntaxTree(state).resolveInner(pos, 1); n && n.parent; n = n.parent) { + if (n.from !== pos) return null; + if (isContainer(n.parent)) return n; + } + return null; } -function swapSpans(state: EditorState, a: LineSpan, b: LineSpan, caretOffset: number, up: boolean): TransactionSpec { - // a is above b; the moved one is `up ? b : a`. - const textA = state.sliceDoc(a.from, a.to); - const textB = state.sliceDoc(b.from, b.to); - const between = state.sliceDoc(a.to, b.from); - const insert = textB + between + textA; - const changes: ChangeSpec = { from: a.from, to: b.to, insert }; - const anchor = up ? a.from + caretOffset : a.from + textB.length + between.length + caretOffset; - return { changes, selection: { anchor }, scrollIntoView: true, userEvent: "move.line" }; +/** Lines a moved statement stops at: blank lines, statement starts and closing braces of blocks. */ +const isStopLine = (state: EditorState, lineNo: number) => firstToken(state, lineNo) === null || !!statementStartingAt(state, lineNo); + +/** The statement containing the first token of the caret line. */ +function statementAround(state: EditorState, pos: number): Node | null { + const start = firstToken(state, state.doc.lineAt(pos).number); + if (start === null) return null; + for (let n: Node | null = syntaxTree(state).resolveInner(start, 1); n && n.parent; n = n.parent) if (isContainer(n.parent)) return n; + return null; +} + +/** The statement ending on a line (at its last token). */ +function statementEndingOn(state: EditorState, lineNo: number): Node | null { + const line = state.doc.line(lineNo); + const end = line.from + line.text.trimEnd().length; + if (end === line.from) return null; + for (let n: Node | null = syntaxTree(state).resolveInner(end, -1); n && n.parent; n = n.parent) { + if (isContainer(n.parent)) return state.doc.lineAt(n.to).number === lineNo ? n : null; + } + return null; +} + +/** A function / class (or a statement holding one, e.g. a call with a callback) moves as one unit. */ +function holdsGuard(n: Node): boolean { + if (isGuard(n)) return true; + for (let c = n.firstChild; c; c = c.nextSibling) if (!CONTAINER.test(c.type.name) && holdsGuard(c)) return true; + return false; +} + +/** Moving `cur` across lines `[s, e]` would carry it into or out of a function / class. */ +function crossesGuard(state: EditorState, cur: Node, s: number, e: number): boolean { + const from = state.doc.line(s).from; + const to = state.doc.line(e).to; + let crosses = false; + syntaxTree(state).iterate({ + from, + to, + enter: (n) => { + if (crosses) return false; + if (!isGuard(n.node)) return; + if (n.from <= cur.from && n.to >= cur.to) { + const gs = state.doc.lineAt(n.from).number; + const ge = state.doc.lineAt(n.to).number; + if ((gs >= s && gs <= e) || (ge >= s && ge <= e)) crosses = true; + } else if (n.from < from || n.to > to) crosses = true; + }, + }); + return crosses; +} + +/** Comment lines directly above line `a` (a declaration's doc comment moves with it). */ +function leadingCommentStart(state: EditorState, a: number): number { + while (a > 1) { + const n = statementStartingAt(state, a - 1); + if (!n || !/Comment/.test(n.type.name)) break; + a = state.doc.lineAt(n.from).number; + } + return a; +} + +export type StatementMove = { spec: TransactionSpec } | "noop" | "line"; + +/** + * Move Statement as in IDEA: the statement around the caret (whole lines) + * moves past the next statement; into a block when the neighbour is a block + * header or closing brace, but never into or out of a function or class (then + * nothing happens). A neighbouring function / class (or a statement holding + * one) is passed as a unit. The moved lines take the indentation of their new + * place. `line` when there is no statement at the caret (move the line). + */ +export function planStatementMove(state: EditorState, up: boolean): StatementMove { + const head = state.selection.main.head; + const cur = statementAround(state, head); + if (!cur) return "line"; + const doc = state.doc; + if (/^[{}]$/.test(cur.type.name)) { + for (let g: Node | null = cur.parent; g; g = g.parent) + if (isGuard(g)) { + const curLine = doc.lineAt(cur.from).number; + if (doc.lineAt(g.from).number === curLine || doc.lineAt(g.to).number === curLine) return "noop"; + break; + } + } + let a = doc.lineAt(cur.from).number; + const b = doc.lineAt(cur.to).number; + if (holdsGuard(cur)) a = leadingCommentStart(state, a); + let s: number; + let e: number; + if (up) { + e = a - 1; + if (e < 1) return "noop"; + const prev = statementEndingOn(state, e); + if (prev && prev.from < cur.from && holdsGuard(prev)) s = leadingCommentStart(state, doc.lineAt(prev.from).number); + else { + s = e; + while (s >= 1 && !isStopLine(state, s)) s--; + if (s < 1) return "noop"; + } + } else { + s = b + 1; + if (s > doc.lines) return "noop"; + const next = statementStartingAt(state, s); + if (next && next.to > cur.to && holdsGuard(next)) e = doc.lineAt(next.to).number; + else { + e = s + 1; + while (e <= doc.lines && !isStopLine(state, e)) e++; + e--; + } + } + if (crossesGuard(state, cur, s, e)) return "noop"; + const moved: LineSpan = { from: doc.line(a).from, to: doc.line(b).to }; + const other: LineSpan = { from: doc.line(s).from, to: doc.line(e).to }; + const [first, second] = up ? [other, moved] : [moved, other]; + const insert = state.sliceDoc(second.from, second.to) + state.lineBreak + state.sliceDoc(first.from, first.to); + const changes = state.changes({ from: first.from, to: second.to, insert }); + const movedFrom = up ? first.from : first.from + (second.to - second.from) + state.lineBreak.length; + const after = state.update({ changes }).state; + const reindent = shiftToIndentation(after, movedFrom, movedFrom + (moved.to - moved.from)); + const caret = movedFrom + (head - moved.from); + return { + spec: { + changes: reindent ? changes.compose(reindent) : changes, + selection: EditorSelection.cursor(reindent ? reindent.mapPos(caret, 1) : caret), + scrollIntoView: true, + userEvent: "move.line", + }, + }; +} + +/** Shifts the lines of `[from, to]` so the first one gets the indentation the language computes for it. */ +function shiftToIndentation(state: EditorState, from: number, to: number) { + if (!syntaxTree(state).length) return null; + const first = state.doc.lineAt(from); + const proper = getIndentation(state, first.from); + if (proper === null) return null; + const delta = proper - countColumn(/^\s*/.exec(first.text)![0], state.tabSize); + if (!delta) return null; + const changes: ChangeSpec[] = []; + for (let pos = from; pos <= to; ) { + const line = state.doc.lineAt(pos); + pos = line.to + 1; + const ws = /^\s*/.exec(line.text)![0]; + if (ws.length === line.length) continue; + const col = Math.max(0, countColumn(ws, state.tabSize) + delta); + changes.push({ from: line.from, to: line.from + ws.length, insert: indentString(state, col) }); + } + return state.changes(changes); } function moveStatement(up: boolean): Command { return (view) => { - const { state } = view; - if (state.readOnly) return false; - if (state.selection.ranges.length === 1 && state.selection.main.empty) { - const head = state.selection.main.head; - const cur = statementAt(state, head); - const sib = cur && siblingStatement(state, head, up); - if (cur && sib && (cur.span.to - cur.span.from > 0)) { - const caretOffset = head - cur.span.from; - const spec = up ? swapSpans(state, sib, cur.span, caretOffset, true) : swapSpans(state, cur.span, sib, caretOffset, false); - view.dispatch(spec); + if (view.state.readOnly) return false; + if (view.state.selection.ranges.length === 1 && view.state.selection.main.empty) { + // The statement and its neighbours need a parsed tree around the caret. + forceParsing(view, Math.min(view.state.doc.length, view.state.selection.main.head + 20_000), 100); + const plan = planStatementMove(view.state, up); + if (plan === "noop") return true; + if (plan !== "line") { + view.dispatch(plan.spec); return true; } } @@ -427,10 +739,17 @@ export const addCaretsToLineEnds: Command = (view) => { return true; }; -/** ⌃⇧G: Unselect Occurrence — drops the most recently added selection. */ +/** ⌃⇧G: Unselect Occurrence — drops the most recently added selection; a single one collapses to its caret. */ export const unselectOccurrence: Command = (view) => { - const sel = view.state.selection; - if (sel.ranges.length < 2) return false; + const { state } = view; + const sel = state.selection; + const run = state.field(occurrenceSearch, false) ?? null; + const effects = setOccurrenceSearch.of({ wholeWords: !!run?.wholeWords, notFound: false }); + if (sel.ranges.length < 2) { + if (sel.main.empty) return false; + view.dispatch({ selection: EditorSelection.cursor(sel.main.head), effects, userEvent: "select" }); + return true; + } const ranges = sel.ranges.filter((_, i) => i !== sel.mainIndex); // The previous occurrence becomes main: the one closest before the removed one, else the last. const removed = sel.main.from; @@ -440,6 +759,6 @@ export const unselectOccurrence: Command = (view) => { main = i; break; } - view.dispatch({ selection: EditorSelection.create(ranges, main), scrollIntoView: true, userEvent: "select" }); + view.dispatch({ selection: EditorSelection.create(ranges, main), effects, scrollIntoView: true, userEvent: "select" }); return true; }; diff --git a/src/editor/core/cm/folding.ts b/src/editor/core/cm/folding.ts index f9fc6251..fa6d71f5 100644 --- a/src/editor/core/cm/folding.ts +++ b/src/editor/core/cm/folding.ts @@ -155,18 +155,23 @@ export function unfoldAtCaret(view: EditorView): boolean { } /** - * IDEA "Fold Selection / Remove Region" (⌘.): folds each selection; with no - * selection, expands the fold at the caret. + * IDEA "Fold Selection / Remove Region" (⌘.): folds each selection (without + * a trailing line break); with no selection, toggles the region at the caret. */ export function foldSelectionEffects(state: EditorState): StateEffect[] { const effects: StateEffect[] = []; for (const range of state.selection.ranges) { if (!range.empty) { - effects.push(foldEffect.of({ from: range.from, to: range.to })); + const to = state.sliceDoc(range.to - 1, range.to) === "\n" ? range.to - 1 : range.to; + if (to > range.from) effects.push(foldEffect.of({ from: range.from, to })); continue; } - const r = foldToExpand(state, range.head); - if (r) effects.push(unfoldEffect.of(r)); + const folded = foldToExpand(state, range.head); + if (folded) effects.push(unfoldEffect.of(folded)); + else { + const r = enclosingFoldRange(state, range.head); + if (r) effects.push(foldEffect.of(r)); + } } return effects; } @@ -175,11 +180,98 @@ export function foldSelection(view: EditorView): boolean { const effects = foldSelectionEffects(view.state); if (!effects.length) return false; const sel = view.state.selection; - // The caret lands after the fold, like IDEA. - view.dispatch({ effects, selection: sel.ranges.some((r) => !r.empty) ? { anchor: sel.main.to } : undefined }); + const folded = effects.filter((e) => e.is(foldEffect)).map((e) => e.value as { from: number; to: number }); + // The caret lands after the new fold, like IDEA. + const after = sel.ranges.some((r) => !r.empty) && folded.length ? { anchor: folded[folded.length - 1]!.to } : undefined; + view.dispatch({ effects, selection: after }); return true; } +function isFolded(state: EditorState, r: { from: number; to: number }): boolean { + let hit = false; + foldedRanges(state).between(r.from, r.from, (a, b) => { + if (a === r.from && b === r.to) hit = true; + }); + return hit; +} + +/** Every foldable region (nested ones included) of the lines in `[from, to]`, kept within `[from, to]`. */ +export function foldableRegions(state: EditorState, from = 0, to = state.doc.length): Array<{ from: number; to: number }> { + const out: Array<{ from: number; to: number }> = []; + for (let pos = state.doc.lineAt(from).from; pos <= to; ) { + const line = state.doc.lineAt(pos); + const r = foldable(state, line.from, line.to); + if (r && r.from >= from && r.to <= to) out.push(r); + if (line.to >= state.doc.length) break; + pos = line.to + 1; + } + return out; +} + +/** The selection, when it holds whole regions to work on; else the whole document. */ +function regionScope(state: EditorState): { from: number; to: number } | null { + const main = state.selection.main; + return main.empty ? null : { from: main.from, to: main.to }; +} + +/** + * IDEA "Collapse All" (⌘⇧-): every region, nested ones included; with a + * selection, the regions inside it (all of them when it holds none). + */ +export function collapseAllEffects(state: EditorState): StateEffect[] { + const scope = regionScope(state); + let regions = scope ? foldableRegions(state, scope.from, scope.to) : []; + if (!regions.length) regions = foldableRegions(state); + return regions.filter((r) => !isFolded(state, r)).map((r) => foldEffect.of(r)); +} + +/** IDEA "Expand All" (⌘⇧=): every folded region, or those inside the selection. */ +export function expandAllEffects(state: EditorState): StateEffect[] { + const all: Array<{ from: number; to: number }> = []; + foldedRanges(state).between(0, state.doc.length, (from, to) => void all.push({ from, to })); + const scope = regionScope(state); + const inside = scope ? all.filter((r) => r.from >= scope.from && r.to <= scope.to) : []; + return (inside.length ? inside : all).map((r) => unfoldEffect.of(r)); +} + +/** + * IDEA "Collapse Recursively" (⌘⌥-): the region at the caret line (else the + * innermost one around the caret) and every region inside it. + */ +export function collapseRecursivelyEffects(state: EditorState, head: number): StateEffect[] { + const line = state.doc.lineAt(head); + const own = foldable(state, line.from, line.to); + const root = own && !isFolded(state, own) ? own : enclosingFoldRange(state, head); + if (!root) return []; + const inner = foldableRegions(state, root.from, root.to).filter((r) => !isFolded(state, r)); + if (!isFolded(state, root) && !inner.some((r) => r.from === root.from && r.to === root.to)) inner.push(root); + return inner.map((r) => foldEffect.of(r)); +} + +/** IDEA "Expand Recursively" (⌘⌥=): the region at the caret and every folded region inside it. */ +export function expandRecursivelyEffects(state: EditorState, head: number): StateEffect[] { + const line = state.doc.lineAt(head); + const root = foldToExpand(state, head) ?? foldable(state, line.from, line.to); + if (!root) return []; + const out: StateEffect[] = []; + foldedRanges(state).between(root.from, root.to, (from, to) => { + if (from >= root.from && to <= root.to) out.push(unfoldEffect.of({ from, to })); + }); + return out; +} + +function dispatchEffects(view: EditorView, effects: StateEffect[]): boolean { + if (effects.length) view.dispatch({ effects }); + return effects.length > 0; +} + +export const collapseAll = (view: EditorView) => dispatchEffects(view, collapseAllEffects(view.state)); +export const expandAll = (view: EditorView) => dispatchEffects(view, expandAllEffects(view.state)); +export const collapseRecursively = (view: EditorView) => + dispatchEffects(view, view.state.selection.ranges.flatMap((r) => collapseRecursivelyEffects(view.state, r.head))); +export const expandRecursively = (view: EditorView) => + dispatchEffects(view, view.state.selection.ranges.flatMap((r) => expandRecursivelyEffects(view.state, r.head))); + function chevron(open: boolean): HTMLElement { const span = document.createElement("span"); span.className = open ? "cm-openidea-foldOpen" : "cm-openidea-foldClosed"; diff --git a/src/editor/core/cm/gutterDrag.ts b/src/editor/core/cm/gutterDrag.ts new file mode 100644 index 00000000..906e034c --- /dev/null +++ b/src/editor/core/cm/gutterDrag.ts @@ -0,0 +1,72 @@ +/** + * Dragging over the line numbers (or the empty space of the other gutters) + * selects whole lines, as in IDEA. A press without a drag is left to the + * gutter's own click handling (e.g. toggling a breakpoint). + */ +import { EditorSelection, type EditorState } from "@codemirror/state"; +import { EditorView, ViewPlugin } from "@codemirror/view"; + +/** The lines from `start` to `end` (1-based, either order), the head at the far line boundary. */ +export function lineRangeSelection(state: EditorState, start: number, end: number): EditorSelection { + const doc = state.doc; + const after = (n: number) => (n < doc.lines ? doc.line(n + 1).from : doc.length); + return end >= start + ? EditorSelection.single(doc.line(start).from, after(end)) + : EditorSelection.single(after(start), doc.line(end).from); +} + +/** Line-number column or empty gutter space; not the fold chevrons, VCS annotations or a gutter's own markers. */ +export function inLineSelectArea(target: EventTarget | null): boolean { + const t = target as HTMLElement | null; + if (!t?.closest?.(".cm-gutters") || t.closest(".cm-foldGutter, .cm-vcs-blame")) return false; + if (t.closest(".cm-lineNumbers")) return true; + if (t.classList.contains("cm-gutter")) return true; + return t.classList.contains("cm-gutterElement") && t.childElementCount === 0; +} + +export const gutterLineDrag = ViewPlugin.fromClass( + class { + private stop: (() => void) | null = null; + + constructor(readonly view: EditorView) { + view.dom.addEventListener("mousedown", this.onMouseDown); + } + + private lineAt(clientY: number): number { + const block = this.view.lineBlockAtHeight(clientY - this.view.documentTop); + return this.view.state.doc.lineAt(block.from).number; + } + + onMouseDown = (e: MouseEvent) => { + if (e.button !== 0 || e.altKey || e.metaKey || e.ctrlKey || e.shiftKey || !inLineSelectArea(e.target)) return; + this.stop?.(); + const start = this.lineAt(e.clientY); + let dragging = false; + const move = (m: MouseEvent) => { + if (!(m.buttons & 1)) return finish(); + const line = this.lineAt(m.clientY); + if (!dragging && line === start) return; + dragging = true; + this.view.dispatch({ + selection: lineRangeSelection(this.view.state, start, line), + scrollIntoView: true, + userEvent: "select.pointer", + }); + }; + const finish = () => { + window.removeEventListener("mousemove", move, true); + window.removeEventListener("mouseup", finish, true); + this.stop = null; + if (dragging) this.view.focus(); + }; + window.addEventListener("mousemove", move, true); + window.addEventListener("mouseup", finish, true); + this.stop = finish; + }; + + destroy() { + this.stop?.(); + this.view.dom.removeEventListener("mousedown", this.onMouseDown); + } + }, +); diff --git a/src/editor/core/cm/ideaParity.test.ts b/src/editor/core/cm/ideaParity.test.ts new file mode 100644 index 00000000..52fca803 --- /dev/null +++ b/src/editor/core/cm/ideaParity.test.ts @@ -0,0 +1,331 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { EditorSelection, EditorState, type Extension } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { codeFolding, foldEffect, foldService, foldedRanges, indentService, indentUnit } from "@codemirror/language"; +import { languages } from "@codemirror/language-data"; +import { + deleteLine, + deleteWordBackward, + duplicateLineOrSelection, + moveStatementDown, + moveStatementUp, + nextOccurrence, + occurrenceSearch, + selectAllOccurrences, + unselectOccurrence, +} from "./editing"; +import { pasteReindentFilter } from "./prefs"; +import { closesStringAt, skipClosingQuote } from "./smartKeys"; +import { + collapseAllEffects, + collapseRecursivelyEffects, + expandRecursivelyEffects, + foldSelectionEffects, +} from "./folding"; +import { columnModeExtension, extendBlock, toggleColumnMode } from "./columnMode"; +import { nearestMark, unfoldAround } from "./lanes"; +import { lineRangeSelection } from "./gutterDrag"; +import { matchBraceTarget } from "./caretCommands"; +import { overwriteField, toggleOverwrite } from "./overwrite"; + +const views: EditorView[] = []; +afterEach(() => views.splice(0).forEach((v) => v.destroy())); + +/** `|` marks carets, `[`/`]` a selection. */ +function make(text: string, extensions: Extension = []) { + 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), indentUnit.of(" "), occurrenceSearch, 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; +} + +const javascript = await languages.find((l) => l.name === "JavaScript")!.load(); + +describe("Move Statement", () => { + it("moves a multi-line statement as a whole, never one of its lines", () => { + const v = make("function f() {\n a();\n |foo(\n 1,\n 2);\n}", javascript); + moveStatementUp(v); + expect(v.state.doc.toString()).toBe("function f() {\n foo(\n 1,\n 2);\n a();\n}"); + moveStatementDown(v); + expect(v.state.doc.toString()).toBe("function f() {\n a();\n foo(\n 1,\n 2);\n}"); + }); + + it("moves into a block past its header and out of it past its closing brace, re-indented", () => { + const v = make("function f() {\n |a();\n if (x) {\n b();\n }\n}", javascript); + moveStatementDown(v); + expect(show(v)).toBe("function f() {\n if (x) {\n |a();\n b();\n }\n}"); + moveStatementDown(v); + moveStatementDown(v); + expect(show(v)).toBe("function f() {\n if (x) {\n b();\n }\n |a();\n}"); + }); + + it("never carries a statement out of its function", () => { + const v = make("function f() {\n |a();\n}\nb();", javascript); + moveStatementUp(v); + expect(v.state.doc.toString()).toBe("function f() {\n a();\n}\nb();"); + moveStatementDown(v); + expect(v.state.doc.toString()).toBe("function f() {\n a();\n}\nb();"); + }); + + it("passes a neighbouring function as a whole", () => { + const v = make("|a();\nfunction f() {\n b();\n}", javascript); + moveStatementDown(v); + expect(v.state.doc.toString()).toBe("function f() {\n b();\n}\na();"); + }); +}); + +describe("occurrences", () => { + it("⌃G from a caret selects the word, then whole-word matches after the last added one", () => { + const v = make("fo|o foobar foo foo"); + expect(nextOccurrence(v)).toBe("selected"); + expect(show(v)).toBe("[foo] foobar foo foo"); + expect(nextOccurrence(v)).toBe("added"); + expect(show(v)).toBe("[foo] foobar [foo] foo"); + }); + + it("matches inside words for a selection made by hand", () => { + const v = make("[foo] foobar foo"); + nextOccurrence(v); + expect(show(v)).toBe("[foo] [foo]bar foo"); + }); + + it("reports nothing found once, then searches from the top", () => { + const v = make("foo x [foo]"); + expect(nextOccurrence(v)).toBe("notFound"); + expect(nextOccurrence(v)).toBe("added"); + expect(show(v)).toBe("[foo] x [foo]"); + }); + + it("⌃⌘G from a caret selects whole-word occurrences only", () => { + const v = make("a|b abc ab"); + selectAllOccurrences(v); + expect(show(v)).toBe("[ab] abc [ab]"); + }); + + it("⌃⇧G keeps the whole-word run going", () => { + const v = make("|ab abc ab ab"); + nextOccurrence(v); + nextOccurrence(v); + nextOccurrence(v); + unselectOccurrence(v); + nextOccurrence(v); + expect(show(v)).toBe("[ab] abc [ab] [ab]"); + }); +}); + +describe("folded lines", () => { + const braces = foldService.of((state, from, to) => { + const line = state.doc.lineAt(from); + if (!line.text.endsWith("{")) return null; + const close = state.doc.toString().indexOf("}", to); + return close > to ? { from: to, to: close } : null; + }); + const folded = () => { + const v = make("x\nf() {\n a;\n}\ny", [braces, codeFolding()]); + v.dispatch({ effects: foldEffect.of({ from: 7, to: 13 }), selection: { anchor: 3 } }); + return v; + }; + + it("⌘D on a folded region's line duplicates the whole folded block", () => { + const v = folded(); + duplicateLineOrSelection(v); + expect(v.state.doc.toString()).toBe("x\nf() {\n a;\n}\nf() {\n a;\n}\ny"); + expect(v.state.selection.main.head).toBe(16); + }); + + it("⌘⌫ on a folded region's line deletes the whole folded block", () => { + const v = folded(); + deleteLine(v); + expect(v.state.doc.toString()).toBe("x\ny"); + }); +}); + +describe("Delete to Word Start", () => { + it("deletes the whitespace before the caret and the word before it", () => { + const v = make("foo bar |"); + deleteWordBackward(v); + expect(show(v)).toBe("foo |"); + const w = make("a.b(|"); + deleteWordBackward(w); + expect(show(w)).toBe("a.b|"); + }); +}); + +describe("paste reformatting", () => { + /** One unit deeper after a line ending in `{`. */ + const braceIndent = indentService.of((cx, pos) => { + const line = cx.state.doc.lineAt(pos); + if (line.number === 1) return 0; + const prev = cx.state.doc.line(line.number - 1); + const base = cx.lineIndent(prev.from); + return prev.text.trimEnd().endsWith("{") ? base + 2 : base; + }); + const paste = (v: EditorView, text: string) => + v.dispatch({ + changes: { from: v.state.selection.main.from, to: v.state.selection.main.to, insert: text }, + userEvent: "input.paste", + }); + + it("indents each pasted line (IDEA's default)", () => { + const v = make("if {\n|\n}", [braceIndent, pasteReindentFilter("indentEachLine")]); + paste(v, "a();\n b();\n c();"); + expect(v.state.doc.toString()).toBe("if {\n a();\n b();\n c();\n}"); + }); + + it("starts a block pasted at a line's indentation at the proper indent", () => { + const v = make("if {\n|\n}", [braceIndent, pasteReindentFilter("indentBlock")]); + paste(v, "a();\n b();\n c();"); + expect(v.state.doc.toString()).toBe("if {\n a();\n b();\n c();\n}"); + }); +}); + +describe("closing quotes", () => { + it("steps over the closing quote of an existing string literal", () => { + const v = make('f("ab|");', [javascript, skipClosingQuote]); + expect(closesStringAt(v.state, v.state.selection.main.head, '"')).toBe(true); + expect(closesStringAt(v.state, 2, '"')).toBe(false); + const handled = v.state.facet(EditorView.inputHandler).some((h) => h(v, 5, 5, '"', () => v.state.update({}))); + expect(handled).toBe(true); + expect(show(v)).toBe('f("ab"|);'); + }); +}); + +describe("folding actions", () => { + const braces = foldService.of((state, from, to) => { + const line = state.doc.lineAt(from); + if (!line.text.trimEnd().endsWith("{")) return null; + let depth = 0; + for (let pos = to - 1; pos < state.doc.length; pos++) { + const ch = state.doc.sliceString(pos, pos + 1); + if (ch === "{") depth++; + else if (ch === "}" && --depth === 0) return { from: to, to: pos }; + } + return null; + }); + const text = "a {\n b {\n c;\n }\n}\nd {\n}\n"; + const state = (sel?: { anchor: number; head?: number }) => + EditorState.create({ doc: text, selection: sel, extensions: [braces, codeFolding()] }); + const foldedOf = (s: EditorState) => { + const out: number[] = []; + foldedRanges(s).between(0, s.doc.length, (a) => void out.push(s.doc.lineAt(a).number)); + return out.sort((a, b) => a - b); + }; + + it("Collapse All folds nested regions too; with a selection only those inside it", () => { + let s = state(); + s = s.update({ effects: collapseAllEffects(s) }).state; + expect(foldedOf(s)).toEqual([1, 2, 6]); + s = state({ anchor: 4, head: 21 }); + s = s.update({ effects: collapseAllEffects(s) }).state; + expect(foldedOf(s)).toEqual([2]); + }); + + it("collapses and expands recursively", () => { + let s = state({ anchor: 1 }); + s = s.update({ effects: collapseRecursivelyEffects(s, 1) }).state; + expect(foldedOf(s)).toEqual([1, 2]); + s = s.update({ effects: expandRecursivelyEffects(s, 1) }).state; + expect(foldedOf(s)).toEqual([]); + }); + + it("⌘. folds a selection without its trailing line break and toggles the region at a caret", () => { + let s = state({ anchor: 4, head: 23 }); + const fold = foldSelectionEffects(s)[0]!; + expect(fold.is(foldEffect) && fold.value).toEqual({ from: 4, to: 22 }); + s = state({ anchor: 12 }); + s = s.update({ effects: foldSelectionEffects(s) }).state; + expect(foldedOf(s)).toEqual([2]); + s = s.update({ effects: foldSelectionEffects(s) }).state; + expect(foldedOf(s)).toEqual([]); + }); +}); + +describe("column selection", () => { + it("keeps the block in visual columns across tabs", () => { + const v = make("\tab\n cd", [columnModeExtension, EditorState.tabSize.of(4)]); + v.dispatch({ selection: { anchor: 1 } }); + toggleColumnMode(v); + extendBlock(v, 1, 0); + extendBlock(v, 0, 1); + expect(v.state.selection.ranges.map((r) => [r.anchor, r.head])).toEqual([ + [1, 2], + [8, 9], + ]); + }); +}); + +describe("error stripe", () => { + it("picks the nearest mark within the tolerance", () => { + const marks = [ + { top: 10, bottom: 12, pos: 1 }, + { top: 30, bottom: 32, pos: 2 }, + ]; + expect(nearestMark(marks, 15)?.pos).toBe(1); + expect(nearestMark(marks, 27)?.pos).toBe(2); + expect(nearestMark(marks, 21)).toBeNull(); + }); + + it("unfolds what hides the target line", () => { + const s = EditorState.create({ doc: "f {\n x\n}", extensions: [codeFolding()] }).update({ + effects: foldEffect.of({ from: 3, to: 8 }), + }).state; + expect(unfoldAround(s, 0)).toHaveLength(1); + }); +}); + +describe("gutter drag", () => { + it("selects whole lines in either direction", () => { + const s = EditorState.create({ doc: "a\nb\nc\nd" }); + expect(lineRangeSelection(s, 2, 3).main).toMatchObject({ anchor: 2, head: 6 }); + expect(lineRangeSelection(s, 3, 1).main).toMatchObject({ anchor: 6, head: 0 }); + expect(lineRangeSelection(s, 4, 4).main).toMatchObject({ anchor: 6, head: 7 }); + }); +}); + +describe("Move Caret to Matching Brace", () => { + it("jumps between braces, else to the unclosed opening brace", () => { + const s = EditorState.create({ doc: "f(a, [b], c)" }); + expect(matchBraceTarget(s, 1)).toBe(12); + expect(matchBraceTarget(s, 12)).toBe(1); + expect(matchBraceTarget(s, 3)).toBe(1); + }); +}); + +describe("overwrite mode", () => { + it("replaces the characters after the caret, never the line break", () => { + const v = make("ab|cd\nx", overwriteField); + toggleOverwrite(v); + expect(v.dom.classList.contains("cm-openidea-blockCaret")).toBe(true); + const type = (text: string) => + v.state + .facet(EditorView.inputHandler) + .some((h) => h(v, v.state.selection.main.from, v.state.selection.main.to, text, () => v.state.update({}))); + type("XYZ"); + expect(show(v)).toBe("abXYZ|\nx"); + }); +}); diff --git a/src/editor/core/cm/lanes.ts b/src/editor/core/cm/lanes.ts index 33c47a33..d02530c9 100644 --- a/src/editor/core/cm/lanes.ts +++ b/src/editor/core/cm/lanes.ts @@ -2,7 +2,8 @@ * The 3px change lane next to the line numbers (`gutterLaneMarkers`) and the * right-hand overview lane over the scrollbar (`scrollbarMarks`). */ -import { type Extension } from "@codemirror/state"; +import { EditorSelection, type EditorState, type Extension, type StateEffect } from "@codemirror/state"; +import { foldedRanges, unfoldEffect } from "@codemirror/language"; import { EditorView, ViewPlugin, gutter, type ViewUpdate } from "@codemirror/view"; import { gutterLaneMarkers, scrollbarMarks, type ScrollbarMark } from "@/editor/api"; @@ -13,6 +14,29 @@ export const changeLane: Extension = gutter({ const MAX_RENDERED = 4000; +/** How far (px) a click may miss a mark on the overview lane. */ +const MARK_TOLERANCE = 6; + +/** The mark nearest to `y` (by its top) among those within the tolerance. */ +export function nearestMark(marks: readonly M[], y: number): M | null { + let best: M | null = null; + for (const m of marks) { + if (y < m.top - MARK_TOLERANCE || y > m.bottom + MARK_TOLERANCE) continue; + if (!best || Math.abs(m.top - y) < Math.abs(best.top - y)) best = m; + } + return best; +} + +/** Unfolds the folds hiding `pos` or the rest of its line (navigating to a mark shows it). */ +export function unfoldAround(state: EditorState, pos: number): StateEffect[] { + const lineEnd = state.doc.lineAt(pos).to; + const out: StateEffect[] = []; + foldedRanges(state).between(pos, lineEnd, (from, to) => { + if (from <= lineEnd && to > pos) out.push(unfoldEffect.of({ from, to })); + }); + return out; +} + class OverviewLane { dom: HTMLElement; private scheduled = false; @@ -101,13 +125,19 @@ class OverviewLane { } private onMouseDown = (e: MouseEvent) => { - const el = (e.target as HTMLElement).closest(".cm-openidea-overviewMark"); - if (!el?.dataset.pos) return; + if (e.button !== 0) return; + const marks = [...this.dom.querySelectorAll(".cm-openidea-overviewMark")].map((el) => { + const r = el.getBoundingClientRect(); + return { top: r.top, bottom: r.bottom, pos: Number(el.dataset.pos) }; + }); + const hit = nearestMark(marks, e.clientY); + if (!hit) return; e.preventDefault(); - const pos = Math.min(Number(el.dataset.pos), this.view.state.doc.length); + const state = this.view.state; + const pos = Math.min(hit.pos, state.doc.length); this.view.dispatch({ - selection: { anchor: pos }, - effects: EditorView.scrollIntoView(pos, { y: "center" }), + selection: EditorSelection.cursor(pos), + effects: [...unfoldAround(state, pos), EditorView.scrollIntoView(pos, { y: "center" })], userEvent: "select.overview", }); this.view.focus(); diff --git a/src/editor/core/cm/languages.ts b/src/editor/core/cm/languages.ts index 38b85220..68f7750c 100644 --- a/src/editor/core/cm/languages.ts +++ b/src/editor/core/cm/languages.ts @@ -35,6 +35,21 @@ export function _resetLanguages() { infosPromise = null; } +/** + * `uiMode` of the backend language for a file name (longest extension first, + * like the backend's detection); `undefined` when the languages are not loaded + * or none matches. + */ +export function uiModeForPath(path: string): string | null | undefined { + if (!infos) return undefined; + let rest = path.slice(path.lastIndexOf("/") + 1).toLowerCase(); + for (let dot = rest.indexOf("."); dot >= 0; dot = rest.indexOf(".")) { + rest = rest.slice(dot + 1); + for (const info of infos.values()) if (info.extensions.includes(rest)) return info.uiMode; + } + return undefined; +} + const supports = new Map>(); export function findLanguageDescription(uiMode: string | null, path: string): LanguageDescription | null { diff --git a/src/editor/core/cm/overwrite.ts b/src/editor/core/cm/overwrite.ts new file mode 100644 index 00000000..f4f6e25f --- /dev/null +++ b/src/editor/core/cm/overwrite.ts @@ -0,0 +1,43 @@ +/** + * Overwrite mode (Insert key, as in IDEA): typed characters replace the ones + * after the caret (never the line break) and the caret is drawn as a block. + */ +import { EditorSelection, Prec, StateEffect, StateField, type EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; + +export const setOverwrite = StateEffect.define(); + +export function isOverwrite(state: EditorState): boolean { + return state.field(overwriteField, false) ?? false; +} + +export function toggleOverwrite(view: EditorView): boolean { + view.dispatch({ effects: setOverwrite.of(!isOverwrite(view.state)) }); + return true; +} + +const overwriteInput = EditorView.inputHandler.of((view, _from, _to, text) => { + const { state } = view; + if (!isOverwrite(state) || state.readOnly || /[\r\n]/.test(text)) return false; + const tr = state.changeByRange((r) => { + let to = r.to; + if (r.empty) { + const line = state.doc.lineAt(r.head); + to = Math.min(line.to, r.head + text.length); + } + return { changes: { from: r.from, to, insert: text }, range: EditorSelection.cursor(r.from + text.length) }; + }); + view.dispatch(state.update(tr, { scrollIntoView: true, userEvent: "input.type" })); + return true; +}); + +export const overwriteField = StateField.define({ + create: () => false, + update: (on, tr) => tr.effects.reduce((acc, e) => (e.is(setOverwrite) ? e.value : acc), on), + provide: (f) => [ + Prec.highest(overwriteInput), + EditorView.editorAttributes.compute([f], (s): Record => + s.field(f) ? { class: "cm-openidea-blockCaret" } : {}, + ), + ], +}); diff --git a/src/editor/core/cm/prefs.ts b/src/editor/core/cm/prefs.ts index 4da4372c..d39d133c 100644 --- a/src/editor/core/cm/prefs.ts +++ b/src/editor/core/cm/prefs.ts @@ -3,7 +3,7 @@ * Keys, Code Editing, Code Style). Everything here is rebuilt into the * `prefs` compartment when settings change. */ -import { EditorSelection, EditorState, Prec, RangeSetBuilder, StateEffect, StateField, type Extension, type Transaction } from "@codemirror/state"; +import { EditorSelection, EditorState, Prec, RangeSetBuilder, StateEffect, StateField, type Extension, type Transaction, type TransactionSpec } from "@codemirror/state"; import { Decoration, EditorView, @@ -16,7 +16,7 @@ import { type ViewUpdate, } from "@codemirror/view"; import { highlightTree } from "@lezer/highlight"; -import { getIndentUnit, indentUnit, syntaxTree } from "@codemirror/language"; +import { getIndentUnit, getIndentation, indentRange, indentString, indentUnit, syntaxTree } from "@codemirror/language"; import { cursorLineStart, deleteCharBackwardStrict, @@ -25,9 +25,17 @@ import { } from "@codemirror/commands"; import type { EditorExtras } from "@/platform/editorSettings"; import { openideaHighlightStyle } from "./highlight"; -import { cursorHumpLeft, cursorHumpRight, deleteHumpBackward, deleteHumpForward, selectHumpLeft, selectHumpRight } from "./editing"; +import { + cursorHumpLeft, + cursorHumpRight, + deleteHumpBackward, + deleteHumpForward, + deleteWordBackward, + selectHumpLeft, + selectHumpRight, +} from "./editing"; import { enterInLineComment, lineCommentStyle, type LineCommentStyle } from "./comments"; -import { smartBackspace, smartBackspaceApplies, tabOut } from "./smartKeys"; +import { skipClosingQuote, smartBackspace, smartBackspaceApplies, tabOut } from "./smartKeys"; // --- soft wraps --------------------------------------------------------------- @@ -207,25 +215,60 @@ export function indentPastedText(text: string, targetCol: number, unit: string, .join("\n"); } -const pasteFilter = EditorState.transactionFilter.of((tr: Transaction) => { - if (!tr.isUserEvent("input.paste") || !tr.docChanged) return tr; - const start = tr.startState; - const changes: Array<{ from: number; to: number; text: string }> = []; - tr.changes.iterChanges((from, to, _fb, _tb, inserted) => changes.push({ from, to, text: inserted.toString() })); - if (changes.length !== 1) return tr; - const { from, to, text } = changes[0]!; - const line = start.doc.lineAt(from); - const before = start.doc.sliceString(line.from, from); - const targetCol = leadingColumns(/^\s*$/.test(before) ? before : line.text, start.tabSize); - const out = indentPastedText(text, targetCol, start.facet(indentUnit)[0] === "\t" ? "\t" : " ", start.tabSize, getIndentUnit(start) || start.tabSize); - if (out === text) return tr; - return { - changes: { from, to, insert: out }, - selection: EditorSelection.cursor(from + out.length), - userEvent: "input.paste", - scrollIntoView: true, - }; -}); +/** + * Reformat on paste. `indentBlock` shifts the pasted block to the paste + * column; `indentEachLine` (IDEA's default) then gives each pasted line the + * language's indentation, where the language can compute one. A paste + * after nothing but whitespace starts at the line's proper indentation. + */ +export function pasteReindentFilter(mode: "indentBlock" | "indentEachLine") { + return EditorState.transactionFilter.of((tr: Transaction) => { + if (!tr.isUserEvent("input.paste") || !tr.docChanged) return tr; + const start = tr.startState; + const changes: Array<{ from: number; to: number; text: string }> = []; + tr.changes.iterChanges((from, to, _fb, _tb, inserted) => changes.push({ from, to, text: inserted.toString() })); + if (changes.length !== 1) return tr; + let { from } = changes[0]!; + const { to, text } = changes[0]!; + if (!text.trim()) return tr; + const line = start.doc.lineAt(from); + const before = start.doc.sliceString(line.from, from); + const atIndent = /^\s*$/.test(before); + const tabSize = start.tabSize; + const unitCols = getIndentUnit(start) || tabSize; + const useTabs = start.facet(indentUnit)[0] === "\t"; + let targetCol = leadingColumns(atIndent ? before : line.text, tabSize); + let prefix = ""; + if (atIndent) { + const proper = getIndentation(start, line.from); + if (proper !== null && proper !== targetCol) { + targetCol = proper; + prefix = indentString(start, proper); + from = line.from; + } + } + const block = indentPastedText(text, targetCol, useTabs ? "\t" : " ", tabSize, unitCols); + const out = prefix + (prefix ? block.trimStart() : block); + const first: TransactionSpec = { + changes: { from, to, insert: out }, + selection: EditorSelection.cursor(from + out.length), + userEvent: "input.paste", + scrollIntoView: true, + }; + if (mode !== "indentEachLine") return out === text && from === changes[0]!.from ? tr : first; + // Lines whose start lies in the pasted text, plus the paste line when it was pasted at its indentation. + const pasted = start.update({ ...first, filter: false }).state; + const end = from + out.length; + const firstLine = pasted.doc.lineAt(from); + const lineStart = atIndent ? firstLine.from : firstLine.to + 1; + const lastLine = pasted.doc.lineAt(end); + const lineEnd = lastLine.from === end && end > from ? end - 1 : end; + if (lineStart > lineEnd) return first; + const reindent = indentRange(pasted, lineStart, lineEnd); + if (reindent.empty) return first; + return [first, { changes: reindent, selection: EditorSelection.cursor(reindent.mapPos(end, 1)), sequential: true }]; + }); +} // --- rich-text copy ----------------------------------------------------------- @@ -397,12 +440,13 @@ export function prefsExtension(o: PrefsOptions): Extension { if (!g.dragAndDrop) out.push(noTextDrag); if (g.caretScroll === "center") out.push(centerCaret); if (g.richTextCopy) out.push(richCopy); - if (!o.readOnly && k.reformatOnPaste === "indentBlock") out.push(pasteFilter); + if (!o.readOnly && (k.reformatOnPaste === "indentBlock" || k.reformatOnPaste === "indentEachLine")) out.push(pasteReindentFilter(k.reformatOnPaste)); // Pairing: closeBrackets() reads the brackets list from language data; ours comes first. 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.pairQuotes) out.push(Prec.highest(skipClosingQuote)); if (k.tabOutOfPair) out.push(tabOut); if (o.commentStyle) out.push(lineCommentStyle.of(o.commentStyle)); @@ -411,9 +455,10 @@ export function prefsExtension(o: PrefsOptions): Extension { keys.push( { key: "Alt-ArrowLeft", run: cursorHumpLeft, shift: selectHumpLeft, preventDefault: true }, { key: "Alt-ArrowRight", run: cursorHumpRight, shift: selectHumpRight, preventDefault: true }, - { key: "Alt-Backspace", run: deleteHumpBackward }, { key: "Alt-Delete", run: deleteHumpForward }, ); + // Delete to Word Start: the whitespace before the caret and the word before it. + keys.push({ key: "Mod-Backspace", mac: "Alt-Backspace", run: k.camelHumps ? deleteHumpBackward : deleteWordBackward }); if (!k.smartHome) keys.push( { key: "Home", run: cursorLineStart, shift: selectLineStart, preventDefault: true }, diff --git a/src/editor/core/cm/preview.ts b/src/editor/core/cm/preview.ts new file mode 100644 index 00000000..0d20fd5b --- /dev/null +++ b/src/editor/core/cm/preview.ts @@ -0,0 +1,31 @@ +/** + * Read-only code previews outside editor tabs (Find in Files, bookmarks): the + * editor's font, colours, line numbers and syntax highlighting, without + * editing, folding or contributions. + */ +import { EditorState, type Extension } from "@codemirror/state"; +import { EditorView, highlightSpecialChars, lineNumbers } from "@codemirror/view"; +import { syntaxHighlighting } from "@codemirror/language"; +import { editorsFor } from "@/editor/api"; +import type { EditorSettings } from "@/platform/ipc"; +import { openideaHighlightStyle } from "./highlight"; +import { openideaTheme } from "./theme"; +import { findLanguageDescription, loadSupport, loadedSupport, uiModeForPath } from "./languages"; +import { fontExtension } from "./setup"; + +export async function previewExtensions(path: string, settings: EditorSettings | undefined): Promise { + const open = editorsFor(path)[0]?.ctx; + const desc = findLanguageDescription(open ? open.uiMode : (uiModeForPath(path) ?? null), path); + const support = desc ? (loadedSupport(desc) ?? (await loadSupport(desc))) : null; + return [ + fontExtension(settings), + openideaTheme, + lineNumbers(), + highlightSpecialChars(), + syntaxHighlighting(openideaHighlightStyle), + EditorState.readOnly.of(true), + EditorView.editable.of(false), + EditorView.contentAttributes.of({ "aria-label": `Preview of ${path}` }), + support ?? [], + ]; +} diff --git a/src/editor/core/cm/setup.ts b/src/editor/core/cm/setup.ts index 3f60ebac..ed2c43fc 100644 --- a/src/editor/core/cm/setup.ts +++ b/src/editor/core/cm/setup.ts @@ -44,6 +44,9 @@ import { multiCaretMouse } from "./multiCaret"; import { columnModeExtension } from "./columnMode"; import { tabKeymap } from "./smartKeys"; import { editorBaseKeymap } from "./baseKeymap"; +import { occurrenceSearch } from "./editing"; +import { gutterLineDrag } from "./gutterDrag"; +import { overwriteField } from "./overwrite"; import { clipboardHistoryExtension } from "../clipboardHistory"; import type { LanguageInfo } from "../backend"; @@ -290,6 +293,9 @@ export function baseExtensions(o: SetupOptions): Extension { EditorState.allowMultipleSelections.of(true), multiCaretMouse, columnModeExtension, + occurrenceSearch, + overwriteField, + gutterLineDrag, clipboardHistoryExtension, indentOnInput(), syntaxHighlighting(openideaHighlightStyle), diff --git a/src/editor/core/cm/smartKeys.ts b/src/editor/core/cm/smartKeys.ts index 870865c9..fd0caaac 100644 --- a/src/editor/core/cm/smartKeys.ts +++ b/src/editor/core/cm/smartKeys.ts @@ -3,8 +3,8 @@ * 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 { EditorView, keymap, type Command } from "@codemirror/view"; +import { getIndentUnit, getIndentation, indentString, indentUnit, syntaxTree } from "@codemirror/language"; import { indentMore } from "@codemirror/commands"; // --- Tab ------------------------------------------------------------------------------- @@ -142,3 +142,32 @@ export const tabOutOfScope: Command = (view) => { /** Settings › Smart Keys › "Jump outside closing bracket/quote with Tab" (Tab itself is `insertTab`). */ export const tabOut: Extension = tabOutScopes; + +// --- closing quote --------------------------------------------------------------------------- + +/** `quote` at `pos` closes the string literal the caret is in (typed or not, the pair's end). */ +export function closesStringAt(state: EditorState, pos: number, quote: string): boolean { + if (state.sliceDoc(pos, pos + 1) !== quote) return false; + for (let n: ReturnType["resolveInner"]> | null = syntaxTree(state).resolveInner(pos, 1); n; n = n.parent) { + if (n.to > pos + 1) break; + if (n.to === pos + 1 && n.from < pos && /String|Char|Template|Rune/.test(n.type.name)) return true; + } + return false; +} + +/** Typing a quote right before the closing quote of a string literal steps over it, as in IDEA. */ +export const skipClosingQuote = EditorView.inputHandler.of((view, _from, _to, text) => { + if (!/^["'`]$/.test(text) || view.state.readOnly) return false; + const { state } = view; + const sel = state.selection; + if (!sel.ranges.every((r) => r.empty && closesStringAt(state, r.head, text))) return false; + view.dispatch({ + selection: EditorSelection.create( + sel.ranges.map((r) => EditorSelection.cursor(r.head + 1)), + sel.mainIndex, + ), + scrollIntoView: true, + userEvent: "input.type", + }); + return true; +}); diff --git a/src/editor/core/cm/stickyScroll.ts b/src/editor/core/cm/stickyScroll.ts index b1a46bc4..332f736b 100644 --- a/src/editor/core/cm/stickyScroll.ts +++ b/src/editor/core/cm/stickyScroll.ts @@ -2,9 +2,9 @@ * Sticky scroll: the header lines of the scopes enclosing the first visible * line stay pinned at the top (IDEA 2023+ / VS Code). Scopes come from the * backend folding ranges, or from the syntax tree when there are none. - * Clicking a sticky line scrolls to it. + * Clicking a sticky line scrolls to it, the caret where it was clicked. */ -import { Compartment, Facet, type EditorState, type Extension } from "@codemirror/state"; +import { Compartment, EditorSelection, Facet, type EditorState, type Extension } from "@codemirror/state"; import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view"; import { foldNodeProp, syntaxTree } from "@codemirror/language"; import { highlightTree } from "@lezer/highlight"; @@ -80,6 +80,23 @@ function renderLineContent(state: EditorState, lineNo: number, target: HTMLEleme push(end, ""); } +/** Character offset in a rendered sticky line closest to `clientX`. */ +export function offsetAtX(inner: HTMLElement, clientX: number): number { + const range = document.createRange(); + let offset = 0; + const walker = document.createTreeWalker(inner, NodeFilter.SHOW_TEXT); + for (let node = walker.nextNode() as Text | null; node; node = walker.nextNode() as Text | null) { + for (let i = 0; i < node.length; i++) { + range.setStart(node, i); + range.setEnd(node, i + 1); + const r = range.getBoundingClientRect(); + if (clientX < r.left + r.width / 2) return offset + i; + } + offset += node.length; + } + return offset; +} + class StickyScroll { dom: HTMLElement; private lines: number[] = []; @@ -203,14 +220,21 @@ class StickyScroll { } private onMouseDown = (e: MouseEvent) => { + if (e.button !== 0) return; const row = (e.target as HTMLElement).closest(".cm-openidea-stickyLine"); if (!row?.dataset.line) return; e.preventDefault(); - const line = this.view.state.doc.line(Math.min(Number(row.dataset.line), this.view.state.doc.lines)); - const indent = /^\s*/.exec(line.text)![0].length; + // The line number of a sticky line is not a link (IDEA). + const inner = (e.target as HTMLElement).closest(".cm-openidea-stickyText")?.querySelector(".cm-openidea-stickyInner"); + if (!inner) return; + const state = this.view.state; + const line = state.doc.line(Math.min(Number(row.dataset.line), state.doc.lines)); + const offset = offsetAtX(inner, e.clientX); + const rowIndex = [...this.dom.children].indexOf(row); + // The line takes the place of its sticky row, so the text stays under the pointer. this.view.dispatch({ - selection: { anchor: line.from + indent }, - effects: EditorView.scrollIntoView(line.from, { y: "start" }), + selection: EditorSelection.cursor(line.from + Math.min(offset, line.length)), + effects: EditorView.scrollIntoView(line.from, { y: "start", yMargin: Math.max(0, rowIndex) * this.view.defaultLineHeight }), userEvent: "select.sticky", }); this.view.focus(); diff --git a/src/editor/core/controller.test.ts b/src/editor/core/controller.test.ts index 7e2e6799..33e7caae 100644 --- a/src/editor/core/controller.test.ts +++ b/src/editor/core/controller.test.ts @@ -65,6 +65,18 @@ describe("EditorController", () => { expect(openFilePaths(useTabs.getState())).toEqual(["/p/a.ts", "/p/b.ts"]); }); + it("closes a tab by its input key", async () => { + ctrl = new EditorController(createMemoryBackend()); + ctrl.start(); + await ctrl.openFile("/p/a.ts", { focus: false }); + await ctrl.openFile("/p/b.ts", { focus: false }); + const key = Object.values(useTabs.getState().panes) + .flatMap((p) => p.tabs) + .find((t) => t.input.path === "/p/b.ts")!.input.key; + ctrl.closeInput(key); + expect(openFilePaths(useTabs.getState())).toEqual(["/p/a.ts"]); + }); + it("builds IDEA's editor tab menu", async () => { useProject.setState({ project: { name: "p", root: "/p", isGit: true, settings: {} } as never, settings: {} as never }); const ids = ["navigate.selectIn", "project.revealInFinder", "vcs.localHistory", "vcs.commit", "project.copyAbsolutePath", "project.rename", "file.settings"]; diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index 60b1a1f6..ea49d27b 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -1003,6 +1003,10 @@ export class EditorController implements SlotHost { }); } + closeInput(key: string) { + this.apply((s) => closeKey(s, key)); + } + 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 9796bcd6..85dc3479 100644 --- a/src/editor/core/index.ts +++ b/src/editor/core/index.ts @@ -9,9 +9,10 @@ import { registerOverlay, registerStatusItem, } from "@/workbench/contributions"; -import { _provideDocumentAttacher } from "@/editor/api"; +import { _provideCodePreview, _provideDocumentAttacher } from "@/editor/api"; import { editorController } from "./controller"; import { attachDocument } from "./model/attachment"; +import { previewExtensions } from "./cm/preview"; import { registerEditorActions } from "./actions"; import { installColorScheme } from "./colorSchemeRuntime"; import { EditorAreaView } from "./view/EditorArea"; @@ -39,8 +40,10 @@ export function activate(): void | (() => void) { focus: () => ctrl.focus(), openPaths: () => ctrl.openPaths(), closePath: (path) => ctrl.closePath(path), + closeInput: (key) => ctrl.closeInput(key), }); _provideDocumentAttacher((path) => attachDocument(ctrl.manager, path)); + _provideCodePreview((path) => previewExtensions(path, ctrl.manager.settings()?.editor)); const disposers = [ installColorScheme(), provideEditorArea(EditorAreaView), @@ -94,6 +97,7 @@ export function activate(): void | (() => void) { return () => { disposers.reverse().forEach((d) => d()); _provideDocumentAttacher(null); + _provideCodePreview(null); ctrl.stop(); }; } diff --git a/src/editor/core/model/camelHumps.ts b/src/editor/core/model/camelHumps.ts index d38349a0..a1b4e0c4 100644 --- a/src/editor/core/model/camelHumps.ts +++ b/src/editor/core/model/camelHumps.ts @@ -53,3 +53,16 @@ export function humpLeft(text: string, pos: number): number { while (i > 0 && !isHumpBoundary(text, i)) i--; return i; } + +/** + * Word start to the left of `pos` within a line without CamelHumps: the + * whitespace before `pos`, then a run of word characters or of punctuation. + */ +export function wordStartLeft(text: string, pos: number): number { + let i = pos; + while (i > 0 && kindOf(text[i - 1]) === "space") i--; + if (i <= 0) return 0; + const punct = kindOf(text[i - 1]) === "punct"; + while (i > 0 && kindOf(text[i - 1]) !== "space" && (kindOf(text[i - 1]) === "punct") === punct) i--; + return i; +} diff --git a/src/editor/lsp/Popups.module.css b/src/editor/lsp/Popups.module.css index 433e9b40..ca8cf7c3 100644 --- a/src/editor/lsp/Popups.module.css +++ b/src/editor/lsp/Popups.module.css @@ -28,6 +28,60 @@ font-family: var(--font-mono); color: var(--accent); } +.withPreview { + flex-direction: row; + width: auto; + max-width: min(1160px, 94vw); +} +.listMain { + display: flex; + flex-direction: column; + min-width: 0; + flex: none; +} +.mnemonic { + width: 10px; + flex: none; + font-family: var(--font-keys); + font-size: var(--fs-small); + color: var(--fg-subtle); + text-align: right; +} +.previewPanel { + width: 420px; + flex: none; + min-height: 60px; + max-height: 340px; + overflow: auto; + border-left: 1px solid var(--border-popup); + background: var(--bg-popup-inline, var(--bg-elev)); +} +.previewText { + padding: 8px 10px; + font-size: var(--fs-small); + color: var(--fg-subtle); +} +.diff { + margin: 0; + padding: 6px 0; + font-family: var(--openidea-editor-font, var(--font-mono)); + font-size: var(--fs-small); + color: var(--fg-editor); +} +.diffLine { + padding: 0 10px; + white-space: pre; + min-height: 1.4em; +} +.diff-added { + background: color-mix(in srgb, var(--vcs-added) 20%, transparent); +} +.diff-removed { + background: color-mix(in srgb, var(--danger) 18%, transparent); +} +.diff-gap { + color: var(--fg-faint); +} .listBody { display: flex; flex-direction: column; @@ -54,11 +108,15 @@ color: var(--fg-faint); } .label { - flex: none; - max-width: 60%; + flex: 0 1 auto; + min-width: 0; overflow: hidden; text-overflow: ellipsis; } +.label:has(+ .preview) { + flex: none; + max-width: 60%; +} .preview { min-width: 80px; flex: 0 1 auto; @@ -163,6 +221,34 @@ .docScope { display: contents; } +.docFrame { + outline: none; +} +.docWindowFrame { + height: 100%; + display: flex; + flex-direction: column; + overflow: auto; + outline: none; +} +.docWindowFrame .docBody { + flex: 1; +} +.docWindowEmpty { + padding: 16px; + font-size: var(--fs-ui); + color: var(--fg-faint); +} +.docToolbar { + position: sticky; + bottom: 0; + display: flex; + align-items: center; + gap: 2px; + padding: 2px 6px; + border-top: 1px solid var(--border-popup); + background: var(--bg-popup); +} .docBody { padding: 10px 14px; } @@ -195,6 +281,17 @@ white-space: nowrap; color: var(--fg-subtle); } +.defFrame { + display: flex; + flex-direction: column; + min-height: 0; + outline: none; +} +.defCount { + flex: none; + font-size: var(--fs-small); + color: var(--fg-subtle); +} .defBody { overflow: auto; padding: 4px 12px; @@ -283,3 +380,24 @@ overflow: hidden; text-overflow: ellipsis; } + +.reformatGroup { + margin: 0 0 10px; + padding: 0; + border: 0; + display: flex; + flex-direction: column; + gap: 6px; +} +.reformatLegend { + margin-bottom: 6px; + padding: 0; + font-size: var(--fs-small); + color: var(--fg-subtle); +} +.reformatOption { + display: flex; + align-items: center; + gap: 6px; + font-size: var(--fs-ui); +} diff --git a/src/editor/lsp/Popups.test.tsx b/src/editor/lsp/Popups.test.tsx new file mode 100644 index 00000000..98c3aebf --- /dev/null +++ b/src/editor/lsp/Popups.test.tsx @@ -0,0 +1,187 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import type { Location } from "@/bindings"; + +const openFile = vi.fn(async () => undefined); +const show = vi.fn(); +vi.mock("@/workbench/contributions", async (orig) => { + const actual = await orig(); + return { + ...actual, + editorService: () => ({ openFile }), + maybeEditorService: () => ({ openFile }), + toolWindowService: () => ({ show, hide: vi.fn(), toggle: vi.fn(), isVisible: () => false }), + }; +}); + +const { LspPopups, DocumentationWindow } = await import("./Popups"); +const { closePopup, openPopup, useLspPopup } = await import("./popupStore"); +const { useDocWindow, pushHistory, stepHistory, hoverCandidates } = await import("./docs"); + +const loc = (path: string, line: number): Location => ({ + path, + range: { start: { line, col: 4 }, end: { line, col: 7 } }, +}); + +afterEach(() => { + act(() => closePopup()); + vi.clearAllMocks(); +}); + +describe("list popup", () => { + it("numbers rows 1-9 and runs a row by its number", () => { + const run = vi.fn(); + openPopup({ + kind: "list", + anchor: { x: 0, y: 0 }, + title: "Refactor This", + numbered: true, + items: [ + { id: "a", label: "Rename…", run: vi.fn() }, + { id: "b", label: "Inline…", run }, + ], + }); + render(); + expect(screen.getAllByRole("option").map((o) => o.textContent)).toEqual(["1Rename…", "2Inline…"]); + fireEvent.keyDown(screen.getByRole("listbox"), { key: "2" }); + expect(run).toHaveBeenCalledOnce(); + expect(useLspPopup.getState().popup).toBeNull(); + }); + + it("reports the selected row and cleans up on close", () => { + const onActive = [vi.fn(), vi.fn()]; + const onClose = vi.fn(); + openPopup({ + kind: "list", + anchor: { x: 0, y: 0 }, + title: "Expressions", + items: onActive.map((fn, i) => ({ id: `${i}`, label: `e${i}`, run: vi.fn(), onActive: fn })), + onClose, + }); + render(); + expect(onActive[0]).toHaveBeenCalledOnce(); + fireEvent.keyDown(screen.getByRole("listbox"), { key: "ArrowDown" }); + expect(onActive[1]).toHaveBeenCalledOnce(); + act(() => closePopup()); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("pins the results elsewhere from the header button", () => { + const pin = vi.fn(); + openPopup({ + kind: "list", + anchor: { x: 0, y: 0 }, + title: "Choose Declaration of foo (2 found)", + items: [{ id: "a", label: "foo", run: vi.fn() }], + pin: { label: "Open in Find Tool Window", run: pin }, + }); + render(); + fireEvent.click(screen.getByRole("button", { name: "Open in Find Tool Window" })); + expect(pin).toHaveBeenCalledOnce(); + expect(useLspPopup.getState().popup).toBeNull(); + }); + + it("shows the selected row's preview, fetched lazily", async () => { + vi.useFakeTimers(); + try { + const preview = vi.fn(async () => ({ kind: "diff" as const, lines: [{ kind: "added" as const, text: "let x = 1;" }] })); + openPopup({ + kind: "list", + anchor: { x: 0, y: 0 }, + title: "Intentions", + items: [{ id: "a", label: "Introduce local variable", run: vi.fn() }], + preview, + }); + render(); + expect(preview).not.toHaveBeenCalled(); + await act(() => vi.advanceTimersByTimeAsync(200)); + expect(preview).toHaveBeenCalledOnce(); + expect(screen.getByLabelText("Preview").textContent).toBe("let x = 1;"); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("documentation", () => { + it("history helpers drop forward entries and clamp steps", () => { + const e = (v: string) => ({ markup: { kind: "plaintext" as const, value: v }, language: null }); + const h = pushHistory({ entries: [e("a"), e("b"), e("c")], index: 0 }, e("d")); + expect(h.entries.map((x) => x.markup.value)).toEqual(["a", "d"]); + expect(h.index).toBe(1); + expect(stepHistory(h, 1)).toBe(1); + expect(stepHistory(h, -1)).toBe(0); + }); + + it("asks about the declared name when a link points at the start of a declaration", () => { + expect(hoverCandidates("export class Foo extends Bar {", 0)).toEqual([0, 13, 25]); + }); + + it("walks the popup history with ← / → and opens the source with F4", async () => { + const source = vi.fn(async () => loc("/p/b.ts", 3)); + openPopup({ + kind: "doc", + anchor: { x: 0, y: 0 }, + entries: [ + { markup: { kind: "plaintext", value: "first" }, language: null }, + { markup: { kind: "plaintext", value: "second" }, language: null, source }, + ], + index: 1, + themeClasses: "", + }); + const { container } = render(); + const frame = () => document.querySelector("[data-lsp-doc]")!; + expect(frame().textContent).toContain("second"); + fireEvent.keyDown(frame(), { key: "ArrowLeft" }); + expect(frame().textContent).toContain("first"); + expect(screen.queryByRole("button", { name: "Edit Source" })).toBeNull(); + fireEvent.keyDown(frame(), { key: "ArrowRight" }); + expect(frame().textContent).toContain("second"); + fireEvent.keyDown(frame(), { key: "F4" }); + await vi.waitFor(() => expect(openFile).toHaveBeenCalledWith("/p/b.ts", { selection: { line: 3, col: 4 }, focus: true })); + expect(container).toBeTruthy(); + }); + + it("moves the popup to the Documentation tool window", () => { + openPopup({ + kind: "doc", + anchor: { x: 0, y: 0 }, + entries: [{ markup: { kind: "plaintext", value: "docs" }, language: null }], + index: 0, + themeClasses: "", + }); + render( + <> + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Show in Documentation Tool Window" })); + expect(useLspPopup.getState().popup).toBeNull(); + expect(show).toHaveBeenCalledWith("documentation", true); + expect(useDocWindow.getState().entries).toHaveLength(1); + }); +}); + +describe("quick definition popup", () => { + it("switches between several definitions with ← / → and edits the source with F4", () => { + openPopup({ + kind: "definition", + anchor: { x: 0, y: 0 }, + targets: [ + { location: loc("/p/a.ts", 1), name: "run", firstLine: 0, text: "function run() {}", language: null }, + { location: loc("/p/b.ts", 5), name: "run", firstLine: 4, text: "run(): void;", language: null }, + ], + index: 0, + themeClasses: "", + }); + render(); + const frame = () => screen.getByRole("dialog", { name: "Definition" }).firstElementChild as HTMLElement; + expect(screen.getByText("1/2")).toBeTruthy(); + fireEvent.keyDown(frame(), { key: "ArrowRight" }); + expect(screen.getByText("2/2")).toBeTruthy(); + expect(frame().textContent).toContain("run(): void;"); + fireEvent.keyDown(frame(), { key: "F4" }); + expect(openFile).toHaveBeenCalledWith("/p/b.ts", { selection: { line: 5, col: 4 }, focus: true }); + }); +}); diff --git a/src/editor/lsp/Popups.tsx b/src/editor/lsp/Popups.tsx index 4381de67..bc6aa24a 100644 --- a/src/editor/lsp/Popups.tsx +++ b/src/editor/lsp/Popups.tsx @@ -2,13 +2,15 @@ import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; import clsx from "clsx"; import { - Button, + Checkbox, Dialog, HighlightedText, Icon, + IconButton, Input, List, Popup, + Select, SymbolBadge, fileIconName, fuzzyMatch, @@ -16,7 +18,24 @@ import { } from "@/ui"; import { basename, relativePath, useProject } from "@/platform/project"; import { editorService } from "@/workbench/contributions"; -import { closePopup, useLspPopup, type ListPopupItem, type LspPopup } from "./popupStore"; +import { + closePopup, + updatePopup, + useLspPopup, + type ListPopupItem, + type ListPreview, + type LspPopup, +} from "./popupStore"; +import { shortcutLabel } from "@/platform/keymap"; +import { + docLinkHandler, + docStep, + editDocSource, + moveDocPopupToWindow, + useDocWindow, + type DocHistory, + type DocHost, +} from "./docs"; import { renderCode, renderMarkup } from "./markdown"; import { describeSummary } from "./workspaceEdit"; import { useUsagesStatus } from "./navigation"; @@ -33,6 +52,27 @@ function ItemIcon({ item }: { item: ListPopupItem }) { return ; } +function ListPreviewPanel({ preview }: { preview: ListPreview | null | "loading" }) { + if (preview === "loading") return
; + if (!preview) return null; + return ( +
+ {preview.kind === "text" ? ( +
{preview.text}
+ ) : ( +
+          {preview.lines.map((l, i) => (
+            
+ {l.kind === "gap" ? "⋯" : l.text || " "} +
+ ))} + {preview.note &&
{preview.note}
} +
+ )} +
+ ); +} + function ListPopup({ popup }: { popup: Extract }) { const [query, setQuery] = useState(""); const filtered = useMemo(() => { @@ -45,6 +85,30 @@ function ListPopup({ popup }: { popup: Extract }) { ); const [active, setActive] = useState(firstEnabled); const activeIndex = Math.min(active, Math.max(0, filtered.length - 1)); + const activeItem = filtered[activeIndex]?.item; + + // Choosers highlight what the selected row would act on. + useEffect(() => { + activeItem?.onActive?.(); + }, [activeItem]); + + const [preview, setPreview] = useState<{ id: string; data: ListPreview | null } | null>(null); + const fetchPreview = popup.preview; + useEffect(() => { + if (!fetchPreview || !activeItem || activeItem.disabled) return; + const ctrl = new AbortController(); + // Moving quickly through the list must not queue a resolve per row. + const timer = setTimeout(() => { + fetchPreview(activeItem, ctrl.signal).then( + (data) => !ctrl.signal.aborted && setPreview({ id: activeItem.id, data }), + () => !ctrl.signal.aborted && setPreview({ id: activeItem.id, data: null }), + ); + }, 120); + return () => { + clearTimeout(timer); + ctrl.abort(); + }; + }, [activeItem, fetchPreview]); const activate = (item: ListPopupItem) => { if (item.disabled) return; @@ -54,7 +118,11 @@ function ListPopup({ popup }: { popup: Extract }) { const onKeyDown = (e: KeyboardEvent) => { if (e.metaKey || e.ctrlKey || e.altKey) return; - if (e.key === "Backspace" && query) { + if (popup.numbered && !query && /^[1-9]$/.test(e.key)) { + const item = popup.items[Number(e.key) - 1]; + e.preventDefault(); + if (item) activate(item); + } else if (e.key === "Backspace" && query) { setQuery(query.slice(0, -1)); setActive(0); e.preventDefault(); @@ -69,55 +137,82 @@ function ListPopup({ popup }: { popup: Extract }) { // Rows are virtualized (absolutely positioned), so size the popup from the content. const width = useMemo(() => { const chars = Math.max( - popup.title.length, + popup.title.length + (popup.pin ? 4 : 0), ...popup.items.map( (i) => i.label.length + (i.preview?.length ?? 0) + (i.detail?.length ?? 0) + (i.shortcut?.length ?? 0), ), ); - return Math.min(720, Math.max(320, chars * 7 + 90)); + return Math.min(720, Math.max(320, chars * 7 + 90 + (popup.numbered ? 16 : 0))); }, [popup]); + const shownPreview = popup.preview + ? preview && preview.id === activeItem?.id + ? preview.data + : activeItem && !activeItem.disabled + ? ("loading" as const) + : null + : null; + const indexOf = (item: ListPopupItem) => popup.items.indexOf(item); return ( -
- {popup.title} - {query && {query}} -
-
- r.item.id} - activeIndex={activeIndex} - onActiveChange={setActive} - hoverSelects - aria-label={popup.title} - onActivate={(r) => activate(r.item)} - empty={ - - Nothing matches - - } - renderItem={({ item, match }) => ( -
- - - {item.preview && {item.preview}} - - {item.detail && {item.detail}} - {item.shortcut && {item.shortcut}} -
+
+
+ {popup.title} + {query && {query}} + {popup.pin && ( + { + const run = popup.pin!.run; + closePopup(); + run(); + }} + /> )} - /> +
+
+ r.item.id} + activeIndex={activeIndex} + onActiveChange={setActive} + hoverSelects + aria-label={popup.title} + onActivate={(r) => activate(r.item)} + empty={ + + Nothing matches + + } + renderItem={({ item, match }) => { + const n = popup.numbered && !query ? indexOf(item) + 1 : 0; + return ( +
+ {popup.numbered && {n > 0 && n <= 9 ? n : ""}} + + + {item.preview && {item.preview}} + + {item.detail && {item.detail}} + {item.shortcut && {item.shortcut}} +
+ ); + }} + /> +
+ {popup.preview && } ); } @@ -209,25 +304,112 @@ function DomContent({ build, className }: { build: () => HTMLElement; className? return
; } +/** Documentation with its history toolbar; shared by the popup and the Documentation tool window. */ +function DocView({ host, history, themeClasses }: { host: DocHost; history: DocHistory; themeClasses: string }) { + const entry = history.entries[history.index]; + const onKeyDown = (e: KeyboardEvent) => { + if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return; + if (e.key === "ArrowLeft" || e.key === "ArrowRight") { + e.preventDefault(); + docStep(host, e.key === "ArrowLeft" ? -1 : 1); + } else if (e.key === "F4" && entry?.source) { + e.preventDefault(); + void editDocSource(host); + } + }; + const ref = useRef(null); + return ( +
+ {entry && ( + renderMarkup(entry.markup, entry.language, docLinkHandler(host))} + /> + )} +
+ docStep(host, -1)} + /> + = history.entries.length - 1} + onClick={() => docStep(host, 1)} + /> + + {entry?.source && ( + void editDocSource(host)} /> + )} + {host === "popup" && ( + moveDocPopupToWindow()} + /> + )} +
+
+ ); +} + 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 }); + if (focusSeq !== opened.current) scope.current?.querySelector("[data-lsp-doc]")?.focus({ preventScroll: true }); }, [focusSeq]); return ( - -
- renderMarkup(popup.markup, popup.language)} /> + +
+
); } +/** The Documentation tool window (F1 on a focused documentation popup). */ +export function DocumentationWindow() { + const history = useDocWindow(); + if (!history.entries.length) { + return ( +
+ No documentation +
+ ); + } + return ; +} + function DefinitionPopup({ popup }: { popup: Extract }) { const root = useProject((s) => s.project?.root ?? ""); - const { location } = popup; + const target = popup.targets[popup.index]!; + const { location } = target; + const many = popup.targets.length > 1; const open = () => { closePopup(); void editorService().openFile(location.path, { @@ -235,21 +417,56 @@ function DefinitionPopup({ popup }: { popup: Extract + updatePopup("definition", { index: (index + popup.targets.length) % popup.targets.length }); + const onKeyDown = (e: KeyboardEvent) => { + if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return; + if (many && (e.key === "ArrowLeft" || e.key === "ArrowRight")) { + e.preventDefault(); + select(popup.index + (e.key === "ArrowLeft" ? -1 : 1)); + } else if (e.key === "F4") { + e.preventDefault(); + open(); + } + }; return ( -
- - {basename(location.path)} - - {relativePath(root, location.path)}:{location.range.start.line + 1} - - - -
-
- renderCode(popup.text, popup.language, "cm-lsp-code")} /> +
+
+ + {many ? ( + setSelectionOnly(false)} /> + Whole file + + + +
+ + Options + + +
+ + ); +} + /** Status bar text while ⌘⇧F7 usages are highlighted ("3 usages of 'x' found…"). */ export function UsagesStatus() { const text = useUsagesStatus((s) => s.text); @@ -353,5 +627,7 @@ export function LspPopups() { return ; case "preview": return ; + case "reformat": + return ; } } diff --git a/src/editor/lsp/actions.ts b/src/editor/lsp/actions.ts index dff9b73c..38cbadc4 100644 --- a/src/editor/lsp/actions.ts +++ b/src/editor/lsp/actions.ts @@ -13,7 +13,7 @@ import { gotoDiagnostic } from "./diagnostics"; import { applyKindSilently, runKindAction, showIntentions } from "./intentions"; import { workflowSettings, type ActionsOnSaveValid } from "@/platform/workflowSettings"; import { startRename } from "./rename"; -import { formatDocument } from "./format"; +import { formatDocument, reformatDialog } from "./format"; import { showHint } from "./hint"; import { refactorThis, runRefactoring, safeDelete } from "@/editor/refactor"; @@ -114,6 +114,9 @@ export function registerLspActions(): () => void { editorAction("code.reformat", "Reformat Code", "Code", reformat, { keywords: ["format", "prettier"], }), + editorAction("code.reformatDialog", "Reformat File…", "Code", (v) => + reformatDialog(v, (view) => applyKindSilently(view, "source.organizeImports")), + ), editorAction("code.optimizeImports", "Optimize Imports", "Code", optimizeImports, { keywords: ["organize imports"] }), // Project view / editor tab variants: `{ path }` of a file, opened first. { id: "code.reformatFile", title: "Reformat File", category: "Code", hidden: true, when: "projectOpen", run: inFile(reformat) }, diff --git a/src/editor/lsp/codeLens.test.ts b/src/editor/lsp/codeLens.test.ts new file mode 100644 index 00000000..d9f45043 --- /dev/null +++ b/src/editor/lsp/codeLens.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import type { OutlineItem } from "@/platform/ipc"; +import { lensTargets } from "./codeLens"; + +const item = (name: string, kind: OutlineItem["kind"], line: number, children: OutlineItem[] = []): OutlineItem => { + const r = { start: { line, col: 0 }, end: { line, col: 1 } }; + return { name, kind, detail: null, range: r, selection_range: r, children }; +}; + +describe("usages lens targets", () => { + it("skips callbacks and anonymous functions", () => { + const items = [ + item("Cart", "class", 0, [ + item("count", "method", 1, [item("reduce() callback", "function", 2)]), + item("", "function", 3), + ]), + ]; + expect(lensTargets(items).map((t) => t.name)).toEqual(["Cart", "count"]); + }); +}); diff --git a/src/editor/lsp/codeLens.ts b/src/editor/lsp/codeLens.ts index abcfb84f..70a33266 100644 --- a/src/editor/lsp/codeLens.ts +++ b/src/editor/lsp/codeLens.ts @@ -40,12 +40,15 @@ export interface LensTarget { col: number; } +/** Servers name callbacks and anonymous functions (``, `map() callback`); they have no usages. */ +const anonymous = (name: string) => /^<.*>$/.test(name) || / callback$/.test(name); + /** Declarations worth a usages lens, in document order. Exported for tests. */ export function lensTargets(items: readonly OutlineItem[]): LensTarget[] { const out: LensTarget[] = []; const walk = (list: readonly OutlineItem[]) => { for (const it of list) { - if (LENS_KINDS.has(it.kind)) out.push({ name: it.name, line: it.selection_range.start.line, col: it.selection_range.start.col }); + if (LENS_KINDS.has(it.kind) && !anonymous(it.name)) out.push({ name: it.name, line: it.selection_range.start.line, col: it.selection_range.start.col }); if (it.children.length) walk(it.children); } }; diff --git a/src/editor/lsp/completion.ts b/src/editor/lsp/completion.ts index 964e4dca..f557e100 100644 --- a/src/editor/lsp/completion.ts +++ b/src/editor/lsp/completion.ts @@ -351,10 +351,27 @@ export function matchesCase(label: string, typed: string, mode: string): boolean return i >= typed.length; } +/** + * A source another feature restricts the lookup to, via the + * `openideaExclusiveCompletion` language-data key (`(state) => source | null`; + * ⌘J's live templates). + */ +function exclusiveSource(ctx: CompletionContext): CompletionSource | null { + type Provider = (state: CompletionContext["state"]) => CompletionSource | null; + for (const f of ctx.state.languageDataAt("openideaExclusiveCompletion", ctx.pos)) { + const source = f(ctx.state); + if (source) return source; + } + return null; +} + /** Show suggestions as you type: sources only answer explicit requests when it is off. */ function gated(source: CompletionSource): CompletionSource { - return (ctx) => - !ctx.explicit && (!editorPrefs().completion.autoPopup || inplaceRenameActive(ctx.state)) ? null : source(ctx); + return (ctx) => { + const only = exclusiveSource(ctx); + if (only) return source === contributedSource ? only(ctx) : null; + return !ctx.explicit && (!editorPrefs().completion.autoPopup || inplaceRenameActive(ctx.state)) ? null : source(ctx); + }; } // --------------------------------------------------------------------------- diff --git a/src/editor/lsp/docs.ts b/src/editor/lsp/docs.ts new file mode 100644 index 00000000..eb0b04aa --- /dev/null +++ b/src/editor/lsp/docs.ts @@ -0,0 +1,163 @@ +/** + * Quick Documentation navigation, as in IDEA: links to other symbols open + * their documentation in place (⌘-click opens the source instead), ← / → + * walk the history, Edit Source (F4) jumps to the declaration, and F1 on a + * focused popup moves it to the Documentation tool window, which then shows + * F1's documentation instead of a popup while it is open. + */ +import { create } from "zustand"; +import { commands, nextRequestId, type Location } from "@/platform/ipc"; +import { hasPrimaryModifier } from "@/platform/keymap"; +import { editorService, maybeEditorService, toolWindowService } from "@/workbench/contributions"; +import { closePopup, updatePopup, useLspPopup, type DocEntry } from "./popupStore"; +import { isEmptyMarkup, languageForPath, sourceLink, type DocLinkHandler } from "./markdown"; +import { lspRequest } from "./lsp"; +import { readFileLines, withDocument } from "./files"; + +export const DOCUMENTATION_WINDOW = "documentation"; + +export interface DocHistory { + entries: DocEntry[]; + index: number; +} + +/** History after following a link from the shown entry: forward entries are dropped. */ +export function pushHistory(h: DocHistory, entry: DocEntry): DocHistory { + return { entries: [...h.entries.slice(0, h.index + 1), entry], index: h.index + 1 }; +} + +/** History index after ← (-1) / → (+1); unchanged at the ends. */ +export function stepHistory(h: DocHistory, delta: -1 | 1): number { + return Math.max(0, Math.min(h.entries.length - 1, h.index + delta)); +} + +// --------------------------------------------------------------------------- +// Documentation tool window + +interface DocWindowState extends DocHistory { + themeClasses: string; +} + +export const useDocWindow = create(() => ({ entries: [], index: 0, themeClasses: "" })); + +export function docWindowVisible(): boolean { + try { + return toolWindowService().isVisible(DOCUMENTATION_WINDOW); + } catch { + return false; + } +} + +/** Shows `entry` in the Documentation tool window (a new history step). */ +export function showInDocWindow(entry: DocEntry, themeClasses: string) { + useDocWindow.setState((s) => ({ ...pushHistory(s, entry), themeClasses: themeClasses || s.themeClasses })); + toolWindowService().show(DOCUMENTATION_WINDOW, false); +} + +/** F1 on a focused documentation popup: the popup's history moves to the tool window. */ +export function moveDocPopupToWindow(): boolean { + const popup = useLspPopup.getState().popup; + if (popup?.kind !== "doc") return false; + useDocWindow.setState({ entries: popup.entries, index: popup.index, themeClasses: popup.themeClasses }); + closePopup(); + toolWindowService().show(DOCUMENTATION_WINDOW, true); + return true; +} + +// --------------------------------------------------------------------------- +// Shared by the popup and the tool window + +/** Where a doc view keeps its history: the popup or the tool window. */ +export type DocHost = "popup" | "window"; + +function history(host: DocHost): DocHistory | null { + if (host === "window") return useDocWindow.getState(); + const p = useLspPopup.getState().popup; + return p?.kind === "doc" ? p : null; +} + +function setHistory(host: DocHost, h: DocHistory) { + if (host === "window") useDocWindow.setState(h); + else updatePopup("doc", h); +} + +export function docStep(host: DocHost, delta: -1 | 1) { + const h = history(host); + if (h) setHistory(host, { entries: h.entries, index: stepHistory(h, delta) }); +} + +/** F4 / Edit Source: the documented element's declaration. */ +export async function editDocSource(host: DocHost): Promise { + const h = history(host); + const entry = h?.entries[h.index]; + const loc = (await entry?.source?.().catch(() => null)) ?? null; + if (!loc) return false; + if (host === "popup") closePopup(); + await editorService().openFile(loc.path, { selection: { line: loc.range.start.line, col: loc.range.start.col }, focus: true }); + return true; +} + +const DECLARATION_KEYWORDS = + /^(export|default|declare|abstract|public|private|protected|internal|static|final|async|const|let|var|val|function|fun|fn|func|def|class|interface|struct|enum|trait|type|impl|pub|mod|object|record|override|readonly|open|data|sealed|extends|implements)$/; + +/** + * Positions on the linked line worth asking the server about: the link's own + * column, then the identifiers after declaration keywords (links often point + * at the start of `export class Foo`). + */ +export function hoverCandidates(lineText: string, col: number): number[] { + const out = [col]; + const re = /[A-Za-z_$][\w$]*/g; + for (let m = re.exec(lineText); m && out.length < 5; m = re.exec(lineText)) { + if (m.index < col || DECLARATION_KEYWORDS.test(m[0])) continue; + if (!out.includes(m.index)) out.push(m.index); + } + return out; +} + +async function docAt(target: { path: string; line: number; col: number }): Promise { + const line = (await readFileLines(target.path))?.[target.line] ?? ""; + return withDocument(target.path, async (doc) => { + for (const col of hoverCandidates(line, target.col)) { + const hover = await lspRequest(() => commands.lspHover(nextRequestId(), doc, { line: target.line, col })).catch( + () => null, + ); + if (hover && !isEmptyMarkup(hover.contents)) { + const location: Location = { + path: target.path, + range: { start: { line: target.line, col }, end: { line: target.line, col } }, + }; + return { markup: hover.contents, language: languageForPath(target.path), source: async () => location }; + } + } + return null; + }); +} + +/** + * Link clicks in a documentation view: a source link shows that symbol's + * documentation in the same view (⌘-click opens the source instead). + */ +export function docLinkHandler(host: DocHost): DocLinkHandler { + return (href, e) => { + const target = sourceLink(href); + if (!target || hasPrimaryModifier(e)) { + if (host === "popup" && target) closePopup(); + return false; + } + void (async () => { + const entry = await docAt(target).catch(() => null); + const h = history(host); + if (!entry || !h) { + if (host === "popup") closePopup(); + void maybeEditorService()?.openFile(target.path, { + selection: { line: target.line, col: target.col }, + focus: true, + }); + return; + } + setHistory(host, pushHistory(h, entry)); + })(); + return true; + }; +} diff --git a/src/editor/lsp/features.test.ts b/src/editor/lsp/features.test.ts index acd5c67a..1f40be53 100644 --- a/src/editor/lsp/features.test.ts +++ b/src/editor/lsp/features.test.ts @@ -7,6 +7,7 @@ import { splitSignature } from "./signature"; import { dedupeLocations, usagesStatusText } from "./navigation"; import { declarationSummary } from "./markdown"; import { linkedSpans } from "./rename"; +import { definitionSpan } from "./hover"; describe("semanticRanges", () => { const doc = Text.of(["let mut count = Foo::new();", "const MAX: u32 = 1;"]); @@ -120,3 +121,17 @@ describe("linkedSpans", () => { ]); }); }); + +describe("definitionSpan", () => { + const loc = { path: "/a.ts", range: { start: { line: 40, col: 9 }, end: { line: 40, col: 12 } } }; + const range = (a: number, b: number) => ({ start: { line: a, col: 0 }, end: { line: b, col: 1 } }); + + it("shows the whole enclosing declaration when the syntax tree knows it", () => { + expect(definitionSpan(loc, 500, range(38, 120))).toEqual({ first: 38, last: 120 }); + }); + + it("falls back to a window around the name otherwise", () => { + expect(definitionSpan(loc, 500, null)).toEqual({ first: 39, last: 52 }); + expect(definitionSpan(loc, 500, range(0, 10))).toEqual({ first: 39, last: 52 }); + }); +}); diff --git a/src/editor/lsp/files.ts b/src/editor/lsp/files.ts index 4eb574fa..692ccfc0 100644 --- a/src/editor/lsp/files.ts +++ b/src/editor/lsp/files.ts @@ -1,4 +1,4 @@ -/** Reading other files' text for previews (definitions, choosers). */ +/** Reading other files' text for previews (definitions, choosers), and backend documents for them. */ import { editorsFor } from "@/editor/api"; import { call, commands, isTauri } from "@/platform/ipc"; import { isVirtualUri, virtualDocument } from "@/platform/virtualDocs"; @@ -34,3 +34,30 @@ export async function readManyFileLines(paths: readonly string[], concurrency = await Promise.all(Array.from({ length: Math.min(concurrency, queue.length) }, worker)); return out; } + +/** + * Runs `fn` with a backend document id for `path` (an open editor's when there + * is one; otherwise opened for the call only, the backend ref-counts it). + */ +export async function withDocument(path: string, fn: (doc: number) => Promise): Promise { + const open = editorsFor(path)[0]; + if (open) return fn(open.ctx.doc); + const info = await call(isVirtualUri(path) ? commands.docOpenVirtual(path) : commands.docOpen(path)); + try { + return await fn(info.id); + } finally { + void commands.docClose(info.id); + } +} + +/** The identifier at (or starting right after) `col` of `line`; null when there is none. */ +export function identifierAt(line: string, col: number): string | null { + const re = /[\p{L}\p{N}_$]+/gu; + for (let m = re.exec(line); m; m = re.exec(line)) { + const end = m.index + m[0].length; + if (end < col) continue; + if (m.index > col + 1) return null; + return /^\p{N}/u.test(m[0]) ? null : m[0]; + } + return null; +} diff --git a/src/editor/lsp/format.test.ts b/src/editor/lsp/format.test.ts new file mode 100644 index 00000000..8f604b79 --- /dev/null +++ b/src/editor/lsp/format.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { Text } from "@codemirror/state"; +import { formattedLines } from "./format"; + +describe("formattedLines", () => { + const doc = Text.of(["fn main(){", "let x=1;", "}", "", "fn other() {}"]); + const lineStart = (n: number) => doc.line(n).from; + + it("counts the lines of the result the changes touched", () => { + expect( + formattedLines(doc, [ + { from: lineStart(1) + 2, to: lineStart(1) + 4, insert: " " }, + { from: lineStart(1) + 10, to: lineStart(1) + 10, insert: " " }, + { from: lineStart(2), to: lineStart(2), insert: " " }, + ]), + ).toBe(2); + }); + + it("numbers lines as in the new text when a change adds lines", () => { + // `{}` split over two lines, then a change on (old) line 5 = new line 6. + expect( + formattedLines(doc, [ + { from: lineStart(5) + 11, to: lineStart(5) + 12, insert: "{\n" }, + { from: lineStart(1), to: lineStart(1) + 1, insert: "f" }, + ]), + ).toBe(3); + }); +}); diff --git a/src/editor/lsp/format.ts b/src/editor/lsp/format.ts index 3399a583..01276154 100644 --- a/src/editor/lsp/format.ts +++ b/src/editor/lsp/format.ts @@ -1,28 +1,79 @@ /** * Reformat Code (⌥⌘L): `lsp_format` for the selection (or the whole file), * applied locally as one CodeMirror transaction and one undo step; editor/core - * syncs it to the backend like any user edit. + * syncs it to the backend like any user edit. As in IDEA an explicit reformat + * reports "Formatted N lines" with a link to the Reformat File dialog + * (⌥⇧⌘L: scope and Optimize Imports). */ import { indentUnit } from "@codemirror/language"; import { isolateHistory } from "@codemirror/commands"; +import type { Text } from "@codemirror/state"; import type { EditorView } from "@codemirror/view"; import { commands } from "@/platform/ipc"; +import { shortcutLabel } from "@/platform/keymap"; +import { executeAction } from "@/platform/actions"; import { CONTRIBUTED_FORMAT, contributedAt, whenSynced, type ContributedFormatter } from "@/editor/api"; import { docOf, lspRequest, reportLspError } from "./lsp"; import { offsetToPos, textEditsToChanges } from "./positions"; import { showHint } from "./hint"; +import { openPopup, type ReformatOptions } from "./popupStore"; export interface FormatResult { applied: boolean; /** Number of changed regions. */ changes: number; + /** Lines of the result that the changes touched. */ + lines: number; } -export async function formatDocument(view: EditorView, opts: { explicit: boolean; selectionOnly?: boolean } = { explicit: true }): Promise { +const NOTHING: FormatResult = { applied: false, changes: 0, lines: 0 }; + +/** Lines of the new document touched by `changes` (given against `doc`), as IDEA counts "Formatted N lines". */ +export function formattedLines(doc: Text, changes: readonly { from: number; to: number; insert: string }[]): number { + const touched = new Set(); + // Line shift of the changes so far, to number lines as in the new text. + let shift = 0; + for (const c of [...changes].sort((a, b) => a.from - b.from)) { + const startLine = doc.lineAt(c.from).number + shift; + const insertedLines = c.insert.split("\n").length - 1; + for (let n = startLine; n <= startLine + insertedLines; n++) touched.add(n); + shift += insertedLines - (doc.lineAt(c.to).number - doc.lineAt(c.from).number); + } + return touched.size; +} + +function formattedHint(view: EditorView, lines: number) { + const keys = shortcutLabel("code.reformatDialog"); + showHint(view, () => { + const box = document.createElement("div"); + box.textContent = `Formatted ${lines} ${lines === 1 ? "line" : "lines"}`; + const more = document.createElement("div"); + const link = document.createElement("button"); + link.type = "button"; + link.className = "cm-lsp-link-button"; + link.textContent = "Show"; + link.addEventListener("mousedown", (e) => e.preventDefault()); + link.addEventListener("click", () => void executeAction("code.reformatDialog")); + more.append(link, ` the Reformat dialog${keys ? `: ${keys}` : ""}`); + box.appendChild(more); + return box; + }); +} + +export async function formatDocument( + view: EditorView, + opts: { explicit: boolean; selectionOnly?: boolean } = { explicit: true }, +): Promise { + const result = await format(view, opts); + if (opts.explicit && result.applied) formattedHint(view, result.lines); + return result; +} + +async function format(view: EditorView, opts: { explicit: boolean; selectionOnly?: boolean }): Promise { const own = contributedAt(view.state, CONTRIBUTED_FORMAT, 0); if (own) return applyContributedFormat(view, own, opts); const doc = docOf(view); - if (!doc || doc.readOnly) return { applied: false, changes: 0 }; + if (!doc || doc.readOnly) return NOTHING; // The server formats the backend's copy: let pending keystrokes reach it first. await whenSynced(view); const state = view.state; @@ -35,16 +86,16 @@ export async function formatDocument(view: EditorView, opts: { explicit: boolean edits = await lspRequest(() => commands.lspFormat(doc.doc, range, options)); } catch (e) { reportLspError(view, e, opts.explicit); - return { applied: false, changes: 0 }; + return NOTHING; } if (view.state.doc !== state.doc) { if (opts.explicit) showHint(view, "The file changed while formatting; try again"); - return { applied: false, changes: 0 }; + return NOTHING; } const changes = textEditsToChanges(state.doc, edits); if (!changes.length) { - if (opts.explicit) showHint(view, useSelection ? "The selection is already formatted" : "The code is already formatted"); - return { applied: false, changes: 0 }; + if (opts.explicit) showHint(view, "No lines changed: content is already properly formatted"); + return NOTHING; } view.dispatch({ changes, @@ -52,7 +103,7 @@ export async function formatDocument(view: EditorView, opts: { explicit: boolean annotations: isolateHistory.of("full"), scrollIntoView: false, }); - return { applied: true, changes: changes.length }; + return { applied: true, changes: changes.length, lines: formattedLines(state.doc, changes) }; } function applyContributedFormat( @@ -61,16 +112,50 @@ function applyContributedFormat( opts: { explicit: boolean; selectionOnly?: boolean }, ): FormatResult { const state = view.state; - if (state.readOnly) return { applied: false, changes: 0 }; + if (state.readOnly) return NOTHING; const sel = state.selection.main; const useSelection = (opts.selectionOnly ?? true) && !sel.empty; const changes = format(state, useSelection ? { from: sel.from, to: sel.to } : null).filter( (c) => state.sliceDoc(c.from, c.to) !== c.insert, ); if (!changes.length) { - if (opts.explicit) showHint(view, useSelection ? "The selection is already formatted" : "The code is already formatted"); - return { applied: false, changes: 0 }; + if (opts.explicit) showHint(view, "No lines changed: content is already properly formatted"); + return NOTHING; } view.dispatch({ changes, userEvent: "format", annotations: isolateHistory.of("full"), scrollIntoView: false }); - return { applied: true, changes: changes.length }; + return { applied: true, changes: changes.length, lines: formattedLines(state.doc, changes) }; +} + +/** The dialog's last choices, as IDEA remembers them. */ +let lastOptions: ReformatOptions = { selectionOnly: true, optimizeImports: false }; + +/** + * Reformat File… (⌥⇧⌘L): asks for the scope and options, then optimizes + * imports (when chosen) and reformats. + */ +export async function reformatDialog( + view: EditorView, + optimizeImports: (view: EditorView) => Promise = async () => {}, +): Promise { + const ctx = docOf(view); + if (!ctx || ctx.readOnly) { + if (ctx?.readOnly) showHint(view, "The file is read-only"); + return; + } + const sel = view.state.selection.main; + const name = ctx.path.slice(ctx.path.lastIndexOf("/") + 1); + openPopup({ + kind: "reformat", + title: `Reformat File: ${name}`, + hasSelection: !sel.empty, + canOptimizeImports: !contributedAt(view.state, CONTRIBUTED_FORMAT, 0), + initial: lastOptions, + onRun: (options) => + void (async () => { + lastOptions = { ...lastOptions, ...options, selectionOnly: sel.empty ? lastOptions.selectionOnly : options.selectionOnly }; + view.focus(); + if (options.optimizeImports) await optimizeImports(view); + await formatDocument(view, { explicit: true, selectionOnly: options.selectionOnly }); + })(), + }); } diff --git a/src/editor/lsp/hover.ts b/src/editor/lsp/hover.ts index 30f25db8..11ed5a94 100644 --- a/src/editor/lsp/hover.ts +++ b/src/editor/lsp/hover.ts @@ -1,11 +1,11 @@ /** * Hover tooltip (500ms): diagnostics under the pointer + quick documentation, - * plus the explicit actions code.quickDocumentation (F1, sticky popup) and - * code.quickDefinition (⌥Space, definition source popup). + * plus the explicit actions code.quickDocumentation (F1, sticky popup with + * history, `docs.ts`) and code.quickDefinition (⌥Space, definition source popup). */ import { EditorSelection } from "@codemirror/state"; import { hoverTooltip, type EditorView } from "@codemirror/view"; -import { commands, isTauri, nextRequestId, type Hover, type RequestId } from "@/platform/ipc"; +import { commands, isTauri, nextRequestId, type Hover, type Location, type Pos, type Range, type RequestId } from "@/platform/ipc"; import { executeAction } from "@/platform/actions"; import { shortcutLabel } from "@/platform/keymap"; import { diagnosticsAt, type PlacedDiagnostic } from "./diagnostics"; @@ -13,9 +13,12 @@ import { isEmptyMarkup, languageForPath, renderMarkup } from "./markdown"; import { docOf, lspRequest, reportLspError } from "./lsp"; import { offsetToPos, rangeToOffsets } from "./positions"; import { showHint } from "./hint"; -import { caretAnchor, focusPopup, openPopup, useLspPopup } from "./popupStore"; +import { caretAnchor, focusPopup, openPopup, useLspPopup, type DefinitionTarget, type DocEntry } from "./popupStore"; import { selectedLookupDoc, showLookupDocs } from "./completion"; -import { readFileLines } from "./files"; +import { identifierAt, readFileLines, withDocument } from "./files"; +import { docWindowVisible, moveDocPopupToWindow, showInDocWindow } from "./docs"; +import { structure } from "@/editor/structure"; +import { basename } from "@/platform/project"; import { iconNode } from "./iconMarkup"; import { editorPrefs } from "@/platform/editorSettings"; import { CONTRIBUTED_HOVER, contributedAt, type ContributedHover } from "@/editor/api"; @@ -118,13 +121,39 @@ export function hoverExtension() { ); } +/** The documentation popup has the keyboard (F1 pressed twice, or clicked into). */ +function docPopupFocused(): boolean { + return !!(document.activeElement as HTMLElement | null)?.closest?.("[data-lsp-doc]"); +} + +/** Lazily resolved declaration of the element at `pos` (Edit Source). */ +function definitionOf(doc: number, pos: Pos): () => Promise { + let found: Promise | null = null; + return () => + (found ??= lspRequest(() => commands.lspGoto(doc, pos, "definition")).then( + (locs) => locs[0] ?? null, + () => null, + )); +} + +function showDoc(view: EditorView, entry: DocEntry, at: number) { + if (docWindowVisible()) { + showInDocWindow(entry, view.themeClasses); + return; + } + openPopup({ kind: "doc", anchor: caretAnchor(view, at), entries: [entry], index: 0, themeClasses: view.themeClasses }); +} + /** - * 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. + * F1: documentation for the symbol at the caret in a popup (or the + * Documentation tool window while it is open); in an open lookup, for its + * selected item. Again on an open popup: focuses it; once more: moves it to + * the tool window. */ export async function quickDocumentation(view: EditorView) { if (useLspPopup.getState().popup?.kind === "doc") { - focusPopup(); + if (docPopupFocused()) moveDocPopupToWindow(); + else focusPopup(); return; } const pos = view.state.selection.main.head; @@ -133,14 +162,7 @@ export async function quickDocumentation(view: EditorView) { 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, - }); + else showDoc(view, { markup: doc.markup, language: doc.language }, pos); return; } try { @@ -149,13 +171,16 @@ export async function quickDocumentation(view: EditorView) { showHint(view, "No documentation found"); return; } - openPopup({ - kind: "doc", - anchor: caretAnchor(view, hover.range ? rangeToOffsets(view.state.doc, hover.range).from : pos), - markup: hover.contents, - language: docOf(view)?.uiMode ?? null, - themeClasses: view.themeClasses, - }); + const ctx = docOf(view); + showDoc( + view, + { + markup: hover.contents, + language: ctx?.uiMode ?? null, + source: ctx ? definitionOf(ctx.doc, offsetToPos(view.state.doc, pos)) : undefined, + }, + hover.range ? rangeToOffsets(view.state.doc, hover.range).from : pos, + ); } catch (e) { reportLspError(view, e, true); } @@ -163,36 +188,72 @@ export async function quickDocumentation(view: EditorView) { const DEFINITION_CONTEXT_BEFORE = 1; const DEFINITION_MAX_LINES = 30; +/** A declaration taller than this is cut (IDEA scrolls; we keep the popup light). */ +const DECLARATION_MAX_LINES = 400; +const MAX_TARGETS = 20; -/** ⌥Space: the definition's source in a popup, without leaving the editor. */ +/** Line span to show for a definition: its whole declaration when the syntax tree knows it. */ +export function definitionSpan( + loc: Location, + lineCount: number, + declaration: Range | null, +): { first: number; last: number } { + const start = loc.range.start.line; + if (declaration && declaration.start.line <= start && start <= declaration.end.line) { + const first = Math.max(0, declaration.start.line); + return { first, last: Math.min(lineCount - 1, declaration.end.line, first + DECLARATION_MAX_LINES - 1) }; + } + const first = Math.max(0, start - DEFINITION_CONTEXT_BEFORE); + const spanEnd = Math.max(loc.range.end.line, start + 12); + return { first, last: Math.min(lineCount - 1, Math.min(spanEnd, first + DEFINITION_MAX_LINES - 1)) }; +} + +async function declarationRange(loc: Location): Promise { + try { + return await withDocument(loc.path, async (doc) => (await structure.structuralRanges(doc, loc.range.start)).declaration?.range ?? null); + } catch { + return null; + } +} + +async function definitionTarget(loc: Location, fallbackLanguage: string | null): Promise { + const [lines, declaration] = await Promise.all([readFileLines(loc.path), declarationRange(loc)]); + if (!lines) return null; + const { first, last } = definitionSpan(loc, lines.length, declaration); + const name = identifierAt(lines[loc.range.start.line] ?? "", loc.range.start.col) ?? basename(loc.path); + return { + location: loc, + name, + firstLine: first, + text: lines.slice(first, last + 1).join("\n"), + language: languageForPath(loc.path) ?? fallbackLanguage, + }; +} + +/** ⌥Space: the definition's source in a popup, without leaving the editor; ← / → switch between several. */ export async function quickDefinition(view: EditorView) { const doc = docOf(view); if (!doc) return; const pos = view.state.selection.main.head; try { - const locations = await lspRequest(() => commands.lspGoto(doc.doc, offsetToPos(view.state.doc, pos), "definition")); - const loc = locations[0]; - if (!loc) { + const found = await lspRequest(() => commands.lspGoto(doc.doc, offsetToPos(view.state.doc, pos), "definition")); + const seen = new Set(); + const locations = found.filter((l) => { + const key = `${l.path}:${l.range.start.line}:${l.range.start.col}`; + return !seen.has(key) && !!seen.add(key); + }); + if (!locations.length) { showHint(view, "No definition found"); return; } - const lines = await readFileLines(loc.path); - if (!lines) { + const targets = (await Promise.all(locations.slice(0, MAX_TARGETS).map((l) => definitionTarget(l, doc.uiMode)))).filter( + (t): t is DefinitionTarget => !!t, + ); + if (!targets.length) { showHint(view, "Cannot read the definition's file"); return; } - const first = Math.max(0, loc.range.start.line - DEFINITION_CONTEXT_BEFORE); - const spanEnd = Math.max(loc.range.end.line, loc.range.start.line + 12); - const last = Math.min(lines.length - 1, Math.min(spanEnd, first + DEFINITION_MAX_LINES - 1)); - openPopup({ - kind: "definition", - anchor: caretAnchor(view, pos), - location: loc, - firstLine: first, - text: lines.slice(first, last + 1).join("\n"), - language: languageForPath(loc.path) ?? doc.uiMode, - themeClasses: view.themeClasses, - }); + openPopup({ kind: "definition", anchor: caretAnchor(view, pos), targets, index: 0, themeClasses: view.themeClasses }); } catch (e) { reportLspError(view, e, true); } diff --git a/src/editor/lsp/index.ts b/src/editor/lsp/index.ts index 76b567ed..ae2a22ad 100644 --- a/src/editor/lsp/index.ts +++ b/src/editor/lsp/index.ts @@ -7,14 +7,14 @@ */ import type { Extension } from "@codemirror/state"; import { registerEditorContribution, type EditorDocContext } from "@/editor/api"; -import { registerOverlay, registerStatusItem } from "@/workbench/contributions"; +import { registerOverlay, registerStatusItem, registerToolWindow } 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"; +import { parameterTabKeys, signatureExtension } from "./signature"; import { navigationExtension } from "./navigation"; import { intentionsExtension } from "./intentions"; import { inlayExtension } from "./inlay"; @@ -26,7 +26,8 @@ import { registerActionsOnSave, registerFormatOnSave, registerLspActions } from import { lspTheme } from "./theme"; import { inspectionWidgetExtension } from "./inspectionWidget"; import { unresolvedReferences } from "./fileReferences"; -import { LspPopups, UsagesStatus } from "./Popups"; +import { DocumentationWindow, LspPopups, UsagesStatus } from "./Popups"; +import { DOCUMENTATION_WINDOW } from "./docs"; import { prefetchMarkdown } from "./markdown"; import { registerNavigationActions } from "@/editor/navigation"; @@ -56,8 +57,21 @@ export function activate(): () => void { installDiagnosticsEvents(), installAvailabilityEvents(), registerEditorContribution({ id: "lsp", order: 50, extensions: lspExtensions }), + // After the templates (order 90): live template / postfix expansion keeps ⇥ first. + registerEditorContribution({ id: "lsp.parameterTab", order: 95, extensions: () => parameterTabKeys }), registerOverlay({ id: "editor-lsp.popups", component: LspPopups }), registerStatusItem({ id: "editor-lsp.usagesStatus", align: "left", priority: 10, component: UsagesStatus }), + // IDEA's Documentation tool window: right side, listed once F1 moved documentation there. + registerToolWindow({ + id: DOCUMENTATION_WINDOW, + title: "Documentation", + icon: "info", + anchor: "right", + secondary: true, + stripe: "onUse", + order: 90, + component: DocumentationWindow, + }), registerLspActions(), registerNavigationActions(), registerFormatOnSave(), diff --git a/src/editor/lsp/inlay.test.ts b/src/editor/lsp/inlay.test.ts new file mode 100644 index 00000000..1b0deb77 --- /dev/null +++ b/src/editor/lsp/inlay.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EditorState, StateEffect } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import type { InlayHint } from "@/bindings"; + +const openFile = vi.fn(async () => undefined); +vi.mock("@/workbench/contributions", async (orig) => { + const actual = await orig(); + return { ...actual, maybeEditorService: () => ({ openFile }) }; +}); + +const { inlayDecorations, inlayMenuItems } = await import("./inlay"); +const { useContextMenuStore } = await import("@/ui"); + +const hint: InlayHint = { + position: { line: 0, col: 5 }, + label: ": Foo", + kind: "type", + paddingLeft: false, + paddingRight: false, + tooltip: null, + parts: [ + { value: ": ", tooltip: null, location: null }, + { + value: "Foo", + tooltip: "struct Foo", + location: { path: "/p/foo.rs", range: { start: { line: 4, col: 7 }, end: { line: 4, col: 10 } } }, + }, + ], +}; + +const views: EditorView[] = []; +afterEach(() => { + views.splice(0).forEach((v) => v.destroy()); + vi.clearAllMocks(); +}); + +function editorWith(hints: InlayHint[]) { + const view = new EditorView({ state: EditorState.create({ doc: "let x = foo();" }), parent: document.body }); + views.push(view); + view.dispatch({ effects: StateEffect.appendConfig.of(EditorView.decorations.of(inlayDecorations(view, hints))) }); + return view; +} + +describe("inlay hints", () => { + it("renders label parts; ⌘-click on a part with a location navigates there", () => { + const view = editorWith([hint]); + const part = view.dom.querySelector(".cm-lsp-inlay-part")!; + expect(part.textContent).toBe("Foo"); + expect(part.title).toBe("struct Foo"); + expect(view.dom.querySelector(".cm-lsp-inlay")!.textContent).toBe(": Foo"); + part.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 })); + expect(openFile).not.toHaveBeenCalled(); + part.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, metaKey: true, ctrlKey: true })); + expect(openFile).toHaveBeenCalledWith("/p/foo.rs", { selection: { line: 4, col: 7 }, focus: true }); + }); + + it("offers to disable hints of the clicked kind", () => { + const view = editorWith([hint]); + view.dom.querySelector(".cm-lsp-inlay")!.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true })); + const items = useContextMenuStore.getState().menu?.items ?? []; + expect(items.map((i) => ("label" in i ? i.label : i.kind))).toEqual([ + "Disable Type hints", + "separator", + "Inlay Hints Settings…", + ]); + expect((inlayMenuItems("parameter")[0] as { label: string }).label).toBe("Disable Parameter name hints"); + }); +}); diff --git a/src/editor/lsp/inlay.ts b/src/editor/lsp/inlay.ts index d93f51b0..f97ee238 100644 --- a/src/editor/lsp/inlay.ts +++ b/src/editor/lsp/inlay.ts @@ -1,16 +1,42 @@ /** * Inlay hints (settings.editor.inlayHints) for the visible range, refreshed * after edits / scrolling (debounced) and rendered as subtle inline widgets. + * Label parts that name a declaration navigate there on ⌘-click; right-click + * offers to disable hints of that kind. */ import { StateEffect, StateField, type Extension } from "@codemirror/state"; import { Decoration, EditorView, ViewPlugin, WidgetType, type DecorationSet, type ViewUpdate } from "@codemirror/view"; -import { commands, type InlayHint } from "@/platform/ipc"; +import { call, commands, isTauri, type InlayHint } from "@/platform/ipc"; +import { executeAction } from "@/platform/actions"; +import { hasPrimaryModifier } from "@/platform/keymap"; +import { maybeEditorService } from "@/workbench/contributions"; +import { showContextMenu, type MenuItem } from "@/ui"; import { useProject } from "@/platform/project"; import { editorPrefs } from "@/platform/editorSettings"; import { debounce, docOf, isUnavailable, lspRequest } from "./lsp"; import { offsetsToRange, posToOffset } from "./positions"; import { onLspRefresh, serverBackoff } from "./availability"; +const KIND_LABEL: Record = { + type: "Type hints", + parameter: "Parameter name hints", + other: "Other hints", +}; + +async function setEditorSetting(key: string, value: unknown) { + if (!isTauri()) return; + useProject.setState({ settings: await call(commands.settingsSet("global", `editor.${key}`, JSON.stringify(value))) }); +} + +/** Right-click on a hint: turn its kind off (IDEA's "Disable '…' hints"), or open the settings. */ +export function inlayMenuItems(kind: InlayHint["kind"]): MenuItem[] { + return [ + { label: `Disable ${KIND_LABEL[kind]}`, run: () => void setEditorSetting(`inlay.${kind}`, false) }, + { kind: "separator" }, + { label: "Inlay Hints Settings…", run: () => void executeAction("file.settings", "editor.inlayHints") }, + ]; +} + class InlayWidget extends WidgetType { constructor(readonly hint: InlayHint) { super(); @@ -18,16 +44,51 @@ class InlayWidget extends WidgetType { eq(other: InlayWidget) { const a = this.hint, b = other.hint; - return a.label === b.label && a.kind === b.kind && a.paddingLeft === b.paddingLeft && a.paddingRight === b.paddingRight; + return ( + a.label === b.label && + a.kind === b.kind && + a.paddingLeft === b.paddingLeft && + a.paddingRight === b.paddingRight && + (a.parts?.length ?? 0) === (b.parts?.length ?? 0) && + (a.parts ?? []).every((p, i) => p.location?.path === b.parts[i]?.location?.path && p.value === b.parts[i]?.value) + ); } - toDOM() { + toDOM(view: EditorView) { const el = document.createElement("span"); el.className = `cm-lsp-inlay cm-lsp-inlay-${this.hint.kind}`; if (this.hint.paddingLeft) el.classList.add("cm-lsp-inlay-pl"); if (this.hint.paddingRight) el.classList.add("cm-lsp-inlay-pr"); - el.textContent = this.hint.label; + const parts = this.hint.parts ?? []; + if (parts.some((p) => p.location || p.tooltip)) { + for (const part of parts) { + const span = document.createElement("span"); + span.textContent = part.value; + if (part.tooltip) span.title = part.tooltip; + if (part.location) { + span.className = "cm-lsp-inlay-part"; + const loc = part.location; + // ⌘-click (Ctrl elsewhere) navigates to the declaration the part names, as in IDEA. + span.addEventListener("mousedown", (e) => { + if (e.button !== 0 || !hasPrimaryModifier(e)) return; + e.preventDefault(); + e.stopPropagation(); + void maybeEditorService()?.openFile(loc.path, { + selection: { line: loc.range.start.line, col: loc.range.start.col }, + focus: true, + }); + }); + } + el.appendChild(span); + } + } else el.textContent = this.hint.label; if (this.hint.tooltip) el.title = this.hint.tooltip; el.setAttribute("aria-hidden", "true"); + el.addEventListener("contextmenu", (e) => { + e.preventDefault(); + e.stopPropagation(); + view.focus(); + showContextMenu(e, inlayMenuItems(this.hint.kind)); + }); return el; } ignoreEvent() { diff --git a/src/editor/lsp/inplaceRename.test.ts b/src/editor/lsp/inplaceRename.test.ts index 338f41bd..f444cd99 100644 --- a/src/editor/lsp/inplaceRename.test.ts +++ b/src/editor/lsp/inplaceRename.test.ts @@ -2,7 +2,13 @@ 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"; +import { + cycleSuggestion, + endInplaceRename, + inplaceRenameActive, + inplaceRenameExtension, + startInplaceRename, +} from "./inplaceRename"; const views: EditorView[] = []; afterEach(() => views.splice(0).forEach((v) => v.destroy())); @@ -84,4 +90,35 @@ describe("in-place rename edits elsewhere", () => { await Promise.resolve(); expect(commit).toHaveBeenCalledWith("bar"); }); + + it("cycles name suggestions with ↑ / ↓, mirrored and committed like typing", () => { + const view = new EditorView({ + state: EditorState.create({ doc: "let foo = 1; foo + foo;", extensions: [inplaceRenameExtension()] }), + }); + views.push(view); + const commit = vi.fn(); + startInplaceRename(view, { + primary: { from: 4, to: 7 }, + others: [{ from: 13, to: 16 }], + hint: "", + suggestions: ["count", "total"], + commit, + dialog: vi.fn(), + }); + expect(cycleSuggestion(view, 1)).toBe(true); + expect(view.state.doc.toString()).toBe("let count = 1; count + foo;"); + expect(view.state.selection.main).toMatchObject({ from: 4, to: 9 }); + cycleSuggestion(view, 1); + expect(view.state.doc.toString()).toBe("let total = 1; total + foo;"); + cycleSuggestion(view, 1); + expect(view.state.doc.toString()).toBe("let count = 1; count + foo;"); + endInplaceRename(view, "commit"); + expect(commit).toHaveBeenCalledWith("count"); + expect(view.state.doc.toString()).toBe("let foo = 1; foo + foo;"); + }); + + it("leaves the arrows alone without suggestions", () => { + const { view } = session(); + expect(cycleSuggestion(view, 1)).toBe(false); + }); }); diff --git a/src/editor/lsp/inplaceRename.ts b/src/editor/lsp/inplaceRename.ts index 7c1fab39..87f2a16c 100644 --- a/src/editor/lsp/inplaceRename.ts +++ b/src/editor/lsp/inplaceRename.ts @@ -11,6 +11,7 @@ */ 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 { completionStatus } from "@codemirror/autocomplete"; import { isExternalChange } from "@/editor/api"; export interface Span { @@ -30,6 +31,8 @@ export interface InplaceSession { commit: (newName: string) => void; /** ⇧F6 again: the rename dialog, prefilled with the typed name. */ dialog: (typed: string) => void; + /** Name suggestions listed under the name; ↑ / ↓ put the previous / next one in (Introduce Variable). */ + suggestions?: readonly string[]; } const startSession = StateEffect.define(); @@ -63,11 +66,30 @@ const sessionField = StateField.define({ ? { pos: s.primary.from, above: false, - create: () => { + create: (view) => { const dom = document.createElement("div"); dom.className = "cm-lsp-hint cm-lsp-inplace-hint"; dom.setAttribute("role", "status"); - dom.textContent = s.hint; + if (s.suggestions?.length) { + const current = view.state.sliceDoc(s.primary.from, s.primary.to); + const list = document.createElement("ul"); + list.className = "cm-lsp-inplace-suggestions"; + list.setAttribute("aria-label", "Suggested names"); + for (const name of s.suggestions) { + const li = document.createElement("li"); + li.textContent = name; + if (name === current) li.className = "cm-lsp-inplace-suggestion-active"; + li.addEventListener("mousedown", (e) => { + e.preventDefault(); + putSuggestion(view, name); + }); + list.appendChild(li); + } + dom.appendChild(list); + } + const hint = document.createElement("div"); + hint.textContent = s.hint; + dom.appendChild(hint); return { dom }; }, } @@ -117,6 +139,31 @@ export function startInplaceRename(view: EditorView, session: Omit 0 ? 0 : list.length - 1) : (i + delta + list.length) % list.length]!; + return putSuggestion(view, next); +} + /** * Ends the session, putting the old name back everywhere. Returns the name * that was typed (null without a session). `commit` then runs the rename. @@ -167,6 +214,8 @@ const keys = Prec.highest( { 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 }, + { key: "ArrowDown", run: (v) => cycleSuggestion(v, 1) }, + { key: "ArrowUp", run: (v) => cycleSuggestion(v, -1) }, ]), ); diff --git a/src/editor/lsp/intentions.test.ts b/src/editor/lsp/intentions.test.ts new file mode 100644 index 00000000..7f0ae87f --- /dev/null +++ b/src/editor/lsp/intentions.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import type { CodeAction } from "@/bindings"; + +const resolved: CodeAction = { + title: "Introduce local variable", + kind: "refactor.extract", + isPreferred: false, + disabledReason: null, + edit: { + files: [ + { + kind: "edit", + path: "/p/a.ts", + edits: [{ range: { start: { line: 0, col: 0 }, end: { line: 0, col: 0 } }, new_text: "const x = 1;\n" }], + }, + { kind: "create", path: "/p/b.ts", overwrite: false }, + ], + }, + command: null, + data: null, + server: 1, +}; +const lspResolveCodeAction = vi.fn(async () => ({ status: "ok" as const, data: resolved })); +vi.mock("@/platform/ipc", async (orig) => { + const actual = await orig(); + return { ...actual, isTauri: () => true, commands: { ...actual.commands, lspResolveCodeAction } }; +}); + +const { intentionPreview } = await import("./intentions"); +const { lspDoc } = await import("./lsp"); + +const views: EditorView[] = []; +afterEach(() => { + views.splice(0).forEach((v) => v.destroy()); + vi.clearAllMocks(); +}); + +describe("intention preview", () => { + it("resolves the action once and shows its diff for this file", async () => { + const view = new EditorView({ + state: EditorState.create({ + doc: "f(1);", + extensions: [lspDoc.of({ doc: 1, path: "/p/a.ts", language: "typescript", uiMode: null, readOnly: false })], + }), + }); + views.push(view); + const action = { ...resolved, edit: null }; + const cache = new Map>(); + const signal = new AbortController().signal; + const preview = await intentionPreview(view, action, cache, signal); + expect(preview).toEqual({ + kind: "diff", + lines: [ + { kind: "added", text: "const x = 1;" }, + { kind: "context", text: "f(1);" }, + ], + note: "Also changes 1 other file", + }); + await intentionPreview(view, action, cache, signal); + expect(lspResolveCodeAction).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/editor/lsp/intentions.ts b/src/editor/lsp/intentions.ts index 14451360..82c5118c 100644 --- a/src/editor/lsp/intentions.ts +++ b/src/editor/lsp/intentions.ts @@ -12,7 +12,8 @@ import { shortcutLabel } from "@/platform/keymap"; import { debounce, docOf, isUnavailable, lspRequest, reportLspError } from "./lsp"; import { offsetsToRange } from "./positions"; import { showHint } from "./hint"; -import { caretAnchor, openPopup, type ListPopupItem } from "./popupStore"; +import { caretAnchor, openPopup, type ListPopupItem, type ListPreview } from "./popupStore"; +import { editPreviewLines } from "./workspaceEdit"; import { serverBackoff } from "./availability"; import { iconNode } from "./iconMarkup"; import { editorPrefs } from "@/platform/editorSettings"; @@ -121,17 +122,53 @@ export async function applyKindSilently(view: EditorView, kind: string): Promise return true; } -function actionItems(view: EditorView, actions: CodeAction[]): ListPopupItem[] { +function actionItems(view: EditorView, actions: CodeAction[], resolved?: Map>): ListPopupItem[] { return sortCodeActions(actions).map((a, i) => ({ id: `${i}`, label: a.title, icon: isQuickFix(a) ? "bulb-fix" : "bulb", emphasis: a.isPreferred, disabled: a.disabledReason, - run: () => applyCodeAction(view, a), + // The edit the preview already resolved is applied as is. + run: async () => applyCodeAction(view, (await resolved?.get(a)?.catch(() => a)) ?? a), })); } +/** The action with its edit (resolved once per popup; servers may compute edits lazily). */ +function resolveOnce(doc: number, action: CodeAction, cache: Map>): Promise { + let p = cache.get(action); + if (!p) { + p = action.edit || action.command ? Promise.resolve(action) : lspRequest(() => commands.lspResolveCodeAction(doc, action)); + cache.set(action, p); + p.catch(() => cache.delete(action)); + } + return p; +} + +/** Intention preview (IDEA's side panel): the diff the action would make to this file. */ +export async function intentionPreview( + view: EditorView, + action: CodeAction, + cache: Map>, + signal: AbortSignal, +): Promise { + const ctx = docOf(view); + if (!ctx) return null; + const doc = view.state.doc; + const full = await resolveOnce(ctx.doc, action, cache); + if (signal.aborted || view.state.doc !== doc) return null; + const files = full.edit?.files ?? []; + const here = files.find((f) => f.kind === "edit" && f.path === ctx.path); + const others = new Set(files.map((f) => (f.kind === "rename" ? f.from : f.path)).filter((p) => p !== ctx.path)).size; + const note = others ? `Also changes ${others} other ${others === 1 ? "file" : "files"}` : undefined; + if (here?.kind === "edit") { + const lines = editPreviewLines(doc, here.edits); + if (lines.length) return { kind: "diff", lines, note }; + } + if (note) return { kind: "text", text: note }; + return { kind: "text", text: full.command && !full.edit ? "Preview isn't available for this action" : "No changes in this file" }; +} + /** ⌥↵ */ export async function showIntentions(view: EditorView) { try { @@ -141,11 +178,18 @@ export async function showIntentions(view: EditorView) { showHint(view, "No intentions available"); return; } + const sorted = sortCodeActions(actions); + const resolved = new Map>(); + const items = actionItems(view, sorted, resolved); openPopup({ kind: "list", anchor: caretAnchor(view, view.state.selection.main.head), title: "Intentions", - items: actionItems(view, actions), + items, + preview: (item, signal) => { + const action = sorted[items.indexOf(item)]; + return action ? intentionPreview(view, action, resolved, signal) : Promise.resolve(null); + }, }); } catch (e) { reportLspError(view, e, true); diff --git a/src/editor/lsp/markdown.ts b/src/editor/lsp/markdown.ts index b07cf978..fbae9e53 100644 --- a/src/editor/lsp/markdown.ts +++ b/src/editor/lsp/markdown.ts @@ -158,29 +158,57 @@ function highlightBlocks(root: HTMLElement, fallback: string | null) { }); } -function onLinkClick(e: MouseEvent) { - const a = (e.target as Element | null)?.closest?.("a"); - if (!a) return; - e.preventDefault(); - const href = a.getAttribute("href") ?? ""; +/** A documentation link to a source position (`file://…#L12,5`, `jdt://…#L42`); null for web links. */ +export function sourceLink(href: string): { path: string; line: number; col: number } | null { if (href.startsWith("file://")) { - const url = new URL(href); + let url: URL; + try { + url = new URL(href); + } catch { + return null; + } const m = /^#?L?(\d+)(?:[,:](\d+))?/.exec(url.hash.slice(1)); - const selection = m ? { line: Math.max(0, Number(m[1]) - 1), col: m[2] ? Math.max(0, Number(m[2]) - 1) : 0 } : undefined; - void maybeEditorService()?.openFile(decodeURIComponent(url.pathname), { selection, focus: true }); - } else if (isVirtualUri(href) && !/^https?:/i.test(href) && !/^mailto:/i.test(href)) { + return { + path: decodeURIComponent(url.pathname), + line: m ? Math.max(0, Number(m[1]) - 1) : 0, + col: m?.[2] ? Math.max(0, Number(m[2]) - 1) : 0, + }; + } + if (isVirtualUri(href) && !/^https?:/i.test(href) && !/^mailto:/i.test(href)) { // Library class links (jdtls hover: `jdt://contents/…#L42`). const hash = href.indexOf("#"); const m = hash >= 0 ? /^L?(\d+)/.exec(href.slice(hash + 1)) : null; - const selection = m ? { line: Math.max(0, Number(m[1]) - 1), col: 0 } : undefined; - void maybeEditorService()?.openFile(hash >= 0 ? href.slice(0, hash) : href, { selection, focus: true }); + return { path: hash >= 0 ? href.slice(0, hash) : href, line: m ? Math.max(0, Number(m[1]) - 1) : 0, col: 0 }; + } + return null; +} + +/** Opens a documentation link: source links in the editor, web links in the browser. */ +export function openDocLink(href: string) { + const target = sourceLink(href); + if (target) { + void maybeEditorService()?.openFile(target.path, { selection: { line: target.line, col: target.col }, focus: true }); } else if (/^https?:\/\//.test(href) && isTauri()) { void import("@tauri-apps/plugin-opener").then((m) => m.openUrl(href)).catch(() => {}); } } +/** Handles a link click itself (true) or leaves it to `openDocLink` (false). */ +export type DocLinkHandler = (href: string, e: MouseEvent) => boolean; + +function linkClickHandler(onLink?: DocLinkHandler) { + return (e: MouseEvent) => { + const a = (e.target as Element | null)?.closest?.("a"); + if (!a) return; + e.preventDefault(); + const href = a.getAttribute("href") ?? ""; + if (onLink?.(href, e)) return; + openDocLink(href); + }; +} + /** Renders markup into a `.cm-lsp-markdown` element. */ -export function renderMarkup(m: Markup, fallbackLanguage: string | null = null): HTMLElement { +export function renderMarkup(m: Markup, fallbackLanguage: string | null = null, onLink?: DocLinkHandler): HTMLElement { const dom = document.createElement("div"); dom.className = "cm-lsp-markdown"; dom.innerHTML = markupToHtml(m); @@ -193,7 +221,7 @@ export function renderMarkup(m: Markup, fallbackLanguage: string | null = null): }) .catch(() => {}); } - dom.addEventListener("click", onLinkClick); + dom.addEventListener("click", linkClickHandler(onLink)); return dom; } diff --git a/src/editor/lsp/navigation.ts b/src/editor/lsp/navigation.ts index 4b648e22..8d1705a2 100644 --- a/src/editor/lsp/navigation.ts +++ b/src/editor/lsp/navigation.ts @@ -42,7 +42,7 @@ import { offsetToPos, rangeToOffsets } from "./positions"; import { SYNTAX_NOTE, declarationFallback, isSelfTarget, localHighlights, type DeclarationTarget } from "./syntaxFallback"; import { showHint } from "./hint"; import { caretAnchor, openPopup, type ListPopupItem } from "./popupStore"; -import { readManyFileLines } from "./files"; +import { identifierAt, readManyFileLines } from "./files"; import { documentLinks, followReference, knownLinks, linkReference, referenceAt, textReference } from "./links"; import { serverBackoff } from "./availability"; import { requestHover } from "./hover"; @@ -100,10 +100,12 @@ export async function locationItems(locs: readonly DeclarationTarget[]): Promise symbol: l.kind, run: () => navigateTo(l), }; + // Server targets: the declared name (speed search matches it), file and line after it. + const name = identifierAt(files.get(l.path)?.[l.range.start.line] ?? "", l.range.start.col); return { id: `${i}`, - label: basename(l.path), - detail: where, + label: name ?? basename(l.path), + detail: name ? `${basename(l.path)} · ${where}` : where, preview, icon: fileIconName(basename(l.path)), run: () => navigateTo(l), @@ -111,6 +113,19 @@ export async function locationItems(locs: readonly DeclarationTarget[]): Promise }); } +/** "Choose Implementation of foo (3 found)". */ +export function chooserTitle(kind: GotoKind, name: string | null, count: number, fromSyntax = false): string { + return `${TITLES[kind]}${name ? ` of ${name}` : ""} (${count} found${fromSyntax ? ", based on syntax" : ""})`; +} + +/** Pin button of the targets chooser: the targets as a Find tool window tab (code-insight). */ +function pinToFind(symbol: string, locations: readonly Location[]) { + return { + label: "Open in Find Tool Window", + run: () => void executeAction("find.showUsageResults", { symbol, locations }), + }; +} + export async function gotoTargets(view: EditorView, kind: GotoKind, at?: number) { const offset = at ?? view.state.selection.main.head; if (kind === "definition" || kind === "declaration") { @@ -169,11 +184,14 @@ export async function gotoTargets(view: EditorView, kind: GotoKind, at?: number) }, 150); return; } + const word = view.state.wordAt(offset); + const name = word ? view.state.sliceDoc(word.from, word.to) : null; openPopup({ kind: "list", anchor: caretAnchor(view, offset), - title: `${TITLES[kind]} (${locs.length} found${fromSyntax ? ", based on syntax" : ""})`, + title: chooserTitle(kind, name, locs.length, fromSyntax), items: await locationItems(locs), + pin: pinToFind(name ?? "", locs), }); } @@ -207,6 +225,7 @@ async function gotoWebReference(view: EditorView, doc: EditorDocContext, pos: Po anchor: caretAnchor(view, offset), title: `Choose Declaration of '${label}' (${targets.length} found)`, items: await locationItems(targets), + pin: pinToFind(label, targets), }); return true; } diff --git a/src/editor/lsp/popupStore.ts b/src/editor/lsp/popupStore.ts index 3f827a31..f098dab2 100644 --- a/src/editor/lsp/popupStore.ts +++ b/src/editor/lsp/popupStore.ts @@ -24,20 +24,81 @@ export interface ListPopupItem { disabled?: string | null; shortcut?: string; run: () => void | Promise; + /** Called when the row becomes the selected one (e.g. highlight what it would act on). */ + onActive?: () => void; +} + +/** Side panel content for the selected row of a list popup (intention preview). */ +export type ListPreview = + | { kind: "diff"; lines: DiffLine[]; /** Other files the change touches. */ note?: string } + | { kind: "text"; text: string }; + +export interface DiffLine { + kind: "context" | "added" | "removed" | "gap"; + text: string; +} + +export interface DocEntry { + markup: Markup; + language: string | null; + /** Declaration of the documented element (Edit Source), resolved on demand. */ + source?: () => Promise; +} + +export interface DefinitionTarget { + location: Location; + /** Name shown in the switcher (symbol or file name). */ + name: string; + firstLine: number; + text: string; + language: string | null; +} + +export interface ReformatOptions { + selectionOnly: boolean; + optimizeImports: boolean; } export type LspPopup = - | { kind: "doc"; anchor: PopupPoint; markup: Markup; language: string | null; themeClasses: string } + | { + kind: "doc"; + anchor: PopupPoint; + /** Navigation history (links followed inside the popup); `index` is shown. */ + entries: DocEntry[]; + index: number; + themeClasses: string; + } | { kind: "definition"; anchor: PopupPoint; - location: Location; - firstLine: number; - text: string; - language: string | null; + /** Every target (⌥Space on an overloaded / multiply defined symbol); `index` is shown. */ + targets: DefinitionTarget[]; + index: number; themeClasses: string; } - | { kind: "list"; anchor: PopupPoint; title: string; items: ListPopupItem[] } + | { + kind: "list"; + anchor: PopupPoint; + title: string; + items: ListPopupItem[]; + /** Rows 1-9 get a number; typing it runs the row (IDEA's numbered popups). */ + numbered?: boolean; + /** Header pin button (e.g. "Open in Find Tool Window"). */ + pin?: { label: string; run: () => void }; + /** Side panel for the selected row, fetched lazily; `signal` aborts when the selection moves. */ + preview?: (item: ListPopupItem, signal: AbortSignal) => Promise; + /** Runs when the popup goes away (closed, replaced or a row ran). */ + onClose?: () => void; + } + | { + kind: "reformat"; + title: string; + hasSelection: boolean; + /** Whether the server offers Optimize Imports for the file. */ + canOptimizeImports: boolean; + initial: ReformatOptions; + onRun: (options: ReformatOptions) => void; + } | { kind: "rename"; anchor: PopupPoint; @@ -85,12 +146,27 @@ export function focusPopup() { useLspPopup.setState((s) => ({ focusSeq: s.focusSeq + 1 })); } +function disposed(popup: LspPopup | null) { + if (popup?.kind === "list") popup.onClose?.(); +} + export function openPopup(popup: LspPopup) { + const previous = useLspPopup.getState().popup; useLspPopup.setState((s) => ({ popup, seq: s.seq + 1 })); + if (previous !== popup) disposed(previous); } export function closePopup() { + const previous = useLspPopup.getState().popup; useLspPopup.setState({ popup: null }); + disposed(previous); +} + +/** Replaces the open popup's state without remounting it (doc history, definition switching). */ +export function updatePopup(kind: K, patch: Partial>): void { + const current = useLspPopup.getState().popup; + if (current?.kind !== kind) return; + useLspPopup.setState({ popup: { ...current, ...patch } as LspPopup }); } /** Screen point just below `pos` (falls back to the editor's top-left). */ diff --git a/src/editor/lsp/signature.test.ts b/src/editor/lsp/signature.test.ts index 5b3cd65f..51cd1d97 100644 --- a/src/editor/lsp/signature.test.ts +++ b/src/editor/lsp/signature.test.ts @@ -11,7 +11,9 @@ vi.mock("@/platform/ipc", async (orig) => { return { ...actual, isTauri: () => true, commands: { ...actual.commands, lspSignatureHelp } }; }); -const { signatureExtension, acceptsParameter } = await import("./signature"); +const { signatureExtension, acceptsParameter, argumentSpans, argumentStop, parameterTabKeys, triggerSignatureHelp } = await import( + "./signature" +); const { lspDoc } = await import("./lsp"); // jsdom has no layout; CodeMirror measures text when the tooltip is positioned. @@ -62,3 +64,53 @@ describe("acceptsParameter", () => { expect(acceptsParameter({ label: "format(String f, Object... a)", parameters: [[7, 15], [17, 28]] }, 4)).toBe(true); }); }); + +describe("argument navigation", () => { + const at = (src: string) => ({ text: src.replace("|", ""), pos: src.indexOf("|") }); + const stop = (src: string, dir: 1 | -1) => { + const { text, pos } = at(src); + const to = argumentStop(text, pos, dir); + return to === null ? null : `${text.slice(0, to)}|${text.slice(to)}`; + }; + + it("splits arguments at top-level commas, outside strings and nested brackets", () => { + const text = 'f(a, g(b, c), "x, y", [1, 2])'; + expect(argumentSpans(text, 1).map((s) => text.slice(s.from, s.to))).toEqual(["a", "g(b, c)", '"x, y"', "[1, 2]"]); + expect(argumentSpans("f()", 1)).toEqual([]); + }); + + it("moves to the start of the next / previous argument, not past the ends", () => { + expect(stop("f(a|b, c)", 1)).toBe("f(ab, |c)"); + expect(stop("f(ab, c|)", -1)).toBe("f(|ab, c)"); + expect(stop("f(ab,| c)", 1)).toBeNull(); + expect(stop("f(ab, c|)", 1)).toBeNull(); + expect(stop("f(a, |)", -1)).toBe("f(|a, )"); + expect(stop("f(a, g(|x, y), z)", 1)).toBe("f(a, g(x, |y), z)"); + expect(stop('f("(", |b)', -1)).toBe('f(|"(", b)'); + }); + + it("⇥ moves while parameter info is shown and is left alone otherwise", async () => { + const text = "f(a, b)"; + const view = new EditorView({ + state: EditorState.create({ + doc: text, + selection: { anchor: 3 }, + extensions: [ + lspDoc.of({ doc: 1, path: "/a.ts", language: "typescript", uiMode: null, readOnly: false }), + signatureExtension(), + parameterTabKeys, + ], + }), + parent: document.body, + }); + views.push(view); + const tab = () => + view.contentDOM.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", keyCode: 9, bubbles: true, cancelable: true })); + expect(tab()).toBe(true); // not handled: no parameter info + expect(view.state.selection.main.head).toBe(3); + triggerSignatureHelp(view, true); + await vi.waitFor(() => expect(view.dom.querySelector(".cm-lsp-signature")).not.toBeNull()); + expect(tab()).toBe(false); + expect(view.state.selection.main.head).toBe(5); + }); +}); diff --git a/src/editor/lsp/signature.ts b/src/editor/lsp/signature.ts index bd865003..e4313328 100644 --- a/src/editor/lsp/signature.ts +++ b/src/editor/lsp/signature.ts @@ -1,9 +1,11 @@ /** * Parameter info (⌘P, and automatically after `(` / `,`): a tooltip above the * caret listing the signatures with the active parameter in bold. While open - * it follows the caret and closes when the server reports no signature. + * it follows the caret and closes when the server reports no signature; + * ⇥ / ⇧⇥ move to the next / previous argument. */ -import { Prec, StateEffect, StateField, type Extension } from "@codemirror/state"; +import { EditorSelection, Prec, StateEffect, StateField, type Extension } from "@codemirror/state"; +import { completionStatus, hasNextSnippetField, hasPrevSnippetField } from "@codemirror/autocomplete"; import { EditorView, ViewPlugin, keymap, showTooltip, type Tooltip, type ViewUpdate } from "@codemirror/view"; import { commands, type SignatureHelp } from "@/platform/ipc"; import { docOf, lspRequest, reportLspError, debounce } from "./lsp"; @@ -11,6 +13,7 @@ import { offsetToPos } from "./positions"; import { showHint } from "./hint"; import { renderMarkup, isEmptyMarkup } from "./markdown"; import { editorPrefs } from "@/platform/editorSettings"; +import { inplaceRenameActive } from "./inplaceRename"; interface SigState { pos: number; @@ -140,6 +143,112 @@ const signaturePlugin = ViewPlugin.fromClass( }, ); +// --------------------------------------------------------------------------- +// ⇥ / ⇧⇥: next / previous argument while parameter info is shown + +const OPEN = "([{"; +const CLOSE = ")]}"; + +/** Skips a string literal starting at `i` (its quote); returns the index after it. */ +function skipString(text: string, i: number): number { + const q = text[i]!; + let j = i + 1; + while (j < text.length && text[j] !== q) { + if (text[j] === "\\") j++; + else if (text[j] === "\n" && q !== "`") break; + j++; + } + return j + 1; +} + +const isQuote = (c: string) => c === '"' || c === "'" || c === "`"; + +/** The `(` of the innermost call around `pos`, scanning brackets outside strings and line comments. */ +export function enclosingParen(text: string, pos: number, window = 4000): number | null { + const start = Math.max(0, pos - window); + const stack: number[] = []; + for (let i = start; i < pos; i++) { + const c = text[i]!; + if (isQuote(c) && !(c === "'" && /\w/.test(text[i - 1] ?? ""))) { + i = skipString(text, i) - 1; + } else if (c === "/" && text[i + 1] === "/") { + while (i < pos && text[i] !== "\n") i++; + } else if (OPEN.includes(c)) stack.push(i); + else if (CLOSE.includes(c)) stack.pop(); + } + for (let k = stack.length - 1; k >= 0; k--) if (text[stack[k]!] === "(") return stack[k]!; + return null; +} + +/** Argument spans (trimmed; empty ones collapse to where they would start) of the call opened at `open`. */ +export function argumentSpans(text: string, open: number): { from: number; to: number }[] { + const out: { from: number; to: number }[] = []; + let depth = 0; + let argStart = open + 1; + const push = (end: number) => { + let from = argStart; + let to = end; + while (from < to && /\s/.test(text[from]!)) from++; + while (to > from && /\s/.test(text[to - 1]!)) to--; + out.push({ from, to }); + }; + for (let i = open + 1; i < text.length; i++) { + const c = text[i]!; + if (isQuote(c) && !(c === "'" && /\w/.test(text[i - 1] ?? ""))) i = skipString(text, i) - 1; + else if (OPEN.includes(c)) depth++; + else if (CLOSE.includes(c)) { + if (depth === 0) { + push(i); + return out.length === 1 && out[0]!.from === out[0]!.to ? [] : out; + } + depth--; + } else if (c === "," && depth === 0) { + push(i); + argStart = i + 1; + } + } + return out; +} + +/** Caret target of ⇥ (`dir` 1) / ⇧⇥ (-1) in the call around `pos`; null at the ends (the key then does its usual thing). */ +export function argumentStop(text: string, pos: number, dir: 1 | -1): number | null { + const open = enclosingParen(text, pos); + if (open === null) return null; + const args = argumentSpans(text, open); + if (!args.length) return null; + // The argument the caret is in: past an argument's separating comma it is that argument. + let current = 0; + for (let i = 1; i < args.length; i++) if (pos > text.lastIndexOf(",", args[i]!.from)) current = i; + const target = args[current + dir]; + return target ? target.from : null; +} + +function moveToArgument(view: EditorView, dir: 1 | -1): boolean { + const state = view.state; + if (!state.field(signatureField, false) || state.readOnly) return false; + if (state.selection.ranges.length > 1 || completionStatus(state) === "active") return false; + // Snippet / template fields and the in-place rename own ⇥. + if (dir > 0 ? hasNextSnippetField(state) : hasPrevSnippetField(state)) return false; + if (inplaceRenameActive(state)) return false; + const head = state.selection.main.head; + const to = argumentStop(state.doc.toString(), head, dir); + if (to === null || to === head) return false; + view.dispatch({ selection: EditorSelection.cursor(to), scrollIntoView: true, userEvent: "select" }); + return true; +} + +/** + * Next / Previous Parameter keys. Registered after the template contributions + * (see `index.ts`) so live template / postfix expansion keeps ⇥ first; the + * lookup, snippet fields and the in-place rename are checked explicitly. + */ +export const parameterTabKeys: Extension = Prec.high( + keymap.of([ + { key: "Tab", run: (v) => moveToArgument(v, 1) }, + { key: "Shift-Tab", run: (v) => moveToArgument(v, -1) }, + ]), +); + export function signatureExtension(): Extension { return [ signatureField, diff --git a/src/editor/lsp/syntaxFallback.test.ts b/src/editor/lsp/syntaxFallback.test.ts index 052deb2f..8170d893 100644 --- a/src/editor/lsp/syntaxFallback.test.ts +++ b/src/editor/lsp/syntaxFallback.test.ts @@ -274,7 +274,8 @@ describe("navigation wiring", () => { const popup = useLspPopup.getState().popup; expect(popup?.kind).toBe("list"); if (popup?.kind !== "list") return; - expect(popup.title).toBe("Choose Declaration (2 found, based on syntax)"); + expect(popup.title).toBe("Choose Declaration of run (2 found, based on syntax)"); + expect(popup.pin?.label).toBe("Open in Find Tool Window"); expect(popup.items.map((i) => [i.label, i.symbol, i.detail?.split(" · ")[0]])).toEqual([ ["run", "method", "A"], ["run", "method", "B"], diff --git a/src/editor/lsp/tempHighlight.ts b/src/editor/lsp/tempHighlight.ts new file mode 100644 index 00000000..9cba8aa9 --- /dev/null +++ b/src/editor/lsp/tempHighlight.ts @@ -0,0 +1,58 @@ +/** + * Temporary range highlight (styled in `theme.ts`) while a chooser moves its selection (the + * expression / occurrences an Introduce refactoring would take, a postfix + * template's target…), as in IDEA. Not part of the document state: any + * edit clears it, and callers clear it when their popup closes. + */ +import { StateEffect, StateField, type Extension } from "@codemirror/state"; +import { Decoration, EditorView } from "@codemirror/view"; + +export interface HighlightRange { + from: number; + to: number; +} + +const setTemporary = StateEffect.define(); + +const mark = Decoration.mark({ class: "cm-lsp-chooser-highlight" }); + +const temporaryField = StateField.define({ + create: () => [], + update(value, tr) { + for (const e of tr.effects) if (e.is(setTemporary)) return e.value; + return tr.docChanged && value.length ? [] : value; + }, + provide: (f) => + EditorView.decorations.from(f, (ranges) => + Decoration.set( + ranges.filter((r) => r.from < r.to).map((r) => mark.range(r.from, r.to)), + true, + ), + ), +}); + +/** Highlights `ranges` until the next call, `clearTemporaryHighlight` or an edit; scrolls the first into view. */ +export function highlightRanges(view: EditorView, ranges: readonly HighlightRange[]): void { + const len = view.state.doc.length; + const clamped = ranges + .map((r) => ({ from: Math.max(0, Math.min(r.from, len)), to: Math.max(0, Math.min(r.to, len)) })) + .sort((a, b) => a.from - b.from); + const effects: StateEffect[] = [setTemporary.of(clamped)]; + if (!view.state.field(temporaryField, false)) effects.push(StateEffect.appendConfig.of(temporaryHighlightExtension())); + const first = clamped[0]; + if (first) effects.push(EditorView.scrollIntoView(first.from, { y: "nearest" })); + view.dispatch({ effects }); +} + +export function clearTemporaryHighlight(view: EditorView): void { + const current = view.state.field(temporaryField, false); + if (current?.length) view.dispatch({ effects: setTemporary.of([]) }); +} + +export function temporaryHighlights(view: EditorView): readonly HighlightRange[] { + return view.state.field(temporaryField, false) ?? []; +} + +export function temporaryHighlightExtension(): Extension { + return [temporaryField]; +} diff --git a/src/editor/lsp/theme.ts b/src/editor/lsp/theme.ts index df8ba2b2..d14aa914 100644 --- a/src/editor/lsp/theme.ts +++ b/src/editor/lsp/theme.ts @@ -256,6 +256,8 @@ export const lspTheme = EditorView.theme({ backgroundColor: "var(--bg-identifier-under-caret-write, color-mix(in srgb, var(--danger) 12%, transparent))", }, ".cm-lsp-nav-info": { padding: "4px 8px" }, + // Chooser highlight (tempHighlight.ts): the expression / occurrences a choice would take. + ".cm-lsp-chooser-highlight": { backgroundColor: "var(--bg-text-search-result)" }, // --- in-place rename (inplaceRename.ts) ------------------------------------ ".cm-lsp-inplace": { @@ -267,6 +269,17 @@ export const lspTheme = EditorView.theme({ borderRadius: "2px", }, ".cm-lsp-inplace-hint": { color: "var(--fg-secondary)", fontSize: "var(--fs-small)", padding: "3px 8px" }, + ".cm-lsp-inplace-suggestions": { + listStyle: "none", + margin: "0 -8px 4px", + padding: "0", + fontFamily: "var(--openidea-editor-font, var(--font-mono))", + fontSize: "var(--fs-ui)", + color: "var(--fg)", + }, + ".cm-lsp-inplace-suggestions li": { padding: "1px 8px", cursor: "pointer" }, + ".cm-lsp-inplace-suggestions li:hover": { backgroundColor: "var(--bg-hover)" }, + ".cm-lsp-inplace-suggestion-active": { backgroundColor: "var(--bg-selected)" }, // --- floating lightbulb (intentions.ts) ----------------------------------- ".cm-lsp-bulb": { @@ -295,6 +308,8 @@ export const lspTheme = EditorView.theme({ WebkitUserSelect: "none", userSelect: "none", }, + // A part naming a declaration (⌘-click navigates there). + ".cm-lsp-inlay-part:hover": { textDecoration: "underline", textUnderlineOffset: "2px" }, ".cm-lsp-inlay-pl": { marginLeft: "4px" }, ".cm-lsp-inlay-pr": { marginRight: "4px" }, diff --git a/src/editor/lsp/typeInfo.ts b/src/editor/lsp/typeInfo.ts index 389c4b09..8b7d582e 100644 --- a/src/editor/lsp/typeInfo.ts +++ b/src/editor/lsp/typeInfo.ts @@ -67,8 +67,10 @@ export async function expressionTypeInfo(view: EditorView) { openPopup({ kind: "doc", anchor: caretAnchor(view, hover.range ? rangeToOffsets(view.state.doc, hover.range).from : pos), - markup: { kind: "markdown", value: `\`\`\`${language}\n${info.text}\n\`\`\`` }, - language: docOf(view)?.uiMode ?? null, + entries: [ + { markup: { kind: "markdown", value: `\`\`\`${language}\n${info.text}\n\`\`\`` }, language: docOf(view)?.uiMode ?? null }, + ], + index: 0, themeClasses: view.themeClasses, }); } catch (e) { diff --git a/src/editor/lsp/workspaceEdit.test.ts b/src/editor/lsp/workspaceEdit.test.ts index b5c537f3..448bc014 100644 --- a/src/editor/lsp/workspaceEdit.test.ts +++ b/src/editor/lsp/workspaceEdit.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { describeSummary, needsPreview, summarizeWorkspaceEdit } from "./workspaceEdit"; +import { Text } from "@codemirror/state"; +import { describeSummary, editPreviewLines, needsPreview, summarizeWorkspaceEdit } from "./workspaceEdit"; const edit = (line: number) => ({ range: { start: { line, col: 0 }, end: { line, col: 3 } }, new_text: "new" }); @@ -47,3 +48,31 @@ describe("summarizeWorkspaceEdit", () => { expect(s).toMatchObject({ files: 0, occurrences: 0 }); }); }); + +describe("editPreviewLines", () => { + const doc = Text.of(["function f() {", " return price * 2;", "}", "", "const a = 1;", "const b = 2;", "const c = 3;"]); + const at = (line: number, col: number) => ({ line, col }); + + it("shows changed lines with one line of context, hunks separated", () => { + const lines = editPreviewLines(doc, [ + { range: { start: at(1, 2), end: at(1, 2) }, new_text: "const x = price * 2;\n " }, + { range: { start: at(1, 9), end: at(1, 18) }, new_text: "x" }, + { range: { start: at(6, 10), end: at(6, 11) }, new_text: "4" }, + ]); + expect(lines).toEqual([ + { kind: "context", text: "function f() {" }, + { kind: "removed", text: " return price * 2;" }, + { kind: "added", text: " const x = price * 2;" }, + { kind: "added", text: " return x;" }, + { kind: "context", text: "}" }, + { kind: "gap", text: "" }, + { kind: "context", text: "const b = 2;" }, + { kind: "removed", text: "const c = 3;" }, + { kind: "added", text: "const c = 4;" }, + ]); + }); + + it("is empty when nothing changes", () => { + expect(editPreviewLines(doc, [{ range: { start: at(0, 0), end: at(0, 8) }, new_text: "function" }])).toEqual([]); + }); +}); diff --git a/src/editor/lsp/workspaceEdit.ts b/src/editor/lsp/workspaceEdit.ts index 6543dc9e..1ad882d6 100644 --- a/src/editor/lsp/workspaceEdit.ts +++ b/src/editor/lsp/workspaceEdit.ts @@ -1,5 +1,8 @@ -/** Summaries of workspace edits for rename / refactoring previews. */ -import type { WorkspaceEdit } from "@/bindings"; +/** Summaries of workspace edits for rename / refactoring previews, and the intention preview diff. */ +import { ChangeSet, type Text } from "@codemirror/state"; +import type { TextEdit, WorkspaceEdit } from "@/bindings"; +import type { DiffLine } from "./popupStore"; +import { textEditsToChanges } from "./positions"; export interface WorkspaceEditFileSummary { path: string; @@ -70,3 +73,68 @@ export function describeSummary(s: WorkspaceEditSummary): string { export function needsPreview(s: WorkspaceEditSummary): boolean { return s.files > 1 || s.hasFileOperations; } + +/** Line diff of `a` → `b` (LCS; both short: they are the changed lines of one hunk). */ +function lineDiff(a: readonly string[], b: readonly string[]): DiffLine[] { + const n = a.length; + const m = b.length; + if (n * m > 250_000) return [...a.map((text) => ({ kind: "removed" as const, text })), ...b.map((text) => ({ kind: "added" as const, text }))]; + const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0)); + for (let i = n - 1; i >= 0; i--) + for (let j = m - 1; j >= 0; j--) lcs[i]![j] = a[i] === b[j] ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!); + const out: DiffLine[] = []; + let i = 0; + let j = 0; + while (i < n || j < m) { + if (i < n && j < m && a[i] === b[j]) { + out.push({ kind: "context", text: a[i]! }); + i++; + j++; + } else if (i < n && (j >= m || lcs[i + 1]![j]! >= lcs[i]![j + 1]!)) out.push({ kind: "removed", text: a[i++]! }); + else out.push({ kind: "added", text: b[j++]! }); + } + return out; +} + +const lines = (doc: Text, first: number, last: number) => { + const out: string[] = []; + for (let n = first; n <= last; n++) out.push(doc.line(n).text); + return out; +}; + +/** + * The intention preview of `edits` to `doc`: changed lines with `context` + * unchanged lines around each hunk, hunks separated by a gap line, at most + * `maxLines` lines. + */ +export function editPreviewLines(doc: Text, edits: readonly TextEdit[], context = 1, maxLines = 60): DiffLine[] { + const changes = textEditsToChanges(doc, edits); + if (!changes.length) return []; + const set = ChangeSet.of(changes, doc.length); + const after = set.apply(doc); + // Hunks: changed old-line ranges, merged when their contexts touch. + const hunks: { a1: number; a2: number }[] = []; + for (const c of changes) { + const a1 = doc.lineAt(c.from).number; + const a2 = doc.lineAt(c.to).number; + const last = hunks[hunks.length - 1]; + if (last && a1 - last.a2 <= 2 * context + 1) last.a2 = Math.max(last.a2, a2); + else hunks.push({ a1, a2 }); + } + const out: DiffLine[] = []; + for (const h of hunks) { + if (out.length) out.push({ kind: "gap", text: "" }); + const from = doc.line(h.a1).from; + const to = doc.line(h.a2).to; + const b1 = after.lineAt(set.mapPos(from, -1)).number; + const b2 = after.lineAt(set.mapPos(to, 1)).number; + const c1 = Math.max(1, h.a1 - context); + const c2 = Math.min(doc.lines, h.a2 + context); + for (const text of lines(doc, c1, h.a1 - 1)) out.push({ kind: "context", text }); + out.push(...lineDiff(lines(doc, h.a1, h.a2), lines(after, b1, b2))); + for (const text of lines(doc, h.a2 + 1, c2)) out.push({ kind: "context", text }); + if (out.length >= maxLines) break; + } + if (out.length <= maxLines) return out; + return [...out.slice(0, maxLines), { kind: "gap", text: "" }]; +} diff --git a/src/editor/refactor/introduce.ts b/src/editor/refactor/introduce.ts index 3a45f616..5142bc17 100644 --- a/src/editor/refactor/introduce.ts +++ b/src/editor/refactor/introduce.ts @@ -16,6 +16,7 @@ import { docOf } from "@/editor/lsp/lsp"; import { offsetsToRange, rangeToOffsets } from "@/editor/lsp/positions"; import { showHint } from "@/editor/lsp/hint"; import { caretAnchor, openPopup } from "@/editor/lsp/popupStore"; +import { clearTemporaryHighlight, highlightRanges } from "@/editor/lsp/tempHighlight"; import { call, commands, isCancelled, nextRequestId } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { expressionWords, findTextOccurrences, normalizeExpression } from "./occurrences"; @@ -150,7 +151,8 @@ function isCallee(doc: Text, node: SyntaxNode, outer: SyntaxNode | undefined): b return /^\s*(\(|<|::<)/.test(doc.sliceString(end, end + 4)); } -function preview(text: string): string { +/** Chooser label of an expression: one line, shortened. */ +export function expressionPreview(text: string): string { const one = text.replace(/\s+/g, " ").trim(); return one.length > 80 ? `${one.slice(0, 77)}…` : one; } @@ -194,7 +196,7 @@ async function introduce(view: EditorView, kind: IntroduceKind, reason: Fallback const p: Prepared = { view, ctx, kind, reason, doc, at }; let candidates: SyntaxNode[]; if (sel.empty) { - candidates = at.candidates.filter((c, i) => !c.error && !c.missing && !isCallee(doc, c, at.candidates[i + 1])); + candidates = expressionCandidates(doc, at); if (!candidates.length) return refuse(p, "no expression at the caret"); } else { if (!at.expression || !at.exact) return refuse(p, "selected block should represent an expression"); @@ -208,11 +210,22 @@ async function introduce(view: EditorView, kind: IntroduceKind, reason: Fallback kind: "list", anchor: caretAnchor(view, sel.head), title: "Expressions", - items: candidates.map((c, i) => ({ id: `${i}`, label: preview(nodeText(doc, c)), run: () => perform(p, c) })), + items: candidates.map((c, i) => ({ + id: `${i}`, + label: expressionPreview(nodeText(doc, c)), + onActive: () => highlightRanges(view, [rangeToOffsets(doc, c.range)]), + run: () => perform(p, c), + })), + onClose: () => clearTemporaryHighlight(view), }); return true; } +/** The expressions at an empty selection, innermost first, that are worth extracting (no callee names). */ +export function expressionCandidates(doc: Text, at: ExpressionAt): SyntaxNode[] { + return at.candidates.filter((c, i) => !c.error && !c.missing && !isCallee(doc, c, at.candidates[i + 1])); +} + async function brokenAround( ctx: EditorDocContext, doc: Text, @@ -331,7 +344,7 @@ async function introduceVariable(p: Prepared, expr: SyntaxNode, chain: SyntaxNod } return { changes: state.changes(specs), decl, declAt: stmtFrom, refs }; }; - await chooseOccurrences(p, others.length, (all) => applyAndName(p, plan(all), names)); + await chooseOccurrences(p, { from: exprFrom, to: exprTo }, others, (all) => applyAndName(p, plan(all), names)); } async function variableContext( @@ -469,7 +482,7 @@ async function introduceConstant(p: Prepared, expr: SyntaxNode, chain: SyntaxNod } return { changes: state.changes(specs), decl, declAt: place.insertAt, declIndent: place.indent.length, refs }; }; - await chooseOccurrences(p, others.length, (all) => applyAndName(p, plan(all), names)); + await chooseOccurrences(p, { from: exprFrom, to: exprTo }, others, (all) => applyAndName(p, plan(all), names)); } /** Not an object key, type, import source…: a place where a named constant can stand. */ @@ -561,20 +574,32 @@ async function verifiedOccurrences( } /** IDEA's "Multiple occurrences found" choice; runs `apply` directly when there are none. */ -async function chooseOccurrences(p: Prepared, others: number, apply: (all: boolean) => void) { - if (!others) { +async function chooseOccurrences(p: Prepared, self: Occurrence, others: Occurrence[], apply: (all: boolean) => void) { + if (!others.length) { apply(false); return; } - const head = p.view.state.selection.main.head; + const { view } = p; + const head = view.state.selection.main.head; openPopup({ kind: "list", - anchor: caretAnchor(p.view, head), + anchor: caretAnchor(view, head), title: "Multiple occurrences found", items: [ - { id: "one", label: "Replace this occurrence only", run: () => apply(false) }, - { id: "all", label: `Replace all ${others + 1} occurrences`, run: () => apply(true) }, + { + id: "one", + label: "Replace this occurrence only", + onActive: () => highlightRanges(view, [self]), + run: () => apply(false), + }, + { + id: "all", + label: `Replace all ${others.length + 1} occurrences`, + onActive: () => highlightRanges(view, [self, ...others]), + run: () => apply(true), + }, ], + onClose: () => clearTemporaryHighlight(view), }); } diff --git a/src/editor/refactor/refactorThis.test.ts b/src/editor/refactor/refactorThis.test.ts index ecd3e6c2..bc0722fa 100644 --- a/src/editor/refactor/refactorThis.test.ts +++ b/src/editor/refactor/refactorThis.test.ts @@ -11,7 +11,11 @@ let query: (request: number) => Promise = async () => ok({ actions: [], let prepare: Result = ok(null); const lspCodeActionQuery = vi.fn(async (request: number, _doc: number, _range: unknown, _only: string[]) => query(request)); const lspPrepareRename = vi.fn(async () => prepare); -const lspApplyCodeAction = vi.fn(async () => ok(null)); +let onApply: (() => void) | null = null; +const lspApplyCodeAction = vi.fn(async () => { + onApply?.(); + return ok(null); +}); const lspCodeActions = vi.fn(async () => ok([])); const cancelRequest = vi.fn(async () => undefined); const languages = vi.fn(async () => [{ id: "rust", name: "Rust", lineComment: "//", blockComment: ["/*", "*/"] }]); @@ -25,16 +29,22 @@ vi.mock("@/platform/ipc", async (orig) => { }; }); let expression = false; +let candidates: unknown[] = []; vi.mock("@/editor/structure", () => ({ structure: { support: vi.fn(async () => ({ tree: true, locals: false })), - expressionAt: vi.fn(async () => ({ expression: expression ? { kind: "call_expression" } : null })), + expressionAt: vi.fn(async () => ({ expression: expression ? { kind: "call_expression" } : null, exact: expression, candidates })), + identifiers: vi.fn(async () => []), }, })); const showHint = vi.fn(); vi.mock("@/editor/lsp/hint", () => ({ showHint })); const introduceFallback = vi.fn(async () => true); -vi.mock("./introduce", () => ({ introduceFallback })); +vi.mock("./introduce", () => ({ + introduceFallback, + expressionCandidates: (_doc: unknown, at: { candidates?: unknown[] }) => at.candidates ?? [], + expressionPreview: (text: string) => text, +})); const safeDelete = vi.fn(); vi.mock("./safeDelete", () => ({ safeDelete })); @@ -42,6 +52,8 @@ const { classifyAction, kindAdvertised, cannotPerform } = await import("./catalo const { refactorThis, runRefactoring } = await import("./refactorThis"); const { useLspPopup, closePopup } = await import("@/editor/lsp/popupStore"); const { lspDoc } = await import("@/editor/lsp/lsp"); +const { inplaceRenameExtension, inplaceSession } = await import("@/editor/lsp/inplaceRename"); +const { temporaryHighlights } = await import("@/editor/lsp/tempHighlight"); // jsdom has no layout; popups anchor at the caret. Range.prototype.getClientRects ??= () => [] as unknown as DOMRectList; @@ -66,7 +78,10 @@ function editor(text = "fn main() { let total = price * 2; }") { state: EditorState.create({ doc: text, selection: { anchor: text.indexOf("price") + 1 }, - extensions: [lspDoc.of({ doc: 1, path: "/p/a.rs", language: "rust", uiMode: null, readOnly: false })], + extensions: [ + lspDoc.of({ doc: 1, path: "/p/a.rs", language: "rust", uiMode: null, readOnly: false }), + inplaceRenameExtension(), + ], }), }); return view; @@ -80,6 +95,8 @@ beforeEach(() => { query = answer([]); prepare = ok(null); expression = false; + candidates = []; + onApply = null; }); afterEach(() => { view?.destroy(); @@ -139,6 +156,8 @@ describe("Refactor This", () => { ]); await refactorThis(editor(), vi.fn()); expect(labels()).toEqual(["Rename…", "Safe Delete…", "Introduce Variable…", "Inline…", "Convert to raw string"]); + // IDEA's numbered list: 1-9 run a row directly. + expect((useLspPopup.getState().popup as { numbered?: boolean }).numbered).toBe(true); expect(lspCodeActionQuery).toHaveBeenCalledOnce(); expect(lspCodeActionQuery.mock.calls[0]![3]).toEqual(["refactor"]); }); @@ -248,4 +267,65 @@ describe("Refactor menu actions", () => { await runRefactoring(editor(), "extractField"); expect(lspCodeActions).not.toHaveBeenCalled(); }); + + it("with only a caret, choose the expression first (highlighted while choosing) and ask the server for it", async () => { + const text = "fn main() { let total = price * 2; }"; + const node = (t: string) => { + const from = text.indexOf(t); + return { kind: "e", range: { start: { line: 0, col: from }, end: { line: 0, col: from + t.length } }, error: false, missing: false }; + }; + candidates = [node("price"), node("price * 2")]; + const v = editor(text); + await runRefactoring(v, "extractVariable"); + const popup = useLspPopup.getState().popup; + expect(popup?.kind === "list" && popup.title).toBe("Expressions"); + expect(labels()).toEqual(["price", "price * 2"]); + expect(lspCodeActionQuery).not.toHaveBeenCalled(); + if (popup?.kind !== "list") return; + popup.items[1]!.onActive!(); + expect(temporaryHighlights(v)).toEqual([{ from: text.indexOf("price"), to: text.indexOf("2") + 1 }]); + + query = answer([action("Extract into variable", "refactor.extract")]); + closePopup(); + expect(temporaryHighlights(v)).toEqual([]); + popup.items[1]!.run(); + await vi.waitFor(() => expect(lspCodeActionQuery).toHaveBeenCalledOnce()); + expect(v.state.sliceDoc(v.state.selection.main.from, v.state.selection.main.to)).toBe("price * 2"); + expect(lspCodeActionQuery.mock.calls[0]![2]).toEqual((candidates[1] as { range: unknown }).range); + }); + + it("offer IDEA's occurrences choice when the server has both variants", async () => { + const text = "fn f() { g(price); h(price); }"; + const v = editor(text); + v.dispatch({ selection: { anchor: text.indexOf("price"), head: text.indexOf("price") + 5 } }); + query = answer([ + action("Extract to local variable", "refactor.extract.variable"), + action("Extract to local variable (replace all occurrences)", "refactor.extract.variable"), + ]); + await runRefactoring(v, "extractVariable"); + expect(labels()).toEqual(["Replace this occurrence only", "Replace all 2 occurrences"]); + const popup = useLspPopup.getState().popup; + if (popup?.kind !== "list") return; + popup.items[1]!.onActive!(); + expect(temporaryHighlights(v)).toHaveLength(2); + }); + + it("rename the introduced name in place after the server's edit", async () => { + const text = "fn main() { let total = price * 2; }"; + const v = editor(text); + v.dispatch({ selection: { anchor: text.indexOf("price"), head: text.indexOf("2") + 1 } }); + query = answer([action("Extract into variable", "refactor.extract")]); + onApply = () => + v.dispatch({ + changes: [ + { from: text.indexOf("let total"), insert: "let var_name = price * 2;\n " }, + { from: text.indexOf("price"), to: text.indexOf("2") + 1, insert: "var_name" }, + ], + }); + await runRefactoring(v, "extractVariable"); + await vi.waitFor(() => expect(inplaceSession(v.state)).not.toBeNull()); + const session = inplaceSession(v.state)!; + expect(session.oldName).toBe("var_name"); + expect(session.others).toHaveLength(1); + }); }); diff --git a/src/editor/refactor/refactorThis.ts b/src/editor/refactor/refactorThis.ts index 87e6d261..c421ae36 100644 --- a/src/editor/refactor/refactorThis.ts +++ b/src/editor/refactor/refactorThis.ts @@ -30,6 +30,13 @@ import { } from "./catalog"; import { introduceFallback, type IntroduceKind } from "./introduce"; import { languageName, queryRefactorActions } from "./query"; +import { + EXPRESSION_REFACTORINGS, + applyIntroduceAction, + chooseExpressionFirst, + chooseOccurrences, + introducesName, +} from "./serverIntroduce"; import { safeDelete } from "./safeDelete"; /** Outcome of the server query: its answer, or why there is none. */ @@ -66,9 +73,14 @@ export function groupActions(actions: readonly CodeAction[]): { return { mapped, other }; } -/** Applies the single action, or lets the user pick one. */ -function runActions(view: EditorView, title: string, actions: readonly CodeAction[]) { - if (actions.length === 1) return applyCodeAction(view, actions[0]!); +/** + * Applies the single action, or lets the user pick one. Introduce / Extract + * actions offer IDEA's occurrences choice and rename the new name in place. + */ +function runActions(view: EditorView, title: string, actions: readonly CodeAction[], id: RefactoringId | null = null) { + if (id && EXPRESSION_REFACTORINGS.has(id) && chooseOccurrences(view, actions, id)) return; + const apply = (a: CodeAction) => (id && introducesName(id) ? applyIntroduceAction(view, a, id) : applyCodeAction(view, a)); + if (actions.length === 1) return apply(actions[0]!); openPopup({ kind: "list", anchor: caretAnchor(view, view.state.selection.main.head), @@ -78,7 +90,7 @@ function runActions(view: EditorView, title: string, actions: readonly CodeActio label: a.title, icon: "bulb", emphasis: a.isPreferred, - run: () => applyCodeAction(view, a), + run: () => apply(a), })), }); } @@ -127,6 +139,10 @@ export async function runRefactoring(view: EditorView, id: RefactoringId): Promi showHint(view, cannotPerform("The file is read-only")); return; } + // IDEA picks the expression first (and the server then extracts exactly that one). + if (EXPRESSION_REFACTORINGS.has(id) && view.state.selection.main.empty) { + if (await chooseExpressionFirst(view, () => runRefactoring(view, id))) return; + } menuRun?.abort(); const run = (menuRun = new AbortController()); const state = view.state; @@ -143,7 +159,7 @@ export async function runRefactoring(view: EditorView, id: RefactoringId): Promi const all = answer.status === "ok" ? answer.query.actions : []; const actions = groupActions(all).mapped.get(id) ?? []; if (actions.length) { - await runActions(view, r.label.replace(/…$/, ""), actions); + await runActions(view, r.label.replace(/…$/, ""), actions, id); return; } const introduce = INTRODUCE[id]; @@ -195,10 +211,15 @@ export function refactorThisItems( const { mapped, other } = groupActions(answer.status === "ok" ? answer.query.actions : []); const serverUsable = answer.status === "ok"; const items: ListPopupItem[] = []; + const caretOnly = view.state.selection.main.empty; for (const r of REFACTORINGS) { const actions = mapped.get(r.id) ?? []; + const expression = EXPRESSION_REFACTORINGS.has(r.id); let go: (() => unknown) | null = null; - if (actions.length) go = () => runActions(view, r.label.replace(/…$/, ""), actions); + // With only a caret the expression is chosen first, then the server asked for it. + if (expression && caretOnly && serverUsable && (actions.length || facts.expression)) + go = () => runRefactoring(view, r.id); + else if (actions.length) go = () => runActions(view, r.label.replace(/…$/, ""), actions, r.id); else if (r.id === "rename" && facts.symbol) go = () => run.rename(view); else if (r.id === "safeDelete" && facts.symbol && !docOf(view)?.readOnly) go = () => run.safeDelete(view); else if (INTRODUCE[r.id] && facts.expression && !serverUsable) @@ -243,5 +264,5 @@ export async function refactorThis(view: EditorView, rename: (view: EditorView) showHint(view, "No refactorings available at the caret"); return; } - openPopup({ kind: "list", anchor: caretAnchor(view, state.selection.main.head), title: "Refactor This", items }); + openPopup({ kind: "list", anchor: caretAnchor(view, state.selection.main.head), title: "Refactor This", items, numbered: true }); } diff --git a/src/editor/refactor/safeDelete.test.ts b/src/editor/refactor/safeDelete.test.ts index 5ac7893c..d5e088c7 100644 --- a/src/editor/refactor/safeDelete.test.ts +++ b/src/editor/refactor/safeDelete.test.ts @@ -47,10 +47,12 @@ vi.mock("@/platform/ipc", async (orig) => { let locals: Locals = { status: "noIdentifier" }; let statement: SyntaxNode | null = null; +let identifiers: { range: Range; qualified: boolean }[] = []; vi.mock("@/editor/structure", () => ({ structure: { locals: vi.fn(async () => locals), structuralRanges: vi.fn(async (): Promise> => ({ statement })), + identifiers: vi.fn(async () => identifiers), }, })); const showHint = vi.fn(); @@ -59,11 +61,13 @@ const executeAction = vi.fn(); vi.mock("@/platform/actions", () => ({ executeAction })); let answer = "cancel"; const ask = vi.fn(async (_request: MessageRequest) => ({ button: answer, checked: false })); -vi.mock("@/workbench/messages", () => ({ ask })); +let confirmAnswer: { checked: boolean } | null = { checked: false }; +const confirmChecked = vi.fn(async (_opts: { title: string; message: string; checkbox?: string; checked?: boolean }) => confirmAnswer); +vi.mock("@/workbench/messages", () => ({ ask, confirmChecked })); -const { deletionRange, localDeclaration, outlineItemAt, safeDelete, fileUsages, usagesMessage } = +const { deletionRange, localDeclaration, outlineItemAt, safeDelete, fileUsages, usagesMessage, textOccurrences } = await import("./safeDelete"); -const { closePopup } = await import("@/editor/lsp/popupStore"); +const { closePopup, useLspPopup } = await import("@/editor/lsp/popupStore"); const { lspDoc } = await import("@/editor/lsp/lsp"); // --- helpers ---------------------------------------------------------------- @@ -112,6 +116,8 @@ beforeEach(() => { references = ok([]); locals = { status: "noIdentifier" }; statement = null; + identifiers = []; + confirmAnswer = { checked: false }; }); afterEach(() => { @@ -192,6 +198,16 @@ describe("localDeclaration", () => { }); }); +describe("textOccurrences", () => { + it("finds whole-word mentions that are not identifier tokens, outside the declaration", () => { + const text = 'foo(); // foo and food\nlet s = "foo";\nfn foo() {}'; + const ids = [0, text.lastIndexOf("foo")].map((from) => ({ from, to: from + 3 })); + const decl = text.indexOf("fn foo"); + const hits = textOccurrences(text, "foo", ids, { from: decl, to: text.length }); + expect(hits.map((h) => text.slice(h.from - 3, h.to))).toEqual(["// foo", '= "foo']); + }); +}); + describe("usagesMessage", () => { it("says how many usages and where", () => { const loc = (path: string): Location => ({ path, range: rangeOf("x", 0, 1) }); @@ -245,6 +261,37 @@ describe("safeDelete in the editor", () => { expect((await run("delete")).state.doc.toString()).toBe("fn unused() {\n unused();\n}\n\nfn main() {}\n"); }); + it("confirms first ('Delete function …?') and searches nothing when cancelled", async () => { + const v = editor(text, "unused() {"); + outline = ok([item(text, "unused", "fn unused() {\n unused();\n}")]); + confirmAnswer = null; + await safeDelete(v); + expect(confirmChecked).toHaveBeenCalledTimes(1); + const opts = confirmChecked.mock.calls[0]![0]; + expect(opts.title).toBe("Safe Delete"); + expect(opts.message).toBe("Delete function 'unused'?"); + expect(opts.checkbox).toBe("Search in comments and strings"); + expect(lspReferences).not.toHaveBeenCalled(); + expect(v.state.doc.toString()).toBe(text); + }); + + it("with 'Search in comments and strings' previews mentions before deleting", async () => { + const src = "// calls unused\nfn unused() {}\n\nfn main() {}\n"; + const v = editor(src, "unused() {"); + outline = ok([item(src, "unused", "fn unused() {}")]); + const decl = src.indexOf("unused() {"); + identifiers = [{ range: rangeOf(src, decl, decl + 6), qualified: false }]; + confirmAnswer = { checked: true }; + await safeDelete(v); + const popup = useLspPopup.getState().popup; + expect(popup?.kind).toBe("preview"); + if (popup?.kind !== "preview") return; + expect(popup.occurrences).toEqual([{ line: 0, text: "// calls unused", from: 9, to: 15 }]); + expect(v.state.doc.toString()).toBe(src); + popup.onConfirm(); + expect(v.state.doc.toString()).not.toContain("fn unused"); + }); + it("refuses a declaration in another file instead of doing something else", async () => { const v = editor(text, "main"); goto = ok([{ path: "/p/other.rs", range: rangeOf(text, 0, 1) }]); diff --git a/src/editor/refactor/safeDelete.ts b/src/editor/refactor/safeDelete.ts index c8aafd15..ee6f6609 100644 --- a/src/editor/refactor/safeDelete.ts +++ b/src/editor/refactor/safeDelete.ts @@ -2,7 +2,8 @@ * Safe Delete (⌘⌦): resolves the declaration at the caret (LSP definition + * document symbols; the tree-sitter structure service for local declarations * only), searches its usages (LSP references, the declaration's own body - * excluded), then deletes it — with attached doc comments / annotations and + * excluded) once the user confirmed ("Delete 'x'?", optionally searching + * comments and strings too), then deletes it — with attached doc comments / annotations and * the trailing blank line — as one undo step, or, when it is still used, * asks like IDEA: "Usages Detected" → View Usages / Delete Anyway / Cancel. * @@ -20,7 +21,8 @@ import { structure } from "@/editor/structure"; import { docOf, isUnavailable, lspRequest } from "@/editor/lsp/lsp"; import { comparePos, offsetToPos, rangeContains, rangeToOffsets } from "@/editor/lsp/positions"; import { showHint } from "@/editor/lsp/hint"; -import { ask } from "@/workbench/messages"; +import { ask, confirmChecked } from "@/workbench/messages"; +import { openPopup, type PreviewOccurrence } from "@/editor/lsp/popupStore"; import { cannotPerform } from "./catalog"; import { cancelled, languageName } from "./query"; @@ -333,6 +335,77 @@ function applyDeletion(view: EditorView, docBefore: Text, target: SafeDeleteTarg } let running: EditorView | null = null; +/** "Search in comments and strings", remembered like IDEA does (on by default). */ +let searchInComments = true; + +/** + * Mentions of the name in this file's comments and strings: whole-word text + * matches that are not identifier tokens, outside the declaration itself. + */ +export function textOccurrences( + text: string, + name: string, + identifiers: readonly { from: number; to: number }[], + exclude: { from: number; to: number }, +): { from: number; to: number }[] { + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = new RegExp(`(? o.from)); + const out: { from: number; to: number }[] = []; + for (let m = re.exec(text); m; m = re.exec(text)) { + const from = m.index; + if (ids.has(from) || (from >= exclude.from && from < exclude.to)) continue; + out.push({ from, to: from + name.length }); + } + return out; +} + +async function commentAndStringOccurrences( + ctx: EditorDocContext, + doc: Text, + target: SafeDeleteTarget, +): Promise<{ from: number; to: number }[]> { + let identifiers; + try { + identifiers = await structure.identifiers(ctx.doc, target.name); + } catch (e) { + if (isCancelled(e)) throw e; + // Without a syntax tree comments can't be told from code. + return []; + } + const ids = identifiers.map((o) => rangeToOffsets(doc, o.range)); + return textOccurrences(doc.toString(), target.name, ids, rangeToOffsets(doc, target.range)); +} + +/** No code usages, but mentions in comments / strings: show them before deleting (IDEA's preview). */ +function previewTextOccurrences( + ctx: EditorDocContext, + doc: Text, + target: SafeDeleteTarget, + hits: readonly { from: number; to: number }[], + apply: () => void, +) { + const occurrences: PreviewOccurrence[] = hits.map((h) => { + const line = doc.lineAt(h.from); + return { line: line.number - 1, text: line.text, from: h.from - line.from, to: Math.min(h.to, line.to) - line.from }; + }); + openPopup({ + kind: "preview", + title: `Safe Delete ${target.kind.toLowerCase()} '${target.name}'`, + summary: { + files: 1, + occurrences: occurrences.length, + hasFileOperations: false, + entries: [{ path: ctx.path, edits: occurrences.length }], + }, + warnings: [ + `'${target.name}' is mentioned in comments or strings. The declaration will be deleted; these mentions stay.`, + ], + occurrences, + confirmLabel: "Delete", + onConfirm: apply, + }); +} export async function safeDelete(view: EditorView): Promise { const ctx = docOf(view); @@ -353,22 +426,44 @@ export async function safeDelete(view: EditorView): Promise { return; } const { target } = resolved; - const [usages, comments] = await Promise.all([findUsages(ctx, target), commentSyntax(ctx.language)]); - if (view.state.doc !== docBefore || !view.state.selection.eq(selBefore)) return; + const comments = await commentSyntax(ctx.language); // Validate before asking: a declaration that can't be removed by lines is refused up front. const { from, to } = rangeToOffsets(docBefore, target.range); if (!deletionRange(docBefore, from, to, comments)) { showHint(view, cannotPerform(`'${target.name}' shares its line with other code`)); return; } - if (!usages.length) { - applyDeletion(view, docBefore, target, comments); + // IDEA always confirms first; usages are searched after OK. + const confirmed = await confirmChecked({ + title: "Safe Delete", + message: `Delete ${target.kind.toLowerCase()} '${target.name}'?`, + ok: "OK", + checkbox: "Search in comments and strings", + checked: searchInComments, + }); + if (!confirmed) { + view.focus(); return; } - const answer = await askUsagesDetected(`${target.kind} '${target.name}'`, usages); - if (answer === "view") viewUsages(target.name, usages); - else if (answer === "delete") applyDeletion(view, docBefore, target, comments); - else view.focus(); + searchInComments = confirmed.checked; + if (view.state.doc !== docBefore || !view.state.selection.eq(selBefore)) return; + const [usages, textHits] = await Promise.all([ + findUsages(ctx, target), + confirmed.checked ? commentAndStringOccurrences(ctx, docBefore, target) : Promise.resolve([]), + ]); + if (view.state.doc !== docBefore) return; + if (usages.length) { + const answer = await askUsagesDetected(`${target.kind} '${target.name}'`, usages); + if (answer === "view") viewUsages(target.name, usages); + else if (answer === "delete") applyDeletion(view, docBefore, target, comments); + else view.focus(); + return; + } + if (textHits.length) { + previewTextOccurrences(ctx, docBefore, target, textHits, () => applyDeletion(view, docBefore, target, comments)); + return; + } + applyDeletion(view, docBefore, target, comments); } catch (e) { if (isCancelled(e)) return; showHint(view, isUnavailable(e) ? cannotPerform("No language server is available") : cannotPerform(errorText(e))); diff --git a/src/editor/refactor/serverIntroduce.test.ts b/src/editor/refactor/serverIntroduce.test.ts new file mode 100644 index 00000000..acb40fcd --- /dev/null +++ b/src/editor/refactor/serverIntroduce.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import type { CodeAction } from "@/bindings"; +import { introducedName, occurrenceVariants } from "./serverIntroduce"; + +const spansText = (text: string, spans: { from: number; to: number }[]) => spans.map((s) => text.slice(s.from, s.to)); + +describe("introducedName", () => { + it("finds the server's variable: declaration and replaced occurrence", () => { + const before = "function f() {\n return price * 2 + 1;\n}\n"; + const after = "function f() {\n const newLocal = price * 2;\n return newLocal + 1;\n}\n"; + const found = introducedName(before, after)!; + expect(found.name).toBe("newLocal"); + expect(found.spans).toHaveLength(2); + expect(spansText(after, found.spans)).toEqual(["newLocal", "newLocal"]); + }); + + it("ignores names that were already there and keywords / types written once", () => { + const before = "void f() {\n g(a.b(), a.b());\n}\n"; + const after = "void f() {\n String b = a.b();\n g(b, b);\n}\n"; + const found = introducedName(before, after)!; + expect(found.name).toBe("b"); + // `b` already existed in `a.b()`; only the three new tokens count. + expect(found.spans.map((s) => after.slice(s.from - 1, s.to + 1))).toEqual([" b ", "(b,", " b)"]); + }); + + it("handles an extracted function placed after its call", () => { + const before = "fn main() {\n let x = 1 + 2;\n}\n"; + const after = "fn main() {\n let x = fun_name();\n}\n\nfn fun_name() -> i32 {\n 1 + 2\n}\n"; + expect(introducedName(before, after)?.name).toBe("fun_name"); + }); + + it("is null without a new name used twice", () => { + expect(introducedName("a + b", "(a + b)")).toBeNull(); + expect(introducedName("x", "x")).toBeNull(); + }); +}); + +describe("occurrenceVariants", () => { + const action = (title: string): CodeAction => ({ + title, + kind: "refactor.extract.variable", + isPreferred: false, + disabledReason: null, + edit: null, + command: null, + data: null, + server: 1, + }); + + it("pairs the single and the all-occurrences action", () => { + const one = action("Extract to local variable"); + const all = action("Extract to local variable (replace all occurrences)"); + expect(occurrenceVariants([one, all])).toEqual({ one, all }); + expect(occurrenceVariants([one])).toBeNull(); + expect(occurrenceVariants([one, action("Extract to field"), all])).toBeNull(); + }); +}); diff --git a/src/editor/refactor/serverIntroduce.ts b/src/editor/refactor/serverIntroduce.ts new file mode 100644 index 00000000..d495fc0a --- /dev/null +++ b/src/editor/refactor/serverIntroduce.ts @@ -0,0 +1,327 @@ +/** + * Introduce Variable / Constant / Field / Parameter and Extract Method through + * the language server, with IDEA's interaction around the server's edit: + * - with only a caret, the expression is chosen first (each candidate is + * highlighted while the chooser moves) and selected before the server is + * asked, so it extracts exactly that expression; + * - when the server offers both a "this occurrence" and an "all + * occurrences" variant, IDEA's "Multiple occurrences found" chooser picks; + * - after the edit the introduced name is renamed in place, with name + * suggestions from the expression (↑ / ↓). + */ +import { EditorSelection } from "@codemirror/state"; +import type { EditorView } from "@codemirror/view"; +import type { CodeAction, SyntaxNode } from "@/bindings"; +import { isCancelled } from "@/platform/ipc"; +import { shortcutLabel } from "@/platform/keymap"; +import { whenSynced } from "@/editor/api"; +import { structure, type ExpressionAt } from "@/editor/structure"; +import { docOf } from "@/editor/lsp/lsp"; +import { offsetsToRange, rangeToOffsets } from "@/editor/lsp/positions"; +import { showHint } from "@/editor/lsp/hint"; +import { caretAnchor, openPopup } from "@/editor/lsp/popupStore"; +import { applyCodeAction } from "@/editor/lsp/intentions"; +import { putSuggestion, startInplaceRename, type Span } from "@/editor/lsp/inplaceRename"; +import { clearTemporaryHighlight, highlightRanges } from "@/editor/lsp/tempHighlight"; +import type { RefactoringId } from "./catalog"; +import { expressionCandidates, expressionPreview } from "./introduce"; +import { findTextOccurrences } from "./occurrences"; +import { isIdentifier, isKeyword, suggestNames, uniqueNames } from "./names"; + +/** Refactorings that take an expression (chosen first when nothing is selected). */ +export const EXPRESSION_REFACTORINGS: ReadonlySet = new Set([ + "extractVariable", + "extractConstant", + "extractField", + "extractParameter", +]); + +/** Refactorings whose result is a new name renamed in place afterwards. */ +const NAMING_REFACTORINGS: ReadonlySet = new Set([...EXPRESSION_REFACTORINGS, "extractMethod"]); + +export function introducesName(id: RefactoringId | null): boolean { + return !!id && NAMING_REFACTORINGS.has(id); +} + +// --------------------------------------------------------------------------- +// Expression chooser + +/** + * With an empty selection: selects the expression at the caret (asking which + * one when there are several) and then runs `then`. False when there is no + * expression to choose (the caller goes on as before). + */ +export async function chooseExpressionFirst(view: EditorView, then: () => unknown): Promise { + const ctx = docOf(view); + const sel = view.state.selection.main; + if (!ctx || !sel.empty) return false; + const support = await structure.support(ctx.language).catch(() => null); + if (!support?.tree) return false; + await whenSynced(view); + const doc = view.state.doc; + let at: ExpressionAt; + try { + at = await structure.expressionAt(ctx.doc, offsetsToRange(doc, sel.from, sel.to)); + } catch (e) { + if (isCancelled(e)) return true; + return false; + } + if (view.state.doc !== doc || !view.state.selection.main.eq(sel)) return true; + const candidates = expressionCandidates(doc, at); + if (!candidates.length) return false; + const select = (node: SyntaxNode) => { + if (view.state.doc !== doc) { + showHint(view, "The document changed; invoke the refactoring again"); + return; + } + const { from, to } = rangeToOffsets(doc, node.range); + view.dispatch({ selection: EditorSelection.range(from, to), scrollIntoView: true }); + view.focus(); + void then(); + }; + if (candidates.length === 1) { + select(candidates[0]!); + return true; + } + openPopup({ + kind: "list", + anchor: caretAnchor(view, sel.head), + title: "Expressions", + items: candidates.map((c, i) => ({ + id: `${i}`, + label: expressionPreview(doc.sliceString(rangeToOffsets(doc, c.range).from, rangeToOffsets(doc, c.range).to)), + onActive: () => highlightRanges(view, [rangeToOffsets(doc, c.range)]), + run: () => select(c), + })), + onClose: () => clearTemporaryHighlight(view), + }); + return true; +} + +// --------------------------------------------------------------------------- +// Occurrences chooser + +const ALL_OCCURRENCES = /\b(all|every) (the )?occurrences?\b|\breplace all\b/i; + +/** The single-occurrence / all-occurrences pair among a refactoring's actions, if the server offers both. */ +export function occurrenceVariants(actions: readonly CodeAction[]): { one: CodeAction; all: CodeAction } | null { + const all = actions.filter((a) => ALL_OCCURRENCES.test(a.title)); + const one = actions.filter((a) => !ALL_OCCURRENCES.test(a.title)); + return all.length === 1 && one.length === 1 ? { one: one[0]!, all: all[0]! } : null; +} + +/** + * IDEA's "Multiple occurrences found" for a server that offers both variants; + * false when it does not (the caller applies / lists the actions as before). + */ +export function chooseOccurrences(view: EditorView, actions: readonly CodeAction[], id: RefactoringId): boolean { + const variants = occurrenceVariants(actions); + if (!variants) return false; + const state = view.state; + const sel = state.selection.main; + const text = state.sliceDoc(sel.from, sel.to); + const occurrences = text.trim() ? findTextOccurrences(state.doc.toString(), 0, state.doc.length, text, 200) : []; + const n = occurrences.length; + openPopup({ + kind: "list", + anchor: caretAnchor(view, sel.head), + title: "Multiple occurrences found", + items: [ + { + id: "one", + label: "Replace this occurrence only", + onActive: () => highlightRanges(view, [{ from: sel.from, to: sel.to }]), + run: () => applyIntroduceAction(view, variants.one, id), + }, + { + id: "all", + label: n > 1 ? `Replace all ${n} occurrences` : "Replace all occurrences", + onActive: () => highlightRanges(view, n ? occurrences : [{ from: sel.from, to: sel.to }]), + run: () => applyIntroduceAction(view, variants.all, id), + }, + ], + onClose: () => clearTemporaryHighlight(view), + }); + return true; +} + +// --------------------------------------------------------------------------- +// The introduced name + +const WORD = /[\p{L}\p{N}_$]/u; +const TOKEN = /[\p{L}_$][\p{L}\p{N}_$]*|\S/gu; + +interface Token { + text: string; + from: number; +} + +function tokens(text: string, offset: number): Token[] { + return [...text.matchAll(TOKEN)].map((m) => ({ text: m[0], from: offset + m.index! })); +} + +/** Tokens of `after` that are not part of the longest common token subsequence with `before`. */ +function insertedTokens(before: Token[], after: Token[]): Token[] { + const n = before.length; + const m = after.length; + if (n * m > 4_000_000) return after; + const lcs = new Uint32Array((n + 1) * (m + 1)); + const at = (i: number, j: number) => i * (m + 1) + j; + for (let i = n - 1; i >= 0; i--) + for (let j = m - 1; j >= 0; j--) + lcs[at(i, j)] = + before[i]!.text === after[j]!.text ? lcs[at(i + 1, j + 1)]! + 1 : Math.max(lcs[at(i + 1, j)]!, lcs[at(i, j + 1)]!); + const out: Token[] = []; + let i = 0; + let j = 0; + while (j < m) { + if (i < n && before[i]!.text === after[j]!.text) { + i++; + j++; + } else if (i < n && lcs[at(i + 1, j)]! >= lcs[at(i, j + 1)]!) i++; + else out.push(after[j++]!); + } + return out; +} + +/** Names servers give new declarations before the user names them. */ +const SERVER_DEFAULT = /^(new[A-Z_]\w*|newLocal\w*|var_name\d*|fun_name\d*|extracted\w*|new_\w+|value\d*)$/; + +/** + * The name a refactoring introduced, and every place the edit wrote it, from + * the text before and after (servers may apply the edit through a command, + * so the edit itself is not always known). Null when no new name appears at + * least twice (declaration and use). + */ +export function introducedName(before: string, after: string): { name: string; spans: Span[] } | null { + let start = 0; + const max = Math.min(before.length, after.length); + while (start < max && before[start] === after[start]) start++; + let end = 0; + while (end < max - start && before[before.length - 1 - end] === after[after.length - 1 - end]) end++; + // Whole words at both ends of the changed region. + while (start > 0 && WORD.test(after[start - 1]!)) start--; + let afterTo = after.length - end; + let beforeTo = before.length - end; + while (afterTo < after.length && WORD.test(after[afterTo]!)) { + afterTo++; + beforeTo++; + } + const inserted = insertedTokens(tokens(before.slice(start, beforeTo), start), tokens(after.slice(start, afterTo), start)); + const byName = new Map(); + for (const t of inserted) { + if (!WORD.test(t.text[0]!) || !isIdentifier(t.text) || isKeyword(t.text)) continue; + let list = byName.get(t.text); + if (!list) byName.set(t.text, (list = [])); + list.push({ from: t.from, to: t.from + t.text.length }); + } + let best: { name: string; spans: Span[] } | null = null; + const score = (name: string, spans: Span[]) => (SERVER_DEFAULT.test(name) ? 1000 : 0) + spans.length; + for (const [name, spans] of byName) { + if (spans.length < 2) continue; + if (!best || score(name, spans) > score(best.name, best.spans)) best = { name, spans }; + } + if (!best) return null; + // The server picked a name free in scope, so every plain (non-member) use in the changed + // region is the new one; the token alignment alone may pair one with an old member name. + const name = best.name; + const spans: Span[] = []; + for (const t of tokens(after.slice(start, afterTo), start)) { + if (t.text !== name) continue; + const lead = after.slice(Math.max(0, t.from - 2), t.from); + if (/(\.|->|::)\s*$/.test(lead) && !best.spans.some((s) => s.from === t.from)) continue; + spans.push({ from: t.from, to: t.from + name.length }); + } + return { name, spans }; +} + +async function nameSuggestions(view: EditorView, id: RefactoringId, expression: { kind: string; text: string } | null) { + const ctx = docOf(view); + if (!ctx || !expression || !EXPRESSION_REFACTORINGS.has(id)) return []; + const raw = suggestNames({ ...expression, language: ctx.language, constant: id === "extractConstant" }); + if (!raw.length) return []; + return uniqueNames(raw, async (name) => { + const found = await structure.identifiers(ctx.doc, name); + return found.some((o) => !o.qualified); + }); +} + +async function expressionInfo(view: EditorView): Promise<{ kind: string; text: string } | null> { + const ctx = docOf(view); + const sel = view.state.selection.main; + if (!ctx || sel.empty) return null; + const text = view.state.sliceDoc(sel.from, sel.to); + try { + const at = await structure.expressionAt(ctx.doc, offsetsToRange(view.state.doc, sel.from, sel.to)); + return { kind: at.exact && at.expression ? at.expression.kind : "", text }; + } catch { + return { kind: "", text }; + } +} + +async function docChange(view: EditorView, before: EditorView["state"]["doc"], ms = 1500): Promise { + for (let waited = 0; waited < ms && view.state.doc === before; waited += 20) await new Promise((r) => setTimeout(r, 20)); + return view.state.doc !== before; +} + +/** + * Applies a server extract / introduce action, then renames the introduced + * name in place (as IDEA does), with name suggestions for an expression. + */ +export async function applyIntroduceAction(view: EditorView, action: CodeAction, id: RefactoringId): Promise { + const expression = await expressionInfo(view); + const before = view.state.doc; + await applyCodeAction(view, action); + if (!(await docChange(view, before))) return; + // The server's edit may arrive in more than one transaction. + await new Promise((r) => setTimeout(r, 30)); + const doc = view.state.doc; + const found = introducedName(before.toString(), doc.toString()); + if (!found) return; + const suggestions = await nameSuggestions(view, id, expression).catch(() => [] as string[]); + if (view.state.doc !== doc) return; + const caret = view.state.selection.main.head; + // The occurrence at the caret (where the expression was), else the declaration. + const primary = found.spans.find((s) => s.from <= caret && caret <= s.to) ?? found.spans[0]!; + const others = found.spans.filter((s) => s !== primary); + const keys = shortcutLabel("refactor.rename"); + const names = suggestions.includes(found.name) ? suggestions : [...suggestions, found.name]; + view.focus(); + startInplaceRename(view, { + primary, + others, + suggestions: names.length > 1 ? names : undefined, + hint: keys ? `Press ${keys} to show dialog with more options` : "↵ to finish, Esc to keep the name", + commit: (name) => renameIntroduced(view, [primary, ...others], found.name, name), + dialog: (typed) => + openPopup({ + kind: "rename", + anchor: caretAnchor(view, primary.from), + title: `Rename '${found.name}'`, + initial: typed, + suggestions: names, + onSubmit: (name) => renameIntroduced(view, [primary, ...others], found.name, name), + }), + }); + // As in IDEA the best suggestion replaces the server's placeholder name right away. + if (suggestions[0] && suggestions[0] !== found.name) putSuggestion(view, suggestions[0]); +} + +/** Writes `name` over the introduced name's occurrences (the session restored `current` there). */ +export function renameIntroduced(view: EditorView, spans: readonly Span[], current: string, name: string) { + if (!name || name === current) return; + if (!isIdentifier(name) || isKeyword(name)) { + showHint(view, `'${name}' is not a valid name; kept '${current}'`, { tone: "warning" }); + return; + } + const state = view.state; + if (spans.some((s) => s.to > state.doc.length || state.sliceDoc(s.from, s.to) !== current)) { + showHint(view, "The document changed; the name was not applied"); + return; + } + view.dispatch({ + changes: spans.map((s) => ({ ...s, insert: name })), + userEvent: "refactor.rename", + scrollIntoView: true, + }); +} diff --git a/src/features/analysis/Analysis.module.css b/src/features/analysis/Analysis.module.css index 5f11a68c..d58ebcb1 100644 --- a/src/features/analysis/Analysis.module.css +++ b/src/features/analysis/Analysis.module.css @@ -278,3 +278,49 @@ .previewNew { color: var(--vcs-added); } + +/* Inspection Results: tree on the left, problem details on the right. */ +.panel { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} +.panel > * { + flex: 1; + min-height: 0; +} +.excluded .message, +.excluded .strong { + opacity: 0.5; + text-decoration: line-through; +} +.details { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 12px; + overflow: auto; + border-left: 1px solid var(--border); + font-size: var(--fs-ui); +} +.detailsEmpty { + align-items: center; + justify-content: center; + color: var(--fg-faint); +} +.detailsTitle { + display: flex; + align-items: center; + gap: 6px; + color: var(--fg-strong); +} +.detailsMessage { + margin: 0; + white-space: pre-wrap; + color: var(--fg); +} +.detailsActions { + display: flex; + gap: 6px; +} diff --git a/src/features/analysis/InspectionView.tsx b/src/features/analysis/InspectionView.tsx index ad967284..88673f49 100644 --- a/src/features/analysis/InspectionView.tsx +++ b/src/features/analysis/InspectionView.tsx @@ -1,13 +1,40 @@ -/** Inspection Results tool window: severity → inspection → file → problem. */ -import { useCallback, useMemo, useState } from "react"; +/** + * Inspection Results tool window: inspection → file → problem (or severity + * first, on request), with the selected problem's details on the right. + */ +import { useCallback, useMemo, useRef, useState, type KeyboardEvent } from "react"; +import clsx from "clsx"; +import { create } from "zustand"; +import { executeAction } from "@/platform/actions"; +import { shortcutLabel } from "@/platform/keymap"; import { basename, relativeDir, useProject } from "@/platform/project"; -import { editorService } from "@/workbench/contributions"; -import { EmptyState, Icon, IconButton, ProgressBar, Tree, fileIconName, type TreeRowState } from "@/ui"; +import { editorService, useOccurrenceNavigator } from "@/workbench/contributions"; +import { + Button, + EmptyState, + Icon, + IconButton, + ProgressBar, + SplitGroup, + SplitHandle, + SplitPanel, + Tree, + fileIconName, + showContextMenu, + type MenuItem, + type TreeHandle, + type TreeRowState, + T, +} from "@/ui"; import { cancelInspection, groupInspection, + problemId, rerunInspection, + severityKey, + toggleExcludedProblems, useInspection, + type InspectionKindGroup, type InspectionProblem, type SeverityKey, } from "./inspect"; @@ -15,7 +42,7 @@ import styles from "./Analysis.module.css"; type Node = | { kind: "severity"; key: string; severity: SeverityKey; count: number; children: Node[] } - | { kind: "inspection"; key: string; name: string; count: number; children: Node[] } + | { kind: "inspection"; key: string; name: string; severity: SeverityKey; count: number; children: Node[] } | { kind: "file"; key: string; path: string; count: number; children: Node[] } | { kind: "problem"; key: string; p: InspectionProblem }; @@ -25,47 +52,152 @@ const SEVERITY_LABEL: Record = { info: "Weak warnings and hints", }; -export function buildInspectionNodes(groups: ReturnType): Node[] { +// --------------------------------------------------------------------------- +// View options (persisted), in the tool window's ⋮ menu. IDEA's defaults: +// grouped by inspection, no autoscroll. + +const OPTIONS_KEY = "openidea.inspection.view.v1"; + +interface InspectionOptions { + groupBySeverity: boolean; + autoscroll: boolean; +} + +function loadOptions(): Partial { + try { + return JSON.parse(globalThis.localStorage?.getItem(OPTIONS_KEY) ?? "{}") as Partial; + } catch { + return {}; + } +} + +export const useInspectionOptions = create(() => ({ + groupBySeverity: false, + autoscroll: false, + ...loadOptions(), +})); + +useInspectionOptions.subscribe((s) => { + try { + globalThis.localStorage?.setItem(OPTIONS_KEY, JSON.stringify(s)); + } catch { + /* storage unavailable */ + } +}); + +/** The Inspection Results tool window's ⋮ menu entries. */ +export function useInspectionHeaderMenu(): MenuItem[] { + const o = useInspectionOptions(); + return [ + { + id: "groupBySeverity", + label: "Group by Severity", + checked: o.groupBySeverity, + run: () => useInspectionOptions.setState({ groupBySeverity: !o.groupBySeverity }), + }, + { + id: "autoscroll", + label: "Autoscroll to Source", + checked: o.autoscroll, + run: () => useInspectionOptions.setState({ autoscroll: !o.autoscroll }), + }, + ]; +} + +const SEVERITY_RANK: Record = { error: 0, warning: 1, info: 2 }; + +/** Inspections across severities, merged by name (the same check may report errors and warnings). */ +function mergeKinds( + groups: ReturnType, +): Array { + const byKind = new Map(); + for (const g of groups) { + for (const k of g.kinds) { + const prev = byKind.get(k.kind); + if (!prev) { + byKind.set(k.kind, { + ...k, + files: k.files.map((f) => ({ ...f, problems: [...f.problems] })), + severity: g.severity, + }); + continue; + } + prev.count += k.count; + for (const f of k.files) { + const same = prev.files.find((x) => x.path === f.path); + if (same) same.problems.push(...f.problems); + else prev.files.push({ ...f, problems: [...f.problems] }); + } + } + } + const pos = (p: InspectionProblem) => p.d.range.start.line * 1e6 + p.d.range.start.col; + return [...byKind.values()] + .map((k) => { + k.files.sort((a, b) => a.relPath.localeCompare(b.relPath)); + k.files.forEach((f) => f.problems.sort((a, b) => pos(a) - pos(b))); + return k; + }) + .sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || a.kind.localeCompare(b.kind)); +} + +function kindNode(k: InspectionKindGroup, severity: SeverityKey, prefix: string): Node { + const kindKey = `${prefix}\u0000${k.kind}`; + return { + kind: "inspection", + key: kindKey, + name: k.kind, + severity, + count: k.count, + children: k.files.map((f) => { + const fileKey = `${kindKey}\u0000${f.path}`; + return { + kind: "file", + key: fileKey, + path: f.path, + count: f.problems.length, + children: f.problems.map((p, i) => ({ kind: "problem", key: `${fileKey}\u0000${i}`, p }) as Node), + } as Node; + }), + }; +} + +/** Result tree: inspection → file → problem, under severity groups with `bySeverity`. */ +export function buildInspectionNodes(groups: ReturnType, bySeverity = true): Node[] { + if (!bySeverity) return mergeKinds(groups).map((k) => kindNode(k, k.severity, "kind")); return groups.map((s) => ({ kind: "severity", key: s.severity, severity: s.severity, count: s.count, - children: s.kinds.map((k) => { - const kindKey = `${s.severity}\u0000${k.kind}`; - return { - kind: "inspection", - key: kindKey, - name: k.kind, - count: k.count, - children: k.files.map((f) => { - const fileKey = `${kindKey}\u0000${f.path}`; - return { - kind: "file", - key: fileKey, - path: f.path, - count: f.problems.length, - children: f.problems.map((p, i) => ({ kind: "problem", key: `${fileKey}\u0000${i}`, p }) as Node), - } as Node; - }), - } as Node; - }), + children: s.kinds.map((k) => kindNode(k, s.severity, s.severity)), })); } -function open(p: InspectionProblem, focus = true) { - void editorService().openFile(p.path, { +function nodeProblems(n: Node): InspectionProblem[] { + return n.kind === "problem" ? [n.p] : n.children.flatMap(nodeProblems); +} + +function open(p: InspectionProblem, focus = true): Promise { + return editorService().openFile(p.path, { selection: { line: p.d.range.start.line, col: p.d.range.start.col }, focus, preview: !focus, }); } +/** Jump to the problem and show the editor's quick fixes there. */ +async function showFixes(p: InspectionProblem) { + await open(p, true); + void executeAction("code.showIntentions"); +} + export function InspectionHeaderActions() { const running = useInspection((s) => s.running); const last = useInspection((s) => s.last); return ( <> + + `${n} ${w}${n === 1 ? "" : "s"}`; export function InspectionView() { - const { files, running, total, done, summary, scopeLabel, error } = useInspection(); + const { files, running, total, done, summary, scopeLabel, error, excluded } = useInspection(); + const bySeverity = useInspectionOptions((o) => o.groupBySeverity); const root = useProject((s) => s.project?.root ?? ""); - const roots = useMemo(() => buildInspectionNodes(groupInspection(files.values())), [files]); + const roots = useMemo(() => buildInspectionNodes(groupInspection(files.values()), bySeverity), [files, bySeverity]); const [collapsed, setCollapsed] = useState>(() => new Set()); - // Severities and inspections start expanded; files too unless there are many problems. + const [lead, setLead] = useState(null); + const tree = useRef(null); + // Groups start expanded; files too unless there are many problems. const expanded = useMemo(() => { const out = new Set(); - const total = roots.reduce((n, r) => n + (r.kind === "severity" ? r.count : 0), 0); - const depthOpen = total <= 200 ? 3 : 2; + const total = roots.reduce((n, r) => n + (r.kind === "problem" ? 0 : r.count), 0); + const depthOpen = (total <= 200 ? 3 : 2) - (bySeverity ? 0 : 1); const visit = (nodes: Node[], depth: number) => { for (const n of nodes) { if (n.kind === "problem") continue; @@ -99,15 +234,40 @@ export function InspectionView() { }; visit(roots, 0); return out; - }, [roots, collapsed]); + }, [roots, collapsed, bySeverity]); const getChildren = useCallback((n: Node) => (n.kind === "problem" ? undefined : n.children), []); + const isExcluded = (n: Node) => nodeProblems(n).every((p) => excluded.has(problemId(p))); + + useOccurrenceNavigator("inspection", (dir, focus) => { + const step = tree.current?.selectOccurrence(dir); + const n = step?.node as Node | undefined; + if (!step || n?.kind !== "problem") return null; + void open(n.p, focus); + return step; + }); + + const exclude = (n: Node | null) => { + if (!n) return; + const problems = nodeProblems(n); + const wasExcluded = isExcluded(n); + toggleExcludedProblems(problems); + // Excluding moves on to the next problem, as in IDEA. + if (!wasExcluded) tree.current?.selectOccurrence(1, { skipSubtree: true }); + }; + const onKeyDown = (e: KeyboardEvent) => { + if ((e.key === "Backspace" || e.key === "Delete") && !e.metaKey && !e.altKey && !e.ctrlKey) { + e.preventDefault(); + exclude(lead); + } + }; const renderNode = (n: Node, state: TreeRowState) => { + const off = isExcluded(n) && styles.excluded; switch (n.kind) { case "severity": return ( - + {SEVERITY_LABEL[n.severity]} {plural(n.count, "problem")} @@ -115,14 +275,15 @@ export function InspectionView() { ); case "inspection": return ( - + + {!bySeverity && } {n.name} {n.count} ); case "file": return ( - + {basename(n.path)} {relativeDir(root, n.path)} @@ -131,7 +292,7 @@ export function InspectionView() { ); case "problem": return ( - + {n.p.d.message.split("\n")[0]} :{n.p.d.range.start.line + 1} @@ -145,7 +306,8 @@ export function InspectionView() { ? `${scopeLabel}: ${plural(summary.problems, "problem")} in ${plural(summary.files, "file")}` + (summary.skipped ? `, ${summary.skipped} without a language server` : "") + (summary.failed ? `, ${summary.failed} failed` : "") + - (summary.cancelled ? " (stopped)" : "") + (summary.cancelled ? " (stopped)" : "") + + (excluded.size ? `, ${excluded.size} excluded` : "") : null; return ( @@ -162,52 +324,132 @@ export function InspectionView() { )}
)} -
- - aria-label="Inspection results" - roots={roots} - getKey={(n) => n.key} - getLabel={(n) => - n.kind === "severity" - ? SEVERITY_LABEL[n.severity] - : n.kind === "inspection" - ? n.name - : n.kind === "file" - ? basename(n.path) - : n.p.d.message - } - getChildren={getChildren} - isExpandable={(n) => n.kind !== "problem"} - 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) => { - if (n.kind === "problem") open(n.p); - }} - onSelectionChange={(_sel, lead) => { - if (lead?.kind === "problem") open(lead.p, false); - }} - activateExpandable={false} - multiSelect={false} - empty={ - error ? ( - - ) : running ? ( - - ) : summary ? ( - - ) : ( - - ) - } - /> +
+ + + + aria-label="Inspection results" + handleRef={tree} + roots={roots} + getKey={(n) => n.key} + getLabel={(n) => + n.kind === "severity" + ? SEVERITY_LABEL[n.severity] + : n.kind === "inspection" + ? n.name + : n.kind === "file" + ? basename(n.path) + : n.p.d.message + } + getChildren={getChildren} + isExpandable={(n) => n.kind !== "problem"} + isOccurrence={(n) => n.kind === "problem" && !excluded.has(problemId(n.p))} + 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) => { + if (n.kind === "problem") void open(n.p); + }} + onSelectionChange={(_sel, node) => { + setLead(node); + // Autoscroll to Source (off by default, as in IDEA). + if (node?.kind === "problem" && useInspectionOptions.getState().autoscroll) void open(node.p, false); + }} + onContextMenu={(n, _sel, e) => { + if (!n) return; + showContextMenu(e, [ + ...(n.kind === "problem" + ? [ + { id: "jump", label: "Jump to Source", shortcut: "F4", run: () => void open(n.p) }, + { + id: "fixes", + label: "Show Quick Fixes", + icon: "bulb", + shortcut: shortcutLabel("code.showIntentions") ?? undefined, + run: () => void showFixes(n.p), + }, + { kind: "separator" as const }, + ] + : []), + { id: "exclude", label: isExcluded(n) ? "Include" : "Exclude", shortcut: "⌫", run: () => exclude(n) }, + ]); + }} + activateExpandable={false} + multiSelect={false} + empty={ + error ? ( + + ) : running ? ( + + ) : summary ? ( + + ) : ( + + ) + } + /> + + {roots.length > 0 && ( + <> + + + + + + )} + +
+
+ ); +} + +/** Right pane: the selected problem (or group) in full, with its actions. */ +function ProblemDetails({ node, root }: { node: Node | null; root: string }) { + if (!node || node.kind === "severity" || node.kind === "file") { + const count = node ? nodeProblems(node).length : 0; + return ( +
+ {node ? plural(count, "problem") : Select a problem to see its details} +
+ ); + } + if (node.kind === "inspection") { + return ( +
+
{node.name}
+
+ {SEVERITY_LABEL[node.severity]} · {plural(node.count, "problem")} +
+
+ ); + } + const { p } = node; + const sev = severityKey(p.d); + return ( +
+
+ + {p.d.source ? `${p.d.source}${p.d.code ? ` (${p.d.code})` : ""}` : Language server} +
+

{p.d.message}

+
+ {relativeDir(root, p.path) ? `${relativeDir(root, p.path)}/` : ""} + {basename(p.path)}:{p.d.range.start.line + 1}:{p.d.range.start.col + 1} +
+
+ +
); diff --git a/src/features/analysis/analysis.test.ts b/src/features/analysis/analysis.test.ts index f029a282..c783b2a7 100644 --- a/src/features/analysis/analysis.test.ts +++ b/src/features/analysis/analysis.test.ts @@ -68,6 +68,23 @@ describe("Inspect Code grouping", () => { expect(new Set(keys).size).toBe(keys.length); expect(nodes[0]!.kind).toBe("severity"); }); + + it("groups by inspection by default, worst severity first, merging severities", () => { + const withWarning = [...files, { path: "/p/d.ts", relPath: "d.ts", diagnostics: [diag(5, "warning", "ts", "2304")] }]; + const nodes = buildInspectionNodes(groupInspection(withWarning), false); + expect(nodes.map((n) => (n.kind === "inspection" ? [n.name, n.severity, n.count] : n.kind))).toEqual([ + ["ts(2304)", "error", 4], + ["ts(7006)", "error", 1], + ["eslint(no-unused-vars)", "warning", 1], + ["Language server", "info", 1], + ]); + const ts2304 = nodes[0]!; + expect(ts2304.kind !== "problem" && ts2304.children.map((f) => (f.kind === "file" ? f.path : ""))).toEqual([ + "/p/b.ts", + "/p/c.ts", + "/p/d.ts", + ]); + }); }); describe("Structural replace", () => { diff --git a/src/features/analysis/index.ts b/src/features/analysis/index.ts index a70ce1eb..aa0116bd 100644 --- a/src/features/analysis/index.ts +++ b/src/features/analysis/index.ts @@ -10,7 +10,7 @@ import { AnalysisDialogHost } from "./dialogs"; import { resetDuplicates } from "./duplicates"; import { DuplicatesHeaderActions, DuplicatesView } from "./DuplicatesView"; import { resetInspection } from "./inspect"; -import { InspectionHeaderActions, InspectionView } from "./InspectionView"; +import { InspectionHeaderActions, InspectionView, useInspectionHeaderMenu } from "./InspectionView"; export function activate(): () => void { const disposers = [ @@ -29,6 +29,7 @@ export function activate(): () => void { order: 36, component: InspectionView, headerActions: InspectionHeaderActions, + headerMenu: useInspectionHeaderMenu, }), registerToolWindow({ id: "duplicates", diff --git a/src/features/analysis/inspect.ts b/src/features/analysis/inspect.ts index ccb89585..505aa434 100644 --- a/src/features/analysis/inspect.ts +++ b/src/features/analysis/inspect.ts @@ -99,6 +99,8 @@ interface InspectState { error: string | null; /** Scope of the last run (Rerun). */ last: ScopeChoice | null; + /** Problems excluded from the results (⌫), by `problemId`. */ + excluded: ReadonlySet; } const initialInspection = (): InspectState => ({ @@ -110,8 +112,22 @@ const initialInspection = (): InspectState => ({ scopeLabel: null, error: null, last: null, + excluded: new Set(), }); +/** Stable identity of a problem across regroupings. */ +export const problemId = (p: InspectionProblem) => + `${p.path}\u0000${p.d.range.start.line}:${p.d.range.start.col}\u0000${kindOf(p.d)}\u0000${p.d.message}`; + +/** Excludes `problems`, or includes them again when all are excluded already. */ +export function toggleExcludedProblems(problems: readonly InspectionProblem[]) { + const ids = problems.map(problemId); + const excluded = new Set(useInspection.getState().excluded); + if (ids.every((id) => excluded.has(id))) ids.forEach((id) => excluded.delete(id)); + else ids.forEach((id) => excluded.add(id)); + useInspection.setState({ excluded }); +} + export const useInspection = create(initialInspection); let inflight: RequestId | null = null; @@ -146,6 +162,7 @@ export async function runInspection(choice: ScopeChoice): Promise { error: null, scopeLabel: describeScope(choice, scope), last: choice, + excluded: new Set(), }); toolWindowService().show("inspection"); if (!isTauri()) { diff --git a/src/features/app/__tests__/editCommand.test.ts b/src/features/app/__tests__/editCommand.test.ts new file mode 100644 index 00000000..6d1460cf --- /dev/null +++ b/src/features/app/__tests__/editCommand.test.ts @@ -0,0 +1,23 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { history } from "@codemirror/commands"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { editCommand } from "../appActions"; + +describe("Edit menu Undo / Redo", () => { + let view: EditorView | null = null; + afterEach(() => { + view?.destroy(); + view = null; + }); + + it("use the focused editor's history, including programmatic edits", () => { + view = new EditorView({ state: EditorState.create({ doc: "a", extensions: history() }), parent: document.body }); + view.dispatch({ changes: { from: 1, insert: "b" }, userEvent: "input.complete" }); + view.contentDOM.focus(); + editCommand("undo")(); + expect(view.state.doc.toString()).toBe("a"); + editCommand("redo")(); + expect(view.state.doc.toString()).toBe("ab"); + }); +}); diff --git a/src/features/app/appActions.ts b/src/features/app/appActions.ts index a78b961e..e6a95375 100644 --- a/src/features/app/appActions.ts +++ b/src/features/app/appActions.ts @@ -1,3 +1,5 @@ +import { redo, undo } from "@codemirror/commands"; +import { EditorView } from "@codemirror/view"; import type { Action } from "@/platform/actions"; import { t } from "@/platform/i18n"; import { call, commands, isTauri, type UpdateInfo } from "@/platform/ipc"; @@ -77,11 +79,20 @@ function notifyUpdate(update: UpdateInfo) { /** * Edit menu items on Windows/Linux (macOS uses native roles). They act on the - * focused element like the native commands would; CodeMirror handles the - * resulting `beforeinput` history events itself. + * focused element like the native commands would. Undo / Redo in an editor go + * to its own history: the native stack misses programmatic edits (templates, + * completion, refactorings). */ -function editCommand(command: "undo" | "redo" | "cut" | "copy" | "paste" | "selectAll") { +export function editCommand(command: "undo" | "redo" | "cut" | "copy" | "paste" | "selectAll") { return () => { + if (command === "undo" || command === "redo") { + const focused = document.activeElement; + const view = focused instanceof HTMLElement ? EditorView.findFromDOM(focused) : null; + if (view) { + (command === "undo" ? undo : redo)(view); + return; + } + } if (command === "paste" && navigator.clipboard?.readText) { void navigator.clipboard.readText().then((text) => document.execCommand("insertText", false, text)); return; diff --git a/src/features/code-insight/HierarchyView.tsx b/src/features/code-insight/HierarchyView.tsx index 55f6b90b..4ea71acd 100644 --- a/src/features/code-insight/HierarchyView.tsx +++ b/src/features/code-insight/HierarchyView.tsx @@ -1,11 +1,25 @@ /** "hierarchy" tool window: call hierarchy (⌃⌥H) and type hierarchy (⌃H). */ -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useRef } from "react"; +import { create } from "zustand"; import { basename, relativePath, useProject } from "@/platform/project"; -import { EmptyState, IconButton, Spinner, SymbolBadge, Tabs, Tree, badgeKind, type TreeRowState, T } from "@/ui"; +import { maybeEditorService, useOccurrenceNavigator } from "@/workbench/contributions"; +import { + EmptyState, + IconButton, + Spinner, + SymbolBadge, + Tabs, + Tree, + badgeKind, + type TreeHandle, + type TreeRowState, + T, +} from "@/ui"; import { openLocation } from "./ipc"; import { ROOT_ID, directionLabel, + hierarchyTarget, loadChildren, refreshHierarchy, setDirection, @@ -16,6 +30,25 @@ import styles from "./HierarchyView.module.css"; type Node = { kind: "root"; id: string } | { kind: "item"; id: string; node: HierarchyNode }; +const OPTIONS_KEY = "openidea.hierarchy.view.v1"; + +/** Autoscroll to Source: selecting a node shows it in the editor (IDEA: off). Persisted. */ +export const useHierarchyOptions = create<{ autoscroll: boolean }>(() => { + try { + return { autoscroll: false, ...JSON.parse(globalThis.localStorage?.getItem(OPTIONS_KEY) ?? "{}") }; + } catch { + return { autoscroll: false }; + } +}); + +useHierarchyOptions.subscribe((s) => { + try { + globalThis.localStorage?.setItem(OPTIONS_KEY, JSON.stringify(s)); + } catch { + /* storage unavailable */ + } +}); + /** Tool window header: `Callers of foo` content tab. */ export function HierarchyTabs() { const mode = useHierarchy((s) => s.mode); @@ -38,6 +71,7 @@ export function HierarchyTabs() { function HierarchyToolbar() { const mode = useHierarchy((s) => s.mode); const direction = useHierarchy((s) => s.direction); + const autoscroll = useHierarchyOptions((s) => s.autoscroll); return (
+ + + + useHierarchyOptions.setState({ autoscroll: !autoscroll })} + />
); } @@ -78,6 +123,23 @@ export function HierarchyView() { }, [children], ); + const tree = useRef(null); + const targetOf = (n: Node) => { + if (n.kind === "item") return hierarchyTarget(mode, direction, n.node.item); + return root ? { path: root.path, line: root.line, col: root.col } : null; + }; + const show = (n: Node, focus: boolean) => { + const t = targetOf(n); + if (t) void maybeEditorService()?.openFile(t.path, { selection: { line: t.line, col: t.col }, focus }); + }; + + // Every node is an occurrence, as in IDEA's hierarchy views. + useOccurrenceNavigator("hierarchy", (dir, focus) => { + const step = tree.current?.selectOccurrence(dir); + if (!step) return null; + show(step.node as Node, focus); + return step; + }); if (!root) return ( @@ -141,15 +203,17 @@ export function HierarchyView() { isExpandable={isExpandable} loadChildren={(n) => (n.kind === "item" ? loadChildren(n.node) : undefined)} defaultExpanded={[ROOT_ID]} + handleRef={tree} + isOccurrence={() => true} + onSelectionChange={(_sel, n) => { + if (n && useHierarchyOptions.getState().autoscroll) show(n, false); + }} renderNode={renderNode} activateExpandable multiSelect={false} onActivate={(n) => { - if (n.kind === "root") openLocation(root.path, root.line, root.col); - else { - const target = n.node.item.selectionRange.start; - openLocation(n.node.item.path, target.line, target.col); - } + const target = n.kind === "root" ? root : hierarchyTarget(mode, direction, n.node.item); + void openLocation(target.path, target.line, target.col); }} />
diff --git a/src/features/code-insight/ProblemsView.module.css b/src/features/code-insight/ProblemsView.module.css index fda40a6a..be2e71b6 100644 --- a/src/features/code-insight/ProblemsView.module.css +++ b/src/features/code-insight/ProblemsView.module.css @@ -89,3 +89,13 @@ flex: none; color: var(--info); } +.panel { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} +.panel > * { + flex: 1; + min-height: 0; +} diff --git a/src/features/code-insight/ProblemsView.tsx b/src/features/code-insight/ProblemsView.tsx index cfe9f1e2..5b60883a 100644 --- a/src/features/code-insight/ProblemsView.tsx +++ b/src/features/code-insight/ProblemsView.tsx @@ -1,13 +1,40 @@ /** Problems tool window: "File" (current editor) and "Project Errors" tabs. */ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useRef, useState, type KeyboardEvent } from "react"; import { create } from "zustand"; import clsx from "clsx"; import type { Diagnostic, Severity } from "@/bindings"; import { useEditorRegistry } from "@/editor/api"; +import { executeAction } from "@/platform/actions"; +import { formatBinding, hasPrimaryModifier, hostBinding, shortcutLabel } from "@/platform/keymap"; import { basename, relativeDir, useProject } from "@/platform/project"; -import { editorService } from "@/workbench/contributions"; -import { EmptyState, Icon, IconButton, Tabs, Tree, fileIconName, type TreeRowState, T } from "@/ui"; -import { countSeverities, fileDiagnostics, useProblems, type ProblemsMap } from "./problemsStore"; +import { editorService, useOccurrenceNavigator } from "@/workbench/contributions"; +import { + EmptyState, + Icon, + IconButton, + SourcePreview, + SplitGroup, + SplitHandle, + SplitPanel, + Tabs, + Tree, + fileIconName, + showContextMenu, + type MenuItem, + type TreeHandle, + type TreeRowState, + T, +} from "@/ui"; +import { previewText } from "./ipc"; +import { + DEFAULT_PROBLEM_ORDER, + countSeverities, + fileDiagnostics, + sortProblems, + useProblems, + type ProblemOrder, + type ProblemsMap, +} from "./problemsStore"; import styles from "./ProblemsView.module.css"; type Tab = "file" | "project"; @@ -26,6 +53,56 @@ export const useProblemsView = create((set) => ({ toggle: (sev) => set((s) => ({ show: { ...s.show, [sev]: !s.show[sev] } })), })); +// --------------------------------------------------------------------------- +// View options (persisted), in the tool window's ⋮ menu. IDEA's defaults: +// no autoscroll, no preview, sorted by severity. + +const OPTIONS_KEY = "openidea.problems.view.v1"; + +interface ProblemsOptions extends ProblemOrder { + /** Selecting a problem opens it in the editor. */ + autoscroll: boolean; + /** Source preview beside the list. */ + preview: boolean; +} + +function loadOptions(): Partial { + try { + return JSON.parse(globalThis.localStorage?.getItem(OPTIONS_KEY) ?? "{}") as Partial; + } catch { + return {}; + } +} + +export const useProblemsOptions = create(() => ({ + autoscroll: false, + preview: false, + ...DEFAULT_PROBLEM_ORDER, + ...loadOptions(), +})); + +useProblemsOptions.subscribe((s) => { + try { + globalThis.localStorage?.setItem(OPTIONS_KEY, JSON.stringify(s)); + } catch { + /* storage unavailable */ + } +}); + +/** The Problems tool window's ⋮ menu entries. */ +export function useProblemsHeaderMenu(): MenuItem[] { + const o = useProblemsOptions(); + const toggle = (key: keyof ProblemsOptions) => () => useProblemsOptions.setState({ [key]: !o[key] }); + return [ + { kind: "header", label: "Sort By" }, + { id: "bySeverity", label: "Severity", checked: o.bySeverity, run: toggle("bySeverity") }, + { id: "byName", label: "Name", checked: o.byName, run: toggle("byName") }, + { kind: "separator" }, + { id: "preview", label: "Show Preview", checked: o.preview, run: toggle("preview") }, + { id: "autoscroll", label: "Autoscroll to Source", checked: o.autoscroll, run: toggle("autoscroll") }, + ]; +} + const group = (s: Severity): "error" | "warning" | "info" => (s === "hint" ? "info" : s); type Node = @@ -36,10 +113,18 @@ const plural = (n: number, word: string) => (n ? `${n} ${word}${n === 1 ? "" : " const SEVERITY_ICON: Record = { error: "error", warning: "warning", info: "info", hint: "info" }; -export function buildProblemNodes(map: ProblemsMap, paths: readonly string[], show: ViewState["show"]): Node[] { +export function buildProblemNodes( + map: ProblemsMap, + paths: readonly string[], + show: ViewState["show"], + order: ProblemOrder = DEFAULT_PROBLEM_ORDER, +): Node[] { const files: Node[] = []; for (const path of paths) { - const items = fileDiagnostics(map, path).filter((d) => show[group(d.severity)]); + const items = sortProblems( + fileDiagnostics(map, path).filter((d) => show[group(d.severity)]), + order, + ); if (!items.length) continue; files.push({ kind: "file", key: path, path, items }); } @@ -47,14 +132,22 @@ export function buildProblemNodes(map: ProblemsMap, paths: readonly string[], sh return files.sort((a, b) => rank(a) - rank(b) || a.path.localeCompare(b.path)); } -function open(path: string, d: Diagnostic, focus = true) { - void editorService().openFile(path, { +function open(path: string, d: Diagnostic, focus = true): Promise { + return editorService().openFile(path, { selection: { line: d.range.start.line, col: d.range.start.col }, focus, preview: !focus, }); } +const problemText = (d: Diagnostic) => `${d.message}${d.source ? ` (${d.source}${d.code ? `(${d.code})` : ""})` : ""}`; + +/** ⌥⏎ on a problem: jump to it and show its quick fixes (the editor's context actions). */ +async function showQuickFixes(path: string, d: Diagnostic) { + await open(path, d, true); + void executeAction("code.showIntentions"); +} + function useCurrentPath(): string | null { return useEditorRegistry((s) => s.active?.ctx.path ?? null); } @@ -64,6 +157,8 @@ export function ProblemsHeaderActions() { const toggle = useProblemsView((s) => s.toggle); return ( <> + + toggle("error")} /> s.map); const { tab, show } = useProblemsView(); + const options = useProblemsOptions(); const current = useCurrentPath(); const root = useProject((s) => s.project?.root ?? ""); const [collapsed, setCollapsed] = useState>(() => new Set()); + const [lead, setLead] = useState(null); + const tree = useRef(null); + const order = useMemo( + () => ({ bySeverity: options.bySeverity, byName: options.byName }), + [options.bySeverity, options.byName], + ); const roots = useMemo(() => { const paths = tab === "file" ? (current ? [current] : []) : [...map.keys()]; - return buildProblemNodes(map, paths, show); - }, [map, tab, current, show]); + return buildProblemNodes(map, paths, show, order); + }, [map, tab, current, show, order]); + + useOccurrenceNavigator("problems", (dir, focus) => { + const step = tree.current?.selectOccurrence(dir); + const n = step?.node as Node | undefined; + if (!step || n?.kind !== "diag") return null; + void open(n.path, n.d, focus); + return step; + }); + + const target = (n: Node | null) => + n?.kind === "diag" ? { path: n.path, d: n.d } : n?.items[0] ? { path: n.path, d: n.items[0] } : null; + const copy = (n: Node | null) => { + if (n?.kind === "diag") void navigator.clipboard?.writeText(problemText(n.d)); + }; + const onKeyDownCapture = (e: KeyboardEvent) => { + const t = target(lead); + if (e.key === "Enter" && e.altKey && !e.metaKey && !e.ctrlKey && t) { + e.preventDefault(); + e.stopPropagation(); + void showQuickFixes(t.path, t.d); + } else if ((e.key === "c" || e.key === "C") && hasPrimaryModifier(e) && !e.altKey && lead?.kind === "diag") { + e.preventDefault(); + e.stopPropagation(); + copy(lead); + } + }; const expanded = useMemo( () => new Set(roots.filter((r) => !collapsed.has(r.key)).map((r) => r.key)), @@ -196,39 +324,93 @@ export function ProblemsView() { ); + const selected = target(lead); + const list = ( + + aria-label="Problems" + handleRef={tree} + roots={roots} + getKey={(n) => n.key} + getLabel={(n) => (n.kind === "file" ? basename(n.path) : n.d.message)} + getChildren={getChildren} + isExpandable={(n) => n.kind === "file"} + 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) => { + if (n.kind === "diag") void open(n.path, n.d); + else if (n.items[0]) void open(n.path, n.items[0]); + }} + onSelectionChange={(_sel, node) => { + setLead(node); + // Autoscroll to Source (off by default, as in IDEA): show the problem without leaving the list. + if (node?.kind === "diag" && useProblemsOptions.getState().autoscroll) void open(node.path, node.d, false); + }} + onContextMenu={(n, _sel, e) => { + const t = target(n); + if (!n || !t) return; + showContextMenu(e, [ + { + id: "showFixes", + label: "Show Quick Fixes", + icon: "bulb", + shortcut: shortcutLabel("code.showIntentions") ?? undefined, + run: () => void showQuickFixes(t.path, t.d), + }, + { id: "jump", label: "Jump to Source", shortcut: "F4", run: () => void open(t.path, t.d) }, + { kind: "separator" }, + { + id: "copy", + label: "Copy Problem Description", + icon: "copy", + shortcut: formatBinding(hostBinding("Meta+C")), + disabled: n.kind !== "diag", + run: () => copy(n), + }, + ]); + }} + activateExpandable={false} + multiSelect={false} + empty={empty} + /> + ); + return (
-
- - aria-label="Problems" - roots={roots} - getKey={(n) => n.key} - getLabel={(n) => (n.kind === "file" ? basename(n.path) : n.d.message)} - getChildren={getChildren} - isExpandable={(n) => n.kind === "file"} - 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) => { - if (n.kind === "diag") open(n.path, n.d); - else if (n.items[0]) open(n.path, n.items[0]); - }} - onSelectionChange={(_sel, lead) => { - // IDEA's autoscroll-to-source: selecting a problem shows it without leaving the list. - if (lead?.kind === "diag") open(lead.path, lead.d, false); - }} - activateExpandable={false} - multiSelect={false} - empty={empty} - /> +
+ {options.preview ? ( + + + {list} + + + + + + + ) : ( + list + )}
); } + +/** The problem's range on its first line. */ +function diagHighlight(d: Diagnostic) { + const { start, end } = d.range; + const to = end.line === start.line ? end.col : Number.MAX_SAFE_INTEGER; + return { line: start.line, ranges: [[start.col, Math.max(start.col + 1, to)] as const] }; +} diff --git a/src/features/code-insight/UsagesPopup.module.css b/src/features/code-insight/UsagesPopup.module.css index d00d9893..92554fbe 100644 --- a/src/features/code-insight/UsagesPopup.module.css +++ b/src/features/code-insight/UsagesPopup.module.css @@ -77,3 +77,47 @@ font-style: italic; color: var(--fg-subtle); } + +.focus { + display: flex; + flex-direction: column; + min-height: 0; + outline: none; +} +.filter { + flex: none; + font-family: var(--font-mono); + color: var(--fg-strong); + background: var(--accent-soft); + border-radius: var(--radius-small); + padding: 0 4px; +} +.noMatch { + color: var(--danger); +} +.empty { + padding: 4px 10px; + color: var(--fg-faint); + font-size: var(--fs-small); +} +.preview { + flex: none; + height: 200px; + border-top: 1px solid var(--border-popup); + overflow: hidden; +} +.previewEditor { + height: 100%; +} +.previewEditor :global(.cm-editor) { + height: 100%; +} +.previewHidden { + visibility: hidden; +} +.previewLine { + background: var(--bg-current-line); +} +.previewMatch { + background: var(--bg-text-search-result); +} diff --git a/src/features/code-insight/UsagesPopup.tsx b/src/features/code-insight/UsagesPopup.tsx index 1c74f73a..96537c65 100644 --- a/src/features/code-insight/UsagesPopup.tsx +++ b/src/features/code-insight/UsagesPopup.tsx @@ -1,70 +1,241 @@ -/** ⌥⌘F7 Show Usages: popup list at the caret. */ -import { useMemo, useState } from "react"; +/** + * ⌥⌘F7 Show Usages: popup list at the caret with speed search (typing + * filters), an optional code preview and "Open in Find Window", which moves + * the found usages to the Find Usages tool window. + */ +import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; +import { create } from "zustand"; +import clsx from "clsx"; +import { EditorState, StateEffect, StateField } from "@codemirror/state"; +import { Decoration, EditorView } from "@codemirror/view"; +import { codePreviewExtensions } from "@/editor/api"; import { basename } from "@/platform/project"; -import { executeAction } from "@/platform/actions"; import { shortcutLabel } from "@/platform/keymap"; -import { Icon, List, Popup, fileIconName } from "@/ui"; -import { openLocation } from "./ipc"; -import { usageCount, useUsagesPopup, type Usage } from "./usages"; +import { Icon, IconButton, List, Popup, fileIconName, listNavigate } from "@/ui"; +import { openLocation, readFileLines } from "./ipc"; +import { showUsageResults, usageCount, useUsagesPopup, type Usage, type UsageGroup } from "./usages"; import { UsagePreview } from "./UsagesView"; import styles from "./UsagesPopup.module.css"; const close = () => useUsagesPopup.setState({ open: false }); +const PREVIEW_KEY = "openidea.usages.popupPreview.v1"; + +function loadPreviewOption(): boolean { + try { + return localStorage.getItem(PREVIEW_KEY) === "true"; + } catch { + return false; + } +} + +/** The preview pane toggle (remembered). */ +export const useUsagesPopupPreview = create<{ preview: boolean }>(() => ({ preview: loadPreviewOption() })); + +export function setUsagesPopupPreview(preview: boolean) { + useUsagesPopupPreview.setState({ preview }); + try { + localStorage.setItem(PREVIEW_KEY, String(preview)); + } catch { + // storage unavailable: the toggle lasts for the session + } +} + +/** Usages whose file name or line text contains the typed text (case-insensitive). */ +export function filterUsages(items: readonly Usage[], filter: string): Usage[] { + const q = filter.trim().toLowerCase(); + if (!q) return items as Usage[]; + return items.filter((u) => u.text.toLowerCase().includes(q) || basename(u.path).toLowerCase().includes(q)); +} + +/** The popup's groups as locations for the Find Usages tool window. */ +export function usageLocations(groups: readonly UsageGroup[]) { + return groups.flatMap((g) => + g.usages.map((u) => ({ + path: u.path, + range: { start: { line: u.line, col: u.col }, end: { line: u.line, col: u.endCol } }, + })), + ); +} + export function UsagesPopup() { const open = useUsagesPopup((s) => s.open); return open ? : null; } +const ROW = 24; + function UsagesPopupInner() { const { anchor, symbol, groups, note } = useUsagesPopup(); - const items = useMemo(() => groups.flatMap((g) => g.usages), [groups]); + const preview = useUsagesPopupPreview((s) => s.preview); + const all = useMemo(() => groups.flatMap((g) => g.usages), [groups]); + const [filter, setFilter] = useState(""); + const items = useMemo(() => filterUsages(all, filter), [all, filter]); const [active, setActive] = useState(0); + const current = Math.min(active, items.length - 1); const total = usageCount(groups); - const pick = (u: Usage) => { + const pick = (u: Usage | undefined) => { + if (!u) return; close(); - openLocation(u.path, u.line, u.col); + void openLocation(u.path, u.line, u.col); }; + const openInFindWindow = () => { + close(); + void showUsageResults({ symbol, locations: usageLocations(groups) }); + }; + + const onKeyDown = (e: KeyboardEvent) => { + const next = listNavigate(e.key, { items, active: current, wrap: true, pageSize: 10 }); + if (next !== null) { + e.preventDefault(); + setActive(next); + return; + } + const plain = !e.metaKey && !e.ctrlKey && !e.altKey; + if (e.key === "Enter") { + e.preventDefault(); + pick(items[current]); + } else if (e.key === "Escape" && filter) { + // The first Esc clears the speed search, the next one closes. + e.preventDefault(); + e.stopPropagation(); + setFilter(""); + setActive(0); + } else if (e.key === "Backspace" && filter) { + e.preventDefault(); + setFilter((f) => f.slice(0, -1)); + setActive(0); + } else if (plain && e.key.length === 1 && (filter || e.key !== " ")) { + e.preventDefault(); + setFilter((f) => f + e.key); + setActive(0); + } + }; + const keys = shortcutLabel("find.usages"); return ( -
- - Usages{symbol ? ` of ${symbol}` : ""} {total} found - - -
- {note &&
{note}
} -
- `${u.path}:${u.line}:${u.col}`} - activeIndex={Math.min(active, items.length - 1)} - onActiveChange={setActive} - hoverSelects - aria-label="Usages" - onActivate={pick} - renderItem={(u) => ( -
- - - - - {basename(u.path)}:{u.line + 1} - -
+
+
+ + Usages{symbol ? ` of ${symbol}` : ""}{" "} + {filter ? `${items.length} of ${total}` : total} found + + {filter && ( + + {filter} + )} - /> + e.preventDefault()} + onClick={() => setUsagesPopupPreview(!preview)} + /> + +
+ {note &&
{note}
} +
+ `${u.path}:${u.line}:${u.col}`} + activeIndex={current} + onActiveChange={setActive} + focusable={false} + hoverSelects + rowHeight={ROW} + aria-label="Usages" + onActivate={pick} + empty={
Nothing matches '{filter}'
} + renderItem={(u) => ( +
+ + + + + {basename(u.path)}:{u.line + 1} + +
+ )} + /> +
+ {preview && }
); } + +const setMark = StateEffect.define<{ from: number; to: number } | null>(); +const usageMark = Decoration.mark({ class: styles.previewMatch }); +const usageLine = Decoration.line({ class: styles.previewLine }); +const marks = StateField.define({ + create: () => Decoration.none, + update(value, tr) { + for (const e of tr.effects) { + if (!e.is(setMark)) continue; + if (!e.value) return Decoration.none; + const line = tr.state.doc.lineAt(e.value.from); + const ranges = [usageLine.range(line.from)]; + if (e.value.to > e.value.from) ranges.push(usageMark.range(e.value.from, e.value.to)); + return Decoration.set(ranges); + } + return value.map(tr.changes); + }, + provide: (f) => EditorView.decorations.from(f), +}); + +/** Read-only view of the usage's file around its line, the usage marked. */ +function UsageCodePreview({ usage }: { usage: Usage | null }) { + const host = useRef(null); + const view = useRef(null); + const [loaded, setLoaded] = useState(null); + + useEffect(() => { + if (!host.current) return; + const v = new EditorView({ parent: host.current, state: EditorState.create({ extensions: marks }) }); + view.current = v; + return () => { + v.destroy(); + view.current = null; + }; + }, []); + + const path = usage?.path ?? null; + useEffect(() => { + if (!path) return; + let alive = true; + // Debounced so arrowing through the list doesn't load every file. + const t = setTimeout(() => { + void Promise.all([readFileLines(path), codePreviewExtensions(path)]).then(([lines, extensions]) => { + if (!alive || !view.current) return; + view.current.setState(EditorState.create({ doc: (lines ?? []).join("\n"), extensions: [extensions, marks] })); + setLoaded(path); + }); + }, 60); + return () => { + alive = false; + clearTimeout(t); + }; + }, [path]); + + useEffect(() => { + const v = view.current; + if (!v || !usage || loaded !== usage.path) return; + const doc = v.state.doc; + if (usage.line >= doc.lines) return; + const line = doc.line(usage.line + 1); + const from = line.from + Math.min(usage.col, line.length); + const to = line.from + Math.min(usage.endCol, line.length); + v.dispatch({ effects: [setMark.of({ from, to }), EditorView.scrollIntoView(from, { y: "center" })] }); + }, [usage, loaded]); + + return ( +
+
+
+ ); +} diff --git a/src/features/code-insight/UsagesView.module.css b/src/features/code-insight/UsagesView.module.css index 39818edd..6398fb41 100644 --- a/src/features/code-insight/UsagesView.module.css +++ b/src/features/code-insight/UsagesView.module.css @@ -102,3 +102,19 @@ font-style: italic; color: var(--fg-subtle); } +.excluded .preview, +.excluded .file, +.excluded .line { + opacity: 0.5; + text-decoration: line-through; +} +.panel { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} +.panel > * { + flex: 1; + min-height: 0; +} diff --git a/src/features/code-insight/UsagesView.tsx b/src/features/code-insight/UsagesView.tsx index 4c71625f..12109ced 100644 --- a/src/features/code-insight/UsagesView.tsx +++ b/src/features/code-insight/UsagesView.tsx @@ -1,10 +1,43 @@ -/** "usages" tool window: Find Usages results grouped by file with line previews. */ -import { useCallback, useMemo, useState } from "react"; +/** + * "usages" tool window: Find Usages results grouped by file with line + * previews, one tab per search (pinnable, as the Find tool window's tabs). + */ +import { useCallback, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; +import clsx from "clsx"; +import { create } from "zustand"; import { formatMessage, useTx } from "@/platform/i18n"; +import { shortcutLabel } from "@/platform/keymap"; import { basename, relativeDir, useProject } from "@/platform/project"; -import { EmptyState, Icon, IconButton, Spinner, Tabs, Tree, fileIconName, type TreeRowState, T } from "@/ui"; -import { openLocation } from "./ipc"; -import { trimPreview, usageCount, useUsages, type Usage, type UsageGroup } from "./usages"; +import { maybeEditorService, useOccurrenceNavigator } from "@/workbench/contributions"; +import { + EmptyState, + Icon, + IconButton, + SourcePreview, + SplitGroup, + SplitHandle, + SplitPanel, + Spinner, + Tabs, + Tree, + fileIconName, + showContextMenu, + type MenuItem, + type TreeHandle, + type TreeRowState, + T, +} from "@/ui"; +import { previewText } from "./ipc"; +import { + activeUsageSearch, + closeUsageTab, + pinUsageTab, + trimPreview, + usageCount, + useUsages, + type Usage, + type UsageGroup, +} from "./usages"; import styles from "./UsagesView.module.css"; type Node = @@ -23,29 +56,78 @@ export function UsagePreview({ usage }: { usage: Usage }) { ); } -/** Tool window header: the current search as a content tab (`Usages of 'foo'`). */ +/** Tool window header: one content tab per search (`Usages of 'foo'`). */ export function UsagesTabs() { const tx = useTx(); - const search = useUsages((s) => s.search); + const tabs = useUsages((s) => s.tabs); + const search = useUsages(activeUsageSearch); if (!search) return null; - const label = search.symbol ? formatMessage(tx("Usages of '{symbol}'"), { symbol: search.symbol }) : tx("Usages"); + const label = (t: (typeof tabs)[number]) => + t.symbol ? formatMessage(tx("Usages of '{symbol}'"), { symbol: t.symbol }) : tx("Usages"); return ( ({ + id: String(t.id), + label: label(t), + closable: true, + icon: t.pinned ? "pin" : undefined, + }))} activeId={String(search.id)} - onSelect={() => undefined} + onSelect={(id) => useUsages.setState({ active: Number(id) })} + onClose={(id) => closeUsageTab(Number(id))} + onContextMenu={(id, e) => { + const t = tabs.find((x) => x.id === Number(id)); + if (!t) return; + showContextMenu(e, [ + { id: "pin", label: t.pinned ? "Unpin Tab" : "Pin Tab", run: () => pinUsageTab(t.id, !t.pinned) }, + { kind: "separator" }, + { id: "close", label: "Close Tab", run: () => closeUsageTab(t.id) }, + { + id: "closeOthers", + label: "Close Other Tabs", + disabled: tabs.length < 2, + run: () => tabs.forEach((o) => o.id !== t.id && closeUsageTab(o.id)), + }, + ]); + }} /> ); } const SUMMARY_KEY = "\u0000summary"; +const usageKey = (u: Usage) => `${u.path}:${u.line}:${u.col}`; + +/** Excluded (⌫) usages per search. */ +const useExcluded = create<{ byTab: Record> }>(() => ({ byTab: {} })); +const NONE: ReadonlySet = new Set(); + +/** Usages under a node (itself for a usage). */ +function nodeUsages(n: Node, files: readonly Node[]): Usage[] { + if (n.kind === "usage") return [n.usage]; + if (n.kind === "file") return n.group.usages; + return files.flatMap((f) => (f.kind === "file" ? f.group.usages : [])); +} + +/** Toggles exclusion of `usages`: all excluded already → include them, else exclude them all. */ +export function toggleExcludedUsages(excluded: ReadonlySet, usages: readonly Usage[]): Set { + const keys = usages.map(usageKey); + const next = new Set(excluded); + if (keys.every((k) => excluded.has(k))) keys.forEach((k) => next.delete(k)); + else keys.forEach((k) => next.add(k)); + return next; +} + +function openUsage(u: Usage, focus: boolean) { + void maybeEditorService()?.openFile(u.path, { selection: { line: u.line, col: u.col }, focus }); +} + export function UsagesView() { const tx = useTx(); - const search = useUsages((s) => s.search); + const search = useUsages(activeUsageSearch); const root = useProject((s) => s.project?.root ?? ""); const [collapsed, setCollapsed] = useState<{ id: number | null; keys: ReadonlySet }>({ id: null, @@ -66,10 +148,21 @@ export function UsagesView() { n.kind === "summary" ? files : n.kind === "file" - ? n.group.usages.map((u) => ({ kind: "usage", key: `${u.path}:${u.line}:${u.col}`, usage: u }) as Node) + ? n.group.usages.map((u) => ({ kind: "usage", key: usageKey(u), usage: u }) as Node) : undefined, [files], ); + const tree = useRef(null); + const [lead, setLead] = useState(null); + const excluded = useExcluded((s) => (searchId != null ? s.byTab[searchId] : undefined)) ?? NONE; + + useOccurrenceNavigator("usages", (dir, focus) => { + const step = tree.current?.selectOccurrence(dir); + const n = step?.node as Node | undefined; + if (!step || n?.kind !== "usage") return null; + openUsage(n.usage, focus); + return step; + }); if (!search) return ( @@ -81,6 +174,21 @@ export function UsagesView() { const setAll = (collapse: boolean) => setCollapsed({ id: search.id, keys: collapse ? new Set(files.map((f) => f.key)) : new Set() }); + const exclude = () => { + if (!lead) return; + const next = toggleExcludedUsages(excluded, nodeUsages(lead, files)); + useExcluded.setState((s) => ({ byTab: { ...s.byTab, [search.id]: next } })); + // Excluding moves on to the next usage, as in IDEA. + if (next.size > excluded.size) tree.current?.selectOccurrence(1, { skipSubtree: true }); + }; + const onKeyDown = (e: KeyboardEvent) => { + if ((e.key === "Backspace" || e.key === "Delete") && !e.metaKey && !e.altKey && !e.ctrlKey) { + e.preventDefault(); + exclude(); + } + }; + const isExcluded = (n: Node) => nodeUsages(n, files).every((u) => excluded.has(usageKey(u))); + const renderNode = (n: Node, _state: TreeRowState) => { if (n.kind === "summary") return ( @@ -97,7 +205,7 @@ export function UsagesView() { if (n.kind === "file") { const path = n.group.path; return ( - + {basename(path)} {relativeDir(root, path)} @@ -108,7 +216,7 @@ export function UsagesView() { ); } return ( - + {n.usage.line + 1} @@ -123,14 +231,18 @@ export function UsagesView() { title={tx("Rerun Find Usages")} size="small" iconSize={16} + shortcut={shortcutLabel("usages.rerun")} disabled={!search.rerun} onClick={() => search.rerun?.()} /> + + + setAll(false)} /> setAll(true)} />
-
+
{(search.loading || search.error) && (
{search.loading ? ( @@ -146,38 +258,124 @@ export function UsagesView() { )}
)} - - key={search.id} - aria-label="Usages" - roots={roots} - getKey={(n) => n.key} - getLabel={(n) => - n.kind === "summary" ? tx("Found usages") : n.kind === "file" ? basename(n.group.path) : n.usage.text.trim() - } - getChildren={getChildren} - isExpandable={(n) => n.kind !== "usage"} - expanded={expanded} - onExpandedChange={(next) => - setCollapsed({ - id: search.id, - keys: new Set([SUMMARY_KEY, ...files.map((f) => f.key)].filter((k) => !next.has(k))), - }) - } - renderNode={renderNode} - multiSelect={false} - onActivate={(n) => { - const u = n.kind === "usage" ? n.usage : n.kind === "file" ? n.group.usages[0] : undefined; - if (u) openLocation(u.path, u.line, u.col); - }} - empty={ - !search.loading && !search.error ? ( - - No usages found - - ) : undefined - } - /> + }> + + key={search.id} + aria-label="Usages" + handleRef={tree} + roots={roots} + getKey={(n) => n.key} + getLabel={(n) => + n.kind === "summary" + ? tx("Found usages") + : n.kind === "file" + ? basename(n.group.path) + : n.usage.text.trim() + } + getChildren={getChildren} + isExpandable={(n) => n.kind !== "usage"} + isOccurrence={(n) => n.kind === "usage" && !excluded.has(n.key)} + expanded={expanded} + onExpandedChange={(next) => + setCollapsed({ + id: search.id, + keys: new Set([SUMMARY_KEY, ...files.map((f) => f.key)].filter((k) => !next.has(k))), + }) + } + renderNode={renderNode} + multiSelect={false} + onSelectionChange={(_sel, node) => setLead(node)} + onContextMenu={(n, _sel, e) => { + if (!n) return; + showContextMenu(e, [ + { + id: "exclude", + label: isExcluded(n) ? "Include" : "Exclude", + shortcut: "⌫", + run: () => { + setLead(n); + const next = toggleExcludedUsages(excluded, nodeUsages(n, files)); + useExcluded.setState((s) => ({ byTab: { ...s.byTab, [search.id]: next } })); + }, + }, + ]); + }} + onActivate={(n) => { + const u = n.kind === "usage" ? n.usage : n.kind === "file" ? n.group.usages[0] : undefined; + if (u) openUsage(u, true); + }} + empty={ + !search.loading && !search.error ? ( + + No usages found + + ) : undefined + } + /> +
); } + +// --------------------------------------------------------------------------- +// Preview pane (persisted toggle in the ⋮ menu; on by default, like the Find tool window's). + +const OPTIONS_KEY = "openidea.usages.view.v1"; + +export const useUsagesOptions = create<{ preview: boolean }>(() => { + try { + return { preview: true, ...JSON.parse(globalThis.localStorage?.getItem(OPTIONS_KEY) ?? "{}") }; + } catch { + return { preview: true }; + } +}); + +useUsagesOptions.subscribe((s) => { + try { + globalThis.localStorage?.setItem(OPTIONS_KEY, JSON.stringify(s)); + } catch { + /* storage unavailable */ + } +}); + +/** The Find Usages tool window's ⋮ menu entries. */ +export function useUsagesHeaderMenu(): MenuItem[] { + const preview = useUsagesOptions((s) => s.preview); + return [ + { + id: "preview", + label: "Preview Source", + checked: preview, + run: () => useUsagesOptions.setState({ preview: !preview }), + }, + ]; +} + +function UsagesSplit({ preview, children }: { preview: ReactNode; children: ReactNode }) { + const show = useUsagesOptions((s) => s.preview); + if (!show) return children; + return ( + + + {children} + + + + {preview} + + + ); +} + +function UsagesPreview({ node }: { node: Node | null }) { + const usage = node?.kind === "usage" ? node.usage : node?.kind === "file" ? (node.group.usages[0] ?? null) : null; + const search = useUsages(activeUsageSearch); + const highlights = useMemo(() => { + const group = usage && search?.groups.find((g) => g.path === usage.path); + return (group?.usages ?? []).map((u) => ({ line: u.line, ranges: [[u.col, u.endCol] as const] })); + }, [usage, search?.groups]); + return ( + + ); +} diff --git a/src/features/code-insight/hierarchy.test.ts b/src/features/code-insight/hierarchy.test.ts new file mode 100644 index 00000000..4f15ee91 --- /dev/null +++ b/src/features/code-insight/hierarchy.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from "vitest"; +import type { HierarchyItem, OutlineItem, SymbolKind } from "@/bindings"; + +const hint = vi.fn(); +vi.mock("@/workbench/contributions", async (orig) => ({ + ...(await orig()), + toolWindowService: () => ({ show: () => undefined }), +})); + +const range = (l1: number, c1: number, l2: number, c2: number) => ({ + start: { line: l1, col: c1 }, + end: { line: l2, col: c2 }, +}); +const outlineItem = (name: string, kind: SymbolKind, r: ReturnType, nameAt: [number, number], children: OutlineItem[] = []): OutlineItem => ({ + name, + detail: null, + kind, + range: r, + selection_range: range(nameAt[0], nameAt[1], nameAt[0], nameAt[1] + name.length), + children, +}); +// class Orders { void list() { int x = 1; } } — class on lines 0-10, `list` on lines 2-6. +const OUTLINE = [ + outlineItem("Orders", "class", range(0, 0, 10, 1), [0, 6], [ + outlineItem("list", "method", range(2, 2, 6, 3), [2, 7]), + outlineItem("count", "field", range(8, 2, 8, 12), [8, 6]), + ]), +]; + +const item = (name: string, fromRanges: HierarchyItem["fromRanges"] = []): HierarchyItem => ({ + name, + kind: "method", + detail: null, + path: "/p/Caller.java", + range: range(10, 0, 20, 1), + selectionRange: range(10, 7, 10, 7 + name.length), + fromRanges, +}); + +vi.mock("@/platform/ipc", async (orig) => { + const real = await orig(); + return { + ...real, + call: async (p: Promise) => p, + commands: { + ...real.commands, + docOutline: async () => OUTLINE, + lspCallHierarchy: async (_doc: number, pos: { line: number; col: number }) => { + // The server prepares a hierarchy only on the method name. + if (pos.line !== 2 || pos.col !== 7) throw new real.IpcError({ kind: "notFound", message: "No symbol" }); + return [item("main", [range(12, 4, 12, 8)])]; + }, + }, + }; +}); +vi.mock("./ipc", async (orig) => ({ + ...(await orig()), + caretContext: () => ({ doc: 1, path: "/p/Orders.java", line: 4, col: 10, word: "x", view: null }), + withDocument: async (_path: string, fn: (doc: number) => Promise) => fn(1), + lspRequest: async (run: () => Promise) => run(), + hint: (...a: unknown[]) => hint(...a), +})); + +const { enclosingDeclaration, hierarchyTarget, showHierarchy, useHierarchy, ROOT_ID } = await import("./hierarchy"); + +describe("hierarchy", () => { + it("finds the innermost enclosing declaration of the wanted kinds", () => { + const calls = new Set(["method", "function", "constructor"]); + const types = new Set(["class"]); + expect(enclosingDeclaration(OUTLINE, { line: 4, col: 10 }, calls)?.name).toBe("list"); + expect(enclosingDeclaration(OUTLINE, { line: 4, col: 10 }, types)?.name).toBe("Orders"); + expect(enclosingDeclaration(OUTLINE, { line: 8, col: 4 }, calls)).toBeNull(); + expect(enclosingDeclaration(OUTLINE, { line: 12, col: 0 }, types)).toBeNull(); + }); + + it("navigates callers to their call site, other nodes to the symbol", () => { + const caller = item("main", [range(12, 4, 12, 8), range(14, 4, 14, 8)]); + expect(hierarchyTarget("call", "incoming", caller)).toEqual({ path: "/p/Caller.java", line: 12, col: 4 }); + expect(hierarchyTarget("call", "outgoing", caller)).toEqual({ path: "/p/Caller.java", line: 10, col: 7 }); + expect(hierarchyTarget("type", "incoming", item("Base"))).toEqual({ path: "/p/Caller.java", line: 10, col: 7 }); + }); + + it("retries at the enclosing method when nothing is at the caret", async () => { + useHierarchy.setState({ mode: "call", direction: "incoming" }); + await showHierarchy("call"); + const s = useHierarchy.getState(); + expect(s.root).toEqual({ name: "list", path: "/p/Orders.java", line: 2, col: 7 }); + expect(s.children.get(ROOT_ID)?.map((n) => n.item.name)).toEqual(["main"]); + expect(s.failed.size).toBe(0); + expect(hint).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/code-insight/hierarchy.ts b/src/features/code-insight/hierarchy.ts index 1e64c588..effd0f66 100644 --- a/src/features/code-insight/hierarchy.ts +++ b/src/features/code-insight/hierarchy.ts @@ -1,10 +1,10 @@ /** Call / type hierarchy state, with lazily expanded levels. */ import { create } from "zustand"; -import type { HierarchyDirection, HierarchyItem } from "@/bindings"; -import { commands } from "@/platform/ipc"; +import type { HierarchyDirection, HierarchyItem, OutlineItem, Pos, SymbolKind } from "@/bindings"; +import { IpcError, call, commands } from "@/platform/ipc"; import { toolWindowService } from "@/workbench/contributions"; import { basename } from "@/platform/project"; -import { caretContext, hint, lspRequest, reportError, withDocument } from "./ipc"; +import { caretContext, hint, lspRequest, reportError, withDocument, type CaretContext } from "./ipc"; export type HierarchyMode = "call" | "type"; @@ -88,24 +88,61 @@ function setChildren(generation: number, parent: string, items: HierarchyItem[]) useHierarchy.setState({ children: new Map(s.children).set(parent, nodes) }); } -async function loadRoot() { +type RootResult = "loaded" | "noTarget" | "failed" | "stale"; + +/** Loads the root's level. `quiet`: a position without a symbol isn't reported (the caller retries). */ +async function loadRoot(quiet = false): Promise { const s = useHierarchy.getState(); const root = s.root; - if (!root) return; + if (!root) return "stale"; const generation = s.generation + 1; useHierarchy.setState({ generation, children: new Map(), failed: new Set(), loading: true }); try { const items = await withDocument(root.path, (doc) => request(s.mode, doc, root.line, root.col, s.direction)); setChildren(generation, ROOT_ID, items); + return "loaded"; } catch (e) { - if (useHierarchy.getState().generation === generation) - useHierarchy.setState((st) => ({ failed: new Set(st.failed).add(ROOT_ID) })); + if (useHierarchy.getState().generation !== generation) return "stale"; + const noTarget = e instanceof IpcError && e.kind === "notFound"; + if (quiet && noTarget) return "noTarget"; + useHierarchy.setState((st) => ({ failed: new Set(st.failed).add(ROOT_ID) })); reportError(e); + return noTarget ? "noTarget" : "failed"; } finally { if (useHierarchy.getState().generation === generation) useHierarchy.setState({ loading: false }); } } +const CALLABLE = new Set(["function", "method", "constructor"]); +const TYPES = new Set(["class", "interface", "struct", "enum", "trait"]); + +const before = (a: Pos, b: Pos) => a.line < b.line || (a.line === b.line && a.col <= b.col); + +/** The innermost outline symbol of `kinds` whose extent contains `pos`. */ +export function enclosingDeclaration( + items: readonly OutlineItem[], + pos: Pos, + kinds: ReadonlySet, +): OutlineItem | null { + for (const item of items) { + if (!before(item.range.start, pos) || !before(pos, item.range.end)) continue; + return enclosingDeclaration(item.children, pos, kinds) ?? (kinds.has(item.kind) ? item : null); + } + return null; +} + +/** IDEA: inside a method / class body the hierarchy is the enclosing declaration's. */ +async function enclosingRoot(c: CaretContext, mode: HierarchyMode): Promise { + try { + const outline = await call(commands.docOutline(c.doc)); + const decl = enclosingDeclaration(outline, { line: c.line, col: c.col }, mode === "call" ? CALLABLE : TYPES); + if (!decl) return null; + const at = decl.selection_range.start; + return { name: decl.name, path: c.path, line: at.line, col: at.col }; + } catch { + return null; + } +} export async function loadChildren(node: HierarchyNode) { const { mode, direction, generation } = useHierarchy.getState(); const at = node.item.selectionRange.start; @@ -136,7 +173,28 @@ export async function showHierarchy(mode: HierarchyMode) { root: { name: c.word ?? basename(c.path), path: c.path, line: c.line, col: c.col }, }); toolWindowService().show("hierarchy"); - await loadRoot(); + if ((await loadRoot(true)) !== "noTarget") return; + // Nothing at the caret: retry at the enclosing method / class. + const root = await enclosingRoot(c, mode); + const now = useHierarchy.getState(); + if (now.mode !== mode || now.root?.path !== c.path || now.root.line !== c.line || now.root.col !== c.col) return; + if (root && (root.line !== c.line || root.col !== c.col)) { + useHierarchy.setState({ root }); + await loadRoot(); + } else { + useHierarchy.setState((st) => ({ failed: new Set(st.failed).add(ROOT_ID) })); + hint(mode === "call" ? "No method at the caret to show the call hierarchy of" : "No type at the caret to show the hierarchy of"); + } +} + +/** Where activating a node goes: a caller's call site (as IDEA), else the symbol itself. */ +export function hierarchyTarget( + mode: HierarchyMode, + direction: HierarchyDirection, + item: HierarchyItem, +): { path: string; line: number; col: number } { + const at = mode === "call" && direction === "incoming" && item.fromRanges[0] ? item.fromRanges[0].start : item.selectionRange.start; + return { path: item.path, line: at.line, col: at.col }; } export function setDirection(direction: HierarchyDirection) { diff --git a/src/features/code-insight/index.ts b/src/features/code-insight/index.ts index c82cf6d2..cca63ee9 100644 --- a/src/features/code-insight/index.ts +++ b/src/features/code-insight/index.ts @@ -8,10 +8,10 @@ import { useProject } from "@/platform/project"; import { registerOverlay, registerStatusItem, registerToolWindow } from "@/workbench/contributions"; import { clearProblems, installProblemsEvents, useProblems } from "./problemsStore"; import { installProblemDecorations } from "./decorations"; -import { ProblemsHeaderActions, ProblemsTabs, ProblemsView } from "./ProblemsView"; -import { UsagesTabs, UsagesView } from "./UsagesView"; +import { ProblemsHeaderActions, ProblemsTabs, ProblemsView, useProblemsHeaderMenu } from "./ProblemsView"; +import { UsagesTabs, UsagesView, useUsagesHeaderMenu } from "./UsagesView"; import { UsagesPopup } from "./UsagesPopup"; -import { findUsages, resetUsages, showUsageResults, showUsagesPopup } from "./usages"; +import { findUsages, rerunActiveUsages, resetUsages, showUsageResults, showUsagesPopup } from "./usages"; import { HierarchyTabs, HierarchyView } from "./HierarchyView"; import { resetHierarchy, showHierarchy } from "./hierarchy"; import { installServerEvents } from "./servers"; @@ -53,6 +53,7 @@ export function activate(): () => void { component: ProblemsView, headerContent: ProblemsTabs, headerActions: ProblemsHeaderActions, + headerMenu: useProblemsHeaderMenu, useBadge: useErrorBadge, }), registerToolWindow({ @@ -64,6 +65,7 @@ export function activate(): () => void { order: 35, component: UsagesView, headerContent: UsagesTabs, + headerMenu: useUsagesHeaderMenu, }), registerToolWindow({ id: "hierarchy", @@ -92,6 +94,15 @@ export function activate(): () => void { when: EDITOR_WHEN, run: () => showUsagesPopup(), }, + { + // ⌘R in the Find Usages tool window, as in IDEA. + id: "usages.rerun", + title: "Rerun Find Usages", + category: "Find", + icon: "rerun", + when: "projectOpen && activeToolWindow == 'usages'", + run: rerunActiveUsages, + }, { id: "find.showUsageResults", title: "Show Usage Results", diff --git a/src/features/code-insight/ipc.ts b/src/features/code-insight/ipc.ts index c6214711..9fcdb4af 100644 --- a/src/features/code-insight/ipc.ts +++ b/src/features/code-insight/ipc.ts @@ -86,6 +86,13 @@ export async function readFileLines(path: string): Promise { } } +/** File text for a preview pane (open buffer, else disk). */ +export async function previewText(path: string): Promise { + const lines = await readFileLines(path); + if (!lines) throw new Error("Cannot read the file"); + return lines.join("\n"); +} + export async function readManyFileLines(paths: readonly string[], concurrency = 4): Promise> { const out = new Map(); const queue = [...new Set(paths)]; @@ -111,6 +118,6 @@ export async function withDocument(path: string, fn: (doc: number) => Promise } } -export function openLocation(path: string, line: number, col: number) { - void maybeEditorService()?.openFile(path, { selection: { line, col }, focus: true }); +export function openLocation(path: string, line: number, col: number): Promise { + return maybeEditorService()?.openFile(path, { selection: { line, col }, focus: true }) ?? Promise.resolve(); } diff --git a/src/features/code-insight/problemsStore.ts b/src/features/code-insight/problemsStore.ts index 0c90b7a4..99a35bbc 100644 --- a/src/features/code-insight/problemsStore.ts +++ b/src/features/code-insight/problemsStore.ts @@ -47,6 +47,30 @@ export function fileDiagnostics(map: ProblemsMap, path: string): Diagnostic[] { ); } +export interface ProblemOrder { + /** Errors before warnings before infos (IDEA's default). */ + bySeverity: boolean; + /** By message text, then position; otherwise by position, then text. */ + byName: boolean; +} + +export const DEFAULT_PROBLEM_ORDER: ProblemOrder = { bySeverity: true, byName: false }; + +const natural = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); + +/** A file's problems in the Problems view order (Sort by Severity / Name). */ +export function sortProblems(items: readonly Diagnostic[], order: ProblemOrder): Diagnostic[] { + const position = (a: Diagnostic, b: Diagnostic) => + a.range.start.line - b.range.start.line || a.range.start.col - b.range.start.col; + return [...items].sort( + (a, b) => + (order.bySeverity ? SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] : 0) || + (order.byName + ? natural.compare(a.message, b.message) || position(a, b) + : position(a, b) || natural.compare(a.message, b.message)), + ); +} + export function countSeverities(diags: Iterable): SeverityCounts { const c: SeverityCounts = { error: 0, warning: 0, info: 0, hint: 0 }; for (const d of diags) c[d.severity]++; diff --git a/src/features/code-insight/usageResults.test.ts b/src/features/code-insight/usageResults.test.ts index 2c37e8ae..b7782b12 100644 --- a/src/features/code-insight/usageResults.test.ts +++ b/src/features/code-insight/usageResults.test.ts @@ -10,14 +10,14 @@ vi.mock("./ipc", async (orig) => ({ readManyFileLines: async () => new Map([["/p/b.rs", ["", " used();"]]]), })); -const { showUsageResults, useUsages } = await import("./usages"); +const { activeUsageSearch, openUsageTab, pinUsageTab, resetUsages, showUsageResults, useUsages } = await import("./usages"); describe("showUsageResults", () => { it("shows given usages in the Find Usages tool window", async () => { const loc = { path: "/p/b.rs", range: { start: { line: 1, col: 4 }, end: { line: 1, col: 8 } } }; await showUsageResults({ symbol: "used", locations: [loc] }); expect(show).toHaveBeenCalledWith("usages"); - const search = useUsages.getState().search!; + const search = activeUsageSearch(useUsages.getState())!; expect(search).toMatchObject({ symbol: "used", loading: false, error: null, rerun: null }); expect(search.groups).toEqual([{ path: "/p/b.rs", usages: [{ path: "/p/b.rs", line: 1, col: 4, endCol: 8, text: " used();" }] }]); }); @@ -28,3 +28,20 @@ describe("showUsageResults", () => { expect(show).not.toHaveBeenCalled(); }); }); + +describe("usage tabs", () => { + const search = (id: number) => ({ id, symbol: `s${id}`, loading: false, groups: [], error: null, note: null, rerun: null }); + + it("replace the selected tab unless it is pinned", () => { + resetUsages(); + openUsageTab(search(101)); + openUsageTab(search(102)); + expect(useUsages.getState().tabs.map((t) => t.id)).toEqual([102]); + pinUsageTab(102, true); + openUsageTab(search(103)); + expect(useUsages.getState().tabs.map((t) => t.id)).toEqual([102, 103]); + expect(activeUsageSearch(useUsages.getState())?.id).toBe(103); + openUsageTab(search(104)); + expect(useUsages.getState().tabs.map((t) => t.id)).toEqual([102, 104]); + }); +}); diff --git a/src/features/code-insight/usages.ts b/src/features/code-insight/usages.ts index 39156f26..fe967345 100644 --- a/src/features/code-insight/usages.ts +++ b/src/features/code-insight/usages.ts @@ -62,7 +62,7 @@ export function trimPreview(u: Usage): { text: string; from: number; to: number return { text, from, to }; } -interface UsageSearch { +export interface UsageSearch { /** Identifies one search run (UI state such as expansion resets per run). */ id: number; symbol: string | null; @@ -73,11 +73,64 @@ interface UsageSearch { note: string | null; /** Re-runs the same query (rerun button). */ rerun: (() => void) | null; + /** Kept when a new search runs (Pin Tab). */ + pinned?: boolean; } let searchSeq = 0; -export const useUsages = create<{ search: UsageSearch | null }>(() => ({ search: null })); +/** One tab per search; as in IDEA a new search replaces the selected tab unless it is pinned. */ +interface UsagesState { + tabs: UsageSearch[]; + active: number | null; +} + +export const useUsages = create(() => ({ tabs: [], active: null })); + +/** The selected tab's search. */ +export const activeUsageSearch = (s: UsagesState): UsageSearch | null => + s.tabs.find((t) => t.id === s.active) ?? s.tabs.at(-1) ?? null; + +const MAX_TABS = 8; + +/** Shows `search` in a new tab, or in place of the selected one unless that is pinned. */ +export function openUsageTab(search: UsageSearch) { + useUsages.setState((s) => { + const reused = s.tabs.find((t) => t.id === s.active && !t.pinned); + const tabs = reused ? s.tabs.map((t) => (t === reused ? search : t)) : [...s.tabs, search]; + return { tabs: tabs.slice(-MAX_TABS), active: search.id }; + }); +} + +/** Replaces tab `id` (a rerun or a finished search), keeping its pin and selection. */ +function replaceUsageTab(id: number, search: UsageSearch): boolean { + const s = useUsages.getState(); + const old = s.tabs.find((t) => t.id === id); + if (!old) return false; + useUsages.setState({ + tabs: s.tabs.map((t) => (t === old ? { ...search, pinned: old.pinned } : t)), + active: s.active === id ? search.id : s.active, + }); + return true; +} + +export function pinUsageTab(id: number, pinned: boolean) { + useUsages.setState((s) => ({ tabs: s.tabs.map((t) => (t.id === id ? { ...t, pinned } : t)) })); +} + +export function closeUsageTab(id: number) { + useUsages.setState((s) => { + const i = s.tabs.findIndex((t) => t.id === id); + const tabs = s.tabs.filter((t) => t.id !== id); + const active = s.active === id ? (tabs[Math.min(i, tabs.length - 1)]?.id ?? null) : s.active; + return { tabs, active }; + }); +} + +/** Rerun of the selected tab (⌘R in the Find Usages tool window). */ +export function rerunActiveUsages() { + activeUsageSearch(useUsages.getState())?.rerun?.(); +} interface UsagesPopupState { open: boolean; @@ -128,7 +181,7 @@ export async function fetchUsages(c: CaretContext): Promise { /** The window switched projects: results (and in-flight searches) of the previous one are dropped. */ export function resetUsages() { - useUsages.setState({ search: null }); + useUsages.setState({ tabs: [], active: null }); useUsagesPopup.setState({ open: false, symbol: null, groups: [], note: null }); } @@ -138,23 +191,22 @@ export async function findUsages() { hint("Open a file to find usages"); return; } - const run = async () => { + const run = async (prev: number | null) => { const id = ++searchSeq; - const current = () => useUsages.getState().search?.id === id; - const base = { id, symbol: c.word, rerun: () => void run() }; - useUsages.setState({ search: { ...base, loading: true, groups: [], error: null, note: null } }); + const base = { id, symbol: c.word, rerun: () => void run(id) }; + const loading = { ...base, loading: true, groups: [], error: null, note: null }; + if (prev === null || !replaceUsageTab(prev, loading)) openUsageTab(loading); toolWindowService().show("usages"); try { const { groups, note } = await fetchUsages(c); - if (current()) useUsages.setState({ search: { ...base, loading: false, groups, error: null, note } }); + replaceUsageTab(id, { ...base, loading: false, groups, error: null, note }); } catch (e) { const message = e instanceof Error ? e.message : String(e); - if (!current()) return; - useUsages.setState({ search: { ...base, loading: false, groups: [], error: message, note: null } }); + if (!replaceUsageTab(id, { ...base, loading: false, groups: [], error: message, note: null })) return; reportError(e); } }; - await run(); + await run(null); } /** @@ -168,11 +220,10 @@ export async function showUsageResults(arg: unknown) { const symbol = typeof a.symbol === "string" ? a.symbol : null; const id = ++searchSeq; const base = { id, symbol, rerun: null }; - useUsages.setState({ search: { ...base, loading: true, groups: [], error: null, note: null } }); + openUsageTab({ ...base, loading: true, groups: [], error: null, note: null }); toolWindowService().show("usages"); const files = await readManyFileLines(locs.map((l) => l.path)); - if (useUsages.getState().search?.id !== id) return; - useUsages.setState({ search: { ...base, loading: false, groups: groupUsages(locs, files), error: null, note: null } }); + replaceUsageTab(id, { ...base, loading: false, groups: groupUsages(locs, files), error: null, note: null }); } export async function showUsagesPopup() { @@ -189,8 +240,10 @@ export async function showUsagesPopup() { return; } if (total === 1) { + // IDEA jumps to the only usage and says so in the target editor. const u = groups[0]!.usages[0]!; - openLocation(u.path, u.line, u.col); + await openLocation(u.path, u.line, u.col); + hint("Only one usage in Project Files"); return; } useUsagesPopup.setState({ open: true, anchor: caretPoint(), symbol: c.word, groups, note }); diff --git a/src/features/code-insight/usagesPopup.test.tsx b/src/features/code-insight/usagesPopup.test.tsx new file mode 100644 index 00000000..9a0276e8 --- /dev/null +++ b/src/features/code-insight/usagesPopup.test.tsx @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; + +const calls: string[] = []; +const loc = { path: "/p/b.ts", range: { start: { line: 1, col: 4 }, end: { line: 1, col: 8 } } }; +vi.mock("./ipc", async (orig) => ({ + ...(await orig()), + caretContext: () => ({ doc: 1, path: "/p/a.ts", line: 0, col: 0, word: "used", view: null }), + lspRequest: async () => [loc], + readManyFileLines: async () => new Map([["/p/b.ts", ["", " used();"]]]), + openLocation: async (path: string, line: number) => void calls.push(`open ${path}:${line}`), + hint: (text: string) => void calls.push(`hint ${text}`), +})); +const showUsageResults = vi.fn(); +vi.mock("./usages", async (orig) => ({ + ...(await orig()), + showUsageResults: (a: unknown) => showUsageResults(a), +})); + +const { UsagesPopup, filterUsages } = await import("./UsagesPopup"); +const { showUsagesPopup, useUsagesPopup } = await import("./usages"); + +const usage = (path: string, line: number, text: string) => ({ path, line, col: 0, endCol: 3, text }); +const groups = [ + { path: "/p/a.ts", usages: [usage("/p/a.ts", 2, "foo(order)"), usage("/p/a.ts", 5, "foo(cart)")] }, + { path: "/p/b.ts", usages: [usage("/p/b.ts", 1, "foo(order, 2)")] }, +]; + +function open() { + render(); + act(() => useUsagesPopup.setState({ open: true, anchor: { x: 0, y: 0 }, symbol: "foo", groups, note: null })); + return screen.getByRole("dialog", { name: "Usages" }).querySelector("[tabindex='0']")!; +} + +afterEach(() => act(() => useUsagesPopup.setState({ open: false }))); + +describe("Show Usages popup", () => { + it("filters by typing; Esc clears the filter, then closes", () => { + const body = open(); + expect(screen.getAllByRole("option")).toHaveLength(3); + for (const key of "cart") fireEvent.keyDown(body, { key }); + expect(screen.getAllByRole("option")).toHaveLength(1); + expect(screen.getByText("1 of 3 found")).toBeInTheDocument(); + fireEvent.keyDown(body, { key: "Escape" }); + expect(screen.getAllByRole("option")).toHaveLength(3); + expect(useUsagesPopup.getState().open).toBe(true); + fireEvent.keyDown(body, { key: "Escape" }); + expect(useUsagesPopup.getState().open).toBe(false); + }); + + it("matches file names and line text", () => { + const all = groups.flatMap((g) => g.usages); + expect(filterUsages(all, "b.ts")).toHaveLength(1); + expect(filterUsages(all, "ORDER")).toHaveLength(2); + expect(filterUsages(all, "")).toBe(all); + }); + + it("Open in Find Window moves the found usages instead of searching again", () => { + open(); + fireEvent.click(screen.getByText(/Open in Find Window/)); + expect(useUsagesPopup.getState().open).toBe(false); + expect(showUsageResults).toHaveBeenCalledWith({ + symbol: "foo", + locations: [ + { path: "/p/a.ts", range: { start: { line: 2, col: 0 }, end: { line: 2, col: 3 } } }, + { path: "/p/a.ts", range: { start: { line: 5, col: 0 }, end: { line: 5, col: 3 } } }, + { path: "/p/b.ts", range: { start: { line: 1, col: 0 }, end: { line: 1, col: 3 } } }, + ], + }); + }); + + it("the preview pane toggles", () => { + open(); + expect(screen.queryByRole("region", { name: "Preview" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Show Preview" })); + expect(screen.getByRole("region", { name: "Preview" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Show Preview" })); + }); + + it("a single usage is opened with a hint in its editor", async () => { + calls.length = 0; + await showUsagesPopup(); + expect(calls).toEqual(["open /p/b.ts:1", "hint Only one usage in Project Files"]); + expect(useUsagesPopup.getState().open).toBe(false); + }); +}); diff --git a/src/features/code-insight/views.test.ts b/src/features/code-insight/views.test.ts index 5000bd96..9a2c9d15 100644 --- a/src/features/code-insight/views.test.ts +++ b/src/features/code-insight/views.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from "vitest"; import { groupUsages, trimPreview, usageCount } from "./usages"; import { serversSummary, type ServerEntry } from "./servers"; import { buildProblemNodes } from "./ProblemsView"; -import { mergeDiagnostics } from "./problemsStore"; +import { mergeDiagnostics, sortProblems } from "./problemsStore"; +import { toggleExcludedUsages } from "./UsagesView"; import { childId, directionLabel } from "./hierarchy"; const loc = (path: string, line: number, col: number, endCol: number) => ({ @@ -83,3 +84,34 @@ describe("hierarchy", () => { expect(childId("root", item)).toBe("root//a.rs:1:3:f"); }); }); + +describe("sortProblems", () => { + const d = (severity: "error" | "warning", line: number, message: string) => ({ + range: { start: { line, col: 0 }, end: { line, col: 1 } }, + severity, + message, + source: null, + code: null, + }); + const items = [d("warning", 1, "b"), d("error", 5, "c"), d("warning", 0, "z"), d("error", 2, "a")]; + const lines = (order: { bySeverity: boolean; byName: boolean }) => sortProblems(items, order).map((x) => x.range.start.line); + + it("orders by severity, then position (IDEA's default)", () => { + expect(lines({ bySeverity: true, byName: false })).toEqual([2, 5, 0, 1]); + }); + it("orders by position or by name when asked", () => { + expect(lines({ bySeverity: false, byName: false })).toEqual([0, 1, 2, 5]); + expect(lines({ bySeverity: false, byName: true })).toEqual([2, 1, 5, 0]); + }); +}); + +describe("toggleExcludedUsages", () => { + const u = (line: number) => ({ path: "/a", line, col: 0, endCol: 1, text: "" }); + it("excludes a group unless all of it is excluded already", () => { + const one = toggleExcludedUsages(new Set(), [u(1)]); + expect([...one]).toEqual(["/a:1:0"]); + const both = toggleExcludedUsages(one, [u(1), u(2)]); + expect([...both].sort()).toEqual(["/a:1:0", "/a:2:0"]); + expect(toggleExcludedUsages(both, [u(1), u(2)]).size).toBe(0); + }); +}); diff --git a/src/features/debugger/__tests__/editor.test.ts b/src/features/debugger/__tests__/editor.test.ts index 58ef0307..feb4a5be 100644 --- a/src/features/debugger/__tests__/editor.test.ts +++ b/src/features/debugger/__tests__/editor.test.ts @@ -88,7 +88,10 @@ describe("breakpoint click area", () => { parent: document.body, }); views.push(view); - const down = (el: Element) => el.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 })); + const down = (el: Element) => { + el.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 })); + el.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, button: 0 })); + }; const cells = view.dom.querySelectorAll(".extra .cm-gutterElement"); down(cells[cells.length - 1]!.firstElementChild!); expect(breakpointsOf(ctx.path)).toHaveLength(0); @@ -98,10 +101,12 @@ 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 })); + // Plain and modifier clicks act on release, so a drag over the gutter can select lines. + const click = (view: EditorView, init: MouseEventInit) => { + const cell = view.dom.querySelector(".cm-lineNumbers .cm-gutterElement:not([style*='hidden'])")!; + cell.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0, ...init })); + cell.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, button: 0, ...init })); + }; it("⇧-click adds a non-suspending logging breakpoint, ⌥-click a temporary one", () => { localStorage.clear(); diff --git a/src/features/debugger/__tests__/smartStep.test.ts b/src/features/debugger/__tests__/smartStep.test.ts new file mode 100644 index 00000000..b2ef4408 --- /dev/null +++ b/src/features/debugger/__tests__/smartStep.test.ts @@ -0,0 +1,135 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { _addEditor, _removeEditor, type TextEditorHandle } from "@/editor/api"; +import { useExecutionPoint } from "../state"; +import { useOverlays } from "../ui/popoverStore"; +import { + calleeName, + installSmartStepPicking, + locateStepTargets, + smartStepTargets, + startInlineSmartStep, + targetsOnLine, +} from "../editor/smartStep"; + +const ipc = vi.hoisted(() => ({ debugStepInTarget: vi.fn() })); +vi.mock("@/platform/ipc", async (orig) => { + const real = await orig(); + return { + ...real, + call: (p: Promise) => p, + commands: { ...real.commands, debugStepInTarget: (...a: unknown[]) => (ipc.debugStepInTarget(...a), Promise.resolve(null)) }, + }; +}); + +const target = (id: number, label: string, column: number | null = null, endColumn: number | null = null) => ({ + id, + label, + line: null, + column, + endColumn, +}); + +describe("locating Smart Step Into targets", () => { + it("names the callee of a label", () => { + expect(calleeName("parse(str(i))")).toBe("parse"); + expect(calleeName("java.lang.String.valueOf(int)")).toBe("valueOf"); + expect(calleeName("")).toBeNull(); + }); + + it("places targets by name, in line order, or by adapter columns", () => { + const line = " x = parse(str(i)) + str (j)"; + const r = locateStepTargets(line, 3, [target(1, "str(i)"), target(2, "parse(str(i))"), target(3, "str(j)")]); + expect(r).toEqual([ + { id: 2, from: 8, to: 13 }, + { id: 1, from: 14, to: 17 }, + { id: 3, from: 24, to: 27 }, + ]); + expect(locateStepTargets(line, 3, [target(7, "whatever", 8, 13), target(8, "x", 14)])).toEqual([ + { id: 7, from: 8, to: 13 }, + { id: 8, from: 14, to: 17 }, + ]); + expect(locateStepTargets(line, 3, [target(1, "missing()")])).toBeNull(); + }); + + it("keeps only the paused line's calls when the adapter lists the whole function", () => { + const line = " total = add(total, square(i))"; + const all = [target(0, "square(i)"), target(1, "add(total, square(i))"), target(2, 'print("total", total)')]; + expect(targetsOnLine(line, 11, all).map((t) => t.id)).toEqual([0, 1]); + expect(targetsOnLine(line, 11, [{ ...target(5, "print()"), line: 11 }, { ...target(6, "add()"), line: 12 }]).map((t) => t.id)).toEqual([5]); + expect(targetsOnLine("pass", 0, all)).toBe(all); + }); +}); + +describe("picking in the editor", () => { + const views: EditorView[] = []; + const cleanup: Array<() => void> = []; + afterEach(() => { + cleanup.splice(0).forEach((d) => d()); + views.splice(0).forEach((v) => v.destroy()); + useOverlays.setState({ smartStep: null }); + useExecutionPoint.setState({ path: null, line: 0, top: true, values: {} }); + ipc.debugStepInTarget.mockReset(); + }); + + function setup() { + const ctx = { doc: 1, path: "/p/app.py", language: "python", uiMode: null, readOnly: false }; + const view = new EditorView({ + state: EditorState.create({ doc: "def f():\n x = parse(str(i))\n", extensions: [smartStepTargets(ctx)] }), + parent: document.body, + }); + views.push(view); + const handle: TextEditorHandle = { view, ctx }; + _addEditor(handle); + cleanup.push(() => _removeEditor(handle), installSmartStepPicking()); + useExecutionPoint.setState({ path: "/p/app.py", line: 1, top: true }); + return view; + } + + const flush = () => new Promise((r) => setTimeout(r, 0)); + const key = (k: string, init: KeyboardEventInit = {}) => + window.dispatchEvent(new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true, ...init })); + const marked = (view: EditorView) => + [...view.dom.querySelectorAll("[data-step-target]")].map((e) => [e.textContent, e.getAttribute("aria-current")]); + + it("highlights the calls, moves with arrows and steps in with Enter", async () => { + const view = setup(); + expect(startInlineSmartStep(3, 1, [target(1, "str(i)"), target(2, "parse(str(i))")])).toBe(true); + await flush(); + expect(marked(view)).toEqual([ + ["parse", null], + ["str", "true"], + ]); + key("ArrowLeft"); + await flush(); + expect(marked(view)[0]).toEqual(["parse", "true"]); + key("Enter"); + expect(ipc.debugStepInTarget).toHaveBeenCalledWith(3, 1, 2); + expect(useOverlays.getState().smartStep).toBeNull(); + await flush(); + expect(marked(view)).toEqual([]); + }); + + it("Escape cancels; a click on a call steps into it", async () => { + const view = setup(); + startInlineSmartStep(3, 1, [target(1, "str(i)"), target(2, "parse(str(i))")]); + key("Escape"); + expect(useOverlays.getState().smartStep).toBeNull(); + expect(ipc.debugStepInTarget).not.toHaveBeenCalled(); + + startInlineSmartStep(3, 1, [target(1, "str(i)"), target(2, "parse(str(i))")]); + await flush(); + const parse = view.dom.querySelector("[data-step-target='2']")!; + parse.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true })); + expect(ipc.debugStepInTarget).toHaveBeenCalledWith(3, 1, 2); + }); + + it("uses the dialog when the line is not open or the calls cannot be placed", () => { + setup(); + expect(startInlineSmartStep(3, 1, [target(1, "a()"), target(2, "b()")])).toBe(false); + useExecutionPoint.setState({ path: "/p/other.py" }); + expect(startInlineSmartStep(3, 1, [target(1, "str(i)"), target(2, "parse(str(i))")])).toBe(false); + expect(useOverlays.getState().smartStep).toBeNull(); + }); +}); diff --git a/src/features/debugger/actions.ts b/src/features/debugger/actions.ts index b8013ddb..f70c73c7 100644 --- a/src/features/debugger/actions.ts +++ b/src/features/debugger/actions.ts @@ -33,6 +33,7 @@ import { } from "./state"; import { focusedDebugView } from "./ui/focusedView"; import { expressionAt } from "./editor/valueTooltip"; +import { pausedLineTargets, startInlineSmartStep } from "./editor/smartStep"; import { openBreakpointsDialog, openConfigChooser, @@ -79,7 +80,10 @@ function selectedFrame(s: DebugSession) { return s.frames.find((f) => f.id === s.selectedFrame) ?? s.frames[0] ?? null; } -/** Smart Step Into: the calls on the current line; one call (or none) steps straight in, like IDEA. */ +/** + * Smart Step Into: the calls on the current line, picked in the editor (the + * dialog when they cannot be placed); one call (or none) steps straight in, like IDEA. + */ async function smartStepInto() { const t = pausedTarget(); if (!t) return; @@ -88,7 +92,7 @@ async function smartStepInto() { await call(commands.debugStep(t.s.id, t.thread, "into")); return; } - const targets = await call(commands.debugStepInTargets(t.s.id, frame.id)); + const targets = pausedLineTargets(await call(commands.debugStepInTargets(t.s.id, frame.id))); if (targets.length <= 1) { await call( targets[0] @@ -97,7 +101,7 @@ async function smartStepInto() { ); return; } - openSmartStepChooser(t.s.id, t.thread, targets); + if (!startInlineSmartStep(t.s.id, t.thread, targets)) openSmartStepChooser(t.s.id, t.thread, targets); } /** Opens the Attach to Process popup and loads the process list. */ diff --git a/src/features/debugger/editor/breakpointGutter.ts b/src/features/debugger/editor/breakpointGutter.ts index c5445fb7..de8cb10f 100644 --- a/src/features/debugger/editor/breakpointGutter.ts +++ b/src/features/debugger/editor/breakpointGutter.ts @@ -219,6 +219,20 @@ export function breakpointGutter(ctx: EditorDocContext): Extension { } if (!canPlaceBreakpoint(ctx.language) && !bp) return; e.preventDefault(); + // Acts on release over the same line: a drag across the gutter selects lines instead. + const up = (u: MouseEvent) => { + window.removeEventListener("mouseup", up, true); + if (u.button === 0 && lineAtEvent(this.view, u) === line) this.onClick(e, line, special, bp); + }; + window.addEventListener("mouseup", up, true); + }; + + onClick = ( + e: MouseEvent, + line: number, + special: ReturnType[number] | undefined, + bp: ReturnType[number] | undefined, + ) => { if (special || bp) { if (e.shiftKey || e.altKey) return; if (special) removeSpecial(special.id); diff --git a/src/features/debugger/editor/editor.module.css b/src/features/debugger/editor/editor.module.css index b45db337..2a89c9df 100644 --- a/src/features/debugger/editor/editor.module.css +++ b/src/features/debugger/editor/editor.module.css @@ -172,3 +172,15 @@ .valueType { color: var(--fg-subtle); } + +/* Smart Step Into: calls on the paused line framed, the selected one filled. */ +.stepTarget { + border-radius: 2px; + box-shadow: inset 0 0 0 1px var(--border-strong); + cursor: pointer; +} + +.stepTargetSelected { + background: var(--accent-soft); + box-shadow: inset 0 0 0 1px var(--accent); +} diff --git a/src/features/debugger/editor/smartStep.ts b/src/features/debugger/editor/smartStep.ts new file mode 100644 index 00000000..bd149f12 --- /dev/null +++ b/src/features/debugger/editor/smartStep.ts @@ -0,0 +1,262 @@ +/** + * Smart Step Into in the editor, as in IDEA: the calls on the current line are + * highlighted in place; ←/→ (or Tab / ⇧Tab) move between them, Enter or a + * click steps into the chosen one, Esc cancels. Hovering a call selects it. + * Falls back to the chooser dialog when the calls cannot be placed on the line. + */ +import { RangeSetBuilder, type Extension } from "@codemirror/state"; +import { Decoration, EditorView, ViewPlugin, type DecorationSet } from "@codemirror/view"; +import { editorsFor, activeEditor, type EditorDocContext, type TextEditorHandle } from "@/editor/api"; +import { call, commands, type DebugSessionId, type StepInTarget } from "@/platform/ipc"; +import { suspendKeyboardDispatch } from "@/platform/keymap"; +import { notifications } from "@/workbench/contributions"; +import { useExecutionPoint } from "../state"; +import { useOverlays, type InlineStepTarget } from "../ui/popoverStore"; +import styles from "./editor.module.css"; + +const IDENT = /[\p{L}\p{N}_$]/u; + +/** The callee name of a target label: `a.b.parse(str(i))` → `parse`. */ +export function calleeName(label: string): string | null { + const head = label.split("(")[0]!.trim(); + const name = head.split(/[.:#\s]+/).pop() ?? ""; + return name && [...name].every((c) => IDENT.test(c)) ? name : null; +} + +/** A call of `name`: `name(` not preceded by an identifier character. */ +function callPattern(name: string, flags: string): RegExp { + return new RegExp(`(?(); + const out: InlineStepTarget[] = []; + for (const t of targets) { + let from = -1; + let to = -1; + if (t.column != null && (t.line == null || t.line === line) && t.column < lineText.length) { + from = t.column; + to = t.endColumn != null && t.endColumn > from ? Math.min(t.endColumn, lineText.length) : identifierEnd(lineText, from); + } else { + const name = calleeName(t.label); + if (!name) return null; + for (const m of lineText.matchAll(callPattern(name, "gu"))) { + if (used.has(m.index!)) continue; + from = m.index!; + to = from + name.length; + break; + } + } + if (from < 0 || to <= from || used.has(from)) return null; + used.add(from); + out.push({ id: t.id, from, to }); + } + return out.sort((a, b) => a.from - b.from); +} + +/** + * Targets that belong to the paused line. Some adapters (debugpy) list every + * call left in the function without positions; those are kept only when their + * callee appears on the line. Unchanged when nothing would remain. + */ +export function targetsOnLine(lineText: string, line: number, targets: StepInTarget[]): StepInTarget[] { + const kept = targets.filter((t) => { + if (t.line != null) return t.line === line; + if (t.column != null) return true; + const name = calleeName(t.label); + return !name || callPattern(name, "u").test(lineText); + }); + return kept.length ? kept : targets; +} + +/** Targets of the paused line, when that line is open in an editor. */ +export function pausedLineTargets(targets: StepInTarget[]): StepInTarget[] { + const { path, line } = useExecutionPoint.getState(); + const doc = path ? editorShowing(path)?.view.state.doc : null; + return doc && line < doc.lines ? targetsOnLine(doc.line(line + 1).text, line, targets) : targets; +} + +/** Editor to pick in: the active one when it shows `path`, else any showing it. */ +function editorShowing(path: string): TextEditorHandle | null { + const active = activeEditor(); + if (active?.ctx.path === path) return active; + return editorsFor(path)[0] ?? null; +} + +/** + * Starts inline picking when the paused line is open in an editor and every + * target can be placed on it; returns false otherwise (caller shows the dialog). + */ +export function startInlineSmartStep(session: DebugSessionId, thread: number, targets: StepInTarget[]): boolean { + const { path, line, top } = useExecutionPoint.getState(); + if (!path || !top) return false; + const editor = editorShowing(path); + const doc = editor?.view.state.doc; + if (!editor || !doc || line >= doc.lines) return false; + const ranges = locateStepTargets(doc.line(line + 1).text, line, targets); + if (!ranges || ranges.length < 2) return false; + const first = ranges.findIndex((r) => r.id === targets[0]!.id); + useOverlays.setState({ smartStep: { session, thread, targets, inline: { path, line, ranges, index: Math.max(0, first) } } }); + editor.view.focus(); + return true; +} + +export function cancelSmartStep() { + useOverlays.setState({ smartStep: null }); +} + +function selectStepTarget(index: number) { + const s = useOverlays.getState().smartStep; + if (!s?.inline) return; + const n = s.inline.ranges.length; + useOverlays.setState({ smartStep: { ...s, inline: { ...s.inline, index: ((index % n) + n) % n } } }); +} + +/** Steps into the target `id` (the selected one by default) and leaves picking mode. */ +export function chooseStepTarget(id?: number) { + const s = useOverlays.getState().smartStep; + if (!s) return; + const target = id ?? s.inline?.ranges[s.inline.index]?.id; + cancelSmartStep(); + if (target == null) return; + void call(commands.debugStepInTarget(s.session, s.thread, target)).catch((e) => + notifications().notify({ + group: "run", + level: "error", + title: "Smart Step Into failed", + body: e instanceof Error ? e.message : String(e), + }), + ); +} + +const MODIFIERS = new Set(["Shift", "Control", "Alt", "Meta", "CapsLock"]); + +function targetIdOf(e: Event): number | null { + const el = e.target instanceof Element ? e.target.closest("[data-step-target]") : null; + return el ? Number(el.dataset.stepTarget) : null; +} + +/** + * While picking: shortcuts are suspended and keys / clicks go to the picker + * (window capture, ahead of the editor). Any other key, a click elsewhere or + * the debugger moving on cancels. + */ +export function installSmartStepPicking(target: Window = window): () => void { + let teardown: (() => void) | null = null; + const onKeyDown = (e: KeyboardEvent) => { + const s = useOverlays.getState().smartStep?.inline; + if (!s || MODIFIERS.has(e.key)) return; + e.preventDefault(); + e.stopPropagation(); + if (e.key === "ArrowLeft" || e.key === "ArrowUp" || (e.key === "Tab" && e.shiftKey)) selectStepTarget(s.index - 1); + else if (e.key === "ArrowRight" || e.key === "ArrowDown" || e.key === "Tab") selectStepTarget(s.index + 1); + else if (e.key === "Enter") chooseStepTarget(); + else cancelSmartStep(); + }; + const onMouseDown = (e: MouseEvent) => { + if (!useOverlays.getState().smartStep?.inline) return; + const id = targetIdOf(e); + if (id == null) return cancelSmartStep(); + e.preventDefault(); + e.stopPropagation(); + chooseStepTarget(id); + }; + const onMouseOver = (e: MouseEvent) => { + const s = useOverlays.getState().smartStep?.inline; + const id = targetIdOf(e); + if (!s || id == null) return; + const index = s.ranges.findIndex((r) => r.id === id); + if (index >= 0 && index !== s.index) selectStepTarget(index); + }; + const start = () => { + const resume = suspendKeyboardDispatch(); + target.addEventListener("keydown", onKeyDown, true); + target.addEventListener("mousedown", onMouseDown, true); + target.addEventListener("mouseover", onMouseOver, true); + const unsubExec = useExecutionPoint.subscribe((p, prev) => { + if (p.path !== prev.path || p.line !== prev.line || p.top !== prev.top) cancelSmartStep(); + }); + teardown = () => { + resume(); + target.removeEventListener("keydown", onKeyDown, true); + target.removeEventListener("mousedown", onMouseDown, true); + target.removeEventListener("mouseover", onMouseOver, true); + unsubExec(); + }; + }; + const sync = () => { + const active = !!useOverlays.getState().smartStep?.inline; + if (active && !teardown) start(); + else if (!active && teardown) { + teardown(); + teardown = null; + } + }; + const unsub = useOverlays.subscribe(sync); + sync(); + return () => { + unsub(); + teardown?.(); + teardown = null; + }; +} + +function decorations(view: EditorView, path: string): DecorationSet { + const inline = useOverlays.getState().smartStep?.inline; + if (!inline || inline.path !== path) return Decoration.none; + const doc = view.state.doc; + if (inline.line >= doc.lines) return Decoration.none; + const line = doc.line(inline.line + 1); + const builder = new RangeSetBuilder(); + inline.ranges.forEach((r, i) => { + if (r.to > line.length) return; + const selected = i === inline.index; + builder.add( + line.from + r.from, + line.from + r.to, + Decoration.mark({ + class: selected ? `${styles.stepTarget} ${styles.stepTargetSelected}` : styles.stepTarget, + attributes: { "data-step-target": String(r.id), ...(selected ? { "aria-current": "true" } : {}) }, + }), + ); + }); + return builder.finish(); +} + +/** Highlights the Smart Step Into targets while picking in this editor. */ +export function smartStepTargets(ctx: EditorDocContext): Extension { + const path = ctx.path; + return ViewPlugin.fromClass( + class { + decorations: DecorationSet; + private unsub: () => void; + private destroyed = false; + constructor(readonly view: EditorView) { + this.decorations = decorations(view, path); + this.unsub = useOverlays.subscribe((s, prev) => { + if (s.smartStep === prev.smartStep) return; + queueMicrotask(() => !this.destroyed && this.view.dispatch({})); + }); + } + update() { + this.decorations = decorations(this.view, path); + } + destroy() { + this.destroyed = true; + this.unsub(); + } + }, + { decorations: (v) => v.decorations }, + ); +} diff --git a/src/features/debugger/index.ts b/src/features/debugger/index.ts index 716836e7..7eaa0bbb 100644 --- a/src/features/debugger/index.ts +++ b/src/features/debugger/index.ts @@ -15,6 +15,7 @@ import { loadExceptionBreakpoints } from "./breakpoints/exceptions"; import { loadSpecialBreakpoints } from "./breakpoints/special"; import { breakpointGutter } from "./editor/breakpointGutter"; import { executionPoint } from "./editor/execution"; +import { installSmartStepPicking, smartStepTargets } from "./editor/smartStep"; import { onHotCodeReplace } from "./hotswap"; import { handleDebugEvent, isLive, loadWatches, publishContext, resetSessions, stopSession, useDebug } from "./state"; import { DebuggerOverlays } from "./ui/Overlays"; @@ -50,8 +51,9 @@ export function activate(): () => void { registerEditorContribution({ id: "debugger", order: 60, - extensions: (ctx) => [breakpointGutter(ctx), executionPoint(ctx), valueTooltip()], + extensions: (ctx) => [breakpointGutter(ctx), executionPoint(ctx), valueTooltip(), smartStepTargets(ctx)], }), + installSmartStepPicking(), onIdeEvent("debug", (e) => { if (e.event.kind === "hotCodeReplace") onHotCodeReplace(e.session, e.event); else handleDebugEvent(e.session, e.event); diff --git a/src/features/debugger/ui/Overlays.tsx b/src/features/debugger/ui/Overlays.tsx index a837d02b..570f1a97 100644 --- a/src/features/debugger/ui/Overlays.tsx +++ b/src/features/debugger/ui/Overlays.tsx @@ -956,7 +956,9 @@ function AttachPopup() { // --- Smart Step Into ------------------------------------------------------------- function SmartStepChooser() { - const state = useOverlays((s) => s.smartStep); + const all = useOverlays((s) => s.smartStep); + // Picked in the editor instead. + const state = all?.inline ? null : all; const close = () => useOverlays.setState({ smartStep: null }); const pick = (targetId: number) => { if (!state) return; diff --git a/src/features/debugger/ui/popoverStore.ts b/src/features/debugger/ui/popoverStore.ts index 449e2797..3b5e1ff4 100644 --- a/src/features/debugger/ui/popoverStore.ts +++ b/src/features/debugger/ui/popoverStore.ts @@ -2,6 +2,13 @@ import { create } from "zustand"; import type { AttachableProcess, DebugSessionId, RunConfig, StepInTarget } from "@/platform/ipc"; +/** A Smart Step Into target placed on the paused line (UTF-16 offsets within the line). */ +export interface InlineStepTarget { + id: number; + from: number; + to: number; +} + interface OverlayState { popover: { path: string; id: string; x: number; y: number; focusLog: boolean } | null; evaluate: { expression: string } | null; @@ -9,8 +16,16 @@ interface OverlayState { chooser: { configs: RunConfig[] } | null; /** Attach to Process popup; `processes` is null while loading. */ attach: { processes: AttachableProcess[] | null; error: string | null } | null; - /** Smart Step Into chooser. */ - smartStep: { session: DebugSessionId; thread: number; targets: StepInTarget[] } | null; + /** + * Smart Step Into: picked in the editor when `inline` is set (targets sorted + * by position, `index` the selected one), else in the chooser dialog. + */ + smartStep: { + session: DebugSessionId; + thread: number; + targets: StepInTarget[]; + inline?: { path: string; line: number; ranges: InlineStepTarget[]; index: number }; + } | null; } export const useOverlays = create(() => ({ diff --git a/src/features/project-tree/HeaderActions.tsx b/src/features/project-tree/HeaderActions.tsx index e1dc6289..27053d31 100644 --- a/src/features/project-tree/HeaderActions.tsx +++ b/src/features/project-tree/HeaderActions.tsx @@ -19,9 +19,9 @@ export function ProjectHeaderActions() { <> diff --git a/src/features/project-tree/actions.ts b/src/features/project-tree/actions.ts index d6242a1c..c1ff897d 100644 --- a/src/features/project-tree/actions.ts +++ b/src/features/project-tree/actions.ts @@ -1,6 +1,8 @@ /** Actions of the project view (Find Action, keymap, other features via `executeAction`). */ -import { registerActions, type Action } from "@/platform/actions"; -import { maybeEditorService, toolWindowService } from "@/workbench/contributions"; +import { activeEditor } from "@/editor/api"; +import { getAction, registerActions, type Action } from "@/platform/actions"; +import { showContextMenu, type MenuItem } from "@/ui"; +import { maybeEditorService, menuGroupItems, toolWindowService } from "@/workbench/contributions"; import { OPTION_LABELS } from "./HeaderActions"; import { revealLabel } from "./contextMenu"; import type { ProjectNode } from "./model"; @@ -77,6 +79,34 @@ export function selectInProjectView(path?: string | null, focus = true) { return revealPath(target, focus); } +/** + * Select In (⌥F1): a chooser of the places that can show `path` (default: the + * active editor's file): the project view, contributed targets (`selectIn` + * menu groups), the navigation bar, Finder / Explorer and the terminal. + */ +export function selectInTargets(path: string): MenuItem[] { + return [ + { kind: "header", label: "Select In" }, + { label: "Project View", icon: "toolwindow-project", run: () => void selectInProjectView(path) }, + ...menuGroupItems("selectIn", { paths: [path] }), + ...(getAction("navigate.navigationBar") && maybeEditorService()?.activePath() === path + ? [{ kind: "action" as const, action: "navigate.navigationBar", label: "Navigation Bar" }] + : []), + { label: revealLabel(), icon: "external-link", run: () => void revealInFinder(nodesFrom(path)) }, + ...(getAction("terminal.openAt") + ? [{ label: "Terminal", icon: "terminal", run: () => void delegate("terminal.openAt", parentOf(path)) }] + : []), + ]; +} + +function showSelectIn(path: string) { + // At the caret, like IDEA's popup; centred near the top otherwise. + const view = activeEditor()?.view; + const coords = view && activeEditor()?.ctx.path === path ? view.coordsAtPos(view.state.selection.main.head) : null; + const at = coords ? { x: coords.left, y: coords.bottom + 2 } : { x: window.innerWidth / 2 - 100, y: 80 }; + showContextMenu(at, selectInTargets(path)); +} + export function registerProjectActions(): () => void { const copyPath = (kind: PathKind, id: string, title: string): Action => ({ id, @@ -97,16 +127,27 @@ export function registerProjectActions(): () => void { return registerActions([ { + // A path argument (links, breadcrumbs, Open In › Project View) selects it in the project view. id: "navigate.selectIn", + title: "Select In…", + category: "Navigate", + when: OPEN, + keywords: ["locate", "reveal", "show in"], + run: ({ arg }) => { + const path = typeof arg === "string" ? arg : (arg as { path?: string } | undefined)?.path; + if (path) return void selectInProjectView(path); + const target = maybeEditorService()?.activePath() ?? leadNode()?.path; + if (target) showSelectIn(target); + }, + }, + { + id: "navigate.selectInProjectView", title: "Select in Project View", category: "Navigate", icon: "locate", when: OPEN, - keywords: ["locate", "reveal", "scroll from source"], - run: ({ arg }) => - void selectInProjectView( - typeof arg === "string" ? arg : ((arg as { path?: string } | undefined)?.path ?? null), - ), + keywords: ["locate", "reveal", "scroll from source", "select opened file"], + run: ({ arg }) => void selectInProjectView(typeof arg === "string" ? arg : null), }, { id: "project.reveal", diff --git a/src/features/project-tree/selectIn.test.ts b/src/features/project-tree/selectIn.test.ts new file mode 100644 index 00000000..0c8bcdc5 --- /dev/null +++ b/src/features/project-tree/selectIn.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { executeAction, getAction, registerActions } from "@/platform/actions"; +import { setContext } from "@/platform/context"; +import { resolveMenuItems, useContextMenuStore, type MenuItem } from "@/ui"; +import { provideEditorService, registerMenuGroup, type EditorService } from "@/workbench/contributions"; +import { registerProjectActions, selectInTargets } from "./actions"; +import { revealLabel } from "./contextMenu"; + +const labels = (items: MenuItem[]) => resolveMenuItems(items).map((r) => (r.kind === "separator" ? "-" : r.label)); + +const disposers: Array<() => void> = []; +afterEach(() => { + disposers.splice(0).forEach((d) => d()); + useContextMenuStore.setState({ menu: null }); +}); + +describe("Select In (⌥F1)", () => { + it("lists the project view, contributed targets, Finder and the terminal", () => { + disposers.push( + registerMenuGroup({ id: "test.selectIn", menus: ["selectIn"], items: () => [{ label: "Structure", run: () => {} }] }), + registerActions([{ id: "terminal.openAt", title: "Open Terminal At", run: () => {} }]), + ); + expect(labels(selectInTargets("/p/src/a.ts"))).toEqual([ + "Select In", + "Project View", + "Structure", + "-", + revealLabel(), + "Terminal", + ]); + }); + + it("without a path shows the chooser for the active file; with one selects it in the project view", async () => { + disposers.push(registerProjectActions(), () => setContext("projectOpen", false)); + setContext("projectOpen", true); + provideEditorService({ activePath: () => "/p/src/a.ts" } as unknown as EditorService); + expect(getAction("navigate.selectInProjectView")?.title).toBe("Select in Project View"); + await executeAction("navigate.selectIn"); + expect(useContextMenuStore.getState().menu?.items[1]).toMatchObject({ label: "Project View" }); + useContextMenuStore.setState({ menu: null }); + await executeAction("navigate.selectIn", { path: "/p/src/a.ts" }); + expect(useContextMenuStore.getState().menu).toBeNull(); + }); +}); diff --git a/src/features/run/RunView.tsx b/src/features/run/RunView.tsx index 3a1c03a3..ed0a1d50 100644 --- a/src/features/run/RunView.tsx +++ b/src/features/run/RunView.tsx @@ -9,11 +9,12 @@ import { runDirectory } from "./navigate"; import { acceptsInput, clearConsole, - closeSession, + closeSessionTab, closeStdin, failedTests, rerunFailed, rerunSession, + runSelectedTests, runtime, sendStdin, setPendingInput, @@ -152,6 +153,7 @@ function SessionPanel({ session }: { session: RunSession }) { running={running} selected={selectedTest} onSelect={setSelectedTest} + onRunTests={(ids, debug) => void runSelectedTests(session.id, ids, debug)} /> @@ -215,7 +217,7 @@ export function RunTabs() { items={items} activeId={"session" in current ? current.session.id : current.trace.id} onSelect={(id) => useRun.setState({ activeSession: id })} - onClose={(id) => (traces.some((t) => t.id === id) ? closeStackTrace(id) : closeSession(id))} + onClose={(id) => (traces.some((t) => t.id === id) ? closeStackTrace(id) : void closeSessionTab(id))} panelIdPrefix="openidea-run" /> ); diff --git a/src/features/run/TestTreeView.test.tsx b/src/features/run/TestTreeView.test.tsx new file mode 100644 index 00000000..1a07f390 --- /dev/null +++ b/src/features/run/TestTreeView.test.tsx @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { ContextMenuHost } from "@/ui"; +import { TestResults } from "./testing"; +import { TestTreeView } from "./TestTreeView"; + +const nav = vi.hoisted(() => ({ openTarget: vi.fn(async () => {}) })); +vi.mock("./navigate", () => nav); +const actions = vi.hoisted(() => ({ executeAction: vi.fn(async () => {}) })); +vi.mock("@/platform/actions", async (orig) => ({ ...(await orig()), executeAction: actions.executeAction })); + +function results() { + const r = new TestResults(); + r.apply([ + { type: "result", path: ["MathTest", "adds()"], status: "passed" }, + { type: "result", path: ["MathTest", "subs()"], status: "failed" }, + { + type: "output", + path: ["MathTest", "subs()"], + line: "org.opentest4j.AssertionFailedError: expected: <1> but was: <2>", + }, + { type: "location", path: ["MathTest", "subs()"], location: { path: "MathTest.java", line: 9, resolve: "name" } }, + ]); + r.finish(1); + return r; +} + +function Host(props: { onRunTests?: (ids: string[], debug: boolean) => void }) { + return ( + <> + {}} + running={false} + {...props} + /> + + + ); +} + +describe("TestTreeView", () => { + beforeEach(() => localStorage.clear()); + afterEach(() => vi.clearAllMocks()); + + it("hides passed tests by default and remembers the toggle", () => { + const { unmount } = render(); + expect(screen.queryByText("adds()")).toBeNull(); + expect(screen.getByText("subs()")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Show Passed" })); + expect(screen.getByText("adds()")).toBeTruthy(); + unmount(); + render(); + expect(screen.getByText("adds()")).toBeTruthy(); + }); + + it("a click selects without navigating; double-click jumps to the source", () => { + render(); + fireEvent.mouseDown(screen.getByText("subs()")); + fireEvent.click(screen.getByText("subs()")); + expect(nav.openTarget).not.toHaveBeenCalled(); + fireEvent.doubleClick(screen.getByText("subs()")); + expect(nav.openTarget).toHaveBeenCalledWith( + expect.objectContaining({ path: "MathTest.java", line: 9 }), + "/p", + true, + ); + }); + + it("context menu: Run / Debug the test and Show Diff of an assertion failure", () => { + const onRunTests = vi.fn(); + render(); + fireEvent.contextMenu(screen.getByText("subs()")); + fireEvent.click(screen.getByText("Debug 'subs()'")); + expect(onRunTests).toHaveBeenCalledWith(["MathTest\u0000subs()"], true); + fireEvent.contextMenu(screen.getByText("subs()")); + fireEvent.click(screen.getByText("Show Diff")); + expect(actions.executeAction).toHaveBeenCalledWith( + "diff.compareTexts", + expect.objectContaining({ left: { text: "1", label: "Expected" }, right: { text: "2", label: "Actual" } }), + ); + }); +}); diff --git a/src/features/run/TestTreeView.tsx b/src/features/run/TestTreeView.tsx index 1d4bc323..0e9ab244 100644 --- a/src/features/run/TestTreeView.tsx +++ b/src/features/run/TestTreeView.tsx @@ -1,10 +1,51 @@ -import { useMemo, useState } from "react"; +import { useMemo, useState, type MouseEvent } from "react"; import clsx from "clsx"; -import { Icon, IconButton, Spinner, Tree } from "@/ui"; +import { executeAction } from "@/platform/actions"; +import { Icon, IconButton, showContextMenu, Spinner, Tree, type MenuItem } from "@/ui"; import { openTarget } from "./navigate"; import { nodeStatus, type TestNode, type TestResults, type TestStatus } from "./testing"; +import { parseComparison } from "./testing/comparison"; import styles from "./Run.module.css"; +/** Test tree filters, kept across runs and restarts (IDEA hides passed tests by default). */ +const FILTERS_KEY = "openidea.run.testFilters"; + +interface TestFilters { + showPassed: boolean; + showIgnored: boolean; +} + +export function loadTestFilters(): TestFilters { + try { + const raw = globalThis.localStorage?.getItem(FILTERS_KEY); + const v = raw ? (JSON.parse(raw) as Partial) : {}; + return { showPassed: v.showPassed === true, showIgnored: v.showIgnored !== false }; + } catch { + return { showPassed: false, showIgnored: true }; + } +} + +function saveTestFilters(f: TestFilters) { + try { + globalThis.localStorage?.setItem(FILTERS_KEY, JSON.stringify(f)); + } catch { + /* storage unavailable */ + } +} + +/** "Show Diff" for an assertion failure: Expected on the left, Actual on the right. */ +export function showTestDiff(node: TestNode): boolean { + const c = parseComparison(node.output); + if (!c) return false; + void executeAction("diff.compareTexts", { + path: `${node.path.join("/")}.txt`, + title: `Comparison Failure: ${node.name}`, + left: { text: c.expected, label: c.labels?.expected ?? "Expected" }, + right: { text: c.actual, label: c.labels?.actual ?? "Actual" }, + }); + return true; +} + export function formatDuration(ms: number | undefined): string { if (ms === undefined) return ""; if (ms < 1000) return `${Math.round(ms)} ms`; @@ -62,12 +103,50 @@ export interface TestTreeViewProps { selected: string | null; onSelect: (id: string | null) => void; running: boolean; + /** Run / Debug the selected tests (context menu); absent when the configuration can't be narrowed. */ + onRunTests?: (ids: string[], debug: boolean) => void; } -export function TestTreeView({ results, version, cwd, selected, onSelect, running }: TestTreeViewProps) { - const [showPassed, setShowPassed] = useState(true); - const [showIgnored, setShowIgnored] = useState(true); +export function TestTreeView({ results, version, cwd, selected, onSelect, running, onRunTests }: TestTreeViewProps) { + const [filters, setFilters] = useState(loadTestFilters); + const { showPassed, showIgnored } = filters; + const toggle = (key: keyof TestFilters) => + setFilters((f) => { + const next = { ...f, [key]: !f[key] }; + saveTestFilters(next); + return next; + }); const [expanded, setExpanded] = useState | null>(null); + const [selection, setSelection] = useState>(new Set()); + const selectedKeys = useMemo( + () => (selected === null ? new Set() : selection.has(selected) ? selection : new Set([selected])), + [selected, selection], + ); + + const jumpToSource = (n: TestNode) => { + const target = n.location ?? firstLocated(n)?.location; + if (target) void openTarget(target, cwd, true); + }; + + const contextMenu = (node: TestNode | null, sel: Set, e: MouseEvent) => { + if (!node) return; + const ids = sel.has(node.id) ? [...sel] : [node.id]; + const name = ids.length === 1 ? (results.get(ids[0]!)?.name ?? node.name) : `${ids.length} tests`; + const located = node.location ?? firstLocated(node)?.location; + const items: MenuItem[] = []; + if (onRunTests) { + items.push( + { label: `Run '${name}'`, verbatim: true, icon: "run", run: () => onRunTests(ids, false) }, + { label: `Debug '${name}'`, verbatim: true, icon: "debug", run: () => onRunTests(ids, true) }, + { kind: "separator" }, + ); + } + if (ids.length === 1 && nodeStatus(node) === "failed" && parseComparison(node.output)) + items.push({ label: "Show Diff", icon: "diff", run: () => void showTestDiff(node) }); + items.push({ label: "Jump to Source", icon: "external-link", shortcut: "F4", disabled: !located, run: () => jumpToSource(node) }); + e.preventDefault(); + showContextMenu(e, items); + }; const counts = useMemo(() => results.counts(), [results, version]); // eslint-disable-line react-hooks/exhaustive-deps const roots = useMemo(() => { @@ -112,7 +191,7 @@ export function TestTreeView({ results, version, cwd, selected, onSelect, runnin iconSize={13} title="Show Passed" pressed={showPassed} - onClick={() => setShowPassed((v) => !v)} + onClick={() => toggle("showPassed")} /> setShowIgnored((v) => !v)} + onClick={() => toggle("showIgnored")} /> @@ -133,16 +212,15 @@ export function TestTreeView({ results, version, cwd, selected, onSelect, runnin isExpandable={(n) => n.children.length > 0} expanded={effectiveExpanded} onExpandedChange={(next) => setExpanded(next)} - selected={selected ? new Set([selected]) : new Set()} - onSelectionChange={(_sel, lead) => { + selected={selectedKeys} + // A click only selects (shows the test's output); double-click / Enter / F4 jump to the source, as in IDEA. + onSelectionChange={(sel, lead) => { + setSelection(sel); onSelect(lead?.id ?? null); - if (lead && lead.location && nodeStatus(lead) === "failed") void openTarget(lead.location, cwd, false); }} activateExpandable - onActivate={(n) => { - const target = n.location ?? firstLocated(n)?.location; - if (target) void openTarget(target, cwd, true); - }} + onActivate={jumpToSource} + onContextMenu={contextMenu} renderNode={(n) => { const s = nodeStatus(n); const d = totalDuration(n); @@ -154,7 +232,11 @@ export function TestTreeView({ results, version, cwd, selected, onSelect, runnin ); }} - empty={
{running ? "Waiting for test results…" : "No test results"}
} + empty={ +
+ {running ? "Waiting for test results…" : results.isEmpty ? "No test results" : "Passed tests are hidden"} +
+ } /> ); diff --git a/src/features/run/context.ts b/src/features/run/context.ts index cf9622bf..a0691a54 100644 --- a/src/features/run/context.ts +++ b/src/features/run/context.ts @@ -39,17 +39,21 @@ function testKinds(relPath: string): string[] { return []; } -/** Config whose working directory most specifically contains `relPath`. */ -function closest(configs: RunConfig[], relPath: string): RunConfig | undefined { +/** Configs whose working directory most specifically contains `relPath` (ties in their original order). */ +function allClosest(configs: RunConfig[], relPath: string): RunConfig[] { const score = (c: RunConfig) => { const cwd = (c.cwd ?? "").replace(/^\.\/?/, "").replace(/\/+$/, ""); if (!cwd) return 0; return relPath === cwd || relPath.startsWith(cwd + "/") ? cwd.length : -1; }; - return configs - .map((c) => [c, score(c)] as const) - .filter(([, s]) => s >= 0) - .sort((a, b) => b[1] - a[1])[0]?.[0]; + const scored = configs.map((c) => [c, score(c)] as const).filter(([, s]) => s >= 0); + const best = Math.max(...scored.map(([, s]) => s)); + return scored.filter(([, s]) => s === best).map(([c]) => c); +} + +/** Config whose working directory most specifically contains `relPath`. */ +function closest(configs: RunConfig[], relPath: string): RunConfig | undefined { + return allClosest(configs, relPath)[0]; } function relToCwd(relPath: string, config: RunConfig): string { @@ -111,11 +115,25 @@ function implicitPytest(relPath: string, configs: RunConfig[]): RunConfig | null }; } -/** File-level test configuration for `relPath` (the closest matching test config, narrowed to the file). */ -export function testConfigFor(relPath: string, configs: RunConfig[]): RunConfig | null { +/** + * Test configurations that can run `relPath` equally well (same, closest working + * directory: `npm run test` and `npm run test:unit`…); the default one first. + */ +export function testBasesFor(relPath: string, configs: RunConfig[]): RunConfig[] { const kinds = testKinds(relPath); const candidates = configs.filter((c) => c.isTest && kinds.includes(c.kind)); - const base = closest(candidates, relPath) ?? candidates[0] ?? implicitPytest(relPath, configs); + const best = allClosest(candidates, relPath); + if (best.length) return best; + const fallback = candidates[0] ?? implicitPytest(relPath, configs); + return fallback ? [fallback] : []; +} + +/** + * File-level test configuration for `relPath` (the closest matching test config, + * or `base`, narrowed to the file). + */ +export function testConfigFor(relPath: string, configs: RunConfig[], base?: RunConfig): RunConfig | null { + base ??= testBasesFor(relPath, configs)[0]; if (!base) return null; const file = relToCwd(relPath, base); switch (base.kind) { diff --git a/src/features/run/contextRun.test.ts b/src/features/run/contextRun.test.ts index bb7cac67..9120f414 100644 --- a/src/features/run/contextRun.test.ts +++ b/src/features/run/contextRun.test.ts @@ -2,8 +2,14 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { RunConfig } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { menuGroupItems, registerMenuGroup } from "@/workbench/contributions"; -import { contextMenuItems } from "./contextRun"; +import type { MenuItem } from "@/ui"; +import { choiceItems, contextMenuItems } from "./contextRun"; +import { OPTIMISTIC, produceContextChoices } from "./producers"; +import { gutterTestState, markerIcon, targetMenuItems } from "./runGutter"; import { useRun } from "./store"; +import type { RunTarget } from "./targets"; +import { TestResults } from "./testing"; +import { clearTestStates, recordTestStates } from "./testing/states"; const ROOT = "/work/app"; const SCRATCHES = "/Users/me/scratches"; @@ -131,3 +137,53 @@ describe("debug entry", () => { expect(labels(rs)[1]).toBe("run.debugContext|Debug 'a.rs'|debug"); }); }); + +describe("run gutter popup", () => { + const unit: RunConfig = { ...npmTest, id: "npm:test:unit", name: "npm run test:unit", args: ["run", "test:unit"] }; + const text = "describe('math', () => {\n it('adds', () => {\n });\n});\n"; + const adds: RunTarget = { line: 1, kind: "test", name: "math > adds", path: ["math", "adds"] }; + const itemLabels = (items: MenuItem[]): string[] => + items.map((i) => (i.kind === "separator" ? "---" : "label" in i ? `${i.label}${"submenu" in i && i.submenu ? " ▸" : ""}` : "")); + + beforeEach(() => { + useProject.setState({ project: { name: "app", root: ROOT, isGit: false, settings: {} } as never }); + useRun.setState({ configs: [npmTest], loaded: true }); + }); + afterEach(() => { + useProject.setState({ project: null }); + useRun.setState({ configs: [], loaded: false }); + clearTestStates(); + }); + + it("ends with Modify Run Configuration… after the executors", () => { + const items = targetMenuItems(adds, `${ROOT}/src/m.test.ts`, text); + expect(itemLabels(items)).toEqual(["Run 'math > adds'", "Debug 'math > adds'", "---", "Modify Run Configuration…"]); + }); + + it("lists each matching test configuration when several match", () => { + useRun.setState({ configs: [npmTest, unit] }); + const items = targetMenuItems(adds, `${ROOT}/src/m.test.ts`, text); + expect(itemLabels(items).slice(0, 2)).toEqual(["Run 'math > adds' ▸", "Debug 'math > adds' ▸"]); + const run = items[0] as Extract; + expect(run.submenu!.map((i) => ("shortcut" in i ? i.shortcut : null))).toEqual(["test", "npm run test:unit"]); + // ⌃⇧R's chooser lists the same, under a header. + expect(choiceItems(produceContextChoices({ path: `${ROOT}/src/m.test.ts`, root: ROOT, configs: [npmTest, unit], content: text, line: 1 }, OPTIMISTIC), false)[0]).toEqual({ + kind: "header", + label: "Choose configuration to run", + }); + }); + + it("icons follow the last test result", () => { + const path = `${ROOT}/src/m.test.ts`; + expect(markerIcon("test", gutterTestState(adds, path))).toBe("gutter-run"); + const r = new TestResults(); + r.apply([{ type: "result", path: ["src/m.test.ts", "math", "adds"], status: "failed" }]); + recordTestStates(r); + expect(markerIcon("test", gutterTestState(adds, path))).toBe("gutter-run-failed"); + expect(markerIcon("suite", gutterTestState({ ...adds, kind: "suite", name: "math", path: ["math"] }, path))).toBe( + "gutter-run-failed", + ); + // `main` targets never show results. + expect(gutterTestState({ line: 0, kind: "main", name: "main", path: [] }, path)).toBeNull(); + }); +}); diff --git a/src/features/run/contextRun.ts b/src/features/run/contextRun.ts index d0d53aa0..5c3b70bb 100644 --- a/src/features/run/contextRun.ts +++ b/src/features/run/contextRun.ts @@ -9,7 +9,7 @@ import { executeAction, getAction } from "@/platform/actions"; import { call, commands, isTauri, type RunConfig } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { isVirtualUri } from "@/platform/virtualDocs"; -import type { MenuItem } from "@/ui"; +import { showContextMenu, type MenuItem } from "@/ui"; import { maybeEditorService, notifications, @@ -21,6 +21,7 @@ import { neededTools, OPTIMISTIC, produceContext, + produceContextChoices, produceDirectory, produceFile, scriptLanguage, @@ -160,12 +161,43 @@ export async function runContextAction(debug: boolean, arg?: unknown): Promise 1) { + // Several test configurations match equally well: like IDEA, let the user choose. + showContextMenu(chooserPoint(target === null), choiceItems(choices, debug)); + return true; + } + const p = await produceResolved(input, dir); if (!p) return false; await launchProduced(p, debug); return true; } +/** Menu entries for each of several productions: "Run ''" with the configuration it derives from. */ +export function choiceItems(choices: readonly Produced[], debug: boolean): MenuItem[] { + return [ + { kind: "header", label: debug ? "Choose configuration to debug" : "Choose configuration to run" }, + ...choices.map( + (p): MenuItem => ({ + label: `${debug ? "Debug" : "Run"} '${p.name}'`, + verbatim: true, + icon: debug ? "debug" : "run", + shortcut: p.detail, + run: () => void launchProduced(p, debug), + }), + ), + ]; +} + +/** Below the caret for the editor context, else the middle of the window. */ +function chooserPoint(fromEditor: boolean): { x: number; y: number } { + const editor = fromEditor ? activeEditor() : null; + const caret = editor?.view.coordsAtPos(editor.view.state.selection.main.head); + if (caret) return { x: caret.left, y: caret.bottom + 2 }; + return { x: Math.round(window.innerWidth / 2 - 120), y: Math.round(window.innerHeight / 3) }; +} + /** Runs the context with a run executor (Run with Coverage, Profile…). False when there is nothing to run. */ export async function executeContext(executor: string, arg?: unknown): Promise { await ensureConfigs(); diff --git a/src/features/run/dialogs.tsx b/src/features/run/dialogs.tsx index 2f4aa98d..3bae27b3 100644 --- a/src/features/run/dialogs.tsx +++ b/src/features/run/dialogs.tsx @@ -36,6 +36,25 @@ export async function confirmRerun(name: string, instances = 1): Promise { + const answer = await confirmChecked({ + title: `Process '${name}' Is Running`, + message: `Do you want to terminate the process '${name}'?`, + ok: "Terminate", + checkbox: "Do not ask again", + }); + if (answer?.checked && isTauri()) { + try { + await call(commands.settingsSet("global", "run.confirmTerminate", "false")); + await loadSettings(); + } catch { + /* keep asking */ + } + } + return answer !== null; +} + /** Asks for the value of `$Prompt$`; null when cancelled. */ export function promptValue(title: string): Promise { return new Promise((resolve) => useRunDialog.setState({ pending: { title, resolve } })); diff --git a/src/features/run/java.test.ts b/src/features/run/java.test.ts index 2b8476ce..64dfdcea 100644 --- a/src/features/run/java.test.ts +++ b/src/features/run/java.test.ts @@ -274,7 +274,7 @@ describe("JUnit XML reports", () => { expect(events).toContainEqual({ type: "location", path: ["a.B$Inner", "bad"], - location: { path: "B.java", line: 42, resolve: "name" }, + location: { path: "a/B.java", line: 42, resolve: "name", className: "a.B$Inner" }, }); }); }); diff --git a/src/features/run/links.test.ts b/src/features/run/links.test.ts index f607c3d3..a24ad7a3 100644 --- a/src/features/run/links.test.ts +++ b/src/features/run/links.test.ts @@ -35,9 +35,27 @@ describe("findOutputLinks", () => { expect(link).toEqual({ path: "app/main.py", line: 3, resolve: "path", text: 'File "app/main.py", line 3' }); }); - it("java / kotlin frames resolve by file name", () => { + it("java / kotlin frames resolve by fully qualified class name", () => { const [link] = files("\tat com.acme.orders.OrderController.list(OrderController.kt:22)"); - expect(link).toEqual({ path: "OrderController.kt", line: 22, resolve: "name", text: "OrderController.kt:22" }); + expect(link).toEqual({ + path: "com/acme/orders/OrderController.kt", + line: 22, + resolve: "name", + className: "com.acme.orders.OrderController", + text: "OrderController.kt:22", + }); + // Nested classes and lambdas live in the outer class's file; JDK module prefixes are skipped. + expect(files("\tat com.acme.Outer$Inner.lambda$run$0(Outer.java:7)")[0]).toMatchObject({ + path: "com/acme/Outer.java", + className: "com.acme.Outer$Inner", + }); + expect(files("\tat java.base/java.util.ArrayList.get(ArrayList.java:427)")[0]).toMatchObject({ + path: "java/util/ArrayList.java", + className: "java.util.ArrayList", + }); + // The default package, and a bare `(File.java:3)` without a frame. + expect(files("\tat Main.main(Main.java:3)")[0]).toMatchObject({ path: "Main.java", className: "Main" }); + expect(files("(Main.java:3)")[0]).toEqual({ path: "Main.java", line: 3, resolve: "name", text: "Main.java:3" }); }); it("go and generic path:line", () => { diff --git a/src/features/run/links.ts b/src/features/run/links.ts index 2179566c..e299098e 100644 --- a/src/features/run/links.ts +++ b/src/features/run/links.ts @@ -10,10 +10,28 @@ export interface FileTarget { line?: number; col?: number; /** - * `path`: resolve against the run directory; `name`: a bare file name from a - * JVM stack frame (`(OrderController.kt:22)`) — look it up in the project. + * `path`: resolve against the run directory; `name`: a file name from a JVM + * stack frame — look it up in the project (`path` then is the file under its + * package directory, `com/acme/OrderController.kt`, when the frame names the class). */ resolve: "path" | "name"; + /** JVM frames: the class's binary name (`com.acme.OrderController$Inner`). */ + className?: string; +} + +/** + * The target of a JVM stack frame `at com.acme.Foo.bar(Foo.kt:22)`: the file + * under its package directory, so frames resolve by fully qualified class name + * (as in IDEA), not by a bare file name that several packages may share. + */ +export function jvmFrameTarget(className: string | undefined, file: string, line: number | undefined): FileTarget { + const pkg = className?.includes(".") ? className.slice(0, className.lastIndexOf(".")).replace(/\./g, "/") : ""; + return { + path: pkg ? `${pkg}/${file}` : file, + line, + resolve: "name", + ...(className ? { className } : {}), + }; } export type OutputLink = @@ -49,10 +67,11 @@ const PATTERNS: Pattern[] = [ }, // JVM: at com.acme.OrderController.list(OrderController.kt:22) { - re: /\(([\w$.-]+\.(?:java|kt|kts|scala|groovy|clj)):(\d+)\)/dg, + re: /(?:([\w$]+(?:\.[\w$]+)*)\.[\w$<>-]+)?\(([\w$.-]+\.(?:java|kt|kts|scala|groovy|clj)):(\d+)\)/dg, build: (m) => { - const [start, end] = groupRange(m, 0); - return { kind: "file", start: start + 1, end: end - 1, target: { path: m[1]!, line: num(m[2]), resolve: "name" } }; + const [start] = groupRange(m, 2); + const [, end] = groupRange(m, 3); + return { kind: "file", start, end, target: jvmFrameTarget(m[1], m[2]!, num(m[3])) }; }, }, // JS / Node: at foo (src/a.ts:3:7) · at file:///abs/a.mjs:3:7 · at /abs/a.js:3:7 diff --git a/src/features/run/navigate.test.ts b/src/features/run/navigate.test.ts new file mode 100644 index 00000000..bd3c041e --- /dev/null +++ b/src/features/run/navigate.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { jvmFrameTarget } from "./links"; + +const files: Record = { + "/p/src/main/java/com/acme/Util.java": "package com.acme;\nclass Util {}", + "/p/other/Util.java": "package org.other;\nclass Util {}", + "/p/src/main/kotlin/Orders.kt": "package com.acme.orders\nfun main() {}", +}; +const symbols = vi.hoisted(() => ({ list: [] as unknown[] })); + +vi.mock("@/platform/ipc", () => ({ + isTauri: () => true, + nextRequestId: () => 1, + call: async (v: unknown) => v, + commands: { + fsReadDir: async () => [], + fsReadText: async (path: string) => files[path] ?? "", + findFiles: async (name: string) => + Object.keys(files) + .filter((p) => p.endsWith(`/${name}`)) + .map((path) => ({ path, relPath: path.slice(3) })), + lspWorkspaceSymbols: async () => symbols.list, + }, +})); + +const { resolveTarget } = await import("./navigate"); + +describe("JVM stack frame resolution", () => { + afterEach(() => { + symbols.list = []; + }); + + it("picks the file of the frame's package, not any file of that name", async () => { + expect(await resolveTarget(jvmFrameTarget("com.acme.Util", "Util.java", 3), "/p")).toBe( + "/p/src/main/java/com/acme/Util.java", + ); + expect(await resolveTarget(jvmFrameTarget("org.other.Util", "Util.java", 3), "/p")).toBe("/p/other/Util.java"); + }); + + it("finds Kotlin files outside their package directory by their package declaration", async () => { + expect(await resolveTarget(jvmFrameTarget("com.acme.orders.OrdersKt", "Orders.kt", 1), "/p")).toBe( + "/p/src/main/kotlin/Orders.kt", + ); + }); + + it("library classes open the language server's source, else nothing", async () => { + expect(await resolveTarget(jvmFrameTarget("java.util.ArrayList", "ArrayList.java", 427), "/p")).toBeNull(); + symbols.list = [ + { + name: "ArrayList", + kind: "class", + container: "java.util", + location: { path: "jdt://contents/ArrayList.class", uri: "jdt://contents/ArrayList.class" }, + }, + ]; + expect(await resolveTarget(jvmFrameTarget("java.util.ArrayList", "ArrayList.java", 427), "/p")).toBe( + "jdt://contents/ArrayList.class", + ); + // Same simple name in another package isn't it. + expect(await resolveTarget(jvmFrameTarget("java.awt.List$X", "List.java", 1), "/p")).toBeNull(); + }); +}); diff --git a/src/features/run/navigate.ts b/src/features/run/navigate.ts index f39a87f8..2d73e7ba 100644 --- a/src/features/run/navigate.ts +++ b/src/features/run/navigate.ts @@ -1,5 +1,5 @@ /** Opens file locations printed in run output (resolving relative and bare names). */ -import { call, commands, isTauri } from "@/platform/ipc"; +import { call, commands, isTauri, nextRequestId } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { editorService, notifications } from "@/workbench/contributions"; import { joinPath, type FileTarget } from "./links"; @@ -16,13 +16,55 @@ async function exists(path: string): Promise { } } -async function findByName(path: string): Promise { - const name = path.split("/").pop() ?? path; +const packageOf = (className: string) => (className.includes(".") ? className.slice(0, className.lastIndexOf(".")) : ""); + +/** Whether a JVM source file declares `pkg` (Kotlin files needn't sit in their package's directory). */ +async function declaresPackage(path: string, pkg: string): Promise { try { - const hits = await call(commands.findFiles(name, 5)); - const tail = path.replace(/^\.?\//, ""); - const best = hits.find((h) => h.relPath.endsWith(tail)) ?? hits.find((h) => h.relPath.split("/").pop() === name); - return best?.path ?? null; + const text = (await call(commands.fsReadText(path))).slice(0, 20_000); + const declared = /^\s*package\s+([\w.`]+)/m.exec(text)?.[1]?.replace(/`/g, "") ?? ""; + return declared === pkg; + } catch { + return false; + } +} + +async function findByName(target: FileTarget): Promise { + const name = target.path.split("/").pop() ?? target.path; + try { + const hits = await call(commands.findFiles(name, 50)); + const tail = target.path.replace(/^\.?\//, ""); + const exact = hits.find((h) => h.relPath === tail || h.relPath.endsWith(`/${tail}`)); + if (exact) return exact.path; + const named = hits.filter((h) => h.relPath.split("/").pop() === name); + // A stack frame names its class: a same-named file of another package is not it. + if (target.className) { + const pkg = packageOf(target.className); + for (const h of named) if (await declaresPackage(h.path, pkg)) return h.path; + return null; + } + return named[0]?.path ?? null; + } catch { + return null; + } +} + +const TYPE_KINDS = new Set(["class", "interface", "enum", "struct", "trait"]); + +/** + * Library / JDK classes: the source the language server has for them (attached + * sources or decompiled), opened as a read-only virtual document. + */ +async function findLibraryClass(className: string): Promise { + const outer = className.split("$")[0]!; + const simple = outer.slice(outer.lastIndexOf(".") + 1); + const pkg = packageOf(outer); + try { + const symbols = await call(commands.lspWorkspaceSymbols(nextRequestId(), simple)); + const hit = symbols.find( + (s) => s.name === simple && TYPE_KINDS.has(s.kind) && (s.container ?? "") === pkg, + ); + return hit ? (hit.location.uri ?? hit.location.path) : null; } catch { return null; } @@ -45,12 +87,23 @@ export async function resolveTarget(target: FileTarget, cwd: string): Promise { if (!isTauri()) return; const path = await resolveTarget(target, cwd); + if (!path && target.className) { + // A library or JDK frame without sources: nothing to open, which is no error. + notifications().notify({ + group: "run", + level: "info", + title: "No sources for this class", + body: `${target.className.split("$")[0]} is not part of the project, and no library sources are available for it.`, + key: "run-link", + }); + return; + } if (!path) { notifications().notify({ group: "run", level: "warning", title: "File not found", body: target.path, key: "run-link" }); return; diff --git a/src/features/run/producers.test.ts b/src/features/run/producers.test.ts index 66afb090..773b7043 100644 --- a/src/features/run/producers.test.ts +++ b/src/features/run/producers.test.ts @@ -5,6 +5,7 @@ import { nodeTypeStripping, OPTIMISTIC, produceContext, + produceContextChoices, produceDirectory, produceFile, shebang, @@ -277,6 +278,24 @@ describe("project producers", () => { expect(at(7)).toMatchObject({ name: "m.test.ts", scope: "file" }); }); + it("several equally close test configurations: one choice each, the default first", () => { + const withUnit = [ + ...configs, + cfg({ id: "npm:test:unit", name: "npm run test:unit", kind: "npm", command: "npm", args: ["run", "test:unit"], isTest: true }), + ]; + const src = "describe('math', () => {\n it('adds', () => {\n });\n});\n"; + const input = { path: `${ROOT}/src/m.test.ts`, root: ROOT, configs: withUnit, content: src, line: 1 }; + const choices = produceContextChoices(input, ALL); + expect(choices.map((c) => [c.name, c.detail, c.scope])).toEqual([ + ["math > adds", "npm:test", "element"], + ["math > adds", "npm run test:unit", "element"], + ]); + expect(choices[1]!.config!.args.slice(0, 2)).toEqual(["run", "test:unit"]); + // The file context offers the same choice; a single matching configuration needs none. + expect(produceContextChoices({ ...input, line: undefined }, ALL).map((c) => c.detail)).toEqual(["npm:test", "npm run test:unit"]); + expect(produceContextChoices({ ...input, configs }, ALL)).toHaveLength(1); + }); + it("python: test function > test class > file; __main__ runs the file", () => { const src = ["class TestMath:", " def test_add(self):", " assert 1", "", " x = 1", "", "def helper():", " pass"].join("\n"); const at = (line: number) => diff --git a/src/features/run/producers.ts b/src/features/run/producers.ts index 1e820580..42e2d304 100644 --- a/src/features/run/producers.ts +++ b/src/features/run/producers.ts @@ -14,7 +14,7 @@ import { isTestPath } from "@/platform/languageSettings"; import { currentRoots } from "@/platform/projectRoots"; import { applicationConfig, jvmModuleDir } from "./application"; import { formatArgs } from "./configs"; -import { contextConfig, fileRole, projectPython, testConfigFor } from "./context"; +import { contextConfig, fileRole, projectPython, testBasesFor, testConfigFor } from "./context"; import { detectRunTargets, targetConfig, targetLanguage, type RunTarget } from "./targets"; export interface LocatedTool { @@ -67,6 +67,8 @@ export interface Produced { missing?: Missing; /** Compiled single files: build `build`, then debug `config` (the binary). */ debug?: { build: RunConfig; config: RunConfig }; + /** Among several choices: the configuration it derives from (`npm run test:unit`). */ + detail?: string; } // --------------------------------------------------------------------------- @@ -757,6 +759,37 @@ export function produceContext(input: ContextInput, env: ToolEnv): Produced | nu return produceFile(input, env); } +/** + * Every configuration that can run the context, when several test + * configurations match it equally well (`npm run test` and `npm run test:unit`): + * like IDEA, the user then chooses one. A single production otherwise. + */ +export function produceContextChoices(input: ContextInput, env: ToolEnv): Produced[] { + const produced = produceContext(input, env); + if (!produced?.config || produced.scope === "directory" || !inProject(input.path, input.root)) return produced ? [produced] : []; + const rel = relTo(input.path, input.root); + const bases = testBasesFor(rel, [...input.configs]); + if (bases.length < 2) return [produced]; + let element: RunTarget | null = null; + if (produced.scope === "element") { + const targets = fileTargets(input, (input.content ?? "").slice(0, 400_000)); + element = innermostTarget(input.content ?? "", targets, input.line ?? 0, /\.py$/.test(input.path)); + if (!element || element.kind === "main") return [produced]; + } else if (!produced.config.isTest) { + return [produced]; + } + const out: Produced[] = []; + for (const base of bases) { + const found = element + ? targetConfig(element, rel, [...input.configs], input.content, base) + : testConfigFor(rel, [...input.configs], base); + const config = found && goTestNamed(found, input, element?.name); + if (!config || out.some((o) => o.config?.id === config.id)) continue; + out.push({ name: config.name, scope: produced.scope, config, detail: base.name }); + } + return out.length > 1 ? out : [produced]; +} + // --------------------------------------------------------------------------- // Directories (project view): "Run 'Tests in 'dir''", "Run 'All Tests'" diff --git a/src/features/run/runGutter.ts b/src/features/run/runGutter.ts index 38c78999..e42f206e 100644 --- a/src/features/run/runGutter.ts +++ b/src/features/run/runGutter.ts @@ -10,13 +10,14 @@ import type { EditorDocContext } from "@/editor/api"; import { useProject } from "@/platform/project"; import { isVirtualUri } from "@/platform/virtualDocs"; import { shortcutLabel } from "@/platform/keymap"; -import { iconMarkup, showContextMenu } from "@/ui"; +import { iconMarkup, showContextMenu, type MenuItem } from "@/ui"; import { notifications, runExecutors } from "@/workbench/contributions"; -import { executeContext, launchProduced, produceResolved } from "./contextRun"; +import { choiceItems, executeContext, launchProduced, modifyContext, produceResolved } from "./contextRun"; import { goModulePath, loadGoModules } from "./goModules"; -import { fileTargets, OPTIMISTIC, produceContext, type ContextInput } from "./producers"; +import { fileTargets, OPTIMISTIC, produceContextChoices, type ContextInput } from "./producers"; import { loadConfigs, useRun } from "./store"; import { targetLanguage, type RunTarget } from "./targets"; +import { onTestStatesChanged, testState, testStatesVersion, type TestState } from "./testing/states"; /** Languages with gutter run targets (C / C++ `main` included). */ const hasGutterTargets = (path: string) => !!targetLanguage(path) || /\.(c|cpp|cc|cxx)$/.test(path); @@ -89,43 +90,56 @@ export function label(t: RunTarget): string { return t.name; } -function showTargetMenu(view: EditorView, target: RunTarget, path: string, at: { x: number; y: number }) { - const content = view.state.doc.toString(); - const p = produceContext(targetInput(path, content.slice(0, 400_000), target.line), OPTIMISTIC); +export function targetMenuItems(target: RunTarget, path: string, content: string): MenuItem[] { + const input = targetInput(path, content.slice(0, 400_000), target.line); + const choices = produceContextChoices(input, OPTIMISTIC); + const p = choices[0] ?? null; const name = p?.name ?? label(target); + const context = { path, line: target.line, content: content.slice(0, 400_000) }; + // Several test configurations match: one entry per configuration, as IDEA's gutter does. + const pick = (debug: boolean): Partial => + choices.length > 1 + ? { submenu: choiceItems(choices, debug).filter((i) => i.kind !== "header") } + : { run: () => void launch(target, path, debug, content) }; // Like IDEA, Debug only where a debugger can run it. const debuggable = !p || !!(p.debug || p.config?.debugAdapter || p.missing); - showContextMenu(at, [ - { - label: `Run '${name}'`, - icon: "run", - shortcut: shortcutLabel("run.runContext"), - run: () => void launch(target, path, false, content), - }, + return [ + { label: `Run '${name}'`, icon: "run", shortcut: shortcutLabel("run.runContext"), ...pick(false) } as MenuItem, ...(debuggable - ? [ - { - label: `Debug '${name}'`, - icon: "debug", - shortcut: shortcutLabel("run.debugContext"), - run: () => void launch(target, path, true, content), - }, - ] + ? [{ label: `Debug '${name}'`, icon: "debug", shortcut: shortcutLabel("run.debugContext"), ...pick(true) } as MenuItem] : []), ...runExecutors() .filter((e) => !!p?.config && e.supports(p.config)) - .map((e) => ({ - label: e.label(name), - icon: e.icon, - run: () => void executeContext(e.id, { path, line: target.line, content: content.slice(0, 400_000) }), - })), - ]); + .map((e) => ({ label: e.label(name), icon: e.icon, run: () => void executeContext(e.id, context) })), + { kind: "separator" }, + { label: "Modify Run Configuration…", icon: "edit", run: () => void modifyContext(context) }, + ]; +} + +function showTargetMenu(view: EditorView, target: RunTarget, path: string, at: { x: number; y: number }) { + showContextMenu(at, targetMenuItems(target, path, view.state.doc.toString())); +} + +/** Results of frameworks that root their test tree at the file (jest/vitest, pytest) also match by file. */ +const fileRootedResults = (path: string) => /\.(py|[cm]?[jt]sx?)$/.test(path); + +/** Last test result of a gutter target (IDEA: green / red run icons after a test run). */ +export function gutterTestState(target: RunTarget, path: string): TestState | null { + if (target.kind === "main") return null; + return testState({ path: target.path, file: fileRootedResults(path) ? path : undefined }); +} + +export function markerIcon(kind: RunTarget["kind"], state: TestState | null): string { + if (state === "failed") return "gutter-run-failed"; + if (state === "passed") return "gutter-run-passed"; + return kind === "suite" ? "gutter-run-all" : "gutter-run"; } class RunMarker extends GutterMarker { constructor( readonly target: PlacedTarget, readonly path: string, + readonly state: TestState | null = null, ) { super(); } @@ -134,7 +148,8 @@ class RunMarker extends GutterMarker { other instanceof RunMarker && other.target.kind === this.target.kind && other.target.name === this.target.name && - other.path === this.path + other.path === this.path && + other.state === this.state ); } override toDOM(): Node { @@ -143,18 +158,29 @@ class RunMarker extends GutterMarker { el.title = `${targetTitle(this.target)} '${label(this.target)}'`; el.setAttribute("role", "button"); el.setAttribute("aria-label", el.title); - el.innerHTML = iconMarkup(this.target.kind === "suite" ? "gutter-run-all" : "gutter-run", 14) ?? ""; + el.innerHTML = iconMarkup(markerIcon(this.target.kind, this.state), 14) ?? ""; return el; } } +/** The gutter asks for markers on every update: rebuild only when targets or test results changed. */ +const markerCache = new WeakMap }>(); + function markers(state: EditorState, path: string): RangeSet { - return RangeSet.of( - state.field(targetsField).map((t) => new RunMarker(t, path).range(t.pos)), + const targets = state.field(targetsField); + const version = testStatesVersion(); + const cached = markerCache.get(targets); + if (cached?.version === version) return cached.set; + const set = RangeSet.of( + targets.map((t) => new RunMarker(t, path, gutterTestState(t, path)).range(t.pos)), true, ); + markerCache.set(targets, { version, set }); + return set; } +const testStatesChanged = StateEffect.define(); + const DEBOUNCE = 350; export function runGutter(ctx: EditorDocContext): Extension { @@ -167,8 +193,13 @@ export function runGutter(ctx: EditorDocContext): Extension { const detector = ViewPlugin.fromClass( class { timer: ReturnType | undefined; + readonly unsubscribe: () => void; constructor(readonly view: EditorView) { this.schedule(0); + // Redraw the markers with the new results (the gutter recomputes them on any update). + this.unsubscribe = onTestStatesChanged(() => { + if (view.state.field(targetsField).length) view.dispatch({ effects: testStatesChanged.of(null) }); + }); } update(u: ViewUpdate) { if (u.docChanged) this.schedule(DEBOUNCE); @@ -187,6 +218,7 @@ export function runGutter(ctx: EditorDocContext): Extension { } destroy() { clearTimeout(this.timer); + this.unsubscribe(); } }, ); diff --git a/src/features/run/session.test.tsx b/src/features/run/session.test.tsx index c4031cc4..5d1d4a78 100644 --- a/src/features/run/session.test.tsx +++ b/src/features/run/session.test.tsx @@ -13,10 +13,11 @@ import { ConsoleModel } from "./console"; import { ConsoleView, type ConsoleInput } from "./ConsoleView"; import { buildRows, mnemonics } from "./ConfigChooser"; import { activate } from "./index"; -import { resetRuns, startRun, useRun, type RunSession } from "./store"; +import { closeSessionTab, resetRuns, startRun, useRun, type RunSession } from "./store"; const confirm = vi.hoisted(() => vi.fn(async () => true)); -vi.mock("./dialogs", () => ({ confirmRerun: confirm, promptValue: vi.fn(), RunDialogsOverlay: () => null })); +const confirmTerminate = vi.hoisted(() => vi.fn(async () => true)); +vi.mock("./dialogs", () => ({ confirmRerun: confirm, confirmTerminate, promptValue: vi.fn(), RunDialogsOverlay: () => null })); const config = (id: string, extra: Partial = {}): RunConfig => ({ id, @@ -51,7 +52,7 @@ beforeEach(() => { provideNotificationService({ notify: () => undefined }); }); -describe("single-instance runs (IDEA restartRunProfile)", () => { +describe("single-instance runs", () => { it("Run on a running configuration asks, then restarts it in its tab", async () => { const app = config("app"); useRun.setState({ sessions: [running("run1", app)] }); @@ -85,6 +86,26 @@ describe("single-instance runs (IDEA restartRunProfile)", () => { }); }); +describe("closing a Run tab", () => { + it("asks before terminating a running process and keeps the tab when cancelled", async () => { + const app = config("app"); + useRun.setState({ sessions: [running("run1", app)] }); + confirmTerminate.mockResolvedValueOnce(false); + expect(await closeSessionTab("run1")).toBe(false); + expect(confirmTerminate).toHaveBeenCalledWith("app"); + expect(useRun.getState().sessions).toHaveLength(1); + expect(await closeSessionTab("run1")).toBe(true); + expect(useRun.getState().sessions).toHaveLength(0); + }); + + it("closes a finished session without asking", async () => { + confirmTerminate.mockClear(); + useRun.setState({ sessions: [{ ...running("run1", config("app")), status: "finished", exitCode: 0 }] }); + expect(await closeSessionTab("run1")).toBe(true); + expect(confirmTerminate).not.toHaveBeenCalled(); + }); +}); + describe("Stop", () => { let dispose: () => void; beforeEach(() => { diff --git a/src/features/run/store.ts b/src/features/run/store.ts index 82eb70c5..62b468ff 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -8,12 +8,15 @@ import { Channel } from "@tauri-apps/api/core"; import { call, commands, isTauri, type OutputChunk, type RunConfig, type RunId, type TestCaseReport } from "@/platform/ipc"; import { formatCompileErrors, isJvmApplication, parseJavaArgs, resolveJavaCommand } from "@/platform/javaLaunch"; import { useProject } from "@/platform/project"; +import { executeAction } from "@/platform/actions"; import { workflowSettings } from "@/platform/workflowSettings"; import { maybeEditorService, notifications, toolWindowService } from "@/workbench/contributions"; import { ConsoleModel } from "./console"; -import { confirmRerun } from "./dialogs"; +import { jvmFrameTarget } from "./links"; +import { confirmRerun, confirmTerminate } from "./dialogs"; import { createTestParser, frameworkFor, TestResults, withTestReporting, type TestEvent, type TestOutputParser } from "./testing"; -import { failedTestPaths, rerunFailedConfig } from "./testing/rerun"; +import { recordTestStates } from "./testing/states"; +import { failedTestPaths, leafTestPaths, rerunFailedConfig, testsConfig } from "./testing/rerun"; export type SessionStatus = "starting" | "running" | "finished" | "failed"; @@ -518,6 +521,7 @@ function finishSession(sessionId: string, code: number | null) { if (frameworkFor(session.config) === "jvm") rt.tests.apply(await jvmReportEvents(session)); if (runtimes.get(sessionId) !== rt) return; rt.tests.finish(code); + recordTestStates(rt.tests); } patchSession(sessionId, { status: "finished", exitCode: code, endedAt: Date.now() }); bump(sessionId); @@ -553,10 +557,10 @@ export function testReportEvents(reports: readonly TestCaseReport[]): TestEvent[ const text = [r.message, r.output].filter(Boolean).join("\n"); for (const line of text.split("\n").slice(0, 200)) events.push({ type: "output", path, line }); const outer = r.className.split("$")[0]!; - const frame = new RegExp(`at ${outer.replace(/[.$]/g, "\\$&")}(?:\\$[\\w$]+)?\\.[\\w$<>]+\\(([\\w$-]+\\.(?:java|kt|groovy|scala)):(\\d+)\\)`).exec( + const frame = new RegExp(`at (${outer.replace(/[.$]/g, "\\$&")}(?:\\$[\\w$]+)?)\\.[\\w$<>]+\\(([\\w$-]+\\.(?:java|kt|groovy|scala)):(\\d+)\\)`).exec( r.output ?? "", ); - if (frame) events.push({ type: "location", path, location: { path: frame[1]!, line: Number(frame[2]), resolve: "name" } }); + if (frame) events.push({ type: "location", path, location: jvmFrameTarget(frame[1], frame[2]!, Number(frame[3])) }); } return events; } @@ -635,6 +639,26 @@ export function rerunFailed(sessionId: string): boolean { return true; } +/** + * Run / Debug the tests selected in a session's test tree (IDEA: a temporary + * configuration for them). False when the framework can't filter tests. + */ +export async function runSelectedTests(sessionId: string, nodeIds: readonly string[], debug: boolean): Promise { + const session = getSession(sessionId); + const tests = runtimes.get(sessionId)?.tests; + if (!session || !tests) return false; + const nodes = nodeIds.map((id) => tests.get(id)).filter((n) => !!n); + const paths = leafTestPaths(nodes); + const base = session.baseConfig ?? session.config; + const name = nodes.length === 1 ? nodes[0]!.name : `${paths.length} tests`; + const derived = testsConfig(base, paths, name); + if (!derived) return false; + const config = adoptTemporary({ ...derived, id: `context:tests:${base.id}:${paths.map((p) => p.join("/")).join("|")}` }); + if (debug) await executeAction("run.debugConfig", config); + else await startRun(config); + return true; +} + /** Whether the console takes typed input: the process runs and its stdin is open. */ export function acceptsInput(session: RunSession): boolean { return session.status === "running" && session.runId !== null && !session.stdinClosed; @@ -676,6 +700,16 @@ export function clearConsole(sessionId: string) { bump(sessionId); } +/** Closing a Run tab: a process still running is terminated once the user agrees, as IDEA asks. */ +export async function closeSessionTab(sessionId: string): Promise { + const session = getSession(sessionId); + if (!session) return false; + const running = session.status === "running" || session.status === "starting"; + if (running && workflowSettings().run.confirmTerminate && !(await confirmTerminate(session.config.name))) return false; + closeSession(sessionId); + return true; +} + export function closeSession(sessionId: string) { const session = getSession(sessionId); if (!session) return; diff --git a/src/features/run/targets.ts b/src/features/run/targets.ts index 8340a425..f39de78d 100644 --- a/src/features/run/targets.ts +++ b/src/features/run/targets.ts @@ -319,12 +319,14 @@ export function targetConfig( relPath: string, configs: RunConfig[], content?: string, + /** The test configuration to narrow (one of `testBasesFor`); the closest by default. */ + base?: RunConfig, ): RunConfig | null { if (target.kind === "main") { if (target.className) return applicationConfig({ className: target.className, spring: target.spring }, relPath, configs); return contextConfig(relPath, configs, content); } - const file = testConfigFor(relPath, configs); + const file = testConfigFor(relPath, configs, base); if (!file) return null; if (target.className && (file.kind === "maven" || file.kind === "gradle")) return jvmTestConfig(file, target.className, target.kind === "test" ? target.path[target.path.length - 1] : undefined); diff --git a/src/features/run/testing/comparison.test.ts b/src/features/run/testing/comparison.test.ts new file mode 100644 index 00000000..c5d38102 --- /dev/null +++ b/src/features/run/testing/comparison.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { parseComparison } from "./comparison"; + +describe("parseComparison", () => { + it("JUnit / opentest4j", () => { + expect(parseComparison(["org.opentest4j.AssertionFailedError: expected: <3> but was: <4>"])).toEqual({ + expected: "3", + actual: "4", + }); + expect(parseComparison(['expected: "a b"', ' but was: "a c"'])).toEqual({ expected: '"a b"', actual: '"a c"' }); + }); + + it("kotlin.test", () => { + expect(parseComparison(["java.lang.AssertionError: Expected <1>, actual <2>."])).toEqual({ + expected: "1", + actual: "2", + }); + }); + + it("jest / vitest", () => { + expect( + parseComparison([ + " expect(received).toBe(expected) // Object.is equality", + "", + " Expected: 3", + " Received: 4", + ]), + ).toEqual({ expected: "3", actual: "4" }); + }); + + it("rust assert_eq! keeps left / right", () => { + expect(parseComparison(["assertion `left == right` failed", " left: 3", " right: 4"])).toEqual({ + expected: "3", + actual: "4", + labels: { expected: "Left", actual: "Right" }, + }); + }); + + it("go testify and pytest", () => { + expect( + parseComparison(["\tError: \tNot equal: ", "\t \texpected: 3", "\t \tactual : 4"]), + ).toEqual({ + expected: "3", + actual: "4", + }); + expect(parseComparison(["> assert add(1, 2) == 4", "E assert 3 == 4"])).toEqual({ + expected: "4", + actual: "3", + }); + }); + + it("no comparison in other failures", () => { + expect(parseComparison(["java.lang.IllegalStateException: boom", "\tat com.acme.A.b(A.java:3)"])).toBeNull(); + }); +}); diff --git a/src/features/run/testing/comparison.ts b/src/features/run/testing/comparison.ts new file mode 100644 index 00000000..20b4fc26 --- /dev/null +++ b/src/features/run/testing/comparison.ts @@ -0,0 +1,54 @@ +/** + * Expected / actual values of an assertion failure, read from a test's captured + * output, for the test tree's "Show Diff" (as in IDEA's comparison failure diff). + */ + +export interface Comparison { + expected: string; + actual: string; + /** Side labels when the framework doesn't say which side is expected (Rust `left` / `right`). */ + labels?: { expected: string; actual: string }; +} + +/** Strips the quotes / angle brackets frameworks wrap values in (`<1>`, `"a"` stays as written). */ +const unwrap = (s: string) => s.trim().replace(/^<([\s\S]*)>$/, "$1"); + +export function parseComparison(output: readonly string[]): Comparison | null { + // Output lines reach the parsers with ANSI escapes already stripped. + const lines = output; + const text = lines.join("\n"); + let m: RegExpExecArray | null; + // JUnit / opentest4j / AssertJ / Hamcrest-style: `expected: but was: `. + if ((m = /expected:\s*(<[\s\S]*?>|\S[^\n]*?)\s*but was:\s*(<[\s\S]*?>|[^\n]*)/.exec(text))) + return { expected: unwrap(m[1]!), actual: unwrap(m[2]!) }; + // kotlin.test: `Expected , actual .` + if ((m = /Expected <([\s\S]*?)>, actual <([\s\S]*?)>/.exec(text))) return { expected: m[1]!, actual: m[2]! }; + // Rust `assert_eq!`: ` left: x` / ` right: y` (the macro doesn't say which one is expected). + const left = lines.findIndex((l) => /^\s*left:\s/.test(l)); + if (left >= 0 && /^\s*right:\s/.test(lines[left + 1] ?? "")) + return { + expected: lines[left]!.replace(/^\s*left:\s*/, ""), + actual: lines[left + 1]!.replace(/^\s*right:\s*/, ""), + labels: { expected: "Left", actual: "Right" }, + }; + // Jest / Vitest: `Expected: x` then `Received: y`. + const exp = lines.findIndex((l) => /^\s*Expected:\s/.test(l)); + const rec = exp >= 0 ? lines.findIndex((l, i) => i > exp && /^\s*Received:\s/.test(l)) : -1; + if (exp >= 0 && rec >= 0) + return { + expected: lines[exp]!.replace(/^\s*Expected:\s*/, ""), + actual: lines[rec]!.replace(/^\s*Received:\s*/, ""), + }; + // Go testify: `expected: x` then `actual : y`. + const gexp = lines.findIndex((l) => /^\s*(?:Error:\s+Not equal:\s*)?expected:\s/.test(l)); + const gact = gexp >= 0 ? lines.findIndex((l, i) => i > gexp && /^\s*actual\s*:\s/.test(l)) : -1; + if (gexp >= 0 && gact >= 0) + return { + expected: lines[gexp]!.replace(/^.*?expected:\s*/, ""), + actual: lines[gact]!.replace(/^\s*actual\s*:\s*/, ""), + }; + // pytest: `assert actual == expected`. + if ((m = /^(?:E\s+)?(?:AssertionError:\s*)?assert\s+(.+?)\s+==\s+(.+)$/m.exec(text))) + return { expected: m[2]!.trim(), actual: m[1]!.trim() }; + return null; +} diff --git a/src/features/run/testing/jvm.ts b/src/features/run/testing/jvm.ts index e4212b0a..cec56175 100644 --- a/src/features/run/testing/jvm.ts +++ b/src/features/run/testing/jvm.ts @@ -1,9 +1,11 @@ /** Gradle test logging (`Class > method() PASSED`) and Maven Surefire reports. */ +import { jvmFrameTarget } from "../links"; import { parseDuration, type TestEvent, type TestOutputParser } from "./types"; const GRADLE_RE = /^(\S[^>]*?(?:\s>\s[^>]+?)+)\s+(PASSED|FAILED|SKIPPED)(?:\s+\(([\d.]+\s*m?s)\))?$/; const GRADLE_SUMMARY_RE = /^(\d+) tests? completed(?:, (\d+) failed)?(?:, (\d+) skipped)?/; -const FRAME_RE = /\(([\w$.-]+\.(?:java|kt|kts|scala|groovy)):(\d+)\)|\bat ([\w$.-]+\.(?:java|kt|kts|scala|groovy)):(\d+)/; +const FRAME_RE = + /(?:([\w$]+(?:\.[\w$]+)*)\.[\w$<>-]+)?\(([\w$.-]+\.(?:java|kt|kts|scala|groovy)):(\d+)\)|\bat ([\w$.-]+\.(?:java|kt|kts|scala|groovy)):(\d+)/; // [ERROR] com.acme.OrderTest.shouldX -- Time elapsed: 0.01 s <<< FAILURE! (Surefire 3) // shouldX(com.acme.OrderTest) Time elapsed: 0.01 sec <<< FAILURE! (Surefire 2) const SUREFIRE3_RE = /^(?:\[\w+\]\s+)?([\w$.]+)\.([\w$]+)(?:\(\))?\s+--\s+Time elapsed: ([\d.]+ s(?:ec)?)\s+<<<\s+(FAILURE|ERROR)!/; @@ -83,7 +85,9 @@ export function jvmParser(): TestOutputParser { events.push({ type: "location", path: failure, - location: { path: (f[1] ?? f[3])!, line: Number(f[2] ?? f[4]), resolve: "name" }, + location: f[2] + ? jvmFrameTarget(f[1], f[2], Number(f[3])) + : { path: f[4]!, line: Number(f[5]), resolve: "name" }, }); } } diff --git a/src/features/run/testing/parsers.test.ts b/src/features/run/testing/parsers.test.ts index 964c8b71..d5cd001f 100644 --- a/src/features/run/testing/parsers.test.ts +++ b/src/features/run/testing/parsers.test.ts @@ -390,10 +390,12 @@ org.opentest4j.AssertionFailedError: expected: <3> but was: <2> `, ); expect(nodeStatus(r.get("com.acme.OrderTest")!)).toBe("failed"); + // Resolved by the frame's class (package directory + file), not by a bare file name. expect(leaf(r, "com.acme.OrderTest", "shouldTotal")?.location).toEqual({ - path: "OrderTest.java", + path: "com/acme/OrderTest.java", line: 31, resolve: "name", + className: "com.acme.OrderTest", }); expect(r.get("com.acme.UserTest")?.own).toBe("passed"); expect(r.summary).toMatchObject({ passed: 2, failed: 1 }); diff --git a/src/features/run/testing/rerun.test.ts b/src/features/run/testing/rerun.test.ts index 0bbf3b51..93a9f85c 100644 --- a/src/features/run/testing/rerun.test.ts +++ b/src/features/run/testing/rerun.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import type { RunConfig } from "@/platform/ipc"; import { TestResults } from "./results"; -import { failedTestPaths, rerunFailedConfig } from "./rerun"; +import { failedTestPaths, leafTestPaths, rerunFailedConfig, testsConfig } from "./rerun"; const cfg = (kind: string, command: string, args: string[]): RunConfig => ({ id: `${kind}:test`, @@ -29,6 +29,38 @@ describe("failedTestPaths", () => { }); }); +describe("tests selected in the test tree", () => { + it("expands suites to their tests; a suite without reported tests stands for itself", () => { + const r = new TestResults(); + r.apply([ + { type: "result", path: ["com.acme.ATest", "one()"], status: "passed" }, + { type: "result", path: ["com.acme.ATest", "two()"], status: "failed" }, + { type: "result", path: ["com.acme.BTest"], status: "passed" }, + ]); + const nodes = [ + r.get("com.acme.ATest")!, + r.get(["com.acme.ATest", "one()"].join("\u0000"))!, + r.get("com.acme.BTest")!, + ]; + expect(leafTestPaths(nodes)).toEqual([ + ["com.acme.ATest", "one()"], + ["com.acme.ATest", "two()"], + ["com.acme.BTest"], + ]); + const c = testsConfig(cfg("gradle", "./gradlew", ["test"]), leafTestPaths(nodes), "3 tests")!; + expect(c.name).toBe("3 tests"); + expect(c.args).toEqual([ + "test", + "--tests", + "com.acme.ATest.one", + "--tests", + "com.acme.ATest.two", + "--tests", + "com.acme.BTest", + ]); + }); +}); + describe("rerunFailedConfig", () => { it("cargo: exact names after --", () => { const c = rerunFailedConfig(cfg("cargo", "cargo", ["test", "--workspace"]), [ diff --git a/src/features/run/testing/rerun.ts b/src/features/run/testing/rerun.ts index c7b783b5..93de0703 100644 --- a/src/features/run/testing/rerun.ts +++ b/src/features/run/testing/rerun.ts @@ -113,8 +113,29 @@ function jvmArgs(config: RunConfig, paths: string[][]): string[] | null { return [...dropFlag(config.args, ["--tests"]), ...filters.flatMap((f) => ["--tests", f])]; } +/** Leaf test paths under the given nodes (a node without reported children stands for itself). */ +export function leafTestPaths(nodes: readonly TestNode[]): string[][] { + const out: string[][] = []; + const seen = new Set(); + const visit = (n: TestNode) => { + if (n.children.length) return n.children.forEach(visit); + const key = n.path.join("\u0000"); + if (seen.has(key)) return; + seen.add(key); + out.push(n.path); + }; + nodes.forEach(visit); + return out; +} + /** Config running only `paths` (failed test paths from the results tree), or null when unsupported. */ export function rerunFailedConfig(config: RunConfig, paths: string[][]): RunConfig | null { + const baseName = config.name.replace(/^Failed: /, ""); + return testsConfig(config, paths, `Failed: ${baseName}`); +} + +/** Config running only the tests at `paths` (test tree paths), named `name`; null when unsupported. */ +export function testsConfig(config: RunConfig, paths: string[][], name: string): RunConfig | null { if (!paths.length) return null; let args: string[] | null; switch (frameworkFor(config)) { @@ -137,6 +158,5 @@ export function rerunFailedConfig(config: RunConfig, paths: string[][]): RunConf args = null; } if (!args) return null; - const baseName = config.name.replace(/^Failed: /, ""); - return { ...config, args, name: `Failed: ${baseName}`, detected: true, env: { ...config.env } }; + return { ...config, args, name, detected: true, env: { ...config.env } }; } diff --git a/src/features/run/testing/states.test.ts b/src/features/run/testing/states.test.ts new file mode 100644 index 00000000..dca6da2e --- /dev/null +++ b/src/features/run/testing/states.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TestResults } from "./results"; +import { clearTestStates, onTestStatesChanged, recordTestStates, testState } from "./states"; + +function run(events: Parameters[0]) { + const r = new TestResults(); + r.apply(events); + r.finish(0); + recordTestStates(r); +} + +describe("test states for the run gutter", () => { + afterEach(() => clearTestStates()); + + it("matches Rust tests by module path under any crate suite", () => { + run([ + { type: "result", path: ["src/lib.rs", "tests", "adds"], status: "passed" }, + { type: "result", path: ["src/lib.rs", "tests", "subs"], status: "failed" }, + ]); + expect(testState({ path: ["tests", "adds"] })).toBe("passed"); + expect(testState({ path: ["tests", "subs"] })).toBe("failed"); + // The suite (`mod tests`) failed because one of its tests did. + expect(testState({ path: ["tests"] })).toBe("failed"); + expect(testState({ path: ["tests", "other"] })).toBeNull(); + }); + + it("matches JVM results named by qualified class and `method()`", () => { + run([ + { type: "result", path: ["com.acme.OrderTest", "lists()"], status: "passed" }, + { type: "result", path: ["com.acme.OrderTest", "creates()"], status: "failed" }, + ]); + expect(testState({ path: ["OrderTest", "lists"] })).toBe("passed"); + expect(testState({ path: ["OrderTest", "creates"] })).toBe("failed"); + expect(testState({ path: ["OrderTest"] })).toBe("failed"); + }); + + it("checks the file for results rooted at it, and any parameter failing fails the test", () => { + run([ + { type: "result", path: ["tests/test_math.py", "test_add[1]"], status: "passed" }, + { type: "result", path: ["tests/test_math.py", "test_add[2]"], status: "failed" }, + { type: "result", path: ["src/math.test.ts", "math", "adds"], status: "passed" }, + ]); + expect(testState({ path: ["test_add"], file: "/work/app/tests/test_math.py" })).toBe("failed"); + expect(testState({ path: ["test_add"], file: "/work/app/other/test_math2.py" })).toBeNull(); + expect(testState({ path: ["math", "adds"], file: "/work/app/src/math.test.ts" })).toBe("passed"); + }); + + it("newer runs win and listeners hear about changes", () => { + const listener = vi.fn(); + const off = onTestStatesChanged(listener); + run([{ type: "result", path: ["pkg", "TestA"], status: "failed" }]); + run([{ type: "result", path: ["pkg", "TestA"], status: "passed" }]); + expect(testState({ path: ["TestA"] })).toBe("passed"); + expect(listener).toHaveBeenCalledTimes(2); + // Unchanged results don't redraw the gutters. + run([{ type: "result", path: ["pkg", "TestA"], status: "passed" }]); + expect(listener).toHaveBeenCalledTimes(2); + off(); + }); +}); diff --git a/src/features/run/testing/states.ts b/src/features/run/testing/states.ts new file mode 100644 index 00000000..bc23a1f9 --- /dev/null +++ b/src/features/run/testing/states.ts @@ -0,0 +1,101 @@ +/** + * Last known result per test, across runs, for the gutter ▶ icons: like IDEA, a + * test (or suite) that failed last time shows a red run icon, a passed one green. + * Results are matched to gutter targets by their test path (the trailing name + * parts), plus the file for frameworks whose results are rooted at the file. + */ +import { nodeStatus, type TestNode, type TestResults } from "./results"; +import type { TestStatus } from "./types"; + +export type TestState = "passed" | "failed"; + +interface Entry { + path: string[]; + state: TestState; +} + +const SEP = "\u0000"; +const entries = new Map(); +const listeners = new Set<() => void>(); +let version = 0; + +export function testStatesVersion(): number { + return version; +} + +export function onTestStatesChanged(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +function toState(s: TestStatus | null): TestState | null { + return s === "passed" || s === "failed" ? s : null; +} + +/** Records the outcome of every finished test and suite of a run (newer runs win). */ +export function recordTestStates(results: Pick) { + let changed = false; + const visit = (n: TestNode) => { + const state = toState(nodeStatus(n)); + if (state) { + const key = n.path.join(SEP); + if (entries.get(key)?.state !== state) changed = true; + entries.set(key, { path: n.path, state }); + } + n.children.forEach(visit); + }; + results.roots.forEach(visit); + if (!changed) return; + version++; + for (const l of listeners) l(); +} + +export function clearTestStates() { + if (!entries.size) return; + entries.clear(); + version++; + for (const l of listeners) l(); +} + +/** `name()`, `name(int)`, `test_x[param]` → `name` / `test_x`. */ +const norm = (s: string) => s.replace(/\s*(\(.*\)|\[.*\])$/, "").trim(); + +function segmentMatches(result: string, target: string): boolean { + const a = norm(result); + const b = norm(target); + // JVM results name the class by its fully qualified name, gutter targets by the simple one. + return a === b || a.endsWith(`.${b}`) || b.endsWith(`.${a}`); +} + +const cleanPath = (p: string) => p.replace(/\\/g, "/").replace(/^\.\//, ""); + +function sameFile(resultRoot: string, relPath: string): boolean { + const a = cleanPath(resultRoot); + const b = cleanPath(relPath); + return a === b || b.endsWith(`/${a}`) || a.endsWith(`/${b}`); +} + +export interface StateQuery { + /** Test path of the gutter target (describe chain, module path, `[Class, method]`…). */ + path: string[]; + /** Project-relative path of the file, checked when results are rooted at their file. */ + file?: string; +} + +/** + * Last state of a gutter target: failed when any matching result failed (a + * parameterised test failed for one of its parameters), else passed. + */ +export function testState({ path, file }: StateQuery): TestState | null { + if (!path.length) return null; + let passed = false; + for (const e of entries.values()) { + if (e.path.length < path.length) continue; + const offset = e.path.length - path.length; + if (!path.every((p, i) => segmentMatches(e.path[offset + i]!, p))) continue; + if (file !== undefined && (offset === 0 || !sameFile(e.path[0]!, file))) continue; + if (e.state === "failed") return "failed"; + passed = true; + } + return passed ? "passed" : null; +} diff --git a/src/features/run/testing/types.ts b/src/features/run/testing/types.ts index 84b1dad0..60228a2b 100644 --- a/src/features/run/testing/types.ts +++ b/src/features/run/testing/types.ts @@ -10,8 +10,10 @@ export interface TestLocation { /** 1-based, as printed. */ line?: number; col?: number; - /** `name`: a bare file name to look up in the project (JVM / Go frames). */ + /** `name`: a file name to look up in the project (JVM / Go frames). */ resolve: "path" | "name"; + /** JVM frames: the class's binary name; `path` is then the file under its package directory. */ + className?: string; } export type TestEvent = diff --git a/src/features/search/__tests__/bookmarkPopups.test.tsx b/src/features/search/__tests__/bookmarkPopups.test.tsx new file mode 100644 index 00000000..7ba36286 --- /dev/null +++ b/src/features/search/__tests__/bookmarkPopups.test.tsx @@ -0,0 +1,88 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { BookmarkPopupsOverlay, bookmarkRows } from "../bookmarks/BookmarkPopups"; +import { bookmarkGutter } from "../bookmarks/gutter"; +import { loadBookmarks, updateBookmarks, useBookmarkPopups, useBookmarks, type Bookmark } from "../bookmarks/store"; + +beforeEach(() => { + localStorage.clear(); + loadBookmarks("/p"); +}); + +afterEach(() => { + act(() => useBookmarkPopups.setState({ chooser: null, list: false, editing: null })); +}); + +const set = (items: Bookmark[]) => act(() => updateBookmarks(() => items)); + +describe("mnemonic chooser", () => { + it("a typed letter assigns that mnemonic (with the description) and closes", () => { + set([{ path: "/p/b", line: 0, text: "b", created: 1, mnemonic: "K" }]); + render(); + act(() => useBookmarkPopups.setState({ chooser: { path: "/p/a", line: 2, text: "fun a()" } })); + expect(screen.getByText("Add Mnemonic Bookmark")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "K" }).className).toMatch(/keyAssigned/); + fireEvent.change(screen.getByLabelText("Bookmark description"), { target: { value: "entry" } }); + fireEvent.keyDown(screen.getByRole("button", { name: "1" }), { key: "k" }); + expect(useBookmarkPopups.getState().chooser).toBeNull(); + const items = useBookmarks.getState().items; + expect(items.find((b) => b.path === "/p/a")).toMatchObject({ line: 2, mnemonic: "K", description: "entry" }); + expect(items.find((b) => b.path === "/p/b")?.mnemonic).toBeUndefined(); + }); +}); + +describe("Bookmarks popup", () => { + it("groups by file; Delete removes and F2 edits the description", () => { + set([ + { path: "/p/a", line: 1, text: "one", created: 1 }, + { path: "/p/a", line: 5, text: "five", created: 2, mnemonic: "3" }, + { path: "/p/b", line: 0, text: "zero", created: 3 }, + ]); + expect(bookmarkRows(useBookmarks.getState().items).map((r) => r.kind)).toEqual(["file", "mark", "mark", "file", "mark"]); + render(); + act(() => useBookmarkPopups.setState({ list: true })); + const list = screen.getByRole("listbox", { name: "Bookmarks" }); + expect(screen.getByText("five")).toBeInTheDocument(); + fireEvent.keyDown(list, { key: "Delete" }); + expect(useBookmarks.getState().items.map((b) => b.line)).toEqual([5, 0]); + fireEvent.keyDown(list, { key: "F2" }); + expect(useBookmarkPopups.getState().editing).toEqual({ path: "/p/a", line: 5 }); + const input = screen.getByLabelText("Description"); + fireEvent.change(input, { target: { value: "main" } }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(useBookmarks.getState().items[0]!.description).toBe("main"); + }); +}); + +describe("bookmark gutter", () => { + it("⌘/Ctrl-click in the gutter toggles a bookmark before other gutter handlers see it", () => { + const toggle = vi.fn(); + const other = vi.fn(); + const view = new EditorView({ + parent: document.body, + state: EditorState.create({ doc: "a\nb\n", extensions: bookmarkGutter("/p/a", toggle) }), + }); + view.dom.addEventListener("mousedown", other); + const gutter = view.dom.querySelector(".cm-gutters")!; + gutter.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0, metaKey: true, ctrlKey: true })); + expect(toggle).toHaveBeenCalledWith(0); + expect(other).not.toHaveBeenCalled(); + gutter.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 })); + expect(toggle).toHaveBeenCalledTimes(1); + expect(other).toHaveBeenCalledTimes(1); + view.destroy(); + }); + + it("shows the mnemonic instead of the bookmark icon", async () => { + set([{ path: "/p/a", line: 1, text: "b", created: 1, mnemonic: "Q" }]); + const view = new EditorView({ + parent: document.body, + state: EditorState.create({ doc: "a\nb\n", extensions: bookmarkGutter("/p/a", () => {}) }), + }); + await act(() => new Promise((r) => setTimeout(r, 0))); + expect(view.dom.querySelector(".cm-openidea-bookmarkMnemonic")?.textContent).toBe("Q"); + view.destroy(); + }); +}); diff --git a/src/features/search/__tests__/bookmarks.test.ts b/src/features/search/__tests__/bookmarks.test.ts index 7b5c1d47..55ee91d4 100644 --- a/src/features/search/__tests__/bookmarks.test.ts +++ b/src/features/search/__tests__/bookmarks.test.ts @@ -1,7 +1,13 @@ import { beforeEach, describe, expect, it } from "vitest"; import { + assignMnemonic, + bookmarkByMnemonic, + bookmarkLabel, + describeBookmark, linesOf, loadBookmarks, + marksOf, + toggleMnemonic, renameBookmarks, setFileLines, toggleBookmark, @@ -64,3 +70,44 @@ describe("bookmark store", () => { expect(useBookmarks.getState().items.map((b) => b.line)).toEqual([2]); }); }); + +describe("mnemonic bookmarks", () => { + it("assigns a mnemonic, moving it from another bookmark", () => { + let list: Bookmark[] = []; + list = assignMnemonic(list, "/p/a", 1, "one", "A", undefined, 1); + list = assignMnemonic(list, "/p/b", 2, "two", "A", "second", 2); + expect(bookmarkByMnemonic(list, "A")).toMatchObject({ path: "/p/b", line: 2, description: "second" }); + expect(list.find((b) => b.path === "/p/a")?.mnemonic).toBeUndefined(); + list = assignMnemonic(list, "/p/b", 2, "two", null, ""); + expect(list.find((b) => b.path === "/p/b")).toEqual({ path: "/p/b", line: 2, text: "two", created: 2 }); + }); + + it("⌃⇧digit toggles a numbered bookmark", () => { + let list = toggleMnemonic([], "/p/a", 3, "x", "1"); + expect(marksOf(list, "/p/a")).toEqual([{ line: 3, mnemonic: "1" }]); + list = toggleMnemonic(list, "/p/a", 5, "y", "1"); + expect(marksOf(list, "/p/a")).toEqual([ + { line: 3, mnemonic: null }, + { line: 5, mnemonic: "1" }, + ]); + list = toggleMnemonic(list, "/p/a", 5, "y", "1"); + expect(linesOf(list, "/p/a")).toEqual([3]); + }); + + it("keeps mnemonics and descriptions when lines move or storage reloads", () => { + const list: Bookmark[] = [{ path: "/p/a", line: 1, text: "a", created: 1, mnemonic: "7", description: "note" }]; + expect(setFileLines(list, "/p/a", [{ line: 4, text: "a" }])[0]).toMatchObject({ line: 4, mnemonic: "7", description: "note" }); + localStorage.setItem("openidea.bookmarks.v1:/m", JSON.stringify([...list, { path: "/p/b", line: 0, mnemonic: "ab" }])); + loadBookmarks("/m"); + expect(useBookmarks.getState().items.map((b) => b.mnemonic)).toEqual(["7", undefined]); + }); + + it("F3 on a selection uses it as the description; Rename edits it", () => { + let list = toggleBookmark([], "/p/a", 0, "let x = 1;", 1, " important "); + expect(bookmarkLabel(list[0]!)).toBe("important"); + list = describeBookmark(list, "/p/a", 0, "renamed"); + expect(bookmarkLabel(list[0]!)).toBe("renamed"); + list = describeBookmark(list, "/p/a", 0, " "); + expect(bookmarkLabel(list[0]!)).toBe("let x = 1;"); + }); +}); diff --git a/src/features/search/__tests__/codePreview.test.tsx b/src/features/search/__tests__/codePreview.test.tsx new file mode 100644 index 00000000..63988197 --- /dev/null +++ b/src/features/search/__tests__/codePreview.test.tsx @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from "vitest"; +import { act, render } from "@testing-library/react"; +import { Text } from "@codemirror/state"; + +vi.mock("../find/preview", () => ({ + loadPreviewText: async () => "fun a() {}\nval order = 1\norder()\n", + clearPreviewCache: () => {}, +})); + +const { CodePreview, previewDecorations } = await import("../find/parts"); + +describe("CodePreview", () => { + it("marks matches (the current line's as current) and the current line", () => { + const doc = Text.of(["ab", "order order", "x"]); + const set = previewDecorations(doc, 1, [ + { line: 1, ranges: [[0, 5], [6, 11]] }, + { line: 2, ranges: [[0, 9]] }, + { line: 7, ranges: [[0, 1]] }, + ]); + const out: string[] = []; + set.between(0, doc.length, (from, to, d) => { + out.push(`${from}-${to}:${(d.spec as { class: string }).class}`); + }); + expect(out).toEqual([ + "3-3:cm-activeLine", + "3-8:cm-openidea-findMatch cm-openidea-findMatch-current", + "9-14:cm-openidea-findMatch cm-openidea-findMatch-current", + "15-16:cm-openidea-findMatch", + ]); + }); + + it("shows the file read-only in an editor view with the matches highlighted", async () => { + const { container } = render( + , + ); + await act(() => new Promise((r) => setTimeout(r, 150))); + expect(container.querySelector(".cm-content")?.textContent).toContain("val order = 1"); + expect(container.querySelector(".cm-content")?.getAttribute("contenteditable")).not.toBe("true"); + expect(container.querySelector(".cm-openidea-findMatch")?.textContent).toBe("order"); + }); +}); diff --git a/src/features/search/__tests__/everywhereModel.test.ts b/src/features/search/__tests__/everywhereModel.test.ts index 6da17cd3..d52d1577 100644 --- a/src/features/search/__tests__/everywhereModel.test.ts +++ b/src/features/search/__tests__/everywhereModel.test.ts @@ -246,3 +246,29 @@ describe("parseCommand", () => { expect(parseCommand("upg")).toBeNull(); }); }); + +describe("Search Everywhere initial text", () => { + it("each tab starts with its own last query; closing remembers the text", async () => { + const { hideSearchEverywhere, selectSearchEverywhereTab, showSearchEverywhere, useSearchEverywhere } = await import( + "../everywhere/store" + ); + localStorage.clear(); + showSearchEverywhere("files"); + useSearchEverywhere.setState({ query: "Order" }); + hideSearchEverywhere(); + showSearchEverywhere("classes"); + expect(useSearchEverywhere.getState().query).toBe(""); + useSearchEverywhere.setState({ query: "Pizza" }); + selectSearchEverywhereTab("symbols"); + hideSearchEverywhere(); + showSearchEverywhere("files"); + expect(useSearchEverywhere.getState().query).toBe("Order"); + hideSearchEverywhere(); + showSearchEverywhere("symbols", { query: "sel" }); + expect(useSearchEverywhere.getState().query).toBe("sel"); + hideSearchEverywhere(); + showSearchEverywhere("classes"); + expect(useSearchEverywhere.getState().query).toBe(""); + hideSearchEverywhere(); + }); +}); diff --git a/src/features/search/__tests__/fileStructure.test.tsx b/src/features/search/__tests__/fileStructure.test.tsx new file mode 100644 index 00000000..c5cd6892 --- /dev/null +++ b/src/features/search/__tests__/fileStructure.test.tsx @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import type { OutlineItem } from "@/platform/ipc"; + +vi.mock("../structure/store", async (orig) => ({ + ...(await orig()), + useLiveOutline: () => undefined, +})); + +const { FileStructureOverlay, useFileStructurePopup } = await import("../structure/StructureView"); +const { useOutline, useStructureOptions } = await import("../structure/store"); + +const item = (name: string, kind: OutlineItem["kind"], line: number, children: OutlineItem[] = []): OutlineItem => ({ + name, + detail: null, + kind, + range: { start: { line, col: 0 }, end: { line: line + 1, col: 1 } }, + selection_range: { start: { line, col: 4 }, end: { line, col: 4 + name.length } }, + children, +}); + +function open() { + useOutline.setState({ + path: "/p/Order.kt", + doc: 1, + loading: false, + error: null, + items: [item("OrderController", "class", 0, [item("list", "method", 2), item("create", "method", 8)]), item("helper", "function", 22)], + }); + useStructureOptions.setState({ narrowDown: true }); + render(); + act(() => useFileStructurePopup.setState({ open: true })); + return screen.getByLabelText("Filter symbols"); +} + +const rows = () => screen.getAllByRole("treeitem").map((r) => r.textContent); + +afterEach(() => act(() => useFileStructurePopup.setState({ open: false }))); + +describe("File Structure popup", () => { + it("shows the outline as an expanded tree; ←/→ collapse and expand", () => { + const input = open(); + expect(rows()).toEqual(["OrderController", "list", "create", "helper"]); + // The first symbol is preselected; ← collapses it, → expands it again. + fireEvent.keyDown(input, { key: "ArrowLeft" }); + expect(rows()).toEqual(["OrderController", "helper"]); + fireEvent.keyDown(input, { key: "ArrowRight" }); + expect(rows()).toEqual(["OrderController", "list", "create", "helper"]); + }); + + it("narrows down on typing keeping ancestors, or only highlights when that is off", () => { + const input = open(); + fireEvent.change(input, { target: { value: "creat" } }); + expect(rows()).toEqual(["OrderController", "create"]); + fireEvent.click(screen.getByLabelText("Narrow down on typing")); + expect(useStructureOptions.getState().narrowDown).toBe(false); + expect(rows()).toEqual(["OrderController", "list", "create", "helper"]); + }); + + it("Esc clears the filter first, then closes", () => { + const input = open(); + fireEvent.change(input, { target: { value: "help" } }); + fireEvent.keyDown(input, { key: "Escape" }); + expect((input as HTMLInputElement).value).toBe(""); + expect(useFileStructurePopup.getState().open).toBe(true); + fireEvent.keyDown(input, { key: "Escape" }); + expect(useFileStructurePopup.getState().open).toBe(false); + }); +}); diff --git a/src/features/search/__tests__/findPopup.test.tsx b/src/features/search/__tests__/findPopup.test.tsx new file mode 100644 index 00000000..bdcf6711 --- /dev/null +++ b/src/features/search/__tests__/findPopup.test.tsx @@ -0,0 +1,153 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import type { ReplaceRequest } from "../find/replaceFlow"; + +const replaced: ReplaceRequest[] = []; +const confirms: unknown[] = []; + +vi.mock("../find/windowStore", async (orig) => ({ + ...(await orig()), + ipcFindDeps: () => ({ + validate: async () => {}, + start: async (_id: number, _q: unknown, onFile: (f: unknown) => void) => { + onFile({ + path: "/p/a.kt", + relPath: "a.kt", + matches: [ + { line: 1, text: "val order = 1", ranges: [[4, 9]], textOffset: 0 }, + { line: 4, text: "order()", ranges: [[0, 5]], textOffset: 0 }, + ], + }); + onFile({ path: "/p/b.kt", relPath: "b.kt", matches: [{ line: 0, text: "// order", ranges: [[3, 8]], textOffset: 0 }] }); + return null; + }, + cancel: () => {}, + nextRequestId: () => 1, + }), +})); +vi.mock("../find/replaceFlow", async (orig) => ({ + ...(await orig()), + startReplace: (r: ReplaceRequest) => { + replaced.push(r); + const replacements = r.targets.reduce((n, t) => n + (t.only?.length ?? 0), 0); + return { + result: Promise.resolve({ files: r.targets.length, replacements, failed: 0, cancelled: false, elapsedMs: 0 }), + cancel: () => {}, + }; + }, +})); +vi.mock("@/workbench/messages", async (orig) => ({ + ...(await orig()), + confirm: async (opts: unknown) => { + confirms.push(opts); + return true; + }, +})); + +const { FindInFilesOverlay } = await import("../find/FindInFiles"); +const { findHistory, hideFindPopup, rememberFindString, runFindPopupCommand, showFindPopup, useFindPopup } = + await import("../find/store"); + +const settle = () => act(() => new Promise((r) => setTimeout(r, 250))); +const selectedRows = () => + within(screen.getByRole("listbox", { name: "Matches" })) + .getAllByRole("option") + .filter((o) => o.getAttribute("aria-selected") === "true"); +const field = () => screen.getByLabelText("Text to find"); + +beforeEach(() => { + replaced.length = 0; + confirms.length = 0; + localStorage.clear(); + useFindPopup.setState({ pinned: false, maskEnabled: false, preserveCase: false, context: "any", replacement: "" }); +}); + +afterEach(() => { + act(() => hideFindPopup()); +}); + +describe("Find in Files popup", () => { + it("⇧↓ extends the selection; ↵ in Replace mode replaces the selected rows", async () => { + render(); + act(() => showFindPopup(true, { query: "order" })); + await settle(); + fireEvent.change(screen.getByLabelText("Replace with"), { target: { value: "item" } }); + fireEvent.keyDown(field(), { key: "ArrowDown", shiftKey: true }); + expect(selectedRows()).toHaveLength(2); + expect(screen.getByRole("button", { name: "Replace 2 Occurrences" })).toBeEnabled(); + fireEvent.keyDown(field(), { key: "Enter" }); + await settle(); + expect(replaced).toHaveLength(1); + expect(replaced[0]!.targets).toEqual([{ path: "/p/a.kt", only: [{ line: 1, col: 4 }, { line: 4, col: 0 }] }]); + // The next row still to replace is selected. + expect(selectedRows().map((o) => o.textContent)).toEqual([expect.stringContaining("b.kt")]); + }); + + it("⌘-click toggles rows into the selection", async () => { + render(); + act(() => showFindPopup(false, { query: "order" })); + await settle(); + const options = within(screen.getByRole("listbox", { name: "Matches" })).getAllByRole("option"); + fireEvent.click(options[2]!, { metaKey: true, ctrlKey: true, detail: 1 }); + expect(selectedRows()).toHaveLength(2); + fireEvent.click(options[1]!, { detail: 1 }); + expect(selectedRows()).toHaveLength(1); + }); + + it("⇧⌥↵ replaces all, asking first only when several files are affected", async () => { + render(); + act(() => showFindPopup(true, { query: "order" })); + await settle(); + fireEvent.keyDown(field(), { key: "Enter", shiftKey: true, altKey: true }); + await settle(); + expect(confirms).toHaveLength(1); + expect(replaced[0]!.targets.map((t) => t.path)).toEqual(["/p/a.kt", "/p/b.kt"]); + }); + + it("⌥ mnemonics toggle Preserve Case and the file mask", async () => { + render(); + act(() => showFindPopup(true, { query: "order" })); + await settle(); + fireEvent.keyDown(field(), { key: "é", code: "KeyE", altKey: true }); + expect(useFindPopup.getState().preserveCase).toBe(true); + fireEvent.keyDown(field(), { key: "k", code: "KeyK", altKey: true }); + expect(useFindPopup.getState().maskEnabled).toBe(true); + }); + + it("the pin is a remembered toggle that keeps the popup open when a result is opened", async () => { + render(); + act(() => showFindPopup(false, { query: "order" })); + await settle(); + const pin = screen.getByRole("button", { name: "Pin Window" }); + fireEvent.click(pin); + expect(pin).toHaveAttribute("aria-pressed", "true"); + expect(localStorage.getItem("openidea.search.findPinned.v1")).toBe("true"); + fireEvent.keyDown(field(), { key: "Enter" }); + expect(useFindPopup.getState().open).toBe(true); + expect(screen.getByRole("button", { name: "Open in Find Window" })).toBeInTheDocument(); + }); + + it("find-next keys move the selection and ⌥↓ lists recent searches", async () => { + rememberFindString("find", "older"); + render(); + act(() => showFindPopup(false, { query: "order" })); + await settle(); + act(() => void runFindPopupCommand("nextResult")); + act(() => void runFindPopupCommand("nextResult")); + expect(selectedRows()[0]!.textContent).toContain("b.kt"); + act(() => void runFindPopupCommand("previousResult")); + expect(selectedRows()[0]!.textContent).toContain("a.kt"); + act(() => void runFindPopupCommand("recentSearches")); + expect(await screen.findByText("older")).toBeInTheDocument(); + act(() => hideFindPopup()); + }); + + it("closing remembers the search text", async () => { + render(); + act(() => showFindPopup(false, { query: "order" })); + await settle(); + fireEvent.keyDown(document.activeElement ?? document.body, { key: "Escape" }); + expect(useFindPopup.getState().open).toBe(false); + expect(findHistory("find")[0]).toBe("order"); + }); +}); diff --git a/src/features/search/__tests__/findScopes.test.ts b/src/features/search/__tests__/findScopes.test.ts index 4f3cc9a2..9abf9ce0 100644 --- a/src/features/search/__tests__/findScopes.test.ts +++ b/src/features/search/__tests__/findScopes.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { FILE_SET_SCOPES, buildQuery, describeQuery, useFindPopup } from "../find/store"; +import { FILE_SET_SCOPES, buildQuery, describeQuery, findHistory, rememberFindString, useFindPopup } from "../find/store"; +import { selectRow, selectedRows, type RowSelection } from "../find/rowSelection"; describe("Find in Files named scopes", () => { it("defaults to IDEA's Project Files and maps root-based scopes to backend file sets", () => { @@ -13,3 +14,44 @@ describe("Find in Files named scopes", () => { ); }); }); + +describe("Find in Files search context and history", () => { + it("sends the context filter only when it narrows the search", () => { + const base = useFindPopup.getState(); + expect(buildQuery(base, { kind: "project" }, 0)).not.toHaveProperty("context"); + const q = buildQuery({ ...base, pattern: "x", context: "exceptComments" }, { kind: "project" }, 0); + expect(q.context).toBe("exceptComments"); + expect(describeQuery(q, "/p")).toBe("'x' in Project, except comments"); + }); + + it("remembers recent strings per field, newest first and unique", () => { + localStorage.clear(); + rememberFindString("find", "a"); + rememberFindString("find", "b"); + rememberFindString("find", "a"); + rememberFindString("replace", "r"); + rememberFindString("find", " "); + expect(findHistory("find")).toEqual(["a", "b"]); + expect(findHistory("replace")).toEqual(["r"]); + }); +}); + +describe("results selection", () => { + const rows = ["a", "b", "c", "d"].map((key) => ({ key })); + const keys = (sel: RowSelection) => selectedRows(rows, sel).map((r) => r.key); + + it("⇧ extends from the anchor, ⌘ toggles, a plain click selects one", () => { + let sel = selectRow(rows, { lead: 0, anchor: 0, keys: null }, 1); + sel = selectRow(rows, sel, 3, "range"); + expect(keys(sel)).toEqual(["b", "c", "d"]); + sel = selectRow(rows, sel, 0, "range"); + expect(keys(sel)).toEqual(["a", "b"]); + sel = selectRow(rows, sel, 3, "toggle"); + expect(keys(sel)).toEqual(["a", "b", "d"]); + sel = selectRow(rows, sel, 3, "toggle"); + expect(keys(sel)).toEqual(["a", "b"]); + expect(sel.lead).toBe(1); + sel = selectRow(rows, sel, 2); + expect(keys(sel)).toEqual(["c"]); + }); +}); diff --git a/src/features/search/__tests__/findWindow.test.ts b/src/features/search/__tests__/findWindow.test.ts new file mode 100644 index 00000000..5bd3e02c --- /dev/null +++ b/src/features/search/__tests__/findWindow.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it } from "vitest"; +import type { FileMatches, SearchQuery } from "@/platform/ipc"; +import { buildFindTree } from "../find/FindToolWindow"; +import type { FindDeps } from "../find/session"; +import { disposeFindWindow, openInFindWindow, pinFindTab, useFindWindow } from "../find/windowStore"; + +const q = (pattern: string): SearchQuery => ({ + pattern, + isRegex: false, + caseSensitive: false, + wholeWord: false, + include: [], + exclude: [], + scope: { kind: "project" }, + maxMatches: 100, +}); + +const deps: FindDeps = { + validate: async () => {}, + start: async () => null, + cancel: () => {}, + nextRequestId: () => 1, +}; + +const file = (rel: string): FileMatches => ({ + path: `/p/${rel}`, + relPath: rel, + matches: [{ line: 0, text: "foo", ranges: [[0, 3]], textOffset: 0 }], +}); + +afterEach(disposeFindWindow); + +describe("Find tool window tabs", () => { + it("reuses the selected tab unless it is pinned", () => { + const a = openInFindWindow(q("a"), "a", null, deps); + const b = openInFindWindow(q("b"), "b", null, deps); + expect(useFindWindow.getState().tabs.map((t) => t.title)).toEqual(["b"]); + expect(useFindWindow.getState().active).toBe(b); + expect(b).not.toBe(a); + + pinFindTab(b, true); + const c = openInFindWindow(q("c"), "c", null, deps); + expect(useFindWindow.getState().tabs.map((t) => t.title)).toEqual(["b", "c"]); + expect(useFindWindow.getState().active).toBe(c); + + // Selecting the pinned tab again opens the next search beside it. + useFindWindow.setState({ active: b }); + openInFindWindow(q("d"), "d", null, deps); + expect(useFindWindow.getState().tabs.map((t) => t.title)).toEqual(["b", "c", "d"]); + }); +}); + +describe("buildFindTree", () => { + const files = [file("src/b.ts"), file("a.ts"), file("src/a.ts")]; + + it("lists files flat by path by default", () => { + const roots = buildFindTree(files); + expect(roots.map((n) => n.kind)).toEqual(["file", "file", "file"]); + expect(roots.map((n) => n.key)).toEqual(["f:/p/a.ts", "f:/p/src/a.ts", "f:/p/src/b.ts"]); + }); + + it("groups files by directory on request", () => { + const roots = buildFindTree(files, true); + expect(roots.map((n) => (n.kind === "dir" ? [n.label, n.count] : n.kind))).toEqual([ + ["/", 1], + ["src", 2], + ]); + }); +}); diff --git a/src/features/search/__tests__/outline.test.ts b/src/features/search/__tests__/outline.test.ts index 2c90bc90..0b75bfe6 100644 --- a/src/features/search/__tests__/outline.test.ts +++ b/src/features/search/__tests__/outline.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { OutlineItem } from "@/platform/ipc"; -import { allKeys, bestRow, flattenOutline, keyOutline, sortOutline, symbolPathAt } from "../structure/outline"; +import { allKeys, filterOutline, keyOutline, parentKeys, sortOutline, symbolPathAt, visibleCount } from "../structure/outline"; const item = (name: string, kind: OutlineItem["kind"], from: number, to: number, children: OutlineItem[] = []): OutlineItem => ({ name, @@ -39,13 +39,29 @@ describe("outline helpers", () => { expect(sortOutline(nodes, false)).toBe(nodes); }); - it("filters keeping ancestors of matches", () => { - const rows = flattenOutline(nodes, "creat"); - expect(rows.map((r) => [r.node.item.name, r.depth, r.matched])).toEqual([ - ["OrderController", 0, false], - ["create", 1, true], - ]); - expect(bestRow(rows)).toBe(1); - expect(flattenOutline(nodes, "")).toHaveLength(5); + it("narrows to matches and their ancestors", () => { + const f = filterOutline(nodes, "creat", true); + expect(f.nodes.map((n) => [n.item.name, n.children.map((c) => c.item.name)])).toEqual([["OrderController", ["create"]]]); + expect(f.best).toBe(nodes[0]!.children[1]!.key); + expect(f.matches.has(nodes[0]!.key)).toBe(false); + expect(filterOutline(nodes, "", true).nodes).toBe(nodes); + }); + + it("matches word and camel-hump starts, not scattered letters", () => { + const loose = keyOutline([item("items.reduce() callback", "function", 0, 1), item("subtotal", "method", 2, 3)]); + expect(filterOutline(loose, "sub", true).nodes.map((n) => n.item.name)).toEqual(["subtotal"]); + expect(filterOutline(nodes, "OC", true).nodes.map((n) => n.item.name)).toEqual(["OrderController"]); + }); + + it("only highlights matches when not narrowing down", () => { + const f = filterOutline(nodes, "list", false); + expect(f.nodes).toBe(nodes); + expect(f.order).toEqual([nodes[0]!.children[0]!.key, nodes[0]!.children[2]!.key]); + }); + + it("counts visible rows and maps parents", () => { + expect(visibleCount(nodes, new Set())).toBe(5); + expect(visibleCount(nodes, new Set([nodes[0]!.key]))).toBe(2); + expect(parentKeys(nodes).get(nodes[0]!.children[1]!.key)).toBe(nodes[0]!.key); }); }); diff --git a/src/features/search/__tests__/recent.test.ts b/src/features/search/__tests__/recent.test.ts index c1e1c3d1..70ae491a 100644 --- a/src/features/search/__tests__/recent.test.ts +++ b/src/features/search/__tests__/recent.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vitest"; import type { ToolWindowContribution } from "@/workbench/contributions"; -import { fromNavigationHistory, recordLocation, renameLocations, type RecentLocation } from "../common/locations"; +import { + fromNavigationHistory, + isDismissed, + recordLocation, + renameLocations, + type RecentLocation, +} from "../common/locations"; import { filterLocations, filterRecentFiles, @@ -107,3 +113,13 @@ describe("navigation history snapshot", () => { expect(merged[1]!.snippet).toEqual(["code"]); }); }); + +describe("removed recent locations", () => { + it("hide the place until it is visited again", () => { + const dismissed = [{ path: "/a", line: 10, time: 100 }]; + expect(isDismissed(loc("/a", 12, { time: 90 }), dismissed)).toBe(true); + expect(isDismissed(loc("/a", 30, { time: 90 }), dismissed)).toBe(false); + expect(isDismissed(loc("/b", 10, { time: 90 }), dismissed)).toBe(false); + expect(isDismissed(loc("/a", 10, { time: 101 }), dismissed)).toBe(false); + }); +}); diff --git a/src/features/search/bookmarks/BookmarkPopups.module.css b/src/features/search/bookmarks/BookmarkPopups.module.css new file mode 100644 index 00000000..9dde0190 --- /dev/null +++ b/src/features/search/bookmarks/BookmarkPopups.module.css @@ -0,0 +1,131 @@ +.chooser { + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px 14px 12px; + outline: none; +} +.chooserTitle { + color: var(--fg-emphasis); + font-weight: 500; +} +.grids { + display: flex; + gap: 14px; +} +.digits, +.letters { + display: grid; + gap: 4px; + outline: none; +} +.digits { + grid-template-columns: repeat(3, 24px); + align-content: start; +} +.letters { + grid-template-columns: repeat(7, 24px); +} +.key { + width: 24px; + height: 24px; + padding: 0; + border: 1px solid var(--border-strong); + border-radius: var(--radius-small); + background: var(--bg-elev); + color: var(--fg); + font: inherit; + font-size: var(--fs-small); + cursor: pointer; +} +.key:hover { + border-color: var(--accent); +} +.key:focus-visible { + outline: 2px solid var(--focus-ring, var(--accent)); + outline-offset: 1px; +} +.keyAssigned { + background: var(--accent-soft); +} +.keyCurrent { + background: color-mix(in srgb, var(--accent) 45%, transparent); + color: var(--fg-strong); +} +.description { + width: 100%; +} +.legend { + display: flex; + align-items: center; + gap: 6px; + color: var(--fg-subtle); + font-size: var(--fs-small); +} +.dot { + width: 8px; + height: 8px; + border-radius: 50%; +} +.legendGap { + width: 8px; +} + +.listDialog { + max-height: 80vh; +} +.listBody { + display: flex; + height: 420px; + min-height: 0; + border-top: 1px solid var(--border-popup); +} +.listPane { + flex: 0 0 300px; + display: flex; + flex-direction: column; + min-height: 0; + border-right: 1px solid var(--border-popup); +} +.listPane > * { + flex: 1; +} +.previewPane { + flex: 1; + display: flex; + min-width: 0; +} +.fileRow, +.markRow { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} +.fileName { + color: var(--fg-strong); +} +.markText { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: pre; +} +.dim { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + color: var(--fg-subtle); +} +.mnemonic { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + border: 1px solid var(--accent); + border-radius: 2px; + background: var(--accent-soft); + font-size: var(--fs-tiny); + font-weight: 600; +} diff --git a/src/features/search/bookmarks/BookmarkPopups.tsx b/src/features/search/bookmarks/BookmarkPopups.tsx new file mode 100644 index 00000000..54edb1dd --- /dev/null +++ b/src/features/search/bookmarks/BookmarkPopups.tsx @@ -0,0 +1,251 @@ +/** + * Bookmark popups: the mnemonic chooser (⌥F3 / Ctrl+F11), the Bookmarks + * popup (⌘F3: list + preview, Enter jumps) and the Edit Description dialog + * (F2 / Rename). + */ +import { useMemo, useState, type KeyboardEvent, type MouseEvent } from "react"; +import clsx from "clsx"; +import { useProject } from "@/platform/project"; +import { Dialog, Icon, Input, List, Popup, T, fileIconName, type DialogButton } from "@/ui"; +import { CodePreview } from "../find/parts"; +import { basename, openLocation, relPath } from "../common/open"; +import { + MNEMONICS, + assignMnemonic, + bookmarkAt, + bookmarkLabel, + describeBookmark, + removeBookmarks, + updateBookmarks, + useBookmarkPopups, + useBookmarks, + type Bookmark, +} from "./store"; +import styles from "./BookmarkPopups.module.css"; + +export function BookmarkPopupsOverlay() { + const chooser = useBookmarkPopups((s) => s.chooser); + const list = useBookmarkPopups((s) => s.list); + const editing = useBookmarkPopups((s) => s.editing); + return ( + <> + {chooser && } + {list && } + {editing && } + + ); +} + +// --- mnemonic chooser --------------------------------------------------------- + +const DIGITS = MNEMONICS.filter((m) => /\d/.test(m)); +const LETTERS = MNEMONICS.filter((m) => /[A-Z]/.test(m)); + +function MnemonicChooser() { + const target = useBookmarkPopups((s) => s.chooser)!; + const items = useBookmarks((s) => s.items); + const current = bookmarkAt(items, target.path, target.line); + const assigned = useMemo(() => new Set(items.filter((b) => b.mnemonic && b !== current).map((b) => b.mnemonic!)), [items, current]); + const [description, setDescription] = useState(current?.description ?? ""); + const close = () => useBookmarkPopups.setState({ chooser: null }); + const choose = (mnemonic: string) => { + updateBookmarks((list) => assignMnemonic(list, target.path, target.line, target.text, mnemonic, description)); + close(); + }; + const title = !current ? "Add Mnemonic Bookmark" : current.mnemonic ? "Change Mnemonic" : "Assign Mnemonic"; + + const onKeyDown = (e: KeyboardEvent) => { + if (e.metaKey || e.ctrlKey || e.altKey) return; + const inInput = (e.target as HTMLElement).tagName === "INPUT"; + if (e.key === "Enter" && inInput) { + e.preventDefault(); + if (current?.mnemonic) choose(current.mnemonic); + else if (current) { + updateBookmarks((list) => describeBookmark(list, target.path, target.line, description)); + close(); + } + return; + } + if (inInput || e.key.length !== 1) return; + const key = e.key.toUpperCase(); + if (!MNEMONICS.includes(key)) return; + e.preventDefault(); + choose(key); + }; + + const button = (m: string) => ( + + ); + + return ( + +
+
+ {title} +
+
+
+ {DIGITS.map(button)} +
+
{LETTERS.map(button)}
+
+ setDescription(e.target.value)} + /> +
+ + Assigned bookmark + + + Current bookmark +
+
+
+ ); +} + +// --- Bookmarks popup ------------------------------------------------------------ + +type Row = { kind: "file"; key: string; path: string } | { kind: "mark"; key: string; bookmark: Bookmark }; + +export function bookmarkRows(items: readonly Bookmark[]): Row[] { + const rows: Row[] = []; + let path: string | null = null; + for (const b of items) { + if (b.path !== path) { + path = b.path; + rows.push({ kind: "file", key: `f:${b.path}`, path: b.path }); + } + rows.push({ kind: "mark", key: `b:${b.path}:${b.line}`, bookmark: b }); + } + return rows; +} + +const isFileRow = (r: Row) => r.kind === "file"; + +function BookmarksPopup() { + const items = useBookmarks((s) => s.items); + const root = useProject((s) => s.project?.root ?? null); + const rows = useMemo(() => bookmarkRows(items), [items]); + const [index, setIndex] = useState(() => rows.findIndex((r) => r.kind === "mark")); + const active = Math.min(index, rows.length - 1); + const row = rows[active]; + const selected = row?.kind === "mark" ? row.bookmark : null; + const close = () => useBookmarkPopups.setState({ list: false }); + const jump = (b: Bookmark | null) => { + if (!b) return; + close(); + setTimeout(() => void openLocation(b.path, { line: b.line, col: 0 }), 0); + }; + + const onKeyDown = (e: KeyboardEvent) => { + if (!selected) return; + if (e.key === "Delete" || e.key === "Backspace") { + e.preventDefault(); + updateBookmarks((list) => removeBookmarks(list, (b) => b.path === selected.path && b.line === selected.line)); + } else if (e.key === "F2") { + e.preventDefault(); + useBookmarkPopups.setState({ editing: { path: selected.path, line: selected.line } }); + } + }; + + return ( + +
+
+ + items={rows} + getKey={(r) => r.key} + isSection={isFileRow} + activeIndex={active} + onActiveChange={setIndex} + onActivate={(r, _i, e) => { + if (e.type === "click" && (e as MouseEvent).detail < 2) return; + if (r.kind === "mark") jump(r.bookmark); + }} + aria-label="Bookmarks" + rowHeight={22} + sectionHeight={24} + empty={{items.length ? null : No bookmarks}} + renderSection={(r) => + r.kind === "file" ? ( + + + {basename(r.path)} + {root ? relPath(r.path) : r.path} + + ) : null + } + renderItem={(r) => + r.kind === "mark" ? ( + + {r.bookmark.mnemonic ? ( + {r.bookmark.mnemonic} + ) : ( + + )} + {bookmarkLabel(r.bookmark) || "(empty line)"} + {r.bookmark.line + 1} + + ) : null + } + /> +
+
+ +
+
+
+ ); +} + +// --- Edit Description -------------------------------------------------------------- + +function EditDescriptionDialog() { + const editing = useBookmarkPopups((s) => s.editing)!; + const bookmark = useBookmarks((s) => bookmarkAt(s.items, editing.path, editing.line)); + const [text, setText] = useState(bookmark?.description ?? bookmark?.text ?? ""); + const close = () => useBookmarkPopups.setState({ editing: null }); + const save = () => { + updateBookmarks((list) => describeBookmark(list, editing.path, editing.line, text)); + close(); + }; + const buttons: DialogButton[] = [ + { label: "Cancel", role: "cancel" }, + { label: "OK", role: "default", onClick: save }, + ]; + return ( + + setText(e.target.value)} + onFocus={(e) => e.currentTarget.select()} + /> + + ); +} diff --git a/src/features/search/bookmarks/BookmarksView.module.css b/src/features/search/bookmarks/BookmarksView.module.css index f58d0bf5..be5ed9d9 100644 --- a/src/features/search/bookmarks/BookmarksView.module.css +++ b/src/features/search/bookmarks/BookmarksView.module.css @@ -35,3 +35,18 @@ white-space: pre; color: var(--fg-subtle); } +.mnemonic { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + margin: 0 1px; + border: 1px solid var(--accent); + border-radius: 2px; + background: var(--accent-soft); + color: var(--fg-strong); + font-size: var(--fs-tiny); + font-weight: 600; +} diff --git a/src/features/search/bookmarks/BookmarksView.tsx b/src/features/search/bookmarks/BookmarksView.tsx index ebac08be..7ff78eea 100644 --- a/src/features/search/bookmarks/BookmarksView.tsx +++ b/src/features/search/bookmarks/BookmarksView.tsx @@ -3,7 +3,7 @@ import { shortcutLabel } from "@/platform/keymap"; import { useProject } from "@/platform/project"; import { EmptyState, Icon, IconButton, Tree, fileIconName, showContextMenu } from "@/ui"; import { basename, openLocation, relPath } from "../common/open"; -import { removeBookmarks, updateBookmarks, useBookmarks, type Bookmark } from "./store"; +import { bookmarkLabel, removeBookmarks, updateBookmarks, useBookmarkPopups, useBookmarks, type Bookmark } from "./store"; import styles from "./BookmarksView.module.css"; type Node = @@ -76,9 +76,19 @@ export function BookmarksView() { else if (n.kind === "file") void openLocation(n.path); }; + const rename = (b: Bookmark) => useBookmarkPopups.setState({ editing: { path: b.path, line: b.line } }); + const onKeyDown = (e: KeyboardEvent) => { - if (e.key !== "Delete" && e.key !== "Backspace") return; const nodes = [...selection.current].map((k) => byKey.get(k)).filter((n): n is Node => !!n); + if (e.key === "F2" && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) { + const mark = nodes.length === 1 && nodes[0]!.kind === "mark" ? nodes[0]!.bookmark : null; + if (!mark) return; + e.preventDefault(); + e.stopPropagation(); + rename(mark); + return; + } + if (e.key !== "Delete" && e.key !== "Backspace") return; if (!nodes.length) return; e.preventDefault(); e.stopPropagation(); @@ -92,7 +102,7 @@ export function BookmarksView() { aria-label="Bookmarks" roots={roots} getKey={(n) => n.key} - getLabel={(n) => (n.kind === "mark" ? n.bookmark.text : n.name)} + getLabel={(n) => (n.kind === "mark" ? bookmarkLabel(n.bookmark) : n.name)} getChildren={(n) => (n.kind === "mark" ? undefined : n.children)} isExpandable={(n) => n.kind !== "mark"} expanded={expanded} @@ -107,6 +117,9 @@ export function BookmarksView() { e.preventDefault(); showContextMenu(e, [ ...(node && node.kind !== "list" ? [{ label: "Jump to Source", run: () => open(node) }] : []), + ...(node?.kind === "mark" && targets.length === 1 + ? [{ label: "Rename…", shortcut: "F2", run: () => rename(node.bookmark) }] + : []), { kind: "separator" as const }, { label: targets.length > 1 ? `Delete ${targets.length} Bookmarks` : "Delete Bookmark", danger: true, run: () => removeNodes(targets) }, ]); @@ -128,11 +141,28 @@ export function BookmarksView() { ) : ( - - - {basename(n.bookmark.path)}:{n.bookmark.line + 1} - - {n.bookmark.text.trim() || "(empty line)"} + {n.bookmark.mnemonic ? ( + + {n.bookmark.mnemonic} + + ) : ( + + )} + {n.bookmark.description ? ( + <> + {n.bookmark.description} + + {basename(n.bookmark.path)}:{n.bookmark.line + 1} + + + ) : ( + <> + + {basename(n.bookmark.path)}:{n.bookmark.line + 1} + + {n.bookmark.text.trim() || "(empty line)"} + + )} ) } diff --git a/src/features/search/bookmarks/gutter.ts b/src/features/search/bookmarks/gutter.ts index cac40478..32726bd3 100644 --- a/src/features/search/bookmarks/gutter.ts +++ b/src/features/search/bookmarks/gutter.ts @@ -1,16 +1,32 @@ /** * Bookmark gutter: a narrow CodeMirror gutter with a marker per bookmarked - * line. Markers map through edits, and moved lines are written back to the - * store so bookmarks follow the code. + * line (the mnemonic's letter / digit for mnemonic bookmarks). Markers map + * through edits, and moved lines are written back to the store so bookmarks + * follow the code. ⌘-click (Ctrl-click) in the gutter toggles a bookmark, as + * in IDEA. */ import { RangeSet, StateEffect, StateField, type Extension } from "@codemirror/state"; import { EditorView, GutterMarker, ViewPlugin, gutter, type ViewUpdate } from "@codemirror/view"; -import { linesOf, setFileLines, updateBookmarks, useBookmarks } from "./store"; +import { hasPrimaryModifier } from "@/platform/keymap"; +import { marksOf, setFileLines, updateBookmarks, useBookmarks } from "./store"; const SVG_NS = "http://www.w3.org/2000/svg"; class BookmarkMarker extends GutterMarker { - toDOM() { + constructor(readonly mnemonic: string | null) { + super(); + } + override eq(other: GutterMarker): boolean { + return other instanceof BookmarkMarker && other.mnemonic === this.mnemonic; + } + override toDOM() { + if (this.mnemonic) { + const el = document.createElement("span"); + el.className = "cm-openidea-bookmarkMnemonic"; + el.textContent = this.mnemonic; + el.setAttribute("aria-label", `Bookmark ${this.mnemonic}`); + return el; + } const svg = document.createElementNS(SVG_NS, "svg"); svg.setAttribute("viewBox", "0 0 24 24"); svg.setAttribute("width", "11"); @@ -25,7 +41,7 @@ class BookmarkMarker extends GutterMarker { } class SpacerMarker extends GutterMarker { - toDOM() { + override toDOM() { const el = document.createElement("span"); el.style.display = "inline-block"; el.style.width = "11px"; @@ -33,37 +49,49 @@ class SpacerMarker extends GutterMarker { } } -const marker = new BookmarkMarker(); +const plain = new BookmarkMarker(null); const spacer = new SpacerMarker(); -const setLines = StateEffect.define(); +type Mark = { line: number; mnemonic: string | null }; -function build(doc: EditorView["state"]["doc"], lines: readonly number[]): RangeSet { - const ranges = [...new Set(lines)] - .filter((l) => l >= 0 && l < doc.lines) - .sort((a, b) => a - b) - .map((l) => marker.range(doc.line(l + 1).from)); +const setMarks = StateEffect.define(); + +function build(doc: EditorView["state"]["doc"], marks: readonly Mark[]): RangeSet { + const seen = new Set(); + const ranges = [...marks] + .filter((m) => m.line >= 0 && m.line < doc.lines && !seen.has(m.line) && seen.add(m.line)) + .sort((a, b) => a.line - b.line) + .map((m) => (m.mnemonic ? new BookmarkMarker(m.mnemonic) : plain).range(doc.line(m.line + 1).from)); return RangeSet.of(ranges); } -function linesIn(view: EditorView, set: RangeSet): number[] { - const out: number[] = []; +function marksIn(view: EditorView, set: RangeSet): Mark[] { + const out: Mark[] = []; const iter = set.iter(); while (iter.value) { - out.push(view.state.doc.lineAt(iter.from).number - 1); + out.push({ line: view.state.doc.lineAt(iter.from).number - 1, mnemonic: iter.value.mnemonic }); iter.next(); } return out; } -const sameLines = (a: readonly number[], b: readonly number[]) => a.length === b.length && a.every((v, i) => v === b[i]); +const sameMarks = (a: readonly Mark[], b: readonly Mark[]) => + a.length === b.length && a.every((v, i) => v.line === b[i]!.line && v.mnemonic === b[i]!.mnemonic); -export function bookmarkGutter(path: string): Extension { - const field = StateField.define>({ - create: (state) => build(state.doc, linesOf(useBookmarks.getState().items, path)), +/** The 0-based line of a click in the gutter, or null outside it. */ +function gutterLine(view: EditorView, e: MouseEvent): number | null { + const t = e.target as HTMLElement | null; + if (!t?.closest?.(".cm-gutters") || t.closest(".cm-foldGutter")) return null; + const block = view.lineBlockAtHeight(e.clientY - view.documentTop); + return block ? view.state.doc.lineAt(block.from).number - 1 : null; +} + +export function bookmarkGutter(path: string, toggle: (line: number) => void): Extension { + const field = StateField.define>({ + create: (state) => build(state.doc, marksOf(useBookmarks.getState().items, path)), update(value, tr) { let next = tr.docChanged ? value.map(tr.changes) : value; - for (const e of tr.effects) if (e.is(setLines)) next = build(tr.state.doc, e.value); + for (const e of tr.effects) if (e.is(setMarks)) next = build(tr.state.doc, e.value); return next; }, }); @@ -75,17 +103,27 @@ export function bookmarkGutter(path: string): Extension { constructor(private readonly view: EditorView) { this.unsub = useBookmarks.subscribe((s, prev) => { if (s.items === prev.items) return; - const want = linesOf(s.items, path); - if (!sameLines(want, linesIn(this.view, this.view.state.field(field)))) { - this.view.dispatch({ effects: setLines.of(want) }); + const want = marksOf(s.items, path); + if (!sameMarks(want, marksIn(this.view, this.view.state.field(field)))) { + this.view.dispatch({ effects: setMarks.of(want) }); } }); // Bookmarks may have changed while this editor was being created. - const want = linesOf(useBookmarks.getState().items, path); - if (!sameLines(want, linesIn(view, view.state.field(field)))) { - queueMicrotask(() => view.dispatch({ effects: setLines.of(want) })); + const want = marksOf(useBookmarks.getState().items, path); + if (!sameMarks(want, marksIn(view, view.state.field(field)))) { + queueMicrotask(() => view.dispatch({ effects: setMarks.of(want) })); } + // Capture phase: runs before the breakpoint gutter's click handler, which ⌘-click must not reach. + view.dom.addEventListener("mousedown", this.onMouseDown, true); } + onMouseDown = (e: MouseEvent) => { + if (e.button !== 0 || !hasPrimaryModifier(e) || e.shiftKey || e.altKey) return; + const line = gutterLine(this.view, e); + if (line == null) return; + e.preventDefault(); + e.stopPropagation(); + toggle(line); + }; update(u: ViewUpdate) { if (!u.docChanged || this.pending) return; this.pending = true; @@ -94,20 +132,21 @@ export function bookmarkGutter(path: string): Extension { this.pending = false; const view = this.view; if (!view.dom.isConnected) return; - const lines = linesIn(view, view.state.field(field)); - const stored = linesOf(useBookmarks.getState().items, path); - if (sameLines(lines, stored)) return; + const marks = marksIn(view, view.state.field(field)); + const stored = marksOf(useBookmarks.getState().items, path); + if (sameMarks(marks, stored)) return; updateBookmarks((list) => setFileLines( list, path, - lines.map((l) => ({ line: l, text: view.state.doc.line(l + 1).text })), + marks.map((m) => ({ line: m.line, text: view.state.doc.line(m.line + 1).text })), ), ); }); } destroy() { this.unsub(); + this.view.dom.removeEventListener("mousedown", this.onMouseDown, true); } }, ); @@ -137,4 +176,20 @@ const theme = EditorView.baseTheme({ strokeWidth: "2", strokeLinejoin: "round", }, + ".cm-openidea-bookmarkMnemonic": { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + minWidth: "11px", + height: "12px", + padding: "0 1px", + borderRadius: "2px", + border: "1px solid var(--accent)", + color: "var(--fg-strong)", + background: "var(--accent-soft)", + fontFamily: "var(--font-ui)", + fontSize: "9px", + fontWeight: "600", + lineHeight: "1", + }, }); diff --git a/src/features/search/bookmarks/store.ts b/src/features/search/bookmarks/store.ts index b032c04a..fb9c99af 100644 --- a/src/features/search/bookmarks/store.ts +++ b/src/features/search/bookmarks/store.ts @@ -1,4 +1,7 @@ -/** Line bookmarks (F3), persisted per project in localStorage. */ +/** + * Line bookmarks (F3), persisted per project in localStorage. A bookmark may + * carry a mnemonic (0-9 / A-Z, unique: ⌥F3, ⌃⇧digit) and a description. + */ import { create } from "zustand"; import { loadJson, saveJson } from "../common/open"; @@ -9,6 +12,16 @@ export interface Bookmark { /** Line text when the bookmark was set / last moved. */ text: string; created: number; + /** `0`-`9` or `A`-`Z`; at most one bookmark has each. */ + mnemonic?: string; + /** User text shown instead of the line (F3 on a selection, Rename). */ + description?: string; +} + +export const MNEMONICS = [..."1234567890", ..."ABCDEFGHIJKLMNOPQRSTUVWXYZ"]; + +export function isMnemonic(c: unknown): c is string { + return typeof c === "string" && /^[0-9A-Z]$/.test(c); } const KEY_PREFIX = "openidea.bookmarks.v1:"; @@ -23,9 +36,80 @@ export function hasBookmark(list: readonly Bookmark[], path: string, line: numbe return list.some((b) => b.path === path && b.line === line); } -export function toggleBookmark(list: readonly Bookmark[], path: string, line: number, text: string, now = Date.now()): Bookmark[] { +export function bookmarkAt(list: readonly Bookmark[], path: string, line: number): Bookmark | undefined { + return list.find((b) => b.path === path && b.line === line); +} + +const clip = (text: string) => text.trim().slice(0, 200); + +/** F3: removes the line's bookmark or adds one (`description`: the selected text, as IDEA). */ +export function toggleBookmark( + list: readonly Bookmark[], + path: string, + line: number, + text: string, + now = Date.now(), + description?: string | null, +): Bookmark[] { if (hasBookmark(list, path, line)) return list.filter((b) => !(b.path === path && b.line === line)); - return sortBookmarks([...list, { path, line, text: text.trim().slice(0, 200), created: now }]); + const b: Bookmark = { path, line, text: clip(text), created: now }; + if (description?.trim()) b.description = clip(description); + return sortBookmarks([...list, b]); +} + +/** + * Gives the line's bookmark (created if needed) `mnemonic`, taking it from any + * other bookmark; `null` turns it into a plain bookmark. `description` replaces + * the description when given (empty clears it). + */ +export function assignMnemonic( + list: readonly Bookmark[], + path: string, + line: number, + text: string, + mnemonic: string | null, + description?: string, + now = Date.now(), +): Bookmark[] { + const here = (b: Bookmark) => b.path === path && b.line === line; + const out = list.filter((b) => !here(b)).map((b) => (mnemonic && b.mnemonic === mnemonic ? withoutMnemonic(b) : b)); + const b: Bookmark = { ...(list.find(here) ?? { path, line, text: clip(text), created: now }) }; + if (mnemonic) b.mnemonic = mnemonic; + else delete b.mnemonic; + if (description !== undefined) { + if (description.trim()) b.description = clip(description); + else delete b.description; + } + return sortBookmarks([...out, b]); +} + +function withoutMnemonic(b: Bookmark): Bookmark { + const { mnemonic: _, ...rest } = b; + return rest; +} + +/** ⌃⇧digit: the line already has `mnemonic` → remove the bookmark; otherwise assign it. */ +export function toggleMnemonic(list: readonly Bookmark[], path: string, line: number, text: string, mnemonic: string): Bookmark[] { + if (bookmarkAt(list, path, line)?.mnemonic === mnemonic) return list.filter((b) => !(b.path === path && b.line === line)); + return assignMnemonic(list, path, line, text, mnemonic); +} + +export function bookmarkByMnemonic(list: readonly Bookmark[], mnemonic: string): Bookmark | undefined { + return list.find((b) => b.mnemonic === mnemonic); +} + +/** Rename (F2 in the Bookmarks view): an empty description shows the line again. */ +export function describeBookmark(list: readonly Bookmark[], path: string, line: number, description: string): Bookmark[] { + return list.map((b) => { + if (b.path !== path || b.line !== line) return b; + const { description: _, ...rest } = b; + return description.trim() ? { ...rest, description: clip(description) } : rest; + }); +} + +/** What the Bookmarks view and popup show for a bookmark. */ +export function bookmarkLabel(b: Bookmark): string { + return b.description?.trim() || b.text.trim(); } export function removeBookmarks(list: readonly Bookmark[], pred: (b: Bookmark) => boolean): Bookmark[] { @@ -47,7 +131,11 @@ export function setFileLines( lines.forEach((l, i) => { if (seen.has(l.line)) return; seen.add(l.line); - moved.push({ path, line: l.line, text: l.text.trim().slice(0, 200), created: old[i]?.created ?? Date.now() }); + const prev = old[i]; + const b: Bookmark = { path, line: l.line, text: clip(l.text), created: prev?.created ?? Date.now() }; + if (prev?.mnemonic) b.mnemonic = prev.mnemonic; + if (prev?.description) b.description = prev.description; + moved.push(b); }); return sortBookmarks([...list.filter((b) => b.path !== path), ...moved]); } @@ -66,13 +154,26 @@ export function linesOf(list: readonly Bookmark[], path: string): number[] { return list.filter((b) => b.path === path).map((b) => b.line).sort((a, b) => a - b); } +/** Lines of `path`'s bookmarks with their mnemonics (gutter), sorted by line. */ +export function marksOf(list: readonly Bookmark[], path: string): Array<{ line: number; mnemonic: string | null }> { + return list + .filter((b) => b.path === path) + .map((b) => ({ line: b.line, mnemonic: b.mnemonic ?? null })) + .sort((a, b) => a.line - b.line); +} + function sanitize(raw: unknown): Bookmark[] { if (!Array.isArray(raw)) return []; return sortBookmarks( raw.filter( (b): b is Bookmark => !!b && typeof b === "object" && typeof (b as Bookmark).path === "string" && Number.isInteger((b as Bookmark).line), - ).map((b) => ({ path: b.path, line: Math.max(0, b.line), text: String(b.text ?? ""), created: Number(b.created) || 0 })), + ).map((b) => { + const out: Bookmark = { path: b.path, line: Math.max(0, b.line), text: String(b.text ?? ""), created: Number(b.created) || 0 }; + if (isMnemonic(b.mnemonic)) out.mnemonic = b.mnemonic; + if (typeof b.description === "string" && b.description.trim()) out.description = b.description; + return out; + }), ); } @@ -96,3 +197,25 @@ export function updateBookmarks(fn: (list: readonly Bookmark[]) => Bookmark[]) { useBookmarks.setState({ items: next }); if (scope) saveJson(KEY_PREFIX + scope, next); } + +// --- popups ----------------------------------------------------------------- + +export interface MnemonicTarget { + path: string; + line: number; + /** Line text, for a bookmark created by the chooser. */ + text: string; + /** Where to show the chooser (the caret); centred without. */ + at?: { x: number; y: number }; +} + +interface BookmarkPopupsState { + /** ⌥F3: the mnemonic chooser for this line. */ + chooser: MnemonicTarget | null; + /** ⌘F3: the Bookmarks popup. */ + list: boolean; + /** Edit Description dialog (F2 in the Bookmarks view / popup). */ + editing: { path: string; line: number } | null; +} + +export const useBookmarkPopups = create(() => ({ chooser: null, list: false, editing: null })); diff --git a/src/features/search/common/locations.ts b/src/features/search/common/locations.ts index 98f02737..cebf8f46 100644 --- a/src/features/search/common/locations.ts +++ b/src/features/search/common/locations.ts @@ -34,6 +34,17 @@ export function mergeLocations(...lists: ReadonlyArray d.path === l.path && Math.abs(d.line - l.line) <= MERGE_DISTANCE && l.time <= d.time); +} + /** Drops entries pointing at removed files, shifts paths of renamed ones. */ export function renameLocations(list: readonly RecentLocation[], from: string, to: string | null): RecentLocation[] { const out: RecentLocation[] = []; diff --git a/src/features/search/common/tracking.ts b/src/features/search/common/tracking.ts index fb574f4b..71fa1866 100644 --- a/src/features/search/common/tracking.ts +++ b/src/features/search/common/tracking.ts @@ -7,7 +7,13 @@ import { EditorView, type ViewUpdate } from "@codemirror/view"; import { create } from "zustand"; import { activeEditor, isExternalChange, onEditorEvent, registerEditorContribution, useEditorRegistry } from "@/editor/api"; import { call, commands, onIdeEvent } from "@/platform/ipc"; -import { recordLocation, renameLocations, type RecentLocation } from "./locations"; +import { + MERGE_DISTANCE, + recordLocation, + renameLocations, + type DismissedLocation, + type RecentLocation, +} from "./locations"; export interface CaretInfo { path: string; @@ -25,6 +31,8 @@ interface TrackingState { /** Files edited in this session (Recent Files "Show edited only"). */ edited: ReadonlySet; locations: readonly RecentLocation[]; + /** Places removed from Recent Locations (the editor's own history can't forget them). */ + dismissed: readonly DismissedLocation[]; } export const useTracking = create(() => ({ @@ -32,13 +40,23 @@ export const useTracking = create(() => ({ editVersions: new Map(), edited: new Set(), locations: [], + dismissed: [], })); +/** Recent Locations › Delete: forgets the place until it is visited again. */ +export function dismissLocation(l: RecentLocation) { + const near = (x: RecentLocation) => x.path === l.path && Math.abs(x.line - l.line) <= MERGE_DISTANCE; + useTracking.setState((s) => ({ + locations: s.locations.filter((x) => !near(x)), + dismissed: [...s.dismissed.slice(-99), { path: l.path, line: l.line, time: Date.now() }], + })); +} + const docPaths = new Map(); /** The window switched projects: the previous one's places and edits don't apply. */ export function resetTracking() { - useTracking.setState({ caret: null, editVersions: new Map(), edited: new Set(), locations: [] }); + useTracking.setState({ caret: null, editVersions: new Map(), edited: new Set(), locations: [], dismissed: [] }); } function bumpVersion(path: string, userEdit: boolean) { diff --git a/src/features/search/everywhere/store.ts b/src/features/search/everywhere/store.ts index d0efefa2..5c3c48b0 100644 --- a/src/features/search/everywhere/store.ts +++ b/src/features/search/everywhere/store.ts @@ -48,6 +48,8 @@ export function setSearchEverywherePreview(preview: boolean) { /** * Opens the dialog, or while it is open switches to `tab` (the query is kept); * re-invoking the shown tab toggles "include non-project items", like IDEA. + * Without `query` (an editor selection) the field starts with the tab's last + * search, so each tab remembers its own text. */ export function showSearchEverywhere(tab: SeTab, opts: { query?: string } = {}) { const s = useSearchEverywhere.getState(); @@ -59,7 +61,7 @@ export function showSearchEverywhere(tab: SeTab, opts: { query?: string } = {}) useSearchEverywhere.setState({ open: true, tab, - query: opts.query ?? s.query, + query: opts.query ?? searchHistory(tab)[0] ?? "", includeNonProject: false, autoEverywhere: true, notFound: null, @@ -95,7 +97,10 @@ export function onSearchEverywhereQuery(query: string) { useSearchEverywhere.setState({ includeNonProject: false, notFound: null }); } +/** Closing remembers the text for the shown tab (also when nothing was opened). */ export function hideSearchEverywhere() { + const s = useSearchEverywhere.getState(); + if (s.open) pushSearchHistory(s.tab, s.query); useSearchEverywhere.setState({ open: false }); } @@ -126,7 +131,7 @@ function loadHistory(): HistoryEntry[] { : []; } -/** Queries that led somewhere in `tab`, newest first. */ +/** Queries typed in `tab` (opened or left there), newest first. */ export function searchHistory(tab: SeTab): string[] { return loadHistory() .filter((e) => e.tab === tab) diff --git a/src/features/search/find/FindInFiles.tsx b/src/features/search/find/FindInFiles.tsx index f109460a..d7590546 100644 --- a/src/features/search/find/FindInFiles.tsx +++ b/src/features/search/find/FindInFiles.tsx @@ -1,6 +1,8 @@ -import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from "react"; import clsx from "clsx"; import { openEditorPaths, useEditorRegistry } from "@/editor/api"; +import { setContext } from "@/platform/context"; +import { useTxTemplate } from "@/platform/i18n"; import { call, commands, type SearchScope } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { @@ -42,16 +44,23 @@ import { type ReplaceRun, type Usage, } from "./replaceFlow"; +import { SINGLE_FIRST, isRowSelected, selectRow, selectedRows, type RowSelection } from "./rowSelection"; import { FindSession, IDLE, matchRows, type FindState, type MatchRow } from "./session"; import { FILE_SET_SCOPES, + SEARCH_CONTEXTS, buildQuery, describeQuery, + findHistory, hideFindPopup, recentMasks, + rememberFindString, rememberMask, + setFindPopupCommandHandler, + setFindPopupPinned, useFindPopup, type FindOptions, + type FindPopupCommand, type NamedScope, type ScopeTab, } from "./store"; @@ -94,13 +103,17 @@ function displayDirectory(dir: string, root: string | null): string { function FindInFilesDialog() { const s = useFindPopup(); + const txt = useTxTemplate(); const root = useProject((p) => p.project?.root ?? null); const editors = useEditorRegistry((r) => r.editors); const [state, setState] = useState(IDLE); const [session] = useState(() => new FindSession(ipcFindDeps(), setState)); - const [index, setIndex] = useState(0); + const [sel, setSel] = useState(SINGLE_FIRST); + const setIndex = (i: number) => setSel({ lead: i, anchor: i, keys: null }); const [recentPaths, setRecentPaths] = useState(null); const [maskMenu, setMaskMenu] = useState(false); + const [historyMenu, setHistoryMenu] = useState<"find" | "replace" | null>(null); + const [contextMenu, setContextMenu] = useState(false); const [previewVersion, setPreviewVersion] = useState(0); const [marks, setMarks] = useState(NO_MARKS); const [running, setRunning] = useState(null); @@ -109,11 +122,21 @@ function FindInFilesDialog() { const [diffPreview, setDiffPreview] = useState<{ key: string; lines: PreviewLine[] } | null>(null); const busy = !!running; const patternRef = useRef(null); + const patternBoxRef = useRef(null); + const replaceBoxRef = useRef(null); const maskRef = useRef(null); + const maskInputRef = useRef(null); + const contextRef = useRef(null); const set = (patch: Partial) => useFindPopup.setState(patch); useEffect(() => () => session.dispose(), [session]); + // Hidden popup actions (⌘G, F4, ⌥↓ …) win over the editor's bindings while this is set. + useEffect(() => { + setContext("findPopupOpen", true); + return () => setContext("findPopupOpen", false); + }, []); + useEffect(() => { patternRef.current?.select(); }, [s.session]); @@ -154,11 +177,12 @@ function FindInFilesDialog() { scopeTab: s.scopeTab, directory: s.directory, namedScope: s.namedScope, + context: s.context, }; const query = useMemo( () => buildQuery(options, scope, POPUP_MAX_MATCHES), // eslint-disable-next-line react-hooks/exhaustive-deps - [s.pattern, s.caseSensitive, s.wholeWord, s.isRegex, s.maskEnabled, s.mask, scope], + [s.pattern, s.caseSensitive, s.wholeWord, s.isRegex, s.maskEnabled, s.mask, s.context, scope], ); const queryKey = JSON.stringify(query); @@ -174,8 +198,9 @@ function FindInFilesDialog() { }, [session, queryKey, rerun]); const rows = useMemo(() => matchRows(state.files), [state.files]); - const active = rows.length ? Math.min(index, rows.length - 1) : -1; + const active = rows.length ? Math.min(sel.lead, rows.length - 1) : -1; const selected = active >= 0 ? rows[active] : undefined; + const chosen = useMemo(() => selectedRows(rows, sel), [rows, sel]); const fileHighlights = useMemo(() => { if (!selected) return []; const f = state.files.find((x) => x.path === selected.path); @@ -186,17 +211,22 @@ function FindInFilesDialog() { }, [selected, state.files]); const close = () => { + rememberFindString("find", s.pattern); + if (s.replace) rememberFindString("replace", s.replacement); clearPreviewCache(); hideFindPopup(); }; + /** Opens a result; a pinned popup stays open (IDEA's pin). */ const openRow = (row: MatchRow | undefined, side = false) => { if (!row) return; + const pos = { line: row.line, col: row.col, endLine: row.line, endCol: row.endCol }; + if (s.pinned) { + rememberFindString("find", s.pattern); + void openLocation(row.path, pos, { side, focus: false }); + return; + } close(); - setTimeout( - () => - void openLocation(row.path, { line: row.line, col: row.col, endLine: row.line, endCol: row.endCol }, { side }), - 0, - ); + setTimeout(() => void openLocation(row.path, pos, { side }), 0); }; const pin = () => { if (!s.pattern) return; @@ -206,15 +236,21 @@ function FindInFilesDialog() { openInFindWindow(pinned, describeQuery(pinned, root), s.replace ? s.replacement : null, undefined, s.preserveCase); }; + /** IDEA asks before Replace All only when the results span more than one file. */ const confirmReplaceAll = async () => { const usages = pendingUsages(rows, marks); if (!usages.length) return; + const counts = countUsages(usages); + if (rows.length <= 1 || counts.files <= 1) { + void replaceAll(usages); + return; + } const ok = await confirm({ title: "Replace All", ok: "Replace", message: ( <> - {confirmText(countUsages(usages), s.pattern, s.replacement || "")} + {confirmText(counts, s.pattern, s.replacement || "")} {marks.excluded.size > 0 && ( <> {" "} @@ -274,16 +310,18 @@ function FindInFilesDialog() { } }; - const replaceCurrent = async () => { - if (!selected) return; - const next = await replaceUsages(pendingUsages([selected], marks)); + /** Replace Selected (↵ in Replace mode): every selected row, then the next row to replace. */ + const replaceSelected = async () => { + if (!chosen.length) return; + const last = rows.indexOf(chosen[chosen.length - 1]!); + const next = await replaceUsages(pendingUsages(chosen, marks)); if (next) { - const i = nextPending(rows, active, next); + const i = nextPending(rows, last, next); if (i >= 0) setIndex(i); } }; const skip = () => { - const i = nextPending(rows, active, marks); + const i = nextPending(rows, rows.indexOf(chosen[chosen.length - 1]!), marks); if (i >= 0) setIndex(i); }; const replaceInFile = async () => { @@ -322,38 +360,103 @@ function FindInFilesDialog() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [previewMode, s.replace, selected?.key, s.replacement, s.preserveCase, queryKey, marks, previewVersion]); + const moveTo = (next: number, extend: boolean) => setSel((cur) => selectRow(rows, cur, next, extend ? "range" : "single")); + const onRowClick = (index: number, e: MouseEvent) => { + const mode = e.shiftKey ? "range" : e.metaKey || e.ctrlKey ? "toggle" : "single"; + setSel((cur) => selectRow(rows, cur, index, mode)); + }; + + const toggleMnemonic = (code: string): boolean => { + const toggle = { KeyC: "caseSensitive", KeyW: "wholeWord", KeyX: "isRegex" }[code] as + | "caseSensitive" + | "wholeWord" + | "isRegex" + | undefined; + if (toggle) set({ [toggle]: !s[toggle] }); + else if (code === "KeyK") { + set({ maskEnabled: !s.maskEnabled }); + if (!s.maskEnabled) setTimeout(() => maskInputRef.current?.select(), 0); + } else if (code === "KeyE" && s.replace) set({ preserveCase: !s.preserveCase }); + else if (code === "KeyA" && s.replace) { + if (rows.length && !busy) void confirmReplaceAll(); + } else return false; + return true; + }; + const onKeyDown = (e: KeyboardEvent) => { const target = e.target as HTMLElement; const inField = target.tagName === "INPUT" && target.closest("[data-find-field]"); - if (e.altKey && !e.metaKey && !e.ctrlKey) { - const toggle = { KeyC: "caseSensitive", KeyW: "wholeWord", KeyX: "isRegex" }[e.code] as - "caseSensitive" | "wholeWord" | "isRegex" | undefined; - if (toggle) { - e.preventDefault(); - set({ [toggle]: !s[toggle] }); - return; - } + if (e.key === "Enter" && e.altKey && e.shiftKey && !e.metaKey && !e.ctrlKey) { + // ⇧⌥↵: Replace All. + e.preventDefault(); + if (s.replace && rows.length && !busy) void confirmReplaceAll(); + return; + } + if (e.altKey && !e.metaKey && !e.ctrlKey && !e.shiftKey && toggleMnemonic(e.code)) { + e.preventDefault(); + return; } if (!inField) return; if (e.key === "Enter") { e.preventDefault(); if (e.metaKey || e.ctrlKey) pin(); - else openRow(selected, e.shiftKey); + else if (s.replace && !e.shiftKey) { + if (!busy) void replaceSelected(); + } else openRow(selected, e.shiftKey); return; } - if (!["ArrowDown", "ArrowUp", "PageDown", "PageUp"].includes(e.key)) return; + if (!["ArrowDown", "ArrowUp", "PageDown", "PageUp"].includes(e.key) || e.altKey) return; const next = listNavigate(e.key, { items: rows, active, pageSize: 9 }); if (next === null) return; e.preventDefault(); - setIndex(next); + moveTo(next, e.shiftKey); }; + // Keys that are global shortcuts too arrive through hidden actions (see store). + const commandRef = useRef<(c: FindPopupCommand) => void>(() => {}); + useLayoutEffect(() => { + commandRef.current = (c) => { + if (c === "nextResult" || c === "previousResult") { + if (!rows.length) return; + const step = c === "nextResult" ? 1 : -1; + moveTo((active + step + rows.length) % rows.length, false); + } else if (c === "openResult") openRow(selected); + else { + const el = document.activeElement; + setHistoryMenu(replaceBoxRef.current?.contains(el) ? "replace" : "find"); + } + }; + }); + useEffect(() => { + setFindPopupCommandHandler((c) => commandRef.current(c)); + return () => setFindPopupCommandHandler(null); + }, []); + const summary = statusText(state); const maskItems: MenuItem[] = recentMasks().map((m) => ({ label: m, checked: m === s.mask ? true : undefined, run: () => set({ mask: m, maskEnabled: true }), })); + const historyItems = (kind: "find" | "replace"): MenuItem[] => { + const h = findHistory(kind); + return h.length + ? [ + { kind: "header", label: kind === "find" ? "Recent Searches" : "Recent Replacements" }, + ...h.map((q) => ({ + label: q, + verbatim: true, + run: () => set(kind === "find" ? { pattern: q } : { replacement: q }), + })), + ] + : [{ label: kind === "find" ? "No recent searches" : "No recent replacements", disabled: true }]; + }; + const contextItems: MenuItem[] = SEARCH_CONTEXTS.map((c) => ({ + label: c.label, + checked: c.value === s.context ? true : undefined, + run: () => set({ context: c.value }), + })); + const pendingSelected = s.replace ? pendingUsages(chosen, marks).length : 0; return ( @@ -378,6 +482,7 @@ function FindInFilesDialog() { set({ maskEnabled: v })} label="File mask:" />
setMaskMenu(true)} />
- + setContextMenu(true)} + /> + setFindPopupPinned(!s.pinned)} + />
-
+
{state.status === "invalid" && state.error &&
{state.error}
} {s.replace && ( -
+
set({ replacement: v })} @@ -450,6 +567,7 @@ function FindInFilesDialog() { id: "preserveCase", icon: "preserve-case", title: "Preserve Case", + shortcut: "⌥E", pressed: s.preserveCase, onToggle: (v) => set({ preserveCase: v }), }, @@ -500,9 +618,9 @@ function FindInFilesDialog() { items={rows} getKey={(r, i) => `${r.key}:${i}`} activeIndex={active} - onActiveChange={setIndex} - onActivate={(r, _i, e) => { - if (e.type === "click" && (e as MouseEvent).detail < 2) return; + isSelected={(r) => isRowSelected(sel, r.key)} + onActivate={(r, i, e) => { + if (e.type === "click" && (e as MouseEvent).detail < 2) return onRowClick(i, e as MouseEvent); openRow(r, e.shiftKey); }} focusable={false} @@ -571,7 +689,11 @@ function FindInFilesDialog() {
- ↵ Open · ⇧↵ Open to the side · ⌘↵ Open in Find Window + {s.replace ? ( + ↵ Replace Selected · ⇧⌥↵ Replace All · ⌘↵ Open in Find Window + ) : ( + ↵ Open · ⇧↵ Open to the side · ⌘↵ Open in Find Window + )} {s.replace && progress && ( @@ -590,14 +712,15 @@ function FindInFilesDialog() { -
); } diff --git a/src/features/search/find/FindToolWindow.module.css b/src/features/search/find/FindToolWindow.module.css index a2c3ca22..e2eb6660 100644 --- a/src/features/search/find/FindToolWindow.module.css +++ b/src/features/search/find/FindToolWindow.module.css @@ -88,3 +88,13 @@ opacity: 0.5; text-decoration: line-through; } +.split { + flex: 1; + min-height: 0; +} +.splitPanel { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} diff --git a/src/features/search/find/FindToolWindow.tsx b/src/features/search/find/FindToolWindow.tsx index 15f02cb4..997b14ff 100644 --- a/src/features/search/find/FindToolWindow.tsx +++ b/src/features/search/find/FindToolWindow.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, type KeyboardEvent } from "react"; +import { useMemo, useRef, useState, type KeyboardEvent } from "react"; import clsx from "clsx"; import { create } from "zustand"; import { executeAction } from "@/platform/actions"; @@ -9,18 +9,22 @@ import { EmptyState, Icon, IconButton, + SplitGroup, + SplitHandle, + SplitPanel, Spinner, Tabs, Tree, fileIconName, showContextMenu, + type TreeHandle, type TreeRowState, T, } from "@/ui"; -import { notifications } from "@/workbench/contributions"; +import { notifications, useOccurrenceNavigator } from "@/workbench/contributions"; import { confirm } from "@/workbench/messages"; import { basename, openLocation, relDir } from "../common/open"; -import { MatchText } from "./parts"; +import { CodePreview, MatchText, type PreviewHighlight } from "./parts"; import { NO_MARKS, confirmText, @@ -38,12 +42,20 @@ import { type UsageGroup, type Usage, } from "./replaceFlow"; -import { closeFindTab, rerunFindTab, stopFindTab, useFindWindow, type FindTab } from "./windowStore"; +import { + closeFindTab, + pinFindTab, + rerunFindTab, + stopFindTab, + useFindViewOptions, + useFindWindow, + type FindTab, +} from "./windowStore"; import styles from "./FindToolWindow.module.css"; type Node = | { kind: "dir"; key: string; label: string; count: number; children: Node[] } - | { kind: "file"; key: string; path: string; name: string; count: number; children: Node[] } + | { kind: "file"; key: string; path: string; name: string; dir: string; count: number; children: Node[] } | { kind: "match"; key: string; @@ -56,13 +68,17 @@ type Node = starts: Array<{ line: number; col: number }>; }; -/** Groups streamed file matches by directory → file → line (IDEA "Group by directory"). */ -export function buildFindTree(files: readonly FileMatches[]): Node[] { +/** + * Streamed file matches as file → line nodes, flat and sorted by path (IDEA's + * default), or under their directory with `groupByDirectory`. + */ +export function buildFindTree(files: readonly FileMatches[], groupByDirectory = false): Node[] { const dirs = new Map(); + const flat: Node[] = []; for (const f of files) { const dir = relDir(f.relPath); let d = dirs.get(dir); - if (!d) { + if (!d && groupByDirectory) { d = { kind: "dir", key: `d:${dir}`, label: dir || "/", count: 0, children: [] }; dirs.set(dir, d); } @@ -84,19 +100,35 @@ export function buildFindTree(files: readonly FileMatches[]): Node[] { starts: m.ranges.map(([s]) => ({ line: m.line, col: s + m.textOffset })), }; }); - d.children.push({ + const file: Node = { kind: "file", key: `f:${f.path}`, path: f.path, name: basename(f.relPath), + dir, count: f.matches.length, children, - }); - d.count += f.matches.length; + }; + if (d) { + d.children.push(file); + d.count += f.matches.length; + } else flat.push(file); + } + if (!groupByDirectory) { + const rel = (n: Node) => (n.kind === "file" ? (n.dir ? `${n.dir}/${n.name}` : n.name) : ""); + return flat.sort((a, b) => rel(a).localeCompare(rel(b))); } return [...dirs.values()].sort((a, b) => a.label.localeCompare(b.label)); } +/** Preview target of a node: a match, or a file's first match. */ +function previewOf(node: Node | null): { path: string; line: number } | null { + if (!node || node.kind === "dir") return null; + if (node.kind === "match") return { path: node.path, line: node.line }; + const first = node.children[0]; + return first?.kind === "match" ? { path: node.path, line: first.line } : null; +} + /** The usages under a node (itself for a match). */ export function nodeGroups(node: Node): UsageGroup[] { if (node.kind === "match") return [{ key: node.key, path: node.path, starts: node.starts }]; @@ -132,10 +164,30 @@ export function FindTabs() { variant="compact" className={styles.tabs} aria-label="Searches" - items={tabs.map((t) => ({ id: String(t.id), label: t.title, closable: true }))} + items={tabs.map((t) => ({ + id: String(t.id), + label: t.title, + closable: true, + icon: t.pinned ? "pin" : undefined, + }))} activeId={String(tab.id)} onSelect={(id) => useFindWindow.setState({ active: Number(id) })} onClose={(id) => closeFindTab(Number(id))} + onContextMenu={(id, e) => { + const t = tabs.find((x) => x.id === Number(id)); + if (!t) return; + showContextMenu(e, [ + { id: "pin", label: t.pinned ? "Unpin Tab" : "Pin Tab", run: () => pinFindTab(t.id, !t.pinned) }, + { kind: "separator" }, + { id: "close", label: "Close Tab", run: () => closeFindTab(t.id) }, + { + id: "closeOthers", + label: "Close Other Tabs", + disabled: tabs.length < 2, + run: () => tabs.forEach((o) => o.id !== t.id && closeFindTab(o.id)), + }, + ]); + }} /> ); } @@ -159,7 +211,11 @@ export function FindToolWindow() { } function FindTabView({ tab }: { tab: FindTab }) { - const roots = useMemo(() => buildFindTree(tab.state.files), [tab.state.files]); + const groupByDirectory = useFindViewOptions((o) => o.groupByDirectory); + const showPreview = useFindViewOptions((o) => o.preview); + const roots = useMemo(() => buildFindTree(tab.state.files, groupByDirectory), [tab.state.files, groupByDirectory]); + const tree = useRef(null); + const [leadNode, setLeadNode] = useState(null); const collapsed = useCollapsed((s) => s.byTab[tab.id]); const expanded = useMemo(() => { const all = allExpandableKeys(roots); @@ -187,9 +243,31 @@ function FindTabView({ tab }: { tab: FindTab }) { const exclude = () => { const groups = selectedGroups(); - if (groups.length) setMarks(tab.id, toggleExcluded(marks, groups)); + if (!groups.length) return; + const next = toggleExcluded(marks, groups); + setMarks(tab.id, next); + // Excluding moves on to the next usage, as in IDEA (including keeps the selection). + if (groups.some((g) => groupState(g, next) === "excluded")) + tree.current?.selectOccurrence(1, { skipSubtree: true }); }; + useOccurrenceNavigator("find", (dir, focus) => { + const step = tree.current?.selectOccurrence(dir); + const n = step?.node as Node | undefined; + if (!step || n?.kind !== "match") return null; + void openLocation(n.path, { line: n.line, col: n.col, endLine: n.line, endCol: n.endCol }, { focus }); + return step; + }); + + const preview = previewOf(leadNode); + const highlights = useMemo(() => { + const f = preview && st.files.find((x) => x.path === preview.path); + return (f?.matches ?? []).map((m) => ({ + line: m.line, + ranges: m.ranges.map(([a, b]) => [a + m.textOffset, b + m.textOffset] as [number, number]), + })); + }, [preview?.path, st.files]); // eslint-disable-line react-hooks/exhaustive-deps + const replace = async (usages: Usage[]) => { if (tab.replacement === null || !usages.length || running) return; const run = startReplace( @@ -225,7 +303,7 @@ function FindTabView({ tab }: { tab: FindTab }) { } }; - const preview = async () => { + const previewReplace = async () => { if (tab.replacement === null) return; const groups = selectedGroups(); const path = groups[0]?.path; @@ -307,7 +385,12 @@ function FindTabView({ tab }: { tab: FindTab }) { Stop )} - + )} void applyStash(s.index, false) }, { label: "Pop Stash", icon: "undo", run: () => void applyStash(s.index, true) }, + { label: "Unstash…", run: () => showUnstashDialog(s.index) }, { kind: "separator" }, { label: "Drop Stash", icon: "trash", run: () => void dropStash(s.index, s.message) }, ]); diff --git a/src/features/vcs/GitLog.tsx b/src/features/vcs/GitLog.tsx index 9a684ddf..3eb4f7f6 100644 --- a/src/features/vcs/GitLog.tsx +++ b/src/features/vcs/GitLog.tsx @@ -38,6 +38,9 @@ import { goToChild, goToParent, loadMore, + ME, + dateRangeLabel, + inDateRange, refsSignature, reloadLog, selectCommit, @@ -52,7 +55,7 @@ import { currentRepo, loadBranches, useVcs } from "./store"; import { checkoutBranch, loadFavoriteBranches, newBranch, useFavoriteBranches } from "./branchOps"; import { branchMenu } from "./branchMenu"; import { branchFolderKeys, branchGroupKeys, buildBranchTree, filterBranchTree, type BranchTreeNode } from "./branches"; -import { promptText } from "./dialogs"; +import { chooseDateRange, chooseMany, promptText } from "./dialogs"; import { buildFileTree, dirKeys, type FileTreeNode } from "./fileTree"; import styles from "./GitLog.module.css"; @@ -167,7 +170,7 @@ function BranchesPanel() { const branchesLoaded = useVcs((s) => s.branchesLoaded); const current = useVcs((s) => s.branch?.head ?? null); const favorites = useFavoriteBranches((s) => s.names); - const filterBranch = useGitLog((s) => s.filters.branch); + const filterBranches = useGitLog((s) => s.filters.branches); const [query, setQuery] = useState(""); useEffect(() => loadFavoriteBranches(), [root]); const allRoots = useMemo(() => buildBranchTree(branches, favorites), [branches, favorites]); @@ -175,8 +178,8 @@ function BranchesPanel() { // While searching every match is shown expanded. const searchExpanded = useMemo(() => (query.trim() ? new Set(branchGroupKeys(roots)) : undefined), [roots, query]); const selected = useMemo( - () => new Set([filterBranch === "HEAD" ? "head" : filterBranch ? `b:${filterBranch}` : ""]), - [filterBranch], + () => new Set(filterBranches.map((b) => (b === "HEAD" ? "head" : `b:${b}`))), + [filterBranches], ); return (
@@ -219,9 +222,10 @@ function BranchesPanel() { } getDetail={branchDetail} selected={selected} - multiSelect={false} - onSelectionChange={(_sel, lead) => { - if (lead?.ref) setLogFilters({ branch: lead.ref === "HEAD" ? "HEAD" : lead.ref }); + onSelectionChange={(sel) => { + // Several selected branches filter the log by all of them. + const refs = [...sel].flatMap((k) => (k === "head" ? ["HEAD"] : k.startsWith("b:") ? [k.slice(2)] : [])); + if (refs.length) setLogFilters({ branches: refs }); }} onActivate={(n) => n.branch && !n.branch.current && void checkoutBranch(n.branch.name)} onContextMenu={(n, _s, e) => { @@ -311,48 +315,80 @@ function FilterBar() { return () => clearTimeout(t); }, [text, filters.text]); + const only = (list: readonly string[], v: string) => list.length === 1 && list[0] === v; + const branchNames = () => branches.filter((b) => !b.name.endsWith("/HEAD")).map((b) => b.name); + const openBranch = (e: ReactMouseEvent) => menuBelow(e, [ { label: "HEAD (Current Branch)", - checked: filters.branch === "HEAD", - run: () => setLogFilters({ branch: "HEAD" }), + checked: only(filters.branches, "HEAD"), + run: () => setLogFilters({ branches: ["HEAD"] }), }, { kind: "separator" }, - ...branches - .filter((b) => !b.name.endsWith("/HEAD")) - .map((b) => ({ - label: b.name, - icon: "branch", - checked: filters.branch === b.name, - run: () => setLogFilters({ branch: b.name }), - })), + ...branchNames().map((name) => ({ + label: name, + icon: "branch", + checked: filters.branches.includes(name), + run: () => setLogFilters({ branches: [name] }), + })), + { kind: "separator" }, + { + label: "Select…", + run: async () => { + const r = await chooseMany({ + title: "Filter by Branch", + options: ["HEAD", ...branchNames()], + initial: filters.branches, + customLabel: "Other revisions (comma-separated):", + }); + if (r) setLogFilters({ branches: r }); + }, + }, ]); const openUser = (e: ReactMouseEvent) => { const authors = [...new Set(commits.map((c) => c.author))].sort((a, b) => a.localeCompare(b)).slice(0, 30); menuBelow(e, [ - ...authors.map((a) => ({ label: a, checked: filters.author === a, run: () => setLogFilters({ author: a }) })), + { label: ME, checked: filters.authors.includes(ME), run: () => setLogFilters({ authors: [ME] }) }, ...(authors.length ? [{ kind: "separator" as const }] : []), + ...authors.map((a) => ({ + label: a, + checked: filters.authors.includes(a), + run: () => setLogFilters({ authors: [a] }), + })), + { kind: "separator" }, { label: "Select…", run: async () => { - const r = await promptText({ title: "Filter by User", label: "User name or email", initial: filters.author }); - if (r) setLogFilters({ author: r.value.trim() }); + const r = await chooseMany({ + title: "Filter by User", + options: [ME, ...authors], + initial: filters.authors, + customLabel: "Other names or emails (comma-separated):", + }); + if (r) setLogFilters({ authors: r }); }, }, ]); }; const openDate = (e: ReactMouseEvent) => - menuBelow( - e, - DATE_OPTIONS.map((o) => ({ + menuBelow(e, [ + ...DATE_OPTIONS.map((o) => ({ label: o.label, - checked: filters.since?.label === o.label, - run: () => setLogFilters({ since: { ms: Date.now() - o.days * DAY, label: o.label } }), + checked: filters.date?.label === o.label, + run: () => setLogFilters({ date: { after: Date.now() - o.days * DAY, before: null, label: o.label } }), })), - ); + { kind: "separator" }, + { + label: "Select…", + run: async () => { + const r = await chooseDateRange(); + if (r) setLogFilters({ date: { ...r, label: dateRangeLabel(r.after, r.before) } }); + }, + }, + ]); const openPaths = (e: ReactMouseEvent) => menuBelow(e, [ @@ -395,21 +431,21 @@ function FilterBar() { /> setLogFilters({ branch: null })} + onClear={() => setLogFilters({ branches: [] })} /> setLogFilters({ author: "" })} + onClear={() => setLogFilters({ authors: [] })} /> setLogFilters({ since: null })} + onClear={() => setLogFilters({ date: null })} /> s.commits); const allRows = useGitLog((s) => s.rows); - const since = useGitLog((s) => s.filters.since); + const date = useGitLog((s) => s.filters.date); // The date filter is applied here; the graph is hidden while it is active (rows are no longer contiguous). - const commits = useMemo(() => (since == null ? all : all.filter((c) => (c.timeMs ?? 0) >= since.ms)), [all, since]); - const rows = since == null ? allRows : []; - const exhausted = since != null && all.length > 0 && (all[all.length - 1]!.timeMs ?? 0) < since.ms; + const commits = useMemo( + () => (date == null ? all : all.filter((c) => inDateRange(c.timeMs ?? 0, date))), + [all, date], + ); + const rows = date == null ? allRows : []; + const exhausted = date?.after != null && all.length > 0 && (all[all.length - 1]!.timeMs ?? 0) < date.after; const loading = useGitLog((s) => s.loading); const done = useGitLog((s) => s.done); const error = useGitLog((s) => s.error); diff --git a/src/features/vcs/MergeEditor.tsx b/src/features/vcs/MergeEditor.tsx index 4b8e64d5..3b6866b7 100644 --- a/src/features/vcs/MergeEditor.tsx +++ b/src/features/vcs/MergeEditor.tsx @@ -1,7 +1,7 @@ /** Three-pane merge conflict editor: Yours | Result | Theirs. */ import { useCallback, useEffect, useRef, useState } from "react"; import clsx from "clsx"; -import { Facet, RangeSetBuilder, StateField, type EditorState, type Extension } from "@codemirror/state"; +import { RangeSetBuilder, StateEffect, StateField, type EditorState, type Extension } from "@codemirror/state"; import { Decoration, EditorView, WidgetType, type DecorationSet } from "@codemirror/view"; import { history, historyKeymap } from "@codemirror/commands"; import { keymap } from "@codemirror/view"; @@ -13,14 +13,7 @@ import { Button, IconButton, Spinner, T } from "@/ui"; import { editorService, type EditorInput } from "@/workbench/contributions"; import { languageFor, viewerBase } from "./cm"; import { confirmDialog } from "./dialogs"; -import { - mergeTitles, - parseConflicts, - resolutionText, - resolveAll, - type ConflictBlock, - type Resolution, -} from "./conflicts"; +import { initialResult, mergeTitles, resolutionText, type Resolution, type ResultConflict } from "./conflicts"; import { baseName } from "./model"; import { errorMessage, readWorkingText, runOp, writeFileText } from "./ops"; import { refreshNow } from "./store"; @@ -29,12 +22,15 @@ import styles from "./MergeEditor.module.css"; import type { MergeData } from "./mergeKind"; export { MERGE_KIND, mergeInput, type MergeData } from "./mergeKind"; -/** The user's commit is the `>>>>>>>` section (a rebase): "Yours" takes it. */ -const reversedSides = Facet.define({ combine: (v) => v.some(Boolean) }); +/** Drops the conflict at an index once a resolution replaced its text. */ +const resolvedConflict = StateEffect.define(); -function resolveBlock(view: EditorView, block: ConflictBlock, how: Resolution) { +function resolveConflict(view: EditorView, index: number, how: Resolution) { + const c = view.state.field(conflictField).conflicts[index]; + if (!c) return; view.dispatch({ - changes: { from: block.from, to: block.to, insert: resolutionText(block, how, view.state.facet(reversedSides)) }, + changes: { from: c.from, to: c.to, insert: resolutionText(c, how) }, + effects: resolvedConflict.of(index), userEvent: "input.resolve", }); } @@ -42,12 +38,12 @@ function resolveBlock(view: EditorView, block: ConflictBlock, how: Resolution) { class ActionsWidget extends WidgetType { constructor( readonly index: number, - readonly block: ConflictBlock, + readonly hasBase: boolean, ) { super(); } eq(o: ActionsWidget) { - return o.index === this.index && o.block.from === this.block.from && o.block.to === this.block.to; + return o.index === this.index && o.hasBase === this.hasBase; } toDOM(view: EditorView) { const el = document.createElement("div"); @@ -60,8 +56,7 @@ class ActionsWidget extends WidgetType { b.onmousedown = (e) => e.preventDefault(); b.onclick = (e) => { e.preventDefault(); - const live = parseConflicts(view.state.doc.toString())[this.index]; - if (live) resolveBlock(view, live, how); + resolveConflict(view, this.index, how); }; el.appendChild(b); }; @@ -71,7 +66,7 @@ class ActionsWidget extends WidgetType { add("Accept Yours", "ours"); add("Accept Theirs", "theirs"); add("Accept Both", "both"); - if (this.block.base !== null) add("Use Base", "base"); + if (this.hasBase) add("Use Base", "base"); return el; } ignoreEvent() { @@ -79,41 +74,38 @@ class ActionsWidget extends WidgetType { } } -const oursLine = Decoration.line({ class: "cm-conflict-ours" }); -const theirsLine = Decoration.line({ class: "cm-conflict-theirs" }); -const markerLine = Decoration.line({ class: "cm-conflict-marker" }); +const conflictLine = Decoration.line({ class: "cm-conflict-marker" }); -function decorate(state: EditorState): { blocks: ConflictBlock[]; deco: DecorationSet } { - const blocks = parseConflicts(state.doc.toString()); +function decorate(state: EditorState, conflicts: readonly ResultConflict[]): DecorationSet { const b = new RangeSetBuilder(); const doc = state.doc; - const [yours, theirs] = state.facet(reversedSides) ? [theirsLine, oursLine] : [oursLine, theirsLine]; - blocks.forEach((blk, i) => { - b.add(blk.from, blk.from, Decoration.widget({ widget: new ActionsWidget(i, blk), block: true, side: -1 })); - const endLine = doc.lineAt(Math.max(blk.from, blk.to - 1)).number; - for (let n = doc.lineAt(blk.from).number; n <= endLine; n++) { - const line = doc.line(n); - const deco = - line.from >= blk.oursFrom && line.from < blk.oursTo - ? yours - : line.from >= blk.theirsFrom && line.from < blk.theirsTo - ? theirs - : markerLine; - b.add(line.from, line.from, deco); - } + conflicts.forEach((c, i) => { + b.add(c.from, c.from, Decoration.widget({ widget: new ActionsWidget(i, c.base !== null), block: true, side: -1 })); + if (c.to <= c.from) return; + const endLine = doc.lineAt(c.to - 1).number; + for (let n = doc.lineAt(c.from).number; n <= endLine; n++) b.add(doc.line(n).from, doc.line(n).from, conflictLine); }); - return { blocks, deco: b.finish() }; + return b.finish(); } -const conflictField = StateField.define<{ blocks: ConflictBlock[]; deco: DecorationSet }>({ - create: decorate, - update: (v, tr) => (tr.docChanged ? decorate(tr.state) : v), +/** + * The result's unresolved conflicts, tracked as ranges holding the base text (no + * markers), as IDEA's merge dialog shows them. Typing at a conflict's edges stays in it. + */ +const conflictField = StateField.define<{ conflicts: ResultConflict[]; deco: DecorationSet }>({ + create: () => ({ conflicts: [], deco: Decoration.none }), + update(v, tr) { + const done = new Set(tr.effects.filter((e) => e.is(resolvedConflict)).map((e) => e.value as number)); + let conflicts = v.conflicts.filter((_, i) => !done.has(i)); + if (tr.docChanged) + conflicts = conflicts.map((c) => ({ ...c, from: tr.changes.mapPos(c.from, -1), to: tr.changes.mapPos(c.to, 1) })); + if (!tr.docChanged && !done.size) return v; + return { conflicts, deco: decorate(tr.state, conflicts) }; + }, provide: (f) => EditorView.decorations.from(f, (v) => v.deco), }); const resultTheme = EditorView.baseTheme({ - ".cm-conflict-ours": { backgroundColor: "color-mix(in srgb, var(--accent) 12%, transparent)" }, - ".cm-conflict-theirs": { backgroundColor: "color-mix(in srgb, var(--vcs-added) 12%, transparent)" }, ".cm-conflict-marker": { backgroundColor: "color-mix(in srgb, var(--vcs-conflict) 14%, transparent)", color: "var(--fg-subtle)", @@ -148,6 +140,7 @@ export function MergeEditor({ input }: { input: EditorInput }) { theirs: string; base: string | null; working: string; + merged: string | null; sides: MergeSides; } | null>(null); const [error, setError] = useState(null); @@ -164,7 +157,14 @@ export function MergeEditor({ input }: { input: EditorInput }) { const load = useCallback(async () => { try { const [v, working] = await Promise.all([call(commands.vcsConflictVersions(path)), readWorkingText(path)]); - setState({ ours: v.ours ?? "", theirs: v.theirs ?? "", base: v.base, working: working ?? "", sides: v.sides }); + setState({ + ours: v.ours ?? "", + theirs: v.theirs ?? "", + base: v.base, + working: working ?? "", + merged: v.merged, + sides: v.sides, + }); setError(null); } catch (e) { setError(errorMessage(e)); @@ -191,24 +191,27 @@ export function MergeEditor({ input }: { input: EditorInput }) { ]; const ours = new EditorView({ doc: state.ours, parent: oursRef.current, extensions: side() }); const theirs = new EditorView({ doc: state.theirs, parent: theirsRef.current, extensions: side() }); + // Like IDEA, start from the three-way merge (non-conflicting changes applied, conflicts + // showing the base) rather than the marker-laden working copy. + const start = + state.merged !== null ? initialResult(state.merged) : initialResult(state.working, state.sides.reversed); const result = new EditorView({ - doc: state.working, + doc: start.text, parent: resultRef.current, extensions: [ viewerBase({ readOnly: false }), history(), keymap.of(historyKeymap), lang, - reversedSides.of(state.sides.reversed), - conflictField, + conflictField.init((s) => ({ conflicts: start.conflicts, deco: decorate(s, start.conflicts) })), resultTheme, EditorView.updateListener.of((u) => { - if (u.docChanged) setLeft(u.state.field(conflictField).blocks.length); + if (u.docChanged) setLeft(u.state.field(conflictField).conflicts.length); }), ], }); resultView.current = result; - setLeft(result.state.field(conflictField).blocks.length); + setLeft(result.state.field(conflictField).conflicts.length); return () => { ours.destroy(); theirs.destroy(); @@ -220,7 +223,7 @@ export function MergeEditor({ input }: { input: EditorInput }) { const goto = (dir: 1 | -1) => { const v = resultView.current; if (!v) return; - const blocks = v.state.field(conflictField).blocks; + const blocks = v.state.field(conflictField).conflicts; if (!blocks.length) return; const head = v.state.selection.main.head; const target = @@ -237,9 +240,10 @@ export function MergeEditor({ input }: { input: EditorInput }) { const acceptAll = (how: Resolution) => { const v = resultView.current; if (!v) return; - const text = v.state.doc.toString(); + const conflicts = v.state.field(conflictField).conflicts; v.dispatch({ - changes: { from: 0, to: text.length, insert: resolveAll(text, how, v.state.facet(reversedSides)) }, + changes: conflicts.map((c) => ({ from: c.from, to: c.to, insert: resolutionText(c, how) })), + effects: conflicts.map((_, i) => resolvedConflict.of(i)), userEvent: "input.resolve", }); }; @@ -247,12 +251,12 @@ export function MergeEditor({ input }: { input: EditorInput }) { const apply = async () => { const v = resultView.current; if (!v) return; - const remaining = v.state.field(conflictField).blocks.length; + const remaining = v.state.field(conflictField).conflicts.length; if ( remaining > 0 && !(await confirmDialog({ title: "Unresolved Conflicts", - message: `${remaining} conflict${remaining === 1 ? "" : "s"} still contain conflict markers. Save and mark the file as resolved anyway?`, + message: `${remaining} conflict${remaining === 1 ? " is" : "s are"} unresolved and keep the base text. Save and mark the file as resolved anyway?`, okLabel: "Mark Resolved", })) ) @@ -263,12 +267,15 @@ export function MergeEditor({ input }: { input: EditorInput }) { async () => { await writeFileText(path, text, { save: true }); await call(commands.vcsStage([path])); + return true; }, { success: `${baseName(path)}: conflicts resolved` }, ); - if (ok !== undefined) { + if (ok) { setResolved(true); await refreshNow(); + // Like IDEA, applying the result closes the merge editor. + editorService().closeInput?.(input.key); } }; diff --git a/src/features/vcs/__tests__/branches.test.ts b/src/features/vcs/__tests__/branches.test.ts index 1b1a3ffc..a47acf42 100644 --- a/src/features/vcs/__tests__/branches.test.ts +++ b/src/features/vcs/__tests__/branches.test.ts @@ -40,7 +40,15 @@ const actions = [ ]; const summary = (rows: BranchRow[]) => - rows.map((r) => (r.type === "section" ? `# ${r.label}` : r.type === "action" ? `> ${r.action.label}` : `${r.group}:${r.branch.name}`)); + rows.map((r) => + r.type === "section" + ? `# ${r.label}` + : r.type === "action" + ? `> ${r.action.label}` + : r.type === "tag" + ? `tag:${r.tag}` + : `${r.group}:${r.branch.name}${r.favorite ? " ★" : ""}`, + ); describe("buildBranchRows", () => { it("groups actions, recent, local (current first, favourites next) and remote", () => { @@ -51,25 +59,50 @@ describe("buildBranchRows", () => { "recent:bugfix", "# Local", "local:feature/order-paging", - "local:main", + "local:main ★", "local:bugfix", "local:zeta", "# Remote", - "remote:origin/main", + "remote:origin/main ★", "remote:origin/feature/order-paging", ]); }); it("filters branches and actions by fuzzy query and drops the Recent group", () => { const rows = buildBranchRows(branches, ["main"], "main", actions); - expect(summary(rows)).toEqual(["# Local", "local:main", "# Remote", "remote:origin/main"]); + expect(summary(rows)).toEqual(["# Local", "local:main ★", "# Remote", "remote:origin/main ★"]); const hit = rows[1]!; expect(hit.type === "branch" && hit.matches).toEqual([0, 1, 2, 3]); expect(summary(buildBranchRows(branches, [], "push", actions))).toEqual(["> Push…"]); }); it("omits empty groups", () => { - expect(summary(buildBranchRows([b("main", { current: true })], [], "", []))).toEqual(["# Local", "local:main"]); + expect(summary(buildBranchRows([b("main", { current: true })], [], "", []))).toEqual(["# Local", "local:main ★"]); + }); + + it("puts the user's favorites right after the current branch", () => { + const rows = buildBranchRows(branches, [], "", [], ["zeta", "origin/feature/order-paging"]); + expect(summary(rows)).toEqual([ + "# Local", + "local:feature/order-paging", + "local:zeta ★", + "local:bugfix", + "local:main", + "# Remote", + "remote:origin/feature/order-paging ★", + "remote:origin/main", + ]); + }); + + it("lists tags last, newest version first, and filters them by the query", () => { + const tags = ["v1.2", "v1.10", "release"]; + expect(summary(buildBranchRows([], [], "", [], null, tags))).toEqual([ + "# Tags", + "tag:v1.10", + "tag:v1.2", + "tag:release", + ]); + expect(summary(buildBranchRows(branches, [], "v1.1", [], null, tags))).toEqual(["# Tags", "tag:v1.10"]); }); }); @@ -89,7 +122,17 @@ describe("buildBranchTree", () => { it("groups names into folders with favorites first", () => { const tree = buildBranchTree( - [b("zeta"), b("feat/b"), b("feat/a"), b("main"), b("fix/deep/x"), b("dev", { current: true }), b("origin/main"), b("origin/feat/a"), b("origin/HEAD")], + [ + b("zeta"), + b("feat/b"), + b("feat/a"), + b("main"), + b("fix/deep/x"), + b("dev", { current: true }), + b("origin/main"), + b("origin/feat/a"), + b("origin/HEAD"), + ], null, ); expect(outline(tree)).toEqual([ diff --git a/src/features/vcs/__tests__/conflicts.test.ts b/src/features/vcs/__tests__/conflicts.test.ts index fc32ac02..cf41d834 100644 --- a/src/features/vcs/__tests__/conflicts.test.ts +++ b/src/features/vcs/__tests__/conflicts.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { blockAt, mergeTitles, parseConflicts, resolveAll, resolutionText } from "../conflicts"; +import { blockAt, initialResult, mergeTitles, parseConflicts, resolveAll, resolutionText } from "../conflicts"; const text = [ "keep\n", @@ -71,6 +71,30 @@ describe("mergeTitles", () => { yours: "Local Changes", theirs: "Changes from cherry-pick abc12345", }); + expect(mergeTitles(sides("unstash"))).toEqual({ + yours: "Your uncommitted changes", + theirs: "Changes from feature", + }); expect(mergeTitles(null)).toEqual({ yours: "Your version", theirs: "Theirs (incoming)" }); }); }); + +describe("initialResult", () => { + const merged = "a\n<<<<<<< yours\nmine\n||||||| base\nold\n=======\nhers\n>>>>>>> theirs\nz\n"; + + it("replaces each conflict with its base text, without markers", () => { + const { text, conflicts } = initialResult(merged); + expect(text).toBe("a\nold\nz\n"); + expect(conflicts).toEqual([{ from: 2, to: 6, ours: "mine\n", base: "old\n", theirs: "hers\n" }]); + expect(resolutionText(conflicts[0]!, "ours")).toBe("mine\n"); + }); + + it("swaps marker sides written in git's order during a rebase", () => { + const { conflicts } = initialResult(merged, true); + expect(conflicts[0]).toMatchObject({ ours: "hers\n", theirs: "mine\n" }); + }); + + it("keeps text without conflicts as is", () => { + expect(initialResult("x\n")).toEqual({ text: "x\n", conflicts: [] }); + }); +}); diff --git a/src/features/vcs/__tests__/logFilters.test.ts b/src/features/vcs/__tests__/logFilters.test.ts new file mode 100644 index 00000000..5b6a7267 --- /dev/null +++ b/src/features/vcs/__tests__/logFilters.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const vcsLog = vi.fn(); +const vcsConfigGet = vi.fn(); + +vi.mock("@/platform/ipc", async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + commands: { + ...real.commands, + vcsLog: (repo: unknown, query: unknown) => vcsLog(repo, query), + vcsConfigGet: (repo: unknown, key: string) => vcsConfigGet(repo, key), + }, + }; +}); + +import { parseDay, splitList } from "../dialogs"; +import { unstashTitles } from "../changeOps"; +import { + ME, + dateRangeLabel, + emptyFilters, + hasFilters, + inDateRange, + reloadLog, + resolveAuthors, + useGitLog, +} from "../logStore"; + +describe("Git Log filters", () => { + it("resolves the 'me' user to the current Git user and drops duplicates", () => { + expect(resolveAuthors([ME, "alice", " alice "], "me@corp.test")).toEqual(["me@corp.test", "alice"]); + expect(resolveAuthors([ME], null)).toEqual([]); + }); + + it("matches a custom date range with an exclusive end", () => { + const d = { after: 100, before: 200, label: "" }; + expect([99, 100, 199, 200].map((t) => inDateRange(t, d))).toEqual([false, true, true, false]); + expect(inDateRange(5, { after: null, before: 10, label: "" })).toBe(true); + expect(inDateRange(5, null)).toBe(true); + }); + + it("labels a date range by its first and last day", () => { + const jan1 = parseDay("2024-01-01")!; + const feb1 = parseDay("2024-02-01")!; + expect(dateRangeLabel(jan1, feb1)).toBe("2024-01-01 – 2024-01-31"); + expect(dateRangeLabel(jan1, null)).toBe("after 2024-01-01"); + expect(dateRangeLabel(null, feb1)).toBe("until 2024-01-31"); + expect(parseDay("nope")).toBeNull(); + }); + + it("counts multi-value filters as filters", () => { + expect(hasFilters(emptyFilters())).toBe(false); + expect(hasFilters({ ...emptyFilters(), branches: ["main", "dev"] })).toBe(true); + expect(hasFilters({ ...emptyFilters(), authors: [ME] })).toBe(true); + }); + + it("splits comma-separated custom values", () => { + expect(splitList(" a, ,b ,")).toEqual(["a", "b"]); + }); +}); + +describe("loading the log", () => { + beforeEach(() => { + vcsLog.mockReset().mockResolvedValue({ status: "ok", data: [] }); + vcsConfigGet.mockReset().mockImplementation(async (_repo: unknown, key: string) => ({ + status: "ok", + data: key === "user.email" ? "me@corp.test" : null, + })); + }); + + it("sends every selected branch and user, with 'me' resolved", async () => { + useGitLog.setState({ filters: { ...emptyFilters(), branches: ["main", "dev"], authors: [ME, "bob"] } }); + await reloadLog(); + const queries = vcsLog.mock.calls.map((c) => c[1] as { branches?: string[]; authors?: string[]; range: unknown }); + const page = queries.find((q) => q.range === null)!; + expect(page.branches).toEqual(["main", "dev"]); + expect(page.authors).toEqual(["me@corp.test", "bob"]); + }); +}); + +describe("unstash", () => { + it("names the operation after what it does", () => { + const base = { index: 2, pop: false, reinstateIndex: true, branch: null }; + expect(unstashTitles(base).title).toBe("Apply stash@{2}"); + expect(unstashTitles({ ...base, pop: true }).success).toBe("Unstashed stash@{2}"); + expect(unstashTitles({ ...base, branch: "fix" }).title).toBe("Unstash stash@{2} as branch fix"); + }); +}); diff --git a/src/features/vcs/__tests__/projectSwitch.test.ts b/src/features/vcs/__tests__/projectSwitch.test.ts index c6fe7845..935cb0cb 100644 --- a/src/features/vcs/__tests__/projectSwitch.test.ts +++ b/src/features/vcs/__tests__/projectSwitch.test.ts @@ -13,14 +13,14 @@ describe("switching the window to another repository", () => { disposers.push(installPreparedMessage(), installGitLog()); useCommit.setState({ message: "wip", amend: true, selection: ["/a/x"] }); useGitLog.setState({ commits: [{ id: "c1" } as never], selected: "c1", selection: ["c1"] }); - useGitLog.setState((s) => ({ filters: { ...s.filters, author: "me" } })); + useGitLog.setState((s) => ({ filters: { ...s.filters, authors: ["me"] } })); logConsole("cmd", "git status"); useVcs.setState({ root: "/b" }); expect(useCommit.getState()).toMatchObject({ message: "", amend: false, selection: [] }); expect(useGitLog.getState()).toMatchObject({ commits: [], selected: null, selection: [] }); - expect(useGitLog.getState().filters.author).toBe(""); + expect(useGitLog.getState().filters.authors).toEqual([]); expect(useVcsConsole.getState().lines).toEqual([]); }); }); diff --git a/src/features/vcs/actions.tsx b/src/features/vcs/actions.tsx index 8c6dba50..57c370ea 100644 --- a/src/features/vcs/actions.tsx +++ b/src/features/vcs/actions.tsx @@ -14,6 +14,7 @@ import { rollbackCandidates, showRollbackDialog, stashChanges, + showUnstashDialog, } from "./changeOps"; import { setAmend, useCommit } from "./commitStore"; import { gotoConflict, resolveAtCaret, showConflictsDialog } from "./conflictsUi"; @@ -174,7 +175,14 @@ export function registerVcsActions(): () => void { keywords: ["clone", "checkout", "git"], run: (c) => showCloneDialog(typeof c.arg === "string" ? c.arg : ""), }, - { id: "vcs.push", title: "Push…", category: "Git", icon: "git-push", when: VCS, run: ({ arg }) => void showPushDialog(arg && typeof arg === "object" ? (arg as PushTarget) : {}) }, + { + id: "vcs.push", + title: "Push…", + category: "Git", + icon: "git-push", + when: VCS, + run: ({ arg }) => void showPushDialog(arg && typeof arg === "object" ? (arg as PushTarget) : {}), + }, { id: "vcs.update", title: "Update Project…", @@ -354,7 +362,7 @@ export function registerVcsActions(): () => void { run: ({ arg }) => { const a = (arg ?? {}) as { branch?: string; path?: string }; useGitLog.setState({ tab: "log" }); - setLogFilters({ ...emptyFilters(), branch: a.branch ?? null, path: a.path ?? null }); + setLogFilters({ ...emptyFilters(), branches: a.branch ? [a.branch] : [], path: a.path ?? null }); showGitWindow(); }, }, @@ -408,7 +416,7 @@ export function registerVcsActions(): () => void { title: "Unstash Changes…", category: "Git", when: VCS, - run: () => showCommitWindow("stashes"), + run: () => showUnstashDialog(), }, { id: "vcs.resolveConflicts", diff --git a/src/features/vcs/branchMenu.ts b/src/features/vcs/branchMenu.ts index 14b24392..44609e92 100644 --- a/src/features/vcs/branchMenu.ts +++ b/src/features/vcs/branchMenu.ts @@ -6,6 +6,7 @@ import { isFavoriteBranch } from "./branches"; import { checkoutAndRebase, checkoutBranch, + checkoutDetached, deleteBranch, deleteRemoteBranch, loadFavoriteBranches, @@ -53,9 +54,7 @@ export function branchMenu(b: Branch, current: string | null, done: () => void = const items: MenuItem[] = [ { label: "Checkout", run: run(() => checkoutBranch(b.name)) }, { label: `New Branch from '${b.name}'…`, icon: "branch", run: run(() => newBranch(b.name)) }, - ...(current - ? [{ label: `Checkout and Rebase onto '${cur}'`, run: run(() => checkoutAndRebase(b.name)) }] - : []), + ...(current ? [{ label: `Checkout and Rebase onto '${cur}'`, run: run(() => checkoutAndRebase(b.name)) }] : []), { kind: "separator" }, { label: `Compare with '${cur}'`, icon: "diff", run: run(() => showCompareBranches(b.name)) }, { label: "Show Diff with Working Tree", run: run(() => showDiffWithWorkingTree(b.name)) }, @@ -83,3 +82,19 @@ export function branchMenu(b: Branch, current: string | null, done: () => void = ); return items; } + +/** Tag entries of the Branches popup. */ +export function tagMenu(tag: string, done: () => void = () => {}): MenuItem[] { + const run = (fn: () => unknown) => () => { + done(); + void fn(); + }; + return [ + { label: "Checkout", run: run(() => checkoutDetached(tag, tag)) }, + { label: `New Branch from '${tag}'…`, icon: "branch", run: run(() => newBranch(tag)) }, + { kind: "separator" }, + { label: "Compare with Current", icon: "diff", run: run(() => showCompareBranches(tag)) }, + { label: "Show Diff with Working Tree", run: run(() => showDiffWithWorkingTree(tag)) }, + { label: "Show History", icon: "history", run: run(() => executeAction("vcs.showLog", { branch: tag })) }, + ]; +} diff --git a/src/features/vcs/branchOps.ts b/src/features/vcs/branchOps.ts index 36392ea9..3f4802ef 100644 --- a/src/features/vcs/branchOps.ts +++ b/src/features/vcs/branchOps.ts @@ -75,6 +75,8 @@ async function performCheckout(rev: string, success: () => string): Promise call(commands.vcsCheckout(currentRepo(), rev, false, opts))); if (!outcome) return false; + // `success` may name the branch now checked out. + if (outcome.kind === "done" || outcome.kind === "conflicts") await refreshNow(); switch (outcome.kind) { case "done": notifications().notify({ group: "vcs", level: "info", title: success() }); diff --git a/src/features/vcs/branches.ts b/src/features/vcs/branches.ts index 8d3b4b06..6561e4ac 100644 --- a/src/features/vcs/branches.ts +++ b/src/features/vcs/branches.ts @@ -15,31 +15,28 @@ export type BranchGroup = "recent" | "local" | "remote"; export type BranchRow = | { type: "section"; key: string; label: string } | { type: "action"; key: string; action: PopupAction; matches: number[] | null } - | { type: "branch"; key: string; branch: Branch; group: BranchGroup; matches: number[] | null }; + | { + type: "branch"; + key: string; + branch: Branch; + group: BranchGroup; + matches: number[] | null; + favorite: boolean; + } + | { type: "tag"; key: string; tag: string; matches: number[] | null }; -const FAVORITES = ["main", "master", "develop", "trunk"]; - -export function sortLocal(branches: readonly Branch[]): Branch[] { - return branches - .filter((b) => !b.remote) - .sort((a, b) => { - if (a.current !== b.current) return a.current ? -1 : 1; - const fa = FAVORITES.indexOf(a.name); - const fb = FAVORITES.indexOf(b.name); - if (fa !== fb) return (fa < 0 ? 99 : fa) - (fb < 0 ? 99 : fb); - return a.name.localeCompare(b.name); - }); +/** Current branch first, then favorites, then the rest; by name within each. */ +function byRank(favorites: readonly string[] | null) { + const rank = (b: Branch) => (b.current ? 0 : isFavoriteBranch(b, favorites) ? 1 : 2); + return (a: Branch, b: Branch) => rank(a) - rank(b) || a.name.localeCompare(b.name); } -export function sortRemote(branches: readonly Branch[]): Branch[] { - return branches - .filter((b) => b.remote && !b.name.endsWith("/HEAD")) - .sort((a, b) => { - const fa = FAVORITES.indexOf(shortRemoteName(a.name)); - const fb = FAVORITES.indexOf(shortRemoteName(b.name)); - if (fa !== fb) return (fa < 0 ? 99 : fa) - (fb < 0 ? 99 : fb); - return a.name.localeCompare(b.name); - }); +export function sortLocal(branches: readonly Branch[], favorites: readonly string[] | null = null): Branch[] { + return branches.filter((b) => !b.remote).sort(byRank(favorites)); +} + +export function sortRemote(branches: readonly Branch[], favorites: readonly string[] | null = null): Branch[] { + return branches.filter((b) => b.remote && !b.name.endsWith("/HEAD")).sort(byRank(favorites)); } /** `origin/feature/x` → `feature/x`. */ @@ -52,15 +49,17 @@ export const MAX_RECENT = 5; /** * Rows for the Branches popup. Without a query: actions, Recent (recently - * checked-out local branches other than the current one), Local, Remote. - * With a query: matching actions and branches; the Recent group is dropped - * so each branch appears once. + * checked-out local branches other than the current one), Local, Remote, Tags. + * With a query: matching actions, branches and tags; the Recent group is dropped + * so each branch appears once. Favorites (★) come right after the current branch. */ export function buildBranchRows( branches: readonly Branch[], recent: readonly string[], query: string, actions: readonly PopupAction[], + favorites: readonly string[] | null = null, + tags: readonly string[] = [], ): BranchRow[] { const rows: BranchRow[] = []; const q = query.trim(); @@ -75,13 +74,20 @@ export function buildBranchRows( rows.push({ type: "action", key: `action:${a.id}`, action: a, matches: m }); } - const local = sortLocal(branches); + const local = sortLocal(branches, favorites); const pushGroup = (group: BranchGroup, label: string, list: readonly Branch[]) => { const items: BranchRow[] = []; for (const b of list) { const m = match(b.name); if (m === false) continue; - items.push({ type: "branch", key: `${group}:${b.name}`, branch: b, group, matches: m }); + items.push({ + type: "branch", + key: `${group}:${b.name}`, + branch: b, + group, + matches: m, + favorite: isFavoriteBranch(b, favorites), + }); } if (!items.length) return; rows.push({ type: "section", key: `section:${group}`, label }, ...items); @@ -96,7 +102,13 @@ export function buildBranchRows( pushGroup("recent", "Recent", rec); } pushGroup("local", "Local", local); - pushGroup("remote", "Remote", sortRemote(branches)); + pushGroup("remote", "Remote", sortRemote(branches, favorites)); + const tagRows: BranchRow[] = []; + for (const t of [...tags].sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))) { + const m = match(t); + if (m !== false) tagRows.push({ type: "tag", key: `tag:${t}`, tag: t, matches: m }); + } + if (tagRows.length) rows.push({ type: "section", key: "section:tags", label: "Tags" }, ...tagRows); return rows; } @@ -176,7 +188,10 @@ export function buildBranchTree(branches: readonly Branch[], favorites: readonly label: "Local", kind: "group", ref: null, - children: level(local.map((b) => ({ b, rest: b.name })), "f:local"), + children: level( + local.map((b) => ({ b, rest: b.name })), + "f:local", + ), }); if (byRemote.size) out.push({ @@ -189,7 +204,10 @@ export function buildBranchTree(branches: readonly Branch[], favorites: readonly label: r, kind: "remote", ref: null, - children: level(list.map((b) => ({ b, rest: b.name.slice(r.length + 1) })), `f:r:${r}`), + children: level( + list.map((b) => ({ b, rest: b.name.slice(r.length + 1) })), + `f:r:${r}`, + ), })), }); return out; diff --git a/src/features/vcs/changeOps.tsx b/src/features/vcs/changeOps.tsx index 831034cc..8b86eba8 100644 --- a/src/features/vcs/changeOps.tsx +++ b/src/features/vcs/changeOps.tsx @@ -2,13 +2,13 @@ import { useState } from "react"; import type { FileStatus, PartialCommitFile } from "@/bindings"; import { call, commands } from "@/platform/ipc"; -import { Checkbox, Dialog, Icon, fileIconName } from "@/ui"; +import { Checkbox, Dialog, Icon, Input, fileIconName } from "@/ui"; import { notifications } from "@/workbench/contributions"; import { commitRepos, groupOf, planCommit, renamedFromPath } from "./changes"; import { useChangelists, withListDefaults } from "./changelists"; import { runCommitChecks } from "./commitChecks"; import { forgetAmendDraft, rememberMessage, useCommit } from "./commitStore"; -import { confirmDialog, openDialog, promptText } from "./dialogs"; +import { confirmDialog, openDialog } from "./dialogs"; import { hunkKeys } from "./hunks"; import { KIND_COLOR, baseName, dirName, effectiveKind, relTo, rootOf, stripSlash } from "./model"; import { logConsole, runOp, saveAll } from "./ops"; @@ -16,7 +16,12 @@ import { currentRepo, loadStashes, refreshNow, useVcs } from "./store"; import styles from "./common.module.css"; export function firstLine(message: string): string { - return message.split("\n").find((l) => l.trim())?.trim() ?? ""; + return ( + message + .split("\n") + .find((l) => l.trim()) + ?.trim() ?? "" + ); } /** Stages the included changes and commits them. Returns the new commit id. */ @@ -29,7 +34,12 @@ export async function commitChanges(): Promise { await saveAll(); await refreshNow(); const status = useVcs.getState().status; - const checks = withListDefaults(status, useVcs.getState().root ?? "", useChangelists.getState().state, useCommit.getState().checks); + const checks = withListDefaults( + status, + useVcs.getState().root ?? "", + useChangelists.getState().state, + useCommit.getState().checks, + ); const plan = planCommit(status, checks); // Concluding a merge needs no further changes (e.g. every conflict resolved as "yours"). const merging = useVcs.getState().branch?.operation === "merge"; @@ -150,7 +160,10 @@ export function isNestedRepoEntry(path: string, roots: readonly string[] = useVc * sides (the old file is restored); added files become unversioned after * unstaging and are only deleted when asked to. */ -export function rollbackPlan(files: readonly FileStatus[], deleteAdded: boolean): { unstage: string[]; discard: string[] } { +export function rollbackPlan( + files: readonly FileStatus[], + deleteAdded: boolean, +): { unstage: string[]; discard: string[] } { const unstage: string[] = []; const discard: string[] = []; for (const f of files) { @@ -260,7 +273,11 @@ export function addToVcsTargets(paths: readonly string[], status: readonly FileS export async function addToVcs(paths: readonly string[]) { const targets = addToVcsTargets(paths, useVcs.getState().status); if (!targets.length) { - notifications().notify({ group: "vcs", level: "info", title: "Nothing to add: the files are already under version control" }); + notifications().notify({ + group: "vcs", + level: "info", + title: "Nothing to add: the files are already under version control", + }); return; } await runOp(`Add ${targets.length} file${targets.length === 1 ? "" : "s"} to Git`, () => @@ -268,31 +285,173 @@ export async function addToVcs(paths: readonly string[]) { ); } -export async function stashChanges() { - const r = await promptText({ - title: "Stash Changes", - label: "Message:", - placeholder: "WIP", - okLabel: "Create Stash", - validate: () => null, - checkbox: { label: "Include untracked files", initial: false }, +/** Stash Changes dialog: message, Keep index (staged changes stay), include untracked files. */ +export function stashChanges(): Promise { + return new Promise((resolve) => { + const close = openDialog(() => ( + { + close(); + if (r) { + await saveAll(); + await runOp( + "Stash changes", + () => call(commands.vcsStashPush(currentRepo(), r.message || null, r.includeUntracked, r.keepIndex)), + { success: "Changes stashed" }, + ); + void loadStashes().catch(() => undefined); + } + resolve(); + }} + /> + )); }); - if (!r) return; - await saveAll(); - await runOp("Stash changes", () => call(commands.vcsStashPush(currentRepo(), r.value || null, r.checked)), { - success: "Changes stashed", - }); - void loadStashes().catch(() => undefined); } -export async function applyStash(index: number, pop: boolean) { +function StashDialog({ + onDone, +}: { + onDone: (r: { message: string; keepIndex: boolean; includeUntracked: boolean } | null) => void; +}) { + const [message, setMessage] = useState(""); + const [keepIndex, setKeepIndex] = useState(false); + const [includeUntracked, setIncludeUntracked] = useState(false); + const branch = useVcs((s) => s.branch?.head ?? null); + const submit = () => onDone({ message: message.trim(), keepIndex, includeUntracked }); + return ( + onDone(null)} + width={440} + buttons={[ + { label: "Create Stash", role: "default", onClick: submit }, + { label: "Cancel", role: "cancel" }, + ]} + > + { + e.preventDefault(); + submit(); + }} + > + {branch &&
Current branch: {branch}
} + + setMessage(e.target.value)} + /> + + + +
+ ); +} + +export interface UnstashOptions { + index: number; + /** Drop the stash after applying it. */ + pop: boolean; + /** Also restore what was staged. */ + reinstateIndex: boolean; + /** Apply on a new branch created at the stash's base commit (implies pop and reinstate index). */ + branch: string | null; +} + +/** What an unstash does, for its progress title and success message. */ +export function unstashTitles(o: UnstashOptions): { title: string; success: string } { + const ref = `stash@{${o.index}}`; + if (o.branch) + return { title: `Unstash ${ref} as branch ${o.branch}`, success: `Unstashed ${ref} on branch ${o.branch}` }; + return o.pop + ? { title: `Pop ${ref}`, success: `Unstashed ${ref}` } + : { title: `Apply ${ref}`, success: `Applied ${ref}` }; +} + +export async function unstash(o: UnstashOptions) { await saveAll(); - await runOp(`${pop ? "Pop" : "Apply"} stash@{${index}}`, () => call(commands.vcsStashApply(currentRepo(), index, pop)), { - success: pop ? `Unstashed stash@{${index}}` : `Applied stash@{${index}}`, + const { title, success } = unstashTitles(o); + await runOp(title, () => call(commands.vcsStashApply(currentRepo(), o.index, o.pop, o.reinstateIndex, o.branch)), { + success, }); void loadStashes().catch((e) => logConsole("err", String(e))); } +export function applyStash(index: number, pop: boolean) { + return unstash({ index, pop, reinstateIndex: false, branch: null }); +} + +/** Unstash Changes dialog: pick a stash; Pop, Reinstate index, or As new branch. */ +export function showUnstashDialog(index = 0) { + void loadStashes().catch(() => undefined); + openDialog((close) => ); +} + +function UnstashDialog({ initial, onClose }: { initial: number; onClose: () => void }) { + const stashes = useVcs((s) => s.stashes); + const [index, setIndex] = useState(initial); + const [pop, setPop] = useState(false); + const [reinstateIndex, setReinstateIndex] = useState(false); + const [branch, setBranch] = useState(""); + const asBranch = branch.trim(); + const chosen = stashes.find((s) => s.index === index) ?? stashes[0]; + const submit = () => { + if (!chosen) return; + onClose(); + void unstash({ index: chosen.index, pop, reinstateIndex, branch: asBranch || null }); + }; + return ( + +
{ + e.preventDefault(); + submit(); + }} + > +
+ {stashes.length === 0 &&
No stashes
} + {stashes.map((s) => ( + + ))} +
+ + + + setBranch(e.target.value)} /> + +
+ ); +} + export async function dropStash(index: number, message: string) { const ok = await confirmDialog({ title: "Drop Stash", diff --git a/src/features/vcs/common.module.css b/src/features/vcs/common.module.css index 5f152961..2014098e 100644 --- a/src/features/vcs/common.module.css +++ b/src/features/vcs/common.module.css @@ -220,3 +220,9 @@ width: 220px; margin-left: 6px; } +.choiceList { + max-height: 240px; + overflow: auto; + border: 1px solid var(--border); + border-radius: var(--radius-small); +} diff --git a/src/features/vcs/conflicts.ts b/src/features/vcs/conflicts.ts index 6591743a..f51688bb 100644 --- a/src/features/vcs/conflicts.ts +++ b/src/features/vcs/conflicts.ts @@ -83,7 +83,11 @@ export type Resolution = "ours" | "theirs" | "both" | "base"; * Text replacing `block`. With `reversed` (a rebase, where git writes the upstream as * the `<<<<<<<` section and the user's commit as `>>>>>>>`) the sides swap, as IDEA does. */ -export function resolutionText(block: ConflictBlock, how: Resolution, reversed = false): string { +export function resolutionText( + block: Pick, + how: Resolution, + reversed = false, +): string { const yours = reversed ? block.theirs : block.ours; const theirs = reversed ? block.ours : block.theirs; switch (how) { @@ -133,6 +137,11 @@ export function mergeTitles(sides: MergeSides | null | undefined): { yours: stri yours: branch ? `Changes from ${branch}` : "Your version", theirs: other ? `Changes from ${other}` : commit ? `Changes from ${commit}` : "Changes from diverging branches", }; + case "unstash": + return { + yours: "Your uncommitted changes", + theirs: branch ? `Changes from ${branch}` : "Changes from remote", + }; case "cherry-pick": return { yours: "Local Changes", @@ -145,3 +154,39 @@ export function mergeTitles(sides: MergeSides | null | undefined): { yours: stri }; } } + +/** An unresolved conflict in the merge editor's result: `from`–`to` holds the base text. */ +export interface ResultConflict { + from: number; + to: number; + /** The user's side ("Accept Yours"). */ + ours: string; + base: string | null; + theirs: string; +} + +/** + * The merge editor's starting result, as IDEA shows it: the merged text with every + * conflict block replaced by its base section, so no markers appear. `reversed` swaps + * the sides of marker sections written in git's order (a rebase). + */ +export function initialResult(merged: string, reversed = false): { text: string; conflicts: ResultConflict[] } { + const conflicts: ResultConflict[] = []; + let text = ""; + let pos = 0; + for (const b of parseConflicts(merged)) { + text += merged.slice(pos, b.from); + const base = b.base ?? ""; + const from = text.length; + text += base; + conflicts.push({ + from, + to: text.length, + ours: reversed ? b.theirs : b.ours, + base: b.base, + theirs: reversed ? b.ours : b.theirs, + }); + pos = b.to; + } + return { text: text + merged.slice(pos), conflicts }; +} diff --git a/src/features/vcs/dialogs.tsx b/src/features/vcs/dialogs.tsx index 29305138..2c5d1545 100644 --- a/src/features/vcs/dialogs.tsx +++ b/src/features/vcs/dialogs.tsx @@ -129,3 +129,162 @@ export function confirmDialog(opts: ConfirmOptions): Promise { icon: opts.danger ? "warning" : "question", }); } + +export interface ChooseManyOptions { + title: string; + /** Values offered as checkboxes. */ + options: readonly string[]; + initial: readonly string[]; + /** Label of a free-text field for values not in `options` (comma-separated). */ + customLabel?: string; + okLabel?: string; +} + +/** Splits a comma-separated list, trimming blanks. */ +export function splitList(text: string): string[] { + return text + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +} + +/** Multi-select chooser (Log Branch / User filters › Select…); null when cancelled. */ +export function chooseMany(opts: ChooseManyOptions): Promise { + return new Promise((resolve) => { + const close = openDialog(() => ( + { + close(); + resolve(r); + }} + /> + )); + }); +} + +function ChooseManyDialog(props: ChooseManyOptions & { onDone: (r: string[] | null) => void }) { + const { title, options, initial, customLabel, okLabel = "OK", onDone } = props; + const [query, setQuery] = useState(""); + const [checked, setChecked] = useState(() => new Set(initial.filter((v) => options.includes(v)))); + const [custom, setCustom] = useState(() => initial.filter((v) => !options.includes(v)).join(", ")); + const q = query.trim().toLowerCase(); + const shown = q ? options.filter((o) => o.toLowerCase().includes(q)) : options; + const submit = () => onDone([...options.filter((o) => checked.has(o)), ...(customLabel ? splitList(custom) : [])]); + return ( + onDone(null)} + width={420} + buttons={[ + { label: okLabel, role: "default", onClick: submit }, + { label: "Cancel", role: "cancel" }, + ]} + > +
{ + e.preventDefault(); + submit(); + }} + > + setQuery(e.target.value)} + /> +
+ {shown.map((o) => ( +
+ + setChecked((s) => { + const n = new Set(s); + if (v) n.add(o); + else n.delete(o); + return n; + }) + } + /> +
+ ))} +
+ {customLabel && ( + <> + + setCustom(e.target.value)} /> + + )} +
+
+ ); +} + +/** Local date of an `` value (`2024-05-01`) as epoch ms at local midnight. */ +export function parseDay(value: string): number | null { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim()); + if (!m) return null; + return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])).getTime(); +} + +/** Date filter › Select…: `after` inclusive, `before` the day after the chosen "To" date. */ +export function chooseDateRange(): Promise<{ after: number | null; before: number | null } | null> { + return new Promise((resolve) => { + const close = openDialog(() => ( + { + close(); + resolve(r); + }} + /> + )); + }); +} + +function DateRangeDialog({ onDone }: { onDone: (r: { after: number | null; before: number | null } | null) => void }) { + const [from, setFrom] = useState(""); + const [to, setTo] = useState(""); + const after = parseDay(from); + const toDay = parseDay(to); + const before = toDay == null ? null : new Date(toDay).setDate(new Date(toDay).getDate() + 1); + const error = + after == null && before == null + ? "Choose a start or end date" + : after != null && before != null && after >= before + ? "The start date is after the end date" + : null; + const submit = () => { + if (!error) onDone({ after, before }); + }; + return ( + onDone(null)} + width={360} + buttons={[ + { label: "OK", role: "default", disabled: !!error, onClick: submit }, + { label: "Cancel", role: "cancel" }, + ]} + > +
{ + e.preventDefault(); + submit(); + }} + > + + setFrom(e.target.value)} /> + + setTo(e.target.value)} /> + {(from || to) && error &&
{error}
} +
+
+ ); +} diff --git a/src/features/vcs/logStore.ts b/src/features/vcs/logStore.ts index beb575d6..9fc58704 100644 --- a/src/features/vcs/logStore.ts +++ b/src/features/vcs/logStore.ts @@ -7,17 +7,28 @@ import { computeGraph, emptyGraphState, type GraphRow, type GraphState } from ". import { clearConsole, errorMessage } from "./ops"; import { currentRepo, repoOf, selectRepo, useVcs } from "./store"; +/** Date filter: commits made after / before the given times (epoch ms); applied client-side. */ +export interface LogDateFilter { + after: number | null; + before: number | null; + label: string; +} + +/** User filter entry standing for the current Git user (`user.name` / `user.email`). */ +export const ME = "me"; + export interface LogFilters { - branch: string | null; - author: string; + /** Branches / revisions to show; empty = all. */ + branches: string[]; + /** Author names or emails (any matches); `ME` = the current user. */ + authors: string[]; text: string; /** `text` is a regular expression / matches case-sensitively. */ regex: boolean; matchCase: boolean; /** Absolute path (file or directory). */ path: string | null; - /** Only commits newer than `ms` (epoch); applied client-side. */ - since: { ms: number; label: string } | null; + date: LogDateFilter | null; } interface LogState { @@ -43,15 +54,57 @@ interface LogState { export const PAGE = 300; export const emptyFilters = (): LogFilters => ({ - branch: null, - author: "", + branches: [], + authors: [], text: "", regex: false, matchCase: false, path: null, - since: null, + date: null, }); +/** Any filter other than the text field's toggles is set. */ +export function hasFilters(f: LogFilters): boolean { + return !!(f.branches.length || f.authors.length || f.text || f.path || f.date); +} + +/** `ME` replaced by the current user's email (or name); duplicates and blanks dropped. */ +export function resolveAuthors(authors: readonly string[], me: string | null): string[] { + const out = authors.map((a) => (a === ME ? (me ?? "") : a).trim()).filter(Boolean); + return [...new Set(out)]; +} + +/** The commit's time falls inside the date filter. */ +export function inDateRange(timeMs: number, d: LogDateFilter | null): boolean { + if (!d) return true; + return (d.after == null || timeMs >= d.after) && (d.before == null || timeMs < d.before); +} + +/** Label of a custom date range: `after 2024-01-01`, `until …`, or `a – b` (`before` is exclusive). */ +export function dateRangeLabel(after: number | null, before: number | null): string { + const day = (ms: number) => { + const d = new Date(ms); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + }; + const lastDay = (ms: number) => day(new Date(ms).setDate(new Date(ms).getDate() - 1)); + if (after != null && before != null) return `${day(after)} – ${lastDay(before)}`; + if (after != null) return `after ${day(after)}`; + if (before != null) return `until ${lastDay(before)}`; + return ""; +} + +let me: { repo: string | null; value: Promise } | null = null; + +/** The current Git user for the "me" filter: `user.email`, else `user.name`. */ +function currentUser(): Promise { + const repo = currentRepo(); + if (me?.repo !== repo) { + const get = (key: string) => call(commands.vcsConfigGet(repo, key)).catch(() => null); + me = { repo, value: get("user.email").then(async (email) => email || (await get("user.name"))) }; + } + return me.value; +} + const initialLog = (): Omit => ({ filters: emptyFilters(), commits: [], @@ -158,11 +211,18 @@ export function loadMore(): Promise { useGitLog.setState({ loading: true }); const p = (async () => { try { + const authors = resolveAuthors(f.authors, f.authors.includes(ME) ? await currentUser() : null); + if (f.authors.length && !authors.length) { + if (useGitLog.getState().generation === gen) useGitLog.setState({ done: true }); + return; + } const page = await call( commands.vcsLog(currentRepo(), { - branch: f.branch, + branch: null, + branches: f.branches, + authors, path: f.path, - author: f.author.trim() || null, + author: null, text: f.text.trim() || null, textRegex: f.regex, textMatchCase: f.matchCase, @@ -247,7 +307,7 @@ const MAX_SEARCH_PAGES = 30; export async function revealCommit(id: string) { useGitLog.setState({ tab: "log" }); const f = useGitLog.getState().filters; - const filtered = f.branch || f.author || f.text || f.path || f.since; + const filtered = hasFilters(f); const find = () => useGitLog.getState().commits.find((c) => c.id === id || c.id.startsWith(id)); if (filtered) { useGitLog.setState({ filters: emptyFilters() }); diff --git a/src/platform/editorSettings.ts b/src/platform/editorSettings.ts index b06fb007..1545d945 100644 --- a/src/platform/editorSettings.ts +++ b/src/platform/editorSettings.ts @@ -60,7 +60,7 @@ export const EDITOR_EXTRA_DEFAULTS = { caretScroll: "minimize", softWrapFiles: "*.md; *.txt; *.rst; *.adoc", softWrapIndent: true, - scrollPastEnd: true, + scrollPastEnd: false, stripTrailingScope: "modified", keepTrailingOnCaretLine: true, removeTrailingBlankLines: false, @@ -112,7 +112,7 @@ export const EDITOR_EXTRA_DEFAULTS = { pairQuotes: true, surroundSelection: true, tabOutOfPair: true, - reformatOnPaste: "indentBlock", + reformatOnPaste: "indentEachLine", smartIndent: true, backspaceUnindent: "autoindent", }, diff --git a/src/platform/keymap.test.ts b/src/platform/keymap.test.ts index 58b9fe45..82b12c24 100644 --- a/src/platform/keymap.test.ts +++ b/src/platform/keymap.test.ts @@ -1,5 +1,14 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { formatBinding, installKeyboardDispatcher, isAltGrTyping, normalizeBinding, strokeOf, useKeymap } from "./keymap"; +import { + formatBinding, + installKeyboardDispatcher, + isAltGrTyping, + isValidBinding, + normalizeBinding, + parseDoubleModifier, + strokeOf, + useKeymap, +} from "./keymap"; import { executeAction, registerAction } from "./actions"; import { usePlatform } from "./platformInfo"; @@ -167,6 +176,68 @@ describe("keymap", () => { }); }); + describe("double modifier with a key", () => { + let uninstall = () => {}; + afterEach(() => uninstall()); + + it("parses, validates and formats the gesture", () => { + expect(parseDoubleModifier("alt alt+up")).toEqual({ modifier: "Alt", key: "up" }); + expect(parseDoubleModifier("Ctrl Ctrl")).toEqual({ modifier: "Ctrl", key: null }); + expect(parseDoubleModifier("Alt Ctrl+Up")).toBeNull(); + expect(isValidBinding("Alt Alt+Up")).toBe(true); + expect(normalizeBinding("alt ALT+Down")).toBe("Alt Alt+Down"); + expect(formatBinding("Alt Alt+Up", true)).toBe("⌥⌥↑"); + expect(formatBinding("Ctrl Ctrl+Up", false)).toBe("Ctrl, Ctrl+Up"); + }); + + function setup() { + const above = vi.fn(); + const anything = vi.fn(); + registerAction({ id: "t.cloneAbove", title: "clone above", run: above }); + registerAction({ id: "t.ctrlCtrl", title: "ctrl ctrl", run: anything }); + useKeymap.setState({ overrides: { "t.cloneAbove": ["Alt Alt+Up"], "t.ctrlCtrl": ["Ctrl Ctrl"] } }); + let t = 1000; + uninstall = installKeyboardDispatcher(window, () => t); + const mod = (type: "keydown" | "keyup", key: "Alt" | "Control", ms: number) => { + t = ms; + const held = type === "keydown"; + window.dispatchEvent(new KeyboardEvent(type, { key, code: `${key}Left`, altKey: key === "Alt" && held, ctrlKey: key === "Control" && held })); + }; + const up = (ms: number) => { + t = ms; + const e = new KeyboardEvent("keydown", { key: "ArrowUp", code: "ArrowUp", altKey: true, cancelable: true }); + window.dispatchEvent(e); + return e; + }; + return { above, anything, mod, up }; + } + + it("runs while the second press is held, once per key press", async () => { + const { above, mod, up } = setup(); + mod("keydown", "Alt", 1000); + mod("keyup", "Alt", 1100); + mod("keydown", "Alt", 1200); + expect(up(1500).defaultPrevented).toBe(true); + up(1700); + mod("keyup", "Alt", 1800); + await Promise.resolve(); + expect(above).toHaveBeenCalledTimes(2); + // A single press is plain Alt+Up again. + mod("keydown", "Alt", 3000); + expect(up(3100).defaultPrevented).toBe(false); + }); + + it("fires a bare double tap of another modifier on its second release", async () => { + const { anything, mod } = setup(); + mod("keydown", "Control", 1000); + mod("keyup", "Control", 1050); + mod("keydown", "Control", 1150); + mod("keyup", "Control", 1200); + await Promise.resolve(); + expect(anything).toHaveBeenCalledOnce(); + }); + }); + describe("AltGr", () => { let uninstall = () => {}; afterEach(() => { diff --git a/src/platform/keymap.ts b/src/platform/keymap.ts index 81caa76f..a5aa7acb 100644 --- a/src/platform/keymap.ts +++ b/src/platform/keymap.ts @@ -3,7 +3,8 @@ * * Binding syntax: modifiers `Meta Ctrl Alt Shift` joined with `+` to a key, e.g. * `Meta+Shift+O`, `Alt+F7`, `F8`. Space separates chord strokes - * (`Meta+K Meta+C`). The special binding `Shift Shift` is IDEA's double-Shift. + * (`Meta+K Meta+C`). A modifier pressed twice is `Shift Shift` (IDEA's double + * Shift, `Ctrl Ctrl`…); pressed twice and held, then a key, `Alt Alt+Up`. * Keys are matched by physical position (`KeyboardEvent.code`), so ⌥-letter * combos work on macOS where `event.key` would be a symbol. */ @@ -66,11 +67,24 @@ export const useKeymap = create(() => ({ preset: defaultPreset(), o const STROKE_RE = /^(?:(?:Ctrl|Alt|Shift|Meta)\+)*[^\s+]+$|^(?:(?:Ctrl|Alt|Shift|Meta)\+)*\+$/i; -/** Syntactic check: one or two strokes of `Mod+…+Key`, or `Shift Shift`. */ +const MODIFIERS = ["Ctrl", "Alt", "Shift", "Meta"] as const; +export type Modifier = (typeof MODIFIERS)[number]; +const DOUBLE_RE = /^(Ctrl|Alt|Shift|Meta) (Ctrl|Alt|Shift|Meta)(?:\+([^\s+]+|\+))?$/i; +const modifierNamed = (name: string) => MODIFIERS.find((m) => m.toLowerCase() === name.toLowerCase())!; + +/** A double-modifier gesture: `Shift Shift` (key null) or `Alt Alt+Up`. */ +export function parseDoubleModifier(binding: string): { modifier: Modifier; key: string | null } | null { + const m = DOUBLE_RE.exec(binding.trim()); + if (!m || m[1]!.toLowerCase() !== m[2]!.toLowerCase()) return null; + const key = m[3] ?? null; + return { modifier: modifierNamed(m[1]!), key: key && key.length === 1 ? key.toUpperCase() : key }; +} + +/** Syntactic check: one or two strokes of `Mod+…+Key`, or a double-modifier gesture. */ export function isValidBinding(binding: unknown): binding is string { if (typeof binding !== "string") return false; const b = binding.trim(); - if (b === "Shift Shift") return true; + if (parseDoubleModifier(b)) return true; const strokes = b.split(/\s+/); return strokes.length >= 1 && strokes.length <= 2 && strokes.every((s) => STROKE_RE.test(s)); } @@ -131,15 +145,16 @@ export function effectiveBindings(): Array<[string, string]> { return Object.entries(merged).flatMap(([id, keys]) => keys.map((k) => [normalizeBinding(k), id] as [string, string])); } -const MOD_ORDER = ["Ctrl", "Alt", "Shift", "Meta"] as const; +const MOD_ORDER = MODIFIERS; /** Canonical form: modifiers in fixed order, so `Shift+Meta+o` == `Meta+Shift+O`. */ export function normalizeBinding(binding: string): string { + const double = parseDoubleModifier(binding); + if (double) return `${double.modifier} ${double.modifier}${double.key ? `+${double.key}` : ""}`; return binding .trim() .split(/\s+/) .map((stroke) => { - if (stroke === "Shift" || stroke === "Shift Shift") return stroke; const parts = stroke.split("+"); const key = parts.pop()!; const mods = MOD_ORDER.filter((m) => parts.some((p) => p.toLowerCase() === m.toLowerCase())); @@ -220,12 +235,13 @@ const SYMBOLS: Record = { }; /** - * Human display. macOS: `Meta+Shift+O` → `⇧⌘O`, `Shift Shift` → `⇧⇧`. - * Windows/Linux: `Ctrl+Shift+N`, chords as `Ctrl+K, Ctrl+I`, `Shift, Shift`. + * Human display. macOS: `Meta+Shift+O` → `⇧⌘O`, `Shift Shift` → `⇧⇧`, `Alt Alt+Up` → `⌥⌥↑`. + * Windows/Linux: `Ctrl+Shift+N`, chords as `Ctrl+K, Ctrl+I`, `Shift, Shift`, `Ctrl, Ctrl+Up`. */ export function formatBinding(binding: string, mac = isMacHost()): string { - if (!mac) return binding === "Shift Shift" ? "Shift, Shift" : normalizeBinding(binding).split(" ").join(", "); - if (binding === "Shift Shift") return "⇧⇧"; + if (!mac) return normalizeBinding(binding).split(" ").join(", "); + const double = parseDoubleModifier(binding); + if (double) return SYMBOLS[double.modifier]!.repeat(2) + (double.key ? (SYMBOLS[double.key] ?? double.key) : ""); return normalizeBinding(binding) .split(" ") .map((stroke) => { @@ -249,9 +265,18 @@ export function shortcutLabel(actionId: string): string | undefined { const CHORD_TIMEOUT = 2000; /** IDEA's double-modifier timing: each press/release step. */ const DOUBLE_TAP_STEP_MS = 300; -/** A Shift tap this soon after typing another key is part of typing, not a gesture. */ +/** A modifier tap this soon after typing another key is part of typing, not a gesture. */ const DOUBLE_TAP_TYPING_GUARD_MS = 100; +const KEY_MODIFIER: Record = { Shift: "Shift", Control: "Ctrl", Alt: "Alt", Meta: "Meta" }; + +function modifierHeld(e: Pick, m: Modifier): boolean { + return m === "Ctrl" ? e.ctrlKey : m === "Alt" ? e.altKey : m === "Shift" ? e.shiftKey : e.metaKey; +} + +const otherModifiers = (e: Pick, m: Modifier) => + MODIFIERS.some((x) => x !== m && modifierHeld(e, x)); + function isPlainTextField(target: EventTarget | null): boolean { const el = target as HTMLElement | null; if (!el || !el.tagName) return false; @@ -293,44 +318,67 @@ function runIfEnabled(id: string): boolean { /** * Installs the global keydown dispatcher (capture phase, so IDE shortcuts win * over CodeMirror defaults). Returns an uninstall function. `now` is the clock - * for the double-Shift gesture. + * for the double-modifier gestures. */ export function installKeyboardDispatcher(target: Window = window, now: () => number = () => performance.now()): () => void { let pending: string[] = []; let pendingTimer: ReturnType | undefined; - // Double Shift: 0 idle, 1 pressed, 2 released, 3 pressed again; fires on the second release. - let shiftStep = 0; - let shiftAt = 0; + /** + * Double modifier: step 1 pressed, 2 released, 3 pressed again. A bare + * gesture (`Shift Shift`) fires on the second release; while the second + * press is held, keys fire their `Alt Alt+Up` gestures. + */ + let tap: { modifier: Modifier; step: 1 | 2 | 3; at: number; typed: boolean; keyUsed: boolean } | null = null; let otherKeyAt = -Infinity; const reset = () => { pending = []; clearTimeout(pendingTimer); }; - const resetShift = () => { - shiftStep = 0; + const resetTap = () => { + tap = null; }; - const onShiftDown = (e: KeyboardEvent) => { + const onModifierDown = (e: KeyboardEvent, modifier: Modifier) => { const t = now(); - if (e.repeat || e.metaKey || e.ctrlKey || e.altKey || t - otherKeyAt < DOUBLE_TAP_TYPING_GUARD_MS) return resetShift(); - if (shiftStep !== 0 && t - shiftAt > DOUBLE_TAP_STEP_MS) resetShift(); - if (shiftStep === 0 || shiftStep === 2) { - shiftStep++; - shiftAt = t; - } else { - resetShift(); + if (e.repeat && tap?.step === 3 && tap.modifier === modifier) return; + if (e.repeat || otherModifiers(e, modifier)) return resetTap(); + const typed = t - otherKeyAt < DOUBLE_TAP_TYPING_GUARD_MS; + if (tap && (tap.modifier !== modifier || t - tap.at > DOUBLE_TAP_STEP_MS)) resetTap(); + if (!tap) tap = { modifier, step: 1, at: t, typed, keyUsed: false }; + else if (tap.step === 2) tap = { ...tap, step: 3, at: t, typed: tap.typed || typed }; + else resetTap(); + }; + + /** A key pressed while the second press of a modifier is held: its `Alt Alt+Up` gesture. */ + const runHeldGesture = (e: KeyboardEvent): boolean => { + if (tap?.step !== 3 || !modifierHeld(e, tap.modifier) || otherModifiers(e, tap.modifier)) return false; + const key = strokeOf(e)?.split("+").pop(); + if (!key) return false; + const binding = normalizeBinding(`${tap.modifier} ${tap.modifier}+${key}`); + const ids = effectiveBindings().filter(([b]) => b === binding).map(([, id]) => id); + for (const id of ids.reverse()) { + const action = getAction(id); + if (!action || !isActionKeyEnabled(action)) continue; + e.preventDefault(); + e.stopPropagation(); + tap.keyUsed = true; + void executeAction(id); + return true; } + return false; }; const onKeyDown = (e: KeyboardEvent) => { if (suspended > 0) { reset(); - resetShift(); + resetTap(); return; } - if (e.key === "Shift") return onShiftDown(e); - resetShift(); + const modifier = KEY_MODIFIER[e.key]; + if (modifier) return onModifierDown(e, modifier); + if (runHeldGesture(e)) return reset(); + resetTap(); otherKeyAt = e.key === "Escape" || e.key === "Tab" ? -Infinity : now(); if (e.isComposing) return; const stroke = strokeOf(e); @@ -374,27 +422,27 @@ export function installKeyboardDispatcher(target: Window = window, now: () => nu }; const onKeyUp = (e: KeyboardEvent) => { - if (suspended > 0 || e.key !== "Shift" || shiftStep === 0) return; + const modifier = KEY_MODIFIER[e.key]; + if (suspended > 0 || !modifier || !tap || tap.modifier !== modifier) return; const t = now(); - if (e.metaKey || e.ctrlKey || e.altKey || t - shiftAt > DOUBLE_TAP_STEP_MS) return resetShift(); - if (shiftStep === 1) { - shiftStep = 2; - shiftAt = t; + if (otherModifiers(e, modifier) || t - tap.at > DOUBLE_TAP_STEP_MS) return resetTap(); + if (tap.step === 1) { + tap = { ...tap, step: 2, at: t }; return; } - const fire = shiftStep === 3; - resetShift(); + const fire = tap.step === 3 && !tap.keyUsed && !tap.typed; + resetTap(); if (!fire) return; - const ids = effectiveBindings().filter(([b]) => b === "Shift Shift").map(([, id]) => id); + const ids = effectiveBindings().filter(([b]) => b === `${modifier} ${modifier}`).map(([, id]) => id); for (const id of ids.reverse()) if (runIfEnabled(id)) return; }; // Shift+click (extend a selection) is not half of a double Shift. - const onMouseDown = () => resetShift(); + const onMouseDown = () => resetTap(); const onBlur = () => { reset(); - resetShift(); + resetTap(); }; target.addEventListener("keydown", onKeyDown, true); diff --git a/src/platform/keymaps/ideaParity.test.ts b/src/platform/keymaps/ideaParity.test.ts index f60c61ee..5faf6f26 100644 --- a/src/platform/keymaps/ideaParity.test.ts +++ b/src/platform/keymaps/ideaParity.test.ts @@ -36,6 +36,17 @@ describe("IDEA keymap parity", () => { ["project.copy", ["Meta+C"], ["Ctrl+C", "Ctrl+Insert"]], ["project.cut", ["Meta+X"], ["Ctrl+X", "Shift+Delete"]], ["project.paste", ["Meta+V"], ["Ctrl+V", "Shift+Insert"]], + ["editor.matchBrace", ["Ctrl+M"], ["Ctrl+Shift+M"]], + ["code.collapseRecursively", ["Meta+Alt+-"], ["Ctrl+Alt+-"]], + ["code.expandRecursively", ["Meta+Alt+="], ["Ctrl+Alt+="]], + ["editor.cloneCaretAbove", ["Alt Alt+Up"], ["Ctrl Ctrl+Up"]], + ["editor.cloneCaretBelow", ["Alt Alt+Down"], ["Ctrl Ctrl+Down"]], + ["editor.toggleInsertState", ["Insert"], ["Insert"]], + ["navigate.nextOccurrence", ["Meta+Alt+Down"], ["Ctrl+Alt+Down"]], + ["navigate.prevOccurrence", ["Meta+Alt+Up"], ["Ctrl+Alt+Up"]], + // Rerun in the Find / Find Usages tool windows uses the Rerun shortcut. + ["find.rerun", ["Meta+R"], ["Ctrl+F5"]], + ["usages.rerun", ["Meta+R"], ["Ctrl+F5"]], ])("%s", (id, mac, win) => { expect(intellijMacos[id]).toEqual(mac); expect(intellijWindows[id]).toEqual(win); @@ -56,5 +67,6 @@ describe("IDEA keymap parity", () => { order(km, "code.fold", "project.collapseAll"); order(km, "navigate.nextError", "vcs.changelist.edit"); order(km, "debug.setValue", "vcs.changelist.edit"); + order(km, "find.replaceInFile", "find.rerun"); }); }); diff --git a/src/platform/keymaps/intellij-macos.ts b/src/platform/keymaps/intellij-macos.ts index a6f60897..dd377dbc 100644 --- a/src/platform/keymaps/intellij-macos.ts +++ b/src/platform/keymaps/intellij-macos.ts @@ -28,6 +28,27 @@ export const intellijMacos: Record = { "navigate.typeHierarchy": ["Ctrl+H"], "navigate.toggleBookmark": ["F3"], "navigate.showBookmarks": ["Meta+F3"], + "navigate.toggleBookmarkWithMnemonic": ["Alt+F3"], + "navigate.toggleBookmark0": ["Ctrl+Shift+0"], + "navigate.toggleBookmark1": ["Ctrl+Shift+1"], + "navigate.toggleBookmark2": ["Ctrl+Shift+2"], + "navigate.toggleBookmark3": ["Ctrl+Shift+3"], + "navigate.toggleBookmark4": ["Ctrl+Shift+4"], + "navigate.toggleBookmark5": ["Ctrl+Shift+5"], + "navigate.toggleBookmark6": ["Ctrl+Shift+6"], + "navigate.toggleBookmark7": ["Ctrl+Shift+7"], + "navigate.toggleBookmark8": ["Ctrl+Shift+8"], + "navigate.toggleBookmark9": ["Ctrl+Shift+9"], + "navigate.gotoBookmark0": ["Ctrl+0"], + "navigate.gotoBookmark1": ["Ctrl+1"], + "navigate.gotoBookmark2": ["Ctrl+2"], + "navigate.gotoBookmark3": ["Ctrl+3"], + "navigate.gotoBookmark4": ["Ctrl+4"], + "navigate.gotoBookmark5": ["Ctrl+5"], + "navigate.gotoBookmark6": ["Ctrl+6"], + "navigate.gotoBookmark7": ["Ctrl+7"], + "navigate.gotoBookmark8": ["Ctrl+8"], + "navigate.gotoBookmark9": ["Ctrl+9"], "navigate.selectIn": ["Alt+F1"], "navigate.lastEditLocation": ["Meta+Shift+Backspace"], "navigate.nextEditLocation": [], @@ -35,6 +56,8 @@ export const intellijMacos: Record = { "navigate.superMethod": ["Meta+U"], "navigate.nextMethod": ["Ctrl+Shift+Down"], "navigate.previousMethod": ["Ctrl+Shift+Up"], + "navigate.nextOccurrence": ["Meta+Alt+Down"], + "navigate.prevOccurrence": ["Meta+Alt+Up"], // Find "find.inFile": ["Meta+F"], @@ -54,6 +77,7 @@ export const intellijMacos: Record = { "code.quickDefinition": ["Alt+Space", "Meta+Y"], "code.showIntentions": ["Alt+Enter"], "code.reformat": ["Meta+Alt+L"], + "code.reformatDialog": ["Meta+Alt+Shift+L"], "code.optimizeImports": ["Ctrl+Alt+O"], "code.commentLine": ["Meta+/"], "code.commentBlock": ["Meta+Alt+/", "Ctrl+Shift+/"], @@ -72,6 +96,8 @@ export const intellijMacos: Record = { "code.unfold": ["Meta+="], "code.foldAll": ["Meta+Shift+-"], "code.unfoldAll": ["Meta+Shift+="], + "code.collapseRecursively": ["Meta+Alt+-"], + "code.expandRecursively": ["Meta+Alt+="], "code.addCaretNextOccurrence": ["Ctrl+G"], "code.selectAllOccurrences": ["Ctrl+Meta+G"], "code.unselectOccurrence": ["Ctrl+Shift+G"], @@ -83,9 +109,11 @@ export const intellijMacos: Record = { "editor.scrollToCenter": ["Ctrl+L"], "editor.codeBlockStart": ["Meta+Alt+["], "editor.codeBlockEnd": ["Meta+Alt+]"], - // IDEA clones carets with ⌥⌥↑ / ⌥⌥↓ (double modifier); no single-stroke default. - "editor.cloneCaretAbove": [], - "editor.cloneCaretBelow": [], + // ⌥ pressed twice and held, then ↑ / ↓. + "editor.cloneCaretAbove": ["Alt Alt+Up"], + "editor.cloneCaretBelow": ["Alt Alt+Down"], + "editor.matchBrace": ["Ctrl+M"], + "editor.toggleInsertState": ["Insert"], "code.joinLines": ["Ctrl+Shift+J"], "code.toggleCase": ["Meta+Shift+U"], "code.pasteFromHistory": ["Meta+Shift+V"], @@ -184,6 +212,9 @@ export const intellijMacos: Record = { // Rerun in the Run / Debug tool window only (when / keyWhen); Meta+R stays with the editor elsewhere. "run.rerun": ["Meta+R"], "debug.rerun": ["Meta+R"], + // Rerun in the Find / Find Usages tool windows only (when). + "find.rerun": ["Meta+R"], + "usages.rerun": ["Meta+R"], "run.chooseConfig": ["Ctrl+Alt+R"], "run.editConfigs": ["Ctrl+Alt+Shift+R"], "debug.toggleBreakpoint": ["Meta+F8"], @@ -283,4 +314,10 @@ export const intellijMacos: Record = { "terminal.new": ["Meta+T"], // Last: later bindings win, so ⌘N is Generate in the editor and New in tool windows. "code.generate": ["Meta+N"], + // Find in Files popup (findPopupOpen): after the editor's Find Next, Jump to Source and + // selection keys, so these win while the popup is open. + "find.popup.nextResult": ["Meta+G"], + "find.popup.previousResult": ["Meta+Shift+G"], + "find.popup.openResult": ["F4", "Meta+Down"], + "find.popup.recentSearches": ["Alt+Down"], }; diff --git a/src/platform/keymaps/intellij-windows.ts b/src/platform/keymaps/intellij-windows.ts index cef0c102..3a923c87 100644 --- a/src/platform/keymaps/intellij-windows.ts +++ b/src/platform/keymaps/intellij-windows.ts @@ -29,6 +29,27 @@ export const intellijWindows: Record = { "navigate.typeHierarchy": ["Ctrl+H"], "navigate.toggleBookmark": ["F11"], "navigate.showBookmarks": ["Shift+F11"], + "navigate.toggleBookmarkWithMnemonic": ["Ctrl+F11"], + "navigate.toggleBookmark0": ["Ctrl+Shift+0"], + "navigate.toggleBookmark1": ["Ctrl+Shift+1"], + "navigate.toggleBookmark2": ["Ctrl+Shift+2"], + "navigate.toggleBookmark3": ["Ctrl+Shift+3"], + "navigate.toggleBookmark4": ["Ctrl+Shift+4"], + "navigate.toggleBookmark5": ["Ctrl+Shift+5"], + "navigate.toggleBookmark6": ["Ctrl+Shift+6"], + "navigate.toggleBookmark7": ["Ctrl+Shift+7"], + "navigate.toggleBookmark8": ["Ctrl+Shift+8"], + "navigate.toggleBookmark9": ["Ctrl+Shift+9"], + "navigate.gotoBookmark0": ["Ctrl+0"], + "navigate.gotoBookmark1": ["Ctrl+1"], + "navigate.gotoBookmark2": ["Ctrl+2"], + "navigate.gotoBookmark3": ["Ctrl+3"], + "navigate.gotoBookmark4": ["Ctrl+4"], + "navigate.gotoBookmark5": ["Ctrl+5"], + "navigate.gotoBookmark6": ["Ctrl+6"], + "navigate.gotoBookmark7": ["Ctrl+7"], + "navigate.gotoBookmark8": ["Ctrl+8"], + "navigate.gotoBookmark9": ["Ctrl+9"], "navigate.selectIn": ["Alt+F1"], "navigate.lastEditLocation": ["Ctrl+Shift+Backspace"], "navigate.nextEditLocation": [], @@ -36,6 +57,8 @@ export const intellijWindows: Record = { "navigate.superMethod": ["Ctrl+U"], "navigate.nextMethod": ["Alt+Down"], "navigate.previousMethod": ["Alt+Up"], + "navigate.nextOccurrence": ["Ctrl+Alt+Down"], + "navigate.prevOccurrence": ["Ctrl+Alt+Up"], // Find "find.inFile": ["Ctrl+F"], @@ -55,6 +78,7 @@ export const intellijWindows: Record = { "code.quickDefinition": ["Ctrl+Shift+I"], "code.showIntentions": ["Alt+Enter"], "code.reformat": ["Ctrl+Alt+L"], + "code.reformatDialog": ["Ctrl+Alt+Shift+L"], "code.optimizeImports": ["Ctrl+Alt+O"], "code.commentLine": ["Ctrl+/"], "code.commentBlock": ["Ctrl+Shift+/"], @@ -73,6 +97,8 @@ export const intellijWindows: Record = { "code.unfold": ["Ctrl+="], "code.foldAll": ["Ctrl+Shift+-"], "code.unfoldAll": ["Ctrl+Shift+="], + "code.collapseRecursively": ["Ctrl+Alt+-"], + "code.expandRecursively": ["Ctrl+Alt+="], "code.addCaretNextOccurrence": ["Alt+J"], "code.selectAllOccurrences": ["Ctrl+Alt+Shift+J"], "code.unselectOccurrence": ["Alt+Shift+J"], @@ -84,9 +110,11 @@ export const intellijWindows: Record = { "editor.scrollToCenter": ["Ctrl+M"], "editor.codeBlockStart": ["Ctrl+["], "editor.codeBlockEnd": ["Ctrl+]"], - // IDEA clones carets with Ctrl, Ctrl+Up / Down (double modifier); no single-stroke default. - "editor.cloneCaretAbove": [], - "editor.cloneCaretBelow": [], + // Ctrl pressed twice and held, then Up / Down. + "editor.cloneCaretAbove": ["Ctrl Ctrl+Up"], + "editor.cloneCaretBelow": ["Ctrl Ctrl+Down"], + "editor.matchBrace": ["Ctrl+Shift+M"], + "editor.toggleInsertState": ["Insert"], "code.joinLines": ["Ctrl+Shift+J"], "code.toggleCase": ["Ctrl+Shift+U"], "code.pasteFromHistory": ["Ctrl+Shift+V"], @@ -182,6 +210,9 @@ export const intellijWindows: Record = { // Rerun in the Run / Debug tool window only (when / keyWhen); Ctrl+F5 stays with the editor elsewhere. "run.rerun": ["Ctrl+F5"], "debug.rerun": ["Ctrl+F5"], + // Rerun in the Find / Find Usages tool windows only (when). + "find.rerun": ["Ctrl+F5"], + "usages.rerun": ["Ctrl+F5"], "run.chooseConfig": ["Alt+Shift+F10"], "run.editConfigs": [], "debug.toggleBreakpoint": ["Ctrl+F8"], @@ -280,4 +311,10 @@ export const intellijWindows: Record = { "terminal.new": ["Ctrl+Shift+T"], // Last: later bindings win, so Alt+Insert is Generate in the editor and New in tool windows. "code.generate": ["Alt+Insert"], + // Find in Files popup (findPopupOpen): after the editor's Find Next, Jump to Source and + // selection keys, so these win while the popup is open. + "find.popup.nextResult": ["F3", "Ctrl+L"], + "find.popup.previousResult": ["Shift+F3", "Ctrl+Shift+L"], + "find.popup.openResult": ["F4"], + "find.popup.recentSearches": ["Alt+Down"], }; diff --git a/src/platform/keymaps/vscode-macos.ts b/src/platform/keymaps/vscode-macos.ts index 4f5df335..86e61b64 100644 --- a/src/platform/keymaps/vscode-macos.ts +++ b/src/platform/keymaps/vscode-macos.ts @@ -29,6 +29,27 @@ export const vscodeMacos: Record = { "navigate.typeHierarchy": [], "navigate.toggleBookmark": [], "navigate.showBookmarks": [], + "navigate.toggleBookmarkWithMnemonic": [], + "navigate.toggleBookmark0": [], + "navigate.toggleBookmark1": [], + "navigate.toggleBookmark2": [], + "navigate.toggleBookmark3": [], + "navigate.toggleBookmark4": [], + "navigate.toggleBookmark5": [], + "navigate.toggleBookmark6": [], + "navigate.toggleBookmark7": [], + "navigate.toggleBookmark8": [], + "navigate.toggleBookmark9": [], + "navigate.gotoBookmark0": [], + "navigate.gotoBookmark1": [], + "navigate.gotoBookmark2": [], + "navigate.gotoBookmark3": [], + "navigate.gotoBookmark4": [], + "navigate.gotoBookmark5": [], + "navigate.gotoBookmark6": [], + "navigate.gotoBookmark7": [], + "navigate.gotoBookmark8": [], + "navigate.gotoBookmark9": [], "navigate.selectIn": [], "navigate.lastEditLocation": [], "navigate.nextEditLocation": [], @@ -36,6 +57,8 @@ export const vscodeMacos: Record = { "navigate.superMethod": [], "navigate.nextMethod": [], "navigate.previousMethod": [], + "navigate.nextOccurrence": [], + "navigate.prevOccurrence": [], // Find "find.inFile": ["Meta+F"], @@ -56,6 +79,7 @@ export const vscodeMacos: Record = { "code.showIntentions": ["Meta+."], "code.generate": [], "code.reformat": ["Alt+Shift+F"], + "code.reformatDialog": [], "code.optimizeImports": ["Alt+Shift+O"], "code.commentLine": ["Meta+/"], "code.commentBlock": ["Alt+Shift+A"], @@ -73,6 +97,8 @@ export const vscodeMacos: Record = { "code.fold": ["Meta+Alt+["], "code.unfold": ["Meta+Alt+]"], "code.foldAll": ["Meta+K Meta+0"], + "code.collapseRecursively": ["Meta+K Meta+["], + "code.expandRecursively": ["Meta+K Meta+]"], "code.unfoldAll": ["Meta+K Meta+J"], "code.addCaretNextOccurrence": ["Meta+D"], "code.selectAllOccurrences": ["Meta+Shift+L"], @@ -128,6 +154,8 @@ export const vscodeMacos: Record = { "editor.codeBlockEnd": [], "editor.cloneCaretAbove": ["Meta+Alt+Up"], "editor.cloneCaretBelow": ["Meta+Alt+Down"], + "editor.matchBrace": ["Meta+Shift+\\"], + "editor.toggleInsertState": [], // Explorer (when: projectViewFocus) "project.newFile": [], @@ -200,6 +228,8 @@ export const vscodeMacos: Record = { "debug.jumpToSource": [], "debug.evaluate": [], "debug.rerun": [], + "find.rerun": [], + "usages.rerun": [], // VCS "vcs.operations": [], @@ -274,4 +304,9 @@ export const vscodeMacos: Record = { "db.sqlScript": [], "db.goToReferencedTable": [], "db.collapseAll": [], + // Find in Files popup (findPopupOpen): after the editor's keys, so these win while it is open. + "find.popup.nextResult": ["Meta+G"], + "find.popup.previousResult": ["Meta+Shift+G"], + "find.popup.openResult": [], + "find.popup.recentSearches": ["Alt+Down"], }; diff --git a/src/platform/keymaps/vscode-windows.ts b/src/platform/keymaps/vscode-windows.ts index 931d15bd..18d46284 100644 --- a/src/platform/keymaps/vscode-windows.ts +++ b/src/platform/keymaps/vscode-windows.ts @@ -25,6 +25,8 @@ export const vscodeWindows: Record = { "find.replaceInFile": ["Ctrl+H"], "find.next": ["F3"], "find.previous": ["Shift+F3"], + "find.popup.nextResult": ["F3"], + "find.popup.previousResult": ["Shift+F3"], "code.extendSelection": ["Alt+Shift+Right"], "code.shrinkSelection": ["Alt+Shift+Left"], "code.fold": ["Ctrl+Shift+["], diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index ad97b4aa..d38d056a 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -109,6 +109,7 @@ "Approve…": "批准…", "Arguments": "实参", "Arrow function": "箭头函数", + "As new branch:": "作为新分支:", "As typed": "按输入", "Ask": "询问", "Asks for a value when the tool runs": "工具运行时询问一个值", @@ -130,6 +131,7 @@ "Auto-update if push of the current branch was rejected": "当前分支推送被拒绝时自动更新", "Autosave": "自动保存", "Autoscroll from Source": "从源代码自动滚动", + "Autoscroll to Source": "自动滚动到源代码", "Available": "可用", "Available plugins": "可用插件", "Back": "后退", @@ -260,7 +262,9 @@ "Children": "子项", "Choose Content to Paste": "选择要粘贴的内容", "Choose a data source for this console": "为此控制台选择数据源", + "Choose a start or end date": "请选择开始或结束日期", "Choose configuration to debug": "选择要调试的配置", + "Choose configuration to run": "选择要运行的配置", "Choose the Statement to Unwrap/Remove": "选择要解包/移除的语句", "Choose which call on the current line to step into": "选择要步入当前行中的哪个调用", "Class": "类", @@ -354,6 +358,7 @@ "Compare with Branch or Revision…": "与分支或修订比较…", "Compare with Branch…": "与分支比较…", "Compare with Clipboard": "与剪贴板比较", + "Compare with Current": "与当前比较", "Compare with Local": "与本地比较", "Compilation failed: fix the errors and reload again.": "编译失败:请修复错误后重新加载。", "Compiles and runs the project: the default jdtls runtime, Run/Debug, and JAVA_HOME for Maven and Gradle.": "用于编译和运行项目:jdtls 的默认运行时、运行/调试,以及 Maven 和 Gradle 的 JAVA_HOME。", @@ -365,6 +370,7 @@ "Configurations": "配置", "Configure Editor Tabs…": "配置编辑器标签页…", "Configure Indents…": "配置缩进…", + "Configure Keymap…": "配置按键映射…", "Configure…": "配置…", "Confirm Drop": "确认删除", "Confirm before exiting the IDE": "退出 IDE 前确认", @@ -405,6 +411,7 @@ "Copy Path": "复制路径", "Copy Path from Project Root": "复制项目根目录起的路径", "Copy Path/Reference…": "复制路径/引用…", + "Copy Problem Description": "复制问题描述", "Copy Reference": "复制引用", "Copy Revision Number": "复制修订号", "Copy Revision Numbers": "复制修订号", @@ -437,6 +444,7 @@ "Coverage needs the desktop app": "覆盖率需要桌面应用", "Coverage suites": "覆盖率套件", "Create": "创建", + "Create Branch": "创建分支", "Create Draft": "创建草稿", "Create New Branch": "创建新分支", "Create New Test…": "创建新测试…", @@ -720,6 +728,8 @@ "Filter": "过滤", "Filter TODO Items": "过滤 TODO 项", "Filter branches": "过滤分支", + "Filter by Branch": "按分支过滤", + "Filter by Date": "按日期过滤", "Filter by Path": "按路径过滤", "Filter by User": "按用户过滤", "Filter by message": "按消息过滤", @@ -782,6 +792,7 @@ "Frames": "帧", "From Template…": "从模板…", "From settings.xml, else ~/.m2/repository": "取自 settings.xml,否则为 ~/.m2/repository", + "From:": "从:", "Function": "函数", "Function component": "函数组件", "Function with return type": "带返回类型的函数", @@ -824,6 +835,7 @@ "Greyscale": "灰度", "Group": "组", "Group by Directory": "按目录分组", + "Group by Severity": "按严重性分组", "Groups": "组", "Gutter Icons": "装订区域图标", "HACK comment": "HACK 注释", @@ -864,6 +876,7 @@ "HotSwap: compile and reload the changed classes into the running Java debug session": "HotSwap:编译已更改的类并重新加载到正在运行的 Java 调试会话中", "Hovering a variable while suspended evaluates it in the selected frame.": "挂起时悬停变量会在所选帧中计算它。", "IDE Updates": "IDE 更新", + "IDE and Project Settings": "IDE 和项目设置", "IDE font": "IDE 字体", "IDE font size": "IDE 字体大小", "Installed {name}": "已安装 {name}", @@ -890,6 +903,11 @@ "Incoming commits": "传入的提交", "Indent": "缩进", "Indent Block": "缩进块", + "Indent Each Line": "缩进每一行", + "Collapse Recursively": "递归折叠", + "Expand Recursively": "递归展开", + "Move Caret to Matching Brace": "将文本光标移至匹配的大括号", + "Toggle Insert/Overwrite": "切换插入/改写", "Indentation": "缩进", "Indents Detection": "缩进检测", "Index": "索引", @@ -959,6 +977,7 @@ "Just Restart": "仅重启", "Keep": "保留", "Keep Column": "保留列", + "Keep index": "保留索引", "Keep local history for": "本地历史记录保留时长", "Keep trailing spaces on caret line": "保留光标所在行的尾随空格", "Key pair": "密钥对", @@ -1117,6 +1136,7 @@ "Name:": "名称:", "Named scope": "命名范围", "Names (e.g. .DS_Store, *.pyc) hidden from the project tree, navigation bar, search, indexing and Git changes.": "从项目树、导航栏、搜索、索引和 Git 更改中隐藏的名称(例如 .DS_Store、*.pyc)。", + "Narrow down on typing": "输入时缩小列表范围", "Navigate": "导航", "Navigation": "导航", "Navigation Bar": "导航栏", @@ -1226,6 +1246,7 @@ "No run or test configuration of the project matches this file.": "项目中没有与此文件匹配的运行或测试配置。", "No search results": "没有搜索结果", "No special permissions.": "没有特殊权限。", + "No stashes": "没有储藏", "No structure to show": "没有可显示的结构", "No surround templates for this language": "此语言没有包围模板", "No tools match.": "没有匹配的工具。", @@ -1258,6 +1279,7 @@ "Offset 0x": "偏移 0x", "Older output is discarded": "较早的输出将被丢弃", "Only in comments": "仅在注释中", + "Only one usage in Project Files": "项目文件中只有一个用法", "Open": "打开", "Open Diff": "打开差异", "Open File": "打开文件", @@ -1309,6 +1331,8 @@ "Other": "其他", "Other File Types": "其他文件类型", "Other hints (chaining, closing labels…)": "其他提示(链式调用、结束标签…)", + "Other names or emails (comma-separated):": "其他名称或电子邮件(逗号分隔):", + "Other revisions (comma-separated):": "其他修订(逗号分隔):", "Otherwise line breakpoints can only be set in files of languages that have a debugger.": "否则只能在有调试器的语言文件中设置行断点。", "Outgoing commits": "传出的提交", "Output": "输出", @@ -1334,6 +1358,10 @@ "Passed as -s (mirrors, servers, proxies).": "以 -s 传入(镜像、服务器、代理)。", "Passed to rustfmt when reformatting, e.g. --edition 2024": "重新格式化时传递给 rustfmt,例如 --edition 2024", "Passed to the JVM running jdtls (java.jdt.ls.vmargs), e.g. -Xmx2g. A Lombok agent is added automatically when the project uses Lombok.": "传递给运行 jdtls 的 JVM (java.jdt.ls.vmargs),例如 -Xmx2g。项目使用 Lombok 时会自动添加 Lombok 代理。", + "Pop stash": "弹出储藏", + "Preview Source": "预览源代码", + "Reinstate index": "恢复索引", + "Rerun Search": "重新运行搜索", "Right Bottom": "右下", "Right Top": "右上", "Runs jdtls only, like IDEA's JDK for importer; the project keeps compiling and running with the Project JDK. jdtls needs JDK 21 or newer (recent releases 25). The jdtls executable itself is set in Language Servers.": "仅用于运行 jdtls(类似 IDEA 的「导入器 JDK」);项目仍使用项目 JDK 编译和运行。jdtls 需要 JDK 21 或更高版本(较新版本需要 25)。jdtls 可执行文件本身在「语言服务器」中设置。", @@ -1642,6 +1670,16 @@ "Restore Size": "恢复大小", "Restore detected roots": "恢复检测到的根", "Result": "结果", + "Select a problem to see its details": "选择一个问题以查看详情", + "Select an item to preview": "选择一项以预览", + "Severity": "严重性", + "Show Quick Fixes": "显示快速修复", + "Sort By": "排序依据", + "Tags": "标签", + "The start date is after the end date": "开始日期晚于结束日期", + "To:": "到:", + "Unstash Changes": "取消储藏更改", + "Unstash…": "取消储藏…", "result": "个结果", "results": "个结果", "Results": "结果", @@ -1855,6 +1893,7 @@ "Show Log": "显示日志", "Show Memory Indicator Details": "显示内存指示器详细信息", "Show Passed": "显示已通过", + "Show Preview": "显示预览", "Show Raw": "显示原始内容", "Show Replace": "显示替换", "Show Run/Debug window when a process is started": "进程启动时显示运行/调试窗口", @@ -2353,6 +2392,7 @@ "with NO BOM": "不带 BOM", "with statement": "with 语句", "{count} rows exported": "已导出 {count} 行", + "{index} of {total}": "第 {index} 个,共 {total} 个", "{name} failed": "{name}失败", "{name} has unsaved changes": "{name} 有未保存的更改", "{name} installed": "{name} 已安装", @@ -2451,5 +2491,32 @@ "Show Commit Message History": "显示提交消息历史记录", "Don't warn about this target": "不再对此目标发出警告", "Target branch": "目标分支", - "Checked out {name}": "已签出 {name}" + "Checked out {name}": "已签出 {name}", + "Pin Window": "固定窗口", + "Search Context": "搜索上下文", + "Anywhere": "任意位置", + "In Comments": "在注释中", + "In String Literals": "在字符串字面量中", + "Except Comments": "注释除外", + "Except String Literals": "字符串字面量除外", + "Except Comments and String Literals": "注释和字符串字面量除外", + "Recent Replacements": "最近的替换", + "No recent replacements": "没有最近的替换", + "↵ Replace Selected · ⇧⌥↵ Replace All · ⌘↵ Open in Find Window": "↵ 替换所选 · ⇧⌥↵ 全部替换 · ⌘↵ 在查找窗口中打开", + "Replace {count} Occurrences": "替换 {count} 处", + "Add Mnemonic Bookmark": "添加助记符书签", + "Change Mnemonic": "更改助记符", + "Assign Mnemonic": "分配助记符", + "Assigned bookmark": "已分配的书签", + "Current bookmark": "当前书签", + "No bookmarks": "没有书签", + "Edit Bookmark Description": "编辑书签描述", + "Toggle Bookmark with Mnemonic": "使用助记符切换书签", + "Toggle Bookmark {n}": "切换书签 {n}", + "Go to Bookmark {n}": "转到书签 {n}", + "Select In": "选择位置", + "Select In…": "选择位置…", + "Next Result": "下一个结果", + "Previous Result": "上一个结果", + "Open Result": "打开结果" } diff --git a/src/platform/workflowSettings.ts b/src/platform/workflowSettings.ts index 0c0760c0..e606396c 100644 --- a/src/platform/workflowSettings.ts +++ b/src/platform/workflowSettings.ts @@ -73,6 +73,7 @@ export interface DebuggerSettingsValid { export interface RunSettingsValid { confirmRerun: boolean; + confirmTerminate: boolean; showOnStart: boolean; terminationTimeoutMs: number; } @@ -211,7 +212,7 @@ export const DEFAULT_WORKFLOW: WorkflowSettings = { skipLibraryCode: true, python: { attachSubprocess: true, gevent: false }, }, - run: { confirmRerun: true, showOnStart: true, terminationTimeoutMs: 3000 }, + run: { confirmRerun: true, confirmTerminate: true, showOnStart: true, terminationTimeoutMs: 3000 }, terminal: { startDirectory: "", env: {}, @@ -341,6 +342,7 @@ export function validateRun(raw: unknown): RunSettingsValid { const v = obj(raw); return { confirmRerun: bool(v.confirmRerun, d.confirmRerun), + confirmTerminate: bool(v.confirmTerminate, d.confirmTerminate), showOnStart: bool(v.showOnStart, d.showOnStart), terminationTimeoutMs: num(v.terminationTimeoutMs, d.terminationTimeoutMs, WORKFLOW_LIMITS.terminationTimeoutMs, true), }; diff --git a/src/ui/List.tsx b/src/ui/List.tsx index 6b70219e..d8ef96ff 100644 --- a/src/ui/List.tsx +++ b/src/ui/List.tsx @@ -23,6 +23,8 @@ export interface ListProps { activeIndex?: number; defaultActiveIndex?: number; onActiveChange?: (index: number) => void; + /** Rows selected besides the active one (multi-selection); drawn like the active row. */ + isSelected?: (item: T, index: number) => boolean; onActivate?: (item: T, index: number, e: KeyboardEvent | MouseEvent) => void; onContextMenu?: (item: T, index: number, e: MouseEvent) => void; rowHeight?: number; @@ -102,6 +104,7 @@ export function List({ activeIndex, defaultActiveIndex, onActiveChange, + isSelected, onActivate, onContextMenu, rowHeight = 24, @@ -187,15 +190,16 @@ export function List({ ); } const isActive = v.index === active; + const selected = isActive || (isSelected?.(item, v.index) ?? false); return (
setActive(v.index) : undefined} onMouseDown={(e) => { diff --git a/src/ui/SourcePreview.module.css b/src/ui/SourcePreview.module.css new file mode 100644 index 00000000..dfb99aab --- /dev/null +++ b/src/ui/SourcePreview.module.css @@ -0,0 +1,55 @@ +.preview { + /* offsetParent for the scroll-to-line math. */ + position: relative; + flex: 1; + min-height: 0; + height: 100%; + overflow: auto; + background: var(--bg-app); + font-family: var(--font-mono); + font-size: var(--fs-ui); + line-height: 19px; + color: var(--fg); +} +.empty { + display: flex; + align-items: center; + justify-content: center; + color: var(--fg-faint); + font-family: var(--font-ui); +} +.pre { + margin: 0; + padding: 4px 0; + font: inherit; + min-width: max-content; +} +.codeLine { + display: flex; + white-space: pre; +} +.currentLine { + background: var(--bg-current-line); +} +.gutter { + flex: none; + position: sticky; + left: 0; + width: 48px; + padding-right: 12px; + text-align: right; + color: var(--fg-gutter); + background-color: var(--bg-app); +} +.currentLine .gutter { + color: var(--fg-muted); + background-color: var(--bg-current-line); +} +.code { + padding-right: 16px; +} +.match { + background: var(--accent-soft); + color: var(--fg-strong); + border-radius: 2px; +} diff --git a/src/ui/SourcePreview.tsx b/src/ui/SourcePreview.tsx new file mode 100644 index 00000000..15d0d2a7 --- /dev/null +++ b/src/ui/SourcePreview.tsx @@ -0,0 +1,111 @@ +/** + * Read-only source view around a line, with line numbers and highlighted + * ranges: the preview pane beside result trees (TODO, Problems, Inspection + * Results…). The caller supplies the text loader (open buffer or disk). + */ +import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import clsx from "clsx"; +import { T } from "./T"; +import styles from "./SourcePreview.module.css"; + +export interface SourceHighlight { + line: number; + /** UTF-16 column ranges in the line. */ + ranges: ReadonlyArray; +} + +/** Lines rendered on each side of the current one. */ +const WINDOW = 300; + +export interface SourcePreviewProps { + path: string | null; + /** 0-based line to centre and mark as current. */ + line: number; + highlights?: readonly SourceHighlight[]; + /** Stable (module-level) loader; a new function reloads. */ + load: (path: string) => Promise; + /** Bump to reload the same path. */ + version?: number; + className?: string; +} + +export function SourcePreview({ path, line, highlights = [], load, version = 0, className }: SourcePreviewProps) { + const [text, setText] = useState<{ path: string; lines: string[] } | null>(null); + const [error, setError] = useState(null); + const scrollRef = useRef(null); + const current = text && text.path === path ? text : null; + + useEffect(() => { + if (!path) return; + let alive = true; + // Debounced so arrowing through results doesn't load every file. + const t = setTimeout(() => { + load(path).then( + (content) => { + if (!alive) return; + setText({ path, lines: content.split(/\r\n|\r|\n/) }); + setError(null); + }, + (e) => alive && setError(String((e as Error)?.message ?? e)), + ); + }, 80); + return () => { + alive = false; + clearTimeout(t); + }; + }, [path, version, load]); + + useLayoutEffect(() => { + const box = scrollRef.current; + const el = box?.querySelector(`[data-line="${line}"]`); + if (box && el) box.scrollTop = el.offsetTop - box.clientHeight / 2 + el.offsetHeight / 2; + }, [current, line]); + + if (!path) + return ( +
+ Select an item to preview +
+ ); + if (error && !current) return
{error}
; + if (!current) return
; + + const byLine = new Map(highlights.map((h) => [h.line, h.ranges])); + const start = Math.max(0, line - WINDOW); + const end = Math.min(current.lines.length, line + WINDOW); + const rows: ReactNode[] = []; + for (let n = start; n < end; n++) { + const content = current.lines[n] ?? ""; + const ranges = byLine.get(n); + rows.push( +
+ {n + 1} + {ranges ? highlight(content, ranges) : content || " "} +
, + ); + } + return ( +
+
{rows}
+
+ ); +} + +function highlight(text: string, ranges: ReadonlyArray): ReactNode[] { + const parts: ReactNode[] = []; + let pos = 0; + [...ranges] + .sort((a, b) => a[0] - b[0]) + .forEach(([s, e], i) => { + if (s < pos || e <= s) return; + if (s > pos) parts.push(text.slice(pos, s)); + parts.push( + + {text.slice(s, e)} + , + ); + pos = e; + }); + if (pos < text.length) parts.push(text.slice(pos)); + return parts; +} diff --git a/src/ui/Tree.test.tsx b/src/ui/Tree.test.tsx index be257f57..bbfca7a4 100644 --- a/src/ui/Tree.test.tsx +++ b/src/ui/Tree.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; -import { fireEvent, render, screen, within } from "@testing-library/react"; -import { Tree } from "./Tree"; +import { createRef } from "react"; +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { Tree, type TreeHandle } from "./Tree"; interface N { id: string; @@ -78,6 +79,16 @@ describe("Tree keyboard navigation", () => { expect(onActivate).toHaveBeenCalledWith(expect.objectContaining({ id: "README.md" }), expect.anything()); }); + it("F4 and ⌘↓ activate like Enter (Jump to Source)", () => { + const { key, onActivate } = setup(); + key("End"); + key("F4"); + expect(onActivate).toHaveBeenCalledTimes(1); + key("ArrowDown", { metaKey: true }); + expect(onActivate).toHaveBeenCalledTimes(2); + expect(onActivate).toHaveBeenLastCalledWith(expect.objectContaining({ id: "README.md" }), expect.anything()); + }); + it("extends the selection with Shift+Arrow", () => { const onSelectionChange = vi.fn(); const { key } = setup({ defaultExpanded: ["src"], onSelectionChange }); @@ -121,6 +132,22 @@ describe("Tree speed search", () => { expect(tree.querySelectorAll("mark")).toHaveLength(0); }); + it("Home / End jump to the first / last match; Left closes the search and collapses", () => { + const { tree, lead, key } = setup({ defaultExpanded: ["src", "main"] }); + key("ArrowDown"); + key("t"); + key("s"); + // "ts" matches App.tsx, index.ts and util.ts. + key("End"); + expect(lead()).toBe("util.ts"); + key("Home"); + expect(lead()).toBe("App.tsx"); + key("ArrowLeft"); + expect(screen.queryByRole("status")).toBeNull(); + expect(lead()).toBe("main"); + expect(labels(tree)).toContain("App.tsx"); + }); + it("filter mode hides non-matching rows but keeps ancestors", () => { const { tree, key } = setup({ defaultExpanded: ["src", "main"], speedSearchMode: "filter" }); key("ArrowDown"); @@ -129,16 +156,56 @@ describe("Tree speed search", () => { }); }); +describe("Tree occurrences", () => { + it("selects the next / previous leaf, expanding its ancestors", () => { + const handle = createRef(); + const onSelectionChange = vi.fn(); + const { tree, lead } = setup({ handleRef: handle, onSelectionChange }); + let step: ReturnType = null; + act(() => void (step = handle.current!.selectOccurrence(1))); + expect(step).toMatchObject({ key: "App.tsx", index: 1, total: 4 }); + expect(labels(tree)).toEqual(["src", "main", "App.tsx", "index.ts", "util.ts", "README.md"]); + expect(lead()).toBe("App.tsx"); + expect(onSelectionChange).toHaveBeenLastCalledWith( + new Set(["App.tsx"]), + expect.objectContaining({ id: "App.tsx" }), + ); + act(() => void (step = handle.current!.selectOccurrence(1))); + expect(step).toMatchObject({ key: "index.ts", index: 2 }); + act(() => void (step = handle.current!.selectOccurrence(-1))); + expect(step).toMatchObject({ key: "App.tsx", index: 1 }); + act(() => void (step = handle.current!.selectOccurrence(-1))); + expect(step).toBeNull(); + expect(lead()).toBe("App.tsx"); + }); + + it("starts from the selected group and honours isOccurrence", () => { + const handle = createRef(); + const { key } = setup({ handleRef: handle, isOccurrence: (n) => n.id !== "src" }); + key("ArrowDown"); // src + let step: ReturnType = null; + act(() => void (step = handle.current!.selectOccurrence(1))); + expect(step).toMatchObject({ key: "main", index: 1, total: 5 }); + act(() => void (step = handle.current!.selectOccurrence(-1))); + expect(step).toBeNull(); + }); + + it("skips the lead's subtree on request", () => { + const handle = createRef(); + const { key } = setup({ handleRef: handle, defaultExpanded: ["src"] }); + key("ArrowDown"); + key("ArrowDown"); // main + let step: ReturnType = null; + act(() => void (step = handle.current!.selectOccurrence(1, { skipSubtree: true }))); + expect(step).toMatchObject({ key: "util.ts", index: 3 }); + }); +}); + describe("siblingPositions", () => { it("numbers rows among siblings", async () => { const { siblingPositions } = await import("./Tree"); expect( - siblingPositions([ - { parentKey: null }, - { parentKey: "a" }, - { parentKey: "a" }, - { parentKey: null }, - ]), + siblingPositions([{ parentKey: null }, { parentKey: "a" }, { parentKey: "a" }, { parentKey: null }]), ).toEqual([ [1, 2], [1, 2], diff --git a/src/ui/Tree.tsx b/src/ui/Tree.tsx index 73d0f436..a38096b5 100644 --- a/src/ui/Tree.tsx +++ b/src/ui/Tree.tsx @@ -1,9 +1,10 @@ /** * Virtualized tree with lazy children, multi-selection, IDEA keyboard model - * (↑↓←→, Enter, Home/End, PageUp/PageDown, Shift-extend) and speed search: - * typing while the tree is focused highlights matches and jumps to them - * (↑/↓ cycle matches, Esc clears); `speedSearchMode="filter"` hides rows - * that neither match nor contain a match. + * (↑↓←→, Enter / F4 / ⌘↓, Home/End, PageUp/PageDown, Shift-extend) and speed + * search: typing while the tree is focused highlights matches and jumps to them + * (↑/↓ cycle matches, Home/End first / last match, Esc clears; ←/→, Enter and + * PageUp/PageDown close it and act as usual); `speedSearchMode="filter"` hides + * rows that neither match nor contain a match. */ import { useCallback, @@ -39,6 +40,14 @@ export interface TreeRowState { matches: number[] | null; } +/** Where Next / Previous Occurrence landed: the selected node and its 1-based position among all occurrences. */ +export interface TreeOccurrence { + key: string; + node: unknown; + index: number; + total: number; +} + export interface TreeHandle { focus(): void; /** Selects `key` (must be visible, i.e. ancestors expanded) and scrolls to it. */ @@ -46,6 +55,13 @@ export interface TreeHandle { leadKey(): string | null; /** Selects the next (1) / previous (-1) row, for a filter field that keeps the focus (Settings search). */ moveSelection(dir: 1 | -1): void; + /** + * Next / Previous Occurrence: selects the next (1) / previous (-1) node that + * `isOccurrence` accepts (default: leaves) after the lead, in tree order over + * the loaded nodes, expanding its collapsed ancestors. `null` when there is none. + * `skipSubtree`: forward, ignore the lead's descendants (after excluding a group). + */ + selectOccurrence(dir: 1 | -1, options?: { skipSubtree?: boolean }): TreeOccurrence | null; } export interface TreeProps { @@ -77,6 +93,8 @@ export interface TreeProps { /** Enter / double-click on a leaf (or on nodes `activateExpandable` accepts). */ onActivate?: (node: T, e: KeyboardEvent | MouseEvent) => void; activateExpandable?: boolean | ((node: T) => boolean); + /** Nodes Next / Previous Occurrence stops at (`TreeHandle.selectOccurrence`); default: leaves. */ + isOccurrence?: (node: T) => boolean; onContextMenu?: (node: T | null, selection: Set, e: MouseEvent) => void; speedSearch?: boolean; @@ -273,6 +291,32 @@ export function Tree(props: TreeProps) { selectIndex(i); virtualizer.scrollToIndex(i); }, + selectOccurrence: (dir: 1 | -1, options?: { skipSubtree?: boolean }) => { + const found = findOccurrence( + roots, + lead, + dir, + { getKey, getChildren, isExpandable, isOccurrence: props.isOccurrence }, + options?.skipSubtree, + ); + if (!found) return null; + const missing = found.ancestors.filter((a) => !expanded.has(getKey(a))); + if (missing.length) { + const next = new Set(expanded); + for (const a of missing) { + next.add(getKey(a)); + props.onExpandedChange?.(new Set(next), a, true); + } + if (!expandedControlled) setExpandedInner(next); + } + const key = getKey(found.node); + const sel = new Set([key]); + setAnchor(key); + if (!selectedControlled) setSelectedInner(sel); + setLead(key); + props.onSelectionChange?.(sel, found.node); + return { key, node: found.node, index: found.index, total: found.total }; + }, })); const findMatch = (from: number, dir: 1 | -1, inclusive: boolean, q = query, wrap = true): number => { @@ -318,6 +362,15 @@ export function Tree(props: TreeProps) { return; } const range = e.shiftKey && multiSelect; + // Jump to Source (F4 / ⌘↓) activates like Enter. + if (e.key === "F4" || (e.key === "ArrowDown" && e.metaKey && !e.shiftKey && !e.altKey)) { + if (!row) return; + setQuery(""); + activate(row, e); + e.preventDefault(); + e.stopPropagation(); + return; + } switch (e.key) { case "Escape": if (!query) return; @@ -341,12 +394,14 @@ export function Tree(props: TreeProps) { break; } case "ArrowRight": + setQuery(""); if (!row) return; if (row.expandable && !row.expanded) setExpanded(row, true); else if (row.expanded && rows[leadIndex + 1]?.parentKey === row.key && !rows[leadIndex + 1]!.loading) selectIndex(leadIndex + 1); break; case "ArrowLeft": + setQuery(""); if (!row) return; if (row.expanded) setExpanded(row, false); else if (row.parentKey != null) { @@ -355,15 +410,20 @@ export function Tree(props: TreeProps) { } break; case "Home": - selectIndex(step(-1, 1), range ? "range" : "single"); - break; - case "End": - selectIndex(step(rows.length, -1), range ? "range" : "single"); + case "End": { + const first = e.key === "Home"; + if (query) { + const hit = findMatch(-1, first ? 1 : -1, true, query, false); + if (hit >= 0) selectIndex(hit); + } else selectIndex(first ? step(-1, 1) : step(rows.length, -1), range ? "range" : "single"); break; + } case "PageDown": + setQuery(""); selectIndex(Math.min(rows.length - 1, Math.max(0, leadIndex) + pageSize), range ? "range" : "single"); break; case "PageUp": + setQuery(""); selectIndex(Math.max(0, leadIndex - pageSize), range ? "range" : "single"); break; case "Enter": @@ -542,3 +602,42 @@ export function siblingPositions( rows.forEach((r, i) => (out[i]![1] = counts.get(r.parentKey)!)); return out; } + +/** The occurrence after (`dir` 1) / before (-1) `lead` in tree order over loaded nodes, with its ancestors. */ +export function findOccurrence( + roots: readonly T[], + lead: string | null, + dir: 1 | -1, + model: { + getKey: (node: T) => string; + getChildren: (node: T) => readonly T[] | undefined; + isExpandable: (node: T) => boolean; + isOccurrence?: (node: T) => boolean; + }, + skipSubtree = false, +): { node: T; ancestors: T[]; index: number; total: number } | null { + const occurrences: Array<{ node: T; ancestors: T[]; at: number }> = []; + let leadAt = -1; + let leadEnd = -1; + let at = 0; + const walk = (nodes: readonly T[], ancestors: T[]) => { + for (const node of nodes) { + const here = at++; + const isLead = model.getKey(node) === lead; + if (isLead) leadAt = here; + const expandable = model.isExpandable(node); + if (model.isOccurrence ? model.isOccurrence(node) : !expandable) occurrences.push({ node, ancestors, at: here }); + const children = expandable ? model.getChildren(node) : undefined; + if (children?.length) walk(children, [...ancestors, node]); + if (isLead) leadEnd = at - 1; + } + }; + walk(roots, []); + const after = skipSubtree ? leadEnd : leadAt; + let i: number; + if (leadAt < 0) i = dir === 1 ? 0 : occurrences.length - 1; + else if (dir === 1) i = occurrences.findIndex((o) => o.at > after); + else i = occurrences.findLastIndex((o) => o.at < leadAt); + const hit = occurrences[i]; + return hit ? { node: hit.node, ancestors: hit.ancestors, index: i + 1, total: occurrences.length } : null; +} diff --git a/src/ui/expui/general/autoscrollFromSource.svg b/src/ui/expui/general/autoscrollFromSource.svg new file mode 100644 index 00000000..696ff7f2 --- /dev/null +++ b/src/ui/expui/general/autoscrollFromSource.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/general/autoscrollFromSource_dark.svg b/src/ui/expui/general/autoscrollFromSource_dark.svg new file mode 100644 index 00000000..1cc5df1b --- /dev/null +++ b/src/ui/expui/general/autoscrollFromSource_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/general/autoscrollToSource.svg b/src/ui/expui/general/autoscrollToSource.svg new file mode 100644 index 00000000..26c894c7 --- /dev/null +++ b/src/ui/expui/general/autoscrollToSource.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/general/autoscrollToSource_dark.svg b/src/ui/expui/general/autoscrollToSource_dark.svg new file mode 100644 index 00000000..9c49d403 --- /dev/null +++ b/src/ui/expui/general/autoscrollToSource_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/gutter/runError.svg b/src/ui/expui/gutter/runError.svg new file mode 100644 index 00000000..631fccf1 --- /dev/null +++ b/src/ui/expui/gutter/runError.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/ui/expui/gutter/runError_dark.svg b/src/ui/expui/gutter/runError_dark.svg new file mode 100644 index 00000000..bdb0cae7 --- /dev/null +++ b/src/ui/expui/gutter/runError_dark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/ui/expui/gutter/runSuccess.svg b/src/ui/expui/gutter/runSuccess.svg new file mode 100644 index 00000000..22f00a39 --- /dev/null +++ b/src/ui/expui/gutter/runSuccess.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/ui/expui/gutter/runSuccess_dark.svg b/src/ui/expui/gutter/runSuccess_dark.svg new file mode 100644 index 00000000..558a95f1 --- /dev/null +++ b/src/ui/expui/gutter/runSuccess_dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index 40651ed3..9e7b66f9 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -209,6 +209,8 @@ const ICONS = { "collapse-all": "general/collapseAll", "expand-all": "general/expandAll", locate: "general/locate", + "autoscroll-to-source": "general/autoscrollToSource", + "autoscroll-from-source": "general/autoscrollFromSource", sort: "objectBrowser/sortAlphabetically", copy: "general/copy", paste: "general/paste", @@ -280,6 +282,8 @@ const ICONS = { "toolwindow-profiler": "toolwindows/profiler", "gutter-run": "gutter/run", "gutter-run-all": "actions/runAll", + "gutter-run-passed": "gutter/runSuccess", + "gutter-run-failed": "gutter/runError", stop: "run/stop", rerun: "run/rerun", "rerun-filled": "run/rerun_stroke", @@ -343,6 +347,8 @@ const ICONS = { changelist: "vcs/changelist", stash: "openidea/vcs/stash", star: "openidea/vcs/favorite", + "star-outline": "openidea/vcs/notFavorite", + tag: "openidea/vcs/tag", "current-branch": "openidea/vcs/currentBranch", // Symbols (also used by `symbol-`) class: "nodes/class", diff --git a/src/ui/index.ts b/src/ui/index.ts index c9505044..4604e292 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -12,6 +12,7 @@ export * from "./Popup"; export * from "./Menu"; export * from "./Dialog"; export * from "./Splitter"; +export * from "./SourcePreview"; export * from "./Feedback"; export * from "./HighlightedText"; export { fuzzyMatch, indexRuns, wordMatch } from "./match"; diff --git a/src/ui/openidea/vcs/notFavorite.svg b/src/ui/openidea/vcs/notFavorite.svg new file mode 100644 index 00000000..2d082407 --- /dev/null +++ b/src/ui/openidea/vcs/notFavorite.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/openidea/vcs/notFavorite_dark.svg b/src/ui/openidea/vcs/notFavorite_dark.svg new file mode 100644 index 00000000..198a9ba6 --- /dev/null +++ b/src/ui/openidea/vcs/notFavorite_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/openidea/vcs/tag.svg b/src/ui/openidea/vcs/tag.svg new file mode 100644 index 00000000..d67da6a0 --- /dev/null +++ b/src/ui/openidea/vcs/tag.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/openidea/vcs/tag_dark.svg b/src/ui/openidea/vcs/tag_dark.svg new file mode 100644 index 00000000..490cd408 --- /dev/null +++ b/src/ui/openidea/vcs/tag_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/workbench/Notifications.tsx b/src/workbench/Notifications.tsx index 0a2d6bcc..437f4a9a 100644 --- a/src/workbench/Notifications.tsx +++ b/src/workbench/Notifications.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { useTxTemplate } from "@/platform/i18n"; import { EmptyState, IconButton, Toast, T } from "@/ui"; import { - BALLOON_TIMEOUT, + balloonTimeout, clearNotifications, dismissBalloon, markAllNotificationsRead, @@ -39,10 +39,10 @@ function Balloon({ n }: { n: IdeNotification }) { const active = useWindowActive(); const remaining = useRef(null); const txt = useTxTemplate(); + const ms = balloonTimeout(n); // IDEA's smart fade-out: the clock runs only while the window is active and the balloon not hovered. useEffect(() => { - const ms = BALLOON_TIMEOUT[n.level]; - if (n.sticky || ms === null || hover || !active) return; + if (ms === null || hover || !active) return; const left = remaining.current ?? ms; const start = Date.now(); const t = setTimeout(() => dismissBalloon(n.id), left); @@ -50,7 +50,7 @@ function Balloon({ n }: { n: IdeNotification }) { clearTimeout(t); remaining.current = Math.max(1, left - (Date.now() - start)); }; - }, [n.id, n.level, n.sticky, hover, active]); + }, [n.id, ms, hover, active]); return ( s.text); + if (!text) return null; + return ( + + {text} + + ); +} + /** Built-in: background task progress; click lists all running tasks. */ export function TaskStatus() { const tasks = useTasks((s) => s.tasks); diff --git a/src/workbench/TitleBar.test.tsx b/src/workbench/TitleBar.test.tsx new file mode 100644 index 00000000..d33f1e89 --- /dev/null +++ b/src/workbench/TitleBar.test.tsx @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { registerActions } from "@/platform/actions"; +import { useThemeOverride } from "./theme"; +import { gearMenuItems, TitleBar } from "./TitleBar"; + +let disposers: Array<() => void> = []; +afterEach(() => { + disposers.forEach((d) => d()); + disposers = []; + useThemeOverride.setState({ theme: null }); +}); + +describe("title bar gear menu", () => { + it("lists Settings, Plugins, Theme and Keymap switches and Check for Updates", () => { + const items = gearMenuItems("dark", "intellij-macos", ["Mine"]); + expect(items.map((i) => ("action" in i ? i.action : "label" in i ? i.label : i.kind))).toEqual([ + "file.settings", + "plugins.settings", + "separator", + "Theme", + "Keymap", + "separator", + "app.checkForUpdates", + ]); + const theme = (items[3] as { submenu: Array<{ label?: string; checked?: boolean }> }).submenu; + expect(theme.filter((i) => i.checked).map((i) => i.label)).toEqual(["Islands Dark"]); + const keymap = (items[4] as { submenu: Array<{ label?: string; checked?: boolean }> }).submenu; + expect(keymap.map((i) => i.label)).toContain("Mine"); + expect(keymap.filter((i) => i.checked).map((i) => i.label)).toEqual(["macOS"]); + }); + + it("opens a menu instead of Settings directly, and switches the theme", async () => { + let opened = 0; + disposers.push( + registerActions([{ id: "file.settings", title: "Settings…", category: "File", run: () => void opened++ }]), + ); + render(); + fireEvent.click(screen.getByRole("button", { name: "IDE and Project Settings" })); + expect(opened).toBe(0); + expect(await screen.findByText("Settings…")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Theme")); + fireEvent.click(await screen.findByText("High Contrast")); + expect(useThemeOverride.getState().theme).toBe("dark-hc"); + }); +}); diff --git a/src/workbench/TitleBar.tsx b/src/workbench/TitleBar.tsx index 6ccfbea2..8035a3db 100644 --- a/src/workbench/TitleBar.tsx +++ b/src/workbench/TitleBar.tsx @@ -1,7 +1,8 @@ import { useMemo, useRef, useState } from "react"; import clsx from "clsx"; import { useActions } from "@/platform/actions"; -import { shortcutLabel } from "@/platform/keymap"; +import { call, commands, isTauri } from "@/platform/ipc"; +import { PRESET_TITLES, shortcutLabel, useKeymap } from "@/platform/keymap"; import { useProject } from "@/platform/project"; import { BarButton, Icon, IconButton, Menu, type MenuItem, T } from "@/ui"; import { useContributions, useWorkbenchSlots, type TitleItemContribution } from "./contributions"; @@ -10,6 +11,7 @@ import { MainMenu } from "./MainMenu"; import { WindowControls } from "./WindowControls"; import { usePlatform } from "@/platform/platformInfo"; import { projectInitials } from "./projectColor"; +import { currentThemeId, setTheme, THEME_CHOICES, useThemeOverride } from "./theme"; import styles from "./TitleBar.module.css"; export function orderTitleItems(items: Iterable) { @@ -104,6 +106,91 @@ function VcsFallback() { ); } +async function selectKeymap(id: string) { + if (!isTauri()) return; + try { + useProject.setState({ settings: await call(commands.settingsSet("global", "keymap", JSON.stringify(id))) }); + } catch (e) { + console.warn("cannot switch keymap", e); + } +} + +/** IDEA's main toolbar gear: Settings, Plugins, quick Theme / Keymap switches, Check for Updates. */ +export function gearMenuItems(themeId: string, keymapId: string, customKeymaps: string[]): MenuItem[] { + return [ + { kind: "action", action: "file.settings" }, + { kind: "action", action: "plugins.settings" }, + { kind: "separator" }, + { + id: "gear.theme", + label: "Theme", + submenu: [ + ...THEME_CHOICES.map((c) => ({ + id: `theme.${c.id}`, + label: c.label, + checked: c.id === themeId, + run: () => void setTheme(c.id), + })), + { kind: "separator" }, + { kind: "action", action: "settings.appearance" }, + ], + }, + { + id: "gear.keymap", + label: "Keymap", + submenu: [ + ...[...Object.keys(PRESET_TITLES), ...customKeymaps].map((id) => ({ + id: `keymap.${id}`, + label: PRESET_TITLES[id] ?? id, + verbatim: !PRESET_TITLES[id], + checked: id === keymapId, + run: () => void selectKeymap(id), + })), + { kind: "separator" }, + { kind: "action", action: "settings.keymap", label: "Configure Keymap…" }, + ], + }, + { kind: "separator" }, + { kind: "action", action: "app.checkForUpdates" }, + ]; +} + +function GearButton() { + const [open, setOpen] = useState(false); + const ref = useRef(null); + const settings = useProject((s) => s.settings); + useThemeOverride((s) => s.theme); + useActions((s) => s.actions); + const preset = useKeymap((s) => s.preset); + const customs = settings?.customKeymaps; + const items = gearMenuItems( + currentThemeId(), + typeof settings?.keymap === "string" ? settings.keymap : preset, + customs && typeof customs === "object" ? Object.keys(customs) : [], + ); + return ( + <> + setOpen((v) => !v)} + /> + setOpen(false)} + anchor={ref} + items={items} + aria-label="IDE and Project Settings" + /> + + ); +} + /** * 40px main toolbar. macOS: native traffic lights overlay its left edge. * Windows: the window is frameless, so the bar carries IDEA's main menu (☰) and @@ -166,7 +253,7 @@ export function TitleBar({ iconSize={18} className={styles.tool} /> - +
)} diff --git a/src/workbench/Workbench.test.tsx b/src/workbench/Workbench.test.tsx index 69eb3777..c17902eb 100644 --- a/src/workbench/Workbench.test.tsx +++ b/src/workbench/Workbench.test.tsx @@ -9,6 +9,7 @@ import { BALLOON_TIMEOUT, clearNotifications, installNotifyEvents, + STICKY_BALLOON_TIMEOUT, notify, useNotifications, } from "./notificationStore"; @@ -129,6 +130,18 @@ describe("notifications", () => { expect(useNotifications.getState().items).toHaveLength(2); }); + it("sticky balloons fade into the tool window after 5 minutes, staying unread", () => { + vi.useFakeTimers(); + vi.spyOn(document, "hasFocus").mockReturnValue(true); + render(); + act(() => void notify({ level: "error", title: "Sync failed" })); + act(() => vi.advanceTimersByTime(STICKY_BALLOON_TIMEOUT - 1000)); + expect(screen.getByText("Sync failed")).toBeInTheDocument(); + act(() => vi.advanceTimersByTime(1100)); + expect(screen.queryByText("Sync failed")).toBeNull(); + expect(useNotifications.getState().items[0]).toMatchObject({ title: "Sync failed", read: false, balloon: false }); + }); + it("pauses the balloon timeout while the app window is inactive", () => { vi.useFakeTimers(); const hasFocus = vi.spyOn(document, "hasFocus").mockReturnValue(true); diff --git a/src/workbench/activate.ts b/src/workbench/activate.ts index 19941749..d8a58b9e 100644 --- a/src/workbench/activate.ts +++ b/src/workbench/activate.ts @@ -22,7 +22,7 @@ import { } from "./Notifications"; import { loadPlatformInfo } from "@/platform/platformInfo"; import { registerShellActions } from "./shellActions"; -import { TaskStatus } from "./StatusBar"; +import { StatusInfo, TaskStatus } from "./StatusBar"; import { installTaskEvents } from "./tasks"; import { installToolWindowActions } from "./toolwindows/actions"; import { @@ -61,6 +61,7 @@ export function activateWorkbench(): () => void { installNotifyEvents(), installBrowserGuards(), registerStatusItem({ id: "workbench.tasks", align: "right", priority: 0, component: TaskStatus }), + registerStatusItem({ id: "workbench.info", align: "left", priority: 5, component: StatusInfo }), registerToolWindow({ id: NOTIFICATIONS_TOOL_WINDOW, title: "Notifications", diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index 9d43af87..91f97b1c 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -8,7 +8,7 @@ * The workbench renders whatever is registered; the action * `toolwindow.` toggles it (registered automatically). */ -import type { ComponentType, ReactNode, RefObject } from "react"; +import { useEffect, useRef, type ComponentType, type ReactNode, type RefObject } from "react"; import type { RunConfig } from "@/bindings"; import type { MenuItem } from "@/ui/Menu"; import { create } from "zustand"; @@ -311,6 +311,8 @@ export interface EditorService { openPaths?(): string[]; /** Closes every tab showing `path`. */ closePath?(path: string): void; + /** Closes the tab of the input with this `key` (e.g. a merge editor once applied). */ + closeInput?(key: string): void; } export interface ToolWindowService { @@ -522,8 +524,16 @@ export function projectViewRoots(map: ReadonlyMap vo export function runExecutors(map = useRunExecutors.getState().executors): RunExecutorContribution[] { return [...map.values()].sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.id.localeCompare(b.id)); } + +// --------------------------------------------------------------------------- +// Occurrence navigators: result views (Find, Usages, Problems, TODO…) let +// Next / Previous Occurrence step through their results from anywhere, the +// editor included. The actions ask the focused, else the last active tool +// window's navigator; the latest registration per tool window wins. + +/** 1-based position of the occurrence navigated to, among `total`. */ +export interface OccurrenceStep { + index: number; + total: number; +} + +export interface OccurrenceNavigator { + /** + * Selects the next (1) / previous (-1) occurrence and opens it in the editor, + * focusing the editor only when `focusEditor` (the action ran outside the tool + * window). `null` when there is none in that direction. + */ + go(dir: 1 | -1, focusEditor: boolean): OccurrenceStep | null; +} + +const occurrenceNavigators = new Map(); + +export function registerOccurrenceNavigator(toolWindow: string, nav: OccurrenceNavigator): () => void { + occurrenceNavigators.set(toolWindow, [...(occurrenceNavigators.get(toolWindow) ?? []), nav]); + return () => { + const rest = (occurrenceNavigators.get(toolWindow) ?? []).filter((n) => n !== nav); + if (rest.length) occurrenceNavigators.set(toolWindow, rest); + else occurrenceNavigators.delete(toolWindow); + }; +} + +export function occurrenceNavigator(toolWindow: string): OccurrenceNavigator | null { + return occurrenceNavigators.get(toolWindow)?.at(-1) ?? null; +} + +/** Registers `nav` (the latest render's) for `toolWindow` while the calling view is mounted. */ +export function useOccurrenceNavigator(toolWindow: string, nav: OccurrenceNavigator["go"]) { + const latest = useRef(nav); + useEffect(() => { + latest.current = nav; + }); + useEffect( + () => registerOccurrenceNavigator(toolWindow, { go: (dir, focus) => latest.current(dir, focus) }), + [toolWindow], + ); +} + +// --------------------------------------------------------------------------- +// Status bar info text (IDEA's status bar message, e.g. "3 of 12" after Next +// Occurrence). Cleared by the next message or after a while. + +export const useStatusInfo = create<{ text: string | null; seq: number }>(() => ({ text: null, seq: 0 })); + +let statusInfoTimer: ReturnType | undefined; + +export function showStatusInfo(text: string, ms = 8000) { + clearTimeout(statusInfoTimer); + const seq = useStatusInfo.getState().seq + 1; + useStatusInfo.setState({ text, seq }); + statusInfoTimer = setTimeout(() => { + if (useStatusInfo.getState().seq === seq) useStatusInfo.setState({ text: null }); + }, ms); +} diff --git a/src/workbench/distractionFree.test.tsx b/src/workbench/distractionFree.test.tsx index e56c05f4..88bd3be4 100644 --- a/src/workbench/distractionFree.test.tsx +++ b/src/workbench/distractionFree.test.tsx @@ -79,7 +79,7 @@ describe("distraction free mode", () => { setDistractionFree(true); showToolWindow("structure"); expect(savedVisible()).toMatchObject({ left: "project", bottom: "terminal", right: null }); - // An app restart (no session) loads the normal layout. + // Leaving the mode (now or after a restart) loads the normal layout. resetDistractionFree(); resetToolWindowLayout(); loadToolWindowLayout("/work/df"); @@ -95,10 +95,11 @@ describe("distraction free mode", () => { expect(isToolWindowVisible("project")).toBe(true); }); - it("survives a reload of the window (sessionStorage) and re-applies to a newly loaded project", () => { + it("survives an app restart and re-applies to a newly loaded project", () => { setDistractionFree(true); - expect(sessionStorage.getItem("openidea.distractionFree")).toBe("1"); - // Reload: fresh module and layout state, same session. + expect(localStorage.getItem("openidea.distractionFree")).toBe("1"); + // Restart: fresh module, layout and session state; only localStorage remains. + sessionStorage.clear(); useDistractionFree.setState({ active: false }); resetToolWindowLayout(); disposers.push(installDistractionFree()); @@ -107,7 +108,7 @@ describe("distraction free mode", () => { expect(isToolWindowVisible("project")).toBe(false); setDistractionFree(false); expect(isToolWindowVisible("project")).toBe(true); - expect(sessionStorage.getItem("openidea.distractionFree")).toBeNull(); + expect(localStorage.getItem("openidea.distractionFree")).toBeNull(); }); it("is unbound in the IntelliJ keymap (⌃⌘D is macOS Look Up) and Escape does not exit", () => { diff --git a/src/workbench/distractionFree.ts b/src/workbench/distractionFree.ts index 5d2ee9e4..e34bd023 100644 --- a/src/workbench/distractionFree.ts +++ b/src/workbench/distractionFree.ts @@ -3,9 +3,11 @@ * stripes, main toolbar contents, status bar, editor tabs, breadcrumbs and * the editor gutter are hidden and the text column is centred at Settings › * Editor › General › Appearance "Distraction free mode text width". The same - * action enters and exits; Esc does not exit. Kept per window across reloads - * (sessionStorage), not across restarts. Published as the `distractionFree` - * context key; components read `useDistractionFree`. + * action enters and exits; Esc does not exit. Like IDEA the mode is an + * application-wide preference that survives restarts (localStorage, next to + * the saved tool window layouts, which keep the layout to restore on exit). + * Published as the `distractionFree` context key; components read + * `useDistractionFree`. */ import { create } from "zustand"; import { registerAction, type Action } from "@/platform/actions"; @@ -16,7 +18,7 @@ import { enterDistractionFreeLayout, exitDistractionFreeLayout, useToolWindowLay export const DISTRACTION_FREE_ACTION = "editor.toggleZen"; export const DISTRACTION_FREE_CONTEXT = "distractionFree"; export const DEFAULT_DISTRACTION_FREE_WIDTH = 80; -const SESSION_KEY = "openidea.distractionFree"; +const STORAGE_KEY = "openidea.distractionFree"; export const useDistractionFree = create<{ active: boolean }>(() => ({ active: false })); @@ -33,18 +35,18 @@ export function distractionFreeTitle(active: boolean): string { return active ? "Exit Distraction Free Mode" : "Enter Distraction Free Mode"; } -function writeSession(active: boolean) { +function writeStored(active: boolean) { try { - if (active) sessionStorage.setItem(SESSION_KEY, "1"); - else sessionStorage.removeItem(SESSION_KEY); + if (active) localStorage.setItem(STORAGE_KEY, "1"); + else localStorage.removeItem(STORAGE_KEY); } catch { /* storage unavailable */ } } -function readSession(): boolean { +function readStored(): boolean { try { - return sessionStorage.getItem(SESSION_KEY) === "1"; + return localStorage.getItem(STORAGE_KEY) === "1"; } catch { return false; } @@ -54,7 +56,7 @@ export function setDistractionFree(active: boolean) { if (active === isDistractionFree()) return; useDistractionFree.setState({ active }); setContext(DISTRACTION_FREE_CONTEXT, active); - writeSession(active); + writeStored(active); if (active) enterDistractionFreeLayout(); else exitDistractionFreeLayout(); } @@ -75,7 +77,7 @@ function action(active: boolean): Action { /** * Registers the action (its title follows the mode, like IDEA's - * Enter / Exit Distraction Free Mode), restores the mode of this window + * Enter / Exit Distraction Free Mode), restores the saved mode * and re-applies the hidden tool window layout when a project's layout loads. */ export function installDistractionFree(): () => void { @@ -90,7 +92,7 @@ export function installDistractionFree(): () => void { if (s.scope !== prev.scope && isDistractionFree()) enterDistractionFreeLayout(); }); setContext(DISTRACTION_FREE_CONTEXT, isDistractionFree()); - if (readSession()) setDistractionFree(true); + if (readStored()) setDistractionFree(true); return () => { unsubMode(); unsubLayout(); @@ -102,5 +104,5 @@ export function installDistractionFree(): () => void { export function resetDistractionFree() { useDistractionFree.setState({ active: false }); setContext(DISTRACTION_FREE_CONTEXT, false); - writeSession(false); + writeStored(false); } diff --git a/src/workbench/notificationStore.ts b/src/workbench/notificationStore.ts index f9bebf61..d2495075 100644 --- a/src/workbench/notificationStore.ts +++ b/src/workbench/notificationStore.ts @@ -85,8 +85,20 @@ interface NotificationState { export const useNotifications = create(() => ({ items: [] })); -/** IDEA fades balloons out after 10 s; errors stay until dismissed (as IDEA's sticky error groups). */ +/** IDEA fades balloons out after 10 s; errors are sticky balloons (as IDEA's error groups). */ export const BALLOON_TIMEOUT: Record = { info: 10000, warning: 10000, error: null }; +/** + * Sticky balloons fade too, after 5 minutes of active window time, as in IDEA: + * the notification stays unread in the Notifications tool window. One that the + * tool window does not list stays until dismissed. + */ +export const STICKY_BALLOON_TIMEOUT = 5 * 60 * 1000; + +/** Fade-out delay of a balloon, or null when it stays until dismissed. */ +export function balloonTimeout(n: Pick): number | null { + if (n.sticky || BALLOON_TIMEOUT[n.level] === null) return n.logged ? STICKY_BALLOON_TIMEOUT : null; + return BALLOON_TIMEOUT[n.level]; +} const MAX_HISTORY = 200; let seq = 0; diff --git a/src/workbench/theme.ts b/src/workbench/theme.ts index c9084610..22bba627 100644 --- a/src/workbench/theme.ts +++ b/src/workbench/theme.ts @@ -103,15 +103,25 @@ function syncWindowBackground() { } export function currentTheme(): "dark" | "light" { - const override = useThemeOverride.getState().theme; - const id = override - ? resolveThemeId({ theme: override }, prefersLight()) - : resolveThemeId(useProject.getState().settings?.appearance as AppearanceLike | undefined, prefersLight()); - return id === "light" ? "light" : "dark"; + return currentThemeId() === "light" ? "light" : "dark"; } -export async function toggleTheme() { - const next: ThemeSetting = currentTheme() === "dark" ? "light" : "dark"; +/** Themes offered by quick switch menus, in IDEA's order. */ +export const THEME_CHOICES: ReadonlyArray<{ id: "light" | "dark" | "dark-hc"; label: string }> = [ + { id: "light", label: "Islands Light" }, + { id: "dark", label: "Islands Dark" }, + { id: "dark-hc", label: "High Contrast" }, +]; + +/** The concrete theme in effect (`dark-hc` kept distinct). */ +export function currentThemeId(): "dark" | "light" | "dark-hc" { + const override = useThemeOverride.getState().theme; + return override + ? resolveThemeId({ theme: override }, prefersLight()) + : resolveThemeId(useProject.getState().settings?.appearance as AppearanceLike | undefined, prefersLight()); +} + +export async function setTheme(next: ThemeSetting) { useThemeOverride.setState({ theme: next }); if (!isTauri()) return; try { @@ -124,3 +134,7 @@ export async function toggleTheme() { /* keep the session override */ } } + +export async function toggleTheme() { + await setTheme(currentTheme() === "dark" ? "light" : "dark"); +} diff --git a/src/workbench/toolwindows/actions.ts b/src/workbench/toolwindows/actions.ts index bcc13f0c..657afbe6 100644 --- a/src/workbench/toolwindows/actions.ts +++ b/src/workbench/toolwindows/actions.ts @@ -1,5 +1,6 @@ import { registerAction, registerActions } from "@/platform/actions"; -import { useContributions } from "../contributions"; +import { formatMessage, tx } from "@/platform/i18n"; +import { occurrenceNavigator, showStatusInfo, useContributions } from "../contributions"; import { focusEditor } from "../focus"; import { closeSelectedContent } from "./content"; import { @@ -8,10 +9,29 @@ import { hideAllToolWindows, hideToolWindow, jumpToLastToolWindow, + lastUsedToolWindow, useToolWindowLayout, toggleMaximizeToolWindow, } from "./store"; +/** + * Next / Previous Occurrence: steps through the results of the focused tool + * window, else of the last active one, else of a shown one, so it also works + * from the editor. The editor gets the focus only when the action ran outside + * the tool window. + */ +export function goToOccurrence(dir: 1 | -1): boolean { + const s = useToolWindowLayout.getState(); + for (const id of new Set([s.focused, s.lastActive, lastUsedToolWindow(), ...Object.values(s.visible)])) { + const nav = id ? occurrenceNavigator(id) : null; + if (!nav) continue; + const step = nav.go(dir, s.focused !== id); + if (step) showStatusInfo(formatMessage(tx("{index} of {total}"), { index: step.index, total: step.total })); + return !!step; + } + return false; +} + /** Registers `toolwindow.` for every tool window plus the generic tool window actions. */ export function installToolWindowActions(): () => void { const perWindow = new Map void }>(); @@ -80,6 +100,24 @@ export function installToolWindowActions(): () => void { when: "toolWindowFocus && !popupOpen", run: () => focusEditor(), }, + { + id: "navigate.nextOccurrence", + title: "Next Occurrence", + category: "Navigate", + icon: "arrow-down", + keywords: ["usage", "result", "find"], + when: "projectOpen", + run: () => void goToOccurrence(1), + }, + { + id: "navigate.prevOccurrence", + title: "Previous Occurrence", + category: "Navigate", + icon: "arrow-up", + keywords: ["usage", "result", "find"], + when: "projectOpen", + run: () => void goToOccurrence(-1), + }, { id: "toolwindow.maximize", title: "Maximize Tool Window", diff --git a/src/workbench/toolwindows/occurrences.test.ts b/src/workbench/toolwindows/occurrences.test.ts new file mode 100644 index 00000000..4af7f023 --- /dev/null +++ b/src/workbench/toolwindows/occurrences.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { registerOccurrenceNavigator, registerToolWindow, useStatusInfo } from "../contributions"; +import { goToOccurrence } from "./actions"; +import { resetToolWindowLayout, setFocusedToolWindow, showToolWindow } from "./store"; + +const Empty = () => null; +let disposers: Array<() => void> = []; + +beforeEach(() => { + localStorage.clear(); + resetToolWindowLayout(); + disposers = [ + registerToolWindow({ id: "project", title: "Project", icon: "folder", anchor: "left", component: Empty }), + registerToolWindow({ id: "find", title: "Find", icon: "search", anchor: "bottom", component: Empty }), + ]; +}); + +afterEach(() => { + disposers.reverse().forEach((d) => d()); + useStatusInfo.setState({ text: null }); +}); + +describe("Next / Previous Occurrence", () => { + it("steps the focused tool window's results, keeping the focus there", () => { + const go = vi.fn(() => ({ index: 2, total: 5 })); + disposers.push(registerOccurrenceNavigator("find", { go })); + showToolWindow("find"); + setFocusedToolWindow("find"); + expect(goToOccurrence(1)).toBe(true); + expect(go).toHaveBeenCalledWith(1, false); + expect(useStatusInfo.getState().text).toBe("2 of 5"); + }); + + it("from elsewhere (the editor) uses the last active tool window and focuses the editor", () => { + const go = vi.fn(() => ({ index: 1, total: 1 })); + disposers.push(registerOccurrenceNavigator("find", { go })); + showToolWindow("find"); + setFocusedToolWindow("project"); + setFocusedToolWindow(null); + expect(goToOccurrence(-1)).toBe(true); + expect(go).toHaveBeenCalledWith(-1, true); + }); + + it("does nothing without a navigator or past the last occurrence", () => { + showToolWindow("project"); + expect(goToOccurrence(1)).toBe(false); + const nav = { go: vi.fn(() => null) }; + const dispose = registerOccurrenceNavigator("project", nav); + expect(goToOccurrence(1)).toBe(false); + expect(nav.go).toHaveBeenCalled(); + expect(useStatusInfo.getState().text).toBeNull(); + dispose(); + }); +});