Порівняти коміти

..
Автор SHA1 Повідомлення Дата
l0ng-ai 0019a01ff1 feat(languages): add Prisma
Prisma schema files (.prisma) get syntax highlighting, structure view and
folding from the bundled tree-sitter-prisma-io grammar, a file icon, and code
insight from @prisma/language-server (diagnostics, completion, hover,
navigation, rename, formatting), installable from the IDE.
2026-11-12 19:59:42 +08:00
l0ng-ai 099ede3478 fix(vcs): Commit… from Local Changes includes the selected files and focuses the message (#2952)
With the modal commit interface, Commit… (⌘K) and Commit and Push… invoked from Local Changes include just the selected files, and the commit message field takes focus. Invoked elsewhere, the dialog starts with the default inclusion instead of the previous dialog's leftovers.

Fixes #2923
2026-11-12 19:33:15 +08:00
l0ng-ai fb269afdfa fix(lsp): run every server's fix-all on save, and give ESLint its codeActionOnSave setting (#2950)
Run code cleanup applied one source.fixAll action, so in TypeScript files vtsls's won and ESLint's was dropped; fix-all now runs once per server that offers it, each asked again after the previous edit. ESLint rejected every fix-all request because the built-in settings lacked codeActionOnSave; all language files attaching eslint now send { mode = "all", rules = [] }. Failed save actions are logged.

Fixes #2927
Fixes #2928
2026-11-12 19:23:59 +08:00
Alexanderтаformiat 435b00d893 feat(search): colour Find in Files preview matches with the editor's search result colour (#2949)
Matches in the Find in Files and Search Everywhere preview use the editor scheme's search result background (IntelliJ's SEARCH_RESULT_ATTRIBUTES, new token --bg-search-result with values for every built-in theme) instead of the lists' SearchMatch yellow, as a background only so syntax colours stay readable. SEARCH_RESULT_ATTRIBUTES now imports into the new token; --bg-search-match keeps coming from SearchMatch.

Closes #2858

Co-authored-by: formiat <[email protected]>
2026-11-12 19:20:34 +08:00
jiejie1133 14fe542617 fix(editor): leave the caret after a completion snippet without fields (#2939)
CodeMirror leaves the caret before an inserted snippet that has no fields, so accepting a field like `val` at `root.` put the caret before it. LSP snippets without tab stops, choices or unresolved variables now get a final ${0} stop; SQL functions without parameters had the same problem and get one too. Snippets with fields keep their selection.

Closes #2936

Co-authored-by: jiejie1133 <[email protected]>
2026-11-12 19:19:37 +08:00
persicaTaoтаpersicaTao 513762147a fix(db): do not require TLS when SQL Server SSL is disabled (#2938)
Tiberius defaults to EncryptionLevel::Required with rustls, and trust_cert only skips certificate checks, so a SQL Server data source with SSL = Disable still negotiated TLS and timed out against a server without encryption (or one rustls can't speak TLS with). Disable now selects NotSupported; Prefer still trusts the certificate, Require and Verify keep validated TLS.

Closes #2917

Co-authored-by: persicaTao <[email protected]>
2026-11-12 19:18:49 +08:00
l0ng-ai 489f55f52c fix(vcs): don't commit a file that was added to the index and then deleted (#2947)
A file added to the index and then deleted from disk is listed nowhere in the Commit view, but it stayed in the index copy the commit is made from, so committing other files committed it too. Such staged entries are now excluded like unchecked staged files.

Fixes #2908
2026-11-12 19:15:23 +08:00
49 змінених файлів з 575 додано та 49 видалено

No files matched your search

Створено
+11
Переглянути файл
@@ -4601,6 +4601,7 @@ dependencies = [
"tree-sitter-nix",
"tree-sitter-ocaml",
"tree-sitter-php",
"tree-sitter-prisma-io",
"tree-sitter-proto",
"tree-sitter-python",
"tree-sitter-ruby",
@@ -10437,6 +10438,16 @@ dependencies = [
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-prisma-io"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c35912d65a6adb07f781db46d843bf0464935e267da6ed192705516adafc7c24"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-proto"
version = "0.6.0"
+38 -3
Переглянути файл
@@ -283,6 +283,16 @@ fn mssql_pool_err(e: bb8::RunError<tiberius::error::Error>) -> IdeError {
}
}
/// Tiberius requires TLS when a TLS backend is enabled. `trust_cert` only skips
/// certificate checks, so Disable has to turn encryption off.
fn apply_sqlserver_ssl(config: &mut tiberius::Config, ssl: DbSslMode) {
match ssl {
DbSslMode::Disable => config.encryption(tiberius::EncryptionLevel::NotSupported),
DbSslMode::Prefer => config.trust_cert(),
DbSslMode::Require | DbSslMode::VerifyCa | DbSslMode::VerifyFull => {}
}
}
const DAYS_1900_01_01: i64 = 693_595;
/// Formats a proleptic Gregorian date given days since `0001-01-01`.
@@ -876,9 +886,7 @@ impl Pool {
if !source.database.is_empty() {
config.database(&source.database);
}
if matches!(source.ssl, DbSslMode::Disable | DbSslMode::Prefer) {
config.trust_cert();
}
apply_sqlserver_ssl(&mut config, source.ssl);
let manager = MssqlConnectionManager(config);
let pool = bb8::Pool::builder()
.max_size(MAX)
@@ -1331,6 +1339,33 @@ mod tests {
use super::*;
use crate::source::{DbOptions, DbProperty};
fn sqlserver_debug(ssl: DbSslMode) -> String {
let mut config = tiberius::Config::new();
apply_sqlserver_ssl(&mut config, ssl);
format!("{config:?}")
}
#[test]
fn sqlserver_disable_does_not_require_tls() {
let debug = sqlserver_debug(DbSslMode::Disable);
assert!(debug.contains("encryption: NotSupported"), "{debug}");
assert!(debug.contains("bypass: false"), "{debug}");
}
#[test]
fn sqlserver_prefer_trusts_the_server_certificate() {
let debug = sqlserver_debug(DbSslMode::Prefer);
assert!(debug.contains("encryption: Required"), "{debug}");
assert!(debug.contains("bypass: true"), "{debug}");
}
#[test]
fn sqlserver_require_keeps_validated_tls() {
let debug = sqlserver_debug(DbSslMode::Require);
assert!(debug.contains("encryption: Required"), "{debug}");
assert!(debug.contains("bypass: false"), "{debug}");
}
#[test]
fn discarded_sqlserver_connections_are_broken() {
let manager = MssqlConnectionManager(tiberius::Config::new());
+1
Переглянути файл
@@ -55,6 +55,7 @@ tree-sitter-xml = "0.7.0"
tree-sitter-haskell = "0.24.1"
tree-sitter-dart = "0.2.0"
tree-sitter-graphql = "0.3.0"
tree-sitter-prisma-io = "1.6.0"
tree-sitter-gdscript = "6.1.0"
[dev-dependencies]
+40
Переглянути файл
@@ -0,0 +1,40 @@
; Prisma schema highlighting (our captures, not the grammar crate's). Later patterns win.
(identifier) @variable
(developer_comment) @comment
(comment) @comment.doc
(number) @number
(string) @string
[(true) (false) (null)] @keyword
(variable) @property
(column_declaration . (identifier) @property)
(type_expression . (identifier) @variable.parameter)
(enumeral (identifier) @constant)
(column_type (identifier) @type)
(column_type (call_expression (identifier) @type))
(call_expression (identifier) @function)
(model_declaration (identifier) @type)
(view_declaration (identifier) @type)
(type_declaration (identifier) @type)
(type_declaration (type_declaration_type) @type)
(enum_declaration (identifier) @type)
(datasource_declaration (identifier) @type)
(generator_declaration (identifier) @type)
(attribute (identifier) @attribute)
(attribute (call_expression (identifier) @attribute))
(attribute (member_expression (identifier) @attribute (property_identifier) @attribute))
(attribute (call_expression (member_expression (identifier) @attribute (property_identifier) @attribute)))
(block_attribute_declaration (identifier) @attribute)
(block_attribute_declaration (call_expression (identifier) @attribute))
["@" "@@"] @attribute
["datasource" "enum" "generator" "model" "type" "view"] @keyword
["(" ")" "[" "]" "{" "}"] @punctuation.bracket
["," ":" "."] @punctuation.delimiter
(maybe) @operator
"=" @operator
+8
Переглянути файл
@@ -0,0 +1,8 @@
(model_declaration (identifier) @name) @def.class
(view_declaration (identifier) @name) @def.class
(type_declaration . (identifier) @name (statement_block)) @def.struct
(enum_declaration (identifier) @name) @def.enum
(enumeral (identifier) @name) @def.enum_member
(datasource_declaration (identifier) @name) @outline.module
(generator_declaration (identifier) @name) @outline.module
(column_declaration . (identifier) @name) @def.field
+2
Переглянути файл
@@ -67,6 +67,7 @@ const BUILTIN: &[(&str, &str)] = &[
("php", include_str!("../../../languages/php.toml")),
("plaintext", include_str!("../../../languages/plaintext.toml")),
("powershell", include_str!("../../../languages/powershell.toml")),
("prisma", include_str!("../../../languages/prisma.toml")),
("properties", include_str!("../../../languages/properties.toml")),
("proto", include_str!("../../../languages/proto.toml")),
("python", include_str!("../../../languages/python.toml")),
@@ -1026,6 +1027,7 @@ mod tests {
("CMakeLists.txt", "cmake"),
("api.proto", "proto"),
("schema.graphql", "graphql"),
("schema.prisma", "prisma"),
("main.tf", "terraform"),
("prod.tfvars", "terraform-vars"),
("job.nomad", "hcl"),
+10
Переглянути файл
@@ -430,6 +430,16 @@ pub(crate) static SPECS: &[Spec] = &[
imports: NONE,
constructors: NONE,
},
Spec {
name: "prisma",
language: tree_sitter_prisma_io::LANGUAGE,
outline: outline!("prisma"),
highlights: Some(Highlights { query: highlights!("prisma"), first_wins: false }),
fold_nodes: NONE,
fold_bodies: NONE,
imports: NONE,
constructors: NONE,
},
Spec {
name: "make",
language: tree_sitter_make::LANGUAGE,
+22
Переглянути файл
@@ -501,6 +501,28 @@ mod tests {
("NEWHOPE", K::Constant),
],
),
(
"prisma",
"// hi\n/// Doc\nmodel User {\n id Int @id @default(autoincrement())\n email String? @db.VarChar(255)\n role Role\n @@index([email])\n}\nenum Role { ADMIN }\ndatasource db {\n provider = \"postgresql\"\n}\n",
&[
("// hi", K::Comment),
("/// Doc", K::DocComment),
("model", K::Keyword),
("User", K::Type),
("id", K::Property),
("Int", K::Type),
("@id", K::Attribute),
("@default", K::Attribute),
("autoincrement", K::FunctionCall),
("VarChar", K::Attribute),
("255", K::Number),
("Role", K::Type),
("@@index", K::Attribute),
("ADMIN", K::Constant),
("provider", K::Property),
("\"postgresql\"", K::String),
],
),
(
"hcl",
"# hi\nresource \"aws_instance\" \"web\" {\n ami = var.ami\n count = 2\n tags = { Name = \"web-${var.env}\" }\n name = upper(\"x\")\n}\n",
+11
Переглянути файл
@@ -886,6 +886,17 @@ fn outline_more_languages() {
Function GetUser
",
);
check_outline(
"prisma",
"datasource db {\n provider = \"sqlite\"\n}\nmodel User {\n id Int @id\n @@map(\"users\")\n}\nenum Role {\n ADMIN\n}\n",
"
Module db
Class User
Field id
Enum Role
EnumMember ADMIN
",
);
check_outline(
"nix",
"{ pkgs, ... }:\nlet\n foo = 1;\n bar = x: x;\nin { inherit foo; }\n",
+11
Переглянути файл
@@ -407,6 +407,17 @@ pub static TOOLS: &[ToolSpec] = &[
homepage: "https://github.com/graphql/graphiql",
needs: &[],
},
ToolSpec {
name: "prisma-language-server",
display: "Prisma Language Server",
kind: LanguageServer,
commands: &["prisma-language-server"],
method: Method::Npm { packages: &["@prisma/language-server"] },
fallback: None,
version: V,
homepage: "https://github.com/prisma/language-tools",
needs: &[],
},
ToolSpec {
name: "intelephense",
display: "Intelephense (PHP)",
+1 -1
Переглянути файл
@@ -67,7 +67,7 @@ All keys are kebab-case. Only `id` and `name` are required; a definition with no
| `formatter` | table | External formatter; see below. |
| `scratch-template` | string | Content of a new scratch file of this language. `$END$` marks where the caret goes. |
Bundled tree-sitter grammars: `bash`, `c`, `cmake`, `cpp`, `csharp`, `css`, `dart`, `elixir`, `gdscript`, `go`, `graphql`, `haskell`, `hcl`, `html`, `java`, `javascript`, `json`, `kotlin`, `lua`, `make`, `markdown`, `nix`, `ocaml`, `ocaml_interface`, `php`, `proto`, `python`, `ruby`, `rust`, `scala`, `swift`, `toml`, `tsx`, `typescript`, `xml`, `yaml`, `zig`. A new grammar has to be compiled into the IDE; a plugin can provide highlighting and structure for a language without a grammar instead (see [Language features](/en/plugins/guides/language-features)).
Bundled tree-sitter grammars: `bash`, `c`, `cmake`, `cpp`, `csharp`, `css`, `dart`, `elixir`, `gdscript`, `go`, `graphql`, `haskell`, `hcl`, `html`, `java`, `javascript`, `json`, `kotlin`, `lua`, `make`, `markdown`, `nix`, `ocaml`, `ocaml_interface`, `php`, `prisma`, `proto`, `python`, `ruby`, `rust`, `scala`, `swift`, `toml`, `tsx`, `typescript`, `xml`, `yaml`, `zig`. A new grammar has to be compiled into the IDE; a plugin can provide highlighting and structure for a language without a grammar instead (see [Language features](/en/plugins/guides/language-features)).
### `[[framework-ui-modes]]`
+1
Переглянути файл
@@ -151,6 +151,7 @@ The table lists every built-in language definition. Server and debugger names in
| PHP | `php` | `.php`, `.phtml`, `.php3`, `.php4`, `.php5`, … | Yes | Yes | **intelephense** → phpactor + **laravel-lsp** | **php-debug** |
| Plain Text | `plaintext` | `.txt`, `.text` | — | — | — | — |
| PowerShell | `powershell` | `.ps1`, `.psm1`, `.psd1`, `.pssc`, `.psrc` | Yes | — | powershell-editor-services | — |
| Prisma | `prisma` | `.prisma` | Yes | Yes | **prisma-language-server** | — |
| Properties | `properties` | `.properties`, `.prefs` | Yes | — | — | — |
| Protocol Buffers | `proto` | `.proto` | Yes | Yes | **buf** | — |
| Python | `python` | `.py`, `.pyi`, `.pyw` | Yes | Yes | **basedpyright** → **pyright** → **ty** → **pyrefly** + **ruff** | **debugpy** |
+2 -1
Переглянути файл
@@ -238,7 +238,7 @@ The full table is `UI_KEYS` in
| `--syn-rainbow-1` … `--syn-rainbow-5` (Semantic highlighting) | `RAINBOW_COLOR1` … `RAINBOW_COLOR5` |
| `--wave-error`, `--wave-warning` | `ERRORS_ATTRIBUTES`, `WARNING_ATTRIBUTES` (effect colour) |
| `--bg-matched-brace`, `--bg-identifier-under-caret` | `MATCHED_BRACE_ATTRIBUTES`, `IDENTIFIER_UNDER_CARET_ATTRIBUTES` |
| `--bg-text-search-result`, `--bg-search-match` | `TEXT_SEARCH_RESULT_ATTRIBUTES`, `SEARCH_RESULT_ATTRIBUTES` |
| `--bg-text-search-result`, `--bg-search-result` | `TEXT_SEARCH_RESULT_ATTRIBUTES`, `SEARCH_RESULT_ATTRIBUTES` |
| `--syn-self`, `--syn-keyword-arg`, `--syn-docstring`, `--syn-predefined` | `PY.SELF_PARAMETER`, `PY.KEYWORD_ARGUMENT`, `PY.DOC_COMMENT`, `PY.BUILTIN_NAME` |
| `--ansi-*` | `CONSOLE_*_OUTPUT` (`CONSOLE_DARKGRAY_OUTPUT` is bright black, `CONSOLE_GRAY_OUTPUT` white) |
@@ -504,6 +504,7 @@ conventionally go in the theme file, groups marked _editor scheme_ in the scheme
| `--fg-editor-selection` | `inherit` | `inherit` |
| `--bg-text-search-result` | `#114957` | `#fcd47e` |
| `--bg-text-search-result-current` | `#165e70` | `#f2bf57` |
| `--bg-search-result` | `#2d543f` | `#ccccff` |
| `--bg-matched-brace` | `#43454a` | `#93d9d9` |
| `--matched-brace-weight` | `var(--fw-code-bold)` | `inherit` |
| `--bg-matched-tag` | `#3f4045` | `#c9ecec` |
+1 -1
Переглянути файл
@@ -67,7 +67,7 @@ install-hint = "Install CodeLLDB in Settings › Languages & Frameworks › Lang
| `formatter` | 表 | 外部格式化工具,见下文。 |
| `scratch-template` | 字符串 | 新建该语言草稿文件时的内容,`$END$` 表示光标位置。 |
内置的 tree-sitter 语法:`bash`、`c`、`cmake`、`cpp`、`csharp`、`css`、`dart`、`elixir`、`gdscript`、`go`、`graphql`、`haskell`、`hcl`、`html`、`java`、`javascript`、`json`、`kotlin`、`lua`、`make`、`markdown`、`nix`、`ocaml`、`ocaml_interface`、`php`、`proto`、`python`、`ruby`、`rust`、`scala`、`swift`、`toml`、`tsx`、`typescript`、`xml`、`yaml`、`zig`。新的语法需要编译进 IDE;没有语法的语言可以改由插件提供高亮和结构(见 [语言功能](/zh/plugins/guides/language-features))。
内置的 tree-sitter 语法:`bash`、`c`、`cmake`、`cpp`、`csharp`、`css`、`dart`、`elixir`、`gdscript`、`go`、`graphql`、`haskell`、`hcl`、`html`、`java`、`javascript`、`json`、`kotlin`、`lua`、`make`、`markdown`、`nix`、`ocaml`、`ocaml_interface`、`php`、`prisma`、`proto`、`python`、`ruby`、`rust`、`scala`、`swift`、`toml`、`tsx`、`typescript`、`xml`、`yaml`、`zig`。新的语法需要编译进 IDE;没有语法的语言可以改由插件提供高亮和结构(见 [语言功能](/zh/plugins/guides/language-features))。
### `[[framework-ui-modes]]`
+1
Переглянути файл
@@ -146,6 +146,7 @@ IDE 安装工具从不需要 `sudo`。工具装在本地数据目录下 IDE 自
| PHP | `php` | `.php`, `.phtml`, `.php3`, `.php4`, `.php5`, … | 是 | 是 | **intelephense** → phpactor + **laravel-lsp** | **php-debug** |
| Plain Text | `plaintext` | `.txt`, `.text` | — | — | — | — |
| PowerShell | `powershell` | `.ps1`, `.psm1`, `.psd1`, `.pssc`, `.psrc` | 是 | — | powershell-editor-services | — |
| Prisma | `prisma` | `.prisma` | 是 | 是 | **prisma-language-server** | — |
| Properties | `properties` | `.properties`, `.prefs` | 是 | — | — | — |
| Protocol Buffers | `proto` | `.proto` | 是 | 是 | **buf** | — |
| Python | `python` | `.py`, `.pyi`, `.pyw` | 是 | 是 | **basedpyright** → **pyright** → **ty** → **pyrefly** + **ruff** | **debugpy** |
+2 -1
Переглянути файл
@@ -210,7 +210,7 @@ pnpm theme:import-intellij path/to/MyTheme.theme.json path/to/MyTheme.icls --out
| `--syn-rainbow-1` … `--syn-rainbow-5`(语义高亮) | `RAINBOW_COLOR1` … `RAINBOW_COLOR5` |
| `--wave-error`, `--wave-warning` | `ERRORS_ATTRIBUTES`, `WARNING_ATTRIBUTES`(效果颜色) |
| `--bg-matched-brace`, `--bg-identifier-under-caret` | `MATCHED_BRACE_ATTRIBUTES`, `IDENTIFIER_UNDER_CARET_ATTRIBUTES` |
| `--bg-text-search-result`, `--bg-search-match` | `TEXT_SEARCH_RESULT_ATTRIBUTES`, `SEARCH_RESULT_ATTRIBUTES` |
| `--bg-text-search-result`, `--bg-search-result` | `TEXT_SEARCH_RESULT_ATTRIBUTES`, `SEARCH_RESULT_ATTRIBUTES` |
| `--syn-self`, `--syn-keyword-arg`, `--syn-docstring`, `--syn-predefined` | `PY.SELF_PARAMETER`, `PY.KEYWORD_ARGUMENT`, `PY.DOC_COMMENT`, `PY.BUILTIN_NAME` |
| `--ansi-*` | `CONSOLE_*_OUTPUT`(`CONSOLE_DARKGRAY_OUTPUT` 为亮黑,`CONSOLE_GRAY_OUTPUT` 为白色) |
@@ -469,6 +469,7 @@ VS Code 主题(扩展中的 `themes/*.json`)包含 `type`(`dark` / `light`
| `--fg-editor-selection` | `inherit` | `inherit` |
| `--bg-text-search-result` | `#114957` | `#fcd47e` |
| `--bg-text-search-result-current` | `#165e70` | `#f2bf57` |
| `--bg-search-result` | `#2d543f` | `#ccccff` |
| `--bg-matched-brace` | `#43454a` | `#93d9d9` |
| `--matched-brace-weight` | `var(--fw-code-bold)` | `inherit` |
| `--bg-matched-tag` | `#3f4045` | `#c9ecec` |
+47
Переглянути файл
@@ -4,6 +4,7 @@
* by `crates/ide-lsp/tests`.
*/
import type { Locator, Page } from "@playwright/test";
import type { CompletionItem } from "../src/bindings";
import { expect, ipcCalls, openFromTree, stubDesktop, test } from "./fixtures";
const ROOT = "/dev/acme-api";
@@ -78,6 +79,52 @@ test("completion lists the server's items and inserts the chosen one", async ({
expect((await ipcCalls(page, "lsp_completion")).length).toBeGreaterThan(0);
});
for (const key of ["Enter", "Tab"]) {
for (const prefix of ["", "v"]) {
test(`${key} leaves the caret after a field snippet at root.${prefix}`, async ({ workbench: page }, info) => {
const editor = await openSample(page);
await page.evaluate(() => {
const item = (label: string): CompletionItem => ({
label,
labelDetail: null,
labelDescription: "int",
detail: "int",
kind: "field",
documentation: null,
sortText: null,
filterText: null,
preselect: label === "val",
deprecated: false,
insertText: label,
isSnippet: true,
replaceRange: null,
replaceRangeTab: null,
additionalEdits: [],
commitCharacters: [],
command: null,
data: null,
server: 1,
});
window.__ipc.handlers.lsp_completion = () => ({ isIncomplete: false, items: [item("val"), item("value")] });
});
await editor.locator(".cm-line", { hasText: "return order.toDto()" }).click();
await page.keyboard.press("End");
await page.keyboard.press("Enter");
await page.keyboard.type(`int a = root.${prefix}`);
await page.keyboard.press("Control+Space");
const popup = page.locator(".cm-tooltip-autocomplete");
await expect(popup.getByText("val", { exact: true })).toBeVisible();
// CodeMirror ignores acceptance during its 75 ms lookup interaction delay.
await page.waitForTimeout(100);
await page.keyboard.press(key);
await expect(popup).toBeHidden();
await page.screenshot({ path: info.outputPath("accepted-completion.png"), animations: "disabled" });
await page.keyboard.type(";");
await expect(editor.locator(".cm-activeLine")).toHaveText(/^\s*int a = root\.val;$/);
});
}
}
test("⌘B goes to the declaration in another file", async ({ workbench: page }) => {
const editor = await openSample(page);
await page.evaluate(
+14
Переглянути файл
@@ -105,6 +105,20 @@ test("Find in Files streams matches by file and opens the chosen one at its line
});
const preview = dialog.getByRole("region", { name: "Preview", exact: true }).locator(".cm-content");
await expect(preview).toHaveAttribute("contenteditable", "true");
// Matches take the editor scheme's search result background (IDEA's SEARCH_RESULT_ATTRIBUTES) and keep the syntax colours.
const match = preview.locator(".cm-openidea-searchResult").first();
await expect(match).toHaveText("OrderService");
expect(
await match.evaluate((el) => {
const probe = document.createElement("span");
probe.style.backgroundColor = "var(--bg-search-result)";
el.parentElement!.append(probe);
const expected = getComputedStyle(probe).backgroundColor;
probe.remove();
const own = getComputedStyle(el);
return own.backgroundColor === expected && own.color === getComputedStyle(el.parentElement!).color;
}),
).toBe(true);
const originalPreview = await preview.textContent();
await preview.focus();
await page.keyboard.press("Meta+a");
+2
Переглянути файл
@@ -41,3 +41,5 @@ nodePath = ""
problems = { shortenToSingleLine = false }
workingDirectory = { mode = "auto" }
codeAction = { disableRuleComment = { enable = true, location = "separateLine" }, showDocumentation = { enable = true } }
# vscode-eslint reads it for every source.fixAll request (Actions on Save, Code Cleanup).
codeActionOnSave = { mode = "all", rules = [] }
+2
Переглянути файл
@@ -75,6 +75,8 @@ nodePath = ""
problems = { shortenToSingleLine = false }
workingDirectory = { mode = "auto" }
codeAction = { disableRuleComment = { enable = true, location = "separateLine" }, showDocumentation = { enable = true } }
# vscode-eslint reads it for every source.fixAll request (Actions on Save, Code Cleanup).
codeActionOnSave = { mode = "all", rules = [] }
[debugger]
adapter = "js-debug"
+2
Переглянути файл
@@ -74,6 +74,8 @@ nodePath = ""
problems = { shortenToSingleLine = false }
workingDirectory = { mode = "auto" }
codeAction = { disableRuleComment = { enable = true, location = "separateLine" }, showDocumentation = { enable = true } }
# vscode-eslint reads it for every source.fixAll request (Actions on Save, Code Cleanup).
codeActionOnSave = { mode = "all", rules = [] }
[debugger]
adapter = "js-debug"
+17
Переглянути файл
@@ -0,0 +1,17 @@
id = "prisma"
name = "Prisma"
extensions = ["prisma"]
line-comment = "//"
indent = { tab-size = 2, insert-spaces = true }
tree-sitter = "prisma"
[[language-servers]]
name = "prisma-language-server"
command = "prisma-language-server"
args = ["--stdio"]
root-markers = ["package.json", "schema.prisma"]
install-hint = "npm i -g @prisma/language-server"
# Asked for as section `prisma` before every validation; a missing section crashes the server.
[language-servers.settings.prisma]
enableDiagnostics = true
+2
Переглянути файл
@@ -43,3 +43,5 @@ nodePath = ""
problems = { shortenToSingleLine = false }
workingDirectory = { mode = "auto" }
codeAction = { disableRuleComment = { enable = true, location = "separateLine" }, showDocumentation = { enable = true } }
# vscode-eslint reads it for every source.fixAll request (Actions on Save, Code Cleanup).
codeActionOnSave = { mode = "all", rules = [] }
+2
Переглянути файл
@@ -89,6 +89,8 @@ nodePath = ""
problems = { shortenToSingleLine = false }
workingDirectory = { mode = "auto" }
codeAction = { disableRuleComment = { enable = true, location = "separateLine" }, showDocumentation = { enable = true } }
# vscode-eslint reads it for every source.fixAll request (Actions on Save, Code Cleanup).
codeActionOnSave = { mode = "all", rules = [] }
[debugger]
adapter = "js-debug"
+2
Переглянути файл
@@ -74,6 +74,8 @@ nodePath = ""
problems = { shortenToSingleLine = false }
workingDirectory = { mode = "auto" }
codeAction = { disableRuleComment = { enable = true, location = "separateLine" }, showDocumentation = { enable = true } }
# vscode-eslint reads it for every source.fixAll request (Actions on Save, Code Cleanup).
codeActionOnSave = { mode = "all", rules = [] }
[debugger]
adapter = "js-debug"
+2
Переглянути файл
@@ -68,3 +68,5 @@ nodePath = ""
problems = { shortenToSingleLine = false }
workingDirectory = { mode = "auto" }
codeAction = { disableRuleComment = { enable = true, location = "separateLine" }, showDocumentation = { enable = true } }
# vscode-eslint reads it for every source.fixAll request (Actions on Save, Code Cleanup).
codeActionOnSave = { mode = "all", rules = [] }
+1
Переглянути файл
@@ -89,6 +89,7 @@
"--fg-editor-selection": "inherit",
"--bg-text-search-result": "#114957",
"--bg-text-search-result-current": "#165e70",
"--bg-search-result": "#2d543f",
"--bg-matched-brace": "#38424f",
"--matched-brace-weight": "700",
"--bg-matched-tag": "#353e4a",
+3 -2
Переглянути файл
@@ -231,7 +231,7 @@ The full table is `UI_KEYS` in [`src/features/plugins/intellijTheme.ts`](../src/
| `--syn-rainbow-1` … `--syn-rainbow-5` (Semantic highlighting, rainbow indent guides) | `RAINBOW_COLOR1` … `RAINBOW_COLOR5` |
| `--wave-error`, `--wave-warning` | `ERRORS_ATTRIBUTES`, `WARNING_ATTRIBUTES` (effect colour) |
| `--bg-matched-brace`, `--bg-identifier-under-caret` | `MATCHED_BRACE_ATTRIBUTES`, `IDENTIFIER_UNDER_CARET_ATTRIBUTES` |
| `--bg-text-search-result`, `--bg-search-match` | `TEXT_SEARCH_RESULT_ATTRIBUTES`, `SEARCH_RESULT_ATTRIBUTES` |
| `--bg-text-search-result`, `--bg-search-result` | `TEXT_SEARCH_RESULT_ATTRIBUTES`, `SEARCH_RESULT_ATTRIBUTES` |
| `--syn-self`, `--syn-keyword-arg`, `--syn-docstring`, `--syn-predefined` | `PY.SELF_PARAMETER`, `PY.KEYWORD_ARGUMENT`, `PY.DOC_COMMENT`, `PY.BUILTIN_NAME` |
| `--ansi-*` | `CONSOLE_*_OUTPUT` (`CONSOLE_DARKGRAY_OUTPUT` is bright black, `CONSOLE_GRAY_OUTPUT` white) |
@@ -329,7 +329,7 @@ a token.
<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->
263 themeable tokens: 125 in the UI theme file, 138 in the editor colour scheme file.
264 themeable tokens: 125 in the UI theme file, 139 in the editor colour scheme file.
### Surfaces (UI theme)
@@ -509,6 +509,7 @@ a token.
| `--fg-editor-selection` | `inherit` | `inherit` |
| `--bg-text-search-result` | `#114957` | `#fcd47e` |
| `--bg-text-search-result-current` | `#165e70` | `#f2bf57` |
| `--bg-search-result` | `#2d543f` | `#ccccff` |
| `--bg-matched-brace` | `#43454a` | `#93d9d9` |
| `--matched-brace-weight` | `var(--fw-code-bold)` | `inherit` |
| `--bg-matched-tag` | `#3f4045` | `#c9ecec` |
+10
Переглянути файл
@@ -219,6 +219,16 @@ describe("IntelliJ theme import", () => {
});
});
it("keeps the search result colour apart from the text search result and the lists' search match", () => {
const bg = (c: string) => `<value><option name="BACKGROUND" value="${c}"/></value>`;
const s = convertIcls(`<scheme name="Search" parent_scheme="Darcula"><attributes>
<option name="SEARCH_RESULT_ATTRIBUTES">${bg("2d543f")}</option>
<option name="TEXT_SEARCH_RESULT_ATTRIBUTES">${bg("114957")}</option>
</attributes></scheme>`);
expect(s.tokens).toMatchObject({ "--bg-search-result": "#2d543f", "--bg-text-search-result": "#114957" });
expect(s.tokens).not.toHaveProperty("--bg-search-match");
});
it("produces an installable package", () => {
const light = stripJsonComments(themeJson.replace('"dark": true,', ""));
const c = convertIntellijTheme(light, icls.replace("Darcula", "Default"), "sea");
+2
Переглянути файл
@@ -332,6 +332,8 @@ export const openideaTheme = EditorView.theme({
// Find in file (cm/find.ts)
...findStyles,
// Find in Files preview matches (search/find/parts.tsx)
".cm-openidea-searchResult": { backgroundColor: "var(--bg-search-result)" },
// Overlays
".cm-tooltip": {
+38
Переглянути файл
@@ -318,6 +318,44 @@ describe("applying completions", () => {
});
for (const mode of ["insert", "replace"] as const) {
for (const prefix of ["", "v"]) {
it(`${mode} leaves the caret after a field snippet accepted at root.${prefix}`, () => {
const { view, req, pos } = setup(`int a = root.${prefix}|Suffix`);
const start = "int a = root.".length;
const c = makeCompletion(
item({
label: "val",
kind: "field",
insertText: "val",
isSnippet: true,
replaceRange: { start: { line: 0, col: start }, end: { line: 0, col: pos } },
}),
req,
);
applyLspCompletion(view, c, start, pos, mode);
expect(view.state.doc.toString()).toBe(`int a = root.val${mode === "insert" ? "Suffix" : ""}`);
expect(view.state.selection.main.head).toBe(start + 3);
});
}
it(`${mode} places a fieldless snippet after imports and a cached UTF-16 prefix`, () => {
const { view, req, pos } = setup("\n/* 😀 */ root.|Suffix", "v");
const c = makeCompletion(
item({
label: "val",
kind: "field",
insertText: "val",
isSnippet: true,
replaceRange: { start: { line: 1, col: pos - 1 }, end: { line: 1, col: pos - 1 } },
additionalEdits: [{ range: { start: { line: 0, col: 0 }, end: { line: 0, col: 0 } }, new_text: "import X;" }],
}),
req,
);
applyLspCompletion(view, c, pos, pos + 1, mode);
expect(view.state.doc.toString()).toBe(`import X;\n/* 😀 */ root.val${mode === "insert" ? "Suffix" : ""}`);
expect(view.state.selection.main.head).toBe(pos + "import X;val".length);
});
for (const shape of ["textEdit", "insertReplace", "noEdit"] as const) {
for (const isSnippet of [false, true]) {
it(`${mode} replaces a mixed-case prefix typed after a ${shape} request (snippet: ${isSnippet})`, () => {
+57
Переглянути файл
@@ -146,6 +146,63 @@ describe("silent code actions", () => {
});
});
describe("fix-all from every server", () => {
const fixAll = (server: number, title: string): CodeAction => ({
...resolved,
title,
kind: "source.fixAll",
server,
edit: null,
data: "{}",
});
const editor = () => {
const view = new EditorView({
state: EditorState.create({
doc: "const a = 'x';",
extensions: [lspDoc.of({ doc: 1, path: "/p/a.ts", language: "typescript", uiMode: null, readOnly: false })],
}),
});
views.push(view);
return view;
};
it("applies each server's source.fixAll in turn, asking the next one again after an edit", async () => {
const view = editor();
const ts = fixAll(1, "Fix all fixable JS/TS issues");
const eslint = fixAll(2, "Fix all fixable ESLint issues");
const eslintAfter = { ...eslint, data: '{"version":2}' };
lspCodeActions
.mockResolvedValueOnce({ status: "ok", data: [ts, eslint] })
.mockResolvedValueOnce({ status: "ok", data: [{ ...ts, data: '{"again":true}' }, eslintAfter] });
lspApplyCodeAction.mockResolvedValue({ status: "ok", data: null });
expect(await applyKindSilently(view, "source.fixAll")).toBe(true);
expect(lspCodeActions).toHaveBeenCalledTimes(2);
expect(lspApplyCodeAction.mock.calls.map((c) => (c as unknown[])[1])).toEqual([ts, eslintAfter]);
});
it("goes on with the next server when one server's action fails", async () => {
const view = editor();
const ts = fixAll(1, "Fix all fixable JS/TS issues");
const eslint = fixAll(2, "Fix all fixable ESLint issues");
lspCodeActions.mockResolvedValue({ status: "ok", data: [ts, eslint] });
lspApplyCodeAction
.mockResolvedValueOnce({ status: "error", error: { kind: "internal", message: "boom" } })
.mockResolvedValueOnce({ status: "ok", data: null });
expect(await applyKindSilently(view, "source.fixAll")).toBe(true);
expect(lspApplyCodeAction.mock.calls.map((c) => ((c as unknown[])[1] as CodeAction).server)).toEqual([1, 2]);
});
it("applies a single organize-imports action", async () => {
const view = editor();
const a = { ...fixAll(1, "Organize Imports"), kind: "source.organizeImports" };
const b = { ...fixAll(2, "Organize Imports"), kind: "source.organizeImports" };
lspCodeActions.mockResolvedValueOnce({ status: "ok", data: [a, b] });
lspApplyCodeAction.mockResolvedValue({ status: "ok", data: null });
expect(await applyKindSilently(view, "source.organizeImports")).toBe(true);
expect(lspApplyCodeAction).toHaveBeenCalledTimes(1);
});
});
describe("intention preview", () => {
it("does not resolve lazy plugin actions while previewing", async () => {
const view = new EditorView({
+47 -13
Переглянути файл
@@ -299,27 +299,61 @@ export function changedLines(before: Text, after: Text): Array<{ from: number; t
* Applies the first enabled whole-file action of `kind` without UI (Actions on
* Save). Waits until the backend edit reached this editor; false when none.
*/
/**
* Applies the server's `kind` action to the whole document without UI (Actions on Save,
* Code Cleanup). Fix-all runs once per server that offers it, one after another, each
* asked again for the document as the previous one left it (VS Code's codeActionsOnSave):
* vtsls and ESLint both answer `source.fixAll` for a TypeScript file.
*/
export async function applyKindSilently(view: EditorView, kind: string): Promise<boolean> {
const doc = docOf(view);
if (!doc || doc.readOnly) return false;
const matching = (actions: CodeAction[], server?: CodeAction["server"]) =>
actions.filter(
(a) =>
a.kind?.startsWith(kind) &&
!a.disabledReason &&
!isLazyPluginAction(a) &&
(server === undefined || a.server === server),
);
const pick = (actions: CodeAction[]) => actions.find((a) => a.isPreferred) ?? actions[0];
let actions: CodeAction[];
try {
actions = (await fetchActions(view, [kind], true)).filter(
(a) => a.kind?.startsWith(kind) && !a.disabledReason && !isLazyPluginAction(a),
);
} catch {
actions = matching(await fetchActions(view, [kind], true));
} catch (e) {
console.warn(`[lsp] ${kind} on ${doc.path} failed`, e);
return false;
}
const action = actions.find((a) => a.isPreferred) ?? actions[0];
if (!action) return false;
const before = view.state.doc;
try {
await lspRequest(() => commands.lspApplyCodeAction(doc.doc, action));
} catch {
return false;
const first = pick(actions);
if (!first) return false;
const servers = kind.startsWith("source.fixAll")
? [first.server, ...new Set(actions.map((a) => a.server).filter((s) => s !== first.server))]
: [first.server];
let applied = false;
for (const [i, server] of servers.entries()) {
let action: CodeAction | undefined = first;
if (i > 0) {
// The previous server's edit changed the document: this one's answer must be fresh.
try {
action = pick(matching(await fetchActions(view, [kind], true), server));
} catch (e) {
console.warn(`[lsp] ${kind} on ${doc.path} failed`, e);
continue;
}
if (!action) continue;
}
const before = view.state.doc;
const chosen = action;
try {
await lspRequest(() => commands.lspApplyCodeAction(doc.doc, chosen));
} catch (e) {
console.warn(`[lsp] applying ${chosen.kind} on ${doc.path} failed`, e);
continue;
}
applied = true;
for (let i = 0; i < 25 && view.state.doc === before; i++) await new Promise((r) => setTimeout(r, 20));
}
for (let i = 0; i < 25 && view.state.doc === before; i++) await new Promise((r) => setTimeout(r, 20));
return true;
return applied;
}
/**
+29 -4
Переглянути файл
@@ -23,7 +23,7 @@ describe("lspSnippetToCm", () => {
});
it("handles LSP escapes", () => {
expect(lspSnippetToCm("cost \\$5 \\} \\\\")).toBe("cost $5 \\} \\");
expect(lspSnippetToCm("cost \\$5 \\} \\\\")).toBe("cost $5 \\} \\" + "${0}");
expect(expand(lspSnippetToCm("a \\${1}")).text).toBe("a ${1}");
});
@@ -37,8 +37,8 @@ describe("lspSnippetToCm", () => {
it("resolves variables, defaults and unknown names", () => {
const vars = snippetVariables("/p/src/Main.kt");
expect(lspSnippetToCm("class $TM_FILENAME_BASE", vars)).toBe("class Main");
expect(lspSnippetToCm("${NOPE:dflt}", vars)).toBe("dflt");
expect(lspSnippetToCm("class $TM_FILENAME_BASE", vars)).toBe("class Main${0}");
expect(lspSnippetToCm("${NOPE:dflt}", vars)).toBe("dflt${0}");
expect(lspSnippetToCm("$NOPE", vars)).toBe("${NOPE}");
});
@@ -51,7 +51,7 @@ describe("lspSnippetToCm", () => {
});
it("treats a lone dollar as text", () => {
expect(lspSnippetToCm("$ x $")).toBe("$ x $");
expect(lspSnippetToCm("$ x $")).toBe("$ x $${0}");
});
it("selects the first field when instantiated", () => {
@@ -60,6 +60,31 @@ describe("lspSnippetToCm", () => {
expect([sel.from, sel.to]).toEqual([5, 8]);
});
it.each([
["val", {}, "val"],
["$TM_FILENAME_BASE", { TM_FILENAME_BASE: "Main" }, "Main"],
["${MISSING:val}", {}, "val"],
["\\${1}", {}, "${1}"],
["cost \\$5", {}, "cost $5"],
["first\n\tlast", {}, "first\n last"],
["/* 😀 */ val", {}, "/* 😀 */ val"],
])("places the caret at the end of a fieldless snippet (%s)", (source, vars, expected) => {
const { text, sel } = expand(lspSnippetToCm(source, vars));
expect(text).toBe(expected);
expect([sel.from, sel.to]).toEqual([expected.length, expected.length]);
});
it.each([
["before$0after", "beforeafter", 6, 6],
["${1:arg}after", "argafter", 0, 3],
["${1|val,other|}after", "valafter", 0, 3],
["$UNKNOWN after", "UNKNOWN after", 0, 7],
])("preserves the selected field in %s", (source, expected, from, to) => {
const { text, sel } = expand(lspSnippetToCm(source));
expect(text).toBe(expected);
expect([sel.from, sel.to]).toEqual([from, to]);
});
it("produces plain text previews", () => {
expect(lspSnippetToPlain("foo(${1:a}, ${2|x,y|})$0")).toBe("foo(a, x)");
});
+7 -2
Переглянути файл
@@ -189,8 +189,12 @@ function field(n: number | string, text: string): string {
/** Converts an LSP snippet into a CodeMirror snippet template. */
export function lspSnippetToCm(snippet: string, vars: SnippetVariables = {}): string {
const nodes = parse(snippet);
const hasField = nodes.some(
(n) => n.t === "tab" || n.t === "choice" || (n.t === "var" && vars[n.name] === undefined && !n.children),
);
let out = "";
for (const n of parse(snippet)) {
for (const n of nodes) {
if (n.t === "text") out += escapeText(n.v);
else if (n.t === "tab") out += field(n.n, n.children ? flatten(n.children, vars) : "");
else if (n.t === "choice") out += field(n.n, n.options[0] ?? "");
@@ -202,7 +206,8 @@ export function lspSnippetToCm(snippet: string, vars: SnippetVariables = {}): st
else out += field(n.name, "");
}
}
return out;
// CodeMirror leaves the caret before an insertion without fields.
return hasField ? out : out + "${0}";
}
/** The snippet's text with every field replaced by its default (for previews / plain insertion). */
+1 -1
Переглянути файл
@@ -343,7 +343,7 @@ export function sqlCandidates({ model, analysis: a, driver, sourceName }: Comple
kind: "function",
tail: `(${f.params})`,
type: f.returns?.toUpperCase(),
snippet: f.params ? `${name}(\${})` : `${name}()`,
snippet: f.params ? `${name}(\${})` : `${name}()\${0}`,
boost: f.aggregate && clause !== "select" && clause !== "having" ? fnBoost - 4 : fnBoost,
});
}
+16
Переглянути файл
@@ -1,4 +1,6 @@
import { describe, expect, it } from "vitest";
import { EditorState } from "@codemirror/state";
import { snippet } from "@codemirror/autocomplete";
import type { DbCompletion, DbDriver } from "@/platform/ipc";
import { analyze, templateContexts, valuesSlot } from "./analyze";
import { aliasFor, quoteIdent, sqlCandidates, type SqlCandidate } from "./complete";
@@ -327,6 +329,20 @@ describe("completion candidates", () => {
expect(c.find((x) => x.label === "count")).toMatchObject({ snippet: "count(${})" });
});
it.each(["sqlite", "postgres", "mysql"] as const)(
"leaves the caret after a %s function without parameters",
(driver) => {
const candidates = complete("select |", driver).filter((c) => c.kind === "function" && c.tail === "()");
expect(candidates.length).toBeGreaterThan(0);
for (const candidate of candidates) {
let state = EditorState.create({ doc: "select ", selection: { anchor: 7 } });
snippet(candidate.snippet!)({ state, dispatch: (tr) => (state = tr.state) }, null, 7, 7);
expect(state.doc.toString()).toBe(`select ${candidate.label}()`);
expect(state.selection.main.head).toBe(state.doc.length);
}
},
);
it("quotes identifiers that need it", () => {
expect(quoteIdent("Users", "postgres")).toBe('"Users"');
expect(quoteIdent("Users", "mysql")).toBe("Users");
+1 -1
Переглянути файл
@@ -172,7 +172,7 @@ export const ICLS_ATTRIBUTES: Record<string, Array<[Part, string]>> = {
WARNING_ATTRIBUTES: [["EFFECT_COLOR", "--wave-warning"]],
NOT_USED_ELEMENT_ATTRIBUTES: [["FOREGROUND", "--fg-unused"]],
MATCHED_BRACE_ATTRIBUTES: [["BACKGROUND", "--bg-matched-brace"]],
SEARCH_RESULT_ATTRIBUTES: [["BACKGROUND", "--bg-search-match"]],
SEARCH_RESULT_ATTRIBUTES: [["BACKGROUND", "--bg-search-result"]],
TEXT_SEARCH_RESULT_ATTRIBUTES: [["BACKGROUND", "--bg-text-search-result"]],
IDENTIFIER_UNDER_CARET_ATTRIBUTES: [["BACKGROUND", "--bg-identifier-under-caret"]],
WRITE_IDENTIFIER_UNDER_CARET_ATTRIBUTES: [["BACKGROUND", "--bg-identifier-under-caret-write"]],
+8 -8
Переглянути файл
@@ -57,7 +57,7 @@ async function preview(container: HTMLElement): Promise<EditorView> {
const { CodePreview, previewDecorations } = await import("../find/parts");
describe("CodePreview", () => {
it("marks matches (the current line's as current) and the current line", () => {
it("marks matches and the current line", () => {
const doc = Text.of(["ab", "order order", "x"]);
const set = previewDecorations(doc, 1, [
{
@@ -76,9 +76,9 @@ describe("CodePreview", () => {
});
expect(out).toEqual([
"3-3:cm-activeLine",
"3-8:cm-openidea-findMatch cm-openidea-findMatch-current",
"9-14:cm-openidea-findMatch cm-openidea-findMatch-current",
"15-16:cm-openidea-findMatch",
"3-8:cm-openidea-searchResult",
"9-14:cm-openidea-searchResult",
"15-16:cm-openidea-searchResult",
]);
});
@@ -87,10 +87,10 @@ describe("CodePreview", () => {
await preview(container);
expect(container.querySelector(".cm-content")?.textContent).toContain("val order = 1");
expect(container.querySelector(".cm-content")?.getAttribute("contenteditable")).toBe("true");
expect(container.querySelector(".cm-openidea-findMatch")?.textContent).toBe("order");
expect(container.querySelector(".cm-openidea-searchResult")?.textContent).toBe("order");
});
it("nests matches inside syntax token spans, so the search-match colour reaches the text", async () => {
it("nests matches inside syntax token spans, as find matches in an editor tab", async () => {
// Like the syntax highlighter: a `Prec.high` mark with its own colour over "order".
const token = Prec.high(
EditorView.decorations.of(Decoration.set(Decoration.mark({ class: "token" }).range(15, 20))),
@@ -101,7 +101,7 @@ describe("CodePreview", () => {
]);
const { container } = render(<CodePreview path={path} line={1} highlights={[{ line: 1, ranges: [[4, 9]] }]} />);
await preview(container);
expect(container.querySelector(".token .cm-openidea-findMatch")?.textContent).toBe("order");
expect(container.querySelector(".token .cm-openidea-searchResult")?.textContent).toBe("order");
});
it.each([false, true])("shares edits, document undo and save with a tab (mounted: %s)", async (mounted) => {
@@ -115,7 +115,7 @@ describe("CodePreview", () => {
act(() => view.dispatch({ changes: { from: 0, insert: "// 😀\n" }, userEvent: "input.type" }));
expect(tab.currentState().doc.toString()).toBe("// 😀\n" + original);
expect(model.getMeta().dirty).toBe(true);
expect(view.state.doc.lineAt(view.posAtDOM(container.querySelector(".cm-openidea-findMatch")!)).number).toBe(3);
expect(view.state.doc.lineAt(view.posAtDOM(container.querySelector(".cm-openidea-searchResult")!)).number).toBe(3);
// The tab's Undo removes preview typing; the preview follows immediately.
act(() => {
undo({ state: tab.currentState(), dispatch: (tr) => tab.dispatchLocal(tr) });
+5 -5
Переглянути файл
@@ -79,12 +79,12 @@ const previewMarks = StateField.define<DecorationSet>({
for (const e of tr.effects) if (e.is(setPreviewMarks)) return e.value;
return value.map(tr.changes);
},
// Above the syntax highlighter (`Prec.high`), so a match nests inside its token span and
// `--fg-search-match` colours the text, as find matches do in an editor tab (#1733).
// Above the syntax highlighter (`Prec.high`), so a match nests inside its token span as find
// matches do in an editor tab.
provide: (f) => Prec.highest(EditorView.decorations.from(f)),
});
const matchMark = Decoration.mark({ class: "cm-openidea-findMatch" });
const currentMatchMark = Decoration.mark({ class: "cm-openidea-findMatch cm-openidea-findMatch-current" });
/** IDEA's SEARCH_RESULT_ATTRIBUTES: a background only, so the syntax colours stay (#2858). */
const matchMark = Decoration.mark({ class: "cm-openidea-searchResult" });
const currentLineMark = Decoration.line({ class: "cm-activeLine" });
const readOnly = [EditorState.readOnly.of(true), EditorView.editable.of(false)];
@@ -98,7 +98,7 @@ export function previewDecorations(doc: Text, line: number, highlights: readonly
for (const [a, b] of h.ranges) {
const from = l.from + Math.min(a, l.length);
const to = l.from + Math.min(b, l.length);
if (to > from) out.push((h.line === line ? currentMatchMark : matchMark).range(from, to));
if (to > from) out.push(matchMark.range(from, to));
}
}
return Decoration.set(out, true);
+1 -1
Переглянути файл
@@ -86,7 +86,7 @@ function CommitDialog({ onClose }: { onClose: () => void }) {
<SplitHandle />
<SplitPanel defaultSize="42%" minSize="120px">
<div className={viewStyles.commit}>
<CommitMessageField included={included} runner={runner} />
<CommitMessageField included={included} runner={runner} autoFocus />
</div>
</SplitPanel>
</SplitGroup>
+12 -1
Переглянути файл
@@ -727,6 +727,7 @@ export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) {
{mode !== "dialog" && <OperationBanner />}
<div
className={styles.treeWrap}
data-changes-tree={mode}
onKeyDownCapture={(e) => {
if (checkboxes && e.key === " " && !e.metaKey && !e.ctrlKey && selected.size) {
e.preventDefault();
@@ -979,7 +980,16 @@ export function useCommitRunner(included: number, onCommitted?: () => void): Com
}
/** Amend, message history and the message field with its inspections and failed checks. */
export function CommitMessageField({ included, runner }: { included: number; runner: CommitRunner }) {
export function CommitMessageField({
included,
runner,
autoFocus = false,
}: {
included: number;
runner: CommitRunner;
/** The message takes the initial focus of the dialog it is in (the Commit dialog). */
autoFocus?: boolean;
}) {
const message = useCommit((s) => s.message);
const amend = useCommit((s) => s.amend);
const focusTick = useCommit((s) => s.focusTick);
@@ -1052,6 +1062,7 @@ export function CommitMessageField({ included, runner }: { included: number; run
</div>
<textarea
ref={ref}
data-autofocus={autoFocus ? "" : undefined}
className={clsx(styles.message, !showMargin && styles.noMargin, emptyError && styles.messageError)}
aria-invalid={emptyError || undefined}
style={{ ["--guide" as string]: `${margin}ch` }}
+41 -1
Переглянути файл
@@ -24,7 +24,7 @@ import { useProject } from "@/platform/project";
import { commitChangelist, commitFiles } from "../changelistsUi";
import { initialChangelists, useChangelists } from "../changelists";
import { gitTabs, shownTab, stashTabOf, stashTabs } from "../gitTabs";
import { pushDialogNeeded } from "../commitUi";
import { commitFromContext, pushDialogNeeded } from "../commitUi";
import {
installPreparedMessage,
keepMessageAfterCommit,
@@ -188,6 +188,46 @@ describe("Commit File… and the modal commit interface", () => {
});
});
describe("Commit… from Local Changes with the modal interface", () => {
const status: FileStatus[] = [
{ path: "/p/src/a.ts", relPath: "src/a.ts", staged: null, unstaged: "modified", renamedFrom: null },
{ path: "/p/src/b.ts", relPath: "src/b.ts", staged: null, unstaged: "modified", renamedFrom: null },
{ path: "/p/new.txt", relPath: "new.txt", staged: null, unstaged: "untracked", renamedFrom: null },
];
const tree = document.createElement("div");
tree.dataset.changesTree = "local";
const row = document.createElement("button");
tree.append(row);
beforeEach(() => {
showCommitDialog.mockClear();
document.body.append(tree);
useProject.setState({ settings: { vcs: { commit: { nonModal: false } } } as Settings });
useVcs.setState({ available: true, root: "/p", repo: "/p", roots: ["/p"], status });
useCommit.setState({ checks: new Map(), selection: ["/p/src/b.ts", "/p/new.txt"] });
});
it("includes just the files selected there", async () => {
row.focus();
commitFromContext();
await vi.waitFor(() => expect(showCommitDialog).toHaveBeenCalled());
const checks = useCommit.getState().checks;
expect([checks.get("/p/src/a.ts"), checks.get("/p/src/b.ts"), checks.get("/p/new.txt")]).toEqual([
"none",
"all",
"all",
]);
});
it("falls back to the default inclusion when invoked elsewhere", async () => {
useCommit.setState({ checks: new Map([["/p/src/a.ts", "none"]]) });
row.blur();
commitFromContext();
await vi.waitFor(() => expect(showCommitDialog).toHaveBeenCalled());
expect(useCommit.getState().checks.size).toBe(0);
});
});
describe("log tabs of their own", () => {
beforeEach(() => {
vcsLog.mockReset().mockResolvedValue({ status: "ok", data: [] });
+3 -3
Переглянути файл
@@ -44,7 +44,7 @@ import { gotoChange, rollbackSelectedLines } from "./gutter";
import { openLocalHistory } from "./localHistoryKind";
import { showSelectionHistory } from "./selectionHistoryKind";
import { openLogTab, revealCommit, selectGitTab, useGitLog } from "./logStore";
import { isModalCommit, openCommit, pushAfterCommit } from "./commitUi";
import { commitFromContext, isModalCommit, pushAfterCommit } from "./commitUi";
import { allRepos, runRemote } from "./ops";
import { showPushDialog, showUpdateDialog, type PushTarget, type UpdateTarget } from "./remoteUi";
import { comparePathsWithRevision, showDirectoryWorkingDiff } from "./comparePaths";
@@ -212,7 +212,7 @@ export function registerVcsActions(): () => void {
category: "Git",
icon: "commit",
when: VCS,
run: openCommit,
run: commitFromContext,
},
{
id: "vcs.commitAndPush",
@@ -223,7 +223,7 @@ export function registerVcsActions(): () => void {
run: async () => {
const { message, amend } = useCommit.getState();
// Nothing to commit with yet (or the modal interface): IDEA shows where the message is typed.
if (isModalCommit() || (!message.trim() && !amend)) return openCommit();
if (isModalCommit() || (!message.trim() && !amend)) return commitFromContext();
const id = await commitChanges();
if (id !== undefined) await pushAfterCommit();
},
+27
Переглянути файл
@@ -6,6 +6,8 @@
import { workflowSettings, type GitSettingsValid } from "@/platform/workflowSettings";
import { toolWindowService } from "@/workbench/contributions";
import { useCommit, type CommitTab } from "./commitStore";
import type { FileStatus } from "@/bindings";
import { groupOf, isPartiallyStaged, type CheckState } from "./changes";
import { stashTabOf } from "./gitTabs";
import { selectGitTab } from "./logStore";
import { isProtectedBranch, pushWithAutoUpdate, showPushDialog } from "./remoteUi";
@@ -31,6 +33,31 @@ export function openCommit() {
void import("./CommitDialog").then((m) => m.showCommitDialog());
}
/**
* Commit… (⌘K) as an action: with the modal interface the dialog includes just the files
* selected in Local Changes when invoked from there, otherwise the default inclusion.
*/
export function commitFromContext() {
if (isModalCommit()) {
const fromLocalChanges = !!(document.activeElement as HTMLElement | null)?.closest?.('[data-changes-tree="local"]');
const selection = fromLocalChanges ? useCommit.getState().selection : [];
useCommit.setState({ checks: selection.length ? includeOnly(useVcs.getState().status, selection) : new Map() });
}
openCommit();
}
/** Inclusion that commits only `paths` of `status` (files elsewhere left out, conflicts as they are). */
export function includeOnly(status: readonly FileStatus[], paths: readonly string[]): Map<string, CheckState> {
const chosen = new Set(paths);
const checks = new Map<string, CheckState>();
for (const fs of status) {
const group = groupOf(fs);
if (!group || group === "conflicts") continue;
checks.set(fs.path, !chosen.has(fs.path) ? "none" : isPartiallyStaged(fs) ? "staged" : "all");
}
return checks;
}
/**
* Commit and Push…: whether the Push dialog comes up after the commit (Settings › Git ›
* "Show Push dialog for Commit and Push", optionally only for protected branches) or
+4
Переглянути файл
@@ -195,6 +195,7 @@ html {
--fg-editor-selection: inherit;
--bg-text-search-result: #114957;
--bg-text-search-result-current: #165e70;
--bg-search-result: #2d543f;
--bg-matched-brace: #43454a;
--matched-brace-weight: var(--fw-code-bold);
--bg-matched-tag: #3f4045;
@@ -621,6 +622,7 @@ html {
--fg-editor-selection: inherit;
--bg-text-search-result: #fcd47e;
--bg-text-search-result-current: #f2bf57;
--bg-search-result: #ccccff;
--bg-matched-brace: #93d9d9;
--matched-brace-weight: inherit;
--bg-matched-tag: #c9ecec;
@@ -913,6 +915,7 @@ html {
--bg-editor-selection-inactive: #1f3f7a;
--bg-text-search-result: #146a7d;
--bg-text-search-result-current: #1d8aa3;
--bg-search-result: #006112;
--bg-matched-brace: #55585f;
--bg-matched-tag: #45474d;
--bg-fold-placeholder: #33353b;
@@ -1178,6 +1181,7 @@ html {
--bg-text-search-result: color-mix(in srgb, var(--palette-yellow) 22%, var(--palette-bg));
--bg-search-match-current: color-mix(in srgb, var(--palette-yellow) 36%, var(--palette-bg));
--bg-text-search-result-current: color-mix(in srgb, var(--palette-yellow) 36%, var(--palette-bg));
--bg-search-result: color-mix(in srgb, var(--palette-green) 22%, var(--palette-bg));
--fg-search-match: var(--palette-fg);
--border-tab-selected: var(--palette-accent);
--border-indent-guide-active: var(--palette-accent);
+2
Переглянути файл
@@ -178,6 +178,7 @@ const ICONS = {
"file-gdshader": "openidea/fileTypes/gdshader",
"file-asciidoc": "openidea/fileTypes/asciidoc",
"file-d": "openidea/fileTypes/d",
"file-prisma": "openidea/fileTypes/prisma",
"file-dart": "openidea/fileTypes/dart",
"file-elixir": "openidea/fileTypes/elixir",
"file-erlang": "openidea/fileTypes/erlang",
@@ -1098,6 +1099,7 @@ const EXT_ICONS = table({
"file-gdshader": "gdshader gdshaderinc",
"file-asciidoc": "adoc asciidoc",
"file-d": "d di",
"file-prisma": "prisma",
"file-dart": "dart",
"file-elixir": "ex exs",
"file-erlang": "erl hrl escript app.src",
+3
Переглянути файл
@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g stroke-linejoin="round"><path d="M14 4C14 2.89543 13.1046 2 12 2H4C2.89543 2 2 2.89543 2 4V12C2 13.1046 2.89543 14 4 14H12C13.1046 14 14 13.1046 14 12V4Z" fill="#3A8A80"/><path transform="translate(5 3.5)" d="M0.5 8.5V0.5H3.2C4.9 0.5 5.9 1.5 5.9 2.9C5.9 4.3 4.9 5.3 3.2 5.3H0.5" stroke="#2B2D30"/></g>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 408 B

+3
Переглянути файл
@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g stroke-linejoin="round"><path d="M14 4C14 2.89543 13.1046 2 12 2H4C2.89543 2 2 2.89543 2 4V12C2 13.1046 2.89543 14 4 14H12C13.1046 14 14 13.1046 14 12V4Z" fill="#4FA397"/><path transform="translate(5 3.5)" d="M0.5 8.5V0.5H3.2C4.9 0.5 5.9 1.5 5.9 2.9C5.9 4.3 4.9 5.3 3.2 5.3H0.5" stroke="#2B2D30"/></g>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 408 B