chore: Format the codebase with oxfmt and check formatting in CI (#135)

Run oxfmt once over the whole repository (formatting only, no logic changes) and add `pnpm format:check` to the frontend CI job so unformatted code no longer builds up. `.oxfmtrc.json` now skips the generated theme docs, test fixtures and code blocks inside Markdown.

Co-authored-by: jaw52 <[email protected]>
This commit is contained in:
Jaw authored and GitHub committed 2026-10-28 19:35:16 +08:00
1 parent a94b4bff42
commit 55836e1b49
379 files changed
+6236 -1461

No files matched your search

+1
View File
@@ -81,6 +81,7 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
- run: pnpm lint
- run: pnpm format:check
- run: pnpm test
- run: pnpm build
+18 -1
View File
@@ -5,5 +5,22 @@
"singleQuote": false,
"trailingComma": "all",
"sortPackageJson": false,
"ignorePatterns": ["dist", "target", "src/bindings.ts", "pnpm-lock.yaml", "**/*.toml"]
"ignorePatterns": [
"dist",
"target",
"src/bindings.ts",
"docs/plugins/themes.md",
"pnpm-lock.yaml",
"**/*.toml",
"fixtures",
"crates/**/tests/fixtures"
],
"overrides": [
{
"files": ["**/*.md"],
"options": {
"embeddedLanguageFormatting": "off"
}
}
]
}
+1 -1
View File
@@ -31,7 +31,7 @@ Opening [freqtrade](https://github.com/freqtrade/freqtrade) (Python, ~770 files)
with warm caches, median of 5 runs:
| | OpenIDEA | IntelliJ IDEA 2026.2.3 |
|---|---|---|
| --------------------------- | ----------------- | ---------------------- |
| Window with the project | **0.9 s** | 3.1 s |
| Ready (CPU settled) | **1.1 s** | 13.2 s |
| Memory (USS, all processes) | **169 MB** | 2011 MB |
-1
View File
@@ -251,7 +251,6 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
```
## Chinese UI terminology from Visual Studio Code
Some Simplified Chinese terms in `src/platform/locales/text/zh.json` follow the
+1 -1
View File
@@ -88,7 +88,7 @@ cover the shell, sidebar, editor, gutter, find matches, terminal, Settings,
focused controls, disabled settings and the selected theme popup.
| Theme | Workbench, editor and terminal | Settings and theme popup |
|---|---|---|
| --------------- | -------------------------------------------------------- | ---------------------------------------------------- |
| Solarized Light | [Screenshot](media/themes/solarized-light-workbench.png) | [Screenshot](media/themes/solarized-light-popup.png) |
| Solarized Dark | [Screenshot](media/themes/solarized-dark-workbench.png) | [Screenshot](media/themes/solarized-dark-popup.png) |
| One Light | [Screenshot](media/themes/one-light-workbench.png) | [Screenshot](media/themes/one-light-popup.png) |
+11 -2
View File
@@ -11,11 +11,20 @@
<div id="root"></div>
<div id="splash" class="splash" data-tauri-drag-region>
<svg class="splash-logo" viewBox="22 24 56 56" aria-hidden="true">
<rect x="22" y="26" width="56" height="52" rx="12" fill="#D9582C" /><rect x="27" y="26" width="46" height="42" rx="9" fill="#1B1A1A" /><circle cx="44" cy="47.4" r="4.1" fill="none" stroke="#EAE7E0" stroke-width="2.7" /><rect x="54.3" y="42" width="2.8" height="10.5" fill="#EAE7E0" /><rect x="51.2" y="42" width="5.9" height="2.5" fill="#EAE7E0" /><rect x="51.2" y="50" width="9.4" height="2.5" fill="#EAE7E0" /><circle cx="55.7" cy="38.4" r="1.8" fill="#EAE7E0" />
<rect x="22" y="26" width="56" height="52" rx="12" fill="#D9582C" />
<rect x="27" y="26" width="46" height="42" rx="9" fill="#1B1A1A" />
<circle cx="44" cy="47.4" r="4.1" fill="none" stroke="#EAE7E0" stroke-width="2.7" />
<rect x="54.3" y="42" width="2.8" height="10.5" fill="#EAE7E0" />
<rect x="51.2" y="42" width="5.9" height="2.5" fill="#EAE7E0" />
<rect x="51.2" y="50" width="9.4" height="2.5" fill="#EAE7E0" />
<circle cx="55.7" cy="38.4" r="1.8" fill="#EAE7E0" />
</svg>
<!-- "OpenIDEA" in Inter 500 as outlines: the web font loads late in dev and would swap in. -->
<svg class="splash-wordmark" viewBox="0 -1984 9994 2478" role="img" aria-label="OpenIDEA">
<path fill="currentColor" d="M785.8 20Q592.6 20 440.4 -71.7Q288.2 -163.4 200.4 -334.7Q112.6 -506 112.6 -744Q112.6 -983.2 200.4 -1154.6Q288.2 -1326 440.4 -1418Q592.6 -1510 785.8 -1510Q979 -1510 1130.7 -1418Q1282.4 -1326 1369.8 -1154.6Q1457.3 -983.2 1457.3 -744Q1457.3 -505.8 1369.8 -334.5Q1282.4 -163.2 1130.7 -71.6Q979 20 785.8 20ZM785.8 -187.7Q913 -187.7 1013.5 -251.6Q1114 -315.5 1172.4 -439.9Q1230.7 -564.3 1230.7 -744Q1230.7 -925.6 1172.4 -1050Q1114 -1174.5 1013.5 -1238.4Q913 -1302.3 785.8 -1302.3Q657.9 -1302.3 557 -1238Q456.1 -1173.7 397.7 -1049.2Q339.2 -924.7 339.2 -744Q339.2 -564.3 397.7 -440.3Q456.1 -316.3 557 -252Q657.9 -187.7 785.8 -187.7ZM1717.9 418V-1118H1932.2V-935.9H1950.4Q1970 -970.8 2005.4 -1016.7Q2040.9 -1062.7 2104.6 -1097.3Q2168.3 -1132 2272.6 -1132Q2407.4 -1132 2512.7 -1064Q2617.9 -996 2678.8 -867Q2739.8 -738 2739.8 -556.4Q2739.8 -375.7 2679.8 -246.2Q2619.8 -116.7 2514.5 -47.3Q2409.1 22.2 2272.7 22.2Q2171.8 22.2 2107.4 -12.4Q2043 -47 2006.6 -93.2Q1970.2 -139.3 1950.4 -175H1937.9V418ZM2224 -165.3Q2320.1 -165.3 2384.9 -217.3Q2449.6 -269.3 2482.4 -358.3Q2515.2 -447.3 2515.2 -558Q2515.2 -668.1 2482.8 -755.4Q2450.4 -842.8 2385.7 -893.7Q2321 -944.5 2224 -944.5Q2129.6 -944.5 2065 -896.1Q2000.4 -847.7 1967.4 -761.2Q1934.4 -674.6 1934.4 -558Q1934.4 -441.7 1967.8 -353.2Q2001.2 -264.7 2066.2 -215Q2131.2 -165.3 2224 -165.3ZM3465.8 23.3Q3300.7 23.3 3181.1 -48.1Q3061.4 -119.5 2996.9 -248.5Q2932.4 -377.5 2932.4 -551.1Q2932.4 -723.3 2995.8 -854Q3059.1 -984.7 3175.1 -1058.4Q3291 -1132 3446.8 -1132Q3542.6 -1132 3632 -1100.7Q3721.4 -1069.4 3792.3 -1002.3Q3863.3 -935.2 3904.7 -828.3Q3946.2 -721.5 3946.2 -570.3V-492.2H3054.8V-656.5H3832.8L3730.1 -602.3Q3730.1 -704.7 3698.2 -783.2Q3666.3 -861.7 3603.5 -905.9Q3540.8 -950 3447.5 -950Q3354.2 -950 3288.2 -905Q3222.1 -860 3187.4 -786.5Q3152.6 -712.9 3152.6 -626.1V-514.4Q3152.6 -399.4 3192.1 -319.8Q3231.6 -240.1 3302.9 -199.4Q3374.2 -158.7 3468.1 -158.7Q3529.6 -158.7 3579.9 -176.3Q3630.2 -194 3666.5 -229.4Q3702.8 -264.9 3722.2 -317.2L3928.6 -268.5Q3902.2 -181.6 3838.6 -115.9Q3775 -50.2 3680.4 -13.4Q3585.8 23.3 3465.8 23.3ZM4406.9 -662.4V0H4186.9V-1118H4396.6L4398.9 -844.3H4376.2Q4424.9 -994.3 4517.8 -1063.1Q4610.7 -1132 4742.5 -1132Q4856.6 -1132 4942 -1084.2Q5027.5 -1036.3 5075.1 -942.5Q5122.7 -848.8 5122.7 -710.4V0H4902.7V-682.9Q4902.7 -803.7 4839.8 -872.1Q4776.9 -940.5 4667.3 -940.5Q4592.9 -940.5 4533.8 -907.9Q4474.8 -875.3 4440.8 -813.3Q4406.9 -751.2 4406.9 -662.4ZM5664.4 -1490V0H5435.9V-1490ZM6478.5 0H6109.9V-199.7H6465.8Q6635 -199.7 6747.1 -263Q6859.2 -326.3 6915 -448.6Q6970.8 -571 6970.8 -747.3Q6970.8 -922.1 6915.2 -1043.3Q6859.7 -1164.4 6750.5 -1227.4Q6641.3 -1290.3 6478.4 -1290.3H6102.2V-1490H6493Q6713.2 -1490 6869.9 -1400.9Q7026.7 -1311.8 7110.7 -1145.2Q7194.7 -978.6 7194.7 -747.3Q7194.7 -514.5 7110.4 -347.1Q7026 -179.6 6865.8 -89.8Q6705.5 0 6478.5 0ZM6222.4 -1490V0H5993.9V-1490ZM7471.9 0V-1490H8409.8V-1293.6H7700.4V-849.7H8359.5V-654.7H7700.4V-196.4H8416.4V0ZM8592.9 0 9123.5 -1490H9403.2L9942.8 0H9696.3L9406.1 -828.9Q9372.2 -930 9331.8 -1066.9Q9291.4 -1203.8 9238.4 -1395.6H9284.5Q9232.7 -1202.2 9192.5 -1063.6Q9152.4 -925.1 9121.4 -828.9L8839.9 0ZM8880.4 -392.6V-583.1H9655.3V-392.6Z" />
<path
fill="currentColor"
d="M785.8 20Q592.6 20 440.4 -71.7Q288.2 -163.4 200.4 -334.7Q112.6 -506 112.6 -744Q112.6 -983.2 200.4 -1154.6Q288.2 -1326 440.4 -1418Q592.6 -1510 785.8 -1510Q979 -1510 1130.7 -1418Q1282.4 -1326 1369.8 -1154.6Q1457.3 -983.2 1457.3 -744Q1457.3 -505.8 1369.8 -334.5Q1282.4 -163.2 1130.7 -71.6Q979 20 785.8 20ZM785.8 -187.7Q913 -187.7 1013.5 -251.6Q1114 -315.5 1172.4 -439.9Q1230.7 -564.3 1230.7 -744Q1230.7 -925.6 1172.4 -1050Q1114 -1174.5 1013.5 -1238.4Q913 -1302.3 785.8 -1302.3Q657.9 -1302.3 557 -1238Q456.1 -1173.7 397.7 -1049.2Q339.2 -924.7 339.2 -744Q339.2 -564.3 397.7 -440.3Q456.1 -316.3 557 -252Q657.9 -187.7 785.8 -187.7ZM1717.9 418V-1118H1932.2V-935.9H1950.4Q1970 -970.8 2005.4 -1016.7Q2040.9 -1062.7 2104.6 -1097.3Q2168.3 -1132 2272.6 -1132Q2407.4 -1132 2512.7 -1064Q2617.9 -996 2678.8 -867Q2739.8 -738 2739.8 -556.4Q2739.8 -375.7 2679.8 -246.2Q2619.8 -116.7 2514.5 -47.3Q2409.1 22.2 2272.7 22.2Q2171.8 22.2 2107.4 -12.4Q2043 -47 2006.6 -93.2Q1970.2 -139.3 1950.4 -175H1937.9V418ZM2224 -165.3Q2320.1 -165.3 2384.9 -217.3Q2449.6 -269.3 2482.4 -358.3Q2515.2 -447.3 2515.2 -558Q2515.2 -668.1 2482.8 -755.4Q2450.4 -842.8 2385.7 -893.7Q2321 -944.5 2224 -944.5Q2129.6 -944.5 2065 -896.1Q2000.4 -847.7 1967.4 -761.2Q1934.4 -674.6 1934.4 -558Q1934.4 -441.7 1967.8 -353.2Q2001.2 -264.7 2066.2 -215Q2131.2 -165.3 2224 -165.3ZM3465.8 23.3Q3300.7 23.3 3181.1 -48.1Q3061.4 -119.5 2996.9 -248.5Q2932.4 -377.5 2932.4 -551.1Q2932.4 -723.3 2995.8 -854Q3059.1 -984.7 3175.1 -1058.4Q3291 -1132 3446.8 -1132Q3542.6 -1132 3632 -1100.7Q3721.4 -1069.4 3792.3 -1002.3Q3863.3 -935.2 3904.7 -828.3Q3946.2 -721.5 3946.2 -570.3V-492.2H3054.8V-656.5H3832.8L3730.1 -602.3Q3730.1 -704.7 3698.2 -783.2Q3666.3 -861.7 3603.5 -905.9Q3540.8 -950 3447.5 -950Q3354.2 -950 3288.2 -905Q3222.1 -860 3187.4 -786.5Q3152.6 -712.9 3152.6 -626.1V-514.4Q3152.6 -399.4 3192.1 -319.8Q3231.6 -240.1 3302.9 -199.4Q3374.2 -158.7 3468.1 -158.7Q3529.6 -158.7 3579.9 -176.3Q3630.2 -194 3666.5 -229.4Q3702.8 -264.9 3722.2 -317.2L3928.6 -268.5Q3902.2 -181.6 3838.6 -115.9Q3775 -50.2 3680.4 -13.4Q3585.8 23.3 3465.8 23.3ZM4406.9 -662.4V0H4186.9V-1118H4396.6L4398.9 -844.3H4376.2Q4424.9 -994.3 4517.8 -1063.1Q4610.7 -1132 4742.5 -1132Q4856.6 -1132 4942 -1084.2Q5027.5 -1036.3 5075.1 -942.5Q5122.7 -848.8 5122.7 -710.4V0H4902.7V-682.9Q4902.7 -803.7 4839.8 -872.1Q4776.9 -940.5 4667.3 -940.5Q4592.9 -940.5 4533.8 -907.9Q4474.8 -875.3 4440.8 -813.3Q4406.9 -751.2 4406.9 -662.4ZM5664.4 -1490V0H5435.9V-1490ZM6478.5 0H6109.9V-199.7H6465.8Q6635 -199.7 6747.1 -263Q6859.2 -326.3 6915 -448.6Q6970.8 -571 6970.8 -747.3Q6970.8 -922.1 6915.2 -1043.3Q6859.7 -1164.4 6750.5 -1227.4Q6641.3 -1290.3 6478.4 -1290.3H6102.2V-1490H6493Q6713.2 -1490 6869.9 -1400.9Q7026.7 -1311.8 7110.7 -1145.2Q7194.7 -978.6 7194.7 -747.3Q7194.7 -514.5 7110.4 -347.1Q7026 -179.6 6865.8 -89.8Q6705.5 0 6478.5 0ZM6222.4 -1490V0H5993.9V-1490ZM7471.9 0V-1490H8409.8V-1293.6H7700.4V-849.7H8359.5V-654.7H7700.4V-196.4H8416.4V0ZM8592.9 0 9123.5 -1490H9403.2L9942.8 0H9696.3L9406.1 -828.9Q9372.2 -930 9331.8 -1066.9Q9291.4 -1203.8 9238.4 -1395.6H9284.5Q9232.7 -1202.2 9192.5 -1063.6Q9152.4 -925.1 9121.4 -828.9L8839.9 0ZM8880.4 -392.6V-583.1H9655.3V-392.6Z"
/>
</svg>
<div class="splash-bar" role="progressbar" aria-label="Loading"><i></i></div>
</div>
+1
View File
@@ -14,6 +14,7 @@
"test:watch": "vitest",
"lint": "oxlint",
"format": "oxfmt",
"format:check": "oxfmt --check",
"typecheck": "tsrs --noEmit && tsrs -p tsconfig.node.json --noEmit && tsrs -p perf --noEmit && tsrs -p scripts --noEmit",
"gen:bindings": "cargo test -p openidea --test export_bindings",
"gen:theme-docs": "node scripts/theme/gen-docs.ts",
+3 -3
View File
@@ -14,7 +14,7 @@ Each run launches the app on the project, waits until it is ready, idles,
closes it, and records:
| Metric | How |
|---|---|
| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `window_s` | first visible window whose title matches `window_title` (the project name) |
| `ready_s` | the process tree's CPU stays under `--quiet-cores` for `--quiet-secs`, after the window and any `ready_after` markers |
| markers | first/last time a log line matches, e.g. OpenIDEA `indexed`, IDEA `exit dumb mode` |
@@ -31,8 +31,8 @@ Output goes to `results/<time>-<project>/`: `report.md` (median, min–max),
- Release builds only; a running instance of the same executable (a
`pnpm tauri dev` build too) would take over the launch, so the script refuses.
- IDEA: open the project once by hand and trust it; turn off *Reopen projects
on startup* and *Confirm before exiting*; note the version, `-Xmx` and whether
- IDEA: open the project once by hand and trust it; turn off _Reopen projects
on startup_ and _Confirm before exiting_; note the version, `-Xmx` and whether
Shared Indexes were on.
- Same machine, power plan and plugged in; exclude the project and caches from
antivirus scanning, or neither.
+16 -16
View File
@@ -49,7 +49,7 @@ All paths are relative to the package directory and may not leave it.
### Permissions
| Permission | Unlocks |
|----------------|-------------------------------------------------|
| -------------- | ------------------------------------------------- |
| `project-read` | `workspace-root`, `read-file` (project root only) |
| `editor-read` | `active-file` |
| `editor-write` | `insert-text`, `replace-selection` |
@@ -134,7 +134,7 @@ Any language with component-model tooling works (e.g. `componentize-py`,
### Host functions
| Function | Notes |
|---|---|
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `log(level, msg)` | Goes to the IDE log, tagged with the plugin id. `eprintln!` output is logged too and shown when the plugin crashes. |
| `register-command(id, title, category?)` | Becomes action `plugin.<plugin-id>.<id>`; call again to rename. Max 256 commands. |
| `show-notification(level, title, body)` | Balloon in the invoking window. |
@@ -144,11 +144,11 @@ Any language with component-model tooling works (e.g. `componentize-py`,
| `insert-text(text)` | Inserts at every cursor of the active editor (cursor ends after the text). |
| `replace-selection(text)` | Replaces every selection and selects the new text. |
| `register-language(toml)` | Same format as `languages/<id>.toml`. |
| `register-theme(json)` | See *Themes* below. |
| `register-theme(json)` | See _Themes_ below. |
`run-command` receives an optional string argument: `executeAction("plugin.hello.insert-date", "arg")`
from another feature, or nothing when run from Search Everywhere / a shortcut.
Returning `Err(msg)` shows *"<title> failed: msg"*; the plugin keeps running.
Returning `Err(msg)` shows _"<title> failed: msg"_; the plugin keeps running.
### Sandbox and limits
@@ -156,13 +156,13 @@ Each plugin gets its own wasmtime `Store`: no preopened directories, no
environment, no network, no inherited stdio — only clocks and randomness from
WASI, plus the host functions above. Every call into the plugin is bounded:
* **CPU:** a fuel budget (≈ 5·10⁹ wasm instructions) per call;
* **time:** 5 s wall clock per call (epoch interruption);
* **memory:** 256 MiB of linear memory.
- **CPU:** a fuel budget (≈ 5·10⁹ wasm instructions) per call;
- **time:** 5 s wall clock per call (epoch interruption);
- **memory:** 256 MiB of linear memory.
A panic, trap, permission violation or exceeded limit stops the plugin and
marks it *Failed* for the session (with the reason in Settings › Plugins);
toggling it off and on, or *Reload*, starts a fresh instance. The IDE itself is
marks it _Failed_ for the session (with the reason in Settings › Plugins);
toggling it off and on, or _Reload_, starts a fresh instance. The IDE itself is
never affected.
## 3. Themes
@@ -181,7 +181,7 @@ Tokens are the CSS variables of `src/styles/tokens.css` (`--bg-*`, `--fg-*`,
keep the values of the built-in base named by `kind`. Values must be plain CSS
values (no `url()`, `;`, braces); `//` and `/* */` comments are allowed. A
manifest theme entry may add `editor-scheme = "themes/x.scheme.json"` (editor
colours, merged over the theme). Pick a theme in Settings › Plugins › *Apply*.
colours, merged over the theme). Pick a theme in Settings › Plugins › _Apply_.
Start from [`templates/theme-plugin`](../templates/theme-plugin); the full guide,
token reference and IntelliJ / VS Code porting tables are in
@@ -196,14 +196,14 @@ token reference and IntelliJ / VS Code porting tables are in
## 5. Installing and developing
* **Install:** Settings › Plugins › *Install Plugin from Disk…* (or the action
*Install Plugin from Disk…*) and pick the package folder. The manifest and the
- **Install:** Settings › Plugins › _Install Plugin from Disk…_ (or the action
_Install Plugin from Disk…_) and pick the package folder. The manifest and the
files it references are copied to `<app-data>/plugins/<id>/`.
* **Dev plugins:** put (or symlink) the package in `<project>/.openidea/plugins/<name>/`.
It loads when the project is opened, is listed with a *dev* tag, and *Reload*
(or the *Reload Plugins* action) picks up a rebuilt `plugin.wasm` without
- **Dev plugins:** put (or symlink) the package in `<project>/.openidea/plugins/<name>/`.
It loads when the project is opened, is listed with a _dev_ tag, and _Reload_
(or the _Reload Plugins_ action) picks up a rebuilt `plugin.wasm` without
restarting the IDE.
* Commands appear in Search Everywhere › Actions under their category, and can
- Commands appear in Search Everywhere › Actions under their category, and can
be bound in Settings › Keymap like any other action.
## 6. Testing
+51 -12
View File
@@ -4,8 +4,13 @@
Until tokens load, the colours are the last theme's --bg-frame / --fg-muted that boot.js
restored as --boot-*, else these built-in ones; the bar keeps the last --accent throughout. */
/* Not on html: the canvas then takes body's (themed) background, which a fast resize shows before relayout. */
body, .splash { background: var(--bg-frame, var(--boot-frame, #e9eaee)); }
:root[data-theme="dark"] :is(body, .splash) { background: var(--bg-frame, var(--boot-frame, #26282c)); }
body,
.splash {
background: var(--bg-frame, var(--boot-frame, #e9eaee));
}
:root[data-theme="dark"] :is(body, .splash) {
background: var(--bg-frame, var(--boot-frame, #26282c));
}
:root:is([data-theme="dark-hc"], [data-theme="dark"][data-contrast="high"]) :is(body, .splash) {
background: var(--bg-frame, var(--boot-frame, #1a1b1e));
}
@@ -27,12 +32,29 @@ body, .splash { background: var(--bg-frame, var(--boot-frame, #e9eaee)); }
animation: splash-in 240ms ease-out 120ms both;
transition: opacity 160ms ease-out;
}
.splash.splash-out { opacity: 0; pointer-events: none; }
:root[data-theme^="dark"] .splash { color: var(--fg-muted, var(--boot-fg, #8c8f95)); }
.splash-logo { width: 56px; height: 56px; }
.splash.splash-out {
opacity: 0;
pointer-events: none;
}
:root[data-theme^="dark"] .splash {
color: var(--fg-muted, var(--boot-fg, #8c8f95));
}
.splash-logo {
width: 56px;
height: 56px;
}
/* 15px Inter: 2048 units per em, the line box from ascender to descender. */
.splash-wordmark { width: 73.2px; height: 18.15px; }
.splash-bar { width: 120px; height: 2px; border-radius: 1px; overflow: hidden; background: rgb(127 127 127 / 0.18); }
.splash-wordmark {
width: 73.2px;
height: 18.15px;
}
.splash-bar {
width: 120px;
height: 2px;
border-radius: 1px;
overflow: hidden;
background: rgb(127 127 127 / 0.18);
}
/* Fake progress: eases towards ~90% over the 4 s cap; hideSplash() fills it before fading out.
The remembered accent comes first: tokens.css brings the theme's default --accent before
settings restore a custom one, which would flash the default mid-load. */
@@ -44,11 +66,28 @@ body, .splash { background: var(--bg-frame, var(--boot-frame, #e9eaee)); }
animation: splash-fill 4s cubic-bezier(0.1, 0.7, 0.3, 1) both;
}
/* Settings window: a dialog, not a launch. Only the bar, and only if loading takes a while. */
:root[data-window="settings"] .splash { animation-delay: 400ms; }
:root[data-window="settings"] .splash > :not(.splash-bar) { display: none; }
@keyframes splash-in { from { opacity: 0; } }
@keyframes splash-fill { from { transform: scaleX(0.04); } to { transform: scaleX(0.92); } }
:root[data-window="settings"] .splash {
animation-delay: 400ms;
}
:root[data-window="settings"] .splash > :not(.splash-bar) {
display: none;
}
@keyframes splash-in {
from {
opacity: 0;
}
}
@keyframes splash-fill {
from {
transform: scaleX(0.04);
}
to {
transform: scaleX(0.92);
}
}
/* The fill stays: it grows in place, which reduced motion allows. */
@media (prefers-reduced-motion: reduce) {
.splash { animation: none; }
.splash {
animation: none;
}
}
+1 -5
View File
@@ -2,11 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": [
"main",
"project-*",
"settings-*"
],
"windows": ["main", "project-*", "settings-*"],
"permissions": [
"core:default",
"core:window:allow-start-dragging",
+2 -8
View File
@@ -2,12 +2,6 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "e2e-bridge",
"description": "Debug-only automation bridge",
"windows": [
"main",
"project-*",
"settings-*"
],
"permissions": [
"mcp-bridge:default"
]
"windows": ["main", "project-*", "settings-*"],
"permissions": ["mcp-bridge:default"]
}
+1 -7
View File
@@ -37,13 +37,7 @@
"active": true,
"targets": "all",
"createUpdaterArtifacts": true,
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/[email protected]",
"icons/icon.icns",
"icons/icon.ico"
],
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/[email protected]", "icons/icon.icns", "icons/icon.ico"],
"category": "DeveloperTool",
"copyright": "Copyright © 2026 OpenIDEA contributors",
"license": "Apache-2.0",
+19 -3
View File
@@ -17,7 +17,15 @@
* - Right-hand overview marks: `scrollbarMarks`; save hooks:
* `registerSaveParticipant`; Back/Forward history: `getNavigationHistory`.
*/
import { Annotation, Compartment, Facet, type EditorState, type Extension, type RangeSet, type Transaction } from "@codemirror/state";
import {
Annotation,
Compartment,
Facet,
type EditorState,
type Extension,
type RangeSet,
type Transaction,
} from "@codemirror/state";
import { GutterMarker, ViewPlugin, type EditorView } from "@codemirror/view";
import type { LanguageDescription } from "@codemirror/language";
import { create } from "zustand";
@@ -252,7 +260,12 @@ export class LaneMarker extends GutterMarker {
super();
}
override eq(other: GutterMarker): boolean {
return other instanceof LaneMarker && other.color === this.color && other.kind === this.kind && other.title === this.title;
return (
other instanceof LaneMarker &&
other.color === this.color &&
other.kind === this.kind &&
other.title === this.title
);
}
override toDOM(): Node {
const el = document.createElement("div");
@@ -283,7 +296,10 @@ export type ContributedFormatter = (
/** Go to Declaration / ⌘-click: `true` when it handled `pos`. */
export type ContributedDefinition = (view: EditorView, pos: number) => boolean;
/** Hover and Quick Documentation: Markdown about the symbol at `pos`. */
export type ContributedHover = (state: EditorState, pos: number) => { from: number; to: number; markdown: string } | null;
export type ContributedHover = (
state: EditorState,
pos: number,
) => { from: number; to: number; markdown: string } | null;
export function contributedAt<T>(state: EditorState, key: string, pos: number): T | undefined {
return state.languageDataAt<T>(key, pos)[0];
+145 -34
View File
@@ -23,12 +23,32 @@ import {
toggleCase,
unselectOccurrence,
} from "./cm/editing";
import { emptyStack, extendSelection, shrinkSelection, type SelectionStack, type SimpleRange } from "./model/selectionStack";
import {
emptyStack,
extendSelection,
shrinkSelection,
type SelectionStack,
type SimpleRange,
} from "./model/selectionStack";
import type { EditorSlot } from "./model/slot";
import { findAgain, openFindBar } from "./view/FindBar";
import { collapseAll, collapseRecursively, expandAll, expandRecursively, 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, cursorMatchBrace, 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";
@@ -86,19 +106,32 @@ export function syntaxCandidates(state: EditorState, sel: SimpleRange): SimpleRa
const word = state.wordAt(sel.from);
if (word) out.push({ from: word.from, to: word.to });
const tree = syntaxTree(state);
for (let node: ReturnType<typeof tree.resolveInner> | null = tree.resolveInner(sel.from, 1); node; node = node.parent) {
for (
let node: ReturnType<typeof tree.resolveInner> | null = tree.resolveInner(sel.from, 1);
node;
node = node.parent
) {
out.push({ from: node.from, to: node.to });
const text = state.sliceDoc(node.from, Math.min(node.to, node.from + 1)) + state.sliceDoc(Math.max(node.from, node.to - 1), node.to);
const text =
state.sliceDoc(node.from, Math.min(node.to, node.from + 1)) +
state.sliceDoc(Math.max(node.from, node.to - 1), node.to);
if (/^[([{"'`][)\]}"'`]$/.test(text) && node.to - node.from > 2) out.push({ from: node.from + 1, to: node.to - 1 });
}
const line = state.doc.lineAt(sel.from);
const endLine = state.doc.lineAt(sel.to);
const indent = /^\s*/.exec(line.text)![0].length;
out.push({ from: line.from + indent, to: endLine.to }, { from: line.from, to: endLine.to }, { from: 0, to: state.doc.length });
out.push(
{ from: line.from + indent, to: endLine.to },
{ from: line.from, to: endLine.to },
{ from: 0, to: state.doc.length },
);
return out;
}
function rangesFromBackend(state: EditorState, ranges: Array<{ start: { line: number; col: number }; end: { line: number; col: number } }>): SimpleRange[] {
function rangesFromBackend(
state: EditorState,
ranges: Array<{ start: { line: number; col: number }; end: { line: number; col: number } }>,
): SimpleRange[] {
const off = (p: { line: number; col: number }) => {
if (p.line + 1 > state.doc.lines) return state.doc.length;
const l = state.doc.line(p.line + 1);
@@ -120,8 +153,14 @@ export interface BackendChain {
* unchanged and the selection still covers its position: every node around
* the selection is around that position too. Repeated ⌥↑ then needs no IPC.
*/
export function reusableChain(chain: BackendChain | undefined, counter: number, current: SimpleRange): SimpleRange[] | null {
return chain && chain.counter === counter && chain.pos >= current.from && chain.pos <= current.to ? chain.ranges : null;
export function reusableChain(
chain: BackendChain | undefined,
counter: number,
current: SimpleRange,
): SimpleRange[] | null {
return chain && chain.counter === counter && chain.pos >= current.from && chain.pos <= current.to
? chain.ranges
: null;
}
const chains = new WeakMap<EditorSlot, BackendChain>();
@@ -156,10 +195,17 @@ async function extend(slot: EditorSlot) {
const results = currents.map((c, i) =>
extendSelection(prev[i] ?? emptyStack(), c, [...candidates[i]!, ...syntaxCandidates(v.state, c)]),
);
stacks.set(slot, results.map((r) => r.state));
stacks.set(
slot,
results.map((r) => r.state),
);
if (!results.some((r) => r.next)) return;
const ranges = results.map((r, i) => (r.next ? EditorSelection.range(r.next.from, r.next.to) : before.ranges[i]!));
v.dispatch({ selection: EditorSelection.create(ranges, before.mainIndex), scrollIntoView: true, userEvent: "select.extend" });
v.dispatch({
selection: EditorSelection.create(ranges, before.mainIndex),
scrollIntoView: true,
userEvent: "select.extend",
});
}
/** Extend / shrink run in order: a quick ⌥↑⌥↑ extends twice although extending waits for the backend. */
@@ -177,10 +223,17 @@ function shrink(slot: EditorSlot) {
const sel = view.state.selection;
const prev = stacks.get(slot) ?? [];
const results = sel.ranges.map((r, i) => shrinkSelection(prev[i] ?? emptyStack(), { from: r.from, to: r.to }));
stacks.set(slot, results.map((r) => r.state));
stacks.set(
slot,
results.map((r) => r.state),
);
if (!results.some((r) => r.next)) return;
const ranges = results.map((r, i) => (r.next ? EditorSelection.range(r.next.from, r.next.to) : sel.ranges[i]!));
view.dispatch({ selection: EditorSelection.create(ranges, sel.mainIndex), scrollIntoView: true, userEvent: "select.shrink" });
view.dispatch({
selection: EditorSelection.create(ranges, sel.mainIndex),
scrollIntoView: true,
userEvent: "select.shrink",
});
}
// --- structural editing without a Lezer tree ------------------------------------------
@@ -231,7 +284,8 @@ function moveStatement(view: EditorView, slot: EditorSlot, up: boolean) {
const v = slot.view;
if (!v) return;
const language = slot.model.getMeta().language;
if ((await hasStructure(language)) && (await moveStatementStructural(v, up, queriesFor(slot.model))) !== "fallback") return;
if ((await hasStructure(language)) && (await moveStatementStructural(v, up, queriesFor(slot.model))) !== "fallback")
return;
if (slot.view === v) (up ? moveLineUp : moveLineDown)(v);
});
}
@@ -246,11 +300,16 @@ function completeCurrentStatement(view: EditorView, slot: EditorSlot) {
if (completionStatus(view.state) === "active") acceptCompletion(view);
const language = slot.model.getMeta().language;
const sel = view.state.selection;
if ((hasLezerTree(view.state) && language !== "java") || sel.ranges.length > 1) return completeStatement(language)(view);
if ((hasLezerTree(view.state) && language !== "java") || sel.ranges.length > 1)
return completeStatement(language)(view);
void queueEdit(slot, async () => {
const v = slot.view;
if (!v || v.state.readOnly) return;
if ((await hasStructure(language)) && (await completeStatementStructural(v, language, queriesFor(slot.model))) !== "fallback") return;
if (
(await hasStructure(language)) &&
(await completeStatementStructural(v, language, queriesFor(slot.model))) !== "fallback"
)
return;
if (slot.view === v) completeStatement(language)(v);
});
}
@@ -299,7 +358,8 @@ function copyReference(view: EditorView, slot: EditorSlot) {
/** ⌃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" });
if (nextOccurrence(view) === "notFound")
void executeAction("editor.showHint", { text: "Not found, press again to search from the top" });
}
/** Settings › Editor › Color Scheme switches that are also toggle actions. */
@@ -309,7 +369,11 @@ export async function toggleSchemeSwitch(key: SchemeSwitch, on = !editorPrefs()[
try {
useProject.setState({ settings: await call(commands.settingsSet("global", `editor.${key}`, JSON.stringify(on))) });
} catch (e) {
notifications().notify({ level: "error", title: "Cannot change the setting", body: e instanceof Error ? e.message : String(e) });
notifications().notify({
level: "error",
title: "Cannot change the setting",
body: e instanceof Error ? e.message : String(e),
});
}
}
@@ -326,7 +390,12 @@ const schemeSwitch = (id: string, title: string, key: SchemeSwitch, keywords: st
export function registerEditorActions(): () => void {
const ctrl = editorController();
const text = (id: string, title: string, run: (view: EditorView, slot: EditorSlot) => unknown, extra: Partial<Action> = {}): Action => ({
const text = (
id: string,
title: string,
run: (view: EditorView, slot: EditorSlot) => unknown,
extra: Partial<Action> = {},
): Action => ({
id,
title,
category: "Code",
@@ -358,22 +427,34 @@ export function registerEditorActions(): () => void {
text("code.moveLineDown", "Move Line Down", (v) => moveLineDown(v)),
text("code.moveStatementUp", "Move Statement Up", (v, slot) => moveStatement(v, slot, true)),
text("code.moveStatementDown", "Move Statement Down", (v, slot) => moveStatement(v, slot, false)),
text("code.extendSelection", "Extend Selection", (_v, slot) => void queueSelection(slot, extend), { category: "Edit" }),
text("code.shrinkSelection", "Shrink Selection", (_v, slot) => void queueSelection(slot, shrink), { category: "Edit" }),
text("code.extendSelection", "Extend Selection", (_v, slot) => void queueSelection(slot, extend), {
category: "Edit",
}),
text("code.shrinkSelection", "Shrink Selection", (_v, slot) => void queueSelection(slot, shrink), {
category: "Edit",
}),
text("code.fold", "Collapse", (v) => foldAtCaret(v), { category: "Folding", keywords: ["fold"] }),
text("code.unfold", "Expand", (v) => unfoldAtCaret(v), { category: "Folding", keywords: ["unfold"] }),
text("code.foldToggle", "Toggle Fold", (v) => toggleFold(v), { category: "Folding" }),
text("code.cyclicExpandWord", "Cyclic Expand Word", (v, slot) => expandWord(v, slot, "backward"), {
keywords: ["hippie", "complete word", "dabbrev"],
}),
text("code.cyclicExpandWordBackward", "Cyclic Expand Word (Backward)", (v, slot) => expandWord(v, slot, "forward"), {
text(
"code.cyclicExpandWordBackward",
"Cyclic Expand Word (Backward)",
(v, slot) => expandWord(v, slot, "forward"),
{
keywords: ["hippie", "complete word", "dabbrev"],
}),
},
),
text("editor.copyReference", "Copy Reference", (v, slot) => copyReference(v, slot), {
category: "Edit",
keywords: ["fully qualified name", "fqn", "copy path"],
}),
text("code.pasteAsPlainText", "Paste as Plain Text", (v) => pasteAsPlainText(v), { category: "Edit", icon: "paste" }),
text("code.pasteAsPlainText", "Paste as Plain Text", (v) => pasteAsPlainText(v), {
category: "Edit",
icon: "paste",
}),
{
id: "editor.toggleSoftWrap",
title: "Soft-Wrap",
@@ -386,17 +467,29 @@ export function registerEditorActions(): () => void {
},
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.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)),
text("code.startNewLineBefore", "Start New Line Before Current", (v) => startNewLineBefore(v)),
text("code.splitLine", "Split Line", (v) => splitLine(v)),
text("editor.scrollToCenter", "Scroll to Center", (v) => scrollToCenter(v), { category: "Editor Actions" }),
text("editor.codeBlockStart", "Move Caret to Code Block Start", (v) => cursorCodeBlockStart(v), { category: "Editor Actions" }),
text("editor.codeBlockEnd", "Move Caret to Code Block End", (v) => cursorCodeBlockEnd(v), { category: "Editor Actions" }),
text("editor.codeBlockStart", "Move Caret to Code Block Start", (v) => cursorCodeBlockStart(v), {
category: "Editor Actions",
}),
text("editor.codeBlockEnd", "Move Caret to Code Block End", (v) => cursorCodeBlockEnd(v), {
category: "Editor Actions",
}),
text("editor.cloneCaretAbove", "Clone Caret Above", (v) => addCursorAbove(v), { category: "Editor Actions" }),
text("editor.cloneCaretBelow", "Clone Caret Below", (v) => addCursorBelow(v), { category: "Editor Actions" }),
text("editor.matchBrace", "Move Caret to Matching Brace", (v) => cursorMatchBrace(v), {
@@ -414,12 +507,19 @@ export function registerEditorActions(): () => void {
}),
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" }),
text("code.addCaretsToLineEnds", "Add Carets to Ends of Selected Lines", (v) => addCaretsToLineEnds(v), {
category: "Edit",
}),
text("code.unselectOccurrence", "Unselect Occurrence", (v) => unselectOccurrence(v), { category: "Edit" }),
text("code.pasteFromHistory", "Paste from History…", (v) => pasteFromHistory(v), { category: "Edit", keywords: ["clipboard", "paste multiple"] }),
text("code.pasteFromHistory", "Paste from History…", (v) => pasteFromHistory(v), {
category: "Edit",
keywords: ["clipboard", "paste multiple"],
}),
text("code.sortLines", "Sort Lines", (v) => sortLines(v), { category: "Edit" }),
text("code.reverseLines", "Reverse Lines", (v) => reverseLines(v), { category: "Edit" }),
text("code.unwrap", "Unwrap/Remove…", (_v, slot) => void unwrapAtCaret(slot), { keywords: ["unwrap", "remove if"] }),
text("code.unwrap", "Unwrap/Remove…", (_v, slot) => void unwrapAtCaret(slot), {
keywords: ["unwrap", "remove if"],
}),
text("code.foldSelection", "Fold Selection / Remove Region", (v) => foldSelection(v), { category: "Folding" }),
{
id: "editor.toggleColumnSelection",
@@ -441,7 +541,12 @@ export function registerEditorActions(): () => void {
when: "projectOpen",
keywords: ["synchronize", "refresh", "revert"],
run: ({ arg }) => {
const paths = typeof arg === "string" ? [arg] : Array.isArray(arg) ? arg.filter((p): p is string => typeof p === "string") : [];
const paths =
typeof arg === "string"
? [arg]
: Array.isArray(arg)
? arg.filter((p): p is string => typeof p === "string")
: [];
const active = ctrl.activePath();
return ctrl.reloadFromDisk(paths.length ? paths : active ? [active] : []);
},
@@ -622,7 +727,13 @@ export function registerEditorActions(): () => void {
run: () => ctrl.reopenClosed(),
},
{ id: "editor.nextTab", title: "Select Next Tab", category: "Window", when: TABS_WHEN, run: () => ctrl.cycle(1) },
{ id: "editor.prevTab", title: "Select Previous Tab", category: "Window", when: TABS_WHEN, run: () => ctrl.cycle(-1) },
{
id: "editor.prevTab",
title: "Select Previous Tab",
category: "Window",
when: TABS_WHEN,
run: () => ctrl.cycle(-1),
},
{
id: "editor.splitRight",
title: "Split Right",
+128 -16
View File
@@ -93,23 +93,131 @@ interface MemDoc {
}
const EXT_LANG: Record<string, string> = {
ts: "typescript", tsx: "typescriptreact", js: "javascript", jsx: "javascriptreact", rs: "rust", kt: "kotlin",
kts: "kotlin", java: "java", py: "python", go: "go", json: "json", md: "markdown", css: "css", html: "html",
yml: "yaml", yaml: "yaml", toml: "toml", c: "c", cpp: "cpp", h: "c", sh: "shellscript", sql: "sql", xml: "xml",
ts: "typescript",
tsx: "typescriptreact",
js: "javascript",
jsx: "javascriptreact",
rs: "rust",
kt: "kotlin",
kts: "kotlin",
java: "java",
py: "python",
go: "go",
json: "json",
md: "markdown",
css: "css",
html: "html",
yml: "yaml",
yaml: "yaml",
toml: "toml",
c: "c",
cpp: "cpp",
h: "c",
sh: "shellscript",
sql: "sql",
xml: "xml",
};
const MEM_LANGUAGES: LanguageInfo[] = [
{ id: "typescript", name: "TypeScript", extensions: ["ts"], uiMode: "TypeScript", lineComment: "//", blockComment: ["/*", "*/"], indent: { tabSize: 2, insertSpaces: true } },
{ id: "typescriptreact", name: "TypeScript JSX", extensions: ["tsx"], uiMode: "TSX", lineComment: "//", blockComment: ["/*", "*/"], indent: { tabSize: 2, insertSpaces: true } },
{ id: "javascript", name: "JavaScript", extensions: ["js"], uiMode: "JavaScript", lineComment: "//", blockComment: ["/*", "*/"], indent: { tabSize: 2, insertSpaces: true } },
{ id: "rust", name: "Rust", extensions: ["rs"], uiMode: "Rust", lineComment: "//", blockComment: ["/*", "*/"], indent: null },
{ id: "kotlin", name: "Kotlin", extensions: ["kt", "kts"], uiMode: "Kotlin", lineComment: "//", blockComment: ["/*", "*/"], indent: null },
{ id: "java", name: "Java", extensions: ["java"], uiMode: "Java", lineComment: "//", blockComment: ["/*", "*/"], indent: null },
{ id: "python", name: "Python", extensions: ["py"], uiMode: "Python", lineComment: "#", blockComment: null, indent: null },
{ id: "json", name: "JSON", extensions: ["json"], uiMode: "JSON", lineComment: null, blockComment: null, indent: { tabSize: 2, insertSpaces: true } },
{ id: "markdown", name: "Markdown", extensions: ["md"], uiMode: "Markdown", lineComment: null, blockComment: ["<!--", "-->"], indent: null },
{ id: "yaml", name: "YAML", extensions: ["yml", "yaml"], uiMode: "YAML", lineComment: "#", blockComment: null, indent: { tabSize: 2, insertSpaces: true } },
{ id: "plaintext", name: "Plain Text", extensions: ["txt"], uiMode: null, lineComment: null, blockComment: null, indent: null },
{
id: "typescript",
name: "TypeScript",
extensions: ["ts"],
uiMode: "TypeScript",
lineComment: "//",
blockComment: ["/*", "*/"],
indent: { tabSize: 2, insertSpaces: true },
},
{
id: "typescriptreact",
name: "TypeScript JSX",
extensions: ["tsx"],
uiMode: "TSX",
lineComment: "//",
blockComment: ["/*", "*/"],
indent: { tabSize: 2, insertSpaces: true },
},
{
id: "javascript",
name: "JavaScript",
extensions: ["js"],
uiMode: "JavaScript",
lineComment: "//",
blockComment: ["/*", "*/"],
indent: { tabSize: 2, insertSpaces: true },
},
{
id: "rust",
name: "Rust",
extensions: ["rs"],
uiMode: "Rust",
lineComment: "//",
blockComment: ["/*", "*/"],
indent: null,
},
{
id: "kotlin",
name: "Kotlin",
extensions: ["kt", "kts"],
uiMode: "Kotlin",
lineComment: "//",
blockComment: ["/*", "*/"],
indent: null,
},
{
id: "java",
name: "Java",
extensions: ["java"],
uiMode: "Java",
lineComment: "//",
blockComment: ["/*", "*/"],
indent: null,
},
{
id: "python",
name: "Python",
extensions: ["py"],
uiMode: "Python",
lineComment: "#",
blockComment: null,
indent: null,
},
{
id: "json",
name: "JSON",
extensions: ["json"],
uiMode: "JSON",
lineComment: null,
blockComment: null,
indent: { tabSize: 2, insertSpaces: true },
},
{
id: "markdown",
name: "Markdown",
extensions: ["md"],
uiMode: "Markdown",
lineComment: null,
blockComment: ["<!--", "-->"],
indent: null,
},
{
id: "yaml",
name: "YAML",
extensions: ["yml", "yaml"],
uiMode: "YAML",
lineComment: "#",
blockComment: null,
indent: { tabSize: 2, insertSpaces: true },
},
{
id: "plaintext",
name: "Plain Text",
extensions: ["txt"],
uiMode: null,
lineComment: null,
blockComment: null,
indent: null,
},
];
const SAMPLE_KOTLIN = `package com.acme.orders
@@ -314,7 +422,10 @@ export function createMemoryBackend(files: Record<string, string> = {}): MemoryB
async outline(doc) {
// Brace-based outline good enough for the dev shell.
const d = get(doc);
const posAt = ((index) => (_text: string, offset: number) => index(offset))(lineIndex(d.text));
const posAt = (
(index) => (_text: string, offset: number) =>
index(offset)
)(lineIndex(d.text));
const items: OutlineItem[] = [];
const re = /\b(class|fun|function|fn|interface|object)\s+([A-Za-z_]\w*)/g;
for (let m = re.exec(d.text); m; m = re.exec(d.text)) {
@@ -342,7 +453,8 @@ export function createMemoryBackend(files: Record<string, string> = {}): MemoryB
};
const parent = items.findLast(
(p) =>
(p.range.start.line < start.line || (p.range.start.line === start.line && p.range.start.col <= start.col)) &&
(p.range.start.line < start.line ||
(p.range.start.line === start.line && p.range.start.col <= start.col)) &&
(p.range.end.line > start.line || (p.range.end.line === start.line && p.range.end.col >= start.col)),
);
if (parent) parent.children.push(item);
+14 -2
View File
@@ -28,9 +28,21 @@ describe("clipboard history (Paste from History)", () => {
it("copies the selections, or whole caret lines without a selection", () => {
const doc = "one\ntwo\nthree";
const ext = EditorState.allowMultipleSelections.of(true);
const sel = EditorState.create({ doc, extensions: ext, selection: EditorSelection.create([EditorSelection.range(0, 3), EditorSelection.range(8, 13)]) });
const sel = EditorState.create({
doc,
extensions: ext,
selection: EditorSelection.create([EditorSelection.range(0, 3), EditorSelection.range(8, 13)]),
});
expect(copiedText(sel)).toBe("one\nthree");
const carets = EditorState.create({ doc, extensions: ext, selection: EditorSelection.create([EditorSelection.cursor(1), EditorSelection.cursor(2), EditorSelection.cursor(5)]) });
const carets = EditorState.create({
doc,
extensions: ext,
selection: EditorSelection.create([
EditorSelection.cursor(1),
EditorSelection.cursor(2),
EditorSelection.cursor(5),
]),
});
expect(copiedText(carets)).toBe("one\ntwo\n");
});
+5 -1
View File
@@ -28,7 +28,11 @@ export function clearClipboardHistory() {
/** What CodeMirror copies: the selections, or the caret lines when nothing is selected. */
export function copiedText(state: EditorState): string {
const ranges = state.selection.ranges;
if (ranges.some((r) => !r.empty)) return ranges.filter((r) => !r.empty).map((r) => state.sliceDoc(r.from, r.to)).join(state.lineBreak);
if (ranges.some((r) => !r.empty))
return ranges
.filter((r) => !r.empty)
.map((r) => state.sliceDoc(r.from, r.to))
.join(state.lineBreak);
const lines: string[] = [];
let last = -1;
for (const r of ranges) {
+8 -2
View File
@@ -14,7 +14,12 @@ afterEach(() => views.splice(0).forEach((v) => v.destroy()));
const braceIndent = indentService.of((cx, pos) => {
const line = cx.lineAt(pos, -1);
const base = /^ */.exec(line.text)![0].length;
return line.text.slice(0, pos - line.from).trimEnd().endsWith("{") ? base + 2 : base;
return line.text
.slice(0, pos - line.from)
.trimEnd()
.endsWith("{")
? base + 2
: base;
});
/** `|` marks carets. */
@@ -96,7 +101,8 @@ describe("code block navigation", () => {
describe("editor base keymap", () => {
it("drops CodeMirror defaults that IDEA uses for other actions", () => {
const runs = editorBaseKeymap.map((b) => b.run);
for (const cmd of [insertBlankLine, toggleTabFocusMode, selectLine, addCursorAbove]) expect(runs).not.toContain(cmd);
for (const cmd of [insertBlankLine, toggleTabFocusMode, selectLine, addCursorAbove])
expect(runs).not.toContain(cmd);
expect(editorBaseKeymap.some((b) => b.key === "Mod-Enter" || b.key === "Ctrl-m")).toBe(false);
expect(editorBaseKeymap.some((b) => b.win === "Ctrl-Shift-z")).toBe(true);
});
+5 -1
View File
@@ -125,6 +125,10 @@ export const cursorMatchBrace: Command = (view) => {
return EditorSelection.cursor(target);
});
if (!moved) return false;
view.dispatch({ selection: EditorSelection.create(ranges, state.selection.mainIndex), scrollIntoView: true, userEvent: "select.navigate" });
view.dispatch({
selection: EditorSelection.create(ranges, state.selection.mainIndex),
scrollIntoView: true,
userEvent: "select.navigate",
});
return true;
};
+3 -1
View File
@@ -6,7 +6,9 @@ const doc = (text: string) => EditorState.create({ doc: text });
describe("code lens layout", () => {
it("places lenses above attributes, annotations and doc comments", () => {
const s = doc("mod a {\n /// Docs\n #[test]\n #[ignore]\n fn t() {}\n}\n@Test\nvoid x() {}\n/**\n * doc\n */\nfunction f() {}");
const s = doc(
"mod a {\n /// Docs\n #[test]\n #[ignore]\n fn t() {}\n}\n@Test\nvoid x() {}\n/**\n * doc\n */\nfunction f() {}",
);
expect(s.doc.lineAt(declarationStart(s, 5)).number).toBe(2);
expect(s.doc.lineAt(declarationStart(s, 8)).number).toBe(7);
expect(s.doc.lineAt(declarationStart(s, 12)).number).toBe(9);
+10 -6
View File
@@ -7,7 +7,10 @@ import { Decoration, EditorView, WidgetType, type DecorationSet } from "@codemir
import { codeLenses, type CodeLens } from "@/editor/api";
/** Groups lenses by line start, ordered by line then `order`. Exported for tests. */
export function groupLenses(state: EditorState, lenses: readonly CodeLens[]): Array<{ lineFrom: number; lenses: CodeLens[] }> {
export function groupLenses(
state: EditorState,
lenses: readonly CodeLens[],
): Array<{ lineFrom: number; lenses: CodeLens[] }> {
const byLine = new Map<number, CodeLens[]>();
const len = state.doc.length;
for (const l of lenses) {
@@ -68,7 +71,11 @@ class LensWidget extends WidgetType {
}
private get key(): string {
return this.indent + "\u0000" + this.lenses.map((l) => `${l.icon ?? ""}|${l.title}|${l.tooltip ?? ""}|${l.run ? 1 : 0}`).join("\u0001");
return (
this.indent +
"\u0000" +
this.lenses.map((l) => `${l.icon ?? ""}|${l.title}|${l.tooltip ?? ""}|${l.run ? 1 : 0}`).join("\u0001")
);
}
override eq(other: LensWidget): boolean {
@@ -184,7 +191,4 @@ const lensTheme = EditorView.theme({
});
/** Base extension (always installed): no cost until a provider contributes lenses. */
export const codeLensExtension: Extension = [
EditorView.decorations.compute([codeLenses], buildDecorations),
lensTheme,
];
export const codeLensExtension: Extension = [EditorView.decorations.compute([codeLenses], buildDecorations), lensTheme];
+5 -1
View File
@@ -12,7 +12,11 @@ afterEach(() => {
function make(doc: string, at: number) {
view = new EditorView({
state: EditorState.create({ doc, selection: { anchor: at }, extensions: [columnModeExtension, EditorState.allowMultipleSelections.of(true)] }),
state: EditorState.create({
doc,
selection: { anchor: at },
extensions: [columnModeExtension, EditorState.allowMultipleSelections.of(true)],
}),
parent: document.body,
});
return view;
+19 -3
View File
@@ -2,7 +2,16 @@
* 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, countColumn, findColumn, 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. */
@@ -79,7 +88,12 @@ export function extendBlock(view: EditorView, dLine: number, dCol: number): bool
const a = state.doc.lineAt(main.anchor);
const h = state.doc.lineAt(main.head);
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) };
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: stepColumn(state.doc.line(b.headLine).text, b.headCol, dCol, state.tabSize) };
@@ -113,5 +127,7 @@ export const columnModeExtension: Extension = [
{ key: "Shift-ArrowRight", run: (v) => extendBlock(v, 0, 1) },
]),
),
EditorView.editorAttributes.compute([columnModeField], (s): Record<string, string> => (isColumnMode(s) ? { class: "cm-openidea-columnMode" } : {})),
EditorView.editorAttributes.compute([columnModeField], (s): Record<string, string> =>
isColumnMode(s) ? { class: "cm-openidea-columnMode" } : {},
),
];
+15 -4
View File
@@ -1,7 +1,14 @@
/**
* Comment with Line Comment and Enter inside a line comment, as in IDEA.
*/
import { EditorSelection, Facet, countColumn, type ChangeSpec, type EditorState, type SelectionRange } from "@codemirror/state";
import {
EditorSelection,
Facet,
countColumn,
type ChangeSpec,
type EditorState,
type SelectionRange,
} from "@codemirror/state";
import type { Command, EditorView } from "@codemirror/view";
import { indentString, syntaxTree } from "@codemirror/language";
import { toggleComment } from "@codemirror/commands";
@@ -57,12 +64,16 @@ export const commentLine: Command = (view) => {
if (!block) blocks.push(target);
target.ranges.push(i);
target.endLine = Math.max(target.endLine, endLine);
const wholeLines = r.empty || (r.from === doc.lineAt(r.from).from && r.to === doc.lineAt(r.to).from && r.to > r.from);
const wholeLines =
r.empty || (r.from === doc.lineAt(r.from).from && r.to === doc.lineAt(r.to).from && r.to > r.from);
const emptyLine = !doc.lineAt(r.from).text.trim();
target.caretUpdate = r.empty && emptyLine && !repeated ? "commentStart" : r.empty ? "down" : wholeLines ? "restoreSelection" : null;
target.caretUpdate =
r.empty && emptyLine && !repeated ? "commentStart" : r.empty ? "down" : wholeLines ? "restoreSelection" : null;
}
const tokens = blocks.map((b) => lineToken(state, doc.line(b.startLine).from + leadingWs(doc.line(b.startLine).text)));
const tokens = blocks.map((b) =>
lineToken(state, doc.line(b.startLine).from + leadingWs(doc.line(b.startLine).text)),
);
// Languages without line comments (CSS, HTML…): block comments.
if (tokens.some((t) => !t)) return toggleComment(view);
+5 -1
View File
@@ -16,7 +16,11 @@ function mount(s: EditorSettings | undefined) {
parent,
state: EditorState.create({
doc: "fn main() {}\n",
extensions: [lineNumbersExtension(s), distractionFreeExtension(s), prefsFor({ settings: s, path: "/p/a.rs", info: undefined, readOnly: false })],
extensions: [
lineNumbersExtension(s),
distractionFreeExtension(s),
prefsFor({ settings: s, path: "/p/a.rs", info: undefined, readOnly: false }),
],
}),
});
return { view, dispose: () => (view.destroy(), parent.remove()) };
+22 -4
View File
@@ -54,7 +54,9 @@ 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);
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;
}
@@ -98,8 +100,21 @@ describe("join lines", () => {
});
it("merges consecutive line comments and keeps brackets tight", () => {
expect((() => { const v = make("// one|\n// two"); joinLines(v); return v.state.doc.toString(); })()).toBe("// one two");
expect((() => { const v = make("f(|\n x\n)"); joinLines(v); joinLines(v); return v.state.doc.toString(); })()).toBe("f(x)");
expect(
(() => {
const v = make("// one|\n// two");
joinLines(v);
return v.state.doc.toString();
})(),
).toBe("// one two");
expect(
(() => {
const v = make("f(|\n x\n)");
joinLines(v);
joinLines(v);
return v.state.doc.toString();
})(),
).toBe("f(x)");
});
it("does nothing on the last line", () => {
@@ -163,7 +178,10 @@ describe("multiple carets", () => {
expect(toggleCaret(start, 3, 3).ranges.map((r) => r.head)).toEqual([1, 3, 5]);
expect(toggleCaret(start, 5, 5).ranges.map((r) => r.head)).toEqual([1]);
expect(toggleCaret(EditorSelection.single(2), 2, 2).ranges.map((r) => r.head)).toEqual([2]);
expect(toggleCaret(EditorSelection.single(0), 2, 4).ranges.map((r) => [r.from, r.to])).toEqual([[0, 0], [2, 4]]);
expect(toggleCaret(EditorSelection.single(0), 2, 4).ranges.map((r) => [r.from, r.to])).toEqual([
[0, 0],
[2, 4],
]);
});
});
+118 -26
View File
@@ -13,7 +13,14 @@ import {
type TransactionSpec,
} from "@codemirror/state";
import { EditorView, type Command } from "@codemirror/view";
import { foldedRanges, forceParsing, syntaxTree, getIndentUnit, indentString, getIndentation } from "@codemirror/language";
import {
foldedRanges,
forceParsing,
syntaxTree,
getIndentUnit,
indentString,
getIndentation,
} from "@codemirror/language";
import {
copyLineDown,
deleteLine as deleteDocLine,
@@ -69,7 +76,9 @@ function deleteHump(forward: boolean, camel = true): Command {
const to = Math.max(target, r.head);
return { changes: { from, to }, range: EditorSelection.cursor(from) };
});
view.dispatch(view.state.update(tr, { scrollIntoView: true, userEvent: forward ? "delete.forward" : "delete.backward" }));
view.dispatch(
view.state.update(tr, { scrollIntoView: true, userEvent: forward ? "delete.forward" : "delete.backward" }),
);
return true;
};
}
@@ -89,7 +98,7 @@ export function visualLineSpan(state: EditorState, 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; ) {
for (let changed = true; changed;) {
changed = false;
folded.between(a, b, (f, t) => {
const na = Math.min(a, state.doc.lineAt(f).from);
@@ -127,12 +136,19 @@ export const duplicateLineOrSelection: Command = (view) => {
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 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" });
view.dispatch({
changes,
selection: EditorSelection.create(ranges, state.selection.mainIndex),
scrollIntoView: true,
userEvent: "input.duplicate",
});
return true;
}
const tr = state.changeByRange((r) => {
@@ -169,7 +185,12 @@ export const deleteLine: Command = (view) => {
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" });
view.dispatch({
changes,
selection: EditorSelection.create(ranges, state.selection.mainIndex),
scrollIntoView: true,
userEvent: "delete.line",
});
return true;
};
@@ -194,7 +215,8 @@ export const occurrenceSearch = StateField.define<OccurrenceSearch | null>({
});
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));
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 {
@@ -221,7 +243,9 @@ function* matches(state: EditorState, text: string, from: number, wholeWords: bo
/** 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);
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
@@ -323,13 +347,15 @@ function statementStartingAt(state: EditorState, lineNo: number): Node | null {
}
/** 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);
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;
for (let n: Node | null = syntaxTree(state).resolveInner(start, 1); n && n.parent; n = n.parent)
if (isContainer(n.parent)) return n;
return null;
}
@@ -460,7 +486,7 @@ function shiftToIndentation(state: EditorState, from: number, to: number) {
const delta = proper - countColumn(/^\s*/.exec(first.text)![0], state.tabSize);
if (!delta) return null;
const changes: ChangeSpec[] = [];
for (let pos = from; pos <= to; ) {
for (let pos = from; pos <= to;) {
const line = state.doc.lineAt(pos);
pos = line.to + 1;
const ws = /^\s*/.exec(line.text)![0];
@@ -494,18 +520,53 @@ export const moveStatementDown = moveStatement(false);
// --- complete statement -------------------------------------------------------
export const SEMICOLON_LANGS = new Set([
"java", "c", "cpp", "csharp", "javascript", "javascriptreact", "typescript", "typescriptreact", "rust", "php",
"dart", "objective-c", "objective-cpp", "groovy", "zig",
"java",
"c",
"cpp",
"csharp",
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"rust",
"php",
"dart",
"objective-c",
"objective-cpp",
"groovy",
"zig",
]);
/** Blocks close with a keyword (`end`) or by indentation: never complete them with braces. */
export const KEYWORD_BLOCK_LANGS = new Set([
"python", "ruby", "lua", "elixir", "haskell", "shellscript", "julia", "erlang", "fsharp", "ocaml", "ocaml.interface",
"yaml", "markdown", "plaintext", "makefile", "cmake", "toml", "ini", "properties", "sql", "r", "perl", "powershell",
"python",
"ruby",
"lua",
"elixir",
"haskell",
"shellscript",
"julia",
"erlang",
"fsharp",
"ocaml",
"ocaml.interface",
"yaml",
"markdown",
"plaintext",
"makefile",
"cmake",
"toml",
"ini",
"properties",
"sql",
"r",
"perl",
"powershell",
]);
/** A line opening an indented body in `end` / layout languages (Ruby, Lua, Elixir, Haskell…). */
const KEYWORD_HEAD =
/\b(do(\s*\|[^|]*\|)?|then|where|of)$|^\s*(def|defp|defmodule|class|module|function|local function|if|unless|elsif|elseif|else|while|until|for|case|begin)\b/;
const BLOCK_HEAD = /^\s*(if|else|for|while|do|switch|try|catch|finally|fn|fun|func|function|class|struct|enum|impl|trait|interface|object|match|when|loop|unsafe|mod|namespace)\b/;
const BLOCK_HEAD =
/^\s*(if|else|for|while|do|switch|try|catch|finally|fn|fun|func|function|class|struct|enum|impl|trait|interface|object|match|when|loop|unsafe|mod|namespace)\b/;
/** Offset in `text` where a trailing line comment starts (outside string literals), else `text.length`. */
export function codeEnd(text: string, lineComment: string | undefined): number {
@@ -548,7 +609,11 @@ export function completeStatementSpec(state: EditorState, language: string): Tra
const from = line.from + text.length;
return {
// A trailing comment moves to the end of the header line.
changes: { from, to: line.from + full.length, insert: insert.slice(0, 2) + full.slice(text.length) + insert.slice(2) },
changes: {
from,
to: line.from + full.length,
insert: insert.slice(0, 2) + full.slice(text.length) + insert.slice(2),
},
selection: { anchor: from + 2 + full.length - text.length + br.length + baseIndent.length + unit.length },
scrollIntoView: true,
userEvent: "input.complete",
@@ -556,9 +621,13 @@ export function completeStatementSpec(state: EditorState, language: string): Tra
}
let insert = "";
if (SEMICOLON_LANGS.has(language) && !comment && text.trim() && !/[;{},:]$/.test(text)) insert = ";";
if (language === "python" && BLOCK_HEAD.test(text.replace(/^\s*(def|elif)\b/, "if")) && !text.endsWith(":")) insert = ":";
if (language === "python" && BLOCK_HEAD.test(text.replace(/^\s*(def|elif)\b/, "if")) && !text.endsWith(":"))
insert = ":";
const opensBlock =
text.endsWith("{") || insert === ":" || text.endsWith(":") || (KEYWORD_BLOCK_LANGS.has(language) && KEYWORD_HEAD.test(text) && !/^\s*module\b.*\bwhere$/.test(text));
text.endsWith("{") ||
insert === ":" ||
text.endsWith(":") ||
(KEYWORD_BLOCK_LANGS.has(language) && KEYWORD_HEAD.test(text) && !/^\s*module\b.*\bwhere$/.test(text));
const nextIndent = baseIndent + (opensBlock ? unit : "");
const indentHere = getIndentation(state, line.to);
const newline = br + (indentHere !== null && !opensBlock ? indentString(state, indentHere) : nextIndent);
@@ -615,7 +684,10 @@ function newLineCommand(before: boolean): Command {
const computed = syntaxTree(state).length ? getIndentation(state, line.from) : null;
let indent = computed !== null ? indentString(state, computed) : own;
if (/^\s*[)\]}]/.test(line.text) && indent.length <= own.length) indent = own + unit;
return { changes: { from: line.from, insert: indent + state.lineBreak }, range: EditorSelection.cursor(line.from + indent.length) };
return {
changes: { from: line.from, insert: indent + state.lineBreak },
range: EditorSelection.cursor(line.from + indent.length),
};
});
view.dispatch(state.update(tr, { scrollIntoView: true, userEvent: "input" }));
return true;
@@ -665,13 +737,22 @@ export const joinLines: Command = (view) => {
// Drop the comment marker of a joined line comment (`// a` + `// b` → `// a b`).
const comment = /^(\/\/+|#+|--)\s?/.exec(nextText);
const curTrim = cur.text.slice(0, cur.length - trail);
const skip = comment && new RegExp(`(^|\\s)${comment[1]!.replace(/[-/#]/g, "\\$&")}`).test(curTrim) ? comment[0].length : 0;
const sep = !curTrim.length || !nextText.length || /[([{]$/.test(curTrim) || /^[)\]}.,;]/.test(nextText.slice(skip)) ? "" : " ";
const skip =
comment && new RegExp(`(^|\\s)${comment[1]!.replace(/[-/#]/g, "\\$&")}`).test(curTrim) ? comment[0].length : 0;
const sep =
!curTrim.length || !nextText.length || /[([{]$/.test(curTrim) || /^[)\]}.,;]/.test(nextText.slice(skip))
? ""
: " ";
changes.push({ from: cur.to - trail, to: next.from + lead + skip, insert: sep });
if (n === first.number) caret = cur.to - trail;
}
const set = state.changes(changes);
return { changes: set, range: r.empty ? EditorSelection.cursor(set.mapPos(caret, -1)) : EditorSelection.range(set.mapPos(r.from, 1), set.mapPos(r.to, -1)) };
return {
changes: set,
range: r.empty
? EditorSelection.cursor(set.mapPos(caret, -1))
: EditorSelection.range(set.mapPos(r.from, 1), set.mapPos(r.to, -1)),
};
});
view.dispatch(state.update(tr, { scrollIntoView: true, userEvent: "input.join" }));
return true;
@@ -713,7 +794,9 @@ export const toggleCase: Command = (view) => {
const t = targets[state.selection.ranges.indexOf(r)];
if (!t) return { range: r };
const insert = caseOf(state, t.from, t.to, lower);
const range = r.empty ? EditorSelection.range(t.from, t.from + insert.length) : EditorSelection.range(r.anchor, r.head);
const range = r.empty
? EditorSelection.range(t.from, t.from + insert.length)
: EditorSelection.range(r.anchor, r.head);
return { changes: { from: t.from, to: t.to, insert }, range };
});
view.dispatch(state.update(tr, { userEvent: "input.case" }));
@@ -735,7 +818,11 @@ export const addCaretsToLineEnds: Command = (view) => {
for (let n = first; n <= last; n++) ranges.push(EditorSelection.cursor(state.doc.line(n).to));
}
if (ranges.length === state.selection.ranges.length && state.selection.ranges.every((r) => r.empty)) return false;
view.dispatch({ selection: EditorSelection.create(ranges, ranges.length - 1), scrollIntoView: true, userEvent: "select" });
view.dispatch({
selection: EditorSelection.create(ranges, ranges.length - 1),
scrollIntoView: true,
userEvent: "select",
});
return true;
};
@@ -759,6 +846,11 @@ export const unselectOccurrence: Command = (view) => {
main = i;
break;
}
view.dispatch({ selection: EditorSelection.create(ranges, main), effects, scrollIntoView: true, userEvent: "select" });
view.dispatch({
selection: EditorSelection.create(ranges, main),
effects,
scrollIntoView: true,
userEvent: "select",
});
return true;
};
+4 -1
View File
@@ -26,7 +26,10 @@ const views: EditorView[] = [];
afterEach(() => views.splice(0).forEach((v) => v.destroy()));
function make(doc: string, o: Partial<FindOptions>, scope?: { from: number; to: number }) {
const view = new EditorView({ state: EditorState.create({ doc, extensions: [findExtension()] }), parent: document.body });
const view = new EditorView({
state: EditorState.create({ doc, extensions: [findExtension()] }),
parent: document.body,
});
views.push(view);
const q = makeQuery({ search: "", caseSensitive: false, wholeWord: false, regexp: false, replace: "", ...o });
view.dispatch({ effects: [setSearchQuery.of(q), ...(scope ? [setFindScope.of(scope)] : [])] });
+22 -5
View File
@@ -7,7 +7,15 @@
* string literals) and excluded matches filter matches through the query's
* `test`, so CodeMirror's own commands honour them too.
*/
import { EditorSelection, Prec, StateEffect, StateField, type ChangeDesc, type EditorState, type Extension } from "@codemirror/state";
import {
EditorSelection,
Prec,
StateEffect,
StateField,
type ChangeDesc,
type EditorState,
type Extension,
} from "@codemirror/state";
import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from "@codemirror/view";
import {
SearchQuery,
@@ -52,7 +60,10 @@ export const findScope = StateField.define<FindScope | null>({
return v && tr.docChanged ? mapScope(v, tr.changes) : v;
},
// Lowest precedence: the scope's mark wraps the match marks, which stay visible inside it.
provide: (f) => Prec.lowest(EditorView.decorations.from(f, (s) => (s ? Decoration.set([scopeDeco.range(s.from, s.to)]) : Decoration.none))),
provide: (f) =>
Prec.lowest(
EditorView.decorations.from(f, (s) => (s ? Decoration.set([scopeDeco.range(s.from, s.to)]) : Decoration.none)),
),
});
/** IDEA's Exclude: matches skipped by Next / Previous and Replace All until the query changes. */
@@ -234,7 +245,10 @@ export function regexValid(source: string): boolean {
export const MAX_COUNT = 9_999;
/** Counts matches (capped) and the 1-based index of the selected one. */
export function countMatches(state: EditorState, budgetMs = 30): { total: number; current: number; capped: boolean; starts: number[] } {
export function countMatches(
state: EditorState,
budgetMs = 30,
): { total: number; current: number; capped: boolean; starts: number[] } {
const query = getSearchQuery(state);
if (!query.valid) return { total: 0, current: 0, capped: false, starts: [] };
const main = state.selection.main;
@@ -270,7 +284,8 @@ function matchesIn(state: EditorState, limit = Infinity): Match[] {
const scope = scopeOf(state);
const out: Match[] = [];
const cursor = query.getCursor(state, scope.from, scope.to);
for (let r = cursor.next(); !r.done && out.length < limit; r = cursor.next()) if (r.value.to > r.value.from) out.push(r.value as Match);
for (let r = cursor.next(); !r.done && out.length < limit; r = cursor.next())
if (r.value.to > r.value.from) out.push(r.value as Match);
return out;
}
@@ -293,7 +308,9 @@ function step(view: EditorView, forward: boolean): boolean {
const ms = matchesIn(view.state);
if (!ms.length) return false;
const { from, to } = view.state.selection.main;
const m = forward ? (ms.find((x) => x.from >= to && !(x.from === from && x.to === to)) ?? ms[0]!) : ([...ms].reverse().find((x) => x.to <= from) ?? ms[ms.length - 1]!);
const m = forward
? (ms.find((x) => x.from >= to && !(x.from === from && x.to === to)) ?? ms[0]!)
: ([...ms].reverse().find((x) => x.to <= from) ?? ms[ms.length - 1]!);
selectMatch(view, m);
return true;
}
+25 -6
View File
@@ -5,7 +5,15 @@
*/
import { Prec, StateEffect, StateField, type EditorState, type Extension, type Line } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import { codeFolding, foldEffect, foldGutter, foldService, foldable, foldedRanges, unfoldEffect } from "@codemirror/language";
import {
codeFolding,
foldEffect,
foldGutter,
foldService,
foldable,
foldedRanges,
unfoldEffect,
} from "@codemirror/language";
import type { FoldRange } from "@/platform/ipc";
export interface BackendFold {
@@ -182,7 +190,8 @@ export function foldSelection(view: EditorView): boolean {
const sel = view.state.selection;
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;
const after =
sel.ranges.some((r) => !r.empty) && folded.length ? { anchor: folded[folded.length - 1]!.to } : undefined;
view.dispatch({ effects, selection: after });
return true;
}
@@ -196,9 +205,13 @@ function isFolded(state: EditorState, r: { from: number; to: number }): boolean
}
/** 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 }> {
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; ) {
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);
@@ -268,9 +281,15 @@ function dispatchEffects(view: EditorView, effects: StateEffect<unknown>[]): boo
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)));
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)));
dispatchEffects(
view,
view.state.selection.ranges.flatMap((r) => expandRecursivelyEffects(view.state, r.head)),
);
function chevron(open: boolean): HTMLElement {
const span = document.createElement("span");
+27 -3
View File
@@ -6,7 +6,11 @@
import { HighlightStyle, type TagStyle } from "@codemirror/language";
import { tags as t, type Tag } from "@lezer/highlight";
function token(tag: Tag | Tag[], name: string, defaults: { italic?: string; bold?: string; effect?: string } = {}): TagStyle {
function token(
tag: Tag | Tag[],
name: string,
defaults: { italic?: string; bold?: string; effect?: string } = {},
): TagStyle {
return {
tag,
color: `var(--syn-${name})`,
@@ -21,7 +25,20 @@ export const openideaHighlightStyle = HighlightStyle.define([
// Markdown tags whole links / quotes; listed first so marks, URL and title inside keep their own colours.
token(t.link, "link", { effect: "underline" }),
token(t.quote, "string"),
token([t.keyword, t.controlKeyword, t.moduleKeyword, t.operatorKeyword, t.definitionKeyword, t.modifier, t.null, t.bool, t.unit], "keyword"),
token(
[
t.keyword,
t.controlKeyword,
t.moduleKeyword,
t.operatorKeyword,
t.definitionKeyword,
t.modifier,
t.null,
t.bool,
t.unit,
],
"keyword",
),
token(t.self, "self"),
token([t.string, t.special(t.string), t.character, t.attributeValue], "string"),
token(t.docString, "docstring", { italic: "italic" }),
@@ -31,7 +48,14 @@ export const openideaHighlightStyle = HighlightStyle.define([
token([t.typeName, t.className, t.namespace, t.definition(t.typeName)], "type"),
token([t.standard(t.typeName), t.standard(t.variableName), t.standard(t.function(t.variableName))], "builtin"),
token([t.function(t.variableName), t.function(t.propertyName)], "function-call"),
token([t.function(t.definition(t.variableName)), t.definition(t.function(t.variableName)), t.function(t.definition(t.propertyName))], "function"),
token(
[
t.function(t.definition(t.variableName)),
t.definition(t.function(t.variableName)),
t.function(t.definition(t.propertyName)),
],
"function",
),
token([t.comment, t.lineComment, t.blockComment], "comment", { italic: "var(--syn-comment-style)" }),
token(t.docComment, "doc-comment", { italic: "italic" }),
token([t.meta, t.annotation, t.macroName], "decorator"),
+4 -1
View File
@@ -13,7 +13,10 @@ function guideDeco(levels: number, step: number) {
let d = cache.get(key);
if (!d) {
d = Decoration.line({
attributes: { class: "cm-openidea-guides", style: `--openidea-guide-levels: ${levels}; --openidea-guide-step: ${step}` },
attributes: {
class: "cm-openidea-guides",
style: `--openidea-guide-levels: ${levels}; --openidea-guide-step: ${step}`,
},
});
cache.set(key, d);
}
+9 -2
View File
@@ -97,7 +97,11 @@ class OverviewLane {
rows.set(key, { top: prev?.top ?? top, height: Math.max(height, prev?.height ?? 0), mark: m });
}
const caret = pos(view.lineBlockAt(view.state.selection.main.head).top);
return { rows: [...rows.values()].sort((x, y) => (x.mark.priority ?? 0) - (y.mark.priority ?? 0)), caret, unit };
return {
rows: [...rows.values()].sort((x, y) => (x.mark.priority ?? 0) - (y.mark.priority ?? 0)),
caret,
unit,
};
},
write: ({ rows, caret, unit }) => {
const key =
@@ -107,7 +111,10 @@ class OverviewLane {
const frag = document.createDocumentFragment();
for (const r of rows) {
const el = document.createElement("div");
el.className = r.mark.kind === "thin" ? "cm-openidea-overviewMark cm-openidea-overviewMark-thin" : "cm-openidea-overviewMark";
el.className =
r.mark.kind === "thin"
? "cm-openidea-overviewMark cm-openidea-overviewMark-thin"
: "cm-openidea-overviewMark";
el.style.top = `${r.top}${unit}`;
el.style.height = `${r.height}${unit}`;
el.style.background = r.mark.color;
+7 -2
View File
@@ -61,14 +61,19 @@ export function findLanguageDescription(uiMode: string | null, path: string): La
const loose = LanguageDescription.matchLanguageName(languageData, uiMode, false);
if (loose) return loose;
}
return uiMode === null && infos ? null : LanguageDescription.matchFilename(languageData, path.slice(path.lastIndexOf("/") + 1));
return uiMode === null && infos
? null
: LanguageDescription.matchFilename(languageData, path.slice(path.lastIndexOf("/") + 1));
}
/** Loads (once) the CodeMirror language for `desc`. */
export function loadSupport(desc: LanguageDescription): Promise<LanguageSupport | null> {
let p = supports.get(desc.name);
if (!p) {
p = desc.load().then((support) => patchSupport(desc, support)).catch((e) => {
p = desc
.load()
.then((support) => patchSupport(desc, support))
.catch((e) => {
console.warn(`failed to load language ${desc.name}`, e);
supports.delete(desc.name);
return null;
+16 -3
View File
@@ -1,5 +1,11 @@
/** Edit › Sort Lines / Reverse Lines: the selected lines, or the whole file without a selection. */
import { EditorSelection, type ChangeSpec, type EditorState, type Transaction, type TransactionSpec } from "@codemirror/state";
import {
EditorSelection,
type ChangeSpec,
type EditorState,
type Transaction,
type TransactionSpec,
} from "@codemirror/state";
const collator = new Intl.Collator(undefined, { numeric: false, sensitivity: "variant" });
@@ -24,7 +30,11 @@ function blocks(state: EditorState): Array<{ from: number; to: number }> {
return out;
}
function rewrite(state: EditorState, transform: (lines: string[]) => string[], userEvent: string): TransactionSpec | null {
function rewrite(
state: EditorState,
transform: (lines: string[]) => string[],
userEvent: string,
): TransactionSpec | null {
const changes: ChangeSpec[] = [];
for (const b of blocks(state)) {
const text = state.sliceDoc(b.from, b.to);
@@ -39,7 +49,10 @@ function rewrite(state: EditorState, transform: (lines: string[]) => string[], u
return {
changes: set,
selection: hadSelection
? EditorSelection.create(state.selection.ranges.map((r) => EditorSelection.range(set.mapPos(r.anchor, -1), set.mapPos(r.head, 1))), state.selection.mainIndex)
? EditorSelection.create(
state.selection.ranges.map((r) => EditorSelection.range(set.mapPos(r.anchor, -1), set.mapPos(r.head, 1))),
state.selection.mainIndex,
)
: undefined,
scrollIntoView: true,
userEvent,
+11 -3
View File
@@ -25,10 +25,18 @@ describe("editor settings behaviour", () => {
expect(detectIndent("a\n\tb\n\t\tc\n")).toEqual({ insertSpaces: false });
expect(detectIndent("plain\ntext\n")).toBeNull();
const info = { id: "python", indent: { tabSize: 4, insertSpaces: true } } as Parameters<typeof effectiveIndent>[1];
const settings = { codeStyle: { languages: { python: { useTab: true, tabSize: 8 } }, detectIndents: true, editorconfig: false } } as unknown as EditorSettings;
const settings = {
codeStyle: { languages: { python: { useTab: true, tabSize: 8 } }, detectIndents: true, editorconfig: false },
} as unknown as EditorSettings;
expect(effectiveIndent(settings, info)).toEqual({ tabSize: 8, insertSpaces: false });
expect(effectiveIndent(settings, info, { indentSize: 2, indentStyle: "space" } as never)).toEqual({ tabSize: 8, insertSpaces: false });
expect(effectiveIndent(settings, info, null, { insertSpaces: true, size: 2 })).toEqual({ tabSize: 2, insertSpaces: true });
expect(effectiveIndent(settings, info, { indentSize: 2, indentStyle: "space" } as never)).toEqual({
tabSize: 8,
insertSpaces: false,
});
expect(effectiveIndent(settings, info, null, { insertSpaces: true, size: 2 })).toEqual({
tabSize: 2,
insertSpaces: true,
});
});
it("removes trailing blank lines and strips all lines on request", () => {
+54 -13
View File
@@ -3,7 +3,17 @@
* 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, type TransactionSpec } from "@codemirror/state";
import {
EditorSelection,
EditorState,
Prec,
RangeSetBuilder,
StateEffect,
StateField,
type Extension,
type Transaction,
type TransactionSpec,
} from "@codemirror/state";
import {
Decoration,
EditorView,
@@ -47,7 +57,13 @@ export function matchesFilePatterns(patterns: string, path: string): boolean {
.map((p) => p.trim())
.filter(Boolean)
.some((p) => {
const re = new RegExp(`^${p.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`, "i");
const re = new RegExp(
`^${p
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*")
.replace(/\?/g, ".")}$`,
"i",
);
return re.test(name);
});
}
@@ -80,7 +96,7 @@ const wrapIndent = ViewPlugin.fromClass(
const b = new RangeSetBuilder<Decoration>();
const tab = view.state.tabSize;
for (const { from, to } of view.visibleRanges) {
for (let pos = from; pos <= to; ) {
for (let pos = from; pos <= to;) {
const line = view.state.doc.lineAt(pos);
let cols = 0;
for (let i = 0; i < line.length; i++) {
@@ -193,7 +209,13 @@ function leadingColumns(text: string, tabSize: number): number {
}
/** Reformat on paste › Indent Block: re-indents a pasted block to the caret's indentation. */
export function indentPastedText(text: string, targetCol: number, unit: string, tabSize: number, unitCols = tabSize): string {
export function indentPastedText(
text: string,
targetCol: number,
unit: string,
tabSize: number,
unitCols = tabSize,
): string {
const lines = text.split("\n");
if (lines.length < 2) return text;
const rest = lines.slice(1).filter((l) => l.trim());
@@ -205,7 +227,8 @@ export function indentPastedText(text: string, targetCol: number, unit: string,
// A block opener copied from mid-line ("if x:", "fn f() {") sat one level above its body.
if (!firstIndented && base > 0 && /[:{([]\s*$/.test(lines[0]!)) base = Math.max(0, base - unitCols);
const delta = targetCol - base;
const pad = (n: number) => (unit === "\t" ? "\t".repeat(Math.floor(n / tabSize)) + " ".repeat(n % tabSize) : " ".repeat(n));
const pad = (n: number) =>
unit === "\t" ? "\t".repeat(Math.floor(n / tabSize)) + " ".repeat(n % tabSize) : " ".repeat(n);
return lines
.map((l, i) => {
if (i === 0) return l.trimStart();
@@ -394,7 +417,11 @@ const OPENERS: Record<string, true> = { "(": true, "[": true, "{": true, '"': tr
/** Typing a bracket / quote over a selection replaces it instead of wrapping it. */
const noSurround = EditorView.inputHandler.of((view, from, to, text) => {
if (!OPENERS[text] || from === to || view.state.selection.ranges.length !== 1) return false;
view.dispatch({ changes: { from, to, insert: text }, selection: EditorSelection.cursor(from + 1), userEvent: "input.type" });
view.dispatch({
changes: { from, to, insert: text },
selection: EditorSelection.cursor(from + 1),
userEvent: "input.type",
});
return true;
});
@@ -441,17 +468,27 @@ export function prefsExtension(o: PrefsOptions): Extension {
if ((o.wrapOverride ?? softWrapActive(o.softWrap, prefs, o.path)) && g.softWrapIndent) out.push(wrapIndent);
if (o.showWhitespace) {
const a = prefs.appearance;
out.push(whitespaceMarks({ leading: a.whitespaceLeading, inner: a.whitespaceInner, trailing: a.whitespaceTrailing }));
out.push(
whitespaceMarks({ leading: a.whitespaceLeading, inner: a.whitespaceInner, trailing: a.whitespaceTrailing }),
);
}
if (prefs.appearance.hardWrapGuides && !o.distractionFree) out.push(marginGuides([o.hardWrapAt, ...prefs.codeStyle.visualGuides]));
if (prefs.appearance.methodSeparators) out.push(EditorView.editorAttributes.of({ class: "cm-openidea-methodSeparators" }));
const hidden = GUTTER_ICON_GROUPS.filter((grp) => !prefs.showGutterIcons || prefs.gutterIconsDisabled.includes(grp.id));
if (hidden.length) out.push(EditorView.editorAttributes.of({ class: hidden.map((grp) => `cm-openidea-hideGutter-${grp.id}`).join(" ") }));
if (prefs.appearance.hardWrapGuides && !o.distractionFree)
out.push(marginGuides([o.hardWrapAt, ...prefs.codeStyle.visualGuides]));
if (prefs.appearance.methodSeparators)
out.push(EditorView.editorAttributes.of({ class: "cm-openidea-methodSeparators" }));
const hidden = GUTTER_ICON_GROUPS.filter(
(grp) => !prefs.showGutterIcons || prefs.gutterIconsDisabled.includes(grp.id),
);
if (hidden.length)
out.push(
EditorView.editorAttributes.of({ class: hidden.map((grp) => `cm-openidea-hideGutter-${grp.id}`).join(" ") }),
);
if (g.wheelZoom) out.push(wheelZoom);
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" || k.reformatOnPaste === "indentEachLine")) out.push(pasteReindentFilter(k.reformatOnPaste));
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 ? ["'", '"'] : [])];
@@ -469,7 +506,11 @@ export function prefsExtension(o: PrefsOptions): Extension {
{ 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 });
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 },
+43 -9
View File
@@ -107,7 +107,8 @@ export function detectIndent(text: string): { insertSpaces: boolean; size?: numb
if (n < 2 || (tabs === 0 && spaces === 0)) return null;
if (tabs > spaces) return { insertSpaces: false };
let best: number | undefined;
for (const [d, c] of deltas) if (best === undefined || c > deltas.get(best)! || (c === deltas.get(best)! && d < best)) best = d;
for (const [d, c] of deltas)
if (best === undefined || c > deltas.get(best)! || (c === deltas.get(best)! && d < best)) best = d;
return { insertSpaces: true, size: best };
}
@@ -166,8 +167,16 @@ export function fontExtension(settings: EditorSettings | undefined): Extension {
if ((family && family !== BUNDLED_MONO_FAMILY) || fallback) {
vars.push(`--openidea-editor-font: ${monoFontFamily(family, fallback, "var(--font-mono)")}`);
}
if (settings?.fontSize) vars.push(`--openidea-editor-font-size: ${settings.fontSize}px`, `--openidea-editor-base-size: ${settings.fontSize}px`);
if (settings?.lineHeight) vars.push(`--openidea-editor-line-height: ${settings.lineHeight}px`, `--openidea-editor-base-line: ${settings.lineHeight}px`);
if (settings?.fontSize)
vars.push(
`--openidea-editor-font-size: ${settings.fontSize}px`,
`--openidea-editor-base-size: ${settings.fontSize}px`,
);
if (settings?.lineHeight)
vars.push(
`--openidea-editor-line-height: ${settings.lineHeight}px`,
`--openidea-editor-base-line: ${settings.lineHeight}px`,
);
const attrs: Record<string, string> = {};
if (vars.length) attrs.style = vars.join("; ");
if (prefs.ligatures) attrs.class = "cm-openidea-ligatures";
@@ -179,12 +188,18 @@ export function readOnlyExtension(readOnly: boolean): Extension {
}
/** Soft wraps per settings (file patterns), unless toggled for the file (`override`). */
export function wrapExtension(settings: EditorSettings | undefined, path: string, override?: boolean | null): Extension {
export function wrapExtension(
settings: EditorSettings | undefined,
path: string,
override?: boolean | null,
): Extension {
return (override ?? softWrapActive(settings?.softWrap, prefsOf(settings), path)) ? EditorView.lineWrapping : [];
}
export function stickyExtension(settings: EditorSettings | undefined): Extension {
return settings?.stickyScroll === false || isDistractionFree() ? [] : [stickyScroll, stickyMaxLines.of(prefsOf(settings).codeEditing.stickyLinesMax)];
return settings?.stickyScroll === false || isDistractionFree()
? []
: [stickyScroll, stickyMaxLines.of(prefsOf(settings).codeEditing.stickyLinesMax)];
}
const matchingBracket = Decoration.mark({ class: "cm-matchingBracket" });
@@ -219,10 +234,16 @@ export function lineNumbersExtension(settings: EditorSettings | undefined): Exte
* Distraction Free Mode: no gutter, text column centred at the configured
* width (see `cm-openidea-distractionFree` in theme.ts).
*/
export function distractionFreeExtension(settings: EditorSettings | undefined, active = isDistractionFree()): Extension {
export function distractionFreeExtension(
settings: EditorSettings | undefined,
active = isDistractionFree(),
): Extension {
if (!active) return [];
const columns = distractionFreeWidth(prefsOf(settings).appearance.distractionFreeWidth);
return EditorView.editorAttributes.of({ class: "cm-openidea-distractionFree", style: `--openidea-df-columns: ${columns}` });
return EditorView.editorAttributes.of({
class: "cm-openidea-distractionFree",
style: `--openidea-df-columns: ${columns}`,
});
}
export function foldingFor(settings: EditorSettings | undefined): Extension {
@@ -310,7 +331,12 @@ export function baseExtensions(o: SetupOptions): Extension {
findExtension(),
overviewLane,
codeLensExtension,
EditorView.contentAttributes.of({ spellcheck: "false", autocorrect: "off", autocapitalize: "off", "aria-label": tx`Editor for ${o.ctx.path}` }),
EditorView.contentAttributes.of({
spellcheck: "false",
autocorrect: "off",
autocapitalize: "off",
"aria-label": tx`Editor for ${o.ctx.path}`,
}),
tabKeymap,
keymap.of([...closeBracketsKeymap, ...editorBaseKeymap, indentWithTab]),
compartments.language.of(o.language ?? []),
@@ -320,7 +346,15 @@ export function baseExtensions(o: SetupOptions): Extension {
compartments.wrap.of(wrapExtension(o.settings, o.ctx.path, o.wrapOverride)),
compartments.sticky.of(stickyExtension(o.settings)),
compartments.heavy.of(heavyExtension(o.docLength, o.settings)),
compartments.prefs.of(prefsFor({ settings: o.settings, path: o.ctx.path, info: o.info, readOnly: o.ctx.readOnly, wrapOverride: o.wrapOverride })),
compartments.prefs.of(
prefsFor({
settings: o.settings,
path: o.ctx.path,
info: o.info,
readOnly: o.ctx.readOnly,
wrapOverride: o.wrapOverride,
}),
),
compartments.contributions.of(contributionExtensions(o.ctx)),
compartments.distractionFree.of(distractionFreeExtension(o.settings)),
o.extra ?? [],
+17 -3
View File
@@ -3,7 +3,14 @@ import { EditorSelection, EditorState, type Extension } from "@codemirror/state"
import { EditorView } from "@codemirror/view";
import { indentService, indentUnit } from "@codemirror/language";
import { closeBrackets, insertBracket } from "@codemirror/autocomplete";
import { autoIndentBackspace, insertTab, smartBackspace, smartBackspaceApplies, tabOutOfScope, tabOutScopes } from "./smartKeys";
import {
autoIndentBackspace,
insertTab,
smartBackspace,
smartBackspaceApplies,
tabOutOfScope,
tabOutScopes,
} from "./smartKeys";
/** One unit (2) deeper after a (non-blank) line ending in `{`, one less on a line starting with `}`. */
const braces = indentService.of((cx, pos) => {
@@ -35,7 +42,12 @@ function make(text: string, extensions: Extension = [], unit = " ") {
state: EditorState.create({
doc,
selection: EditorSelection.create(ranges.length ? ranges : [EditorSelection.cursor(0)]),
extensions: [EditorState.allowMultipleSelections.of(true), EditorState.tabSize.of(4), indentUnit.of(unit), extensions],
extensions: [
EditorState.allowMultipleSelections.of(true),
EditorState.tabSize.of(4),
indentUnit.of(unit),
extensions,
],
}),
parent: document.body,
});
@@ -47,7 +59,9 @@ 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);
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;
}
+20 -4
View File
@@ -2,7 +2,15 @@
* IDEA-style Tab, smart Backspace and Tab out of auto-inserted brackets and
* quotes.
*/
import { EditorSelection, StateEffect, countColumn, StateField, type EditorState, type Extension, type Transaction } from "@codemirror/state";
import {
EditorSelection,
StateEffect,
countColumn,
StateField,
type EditorState,
type Extension,
type Transaction,
} from "@codemirror/state";
import { EditorView, keymap, type Command } from "@codemirror/view";
import { getIndentUnit, getIndentation, indentString, indentUnit, syntaxTree } from "@codemirror/language";
import { indentMore } from "@codemirror/commands";
@@ -45,7 +53,10 @@ export function smartBackspaceApplies(mode: string, language: string | undefined
}
/** The change one Backspace makes at `pos` in "To proper indent position" mode, or null to delete normally. */
export function autoIndentBackspace(state: EditorState, pos: number): { from: number; to: number; insert: string } | null {
export function autoIndentBackspace(
state: EditorState,
pos: number,
): { from: number; to: number; insert: string } | null {
const doc = state.doc;
const line = doc.lineAt(pos);
if (/[^ \t]/.test(doc.sliceString(line.from, pos))) return null;
@@ -128,7 +139,8 @@ function insertedPairs(tr: Transaction): Scope[] {
export const tabOutOfScope: Command = (view) => {
const { state } = view;
const scopes = state.field(tabOutScopes, false);
if (!scopes?.length || state.selection.ranges.some((r) => !r.empty || !scopes.some((s) => s.to === r.head))) return false;
if (!scopes?.length || state.selection.ranges.some((r) => !r.empty || !scopes.some((s) => s.to === r.head)))
return false;
view.dispatch({
selection: EditorSelection.create(
state.selection.ranges.map((r) => EditorSelection.cursor(Math.min(r.head + 1, state.doc.length))),
@@ -148,7 +160,11 @@ export const tabOut: Extension = tabOutScopes;
/** `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<ReturnType<typeof syntaxTree>["resolveInner"]> | null = syntaxTree(state).resolveInner(pos, 1); n; n = n.parent) {
for (
let n: ReturnType<ReturnType<typeof syntaxTree>["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;
}
+7 -2
View File
@@ -225,7 +225,9 @@ class StickyScroll {
if (!row?.dataset.line) return;
e.preventDefault();
// The line number of a sticky line is not a link (IDEA).
const inner = (e.target as HTMLElement).closest(".cm-openidea-stickyText")?.querySelector<HTMLElement>(".cm-openidea-stickyInner");
const inner = (e.target as HTMLElement)
.closest(".cm-openidea-stickyText")
?.querySelector<HTMLElement>(".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));
@@ -234,7 +236,10 @@ class StickyScroll {
// The line takes the place of its sticky row, so the text stays under the pointer.
this.view.dispatch({
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 }),
effects: EditorView.scrollIntoView(line.from, {
y: "start",
yMargin: Math.max(0, rowIndex) * this.view.defaultLineHeight,
}),
userEvent: "select.sticky",
});
this.view.focus();
+8 -2
View File
@@ -15,12 +15,18 @@ function marks(state: EditorState) {
describe("syntaxHighlightField", () => {
const create = () =>
EditorState.create({ doc: "def hi, do: :ok", extensions: [syntaxHighlighting(openideaHighlightStyle), syntaxHighlightField] });
EditorState.create({
doc: "def hi, do: :ok",
extensions: [syntaxHighlighting(openideaHighlightStyle), syntaxHighlightField],
});
it("styles backend ranges with the active highlight style and maps them through edits", () => {
let state = create();
state = state.update({
effects: setSyntaxHighlights.of({ kinds: ["keyword", "atom", "functionCall"], data: [0, 3, 0, 4, 6, 2, 12, 15, 1, 99, 120, 0] }),
effects: setSyntaxHighlights.of({
kinds: ["keyword", "atom", "functionCall"],
data: [0, 3, 0, 4, 6, 2, 12, 15, 1, 99, 120, 0],
}),
}).state;
const keyword = highlightingFor(state, [t.keyword]);
const atom = highlightingFor(state, [t.atom]);
+18 -6
View File
@@ -18,7 +18,8 @@ export const openideaTheme = EditorView.theme({
"--editor-gutter-border": "var(--border-gutter, var(--border))",
"--editor-caret-row": "var(--bg-current-line)",
"--editor-selection": "var(--bg-editor-selection, color-mix(in srgb, var(--accent) 24%, transparent))",
"--editor-selection-inactive": "var(--bg-editor-selection-inactive, color-mix(in srgb, var(--accent) 14%, transparent))",
"--editor-selection-inactive":
"var(--bg-editor-selection-inactive, color-mix(in srgb, var(--accent) 14%, transparent))",
"--editor-indent-guide": "var(--border-indent-guide, var(--border))",
"--editor-search": "var(--bg-search-match, color-mix(in srgb, var(--warning) 30%, transparent))",
"--editor-search-current": "var(--bg-search-match-current, color-mix(in srgb, var(--warning) 48%, transparent))",
@@ -52,18 +53,24 @@ export const openideaTheme = EditorView.theme({
marginLeft: "0",
background: "color-mix(in srgb, var(--caret, var(--fg-strong)) 55%, transparent)",
},
"&.cm-openidea-ligatures .cm-scroller, &.cm-openidea-ligatures .cm-openidea-sticky": { fontVariantLigatures: "normal" },
"&.cm-openidea-ligatures .cm-scroller, &.cm-openidea-ligatures .cm-openidea-sticky": {
fontVariantLigatures: "normal",
},
"&.cm-openidea-methodSeparators .cm-openidea-methodSeparator": {
boxShadow: "inset 0 1px 0 var(--border-method-separator, var(--border))",
},
"&.cm-openidea-hideGutter-run .cm-openidea-gutter-run, &.cm-openidea-hideGutter-bookmarks .cm-openidea-bookmarks": { display: "none" },
"&.cm-openidea-hideGutter-run .cm-openidea-gutter-run, &.cm-openidea-hideGutter-bookmarks .cm-openidea-bookmarks": {
display: "none",
},
// Inheritance markers share one gutter: each group hides its own icons.
"&.cm-openidea-hideGutter-inheritors :is(.cm-openidea-lineMarker-implemented, .cm-openidea-lineMarker-overridden), &.cm-openidea-hideGutter-supers :is(.cm-openidea-lineMarker-implementing, .cm-openidea-lineMarker-overriding)":
{ display: "none" },
// Distraction Free Mode: no gutter; the text column (n chars + line padding) is centred.
"&.cm-openidea-distractionFree": { "--openidea-df-width": "calc(var(--openidea-df-columns, 80) * 1ch + 22px)" },
"&.cm-openidea-distractionFree .cm-gutters": { display: "none" },
"&.cm-openidea-distractionFree .cm-content": { marginInlineStart: "max(0px, calc((100% - var(--openidea-df-width)) / 2))" },
"&.cm-openidea-distractionFree .cm-content": {
marginInlineStart: "max(0px, calc((100% - var(--openidea-df-width)) / 2))",
},
"&.cm-openidea-distractionFree .cm-content.cm-lineWrapping": { maxWidth: "var(--openidea-df-width)" },
".cm-fat-cursor": { background: "color-mix(in srgb, var(--caret, var(--fg-strong)) 45%, transparent)" },
".cm-selectionBackground": { background: "var(--editor-selection-inactive)" },
@@ -71,7 +78,9 @@ export const openideaTheme = EditorView.theme({
background: "var(--editor-selection)",
},
".cm-activeLine": { backgroundColor: "var(--editor-caret-row)" },
".cm-selectionMatch": { backgroundColor: "var(--bg-identifier-under-caret, color-mix(in srgb, var(--fg) 9%, transparent))" },
".cm-selectionMatch": {
backgroundColor: "var(--bg-identifier-under-caret, color-mix(in srgb, var(--fg) 9%, transparent))",
},
// IDEA's MATCHED_BRACE_ATTRIBUTES (bold in dark schemes only) and MATCHED_TAG_NAME (never bold).
".cm-matchingBracket, &.cm-focused .cm-matchingBracket": {
backgroundColor: "var(--editor-brace)",
@@ -214,7 +223,10 @@ export const openideaTheme = EditorView.theme({
borderRadius: "var(--radius-popup)",
boxShadow: "var(--shadow-popup)",
},
".cm-tooltip .cm-tooltip-arrow:before": { borderTopColor: "var(--border-popup)", borderBottomColor: "var(--border-popup)" },
".cm-tooltip .cm-tooltip-arrow:before": {
borderTopColor: "var(--border-popup)",
borderBottomColor: "var(--border-popup)",
},
".cm-tooltip .cm-tooltip-arrow:after": { borderTopColor: "var(--bg-popup)", borderBottomColor: "var(--bg-popup)" },
".cm-panels": { backgroundColor: "var(--bg-elev)", color: "var(--fg)" },
".cm-panels.cm-panels-top": { borderBottom: "1px solid var(--border)" },
+10 -2
View File
@@ -31,7 +31,11 @@ describe("unwrapOptions (Unwrap/Remove)", () => {
it("keeps the else branch the caret is in", () => {
const src = "if (a) {\n x();\n} else {\n y();\n}";
const chain = [node(src, "expression_statement", "y();"), node(src, "block", "{\n y();\n}", "alternative"), node(src, "if_statement", src)];
const chain = [
node(src, "expression_statement", "y();"),
node(src, "block", "{\n y();\n}", "alternative"),
node(src, "if_statement", src),
];
const [o] = unwrapOptions(Text.of(src.split("\n")), chain);
expect(o!.label).toBe("Unwrap 'else'");
expect(apply(src, o!)).toBe("y();");
@@ -81,7 +85,11 @@ describe("unwrapOptions (Unwrap/Remove)", () => {
expect(apply(src, opts[0]!)).toBe("if (a) {\n p();\n} else {\n q();\n}");
expect(apply(src, opts[1]!)).toBe("if (b) {\n q();\n}");
const cond = [node(src, "identifier", "b"), node(src, "parenthesized_expression", "(b)", "condition"), node(src, "if_statement", inner, "alternative")];
const cond = [
node(src, "identifier", "b"),
node(src, "parenthesized_expression", "(b)", "condition"),
node(src, "if_statement", inner, "alternative"),
];
expect(unwrapOptions(Text.of(src.split("\n")), cond)).toEqual([]);
});
});
+34 -6
View File
@@ -60,11 +60,28 @@ const CLAUSES: Record<string, string> = {
};
/** The child on the caret's path must be a body, not the condition / header. */
const HEADER_FIELDS = new Set(["condition", "left", "right", "value", "pattern", "initializer", "update", "resources", "parameters", "type", "name"]);
const HEADER_FIELDS = new Set([
"condition",
"left",
"right",
"value",
"pattern",
"initializer",
"update",
"resources",
"parameters",
"type",
"name",
]);
function isBody(n: ChainNode): boolean {
if (n.field && HEADER_FIELDS.has(n.field)) return false;
return /block|body|statement|compound|suite/.test(n.kind) || n.field === "consequence" || n.field === "alternative" || n.field === "body";
return (
/block|body|statement|compound|suite/.test(n.kind) ||
n.field === "consequence" ||
n.field === "alternative" ||
n.field === "body"
);
}
const leading = (s: string) => /^[ \t]*/.exec(s)![0];
@@ -80,7 +97,10 @@ function bodyLines(doc: Text, body: ChainNode, base: string): string[] {
}
const first = doc.lineAt(from);
if (!doc.sliceString(first.from, from).trim()) from = first.from;
const lines = doc.sliceString(from, to).split("\n").map((l) => l.trimEnd());
const lines = doc
.sliceString(from, to)
.split("\n")
.map((l) => l.trimEnd());
while (lines.length && !lines[0]!.trim()) lines.shift();
while (lines.length && !lines[lines.length - 1]!.trim()) lines.pop();
const indents = lines.filter((l) => l.trim()).map((l) => leading(l).length);
@@ -89,13 +109,17 @@ function bodyLines(doc: Text, body: ChainNode, base: string): string[] {
}
function keyword(doc: Text, n: ChainNode): string {
return /^[A-Za-z_]+/.exec(doc.sliceString(n.from, Math.min(n.to, n.from + 20)))?.[0] ?? n.kind.replace(/_(statement|expression|block)$/, "");
return (
/^[A-Za-z_]+/.exec(doc.sliceString(n.from, Math.min(n.to, n.from + 20)))?.[0] ??
n.kind.replace(/_(statement|expression|block)$/, "")
);
}
function replacement(doc: Text, target: ChainNode, body: ChainNode): { from: number; to: number; insert: string } {
const startLine = doc.lineAt(target.from);
const endLine = doc.lineAt(target.to);
const wholeLines = !doc.sliceString(startLine.from, target.from).trim() && !doc.sliceString(target.to, endLine.to).trim();
const wholeLines =
!doc.sliceString(startLine.from, target.from).trim() && !doc.sliceString(target.to, endLine.to).trim();
if (wholeLines) {
const lines = bodyLines(doc, body, leading(startLine.text));
// An empty body removes the statement's lines altogether.
@@ -134,7 +158,11 @@ export function unwrapOptions(doc: Text, chain: readonly ChainNode[]): UnwrapOpt
const key = `${target.from}:${target.to}:${label}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({ label: tx`Unwrap '${label}'`, target: { from: target.from, to: target.to }, ...replacement(doc, target, child) });
out.push({
label: tx`Unwrap '${label}'`,
target: { from: target.from, to: target.to },
...replacement(doc, target, child),
});
}
return out;
}
+13 -2
View File
@@ -78,8 +78,19 @@ describe("EditorController", () => {
});
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"];
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",
];
const dispose = registerActions(ids.map((id) => ({ id, title: id, run: () => undefined })));
ctrl = new EditorController(createMemoryBackend({ "/p/a.ts": "", "/p/b.ts": "" }));
ctrl.start();
+19 -4
View File
@@ -335,7 +335,10 @@ export class EditorController implements SlotHost {
files[path] = slot.viewState();
}
const kinds = useContributions.getState().editorKinds;
storeSession(root, serializeSession(useTabs.getState(), files, (input) => !!kinds.get(input.kind)?.restorable));
storeSession(
root,
serializeSession(useTabs.getState(), files, (input) => !!kinds.get(input.kind)?.restorable),
);
}
/** Reopens the tabs, splits and carets saved for `root` (after the project opened). */
@@ -912,7 +915,11 @@ export class EditorController implements SlotHost {
const encoding = await this.manager.backend.setEncoding(meta.doc, meta.encoding, true);
model.setMeta({ encoding });
} catch (e) {
notifications().notify({ level: "error", title: "Cannot reload from disk", body: e instanceof Error ? e.message : String(e) });
notifications().notify({
level: "error",
title: "Cannot reload from disk",
body: e instanceof Error ? e.message : String(e),
});
}
}
}
@@ -1074,7 +1081,8 @@ export class EditorController implements SlotHost {
closePath(path: string) {
this.apply((s) => {
const keys = new Set<string>();
for (const pane of Object.values(s.panes)) for (const t of pane.tabs) if (t.input.path === path) keys.add(t.input.key);
for (const pane of Object.values(s.panes))
for (const t of pane.tabs) if (t.input.path === path) keys.add(t.input.key);
let next = s;
for (const key of keys) next = closeKey(next, key);
return next;
@@ -1164,7 +1172,14 @@ export class EditorController implements SlotHost {
{ kind: "action", action: "project.copyAbsolutePath", arg: path, label: "Absolute Path" },
{ kind: "action", action: "project.copyFileName", arg: path, label: "File Name" },
...(inProject
? ([{ kind: "action", action: "project.copyRelativePath", arg: path, label: "Path From Content Root" }] as MenuItem[])
? ([
{
kind: "action",
action: "project.copyRelativePath",
arg: path,
label: "Path From Content Root",
},
] as MenuItem[])
: []),
],
},
+6 -1
View File
@@ -34,7 +34,12 @@ export function pasteFromHistory(view: EditorView) {
id: `${i}`,
label,
detail,
preview: text.split(/\r\n?|\n/).slice(1, 4).join(" ⏎ ").trim() || undefined,
preview:
text
.split(/\r\n?|\n/)
.slice(1, 4)
.join(" ⏎ ")
.trim() || undefined,
run: () => {
recordClipboard(text);
void navigator.clipboard?.writeText(text).catch(() => {});
+6 -5
View File
@@ -22,10 +22,8 @@ function setup(files: Record<string, string>, settings = {}) {
_resetLanguages();
const backend: MemoryBackend = createMemoryBackend(files);
const scheduled: Array<() => void> = [];
const manager = new DocumentManager(
backend,
{ settings: () => settings, notify: vi.fn() },
(fn) => scheduled.push(fn),
const manager = new DocumentManager(backend, { settings: () => settings, notify: vi.fn() }, (fn) =>
scheduled.push(fn),
);
manager.start();
const frame = async () => {
@@ -108,7 +106,10 @@ describe("save cleanup", () => {
it("keeps the caret line and untouched lines", () => {
const state = EditorState.create({ doc: "a \nb \nc " });
const m = new ModifiedRanges();
m.ranges = [[0, 3], [8, 11]];
m.ranges = [
[0, 3],
[8, 11],
];
const changes = saveCleanupChanges(state, m, { trim: true, finalNewline: true, protectLines: new Set([3]) });
expect(changes).toEqual([
{ from: 1, to: 3 },
+51 -8
View File
@@ -7,7 +7,16 @@ import { EditorView } from "@codemirror/view";
import type { LanguageDescription, LanguageSupport } from "@codemirror/language";
import { create } from "zustand";
import type { EditorConfigProps } from "@/bindings";
import type { DocId, DocumentInfo, EditorSettings, FileSettings, LineEnding, OutlineItem, Settings, SyntaxHighlights } from "@/platform/ipc";
import type {
DocId,
DocumentInfo,
EditorSettings,
FileSettings,
LineEnding,
OutlineItem,
Settings,
SyntaxHighlights,
} from "@/platform/ipc";
import {
_emitEditorEvent,
_saveParticipants,
@@ -20,7 +29,14 @@ import {
import { isVirtualUri } from "@/platform/virtualDocs";
import { DocSync, type Scheduler, frameScheduler } from "../sync/DocSync";
import type { DocBackend, LanguageInfo } from "../backend";
import { findLanguageDescription, languageInfo, loadLanguageInfos, loadSupport, loadedSupport, commentTokens } from "../cm/languages";
import {
findLanguageDescription,
languageInfo,
loadLanguageInfos,
loadSupport,
loadedSupport,
commentTokens,
} from "../cm/languages";
import { foldEffect } from "@codemirror/language";
import { backendFolds, setBackendFolds } from "../cm/folding";
import { setSyntaxHighlights } from "../cm/syntaxHighlight";
@@ -137,7 +153,13 @@ export function methodLines(items: readonly OutlineItem[]): number[] {
export function saveCleanupChanges(
state: EditorState,
modified: ModifiedRanges,
opts: { trim: boolean; finalNewline: boolean; protectLines: Set<number>; allLines?: boolean; trailingBlankLines?: boolean },
opts: {
trim: boolean;
finalNewline: boolean;
protectLines: Set<number>;
allLines?: boolean;
trailingBlankLines?: boolean;
},
): Array<{ from: number; to: number; insert?: string }> {
const changes: Array<{ from: number; to: number; insert?: string }> = [];
const doc = state.doc;
@@ -274,7 +296,8 @@ export class DocModel {
addSlot(slot: EditorSlot) {
this.slots.add(slot);
if (this.highlights?.counter === this.changeCounter) slot.reconfigure([setSyntaxHighlights.of(this.highlights.value)]);
if (this.highlights?.counter === this.changeCounter)
slot.reconfigure([setSyntaxHighlights.of(this.highlights.value)]);
}
removeSlot(slot: EditorSlot) {
@@ -320,7 +343,8 @@ export class DocModel {
this.modified.map(cs);
this.changeCounter++;
const primary = this.primarySlot();
for (const s of this.slots) s.applyExternal(cs, [remoteChange.of(true), Transaction.addToHistory.of(s === primary)]);
for (const s of this.slots)
s.applyExternal(cs, [remoteChange.of(true), Transaction.addToHistory.of(s === primary)]);
// Without a tab, the first attached view keeps backend edits (Replace in Files…) undoable.
let first = !primary;
for (const a of this.attachments) {
@@ -405,7 +429,13 @@ export class DocModel {
compartments.heavy.reconfigure(heavyExtension(this.sync.localText.length, editor)),
compartments.distractionFree.reconfigure(distractionFreeExtension(editor)),
compartments.prefs.reconfigure(
prefsFor({ settings: editor, path: this.path, info: this.info, readOnly: this.meta.readOnly, wrapOverride: this.softWrapOverride }),
prefsFor({
settings: editor,
path: this.path,
info: this.info,
readOnly: this.meta.readOnly,
wrapOverride: this.softWrapOverride,
}),
),
];
// LSP contributions read completion / hover timings when they are built.
@@ -422,12 +452,25 @@ export class DocModel {
setReadOnly(readOnly: boolean) {
if (readOnly === this.meta.readOnly) return;
const banner = this.meta.banner;
this.setMeta({ readOnly, banner: readOnly ? (banner ?? (this.virtual ? null : "readOnly")) : banner === "readOnly" || banner === "library" ? null : banner });
this.setMeta({
readOnly,
banner: readOnly
? (banner ?? (this.virtual ? null : "readOnly"))
: banner === "readOnly" || banner === "library"
? null
: banner,
});
for (const s of this.slots) {
s.reconfigure([
compartments.readOnly.reconfigure(readOnlyExtension(readOnly)),
compartments.prefs.reconfigure(
prefsFor({ settings: this.settings(), path: this.path, info: this.info, readOnly, wrapOverride: this.softWrapOverride }),
prefsFor({
settings: this.settings(),
path: this.path,
info: this.info,
readOnly,
wrapOverride: this.softWrapOverride,
}),
),
]);
s.syncContributions(true);
+2 -1
View File
@@ -18,7 +18,8 @@ export class EditHistory {
record(loc: Place) {
const last = this.entries[this.entries.length - 1];
if (last && last.path === loc.path && Math.abs(last.line - loc.line) <= MERGE_LINES) this.entries[this.entries.length - 1] = { ...loc };
if (last && last.path === loc.path && Math.abs(last.line - loc.line) <= MERGE_LINES)
this.entries[this.entries.length - 1] = { ...loc };
else {
this.entries = this.entries.filter((e) => e.path !== loc.path || Math.abs(e.line - loc.line) > MERGE_LINES);
this.entries.push({ ...loc });
+2 -2
View File
@@ -17,8 +17,8 @@ export const NAVIGATE_EVENT = "select.navigate";
/** Caret moves that count as navigation: mouse clicks, find, reveals, stripe / sticky-line clicks, navigate commands. */
export function isNavigation(tr: Transaction): boolean {
return ["select.pointer", "select.reveal", "select.search", "select.overview", "select.sticky", NAVIGATE_EVENT].some((e) =>
tr.isUserEvent(e),
return ["select.pointer", "select.reveal", "select.search", "select.overview", "select.sticky", NAVIGATE_EVENT].some(
(e) => tr.isUserEvent(e),
);
}
const MAX_ENTRIES = 100;
+35 -6
View File
@@ -1,5 +1,14 @@
import { describe, expect, it } from "vitest";
import { initialTabsState, moveTabToNewSplit, openTab, paneIds, splitAtEdge, splitPane, togglePin, type TabsState } from "./tabs";
import {
initialTabsState,
moveTabToNewSplit,
openTab,
paneIds,
splitAtEdge,
splitPane,
togglePin,
type TabsState,
} from "./tabs";
import { loadSession, parseSession, restoreTabsState, serializeSession, slashPath } from "./session";
const file = (path: string) => ({ kind: "file", key: `file:${path}`, path });
@@ -19,7 +28,10 @@ describe("editor session", () => {
const first = s.activePane;
s = splitPane(s, first, "column").state;
s = open(s, "/p/d.ts");
const session = serializeSession(s, { "/p/a.ts": { anchor: 3, head: 5, topLine: 10 }, "/p/zzz.ts": { anchor: 0, head: 0 } })!;
const session = serializeSession(s, {
"/p/a.ts": { anchor: 3, head: 5, topLine: 10 },
"/p/zzz.ts": { anchor: 0, head: 0 },
})!;
expect(session.layout.kind).toBe("split");
expect(session.activePane).toBe(1);
expect(Object.keys(session.files)).toEqual(["/p/a.ts"]);
@@ -50,7 +62,10 @@ describe("editor session", () => {
expect(session.layout).toMatchObject({ kind: "pane", active: 1 });
const r = restoreTabsState(parseSession(JSON.parse(JSON.stringify(session)))!, fileKey);
const pane = r.panes[paneIds(r)[0]!]!;
expect(pane.tabs.map((t) => t.input)).toEqual([file("/p/a.ts"), { kind: "agent-session", key: "agent:1", data: { task: "1" } }]);
expect(pane.tabs.map((t) => t.input)).toEqual([
file("/p/a.ts"),
{ kind: "agent-session", key: "agent:1", data: { task: "1" } },
]);
expect(pane.active).toBe(pane.tabs[1]!.id);
// Only restorable tabs: still a session. Junk inputs (file kind, no key) are dropped.
const empty = initialTabsState();
@@ -59,7 +74,11 @@ describe("editor session", () => {
expect(serializeSession(only, {})).toBeNull();
const junk = parseSession({
version: 1,
layout: { kind: "pane", tabs: [{ input: { kind: "file", key: "k" } }, { input: { kind: "x" } }, { input: { kind: "x", key: "y" } }], active: 0 },
layout: {
kind: "pane",
tabs: [{ input: { kind: "file", key: "k" } }, { input: { kind: "x" } }, { input: { kind: "x", key: "y" } }],
active: 0,
},
})!;
expect(junk.layout).toEqual({ kind: "pane", tabs: [{ input: { kind: "x", key: "y" } }], active: 0 });
});
@@ -71,7 +90,11 @@ describe("editor session", () => {
const ok = parseSession({
version: 1,
activePane: 7,
layout: { kind: "split", dir: "row", children: [{ kind: "pane", tabs: [{ path: "/x" }], active: 9 }, { kind: "bogus" }] },
layout: {
kind: "split",
dir: "row",
children: [{ kind: "pane", tabs: [{ path: "/x" }], active: 9 }, { kind: "bogus" }],
},
files: { "/x": { anchor: 1, head: -1 } },
})!;
expect(ok.layout).toEqual({ kind: "pane", tabs: [{ path: "/x" }], active: 0 });
@@ -109,7 +132,13 @@ describe("moveTabToNewSplit", () => {
const split = splitPane(s, first, "row");
s = open(split.state, "/b");
const bTab = s.panes[split.paneId]!.tabs.find((t) => t.input.path === "/b")!;
s = { ...s, panes: { ...s.panes, [split.paneId]: { ...s.panes[split.paneId]!, tabs: [bTab], active: bTab.id, mru: [bTab.id] } } };
s = {
...s,
panes: {
...s.panes,
[split.paneId]: { ...s.panes[split.paneId]!, tabs: [bTab], active: bTab.id, mru: [bTab.id] },
},
};
const r = moveTabToNewSplit(s, split.paneId, bTab.id, first, "bottom")!;
expect(r.state.panes[split.paneId]).toBeUndefined();
expect(r.state.layout).toMatchObject({ kind: "split", dir: "column" });
+21 -4
View File
@@ -54,7 +54,10 @@ export function serializeSession(
if (!pane) return null;
const kept = pane.tabs.filter(saved);
if (!kept.length) return null;
const active = Math.max(0, kept.findIndex((t) => t.id === pane.active));
const active = Math.max(
0,
kept.findIndex((t) => t.id === pane.active),
);
return {
kind: "pane",
tabs: kept.map((t) => ({
@@ -131,10 +134,19 @@ export function parseSession(raw: unknown): EditorSession | null {
for (const [path, v] of Object.entries(r.files)) {
const f = v as Partial<FileViewState> | null;
if (f && Number.isInteger(f.anchor) && Number.isInteger(f.head) && f.anchor! >= 0 && f.head! >= 0)
files[slashPath(path)] = { anchor: f.anchor!, head: f.head!, ...(Number.isInteger(f.topLine) && f.topLine! >= 0 ? { topLine: f.topLine } : {}) };
files[slashPath(path)] = {
anchor: f.anchor!,
head: f.head!,
...(Number.isInteger(f.topLine) && f.topLine! >= 0 ? { topLine: f.topLine } : {}),
};
}
}
return { version: 1, layout, activePane: typeof r.activePane === "number" ? Math.max(0, Math.floor(r.activePane)) : 0, files };
return {
version: 1,
layout,
activePane: typeof r.activePane === "number" ? Math.max(0, Math.floor(r.activePane)) : 0,
files,
};
}
let seq = 0;
@@ -167,7 +179,12 @@ export function restoreTabsState(session: EditorSession, fileKey: (path: string)
tabs.sort((a, b) => Number(b.pinned) - Number(a.pinned));
const activeTab = l.tabs[l.active];
const active = (activeTab && byKey.get(tabKey(activeTab))) ?? tabs[0]!;
panes[paneId] = { id: paneId, tabs, active: active.id, mru: [active.id, ...tabs.filter((t) => t !== active).map((t) => t.id)] };
panes[paneId] = {
id: paneId,
tabs,
active: active.id,
mru: [active.id, ...tabs.filter((t) => t !== active).map((t) => t.id)],
};
order.push(paneId);
return { kind: "pane", paneId };
}
+25 -5
View File
@@ -18,7 +18,14 @@ import {
type TransactionSpec,
} from "@codemirror/state";
import { EditorView, ViewPlugin, keymap, type ViewUpdate } from "@codemirror/view";
import { _addEditor, _removeEditor, documentSynced, isExternalChange, type EditorDocContext, type TextEditorHandle } from "@/editor/api";
import {
_addEditor,
_removeEditor,
documentSynced,
isExternalChange,
type EditorDocContext,
type TextEditorHandle,
} from "@/editor/api";
import { showContextMenu } from "@/ui";
import { baseExtensions, compartments, contributionExtensions } from "../cm/setup";
import { gutterMenuItems, inGutterMenuArea } from "../gutterMenu";
@@ -186,7 +193,13 @@ export class EditorSlot {
}
if (tr.docChanged && !isExternalChange(tr)) {
this.model.onLocal(tr.changes, this);
if (tr.isUserEvent("input") || tr.isUserEvent("delete") || tr.isUserEvent("move") || tr.isUserEvent("undo") || tr.isUserEvent("redo"))
if (
tr.isUserEvent("input") ||
tr.isUserEvent("delete") ||
tr.isUserEvent("move") ||
tr.isUserEvent("undo") ||
tr.isUserEvent("redo")
)
this.host.onUserEdit(this, tr);
}
if (tr.selection || tr.docChanged) selection = true;
@@ -241,7 +254,9 @@ export class EditorSlot {
}
this.view.dispatch({
selection: EditorSelection.single(anchor, head),
effects: EditorView.scrollIntoView(EditorSelection.range(anchor, head), { y: r.center === false ? "nearest" : "center" }),
effects: EditorView.scrollIntoView(EditorSelection.range(anchor, head), {
y: r.center === false ? "nearest" : "center",
}),
userEvent: "select.reveal",
});
}
@@ -256,9 +271,14 @@ export class EditorSlot {
/** Restores a saved caret / scroll position (session restore). */
restoreView(v: { anchor: number; head: number; topLine?: number }) {
const doc = this.currentState().doc;
const selection = EditorSelection.single(Math.min(Math.max(0, v.anchor), doc.length), Math.min(Math.max(0, v.head), doc.length));
const selection = EditorSelection.single(
Math.min(Math.max(0, v.anchor), doc.length),
Math.min(Math.max(0, v.head), doc.length),
);
const scroll =
v.topLine !== undefined ? EditorView.scrollIntoView(doc.line(Math.min(v.topLine + 1, doc.lines)).from, { y: "start" }) : null;
v.topLine !== undefined
? EditorView.scrollIntoView(doc.line(Math.min(v.topLine + 1, doc.lines)).from, { y: "start" })
: null;
if (this.view) {
this.view.dispatch({ selection, effects: scroll ? [scroll] : [] });
return;
+43 -7
View File
@@ -55,7 +55,12 @@ const nextId = (p: string) => `${p}${++seq}`;
export function initialTabsState(): TabsState {
const id = nextId("pane");
return { layout: { kind: "pane", paneId: id }, panes: { [id]: { id, tabs: [], active: null, mru: [] } }, activePane: id, closed: [] };
return {
layout: { kind: "pane", paneId: id },
panes: { [id]: { id, tabs: [], active: null, mru: [] } },
activePane: id,
closed: [],
};
}
export function paneOf(s: TabsState, paneId: string): Pane {
@@ -206,10 +211,17 @@ function removeTabs(s: TabsState, paneId: string, ids: Set<string>): TabsState {
let active = pane.active;
if (active && ids.has(active)) {
const at = pane.tabs.findIndex((t) => t.id === active);
const left = pane.tabs.slice(0, at).reverse().find((t) => !ids.has(t.id));
const left = pane.tabs
.slice(0, at)
.reverse()
.find((t) => !ids.has(t.id));
const right = pane.tabs.slice(at + 1).find((t) => !ids.has(t.id));
const pick =
tabOptions.activateOnClose === "left" ? (left ?? right) : tabOptions.activateOnClose === "right" ? (right ?? left) : undefined;
tabOptions.activateOnClose === "left"
? (left ?? right)
: tabOptions.activateOnClose === "right"
? (right ?? left)
: undefined;
active = pick?.id ?? mru[0] ?? tabs[0]?.id ?? null;
}
pane = { ...pane, tabs, mru, active };
@@ -241,14 +253,32 @@ export function closeToRight(s: TabsState, paneId: string, tabId: string): TabsS
const pane = paneOf(s, paneId);
const idx = pane.tabs.findIndex((t) => t.id === tabId);
if (idx < 0) return s;
return removeTabs(s, paneId, new Set(pane.tabs.slice(idx + 1).filter((t) => !t.pinned).map((t) => t.id)));
return removeTabs(
s,
paneId,
new Set(
pane.tabs
.slice(idx + 1)
.filter((t) => !t.pinned)
.map((t) => t.id),
),
);
}
export function closeToLeft(s: TabsState, paneId: string, tabId: string): TabsState {
const pane = paneOf(s, paneId);
const idx = pane.tabs.findIndex((t) => t.id === tabId);
if (idx < 0) return s;
return removeTabs(s, paneId, new Set(pane.tabs.slice(0, idx).filter((t) => !t.pinned).map((t) => t.id)));
return removeTabs(
s,
paneId,
new Set(
pane.tabs
.slice(0, idx)
.filter((t) => !t.pinned)
.map((t) => t.id),
),
);
}
/** IDEA "Close Unmodified Tabs": closes the unpinned tabs `modified` says no to. */
@@ -283,7 +313,10 @@ export function reopenClosed(s: TabsState): { state: TabsState; tabId: string; p
const entry = closed.pop()!;
if (openNow.has(entry.input.key) && findTab(s, entry.input.key, entry.paneId)?.paneId === entry.paneId) continue;
const paneId = s.panes[entry.paneId] ? entry.paneId : s.activePane;
const { state, tabId } = openTab({ ...s, closed }, paneId, entry.input, { index: entry.index, pinned: entry.pinned });
const { state, tabId } = openTab({ ...s, closed }, paneId, entry.input, {
index: entry.index,
pinned: entry.pinned,
});
return { state, tabId, paneId };
}
return null;
@@ -325,7 +358,10 @@ export function sortTabs(s: TabsState, paneId: string, title: (t: Tab) => string
const pane = paneOf(s, paneId);
const byTitle = (a: Tab, b: Tab) =>
title(a).localeCompare(title(b), undefined, { sensitivity: "base" }) || title(a).localeCompare(title(b));
const tabs = [...pane.tabs.filter((t) => t.pinned).sort(byTitle), ...pane.tabs.filter((t) => !t.pinned).sort(byTitle)];
const tabs = [
...pane.tabs.filter((t) => t.pinned).sort(byTitle),
...pane.tabs.filter((t) => !t.pinned).sort(byTitle),
];
return tabs.every((t, i) => t === pane.tabs[i]) ? s : withPane(s, { ...pane, tabs });
}
+3 -1
View File
@@ -18,7 +18,9 @@ export const documentBanner: EditorBannerContribution = {
order: -10,
useBanner: (path) => {
const banner = useDocMeta((s) => s[path]?.banner ?? null);
return banner ? { text: TEXT[banner], icon: banner === "readOnly" || banner === "library" ? "lock" : "info" } : null;
return banner
? { text: TEXT[banner], icon: banner === "readOnly" || banner === "library" ? "lock" : "info" }
: null;
},
};
+12 -4
View File
@@ -81,7 +81,11 @@ export const EDITOR_TAB_MIME = "application/x-openidea-editor-tab";
type DropZone = SplitEdge | "center";
/** Where a tab dropped at (x, y) over an editor goes: an edge (new split) or the pane itself. */
export function dropZoneAt(x: number, y: number, rect: { left: number; top: number; width: number; height: number }): DropZone {
export function dropZoneAt(
x: number,
y: number,
rect: { left: number; top: number; width: number; height: number },
): DropZone {
const fx = (x - rect.left) / Math.max(1, rect.width);
const fy = (y - rect.top) / Math.max(1, rect.height);
const edges: Array<[SplitEdge, number]> = [
@@ -108,7 +112,8 @@ function useTitleHints(): Map<string, string> {
const paths = useTabs(
useShallow((s) => {
const out: string[] = [];
for (const p of Object.values(s.panes)) for (const t of p.tabs) if (t.input.kind === "file" && t.input.path) out.push(t.input.path);
for (const p of Object.values(s.panes))
for (const t of p.tabs) if (t.input.kind === "file" && t.input.path) out.push(t.input.path);
return out.sort();
}),
);
@@ -133,7 +138,8 @@ function PaneView({ paneId }: { paneId: string }) {
const [dropZone, setDropZone] = useState<DropZone | null>(null);
const tabPrefs = useEditorPrefs().tabs;
const distractionFree = useDistractionFree((s) => s.active);
const { showExtension, markModified, showCloseButton, showFileIcon, showDirectoryForNonUnique, showFullPathOnHover } = tabPrefs;
const { showExtension, markModified, showCloseButton, showFileIcon, showDirectoryForNonUnique, showFullPathOnHover } =
tabPrefs;
const items: TabItem[] = useMemo(() => {
const names = tabs.map((t) => tabTitle(t.input, kinds));
@@ -266,7 +272,9 @@ function PaneView({ paneId }: { paneId: string }) {
const at = { x: e.clientX, y: e.clientY };
const path = tab.input.kind === "file" ? tab.input.path : undefined;
if (!path) return showContextMenu(at, ctrl.tabMenu(paneId, tab));
void prepareMenuGroups("editorTab", { paths: [path] }).then(() => showContextMenu(at, ctrl.tabMenu(paneId, tab)));
void prepareMenuGroups("editorTab", { paths: [path] }).then(() =>
showContextMenu(at, ctrl.tabMenu(paneId, tab)),
);
}}
trailing={trailing}
/>
+6 -1
View File
@@ -361,7 +361,12 @@ export function ReadOnlyStatus() {
if (!meta || isVirtualUri(meta.path) || meta.banner === "binary" || meta.banner === "tooLarge") return null;
const label = meta.readOnly ? "Make file writable" : "Make file read-only";
return (
<BarButton tooltip={label} aria-label={label} aria-pressed={meta.readOnly} onClick={() => void executeAction("file.toggleReadOnly")}>
<BarButton
tooltip={label}
aria-label={label}
aria-pressed={meta.readOnly}
onClick={() => void executeAction("file.toggleReadOnly")}
>
<span className={styles.lock}>
<Icon name={meta.readOnly ? "lock" : "unlock"} size={14} />
</span>
+1 -3
View File
@@ -37,7 +37,5 @@ export function TextEditorHost({ tabId }: { tabId: string }) {
</EmptyState>
</div>
);
return (
<div ref={ref} className={styles.host} data-editor-host="" aria-busy={!slot || undefined} />
);
return <div ref={ref} className={styles.host} data-editor-host="" aria-busy={!slot || undefined} />;
}
+33 -5
View File
@@ -3,22 +3,50 @@ import type { FileEntry, OutlineItem } from "@/platform/ipc";
import { dirChildren, navSegments, stepSegment, symbolChildren } from "./navBar";
const r = { start: { line: 0, col: 0 }, end: { line: 9, col: 0 } };
const sym = (name: string, children: OutlineItem[] = []): OutlineItem => ({ name, kind: "class", detail: null, range: r, selection_range: r, children });
const sym = (name: string, children: OutlineItem[] = []): OutlineItem => ({
name,
kind: "class",
detail: null,
range: r,
selection_range: r,
children,
});
describe("navigation bar popup model", () => {
it("lists project › dirs › file › symbols, skipping the class named like its file", () => {
const segs = navSegments({ root: "/p", name: "proj" }, "/p/src/a/Foo.java", [sym("Foo"), sym("run")]);
expect(segs.map((s) => `${s.kind}:${s.label}`)).toEqual(["dir:proj", "dir:src", "dir:a", "file:Foo.java", "symbol:run"]);
expect(segs.map((s) => `${s.kind}:${s.label}`)).toEqual([
"dir:proj",
"dir:src",
"dir:a",
"file:Foo.java",
"symbol:run",
]);
expect(segs[2]).toMatchObject({ path: "/p/src/a" });
});
it("shows only the parent folder outside the project (scratches)", () => {
expect(navSegments({ root: "/p", name: "proj" }, "/data/scratches/scratch.py", []).map((s) => s.label)).toEqual(["scratches", "scratch.py"]);
expect(navSegments({ root: "/p", name: "proj" }, "/data/scratches/scratch.py", []).map((s) => s.label)).toEqual([
"scratches",
"scratch.py",
]);
});
it("lists folder children without IDE-hidden names, and symbol children", () => {
const e = (name: string, kind: FileEntry["kind"]) => ({ name, path: `/p/${name}`, kind, size: 0, mtimeMs: 0, ignored: false, excluded: false, hidden: false }) as FileEntry;
expect(dirChildren([e(".git", "dir"), e("src", "dir"), e("a.ts", "file")]).map((c) => `${c.kind}:${c.label}`)).toEqual(["dir:src", "file:a.ts"]);
const e = (name: string, kind: FileEntry["kind"]) =>
({
name,
path: `/p/${name}`,
kind,
size: 0,
mtimeMs: 0,
ignored: false,
excluded: false,
hidden: false,
}) as FileEntry;
expect(
dirChildren([e(".git", "dir"), e("src", "dir"), e("a.ts", "file")]).map((c) => `${c.kind}:${c.label}`),
).toEqual(["dir:src", "file:a.ts"]);
expect(symbolChildren("/p/a.ts", [sym("A", [sym("b")])]).map((c) => c.label)).toEqual(["A"]);
});
+3 -1
View File
@@ -21,7 +21,9 @@ describe("status bar navigation bar popup", () => {
const anchor = document.createElement("button");
document.body.appendChild(anchor);
const onClose = vi.fn();
render(<NavChildrenPopup segment={{ kind: "file", path: "/p/a.ts", label: "a.ts" }} anchor={anchor} onClose={onClose} />);
render(
<NavChildrenPopup segment={{ kind: "file", path: "/p/a.ts", label: "a.ts" }} anchor={anchor} onClose={onClose} />,
);
await act(async () => {});
const list = screen.getByRole("listbox", { name: "a.ts" });
expect(screen.getAllByRole("option").map((o) => o.textContent)).toEqual(["first", "second"]);
+9 -6
View File
@@ -2,14 +2,14 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { VirtualDocument } from "@/platform/ipc";
const JDT = "jdt://contents/lib-1.0.jar/org.example/Lib.class?=demo/%5C/m2%5C/lib-1.0.jar%3Corg.example%28Lib.class";
const TEXT = "// Source code is unavailable, and was generated by the Fernflower decompiler.\npackage org.example;\n\npublic class Lib {\n void run() {}\n}\n";
const TEXT =
"// Source code is unavailable, and was generated by the Fernflower decompiler.\npackage org.example;\n\npublic class Lib {\n void run() {}\n}\n";
vi.mock("@/platform/virtualDocs", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/platform/virtualDocs")>();
return {
...actual,
virtualDocument: vi.fn(
async (uri: string): Promise<VirtualDocument> => {
virtualDocument: vi.fn(async (uri: string): Promise<VirtualDocument> => {
if (uri !== JDT) throw new Error(`no contents for ${uri}`);
return {
uri,
@@ -19,8 +19,7 @@ vi.mock("@/platform/virtualDocs", async (importOriginal) => {
description: "/m2/lib-1.0.jar",
decompiled: true,
};
},
),
}),
};
});
@@ -81,7 +80,11 @@ describe("virtual editor input", () => {
const session = serializeSession(useTabs.getState(), {});
expect(JSON.stringify(session)).not.toContain("jdt:");
expect(ctrl.tabMenu(useTabs.getState().activePane, activeTab()).some((i) => "label" in i && i.label === "Reveal in Project")).toBe(false);
expect(
ctrl
.tabMenu(useTabs.getState().activePane, activeTab())
.some((i) => "label" in i && i.label === "Reveal in Project"),
).toBe(false);
});
it("does not open a tab when the server has no contents", async () => {
+7 -2
View File
@@ -117,7 +117,10 @@ describe("list popup", () => {
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;" }] }));
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 },
@@ -171,7 +174,9 @@ describe("documentation", () => {
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 }));
await vi.waitFor(() =>
expect(openFile).toHaveBeenCalledWith("/p/b.ts", { selection: { line: 3, col: 4 }, focus: true }),
);
expect(container).toBeTruthy();
});
+58 -16
View File
@@ -101,10 +101,17 @@ async function cleanup(view: EditorView, o: CleanupOptions) {
}
/** Code action titles of Implement Methods (⌃I) and Override Methods (⌃O). */
export const IMPLEMENT_TITLE = /\bimplement\b|\b(add|generate) (missing|unimplemented|all missing)\b|missing (members|methods|trait items)/i;
export const IMPLEMENT_TITLE =
/\bimplement\b|\b(add|generate) (missing|unimplemented|all missing)\b|missing (members|methods|trait items)/i;
export const OVERRIDE_TITLE = /\boverride\b/i;
function editorAction(id: string, title: string, category: string, run: (view: EditorView) => unknown, extra: Partial<Action> = {}): Action {
function editorAction(
id: string,
title: string,
category: string,
run: (view: EditorView) => unknown,
extra: Partial<Action> = {},
): Action {
return { id, title, category, when: EDITOR_WHEN, run: withEditor(run), ...extra };
}
@@ -149,9 +156,15 @@ export function registerLspActions(): () => void {
keywords: ["javadoc", "docs", "hover"],
}),
editorAction("code.quickDefinition", "Quick Definition", "Code", quickDefinition),
editorAction("code.quickTypeDefinition", "Quick Type Definition", "View", (v) => quickDefinition(v, "typeDefinition"), {
editorAction(
"code.quickTypeDefinition",
"Quick Type Definition",
"View",
(v) => quickDefinition(v, "typeDefinition"),
{
keywords: ["type declaration", "preview"],
}),
},
),
editorAction("code.showIntentions", "Show Context Actions", "Code", showIntentions, {
keywords: ["intention", "quick fix", "lightbulb"],
}),
@@ -164,13 +177,23 @@ export function registerLspActions(): () => void {
editorAction("code.reformatDialog", "Reformat File…", "Code", (v) =>
reformatDialog(v, (view) => applyKindSilently(view, "source.organizeImports")),
),
editorAction("code.optimizeImports", "Optimize Imports", "Code", optimizeImports, { keywords: ["organize imports"] }),
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) },
{
id: "code.reformatFile",
title: "Reformat File",
category: "Code",
hidden: true,
when: "projectOpen",
run: inFile(reformat),
},
{
id: "code.cleanupFile",
title: "Code Cleanup in File",
description: "Code Cleanup on `{ path, fixAll?, optimizeImports?, reformat? }` (all steps by default), opened first.",
description:
"Code Cleanup on `{ path, fixAll?, optimizeImports?, reformat? }` (all steps by default), opened first.",
category: "Code",
hidden: true,
when: "projectOpen",
@@ -187,11 +210,21 @@ export function registerLspActions(): () => void {
when: "projectOpen",
run: inFile(optimizeImports),
},
editorAction("navigate.declaration", "Go to Declaration or Usages", "Navigate", (v) => gotoTargets(v, "definition"), {
editorAction(
"navigate.declaration",
"Go to Declaration or Usages",
"Navigate",
(v) => gotoTargets(v, "definition"),
{
keywords: ["definition"],
}),
editorAction("navigate.implementation", "Go to Implementation(s)", "Navigate", (v) => gotoTargets(v, "implementation")),
editorAction("navigate.typeDeclaration", "Go to Type Declaration", "Navigate", (v) => gotoTargets(v, "typeDefinition")),
},
),
editorAction("navigate.implementation", "Go to Implementation(s)", "Navigate", (v) =>
gotoTargets(v, "implementation"),
),
editorAction("navigate.typeDeclaration", "Go to Type Declaration", "Navigate", (v) =>
gotoTargets(v, "typeDefinition"),
),
editorAction("navigate.nextError", "Next Highlighted Error", "Navigate", (v) => gotoDiagnostic(v, 1)),
editorAction("navigate.prevError", "Previous Highlighted Error", "Navigate", (v) => gotoDiagnostic(v, -1)),
editorAction("find.highlightUsages", "Highlight Usages in File", "Find", highlightUsages),
@@ -208,13 +241,21 @@ export function registerLspActions(): () => void {
},
},
editorAction("refactor.rename", "Rename…", "Refactor", startRename),
editorAction("refactor.extractVariable", "Introduce Variable…", "Refactor", (v) => runRefactoring(v, "extractVariable")),
editorAction("refactor.extractVariable", "Introduce Variable…", "Refactor", (v) =>
runRefactoring(v, "extractVariable"),
),
editorAction("refactor.extractMethod", "Extract Method…", "Refactor", (v) => runRefactoring(v, "extractMethod")),
editorAction("refactor.extractConstant", "Introduce Constant…", "Refactor", (v) => runRefactoring(v, "extractConstant")),
editorAction("refactor.extractConstant", "Introduce Constant…", "Refactor", (v) =>
runRefactoring(v, "extractConstant"),
),
editorAction("refactor.extractField", "Introduce Field…", "Refactor", (v) => runRefactoring(v, "extractField")),
editorAction("refactor.extractParameter", "Introduce Parameter…", "Refactor", (v) => runRefactoring(v, "extractParameter")),
editorAction("refactor.extractParameter", "Introduce Parameter…", "Refactor", (v) =>
runRefactoring(v, "extractParameter"),
),
editorAction("refactor.move", "Move…", "Refactor", (v) => runRefactoring(v, "move")),
editorAction("refactor.changeSignature", "Change Signature…", "Refactor", (v) => runRefactoring(v, "changeSignature")),
editorAction("refactor.changeSignature", "Change Signature…", "Refactor", (v) =>
runRefactoring(v, "changeSignature"),
),
editorAction("refactor.safeDelete", "Safe Delete…", "Refactor", safeDelete),
editorAction(
"code.generate",
@@ -222,7 +263,8 @@ export function registerLspActions(): () => void {
"Code",
(v) =>
runKindAction(v, "", {
title: /^(generate|implement|override|add (missing|derive|impl|getter|setter|constructor)|create (constructor|getter|setter))/i,
title:
/^(generate|implement|override|add (missing|derive|impl|getter|setter|constructor)|create (constructor|getter|setter))/i,
none: "Nothing to generate here",
label: "Generate",
}),
+3 -1
View File
@@ -36,7 +36,9 @@ export function authorLabel(s: AuthorSummary): string {
}
export function authorTooltip(s: AuthorSummary): string {
return s.others ? `Last changed by ${s.author}; also ${s.authors.slice(1).join(", ")}` : `Last changed by ${s.author}`;
return s.others
? `Last changed by ${s.author}; also ${s.authors.slice(1).join(", ")}`
: `Last changed by ${s.author}`;
}
interface Entry {
+3 -1
View File
@@ -20,7 +20,9 @@ const IMPORT_TITLE = /\bimport\b/i;
/** The single import fix among `actions`, if exactly one exists. */
export function unambiguousImport(actions: readonly CodeAction[]): CodeAction | null {
const imports = actions.filter((a) => !a.disabledReason && IMPORT_TITLE.test(a.title) && (!a.kind || a.kind.startsWith("quickfix")));
const imports = actions.filter(
(a) => !a.disabledReason && IMPORT_TITLE.test(a.title) && (!a.kind || a.kind.startsWith("quickfix")),
);
return imports.length === 1 ? imports[0]! : null;
}
+7 -3
View File
@@ -54,7 +54,8 @@ 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) && !anonymous(it.name)) out.push({
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,
@@ -159,7 +160,9 @@ class UsagesLensPlugin {
const { from, to } = view.viewport;
const firstLine = Math.max(0, text.lineAt(from).number - 1 - MARGIN_LINES);
const lastLine = text.lineAt(to).number - 1 + MARGIN_LINES;
const visible = this.outline.targets.filter((t) => t.line >= firstLine && t.line <= lastLine).slice(0, MAX_PER_PASS);
const visible = this.outline.targets
.filter((t) => t.line >= firstLine && t.line <= lastLine)
.slice(0, MAX_PER_PASS);
const key = (t: LensTarget) => `${t.line}:${t.col}:${t.name}`;
const todo = usages ? visible.filter((t) => !this.cache.has(key(t))) : [];
let unavailable = false;
@@ -185,7 +188,8 @@ class UsagesLensPlugin {
for (const t of visible) {
const offset = posToOffset(text, { line: t.line, col: t.col });
const who = lines && authorSummary(lines, t.startLine, t.endLine);
if (who) lenses.push({ pos: offset, title: authorLabel(who), tooltip: authorTooltip(who), icon: "user", order: 20 });
if (who)
lenses.push({ pos: offset, title: authorLabel(who), tooltip: authorTooltip(who), icon: "user", order: 20 });
const n = usages ? this.cache.get(key(t)) : undefined;
if (n === undefined) continue;
lenses.push({
+6 -6
View File
@@ -330,9 +330,9 @@ describe("typeColumnText", () => {
const edit = (new_text: string) => [{ range: { start: { line: 0, col: 0 }, end: { line: 0, col: 0 } }, new_text }];
it("names the module an auto-import comes from", () => {
expect(typeColumnText({ detail: "Auto-import", additionalEdits: edit("\nfrom collections import OrderedDict") })).toBe(
"collections",
);
expect(
typeColumnText({ detail: "Auto-import", additionalEdits: edit("\nfrom collections import OrderedDict") }),
).toBe("collections");
expect(typeColumnText({ detail: "Map<K, V>", additionalEdits: edit("import java.util.HashMap;\n") })).toBe(
"Map<K, V> (java.util)",
);
@@ -343,9 +343,9 @@ describe("typeColumnText", () => {
});
it("keeps a detail that already says where", () => {
expect(typeColumnText({ detail: "java.util.ArrayList", additionalEdits: edit("import java.util.ArrayList;\n") })).toBe(
"java.util.ArrayList",
);
expect(
typeColumnText({ detail: "java.util.ArrayList", additionalEdits: edit("import java.util.ArrayList;\n") }),
).toBe("java.util.ArrayList");
expect(typeColumnText({ detail: "fn() ", additionalEdits: [] })).toBe("fn() ");
});
});
+9 -1
View File
@@ -74,7 +74,15 @@ function extendsLabel(item: CompletionItem): boolean {
export function withoutDuplicates(items: readonly CompletionItem[]): CompletionItem[] {
const seen = new Set<string>();
return items.filter((it) => {
const key = JSON.stringify([it.server, it.label, it.labelDetail, it.detail, it.kind, it.insertText, it.additionalEdits]);
const key = JSON.stringify([
it.server,
it.label,
it.labelDetail,
it.detail,
it.kind,
it.insertText,
it.additionalEdits,
]);
if (seen.has(key)) return false;
seen.add(key);
return true;
+15 -2
View File
@@ -3,7 +3,13 @@ import { Text } from "@codemirror/state";
import type { Diagnostic } from "@/bindings";
import { diagnosticsFor, setFileDiagnostics, subscribeDiagnostics, toLintDiagnostics } from "./diagnostics";
const d = (line: number, col: number, endCol: number, severity: Diagnostic["severity"] = "error", over: Partial<Diagnostic> = {}): Diagnostic => ({
const d = (
line: number,
col: number,
endCol: number,
severity: Diagnostic["severity"] = "error",
over: Partial<Diagnostic> = {},
): Diagnostic => ({
range: { start: { line, col }, end: { line, col: endCol } },
severity,
message: `m${line}:${col}`,
@@ -40,7 +46,14 @@ describe("toLintDiagnostics", () => {
]);
expect(out).toEqual([
{ from: 3, to: 7, severity: "error", message: "m0:3", source: undefined, markClass: "cm-lsp-deprecated" },
{ from: 18, to: 19, severity: "warning", message: "m1:6", source: "rustc unused", markClass: "cm-lsp-unnecessary" },
{
from: 18,
to: 19,
severity: "warning",
message: "m1:6",
source: "rustc unused",
markClass: "cm-lsp-unnecessary",
},
]);
});
+17 -10
View File
@@ -37,7 +37,12 @@ export function setFileDiagnostics(path: string, source: string, items: Diagnost
const had = byPath.get(path)?.has(source) ?? false;
storeDiagnostics(path, source, items);
// `server` names the producer for views that see the diagnostic alone (inspection controls).
if (items.length || had) publishLocalDiagnostics(path, source, items.map((d) => (d.server ? d : { ...d, server: source })));
if (items.length || had)
publishLocalDiagnostics(
path,
source,
items.map((d) => (d.server ? d : { ...d, server: source })),
);
}
function storeDiagnostics(path: string, source: string, items: Diagnostic[]) {
@@ -47,7 +52,10 @@ function storeDiagnostics(path: string, source: string, items: Diagnostic[]) {
if (!sources.size) byPath.delete(path);
} else {
if (!sources) byPath.set(path, (sources = new Map()));
sources.set(source, items.map((d) => ({ ...d, source: d.source ?? source })));
sources.set(
source,
items.map((d) => ({ ...d, source: d.source ?? source })),
);
noteDiagnosticCodes(source, items);
}
listeners.get(path)?.forEach((l) => l());
@@ -92,7 +100,10 @@ export function installDiagnosticsEvents(): () => void {
const off = onIdeEvent("diagnostics", (e) => storeDiagnostics(e.path, e.source, e.items));
// Diagnostics published before this window (re)loaded were missed: ask for them again.
const resend = () => {
if (isTauri()) void ideEventsReady().then(() => commands.lspResendDiagnostics()).catch(() => {});
if (isTauri())
void ideEventsReady()
.then(() => commands.lspResendDiagnostics())
.catch(() => {});
};
if (useProject.getState().project) resend();
const offProject = useProject.subscribe((s, prev) => {
@@ -244,12 +255,7 @@ const lintTheme = EditorView.theme({
});
export function diagnosticsExtension() {
return [
linter(null, { tooltipFilter: () => [], autoPanel: false }),
marksField,
diagnosticsSync,
lintTheme,
];
return [linter(null, { tooltipFilter: () => [], autoPanel: false }), marksField, diagnosticsSync, lintTheme];
}
export interface PlacedDiagnostic {
@@ -292,7 +298,8 @@ export function gotoDiagnostic(view: EditorView, dir: 1 | -1): boolean {
view.dispatch({ selection: EditorSelection.cursor(target.from), scrollIntoView: true, userEvent: "select.navigate" });
showHint(view, target.diagnostic.message, {
pos: target.from,
tone: target.diagnostic.severity === "error" ? "error" : target.diagnostic.severity === "warning" ? "warning" : "info",
tone:
target.diagnostic.severity === "error" ? "error" : target.diagnostic.severity === "warning" ? "warning" : "info",
timeout: 6000,
});
return true;
+4 -1
View File
@@ -93,7 +93,10 @@ export async function editDocSource(host: DocHost): Promise<boolean> {
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 });
await editorService().openFile(loc.path, {
selection: { line: loc.range.start.line, col: loc.range.start.col },
focus: true,
});
return true;
}
+41 -8
View File
@@ -29,7 +29,9 @@ describe("semanticRanges", () => {
it("applies the Python-specific static style without changing other languages", () => {
const input = { tokenTypes: ["property"], tokenModifiers: ["static"], data: [0, 0, 5, 0, 1] };
expect(semanticRanges(doc, input, false, "python")[0]?.className).toBe("cm-sem-property cm-sem-static cm-sem-python-static");
expect(semanticRanges(doc, input, false, "python")[0]?.className).toBe(
"cm-sem-property cm-sem-static cm-sem-python-static",
);
expect(semanticRanges(doc, input, false, "java")[0]?.className).toBe("cm-sem-property cm-sem-static");
});
});
@@ -56,7 +58,9 @@ describe("semantic highlighting (rainbow identifiers)", () => {
it("hashes names to a stable palette index", () => {
expect(rainbowIndex("count")).toBe(rainbowIndex("count"));
const all = ["a", "b", "count", "total", "index", "value", "name", "item", "result", "i", "j", "k"].map(rainbowIndex);
const all = ["a", "b", "count", "total", "index", "value", "name", "item", "result", "i", "j", "k"].map(
rainbowIndex,
);
for (const n of all) expect(n >= 1 && n <= RAINBOW_COLORS).toBe(true);
expect(new Set(all).size).toBeGreaterThan(2);
});
@@ -73,7 +77,9 @@ describe("semantic highlighting (rainbow identifiers)", () => {
expect(byText.get("count")).toEqual(new Set([String(rainbowIndex("count"))]));
expect(byText.get("obj")).toEqual(new Set([String(rainbowIndex("obj"))]));
// Parameters keep their own class too; self, statics and fields get no rainbow colour.
expect(ranges.find((r) => r.from === 5)!.className).toBe(`cm-sem-parameter cm-sem-declaration cm-sem-rainbow-${rainbowIndex("total")}`);
expect(ranges.find((r) => r.from === 5)!.className).toBe(
`cm-sem-parameter cm-sem-declaration cm-sem-rainbow-${rainbowIndex("total")}`,
);
expect(byText.get("self")).toEqual(new Set([null]));
expect(byText.get("S")).toEqual(new Set([null]));
expect(byText.get("field")).toEqual(new Set([null]));
@@ -90,12 +96,21 @@ describe("semantic highlighting (rainbow identifiers)", () => {
},
true,
);
expect(r.filter((x) => x.className.includes("rainbow")).map((x) => ts.sliceString(x.from, x.to))).toEqual(["p", "x", "p"]);
expect(r.filter((x) => x.className.includes("rainbow")).map((x) => ts.sliceString(x.from, x.to))).toEqual([
"p",
"x",
"p",
]);
});
});
describe("sortCodeActions", () => {
const a = (title: string, kind: string | null, isPreferred = false, disabledReason: string | null = null): CodeAction => ({
const a = (
title: string,
kind: string | null,
isPreferred = false,
disabledReason: string | null = null,
): CodeAction => ({
title,
kind,
isPreferred,
@@ -114,14 +129,29 @@ describe("sortCodeActions", () => {
a("preferred fix", "quickfix.import", true),
a("preferred refactor", "refactor", true),
]);
expect(sorted.map((x) => x.title)).toEqual(["preferred fix", "fix", "preferred refactor", "extract", "disabled fix"]);
expect(sorted.map((x) => x.title)).toEqual([
"preferred fix",
"fix",
"preferred refactor",
"extract",
"disabled fix",
]);
});
});
describe("splitSignature", () => {
it("splits around the active parameter", () => {
const label = "fn create(req: Request, user: Long) -> Order";
expect(splitSignature(label, [[10, 22], [24, 34]], 1)).toEqual({
expect(
splitSignature(
label,
[
[10, 22],
[24, 34],
],
1,
),
).toEqual({
before: "fn create(req: Request, ",
param: "user: Long",
after: ") -> Order",
@@ -141,7 +171,10 @@ describe("lineErrorsAwayFromCaret", () => {
const p = (from: number, to: number, severity = "error") => ({ from, to, severity });
it("offers errors elsewhere on the caret line when none is at the caret", () => {
expect(lineErrorsAwayFromCaret([p(30, 34), p(12, 15), p(50, 52), p(5, 6, "warning")], 0, 40, 2)).toEqual([p(12, 15), p(30, 34)]);
expect(lineErrorsAwayFromCaret([p(30, 34), p(12, 15), p(50, 52), p(5, 6, "warning")], 0, 40, 2)).toEqual([
p(12, 15),
p(30, 34),
]);
});
it("offers nothing extra when an error touches the caret", () => {
+10 -2
View File
@@ -6,7 +6,12 @@
* Paths resolve in the backend like IDEA's file references: relative to the
* file, `/…` against the project root and its resource roots.
*/
import { startCompletion, type Completion, type CompletionContext, type CompletionResult } from "@codemirror/autocomplete";
import {
startCompletion,
type Completion,
type CompletionContext,
type CompletionResult,
} from "@codemirror/autocomplete";
import type { Extension } from "@codemirror/state";
import { EditorView, ViewPlugin } from "@codemirror/view";
import { whenSynced } from "@/editor/api";
@@ -71,7 +76,10 @@ function option(entry: PathCompletion): Completion & { icon: string } {
type: "folder",
icon: folderIconName(),
apply: (view, _c, from, to) => {
view.dispatch({ changes: { from, to, insert: `${entry.name}/` }, selection: { anchor: from + entry.name.length + 1 } });
view.dispatch({
changes: { from, to, insert: `${entry.name}/` },
selection: { anchor: from + entry.name.length + 1 },
});
startCompletion(view);
},
};
+4 -1
View File
@@ -25,7 +25,10 @@ export async function readFileLines(path: string): Promise<string[] | null> {
}
/** Reads several files with bounded concurrency; missing files map to `null`. */
export async function readManyFileLines(paths: readonly string[], concurrency = 4): Promise<Map<string, string[] | null>> {
export async function readManyFileLines(
paths: readonly string[],
concurrency = 4,
): Promise<Map<string, string[] | null>> {
const out = new Map<string, string[] | null>();
const queue = [...new Set(paths)];
const worker = async () => {
+25 -6
View File
@@ -12,7 +12,12 @@ import type { EditorView } from "@codemirror/view";
import { commands } from "@/platform/ipc";
import { shortcutLabel } from "@/platform/keymap";
import { executeAction } from "@/platform/actions";
import { formatExcluded, formatGuardOptions, guardFormatChanges, type FormatGuardOptions } from "@/platform/formatGuard";
import {
formatExcluded,
formatGuardOptions,
guardFormatChanges,
type FormatGuardOptions,
} from "@/platform/formatGuard";
import { useProject } from "@/platform/project";
import { CONTRIBUTED_FORMAT, contributedAt, whenSynced, type ContributedFormatter } from "@/editor/api";
import { docOf, lspRequest, reportLspError } from "./lsp";
@@ -66,7 +71,9 @@ function formattedHint(view: EditorView, lines: number) {
/** LSP `FormattingOptions`: with spaces the indent width is the indent unit, not the tab size. */
export function formatOptionsOf(state: EditorState): { tabSize: number; insertSpaces: boolean } {
const unit = state.facet(indentUnit);
return unit.includes("\t") ? { tabSize: state.tabSize, insertSpaces: false } : { tabSize: unit.length || state.tabSize, insertSpaces: true };
return unit.includes("\t")
? { tabSize: state.tabSize, insertSpaces: false }
: { tabSize: unit.length || state.tabSize, insertSpaces: true };
}
export async function formatDocument(
@@ -83,7 +90,8 @@ async function format(view: EditorView, opts: { explicit: boolean; selectionOnly
const guard = formatGuardOptions(settings);
const path = docOf(view)?.path;
if (path && formatExcluded(path, project?.root ?? null, guard.doNotFormat)) {
if (opts.explicit) showHint(view, "Formatting is turned off for this file (Code Style › Formatter › Do not format)");
if (opts.explicit)
showHint(view, "Formatting is turned off for this file (Code Style › Formatter › Do not format)");
return NOTHING;
}
const own = contributedAt<ContributedFormatter>(view.state, CONTRIBUTED_FORMAT, 0);
@@ -125,7 +133,12 @@ async function format(view: EditorView, opts: { explicit: boolean; selectionOnly
/** Whether `after` has a non-blank line at column 0 where all of `before` was indented. */
export function dedentsToColumnZero(before: string, after: string): boolean {
const minIndent = (t: string) =>
Math.min(...t.split("\n").filter((l) => l.trim()).map((l) => l.length - l.trimStart().length));
Math.min(
...t
.split("\n")
.filter((l) => l.trim())
.map((l) => l.length - l.trimStart().length),
);
return minIndent(before) > 0 && minIndent(after) === 0;
}
@@ -150,7 +163,9 @@ export async function formatLines(view: EditorView, from: number, to: number): P
}
if (view.state.doc !== state.doc) return false;
// Only inside the range: some servers answer a range request with whole-file edits.
const changes = textEditsToChanges(state.doc, edits).filter((c) => c.from >= from && c.to <= to && state.sliceDoc(c.from, c.to) !== c.insert);
const changes = textEditsToChanges(state.doc, edits).filter(
(c) => c.from >= from && c.to <= to && state.sliceDoc(c.from, c.to) !== c.insert,
);
if (!changes.length) return true;
// A server formatting the range out of context (kotlin-language-server) moves
// indented code to column 0: its answer is ignored then.
@@ -212,7 +227,11 @@ export async function reformatDialog(
initial: lastOptions,
onRun: (options) =>
void (async () => {
lastOptions = { ...lastOptions, ...options, selectionOnly: sel.empty ? lastOptions.selectionOnly : options.selectionOnly };
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 });
+1 -4
View File
@@ -70,8 +70,5 @@ export function hideHint(view: EditorView): boolean {
}
export function hintExtension(): Extension {
return [
hintField,
Prec.high(keymap.of([{ key: "Escape", run: (view) => hideHint(view) }])),
];
return [hintField, Prec.high(keymap.of([{ key: "Escape", run: (view) => hideHint(view) }]))];
}
+3 -1
View File
@@ -66,7 +66,9 @@ describe("inlay hints", () => {
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 }));
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",
+20 -5
View File
@@ -136,14 +136,17 @@ export function inlayDecorations(view: EditorView, hints: readonly InlayHint[]):
const doc = view.state.doc;
return Decoration.set(
hints.map((h) =>
Decoration.widget({ widget: new InlayWidget(h), side: h.kind === "parameter" ? 1 : -1 }).range(posToOffset(doc, h.position)),
Decoration.widget({ widget: new InlayWidget(h), side: h.kind === "parameter" ? 1 : -1 }).range(
posToOffset(doc, h.position),
),
),
true,
);
}
const enabled = (language: string) =>
useProject.getState().settings?.editor?.inlayHints !== false && !editorPrefs().inlay.disabledLanguages.includes(language);
useProject.getState().settings?.editor?.inlayHints !== false &&
!editorPrefs().inlay.disabledLanguages.includes(language);
/** Inlay Hints settings: per kind (types / parameters / other). */
function shownKind(kind: InlayHint["kind"]): boolean {
@@ -160,9 +163,14 @@ const inlayPlugin = ViewPlugin.fromClass(
constructor(private view: EditorView) {
this.refresh();
view.contentDOM.classList.toggle("cm-lsp-navigation-modifier", navigationModifierHeld());
this.offModifier = onNavigationModifier((held) => view.contentDOM.classList.toggle("cm-lsp-navigation-modifier", held));
this.offModifier = onNavigationModifier((held) =>
view.contentDOM.classList.toggle("cm-lsp-navigation-modifier", held),
);
this.unsubscribe = useProject.subscribe((s, prev) => {
if (s.settings?.editor?.inlayHints !== prev.settings?.editor?.inlayHints || s.settings?.editor?.inlay !== prev.settings?.editor?.inlay)
if (
s.settings?.editor?.inlayHints !== prev.settings?.editor?.inlayHints ||
s.settings?.editor?.inlay !== prev.settings?.editor?.inlay
)
this.refresh();
});
this.offReady = onLspRefresh("inlayHints", () => this.refresh());
@@ -186,7 +194,14 @@ const inlayPlugin = ViewPlugin.fromClass(
try {
const hints = await lspRequest(() => commands.lspInlayHints(doc.doc, offsetsToRange(state.doc, first, last)));
if (view.state.doc !== state.doc) return this.refresh();
view.dispatch({ effects: setInlays.of(inlayDecorations(view, hints.filter((h) => shownKind(h.kind)))) });
view.dispatch({
effects: setInlays.of(
inlayDecorations(
view,
hints.filter((h) => shownKind(h.kind)),
),
),
});
} catch (e) {
if (isUnavailable(e)) serverBackoff.mark(doc.doc);
}
+9 -2
View File
@@ -16,7 +16,10 @@ afterEach(() => views.splice(0).forEach((v) => v.destroy()));
// "let foo = 1; foo + foo;" — foo at 4, 13 and 19.
function session(commit = vi.fn(), dialog = vi.fn(), extensions: Extension[] = []) {
const view = new EditorView({
state: EditorState.create({ doc: "let foo = 1; foo + foo;", extensions: [inplaceRenameExtension(), ...extensions] }),
state: EditorState.create({
doc: "let foo = 1; foo + foo;",
extensions: [inplaceRenameExtension(), ...extensions],
}),
});
views.push(view);
startInplaceRename(view, {
@@ -33,7 +36,11 @@ function session(commit = vi.fn(), dialog = vi.fn(), extensions: Extension[] = [
}
const type = (view: EditorView, from: number, to: number, insert: string) =>
view.dispatch({ changes: { from, to, insert }, selection: EditorSelection.cursor(from + insert.length), userEvent: "input.type" });
view.dispatch({
changes: { from, to, insert },
selection: EditorSelection.cursor(from + insert.length),
userEvent: "input.type",
});
describe("in-place rename", () => {
it("preselects the name and mirrors edits to the other occurrences", () => {
+36 -5
View File
@@ -9,8 +9,26 @@
* back before the server renames, so the project-wide rename stays a single
* undoable edit computed against the original document.
*/
import { Annotation, EditorSelection, EditorState, Prec, StateEffect, StateField, Transaction, type ChangeSet, type Extension } from "@codemirror/state";
import { Decoration, EditorView, ViewPlugin, keymap, showTooltip, type Tooltip, type ViewUpdate } from "@codemirror/view";
import {
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";
@@ -40,7 +58,10 @@ const endSession = StateEffect.define<null>();
/** Marks the session's own edits (mirroring, restoring). */
const inplaceEdit = Annotation.define<boolean>();
const mapSpan = (s: Span, changes: ChangeSet): Span => ({ from: changes.mapPos(s.from, -1), to: changes.mapPos(s.to, 1) });
const mapSpan = (s: Span, changes: ChangeSet): Span => ({
from: changes.mapPos(s.from, -1),
to: changes.mapPos(s.to, 1),
});
const sessionField = StateField.define<InplaceSession | null>({
create: () => null,
@@ -50,7 +71,11 @@ const sessionField = StateField.define<InplaceSession | null>({
if (e.is(endSession)) return null;
}
if (!value || !tr.docChanged) return value;
return { ...value, primary: mapSpan(value.primary, tr.changes), others: value.others.map((o) => mapSpan(o, tr.changes)) };
return {
...value,
primary: mapSpan(value.primary, tr.changes),
others: value.others.map((o) => mapSpan(o, tr.changes)),
};
},
provide: (f) => [
EditorView.decorations.from(f, (s) => {
@@ -200,7 +225,13 @@ const watcher = ViewPlugin.fromClass(
const outside = head < s.primary.from || head > s.primary.to || u.state.selection.ranges.length > 1;
const foreignEdit = u.transactions.some((tr) => {
const before = inplaceSession(tr.startState);
return !!before && tr.docChanged && !tr.annotation(inplaceEdit) && !isExternalChange(tr) && !changesWithin(tr.changes, before.primary);
return (
!!before &&
tr.docChanged &&
!tr.annotation(inplaceEdit) &&
!isExternalChange(tr) &&
!changesWithin(tr.changes, before.primary)
);
});
if (outside || foreignEdit) queueMicrotask(() => endInplaceRename(this.view, "commit"));
}
+10 -4
View File
@@ -93,15 +93,21 @@ class InspectionWidget {
add(c.typo, "info", "cm-openidea-inspection-typo");
const children: HTMLElement[] = [summary];
if (total) {
const prev = button(`Previous Highlighted Error${shortcutLabel("navigate.prevError") ? ` (${shortcutLabel("navigate.prevError")})` : ""}`, () => {
const prev = button(
`Previous Highlighted Error${shortcutLabel("navigate.prevError") ? ` (${shortcutLabel("navigate.prevError")})` : ""}`,
() => {
gotoDiagnostic(this.view, -1);
this.view.focus();
});
},
);
prev.appendChild(iconNode("chevron-up", 14));
const next = button(`Next Highlighted Error${shortcutLabel("navigate.nextError") ? ` (${shortcutLabel("navigate.nextError")})` : ""}`, () => {
const next = button(
`Next Highlighted Error${shortcutLabel("navigate.nextError") ? ` (${shortcutLabel("navigate.nextError")})` : ""}`,
() => {
gotoDiagnostic(this.view, 1);
this.view.focus();
});
},
);
next.appendChild(iconNode("chevron-down", 14));
children.push(prev, next);
}
+12 -3
View File
@@ -28,8 +28,15 @@ vi.mock("@/platform/ipc", async (orig) => {
return { ...actual, isTauri: () => true, commands: { ...actual.commands, lspResolveCodeAction } };
});
const { bulbLine, changedLines, indentUnderBrace, intentionPreview, lightsBulb, listedCodeActions, withInspectionControls } =
await import("./intentions");
const {
bulbLine,
changedLines,
indentUnderBrace,
intentionPreview,
lightsBulb,
listedCodeActions,
withInspectionControls,
} = await import("./intentions");
const { setFileDiagnostics } = await import("./diagnostics");
const { lspDoc } = await import("./lsp");
@@ -133,7 +140,9 @@ describe("changedLines", () => {
});
it("keeps separate edits apart", () => {
const before = text("package p;\n\nclass A {\n int a;\n int b;\n void f() {\n }\n}\n");
const after = text("package p;\n\nimport java.util.List;\n\nclass A {\n int a;\n int b;\n void f() {\n List<String> x = null;\n x.size();\n }\n}\n");
const after = text(
"package p;\n\nimport java.util.List;\n\nclass A {\n int a;\n int b;\n void f() {\n List<String> x = null;\n x.size();\n }\n}\n",
);
expect(changedLines(before, after)).toEqual([
{ from: 3, to: 4 },
{ from: 9, to: 10 },
+49 -14
View File
@@ -43,7 +43,9 @@ async function fetchActions(view: EditorView, only: string[], whole = false): Pr
if (!doc) return [];
await whenSynced(view);
const sel = view.state.selection.main;
const range = whole ? offsetsToRange(view.state.doc, 0, view.state.doc.length) : offsetsToRange(view.state.doc, sel.from, sel.to);
const range = whole
? offsetsToRange(view.state.doc, 0, view.state.doc.length)
: offsetsToRange(view.state.doc, sel.from, sel.to);
return lspRequest(() => commands.lspCodeActions(doc.doc, range, only));
}
@@ -58,7 +60,12 @@ interface PlacedProblem {
* error elsewhere on the caret line are offered too. The errors to ask about,
* in line order; none when an error is at the caret.
*/
export function lineErrorsAwayFromCaret(problems: readonly PlacedProblem[], lineFrom: number, lineTo: number, caret: number): PlacedProblem[] {
export function lineErrorsAwayFromCaret(
problems: readonly PlacedProblem[],
lineFrom: number,
lineTo: number,
caret: number,
): PlacedProblem[] {
const errors = problems.filter((p) => p.severity === "error");
if (errors.some((p) => p.from <= caret && caret <= p.to)) return [];
return errors.filter((p) => p.from >= lineFrom && p.from <= lineTo).sort((a, b) => a.from - b.from);
@@ -78,7 +85,9 @@ async function fetchIntentions(view: EditorView): Promise<CodeAction[]> {
for (const p of lineErrorsAwayFromCaret(problems, line.from, line.to, sel.head)) {
let more: CodeAction[];
try {
more = await lspRequest(() => commands.lspCodeActions(doc.doc, offsetsToRange(state.doc, p.from, p.to), ["quickfix"]));
more = await lspRequest(() =>
commands.lspCodeActions(doc.doc, offsetsToRange(state.doc, p.from, p.to), ["quickfix"]),
);
} catch {
break;
}
@@ -97,7 +106,9 @@ export async function quickFixesAt(view: EditorView, from: number, to: number):
const doc = docOf(view);
if (!doc) return [];
await whenSynced(view);
const actions = await lspRequest(() => commands.lspCodeActions(doc.doc, offsetsToRange(view.state.doc, from, to), ["quickfix"]));
const actions = await lspRequest(() =>
commands.lspCodeActions(doc.doc, offsetsToRange(view.state.doc, from, to), ["quickfix"]),
);
return sortCodeActions(actions.filter((a) => isQuickFix(a) && !a.disabledReason));
}
@@ -112,7 +123,8 @@ export async function applyQuickFix(view: EditorView) {
const problems: Array<PlacedProblem & { rank: number }> = [];
forEachDiagnostic(state, (d, from, to) => {
if (d.severity === "hint" || to < line.from || from > line.to) return;
const rank = (d.severity === "error" ? 0 : d.severity === "warning" ? 1 : 2) + (from <= caret && caret <= to ? 0 : 3);
const rank =
(d.severity === "error" ? 0 : d.severity === "warning" ? 1 : 2) + (from <= caret && caret <= to ? 0 : 3);
problems.push({ from, to, severity: d.severity, rank });
});
problems.sort((a, b) => a.rank - b.rank);
@@ -162,7 +174,8 @@ async function reformatGenerated(view: EditorView, before: Text) {
// A server that generated code without its context formats ranges the same
// way (back to column 0): its range formatting is skipped then.
if (shift.length) view.dispatch({ changes: shift, userEvent: "format", scrollIntoView: false });
else if (!(await formatLines(view, doc.line(lines.from).from, doc.line(lines.to).to)) && view.state.doc !== doc) return;
else if (!(await formatLines(view, doc.line(lines.from).from, doc.line(lines.to).to)) && view.state.doc !== doc)
return;
}
}
@@ -172,14 +185,20 @@ async function reformatGenerated(view: EditorView, before: Text) {
* one level in, keeping its own nesting. Never applies to indentation-based
* languages, whose blocks don't open with `{`.
*/
export function indentUnderBrace(doc: Text, fromLine: number, toLine: number, unit: string): Array<{ from: number; insert: string }> {
export function indentUnderBrace(
doc: Text,
fromLine: number,
toLine: number,
unit: string,
): Array<{ from: number; insert: string }> {
if (fromLine < 2) return [];
const prev = doc.line(fromLine - 1).text;
if (!prev.trimEnd().endsWith("{")) return [];
const lead = (t: string) => t.slice(0, t.length - t.trimStart().length);
const target = lead(prev) + unit;
let first: string | null = null;
for (let n = fromLine; n <= toLine && first === null; n++) if (doc.line(n).text.trim()) first = lead(doc.line(n).text);
for (let n = fromLine; n <= toLine && first === null; n++)
if (doc.line(n).text.trim()) first = lead(doc.line(n).text);
if (first === null || first.length > lead(prev).length || !target.startsWith(first)) return [];
const extra = target.slice(first.length);
const out: Array<{ from: number; insert: string }> = [];
@@ -248,7 +267,11 @@ export function listedCodeActions(actions: readonly CodeAction[]): CodeAction[]
return sortCodeActions(actions).filter((a) => !seen.has(a.title) && !!seen.add(a.title));
}
function actionItems(view: EditorView, actions: CodeAction[], resolved?: Map<CodeAction, Promise<CodeAction>>): ListPopupItem[] {
function actionItems(
view: EditorView,
actions: CodeAction[],
resolved?: Map<CodeAction, Promise<CodeAction>>,
): ListPopupItem[] {
return listedCodeActions(actions).map((a, i) => ({
id: `${i}`,
label: a.title,
@@ -261,10 +284,17 @@ function actionItems(view: EditorView, actions: CodeAction[], resolved?: Map<Cod
}
/** The action with its edit (resolved once per popup; servers may compute edits lazily). */
function resolveOnce(doc: number, action: CodeAction, cache: Map<CodeAction, Promise<CodeAction>>): Promise<CodeAction> {
function resolveOnce(
doc: number,
action: CodeAction,
cache: Map<CodeAction, Promise<CodeAction>>,
): Promise<CodeAction> {
let p = cache.get(action);
if (!p) {
p = action.edit || action.command ? Promise.resolve(action) : lspRequest(() => commands.lspResolveCodeAction(doc, action));
p =
action.edit || action.command
? Promise.resolve(action)
: lspRequest(() => commands.lspResolveCodeAction(doc, action));
cache.set(action, p);
p.catch(() => cache.delete(action));
}
@@ -292,7 +322,10 @@ export async function intentionPreview(
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" };
return {
kind: "text",
text: full.command && !full.edit ? "Preview isn't available for this action" : "No changes in this file",
};
}
/**
@@ -467,7 +500,8 @@ const bulbPlugin = ViewPlugin.fromClass(
this.lookup.cancel();
// A blur dispatched from inside the update is not allowed.
queueMicrotask(() => {
if (!this.view.hasFocus && this.view.state.field(bulbField, false)) this.view.dispatch({ effects: setBulb.of(null) });
if (!this.view.hasFocus && this.view.state.field(bulbField, false))
this.view.dispatch({ effects: setBulb.of(null) });
});
}
}
@@ -489,7 +523,8 @@ const bulbPlugin = ViewPlugin.fromClass(
if (!local) return;
}
}
if (this.view.state.doc !== state.doc || !this.view.state.selection.eq(state.selection) || !this.view.hasFocus) return;
if (this.view.state.doc !== state.doc || !this.view.state.selection.eq(state.selection) || !this.view.hasFocus)
return;
const line = state.doc.lineAt(state.selection.main.head).number;
const any = actions.some(lightsBulb) || local;
this.view.dispatch({
+19 -5
View File
@@ -39,7 +39,13 @@ import { editorService } from "@/workbench/contributions";
import { fileIconName } from "@/ui";
import { NO_SERVER, debounce, docOf, isUnavailable, lspRequest, reportLspError } from "./lsp";
import { offsetToPos, rangeToOffsets } from "./positions";
import { SYNTAX_NOTE, declarationFallback, isSelfTarget, localHighlights, type DeclarationTarget } from "./syntaxFallback";
import {
SYNTAX_NOTE,
declarationFallback,
isSelfTarget,
localHighlights,
type DeclarationTarget,
} from "./syntaxFallback";
import { showHint } from "./hint";
import { caretAnchor, openPopup, type ListPopupItem } from "./popupStore";
import { identifierAt, readManyFileLines } from "./files";
@@ -305,7 +311,8 @@ const linkField = StateField.define<LinkState | null>({
create: (view) => {
const dom = document.createElement("div");
dom.className = "cm-lsp-hover cm-lsp-nav-info";
if ("code" in info) dom.appendChild(renderCode(info.code, info.language ?? docOf(view)?.uiMode ?? null, "cm-lsp-code"));
if ("code" in info)
dom.appendChild(renderCode(info.code, info.language ?? docOf(view)?.uiMode ?? null, "cm-lsp-code"));
else dom.textContent = info.text;
return { dom };
},
@@ -525,7 +532,10 @@ const highlightField = StateField.define<HighlightState>({
});
/** IDEA's status bar text while ⌘⇧F7 highlights are shown. */
export const useUsagesStatus = create<{ text: string | null; owner: EditorView | null }>(() => ({ text: null, owner: null }));
export const useUsagesStatus = create<{ text: string | null; owner: EditorView | null }>(() => ({
text: null,
owner: null,
}));
function setUsagesStatus(owner: EditorView | null, text: string | null) {
useUsagesStatus.setState({ owner, text });
@@ -557,7 +567,10 @@ async function fetchHighlights(view: EditorView, sticky: boolean) {
}
const at = items.find((i) => i.from <= head && head <= i.to) ?? state.wordAt(head);
const name = at ? state.sliceDoc(at.from, at.to) : "";
setUsagesStatus(view, usagesStatusText(items.length, name ? `'${name}'` : "the element", shortcutLabel("find.highlightUsages") ?? null));
setUsagesStatus(
view,
usagesStatusText(items.length, name ? `'${name}'` : "the element", shortcutLabel("find.highlightUsages") ?? null),
);
};
let lspError: unknown = null;
if (!serverBackoff.active(doc.doc)) {
@@ -628,7 +641,8 @@ const highlightPlugin = ViewPlugin.fromClass(
}
const clear = () => {
this.request.cancel();
if (st.deco.size) queueMicrotask(() => this.view.dispatch({ effects: setHighlights.of({ items: [], sticky: false }) }));
if (st.deco.size)
queueMicrotask(() => this.view.dispatch({ effects: setHighlights.of({ items: [], sticky: false }) }));
};
// Code Editing › "Usages of element at caret".
if (!editorPrefs().codeEditing.highlightUsages || !u.state.selection.main.empty) {
+8 -1
View File
@@ -1,6 +1,13 @@
import { describe, expect, it } from "vitest";
import { Text } from "@codemirror/state";
import { offsetToPos, offsetsToRange, posToOffset, rangeContains, rangeToOffsets, textEditsToChanges } from "./positions";
import {
offsetToPos,
offsetsToRange,
posToOffset,
rangeContains,
rangeToOffsets,
textEditsToChanges,
} from "./positions";
const doc = Text.of(["let a = 1;", "// 😀 émoji", "", "end"]);
+4 -1
View File
@@ -43,7 +43,10 @@ export function rangeContains(range: Range, pos: Pos): boolean {
* sorted, non-overlapping CodeMirror changes. Overlapping edits are dropped
* rather than producing a corrupt change set.
*/
export function textEditsToChanges(doc: Text, edits: readonly TextEdit[]): Array<{ from: number; to: number; insert: string }> {
export function textEditsToChanges(
doc: Text,
edits: readonly TextEdit[],
): Array<{ from: number; to: number; insert: string }> {
const changes = edits
.map((e, i) => ({ ...rangeToOffsets(doc, e.range), insert: e.new_text, i }))
.sort((a, b) => a.from - b.from || a.to - b.to || a.i - b.i);
+19 -3
View File
@@ -137,7 +137,12 @@ export async function renameNamePart(view: EditorView, part: Span, replacement:
.map((o) => rangeToOffsets(view.state.doc, o.range))
.find((o) => o.from <= part.from && part.to <= o.to);
if (!at) return false;
await commitSyntaxRename(view, doc, plan, spliceName(plan.symbol.name, part.from - at.from, word.length, replacement));
await commitSyntaxRename(
view,
doc,
plan,
spliceName(plan.symbol.name, part.from - at.from, word.length, replacement),
);
return true;
};
let range;
@@ -162,7 +167,13 @@ export async function renameNamePart(view: EditorView, part: Span, replacement:
}
/** This file's other occurrences of the symbol (document highlights with the same text). */
async function linkedOccurrences(view: EditorView, doc: number, pos: Pos, primary: Span, name: string): Promise<Span[]> {
async function linkedOccurrences(
view: EditorView,
doc: number,
pos: Pos,
primary: Span,
name: string,
): Promise<Span[]> {
let highlights;
try {
highlights = await lspRequest(() => commands.lspDocumentHighlights(doc, pos));
@@ -275,7 +286,12 @@ async function startSyntaxRename(view: EditorView, doc: EditorDocContext, pos: P
}
/** Applies a local rename in place, asking first when the syntax answer is not certain. */
export async function commitSyntaxRename(view: EditorView, doc: EditorDocContext, plan: LocalRenamePlan, newName: string) {
export async function commitSyntaxRename(
view: EditorView,
doc: EditorDocContext,
plan: LocalRenamePlan,
newName: string,
) {
const oldName = plan.symbol.name;
if (!newName || newName === oldName) return;
if (VALID_NAME.test(oldName) && !VALID_NAME.test(newName)) {
+6 -1
View File
@@ -166,7 +166,12 @@ const semanticPlugin = ViewPlugin.fromClass(
this.last = { doc, tokens };
const ranges = tokens ? semanticRanges(doc, tokens, this.rainbow, docOf(this.view)?.language) : [];
this.view.dispatch({
effects: setSemantic.of(Decoration.set(ranges.map((r) => markFor(r.className).range(r.from, r.to)), true)),
effects: setSemantic.of(
Decoration.set(
ranges.map((r) => markFor(r.className).range(r.from, r.to)),
true,
),
),
});
}
async run() {
+29 -8
View File
@@ -3,7 +3,11 @@ import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { closeBrackets } from "@codemirror/autocomplete";
const help = { signatures: [{ label: "f(a: number)", documentation: null, parameters: [[2, 11]] }], activeSignature: 0, activeParameter: 0 };
const help = {
signatures: [{ label: "f(a: number)", documentation: null, parameters: [[2, 11]] }],
activeSignature: 0,
activeParameter: 0,
};
const lspSignatureHelp = vi.fn(async () => ({ status: "ok" as const, data: help }));
vi.mock("@/platform/ipc", async (orig) => {
@@ -11,9 +15,8 @@ vi.mock("@/platform/ipc", async (orig) => {
return { ...actual, isTauri: () => true, commands: { ...actual.commands, lspSignatureHelp } };
});
const { signatureExtension, acceptsParameter, argumentSpans, argumentStop, parameterTabKeys, triggerSignatureHelp } = await import(
"./signature"
);
const { signatureExtension, acceptsParameter, argumentSpans, argumentStop, parameterTabKeys, triggerSignatureHelp } =
await import("./signature");
const { lspDoc } = await import("./lsp");
const views: EditorView[] = [];
@@ -24,7 +27,8 @@ afterEach(() => {
function typeChar(view: EditorView, ch: string) {
const { from, to } = view.state.selection.main;
const insert = () => view.state.update({ changes: { from, to, insert: ch }, selection: { anchor: from + 1 }, userEvent: "input.type" });
const insert = () =>
view.state.update({ changes: { from, to, insert: ch }, selection: { anchor: from + 1 }, userEvent: "input.type" });
if (!view.state.facet(EditorView.inputHandler).some((h) => h(view, from, to, ch, insert))) view.dispatch(insert());
}
@@ -36,7 +40,11 @@ describe("parameter info auto-popup", () => {
state: EditorState.create({
doc: "f",
selection: { anchor: 1 },
extensions: [closeBrackets(), lspDoc.of({ doc: 1, path: "/a.ts", language: "typescript", uiMode: null, readOnly: false }), signatureExtension()],
extensions: [
closeBrackets(),
lspDoc.of({ doc: 1, path: "/a.ts", language: "typescript", uiMode: null, readOnly: false }),
signatureExtension(),
],
}),
parent: document.body,
});
@@ -57,7 +65,18 @@ describe("acceptsParameter", () => {
expect(acceptsParameter({ label: "println() : void", parameters: [] }, 0)).toBe(false);
expect(acceptsParameter({ label: "println(Object x) : void", parameters: [[8, 16]] }, 0)).toBe(true);
expect(acceptsParameter({ label: "println(Object x) : void", parameters: [[8, 16]] }, 1)).toBe(false);
expect(acceptsParameter({ label: "format(String f, Object... a)", parameters: [[7, 15], [17, 28]] }, 4)).toBe(true);
expect(
acceptsParameter(
{
label: "format(String f, Object... a)",
parameters: [
[7, 15],
[17, 28],
],
},
4,
),
).toBe(true);
});
});
@@ -101,7 +120,9 @@ describe("argument navigation", () => {
});
views.push(view);
const tab = () =>
view.contentDOM.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", keyCode: 9, bubbles: true, cancelable: true }));
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);
+6 -2
View File
@@ -122,7 +122,8 @@ export async function parameterInfoOrLeave(view: EditorView, inside: number) {
// No signature help: treated as nothing to fill in.
}
if (view.state.doc !== state.doc || view.state.selection.main.head !== inside) return;
if (help?.signatures.some((sig) => sig.parameters.length)) view.dispatch({ effects: setSignature.of({ pos: inside, help }) });
if (help?.signatures.some((sig) => sig.parameters.length))
view.dispatch({ effects: setSignature.of({ pos: inside, help }) });
else view.dispatch({ selection: EditorSelection.cursor(inside + 1) });
}
@@ -146,7 +147,10 @@ const signaturePlugin = ViewPlugin.fromClass(
update(u: ViewUpdate) {
const active = u.state.field(signatureField, false);
let typedTrigger = false;
if (u.docChanged && u.transactions.some((tr) => tr.isUserEvent("input.type") || tr.isUserEvent("input.complete"))) {
if (
u.docChanged &&
u.transactions.some((tr) => tr.isUserEvent("input.type") || tr.isUserEvent("input.complete"))
) {
// The character before the caret: `(` is followed by an auto-inserted `)`.
const head = u.state.selection.main.head;
const before = u.state.sliceDoc(Math.max(0, head - 1), head);
+21 -4
View File
@@ -4,7 +4,11 @@ import { EditorView } from "@codemirror/view";
import type { Settings, Typo } from "@/bindings";
let typos: Typo[] = [];
const commentTypo: Typo = { range: { start: { line: 0, col: 3 }, end: { line: 0, col: 9 } }, word: "mesage", context: "comment" };
const commentTypo: Typo = {
range: { start: { line: 0, col: 3 }, end: { line: 0, col: 9 } },
word: "mesage",
context: "comment",
};
const spellCheck = vi.fn(async () => ({ status: "ok" as const, data: typos }));
const settingsSet = vi.fn(async (_scope: string, _key: string, value: string) => ({
status: "ok" as const,
@@ -123,10 +127,17 @@ describe("Typo inspection", () => {
vi.useFakeTimers();
const options = (o: Record<string, boolean>) =>
({ inspections: { default: { "builtin:spelling": { options: o } } } }) as never;
useProject.setState({ project: { root: "/p" } as never, settings: options({ processCode: false, processLiterals: false }) });
useProject.setState({
project: { root: "/p" } as never,
settings: options({ processCode: false, processLiterals: false }),
});
editor("// mesage");
await settle();
expect(spellCheck).toHaveBeenLastCalledWith(7, { code: false, literals: false, comments: true }, expect.any(Number));
expect(spellCheck).toHaveBeenLastCalledWith(
7,
{ code: false, literals: false, comments: true },
expect.any(Number),
);
useProject.setState({ settings: options({ processComments: false }) });
await settle();
expect(spellCheck).toHaveBeenCalledTimes(2);
@@ -136,7 +147,13 @@ describe("Typo inspection", () => {
it("keeps lint conversion of plain info diagnostics unchanged", () => {
const doc = Text.of(["abc"]);
const out = toLintDiagnostics(doc, [
{ range: { start: { line: 0, col: 0 }, end: { line: 0, col: 1 } }, severity: "info", message: "m", source: null, code: null },
{
range: { start: { line: 0, col: 0 }, end: { line: 0, col: 1 } },
severity: "info",
message: "m",
source: null,
code: null,
},
]);
expect(out[0]!.markClass).toBeUndefined();
});
+5 -1
View File
@@ -58,7 +58,11 @@ const spellingEnabled = () => inspectionProfile()[SPELLING_INSPECTION]?.enabled
export function spellScope(): SpellScope {
const profile = inspectionProfile();
const on = (key: string) => inspectionOption(profile, SPELLING_INSPECTION, key);
return { code: on(SPELLING_OPTIONS.code), literals: on(SPELLING_OPTIONS.literals), comments: on(SPELLING_OPTIONS.comments) };
return {
code: on(SPELLING_OPTIONS.code),
literals: on(SPELLING_OPTIONS.literals),
comments: on(SPELLING_OPTIONS.comments),
};
}
const spellingOf = (s: Settings | null) => s?.spelling;
+72 -20
View File
@@ -5,7 +5,8 @@ import type { IdeError, Locals, NodeAt, Range, SymbolMatch, SyntaxNode } from "@
type R<T> = Promise<{ status: "ok"; data: T } | { status: "error"; error: IdeError }>;
const ok = <T>(data: T) => Promise.resolve({ status: "ok" as const, data });
const unavailable = () => Promise.resolve({ status: "error" as const, error: { kind: "unavailable", message: "no server" } as IdeError });
const unavailable = () =>
Promise.resolve({ status: "error" as const, error: { kind: "unavailable", message: "no server" } as IdeError });
const cmd = {
lspHover: vi.fn((): R<unknown> => ok(null)),
@@ -79,7 +80,10 @@ function editor(doc: string, language = "typescript", anchor = 0) {
state: EditorState.create({
doc,
selection: { anchor },
extensions: [lspDoc.of({ doc: 7, path: "/p/a.ts", language, uiMode: null, readOnly: false }), navigationExtension()],
extensions: [
lspDoc.of({ doc: 7, path: "/p/a.ts", language, uiMode: null, readOnly: false }),
navigationExtension(),
],
}),
parent: document.body,
});
@@ -98,15 +102,27 @@ describe("usageRole / filterSymbols", () => {
it("reads the role from the tree", () => {
const id = r(0, 0, 3);
expect(fb.usageRole({ node: node("type_identifier", id), ancestors: [] })).toBe("type");
expect(fb.usageRole({ node: node("identifier", id, "function"), ancestors: [node("call_expression", id)] })).toBe("call");
expect(fb.usageRole({ node: node("identifier", id, "name"), ancestors: [node("method_invocation", id)] })).toBe("call");
expect(fb.usageRole({ node: node("identifier", id, "constructor"), ancestors: [node("new_expression", id)] })).toBe("new");
expect(fb.usageRole({ node: node("identifier", id, "function"), ancestors: [node("call_expression", id)] })).toBe(
"call",
);
expect(fb.usageRole({ node: node("identifier", id, "name"), ancestors: [node("method_invocation", id)] })).toBe(
"call",
);
expect(fb.usageRole({ node: node("identifier", id, "constructor"), ancestors: [node("new_expression", id)] })).toBe(
"new",
);
expect(fb.usageRole({ node: node("identifier", id), ancestors: [node("binary_expression", id)] })).toBe("other");
expect(fb.usageRole(null)).toBe("other");
});
it("keeps exact names whose kind fits the role", () => {
const all = [sym("Foo", "class", "/a"), sym("Foo", "function", "/b"), sym("Foo", "field", "/c"), sym("Foobar", "class", "/d"), sym("Foo", "file", "/e")];
const all = [
sym("Foo", "class", "/a"),
sym("Foo", "function", "/b"),
sym("Foo", "field", "/c"),
sym("Foobar", "class", "/d"),
sym("Foo", "file", "/e"),
];
expect(fb.filterSymbols(all, "Foo", "type").map((s) => s.path)).toEqual(["/a"]);
expect(fb.filterSymbols(all, "Foo", "call").map((s) => s.path)).toEqual(["/a", "/b"]);
expect(fb.filterSymbols(all, "Foo", "other").map((s) => s.path)).toEqual(["/a", "/b", "/c"]);
@@ -153,9 +169,19 @@ describe("declarationFallback", () => {
});
it("looks non-locals up in the index by exact name and role", async () => {
cmd.syntaxLocals.mockReturnValueOnce(ok({ status: "notLocal", name: "foo", range: r(1, 6, 9), reason: "unresolved" }));
cmd.syntaxNodeAt.mockReturnValueOnce(ok({ node: node("identifier", r(1, 6, 9), "function"), ancestors: [node("call_expression", r(1, 6, 11))] }));
cmd.findSymbols.mockReturnValueOnce(ok([sym("foo", "function", "/p/b.ts", "util"), sym("foo", "field", "/p/c.ts"), sym("foobar", "function", "/p/d.ts")]));
cmd.syntaxLocals.mockReturnValueOnce(
ok({ status: "notLocal", name: "foo", range: r(1, 6, 9), reason: "unresolved" }),
);
cmd.syntaxNodeAt.mockReturnValueOnce(
ok({ node: node("identifier", r(1, 6, 9), "function"), ancestors: [node("call_expression", r(1, 6, 11))] }),
);
cmd.findSymbols.mockReturnValueOnce(
ok([
sym("foo", "function", "/p/b.ts", "util"),
sym("foo", "field", "/p/c.ts"),
sym("foobar", "function", "/p/d.ts"),
]),
);
const res = await fb.declarationFallback(doc, text, { line: 1, col: 7 });
expect(cmd.findSymbols).toHaveBeenCalledWith("foo", "all", 500);
expect(res).toEqual({
@@ -168,7 +194,9 @@ describe("declarationFallback", () => {
it("does not filter members by role", async () => {
cmd.syntaxLocals.mockReturnValueOnce(ok({ status: "notLocal", name: "foo", range: r(1, 6, 9), reason: "member" }));
cmd.syntaxNodeAt.mockReturnValueOnce(ok({ node: node("property_identifier", r(1, 6, 9)), ancestors: [] }));
cmd.findSymbols.mockReturnValueOnce(ok([sym("foo", "method", "/p/b.ts", "A"), sym("foo", "field", "/p/c.ts", "B")]));
cmd.findSymbols.mockReturnValueOnce(
ok([sym("foo", "method", "/p/b.ts", "A"), sym("foo", "field", "/p/c.ts", "B")]),
);
const res = await fb.declarationFallback(doc, text, { line: 1, col: 7 });
expect(res).toMatchObject({ kind: "targets", ambiguous: true });
});
@@ -179,7 +207,9 @@ describe("declarationFallback", () => {
await fb.declarationFallback({ ...doc, language: "nolocals" }, text, { line: 1, col: 7 });
expect(cmd.findSymbols).toHaveBeenCalledWith("foo", "all", 500);
cmd.syntaxSupport.mockReturnValueOnce(ok({ tree: false, locals: false }));
expect(await fb.declarationFallback({ ...doc, language: "nogrammar" }, text, { line: 0, col: 0 })).toEqual({ kind: "unsupported" });
expect(await fb.declarationFallback({ ...doc, language: "nogrammar" }, text, { line: 0, col: 0 })).toEqual({
kind: "unsupported",
});
});
});
@@ -205,13 +235,17 @@ describe("rename helpers", () => {
const id = r(0, 0, 1);
expect(fb.isShorthand({ node: node("shorthand_property_identifier", id), ancestors: [] })).toBe(true);
expect(fb.isShorthand({ node: node("shorthand_property_identifier_pattern", id), ancestors: [] })).toBe(true);
expect(fb.isShorthand({ node: node("identifier", id), ancestors: [node("shorthand_field_initializer", id)] })).toBe(true);
expect(fb.isShorthand({ node: node("identifier", id), ancestors: [node("shorthand_field_initializer", id)] })).toBe(
true,
);
expect(fb.isShorthand({ node: node("identifier", id), ancestors: [node("arguments", id)] })).toBe(false);
});
it("refuses non-locals and plans locals with shorthand detection", async () => {
cmd.syntaxLocals.mockReturnValueOnce(ok({ status: "notLocal", name: "foo", range: r(0, 0, 3), reason: "member" }));
expect(await fb.planLocalRename({ doc: 7, language: "typescript" }, { line: 0, col: 1 })).toMatchObject({ kind: "refused" });
expect(await fb.planLocalRename({ doc: 7, language: "typescript" }, { line: 0, col: 1 })).toMatchObject({
kind: "refused",
});
cmd.syntaxLocals.mockReturnValueOnce(ok(local()));
cmd.syntaxNodeAt
.mockReturnValueOnce(ok({ node: node("identifier", r(0, 6, 7)), ancestors: [] }))
@@ -226,7 +260,10 @@ describe("rename helpers", () => {
});
it("flags ambiguity and name clashes inside the scope", async () => {
const plan = { symbol: (local({ certain: false, conflicts: [r(3, 0, 1)] }) as Extract<Locals, { status: "local" }>).symbol, occurrences: [] };
const plan = {
symbol: (local({ certain: false, conflicts: [r(3, 0, 1)] }) as Extract<Locals, { status: "local" }>).symbol,
occurrences: [],
};
cmd.syntaxIdentifiers.mockReturnValueOnce(
ok([
{ range: r(2, 0, 1), kind: "identifier", qualified: false },
@@ -269,7 +306,9 @@ describe("navigation wiring", () => {
it("opens a chooser for several index candidates", async () => {
cmd.syntaxLocals.mockReturnValueOnce(ok({ status: "notLocal", name: "run", range: r(0, 4, 7), reason: "member" }));
cmd.syntaxNodeAt.mockReturnValueOnce(ok({ node: node("property_identifier", r(0, 4, 7)), ancestors: [] }));
cmd.findSymbols.mockReturnValueOnce(ok([sym("run", "method", "/p/a.ts", "A"), sym("run", "method", "/p/b.ts", "B")]));
cmd.findSymbols.mockReturnValueOnce(
ok([sym("run", "method", "/p/a.ts", "A"), sym("run", "method", "/p/b.ts", "B")]),
);
const view = editor("obj.run();", "typescript", 5);
await gotoTargets(view, "definition");
const popup = useLspPopup.getState().popup;
@@ -303,7 +342,9 @@ describe("navigation wiring", () => {
cmd.syntaxLocals.mockReturnValueOnce(ok({ status: "notLocal", name: "x", range: r(0, 0, 1), reason: "topLevel" }));
const view = editor("x;", "typescript", 0);
highlightUsages(view);
await vi.waitFor(() => expect(document.querySelector(".cm-lsp-hint")?.textContent).toBe("No language server available"));
await vi.waitFor(() =>
expect(document.querySelector(".cm-lsp-hint")?.textContent).toBe("No language server available"),
);
expect(view.dom.querySelectorAll(".cm-lsp-occurrence")).toHaveLength(0);
});
});
@@ -326,7 +367,9 @@ describe("rename wiring", () => {
});
it("asks before renaming an ambiguous local", async () => {
cmd.syntaxLocals.mockReturnValueOnce(ok(local({ references: [r(1, 0, 1)], certain: false, conflicts: [r(2, 4, 5)] })));
cmd.syntaxLocals.mockReturnValueOnce(
ok(local({ references: [r(1, 0, 1)], certain: false, conflicts: [r(2, 4, 5)] })),
);
const view = editor("const x = 1;\nx;\nvar x;", "typescript", 6);
await startRename(view);
const popup = useLspPopup.getState().popup;
@@ -364,14 +407,18 @@ describe("rename wiring", () => {
});
it("falls back to the syntax rename of a local, references included", async () => {
cmd.syntaxLocals.mockReturnValueOnce(ok(local({ name: "mesage", definition: r(0, 0, 6), references: [r(1, 6, 12)] })));
cmd.syntaxLocals.mockReturnValueOnce(
ok(local({ name: "mesage", definition: r(0, 0, 6), references: [r(1, 6, 12)] })),
);
const view = editor("mesage = 'hello'\nprint(mesage)", "python", 0);
await renameNamePart(view, { from: 0, to: 6 }, "message");
expect(view.state.doc.toString()).toBe("message = 'hello'\nprint(message)");
});
it("changes nothing when the symbol can't be renamed safely", async () => {
cmd.syntaxLocals.mockReturnValueOnce(ok({ status: "notLocal", name: "mesage", range: r(0, 0, 6), reason: "topLevel" }));
cmd.syntaxLocals.mockReturnValueOnce(
ok({ status: "notLocal", name: "mesage", range: r(0, 0, 6), reason: "topLevel" }),
);
const view = editor("mesage = 'hello'\nprint(mesage)", "python", 0);
await renameNamePart(view, { from: 0, to: 6 }, "message");
expect(view.state.doc.toString()).toBe("mesage = 'hello'\nprint(mesage)");
@@ -387,7 +434,12 @@ describe("rename wiring", () => {
expect(document.querySelector(".cm-lsp-hint")?.textContent).toBe("This element can't be renamed");
cmd.lspPrepareRename.mockReturnValueOnce(ok(null));
cmd.syntaxErrors.mockReturnValueOnce(ok({ errors: [{ kind: "missing", range: r(0, 12, 12), expected: ";", message: "missing `;`" }], truncated: false }));
cmd.syntaxErrors.mockReturnValueOnce(
ok({
errors: [{ kind: "missing", range: r(0, 12, 12), expected: ";", message: "missing `;`" }],
truncated: false,
}),
);
cmd.syntaxLocals.mockReturnValueOnce(ok(local({ references: [] })));
await startRename(view);
expect(useLspPopup.getState().popup?.kind).toBe("rename");
+20 -5
View File
@@ -42,7 +42,8 @@ const NON_DECLARATIONS: ReadonlySet<SymbolKind> = new Set(["file", "package", "m
export type UsageRole = "type" | "call" | "new" | "other";
const TYPE_NODE = /(^|_)type_identifier$|^user_type$|^scoped_type_identifier$/;
const TYPE_PARENT = /^(generic_type|user_type|nullable_type|type_annotation|type_arguments|scoped_type_identifier|qualified_type|array_type)$/;
const TYPE_PARENT =
/^(generic_type|user_type|nullable_type|type_annotation|type_arguments|scoped_type_identifier|qualified_type|array_type)$/;
const CALL_PARENT = /(^|_)(call|invocation)(_expression)?$|^call$/;
const NEW_PARENT = /^(new_expression|object_creation_expression|instance_creation_expression|constructor_invocation)$/;
@@ -53,7 +54,11 @@ export function usageRole(at: NodeAt | null): UsageRole {
const parent = at.ancestors[0];
if (parent && NEW_PARENT.test(parent.kind)) return "new";
if (TYPE_NODE.test(node.kind) || node.field === "type" || (parent && TYPE_PARENT.test(parent.kind))) return "type";
if (parent && CALL_PARENT.test(parent.kind) && (node.field === "function" || node.field === "name" || node.field == null))
if (
parent &&
CALL_PARENT.test(parent.kind) &&
(node.field === "function" || node.field === "name" || node.field == null)
)
return "call";
return "other";
}
@@ -245,7 +250,10 @@ export async function planLocalRename(
const shorthand = SHORTHAND_LANGUAGES.has(doc.language)
? await Promise.all(ranges.map((r) => structure.nodeAt(doc.doc, r, { signal }).then(isShorthand)))
: ranges.map(() => false);
return { kind: "plan", plan: { symbol, occurrences: ranges.map((range, i) => ({ range, shorthand: shorthand[i]! })) } };
return {
kind: "plan",
plan: { symbol, occurrences: ranges.map((range, i) => ({ range, shorthand: shorthand[i]! })) },
};
}
/** CodeMirror changes renaming every occurrence; shorthands keep their key. Null when the text no longer matches. */
@@ -260,7 +268,12 @@ export function renameChanges(doc: Text, plan: LocalRenamePlan, newName: string)
}
/** Why a syntax-based rename needs confirmation (empty: safe to apply). */
export async function renameRisks(doc: number, plan: LocalRenamePlan, newName: string, signal?: AbortSignal): Promise<string[]> {
export async function renameRisks(
doc: number,
plan: LocalRenamePlan,
newName: string,
signal?: AbortSignal,
): Promise<string[]> {
const risks: string[] = [];
const { symbol } = plan;
if (!symbol.certain)
@@ -271,7 +284,9 @@ export async function renameRisks(doc: number, plan: LocalRenamePlan, newName: s
(o) => !o.qualified && rangeContains(symbol.scope, o.range.start),
);
if (clashes.length)
risks.push(`'${newName}' is already used in this scope (${clashes.length} ${clashes.length === 1 ? "occurrence" : "occurrences"}).`);
risks.push(
`'${newName}' is already used in this scope (${clashes.length} ${clashes.length === 1 ? "occurrence" : "occurrences"}).`,
);
return risks;
}
+2 -1
View File
@@ -38,7 +38,8 @@ export function highlightRanges(view: EditorView, ranges: readonly HighlightRang
.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<unknown>[] = [setTemporary.of(clamped)];
if (!view.state.field(temporaryField, false)) effects.push(StateEffect.appendConfig.of(temporaryHighlightExtension()));
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 });
+13 -2
View File
@@ -202,7 +202,14 @@ export const lspTheme = EditorView.theme({
".cm-lsp-diag-warning .cm-lsp-diag-icon": { color: "var(--warning)" },
".cm-lsp-diag-info .cm-lsp-diag-icon, .cm-lsp-diag-hint .cm-lsp-diag-icon": { color: "var(--info)" },
".cm-lsp-diag-message": { color: "var(--fg-strong)", whiteSpace: "pre-wrap", lineHeight: "18px" },
".cm-lsp-diag-meta": { display: "flex", flexWrap: "wrap", columnGap: "12px", rowGap: "2px", marginTop: "6px", alignItems: "baseline" },
".cm-lsp-diag-meta": {
display: "flex",
flexWrap: "wrap",
columnGap: "12px",
rowGap: "2px",
marginTop: "6px",
alignItems: "baseline",
},
".cm-lsp-diag-meta > *": { whiteSpace: "nowrap" },
".cm-lsp-diag-source": { color: "var(--fg-subtle)", fontSize: "var(--fs-small)" },
".cm-lsp-link-button": {
@@ -358,7 +365,11 @@ export const lspTheme = EditorView.theme({
".cm-sem-builtin:not(.cm-sem-function)": { color: "var(--syn-builtin, inherit)" },
".cm-sem-static": { fontStyle: "var(--syn-static-italic, italic)" },
".cm-sem-python-static": { fontStyle: "var(--syn-python-static-italic, normal)" },
".cm-sem-mutable": { textDecoration: "underline", textDecorationColor: "var(--fg-faint)", textUnderlineOffset: "3px" },
".cm-sem-mutable": {
textDecoration: "underline",
textDecorationColor: "var(--fg-faint)",
textUnderlineOffset: "3px",
},
".cm-sem-deprecated": { textDecoration: "line-through" },
// Semantic highlighting: after the base classes so a parameter's colour gives way, and over the
// syntax highlighter's span inside.
+4 -1
View File
@@ -68,7 +68,10 @@ export async function expressionTypeInfo(view: EditorView) {
kind: "doc",
anchor: caretAnchor(view, hover.range ? rangeToOffsets(view.state.doc, hover.range).from : pos),
entries: [
{ markup: { kind: "markdown", value: `\`\`\`${language}\n${info.text}\n\`\`\`` }, language: docOf(view)?.uiMode ?? null },
{
markup: { kind: "markdown", value: `\`\`\`${language}\n${info.text}\n\`\`\`` },
language: docOf(view)?.uiMode ?? null,
},
],
index: 0,
themeClasses: view.themeClasses,
Loaded 100 of 379 files, more files were not shown because too many files have changed in this diff. Show more