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.
Цей коміт міститься в:
l0ng-ai committed 2026-11-04 21:09:12 +08:00
1 джерело 52360c72d2
коміт 3100aa2c00
15 files changed
+145 -14

No files matched your search

+1
Переглянути файл
@@ -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 {
+8
Переглянути файл
@@ -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(),
}
}
}
+4
Переглянути файл
@@ -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");
+1
Переглянути файл
@@ -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()
+1
Переглянути файл
@@ -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,
+8
Переглянути файл
@@ -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"
+8
Переглянути файл
@@ -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"
+7
Переглянути файл
@@ -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"
+7
Переглянути файл
@@ -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
+5
Переглянути файл
@@ -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"
+5
Переглянути файл
@@ -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"
+4
Переглянути файл
@@ -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;
+52 -4
Переглянути файл
@@ -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 {
+22 -2
Переглянути файл
@@ -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;
+12 -8
Переглянути файл
@@ -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({