diff --git a/crates/ide-dap/src/tests.rs b/crates/ide-dap/src/tests.rs index 7f5930b36..996c6f68e 100644 --- a/crates/ide-dap/src/tests.rs +++ b/crates/ide-dap/src/tests.rs @@ -923,6 +923,7 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc, + /// Content of a new scratch file; `$END$` marks where the caret goes. + #[serde(default)] + pub scratch_template: Option, } /// An external formatting tool and its language-specific parser. @@ -242,6 +245,10 @@ pub struct LanguageInfo { #[serde(default)] #[specta(optional)] pub first_line: Option, + /// Content of a new scratch file; `$END$` marks where the caret goes. + #[serde(default)] + #[specta(optional)] + pub scratch_template: Option, } 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(), } } } diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index 832645955..aff9d7de2 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -772,6 +772,10 @@ mod tests { assert!(addr.parse::().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"); diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 324876666..cf7d0fd86 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -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() diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index 35fa33148..533ce79d0 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -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, diff --git a/languages/c.toml b/languages/c.toml index 06da28f9d..436454ed5 100644 --- a/languages/c.toml +++ b/languages/c.toml @@ -6,6 +6,14 @@ block-comment = ["/*", "*/"] indent = { tab-size = 4, insert-spaces = true } ui-mode = "C" tree-sitter = "c" +scratch-template = ''' +#include + +int main(void) { + $END$ + return 0; +} +''' [[language-servers]] name = "clangd" diff --git a/languages/cpp.toml b/languages/cpp.toml index f9e4589c2..16802dc10 100644 --- a/languages/cpp.toml +++ b/languages/cpp.toml @@ -6,6 +6,14 @@ block-comment = ["/*", "*/"] indent = { tab-size = 4, insert-spaces = true } ui-mode = "C++" tree-sitter = "cpp" +scratch-template = ''' +#include + +int main() { + $END$ + return 0; +} +''' [[language-servers]] name = "clangd" diff --git a/languages/go.toml b/languages/go.toml index 6d820dd53..30dbabb6c 100644 --- a/languages/go.toml +++ b/languages/go.toml @@ -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" diff --git a/languages/java.toml b/languages/java.toml index 7e7837b08..af9967e9c 100644 --- a/languages/java.toml +++ b/languages/java.toml @@ -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 diff --git a/languages/kotlin.toml b/languages/kotlin.toml index 212bb9505..097eafcd3 100644 --- a/languages/kotlin.toml +++ b/languages/kotlin.toml @@ -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" diff --git a/languages/rust.toml b/languages/rust.toml index 61bb1dc1e..fcd712e3e 100644 --- a/languages/rust.toml +++ b/languages/rust.toml @@ -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" diff --git a/src/bindings.ts b/src/bindings.ts index 12e9b9368..72ac50c86 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -4534,6 +4534,8 @@ export type LanguageInfo_Deserialize = { filenames?: string[], /** First-line regex of the definition (shebangs, ` { }); }); +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; + let contents: Map; 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 { diff --git a/src/features/scratch/model.ts b/src/features/scratch/model.ts index f559271e4..be6e4ea5a 100644 --- a/src/features/scratch/model.ts +++ b/src/features/scratch/model.ts @@ -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.`, then `scratch_1.`, `scratch_2.`… */ @@ -21,16 +23,34 @@ export function scratchFileName(existing: Iterable, 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; diff --git a/src/features/scratch/scratches.ts b/src/features/scratch/scratches.ts index 864f5cfc6..ef552c487 100644 --- a/src/features/scratch/scratches.ts +++ b/src/features/scratch/scratches.ts @@ -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; listNames(dir: string): Promise; createDir(dir: string): Promise; - createFile(path: string): Promise; + createFile(path: string, text: string): Promise; languages(): Promise; - open(path: string): Promise; + open(path: string, caret: { line: number; col: number } | null): Promise; } 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 { export const scratchesAvailable = () => isTauri() || backend !== tauriScratchBackend; -/** Creates `scratch[_n].` for `lang` and opens it; returns its path. */ +/** Creates `scratch[_n].` for `lang` from its template and opens it; returns its path. */ export async function createScratch(lang: ScratchLanguage): Promise { 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({