дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
feat(scratch): start new scratch files from a language template
A new Java scratch was an empty file that couldn't run until a class and main method were typed in. Languages can now define `scratch-template` in `languages/<id>.toml`; a new scratch gets that content with the caret at `$END$`. - Java, Kotlin, Go, Rust, C and C++ get a runnable `main`. - Other languages still start empty.
Цей коміт міститься в:
1 джерело
52360c72d2
коміт
3100aa2c00
15 files changed
+145
-14
No files matched your search
@@ -923,6 +923,7 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
|
||||
ui_mode: None,
|
||||
framework_ui_modes: Vec::new(),
|
||||
formatter: None,
|
||||
scratch_template: None,
|
||||
tree_sitter: None,
|
||||
language_servers: vec![],
|
||||
debugger: Some(ide_language::DebuggerConfig {
|
||||
|
||||
@@ -60,6 +60,9 @@ pub struct LanguageConfig {
|
||||
/// External formatter used when no language server can format, or selected in settings.
|
||||
#[serde(default)]
|
||||
pub formatter: Option<FormatterConfig>,
|
||||
/// Content of a new scratch file; `$END$` marks where the caret goes.
|
||||
#[serde(default)]
|
||||
pub scratch_template: Option<String>,
|
||||
}
|
||||
|
||||
/// An external formatting tool and its language-specific parser.
|
||||
@@ -242,6 +245,10 @@ pub struct LanguageInfo {
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
pub first_line: Option<String>,
|
||||
/// Content of a new scratch file; `$END$` marks where the caret goes.
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
pub scratch_template: Option<String>,
|
||||
}
|
||||
|
||||
impl From<&LanguageConfig> for LanguageInfo {
|
||||
@@ -258,6 +265,7 @@ impl From<&LanguageConfig> for LanguageInfo {
|
||||
language_servers: c.language_servers.iter().map(|s| s.name.clone()).collect(),
|
||||
filenames: c.filenames.clone(),
|
||||
first_line: c.first_line.clone(),
|
||||
scratch_template: c.scratch_template.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -772,6 +772,10 @@ mod tests {
|
||||
assert!(addr.parse::<std::net::SocketAddr>().is_ok(), "{name}: bad connect address {addr}");
|
||||
}
|
||||
}
|
||||
if let Some(t) = &cfg.scratch_template {
|
||||
assert_eq!(t.matches("$END$").count(), 1, "{name}: scratch-template needs one $END$");
|
||||
assert!(!cfg.extensions.is_empty(), "{name}: scratch-template without an extension");
|
||||
}
|
||||
for ext in &cfg.extensions {
|
||||
assert_eq!(ext, &ext.to_ascii_lowercase(), "{name}: extensions must be lower case");
|
||||
assert!(!ext.starts_with('.'), "{name}: extension {ext} must not start with a dot");
|
||||
|
||||
@@ -269,6 +269,7 @@ fn harness_configured(
|
||||
ui_mode: None,
|
||||
framework_ui_modes: Vec::new(),
|
||||
formatter: None,
|
||||
scratch_template: None,
|
||||
tree_sitter: None,
|
||||
language_servers: servers
|
||||
.iter()
|
||||
|
||||
@@ -56,6 +56,7 @@ fn lang(id: &str, ext: &str, server: LanguageServerConfig) -> LanguageConfig {
|
||||
ui_mode: None,
|
||||
framework_ui_modes: Vec::new(),
|
||||
formatter: None,
|
||||
scratch_template: None,
|
||||
tree_sitter: None,
|
||||
language_servers: vec![server],
|
||||
debugger: None,
|
||||
|
||||
@@ -6,6 +6,14 @@ block-comment = ["/*", "*/"]
|
||||
indent = { tab-size = 4, insert-spaces = true }
|
||||
ui-mode = "C"
|
||||
tree-sitter = "c"
|
||||
scratch-template = '''
|
||||
#include <stdio.h>
|
||||
|
||||
int main(void) {
|
||||
$END$
|
||||
return 0;
|
||||
}
|
||||
'''
|
||||
|
||||
[[language-servers]]
|
||||
name = "clangd"
|
||||
|
||||
@@ -6,6 +6,14 @@ block-comment = ["/*", "*/"]
|
||||
indent = { tab-size = 4, insert-spaces = true }
|
||||
ui-mode = "C++"
|
||||
tree-sitter = "cpp"
|
||||
scratch-template = '''
|
||||
#include <iostream>
|
||||
|
||||
int main() {
|
||||
$END$
|
||||
return 0;
|
||||
}
|
||||
'''
|
||||
|
||||
[[language-servers]]
|
||||
name = "clangd"
|
||||
|
||||
@@ -6,6 +6,13 @@ block-comment = ["/*", "*/"]
|
||||
indent = { tab-size = 4, insert-spaces = false }
|
||||
ui-mode = "Go"
|
||||
tree-sitter = "go"
|
||||
scratch-template = '''
|
||||
package main
|
||||
|
||||
func main() {
|
||||
$END$
|
||||
}
|
||||
'''
|
||||
|
||||
[[language-servers]]
|
||||
name = "gopls"
|
||||
|
||||
@@ -6,6 +6,13 @@ block-comment = ["/*", "*/"]
|
||||
indent = { tab-size = 4, insert-spaces = true }
|
||||
ui-mode = "Java"
|
||||
tree-sitter = "java"
|
||||
scratch-template = '''
|
||||
class Scratch {
|
||||
public static void main(String[] args) {
|
||||
$END$
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
# Microsoft java-debug runs inside jdtls as a bundle (`initializationOptions.bundles`,
|
||||
# added when installed); sessions start with `vscode.java.startDebugSession` and the
|
||||
|
||||
@@ -6,6 +6,11 @@ block-comment = ["/*", "*/"]
|
||||
indent = { tab-size = 4, insert-spaces = true }
|
||||
ui-mode = "Kotlin"
|
||||
tree-sitter = "kotlin"
|
||||
scratch-template = '''
|
||||
fun main() {
|
||||
$END$
|
||||
}
|
||||
'''
|
||||
|
||||
[[language-servers]]
|
||||
name = "kotlin-lsp"
|
||||
|
||||
@@ -6,6 +6,11 @@ block-comment = ["/*", "*/"]
|
||||
indent = { tab-size = 4, insert-spaces = true }
|
||||
ui-mode = "Rust"
|
||||
tree-sitter = "rust"
|
||||
scratch-template = '''
|
||||
fn main() {
|
||||
$END$
|
||||
}
|
||||
'''
|
||||
|
||||
[[language-servers]]
|
||||
name = "rust-analyzer"
|
||||
|
||||
@@ -4534,6 +4534,8 @@ export type LanguageInfo_Deserialize = {
|
||||
filenames?: string[],
|
||||
/** First-line regex of the definition (shebangs, `<?php`). */
|
||||
firstLine?: string | null,
|
||||
/** Content of a new scratch file; `$END$` marks where the caret goes. */
|
||||
scratchTemplate?: string | null,
|
||||
};
|
||||
|
||||
/** The UI-facing view of a language. */
|
||||
@@ -4553,6 +4555,8 @@ export type LanguageInfo_Serialize = {
|
||||
filenames?: string[],
|
||||
/** First-line regex of the definition (shebangs, `<?php`). */
|
||||
firstLine?: string | null,
|
||||
/** Content of a new scratch file; `$END$` marks where the caret goes. */
|
||||
scratchTemplate?: string | null,
|
||||
};
|
||||
|
||||
export type LanguageServerSettings = LanguageServerSettings_Serialize | LanguageServerSettings_Deserialize;
|
||||
|
||||
@@ -4,14 +4,26 @@ import userEvent from "@testing-library/user-event";
|
||||
import { getAction } from "@/platform/actions";
|
||||
import { onIdeEvent } from "@/platform/ipc";
|
||||
import { useProjectViewRoots } from "@/workbench/contributions";
|
||||
import { chooserRows, pushRecent, scratchFileName, scratchLanguages, type ScratchLanguage } from "../model";
|
||||
import {
|
||||
chooserRows,
|
||||
pushRecent,
|
||||
scratchContent,
|
||||
scratchFileName,
|
||||
scratchLanguages,
|
||||
type ScratchLanguage,
|
||||
} from "../model";
|
||||
import { createScratch, setScratchBackend, tauriScratchBackend, type ScratchBackend } from "../scratches";
|
||||
import { useScratch } from "../store";
|
||||
import { ScratchChooserHost } from "../ScratchChooser";
|
||||
import { activate } from "..";
|
||||
|
||||
const LANGS: ScratchLanguage[] = scratchLanguages([
|
||||
{ id: "java", name: "Java", extensions: ["java"] },
|
||||
{
|
||||
id: "java",
|
||||
name: "Java",
|
||||
extensions: ["java"],
|
||||
scratchTemplate: "class Scratch {\n public static void main(String[] args) {\n $END$\n }\n}\n",
|
||||
},
|
||||
{ id: "python", name: "Python", extensions: ["py", "pyi"] },
|
||||
{ id: "javascript", name: "JavaScript", extensions: ["js", "mjs"] },
|
||||
{ id: "plaintext", name: "Plain Text", extensions: ["txt"] },
|
||||
@@ -27,6 +39,22 @@ describe("scratch naming", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("scratch content", () => {
|
||||
it("starts from the language's template with the caret at $END$", () => {
|
||||
const cases: [string | undefined, string, { line: number; col: number } | null][] = [
|
||||
[undefined, "", null],
|
||||
["", "", null],
|
||||
["fn main() {\n $END$\n}\n", "fn main() {\n \n}\n", { line: 1, col: 4 }],
|
||||
["package main\n\nfunc main() {\n\t$END$\n}\n", "package main\n\nfunc main() {\n\t\n}\n", { line: 3, col: 1 }],
|
||||
["$END$x", "x", { line: 0, col: 0 }],
|
||||
["// é\n$END$ $END$", "// é\n ", { line: 1, col: 0 }],
|
||||
["no caret\n", "no caret\n", null],
|
||||
];
|
||||
for (const [template, text, caret] of cases)
|
||||
expect(scratchContent(template), String(template)).toEqual({ text, caret });
|
||||
});
|
||||
});
|
||||
|
||||
describe("language chooser rows", () => {
|
||||
it("keeps languages with an extension, sorted by name, first extension wins", () => {
|
||||
expect(LANGS.map((l) => `${l.name}:${l.ext}`)).toEqual([
|
||||
@@ -62,20 +90,30 @@ describe("language chooser rows", () => {
|
||||
|
||||
describe("creating scratch files", () => {
|
||||
let files: Set<string>;
|
||||
let contents: Map<string, string>;
|
||||
let opened: string[];
|
||||
let carets: ({ line: number; col: number } | null)[];
|
||||
let backend: ScratchBackend;
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useScratch.setState({ languages: null, recent: [] });
|
||||
files = new Set(["/data/scratches/scratch.java"]);
|
||||
contents = new Map();
|
||||
opened = [];
|
||||
carets = [];
|
||||
backend = {
|
||||
dataDir: async () => "/data/",
|
||||
listNames: async (dir) => [...files].filter((f) => f.startsWith(dir + "/")).map((f) => f.slice(dir.length + 1)),
|
||||
createDir: async () => {},
|
||||
createFile: async (p) => void files.add(p),
|
||||
createFile: async (p, text) => {
|
||||
files.add(p);
|
||||
contents.set(p, text);
|
||||
},
|
||||
languages: async () => LANGS,
|
||||
open: async (p) => void opened.push(p),
|
||||
open: async (p, caret) => {
|
||||
opened.push(p);
|
||||
carets.push(caret);
|
||||
},
|
||||
};
|
||||
setScratchBackend(backend);
|
||||
});
|
||||
@@ -91,6 +129,16 @@ describe("creating scratch files", () => {
|
||||
expect(opened).toEqual(["/data/scratches/scratch_1.java"]);
|
||||
});
|
||||
|
||||
it("fills a new scratch from its language's template, caret in the body; others start empty", async () => {
|
||||
await createScratch(LANGS.find((l) => l.id === "java")!);
|
||||
await createScratch(LANGS.find((l) => l.id === "python")!);
|
||||
expect(contents.get("/data/scratches/scratch_1.java")).toBe(
|
||||
"class Scratch {\n public static void main(String[] args) {\n \n }\n}\n",
|
||||
);
|
||||
expect(contents.get("/data/scratches/scratch.py")).toBe("");
|
||||
expect(carets).toEqual([{ line: 2, col: 8 }, null]);
|
||||
});
|
||||
|
||||
it("registers the action and the Scratches root; the chooser creates the picked language", async () => {
|
||||
const dispose = activate();
|
||||
try {
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface ScratchLanguage {
|
||||
name: string;
|
||||
/** Extension (without the dot) new scratch files of this language get. */
|
||||
ext: string;
|
||||
/** Initial content; `$END$` marks where the caret goes. */
|
||||
template?: string;
|
||||
}
|
||||
|
||||
/** IDEA's names: `scratch.<ext>`, then `scratch_1.<ext>`, `scratch_2.<ext>`… */
|
||||
@@ -21,16 +23,34 @@ export function scratchFileName(existing: Iterable<string>, ext: string): string
|
||||
|
||||
/** Languages a scratch file can be created for (those with an extension), by name. */
|
||||
export function scratchLanguages(
|
||||
langs: ReadonlyArray<{ id: string; name: string; extensions: readonly string[] }>,
|
||||
langs: ReadonlyArray<{ id: string; name: string; extensions: readonly string[]; scratchTemplate?: string | null }>,
|
||||
): ScratchLanguage[] {
|
||||
const out: ScratchLanguage[] = [];
|
||||
for (const l of langs) {
|
||||
const ext = l.extensions.find((e) => e.trim())?.replace(/^\./, "");
|
||||
if (ext) out.push({ id: l.id, name: l.name, ext });
|
||||
if (ext) out.push({ id: l.id, name: l.name, ext, ...(l.scratchTemplate && { template: l.scratchTemplate }) });
|
||||
}
|
||||
return out.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
|
||||
}
|
||||
|
||||
const CARET = "$END$";
|
||||
|
||||
/** A new scratch file's text and caret (0-based line, UTF-16 column) from its language's template. */
|
||||
export function scratchContent(template: string | undefined): {
|
||||
text: string;
|
||||
caret: { line: number; col: number } | null;
|
||||
} {
|
||||
if (!template) return { text: "", caret: null };
|
||||
const at = template.indexOf(CARET);
|
||||
if (at < 0) return { text: template, caret: null };
|
||||
const before = template.slice(0, at);
|
||||
const lines = before.split("\n");
|
||||
return {
|
||||
text: before + template.slice(at + CARET.length).replaceAll(CARET, ""),
|
||||
caret: { line: lines.length - 1, col: lines[lines.length - 1]!.length },
|
||||
};
|
||||
}
|
||||
|
||||
export interface LanguageRow {
|
||||
lang: ScratchLanguage;
|
||||
matches: number[] | null;
|
||||
|
||||
@@ -6,16 +6,16 @@
|
||||
import { IpcError, call, commands, emitLocalIdeEvent, isTauri } from "@/platform/ipc";
|
||||
import { ipcPath } from "@/platform/paths";
|
||||
import { editorService, notifications } from "@/workbench/contributions";
|
||||
import { scratchFileName, scratchLanguages, type ScratchLanguage } from "./model";
|
||||
import { scratchContent, scratchFileName, scratchLanguages, type ScratchLanguage } from "./model";
|
||||
|
||||
export interface ScratchBackend {
|
||||
/** Absolute app data dir. */
|
||||
dataDir(): Promise<string>;
|
||||
listNames(dir: string): Promise<string[]>;
|
||||
createDir(dir: string): Promise<void>;
|
||||
createFile(path: string): Promise<void>;
|
||||
createFile(path: string, text: string): Promise<void>;
|
||||
languages(): Promise<ScratchLanguage[]>;
|
||||
open(path: string): Promise<void>;
|
||||
open(path: string, caret: { line: number; col: number } | null): Promise<void>;
|
||||
}
|
||||
|
||||
export const tauriScratchBackend: ScratchBackend = {
|
||||
@@ -26,9 +26,12 @@ export const tauriScratchBackend: ScratchBackend = {
|
||||
},
|
||||
listNames: async (dir) => (await call(commands.fsReadDir(dir))).map((e) => e.name),
|
||||
createDir: async (dir) => void (await call(commands.fsCreateDir(dir))),
|
||||
createFile: async (path) => void (await call(commands.fsCreateFile(path))),
|
||||
createFile: async (path, text) =>
|
||||
void (await call(
|
||||
text ? commands.fsCreateFileBytes(path, [...new TextEncoder().encode(text)]) : commands.fsCreateFile(path),
|
||||
)),
|
||||
languages: async () => scratchLanguages(await commands.languages()),
|
||||
open: (path) => editorService().openFile(path, { focus: true }),
|
||||
open: (path, caret) => editorService().openFile(path, { focus: true, ...(caret && { selection: caret }) }),
|
||||
};
|
||||
|
||||
let backend: ScratchBackend = tauriScratchBackend;
|
||||
@@ -64,16 +67,17 @@ export function scratchesDir(): Promise<string> {
|
||||
|
||||
export const scratchesAvailable = () => isTauri() || backend !== tauriScratchBackend;
|
||||
|
||||
/** Creates `scratch[_n].<ext>` for `lang` and opens it; returns its path. */
|
||||
/** Creates `scratch[_n].<ext>` for `lang` from its template and opens it; returns its path. */
|
||||
export async function createScratch(lang: ScratchLanguage): Promise<string | null> {
|
||||
try {
|
||||
const dir = await scratchesDir();
|
||||
const name = scratchFileName(await backend.listNames(dir), lang.ext);
|
||||
const path = `${dir}/${name}`;
|
||||
await backend.createFile(path);
|
||||
const { text, caret } = scratchContent(lang.template);
|
||||
await backend.createFile(path, text);
|
||||
// The file watcher covers the project only: tell the project view.
|
||||
emitLocalIdeEvent({ type: "fsChanged", created: [path], modified: [], removed: [] });
|
||||
await backend.open(path);
|
||||
await backend.open(path, caret);
|
||||
return path;
|
||||
} catch (e) {
|
||||
notifications().notify({
|
||||
|
||||
Reference in new issue
Заблокувати користувача