mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-12 18:36:53 -10:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0019a01ff1 | ||
|
|
099ede3478 | ||
|
|
fb269afdfa | ||
|
|
435b00d893 | ||
|
|
14fe542617 | ||
|
|
513762147a | ||
|
|
489f55f52c |
No files matched your search
Generated
+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"
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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]]`
|
||||
|
||||
|
||||
@@ -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** |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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]]`
|
||||
|
||||
|
||||
@@ -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** |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 = [] }
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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 = [] }
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = [] }
|
||||
@@ -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` |
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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})`, () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"]],
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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` }}
|
||||
|
||||
@@ -283,6 +283,19 @@ describe("commit inclusion", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves out a file added to the index and then deleted, which no group lists", () => {
|
||||
const files = [fs("src/A.java", null, "modified"), fs("src/Gone.java", "added", "deleted")];
|
||||
expect(
|
||||
buildChangeTree(files, "/r", false)
|
||||
.flatMap((g) => g.children ?? [])
|
||||
.map((n) => n.label),
|
||||
).toEqual(["A.java"]);
|
||||
const plan = planCommit(files, new Map());
|
||||
expect(plan.stage).toEqual(["/r/src/A.java"]);
|
||||
expect(plan.exclude).toEqual(["/r/src/Gone.java"]);
|
||||
expect(plan.included.map((f) => f.relPath)).toEqual(["src/A.java"]);
|
||||
});
|
||||
|
||||
it("aggregates checkbox state for groups", () => {
|
||||
const checks = new Map<string, CheckState>();
|
||||
expect(aggregateCheck([status[0]!, status[3]!], checks)).toMatchObject({ checked: true, indeterminate: false });
|
||||
|
||||
@@ -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: [] });
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -390,7 +390,12 @@ export function renamedFromPath(fs: FileStatus): string | null {
|
||||
export function planCommit(status: readonly FileStatus[], checks: ReadonlyMap<string, CheckState>): CommitPlan {
|
||||
const plan: CommitPlan = { stage: [], exclude: [], included: [], partial: [] };
|
||||
for (const fs of status) {
|
||||
if (!groupOf(fs)) continue;
|
||||
if (!groupOf(fs)) {
|
||||
// Listed nowhere, yet staged: added to the index, then deleted from disk. It is no
|
||||
// change against HEAD, so the commit must not pick up the staged addition.
|
||||
if (fs.staged && fs.staged !== "conflicted" && fs.unstaged !== "ignored") plan.exclude.push(stripSlash(fs.path));
|
||||
continue;
|
||||
}
|
||||
const c = checkOf(fs, checks);
|
||||
const path = stripSlash(fs.path);
|
||||
if (c === "none") {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 408 B |
@@ -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>
|
||||
|
After Width: | Height: | Size: 408 B |
Reference in new issue
Block a user