дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
feat(languages): Add Godot / GDScript support (#73)
Adds GDScript, Godot resource and shader file types with tree-sitter highlighting, the GDScript language server over TCP (a headless Godot editor started on demand), "Godot Project" / current-scene run configurations, and debugging through Godot's debug adapter. Breakpoints set through symlinked paths (/tmp, /var on macOS) were reported verified but never hit; the adapter now sends resolved paths and maps stack frames back to the paths the IDE opened. The duplicate exit event Godot sends after disconnect is ignored. Fixes #68 Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
Цей коміт міститься в:
1 джерело
2eaa6cb0c7
коміт
e3dae02de7
52 files changed
+1584
-33
No files matched your search
@@ -197,4 +197,15 @@ describe("run config → launch config", () => {
|
||||
// lldb-dap takes `KEY=VALUE` strings.
|
||||
expect(extra(dap)).toEqual({ env: ["K=v"] });
|
||||
});
|
||||
|
||||
it("Godot: the editor's adapter plays the main scene or the chosen one of the project", () => {
|
||||
const project = rc({ kind: "godot", command: "/Apps/Godot", args: ["--path", "."], cwd: "game", debugAdapter: "godot" });
|
||||
const main = launchConfigFor(project, "/p")!;
|
||||
expect(main).toMatchObject({ adapter: "godot", request: "launch", cwd: "/p/game", program: null });
|
||||
expect(extra(main)).toEqual({ project: "/p/game", scene: "main" });
|
||||
const scene = launchConfigFor({ ...project, args: ["--path", ".", "res://levels/one.tscn"] }, "/p")!;
|
||||
expect(extra(scene)).toEqual({ project: "/p/game", scene: "res://levels/one.tscn" });
|
||||
const relative = launchConfigFor({ ...project, cwd: null, args: ["--path", "game", "menu.tscn"] }, "/p")!;
|
||||
expect(extra(relative)).toEqual({ project: "/p/game", scene: "res://menu.tscn" });
|
||||
});
|
||||
});
|
||||
@@ -137,6 +137,14 @@ describe("remote attach requests", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("Attach to Godot connects to the editor's debug adapter for the project", () => {
|
||||
const l = remoteAttachConfig(configOf(remote.godotAttachType), "/p/game")!;
|
||||
expect(l).toMatchObject({ adapter: "godot", request: "attach", cwd: "/p/game" });
|
||||
expect(JSON.parse(l.extraJson!)).toEqual({ debugServer: "localhost:6006", project: "/p/game" });
|
||||
const custom = remoteAttachConfig(configOf(remote.godotAttachType, { port: "6010" }), "/p")!;
|
||||
expect(JSON.parse(custom.extraJson!).debugServer).toBe("localhost:6010");
|
||||
});
|
||||
|
||||
it("Attach to Node.js attaches js-debug to the inspector with local/remote roots", () => {
|
||||
const l = remoteAttachConfig(configOf(nodeRemoteType, { host: "web", remoteRoot: "/usr/src/app" }), "/p")!;
|
||||
expect(l.adapter).toBe("js-debug");
|
||||
|
||||
@@ -196,6 +196,24 @@ function delveLaunch(rc: RunConfig, cwd: string): LaunchConfig {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The scene a `godot --path <dir> [scene]` command line plays: `main`, or a `res://` path. */
|
||||
export function godotScene(args: readonly string[]): string {
|
||||
const scene = args.find((a, i) => args[i - 1] !== "--path" && /\.t?scn$/i.test(a));
|
||||
if (!scene) return "main";
|
||||
return scene.startsWith("res://") ? scene : `res://${scene.replace(/^\.\//, "")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Godot: the adapter inside the editor plays the scene itself (with the editor's run
|
||||
* settings), so the launch names the project (checked against the editor's) and the scene.
|
||||
*/
|
||||
function godotLaunch(rc: RunConfig, cwd: string): LaunchConfig {
|
||||
const i = rc.args.indexOf("--path");
|
||||
const dir = i >= 0 ? rc.args[i + 1] : undefined;
|
||||
const project = dir ? join(cwd, dir) : cwd;
|
||||
return { ...base(rc, "godot", project), extraJson: JSON.stringify({ project, scene: godotScene(rc.args) }) };
|
||||
}
|
||||
|
||||
/** A native program under CodeLLDB or lldb-dap: program, args, cwd, env. */
|
||||
function nativeLaunch(rc: RunConfig, cwd: string, adapter: string): LaunchConfig {
|
||||
const out: LaunchConfig = { ...base(rc, adapter, cwd), program: resolveCommand(rc.command, cwd), args: [...rc.args] };
|
||||
@@ -219,6 +237,7 @@ export function launchConfigFor(rc: RunConfig, root: string): LaunchConfig | nul
|
||||
if (adapter === "debugpy") return debugpyLaunch(rc, cwd);
|
||||
if (adapter === "js-debug") return jsDebugLaunch(rc, cwd);
|
||||
if (adapter === "delve") return delveLaunch(rc, cwd);
|
||||
if (adapter === "godot") return godotLaunch(rc, cwd);
|
||||
// Adapters without a template (hidden in Edit Configurations): generic arguments.
|
||||
return {
|
||||
...base(rc, adapter, cwd),
|
||||
|
||||
@@ -12,6 +12,7 @@ export const REMOTE_JVM = "remote-jvm";
|
||||
export const GO_REMOTE = "go-remote";
|
||||
export const PYTHON_REMOTE = "python-remote";
|
||||
export const NODE_REMOTE = "node-remote";
|
||||
export const GODOT_ATTACH = "godot-attach";
|
||||
|
||||
type Params = Readonly<Record<string, string>>;
|
||||
|
||||
@@ -220,11 +221,36 @@ export const nodeRemoteType: RunConfigTypeContribution = {
|
||||
commandLine: () => null,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Attach to Godot (the editor's debug adapter, Editor Settings › Network › Debug Adapter)
|
||||
|
||||
export const godotAttachType: RunConfigTypeContribution = {
|
||||
id: GODOT_ATTACH,
|
||||
label: "Attach to Godot",
|
||||
icon: "file-gdscript",
|
||||
description: "Debug the game the Godot editor is running, through the editor's debug adapter",
|
||||
defaults: { host: "localhost", port: "6006" },
|
||||
debugOnly: true,
|
||||
debugAdapter: "godot",
|
||||
fields: [
|
||||
hostField,
|
||||
portField,
|
||||
{
|
||||
key: "hint",
|
||||
label: "Before attaching",
|
||||
control: "output",
|
||||
value: () => "Open the project in the Godot editor and run a scene (F5 / F6)",
|
||||
},
|
||||
],
|
||||
commandLine: () => null,
|
||||
};
|
||||
|
||||
export const REMOTE_TYPES: readonly RunConfigTypeContribution[] = [
|
||||
remoteJvmType,
|
||||
goRemoteType,
|
||||
pythonRemoteType,
|
||||
nodeRemoteType,
|
||||
godotAttachType,
|
||||
];
|
||||
|
||||
export const isRemoteConfig = (c: Pick<RunConfig, "kind">) => REMOTE_TYPES.some((t) => t.id === c.kind);
|
||||
@@ -291,6 +317,8 @@ export function remoteAttachConfig(rc: RunConfig, root: string): LaunchConfig |
|
||||
justMyCode: skip,
|
||||
...(map ? { pathMappings: [{ localRoot: map.local, remoteRoot: map.remote }] } : {}),
|
||||
});
|
||||
case GODOT_ATTACH:
|
||||
return attachBase(rc, "godot", root, { debugServer: serverAddress(host, portOf(p, 6006)), project: root });
|
||||
case NODE_REMOTE:
|
||||
return attachBase(rc, "js-debug", root, {
|
||||
type: "pwa-node",
|
||||
|
||||
@@ -262,6 +262,26 @@ describe("project producers", () => {
|
||||
cfg({ id: "pytest", kind: "pytest", command: ".venv/bin/pytest", args: [], isTest: true }),
|
||||
];
|
||||
|
||||
it("Godot scenes run with godot --path in the project holding them (Current Scene)", () => {
|
||||
const godot = [
|
||||
cfg({ id: "godot:project", kind: "godot", command: "godot", args: ["--path", "."], debugAdapter: "godot" }),
|
||||
cfg({ id: "godot:project:demo", kind: "godot", command: "godot", args: ["--path", "."], cwd: "demo" }),
|
||||
];
|
||||
const root = produceContext({ path: `${ROOT}/levels/one.tscn`, root: ROOT, configs: godot }, OPTIMISTIC)!;
|
||||
expect(root).toMatchObject({ name: "one.tscn", scope: "file" });
|
||||
expect(root.config).toMatchObject({
|
||||
kind: "godot",
|
||||
command: "godot",
|
||||
args: ["--path", ".", "res://levels/one.tscn"],
|
||||
cwd: null,
|
||||
debugAdapter: "godot",
|
||||
});
|
||||
const nested = produceContext({ path: `${ROOT}/demo/main.tscn`, root: ROOT, configs: godot }, OPTIMISTIC)!;
|
||||
expect(nested.config).toMatchObject({ args: ["--path", ".", "res://main.tscn"], cwd: "demo" });
|
||||
// Outside a Godot project there is nothing to run.
|
||||
expect(produceContext({ path: `${ROOT}/a.tscn`, root: ROOT, configs: [] }, OPTIMISTIC)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps package.json test files on the project's runner", () => {
|
||||
const p = produceContext({ path: `${ROOT}/src/a.test.ts`, root: ROOT, configs, content: "test('x', () => {})" }, ALL)!;
|
||||
expect(p).toMatchObject({ name: "a.test.ts", scope: "file" });
|
||||
|
||||
@@ -511,6 +511,33 @@ function cargoOwner(rel: string, configs: readonly RunConfig[]): RunConfig[] {
|
||||
|
||||
const hasBuildTool = (configs: readonly RunConfig[], kinds: string[]) => configs.some((c) => kinds.includes(c.kind));
|
||||
|
||||
/**
|
||||
* A Godot scene: `godot --path <project> res://<scene>` in the Godot project holding it
|
||||
* (a detected "Godot Project" configuration), debuggable through the editor's adapter.
|
||||
*/
|
||||
function godotScene(rel: string, configs: readonly RunConfig[]): Produced | null {
|
||||
const dirs = configs
|
||||
.filter((c) => c.kind === "godot" && c.detected && c.id.startsWith("godot:project"))
|
||||
.map((c) => (c.cwd ?? "").replace(/^\.\/?/, "").replace(/\/+$/, ""));
|
||||
const dir = dirs.filter((d) => !d || rel.startsWith(d + "/")).sort((a, b) => b.length - a.length)[0];
|
||||
if (dir === undefined) return null;
|
||||
const name = baseName(rel);
|
||||
const scene = `res://${dir ? rel.slice(dir.length + 1) : rel}`;
|
||||
const config: RunConfig = {
|
||||
id: `context:file:${rel}`,
|
||||
name,
|
||||
kind: "godot",
|
||||
command: "godot",
|
||||
args: ["--path", ".", scene],
|
||||
cwd: dir || null,
|
||||
env: {},
|
||||
detected: true,
|
||||
isTest: false,
|
||||
debugAdapter: "godot",
|
||||
};
|
||||
return { name, scope: "file", config };
|
||||
}
|
||||
|
||||
/** The file as a whole: tests of a test file, the project's run target, or the runtime for its language. */
|
||||
export function produceFile(input: ContextInput, env: ToolEnv): Produced | null {
|
||||
const s = site(input);
|
||||
@@ -519,6 +546,7 @@ export function produceFile(input: ContextInput, env: ToolEnv): Produced | null
|
||||
const rel = s.idPath;
|
||||
|
||||
if (s.inProject) {
|
||||
if (/\.t?scn$/i.test(rel)) return godotScene(rel, configs);
|
||||
// Project-aware: test files through the project's test runner, Cargo / Go / Gradle / Maven mains.
|
||||
const role = fileRole(rel, input.content);
|
||||
if (role === "test") {
|
||||
|
||||
@@ -77,6 +77,26 @@ describe("configuration types", () => {
|
||||
expect(withParams(old, params)).toMatchObject({ command: "cargo", args: ["run", "--bin", "my app"] });
|
||||
});
|
||||
|
||||
it("Godot runs the main scene or a chosen one, and reads detected configurations back", () => {
|
||||
const godot = typeFor("godot")!;
|
||||
expect(godot.debugAdapter).toBe("godot");
|
||||
const c = cfg("g", { kind: "godot" });
|
||||
expect(withParams(c, { projectDir: "game", scene: "", arguments: "" })).toMatchObject({
|
||||
command: "godot",
|
||||
args: ["--path", "."],
|
||||
cwd: "game",
|
||||
});
|
||||
expect(withParams(c, { scene: "res://one.tscn", arguments: "--debug-collisions" })).toMatchObject({
|
||||
args: ["--path", ".", "res://one.tscn", "--debug-collisions"],
|
||||
cwd: null,
|
||||
});
|
||||
const detected = cfg("godot:project", { kind: "godot", command: "/Apps/Godot.app/Contents/MacOS/Godot", args: ["--path", "."] });
|
||||
expect(godot.paramsFrom!(detected)).toEqual({ projectDir: "", scene: "", arguments: "" });
|
||||
const scene = { ...detected, cwd: "demo", args: ["--path", ".", "res://a.tscn", "--verbose"] };
|
||||
expect(godot.paramsFrom!(scene)).toEqual({ projectDir: "demo", scene: "res://a.tscn", arguments: "--verbose" });
|
||||
expect(godot.paramsFrom!(cfg("x", { command: "cargo" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("shell script files and text derive the command line", () => {
|
||||
const c = cfg("s");
|
||||
expect(
|
||||
|
||||
@@ -111,6 +111,47 @@ const program: RunConfigTypeContribution = {
|
||||
c.args.length || !/\s/.test(c.command) ? { program: c.command, arguments: formatArgs(c.args) } : null,
|
||||
};
|
||||
|
||||
const isScene = (a: string) => /\.t?scn$/i.test(a);
|
||||
const isGodot = (cmd: string) => /^godot([-.\w]*)$/i.test(baseName(cmd).replace(/\.exe$/i, ""));
|
||||
|
||||
/**
|
||||
* Godot: `godot --path <project>` runs the main scene, `… <scene>` one scene. The
|
||||
* executable is the one from Settings › Language Servers › godot (else detected).
|
||||
*/
|
||||
const godot: RunConfigTypeContribution = {
|
||||
id: "godot",
|
||||
label: "Godot",
|
||||
icon: "file-gdscript",
|
||||
description: "Run a Godot project's main scene or a chosen scene",
|
||||
ownsWorkingDirectory: true,
|
||||
debugAdapter: "godot",
|
||||
fields: [
|
||||
{ key: "projectDir", label: "Project directory", mono: true, placeholder: "Project root (holds project.godot)" },
|
||||
{
|
||||
key: "scene",
|
||||
label: "Scene",
|
||||
mono: true,
|
||||
placeholder: "Main scene",
|
||||
hint: "e.g. res://levels/level_1.tscn; empty runs the project's main scene",
|
||||
suggestions: (configs) => [...new Set(configs.filter((c) => c.kind === "godot").flatMap((c) => c.args.filter(isScene)))],
|
||||
},
|
||||
{ key: "arguments", label: "Arguments", mono: true, placeholder: "Godot command line options, e.g. --debug-collisions" },
|
||||
],
|
||||
commandLine: (p) => ({
|
||||
command: "godot",
|
||||
args: ["--path", ".", ...(p.scene?.trim() ? [p.scene.trim()] : []), ...parseArgs(p.arguments ?? "")],
|
||||
cwd: p.projectDir?.trim() || null,
|
||||
}),
|
||||
paramsFrom(c) {
|
||||
if (!isGodot(c.command)) return null;
|
||||
const rest = [...c.args];
|
||||
const path = rest.indexOf("--path");
|
||||
if (path >= 0) rest.splice(path, 2);
|
||||
const scene = rest.find(isScene) ?? "";
|
||||
return { projectDir: dirOf(c.cwd), scene, arguments: formatArgs(rest.filter((a) => a !== scene)) };
|
||||
},
|
||||
};
|
||||
|
||||
const PACKAGE_MANAGERS = ["npm", "pnpm", "yarn", "bun"];
|
||||
const NPM_COMMANDS = ["run", "test", "start", "install", "ci", "publish", "pack"];
|
||||
|
||||
@@ -228,7 +269,7 @@ const compound: RunConfigTypeContribution = {
|
||||
commandLine: () => null,
|
||||
};
|
||||
|
||||
export const BUILTIN_TYPES: readonly RunConfigTypeContribution[] = [shell, program, npm, gradle, maven, compound];
|
||||
export const BUILTIN_TYPES: readonly RunConfigTypeContribution[] = [shell, program, npm, gradle, maven, godot, compound];
|
||||
|
||||
export function registerBuiltinTypes(): () => void {
|
||||
const disposers = BUILTIN_TYPES.map(registerRunConfigType);
|
||||
|
||||
@@ -112,6 +112,9 @@ const ICONS = {
|
||||
"file-ruby": "openidea/fileTypes/ruby",
|
||||
"file-lua": "openidea/fileTypes/lua",
|
||||
"file-zig": "openidea/fileTypes/zig",
|
||||
"file-gdscript": "openidea/fileTypes/gdscript",
|
||||
"file-godot": "openidea/fileTypes/godot",
|
||||
"file-gdshader": "openidea/fileTypes/gdshader",
|
||||
"file-dart": "openidea/fileTypes/dart",
|
||||
"file-elixir": "openidea/fileTypes/elixir",
|
||||
"file-erlang": "openidea/fileTypes/erlang",
|
||||
@@ -800,6 +803,9 @@ const EXT_ICONS = table({
|
||||
"file-ruby": "rb rake gemspec ru rbi jbuilder podspec",
|
||||
"file-lua": "lua luau rockspec",
|
||||
"file-zig": "zig zon",
|
||||
"file-gdscript": "gd",
|
||||
"file-godot": "tscn tres godot import",
|
||||
"file-gdshader": "gdshader gdshaderinc",
|
||||
"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="#478CBF"/><path transform="translate(4 6)" d="M3.5 1.4C3.2 0.8 2.6 0.5 2 0.5C1.1 0.5 0.5 1.3 0.5 2.3V3.7C0.5 4.7 1.1 5.5 2 5.5C2.9 5.5 3.5 4.9 3.5 4V3.5H2" stroke="white"/><path transform="translate(9 6)" d="M0.5 0.5V5.5H1.7C2.8 5.5 3.5 4.7 3.5 3.6V2.4C3.5 1.3 2.8 0.5 1.7 0.5Z" stroke="white"/></g>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 567 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="#5C9DD1"/><path transform="translate(4 6)" d="M3.5 1.4C3.2 0.8 2.6 0.5 2 0.5C1.1 0.5 0.5 1.3 0.5 2.3V3.7C0.5 4.7 1.1 5.5 2 5.5C2.9 5.5 3.5 4.9 3.5 4V3.5H2" stroke="#2B2D30"/><path transform="translate(9 6)" d="M0.5 0.5V5.5H1.7C2.8 5.5 3.5 4.7 3.5 3.6V2.4C3.5 1.3 2.8 0.5 1.7 0.5Z" stroke="#2B2D30"/></g>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 571 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="#8E5DB8"/><path transform="translate(4 6)" d="M3.5 1.3C3.2 0.8 2.7 0.5 2 0.5C1.1 0.5 0.5 1 0.5 1.7C0.5 3.2 3.5 2.6 3.5 4.3C3.5 5 2.9 5.5 2 5.5C1.3 5.5 0.7 5.2 0.4 4.6" stroke="white"/><path transform="translate(9 6)" d="M0.5 0V6M3.5 0V6M0.5 3H3.5" stroke="white"/></g>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 536 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="#A577CC"/><path transform="translate(4 6)" d="M3.5 1.3C3.2 0.8 2.7 0.5 2 0.5C1.1 0.5 0.5 1 0.5 1.7C0.5 3.2 3.5 2.6 3.5 4.3C3.5 5 2.9 5.5 2 5.5C1.3 5.5 0.7 5.2 0.4 4.6" stroke="#2B2D30"/><path transform="translate(9 6)" d="M0.5 0V6M3.5 0V6M0.5 3H3.5" stroke="#2B2D30"/></g>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 540 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="#478CBF"/><path transform="translate(4 6)" d="M3.5 1.4C3.2 0.8 2.6 0.5 2 0.5C1.1 0.5 0.5 1.3 0.5 2.3V3.7C0.5 4.7 1.1 5.5 2 5.5C2.9 5.5 3.5 4.9 3.5 4V3.5H2" stroke="white"/><path transform="translate(9 6)" d="M0.5 5.5V0.5H2C3 0.5 3.5 1.1 3.5 1.9C3.5 2.7 3 3.3 2 3.3H0.5M2 3.3L3.5 5.5" stroke="white"/></g>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 572 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="#5C9DD1"/><path transform="translate(4 6)" d="M3.5 1.4C3.2 0.8 2.6 0.5 2 0.5C1.1 0.5 0.5 1.3 0.5 2.3V3.7C0.5 4.7 1.1 5.5 2 5.5C2.9 5.5 3.5 4.9 3.5 4V3.5H2" stroke="#2B2D30"/><path transform="translate(9 6)" d="M0.5 5.5V0.5H2C3 0.5 3.5 1.1 3.5 1.9C3.5 2.7 3 3.3 2 3.3H0.5M2 3.3L3.5 5.5" stroke="#2B2D30"/></g>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 576 B |
Reference in new issue
Заблокувати користувача