mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 10:16:03 -10:00
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:
379 files changed
+6406
-1631
No files matched your search
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -30,14 +30,14 @@ for 60+ languages. Builds for macOS, Windows and Linux.
|
||||
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 |
|
||||
| CPU time until ready | **2.4 s** | 47.9 s |
|
||||
| Idle CPU | **4 %** of a core | 7 % of a core |
|
||||
| Quit | **0.3 s** | 5.9 s |
|
||||
| | 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 |
|
||||
| CPU time until ready | **2.4 s** | 47.9 s |
|
||||
| Idle CPU | **4 %** of a core | 7 % of a core |
|
||||
| Quit | **0.3 s** | 5.9 s |
|
||||
|
||||
A release build of OpenIDEA from `main` and IDEA with default settings and plugins, on
|
||||
Windows 11, 32-core AMD, 62 GB RAM.
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-5
@@ -87,12 +87,12 @@ terminal is a real xterm renderer, without a native PTY or shell. These images
|
||||
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 |
|
||||
|---|---|---|
|
||||
| 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) |
|
||||
| One Dark | [Screenshot](media/themes/one-dark-workbench.png) | [Screenshot](media/themes/one-dark-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) |
|
||||
| One Dark | [Screenshot](media/themes/one-dark-workbench.png) | [Screenshot](media/themes/one-dark-popup.png) |
|
||||
|
||||
The browser regression also checks refresh / startup cache restoration,
|
||||
selection visibility, same-kind switches, the quick menu, existing themes,
|
||||
|
||||
+11
-2
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
+11
-11
@@ -13,15 +13,15 @@ python perf/compare/bench.py ~/src/spring-petclinic --runs 5 --wipe-caches # c
|
||||
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` |
|
||||
| `peak_rss_mb`, `rss_mb`, `uss_mb`, `pss_mb` | summed over every process the app started (WebView2/WebKit, jdtls, language servers, the JVM) |
|
||||
| `total_cpu_s`, `idle_cpu_pct` | CPU time until ready; CPU while idle for `--idle-secs` |
|
||||
| `exit_s` | until every process has exited |
|
||||
| `cache_mb`, install size | `cache_dirs`, `install_dir` |
|
||||
| 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` |
|
||||
| `peak_rss_mb`, `rss_mb`, `uss_mb`, `pss_mb` | summed over every process the app started (WebView2/WebKit, jdtls, language servers, the JVM) |
|
||||
| `total_cpu_s`, `idle_cpu_pct` | CPU time until ready; CPU while idle for `--idle-secs` |
|
||||
| `exit_s` | until every process has exited |
|
||||
| `cache_mb`, install size | `cache_dirs`, `install_dir` |
|
||||
|
||||
Apps run interleaved, rotating the order, after `--warmup` discarded runs.
|
||||
Output goes to `results/<time>-<project>/`: `report.md` (median, min–max),
|
||||
@@ -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.
|
||||
|
||||
+29
-29
@@ -48,11 +48,11 @@ All paths are relative to the package directory and may not leave it.
|
||||
|
||||
### Permissions
|
||||
|
||||
| Permission | Unlocks |
|
||||
|----------------|-------------------------------------------------|
|
||||
| Permission | Unlocks |
|
||||
| -------------- | ------------------------------------------------- |
|
||||
| `project-read` | `workspace-root`, `read-file` (project root only) |
|
||||
| `editor-read` | `active-file` |
|
||||
| `editor-write` | `insert-text`, `replace-selection` |
|
||||
| `editor-read` | `active-file` |
|
||||
| `editor-write` | `insert-text`, `replace-selection` |
|
||||
|
||||
`log`, `register-command`, `show-notification`, `register-language` and
|
||||
`register-theme` are always available. Calling a gated function without its
|
||||
@@ -133,22 +133,22 @@ 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. |
|
||||
| `workspace-root()` | Root of the project the command runs in. |
|
||||
| `active-file()` | Absolute path of the focused editor's file. |
|
||||
| `read-file(path)` | Absolute or project-relative; `..`/symlinks escaping the root are rejected; UTF-8 only, ≤ 16 MiB. Unsaved editor contents win over disk. |
|
||||
| `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. |
|
||||
| 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. |
|
||||
| `workspace-root()` | Root of the project the command runs in. |
|
||||
| `active-file()` | Absolute path of the focused editor's file. |
|
||||
| `read-file(path)` | Absolute or project-relative; `..`/symlinks escaping the root are rejected; UTF-8 only, ≤ 16 MiB. Unsaved editor contents win over disk. |
|
||||
| `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. |
|
||||
|
||||
`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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,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"]
|
||||
}
|
||||
@@ -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
@@ -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];
|
||||
|
||||
+146
-35
@@ -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"), {
|
||||
keywords: ["hippie", "complete word", "dabbrev"],
|
||||
}),
|
||||
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
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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];
|
||||
@@ -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;
|
||||
|
||||
@@ -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" } : {},
|
||||
),
|
||||
];
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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()) };
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
@@ -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)] : [])] });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -61,18 +61,23 @@ 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) => {
|
||||
console.warn(`failed to load language ${desc.name}`, e);
|
||||
supports.delete(desc.name);
|
||||
return null;
|
||||
});
|
||||
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;
|
||||
});
|
||||
supports.set(desc.name, p);
|
||||
}
|
||||
return p;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
|
||||
@@ -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 ?? [],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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)" },
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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[])
|
||||
: []),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -2,25 +2,24 @@ 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> => {
|
||||
if (uri !== JDT) throw new Error(`no contents for ${uri}`);
|
||||
return {
|
||||
uri,
|
||||
text: TEXT,
|
||||
languageId: "java",
|
||||
title: "Lib.class",
|
||||
description: "/m2/lib-1.0.jar",
|
||||
decompiled: true,
|
||||
};
|
||||
},
|
||||
),
|
||||
virtualDocument: vi.fn(async (uri: string): Promise<VirtualDocument> => {
|
||||
if (uri !== JDT) throw new Error(`no contents for ${uri}`);
|
||||
return {
|
||||
uri,
|
||||
text: TEXT,
|
||||
languageId: "java",
|
||||
title: "Lib.class",
|
||||
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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
+60
-18
@@ -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"), {
|
||||
keywords: ["type declaration", "preview"],
|
||||
}),
|
||||
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"), {
|
||||
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.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.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",
|
||||
}),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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() ");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) }]))];
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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")})` : ""}`, () => {
|
||||
gotoDiagnostic(this.view, -1);
|
||||
this.view.focus();
|
||||
});
|
||||
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")})` : ""}`, () => {
|
||||
gotoDiagnostic(this.view, 1);
|
||||
this.view.focus();
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
@@ -458,7 +465,7 @@ const linkPlugin = ViewPlugin.fromClass(
|
||||
clearTimeout(this.probeTimer);
|
||||
this.probing = null;
|
||||
// Cancellation and server errors are transient, not negative results.
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
Reference in new issue
Block a user