Файли
openidea/src/editor/core/backend.ts
T

404 рядки
14 KiB
TypeScript

/**
* The document backend as seen by editor/core. Production uses the Tauri IPC
* commands; tests and the plain-browser dev shell use the in-memory
* implementation, which follows the same versioning contract (every applied
* change set bumps the version by one; stale base versions are rejected with
* `outOfSync`).
*/
import {
call,
commands,
IpcError,
isTauri,
onIdeEvent,
type DocId,
type DocumentInfo,
type FoldRange,
type LanguageInfo_Serialize,
type LineEnding,
type OutlineItem,
type Range,
type TextChange,
} from "@/platform/ipc";
export type LanguageInfo = LanguageInfo_Serialize;
export interface DocPatchedEvent {
doc: DocId;
baseVersion: number;
version: number;
changes: TextChange[];
}
export interface DocBackend {
open(path: string): Promise<DocumentInfo>;
/** Opens the read-only virtual document behind a non-`file:` URI (library class contents). */
openVirtual(uri: string): Promise<DocumentInfo>;
close(doc: DocId): Promise<void>;
edit(doc: DocId, baseVersion: number, changes: TextChange[]): Promise<number>;
save(doc: DocId): Promise<void>;
snapshot(doc: DocId): Promise<DocumentInfo>;
setLineEnding(doc: DocId, lineEnding: LineEnding): Promise<void>;
/** "Reload with Encoding" (`reload`) or "Convert to Encoding"; resolves to the new encoding. */
setEncoding(doc: DocId, encoding: string, reload: boolean): Promise<string>;
outline(doc: DocId): Promise<OutlineItem[]>;
folding(doc: DocId): Promise<FoldRange[]>;
selectionRanges(doc: DocId, offset: number): Promise<Range[]>;
languages(): Promise<LanguageInfo[]>;
onPatched(handler: (e: DocPatchedEvent) => void): () => void;
onDirty(handler: (e: { doc: DocId; dirty: boolean }) => void): () => void;
/** Files changed on disk (used to refresh nothing but closed-file state). */
onFsChanged?(handler: (e: { removed: string[] }) => void): () => void;
}
export const ipcBackend: DocBackend = {
open: (path) => call(commands.docOpen(path)),
openVirtual: (uri) => call(commands.docOpenVirtual(uri)),
close: async (doc) => void (await call(commands.docClose(doc))),
edit: (doc, baseVersion, changes) => call(commands.docEdit(doc, baseVersion, { changes })),
save: async (doc) => void (await call(commands.docSave(doc))),
snapshot: (doc) => call(commands.docSnapshot(doc)),
setLineEnding: async (doc, le) => void (await call(commands.docSetLineEnding(doc, le))),
setEncoding: (doc, encoding, reload) => call(commands.docSetEncoding(doc, encoding, reload)),
outline: (doc) => call(commands.docOutline(doc)),
folding: (doc) => call(commands.docFolding(doc)),
selectionRanges: (doc, offset) => call(commands.docSelectionRanges(doc, offset)),
languages: () => commands.languages(),
onPatched: (h) => onIdeEvent("docPatched", (e) => h(e)),
onDirty: (h) => onIdeEvent("docDirty", (e) => h(e)),
onFsChanged: (h) => onIdeEvent("fsChanged", (e) => h(e)),
};
// ---------------------------------------------------------------------------
// In-memory backend
interface MemDoc {
id: DocId;
path: string;
text: string;
saved: string;
version: number;
refs: number;
lineEnding: LineEnding;
readOnly: boolean;
language: string;
uri?: string;
}
const EXT_LANG: Record<string, string> = {
ts: "typescript", tsx: "typescriptreact", js: "javascript", jsx: "javascriptreact", rs: "rust", kt: "kotlin",
kts: "kotlin", java: "java", py: "python", go: "go", json: "json", md: "markdown", css: "css", html: "html",
yml: "yaml", yaml: "yaml", toml: "toml", c: "c", cpp: "cpp", h: "c", sh: "shellscript", sql: "sql", xml: "xml",
};
const MEM_LANGUAGES: LanguageInfo[] = [
{ id: "typescript", name: "TypeScript", extensions: ["ts"], uiMode: "TypeScript", lineComment: "//", blockComment: ["/*", "*/"], indent: { tabSize: 2, insertSpaces: true } },
{ id: "typescriptreact", name: "TypeScript JSX", extensions: ["tsx"], uiMode: "TSX", lineComment: "//", blockComment: ["/*", "*/"], indent: { tabSize: 2, insertSpaces: true } },
{ id: "javascript", name: "JavaScript", extensions: ["js"], uiMode: "JavaScript", lineComment: "//", blockComment: ["/*", "*/"], indent: { tabSize: 2, insertSpaces: true } },
{ id: "rust", name: "Rust", extensions: ["rs"], uiMode: "Rust", lineComment: "//", blockComment: ["/*", "*/"], indent: null },
{ id: "kotlin", name: "Kotlin", extensions: ["kt", "kts"], uiMode: "Kotlin", lineComment: "//", blockComment: ["/*", "*/"], indent: null },
{ id: "java", name: "Java", extensions: ["java"], uiMode: "Java", lineComment: "//", blockComment: ["/*", "*/"], indent: null },
{ id: "python", name: "Python", extensions: ["py"], uiMode: "Python", lineComment: "#", blockComment: null, indent: null },
{ id: "json", name: "JSON", extensions: ["json"], uiMode: "JSON", lineComment: null, blockComment: null, indent: { tabSize: 2, insertSpaces: true } },
{ id: "markdown", name: "Markdown", extensions: ["md"], uiMode: "Markdown", lineComment: null, blockComment: ["<!--", "-->"], indent: null },
{ id: "yaml", name: "YAML", extensions: ["yml", "yaml"], uiMode: "YAML", lineComment: "#", blockComment: null, indent: { tabSize: 2, insertSpaces: true } },
{ id: "plaintext", name: "Plain Text", extensions: ["txt"], uiMode: null, lineComment: null, blockComment: null, indent: null },
];
const SAMPLE_KOTLIN = `package com.acme.orders
import org.springframework.web.bind.annotation.*
import com.acme.orders.service.OrderService
@RestController
@RequestMapping("/api/orders")
class OrderController(private val service: OrderService) {
// Paged order lookup, optionally filtered by status
@GetMapping
fun list(
@RequestParam status: OrderStatus?,
@RequestParam page: Int = 0,
@RequestParam size: Int = 20
): Page<OrderDto> =
service.find(status, PageRequest(page, size)).map { it.toDto() }
@PostMapping
fun create(@RequestBody req: CreateOrderRequest): OrderDto {
val order: Order = service.create(req)
return order.toDto()
}
}
`;
function applyChanges(text: string, changes: TextChange[]): string {
let out = "";
let pos = 0;
for (const c of changes) {
out += text.slice(pos, c.from) + c.insert;
pos = c.to;
}
return out + text.slice(pos);
}
function sampleFor(path: string): string {
if (/\.kts?$/.test(path)) return SAMPLE_KOTLIN;
return `// ${path}\n`;
}
/** Offset → 0-based line/col, using a precomputed table of line starts. */
function lineIndex(text: string) {
const starts = [0];
for (let i = text.indexOf("\n"); i >= 0; i = text.indexOf("\n", i + 1)) starts.push(i + 1);
return (offset: number) => {
let lo = 0;
let hi = starts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (starts[mid]! <= offset) lo = mid;
else hi = mid - 1;
}
return { line: lo, col: offset - starts[lo]! };
};
}
export interface MemoryBackend extends DocBackend {
/** Test/dev hook: apply a backend-originated change and emit `docPatched`. */
patch(doc: DocId, changes: TextChange[]): void;
/** Seed file contents before opening. */
files: Map<string, string>;
docs: Map<DocId, MemDoc>;
/** Simulated latency for `edit` (ms); 0 resolves in a microtask. */
latency: number;
}
export function createMemoryBackend(files: Record<string, string> = {}): MemoryBackend {
const docs = new Map<DocId, MemDoc>();
const byPath = new Map<string, DocId>();
const patched = new Set<(e: DocPatchedEvent) => void>();
const dirtyHandlers = new Set<(e: { doc: DocId; dirty: boolean }) => void>();
let nextId = 1;
const store = new Map(Object.entries(files));
const info = (d: MemDoc): DocumentInfo => ({
id: d.id,
path: d.path,
text: d.text,
version: d.version,
language: d.language,
encoding: "UTF-8",
lineEnding: d.lineEnding,
readOnly: d.readOnly,
dirty: d.text !== d.saved,
...(d.uri ? { uri: d.uri } : {}),
});
const get = (doc: DocId) => {
const d = docs.get(doc);
if (!d) throw new IpcError({ kind: "notFound", message: `document ${doc} is not open` });
return d;
};
const setDirty = (d: MemDoc, before: boolean) => {
const now = d.text !== d.saved;
if (now !== before) dirtyHandlers.forEach((h) => h({ doc: d.id, dirty: now }));
};
const delay = <T>(v: () => T): Promise<T> =>
backend.latency > 0
? new Promise((resolve, reject) =>
setTimeout(() => {
try {
resolve(v());
} catch (e) {
reject(e);
}
}, backend.latency),
)
: Promise.resolve().then(v);
const backend: MemoryBackend = {
files: store,
docs,
latency: 0,
async open(path) {
const existing = byPath.get(path);
if (existing !== undefined) {
const d = get(existing);
d.refs++;
return info(d);
}
const text = store.get(path) ?? sampleFor(path);
const ext = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
const d: MemDoc = {
id: nextId++,
path,
text,
saved: text,
version: 0,
refs: 1,
lineEnding: "lf",
readOnly: false,
language: EXT_LANG[ext] ?? "plaintext",
};
docs.set(d.id, d);
byPath.set(path, d.id);
return info(d);
},
async openVirtual(uri) {
const existing = byPath.get(uri);
if (existing !== undefined) {
const d = get(existing);
d.refs++;
return info(d);
}
const text = store.get(uri);
if (text === undefined) throw new IpcError({ kind: "notFound", message: `no contents for ${uri}` });
const name = uri.split(/[?#]/)[0]!;
const ext = name.slice(name.lastIndexOf(".") + 1).toLowerCase();
const d: MemDoc = {
id: nextId++,
path: uri,
text,
saved: text,
version: 0,
refs: 1,
lineEnding: "lf",
readOnly: true,
language: ext === "class" ? "java" : (EXT_LANG[ext] ?? "plaintext"),
uri,
};
docs.set(d.id, d);
byPath.set(uri, d.id);
return info(d);
},
async close(doc) {
const d = docs.get(doc);
if (!d || --d.refs > 0) return;
docs.delete(doc);
byPath.delete(d.path);
},
edit(doc, baseVersion, changes) {
return delay(() => {
const d = get(doc);
if (d.readOnly) throw new IpcError({ kind: "permissionDenied", message: "read-only" });
if (baseVersion !== d.version)
throw new IpcError({ kind: "outOfSync", message: `base ${baseVersion}, document at ${d.version}` });
const before = d.text !== d.saved;
d.text = applyChanges(d.text, changes);
d.version++;
setDirty(d, before);
return d.version;
});
},
async save(doc) {
const d = get(doc);
const before = d.text !== d.saved;
d.saved = d.text;
store.set(d.path, d.text);
setDirty(d, before);
},
async snapshot(doc) {
return info(get(doc));
},
async setLineEnding(doc, le) {
get(doc).lineEnding = le;
},
async setEncoding(_doc, encoding) {
return encoding;
},
async outline(doc) {
// Brace-based outline good enough for the dev shell.
const d = get(doc);
const posAt = ((index) => (_text: string, offset: number) => index(offset))(lineIndex(d.text));
const items: OutlineItem[] = [];
const re = /\b(class|fun|function|fn|interface|object)\s+([A-Za-z_]\w*)/g;
for (let m = re.exec(d.text); m; m = re.exec(d.text)) {
const start = posAt(d.text, m.index);
const nameStart = posAt(d.text, m.index + m[0].length - m[2]!.length);
const open = d.text.indexOf("{", m.index);
let end = d.text.length;
if (open >= 0) {
let depth = 0;
for (let i = open; i < d.text.length; i++) {
if (d.text[i] === "{") depth++;
else if (d.text[i] === "}" && --depth === 0) {
end = i + 1;
break;
}
}
}
const item: OutlineItem = {
name: m[1] === "class" || m[1] === "interface" || m[1] === "object" ? m[2]! : `${m[2]}()`,
detail: null,
kind: m[1] === "class" ? "class" : m[1] === "interface" ? "interface" : "function",
range: { start, end: posAt(d.text, end) },
selection_range: { start: nameStart, end: { line: nameStart.line, col: nameStart.col + m[2]!.length } },
children: [],
};
const parent = items.findLast(
(p) =>
(p.range.start.line < start.line || (p.range.start.line === start.line && p.range.start.col <= start.col)) &&
(p.range.end.line > start.line || (p.range.end.line === start.line && p.range.end.col >= start.col)),
);
if (parent) parent.children.push(item);
else items.push(item);
}
return items;
},
async folding(doc) {
const d = get(doc);
const out: FoldRange[] = [];
const seen = new Set<number>();
const stack: number[] = [];
d.text.split("\n").forEach((line, i) => {
for (const ch of line) {
if (ch === "{" || ch === "(" || ch === "[") stack.push(i);
else if (ch === "}" || ch === ")" || ch === "]") {
const s = stack.pop();
if (s !== undefined && i > s && !seen.has(s)) {
seen.add(s);
out.push({ start_line: s, end_line: i, kind: "region" });
}
}
}
});
return out.sort((a, b) => a.start_line - b.start_line);
},
async selectionRanges() {
return [];
},
async languages() {
return MEM_LANGUAGES;
},
onPatched(h) {
patched.add(h);
return () => patched.delete(h);
},
onDirty(h) {
dirtyHandlers.add(h);
return () => dirtyHandlers.delete(h);
},
patch(doc, changes) {
const d = get(doc);
const before = d.text !== d.saved;
const baseVersion = d.version;
d.text = applyChanges(d.text, changes);
d.version++;
const e = { doc, baseVersion, version: d.version, changes };
patched.forEach((h) => h(e));
setDirty(d, before);
},
};
return backend;
}
let current: DocBackend | null = null;
/** The backend in use: IPC inside Tauri, in-memory in a plain browser. */
export function docBackend(): DocBackend {
return (current ??= isTauri() ? ipcBackend : createMemoryBackend());
}
/** Test hook. */
export function _setDocBackend(b: DocBackend | null) {
current = b;
}