import { EditorState, EditorStateConfig, TransactionSpec, StateEffect, Text, Transaction, } from "@codemirror/state"; import { history } from "@codemirror/commands"; import { Editor } from "./editor"; import van, { State } from "vanjs-core"; import { WorkspaceFile } from "@codemirror/lsp-client"; import { inferLanguageFromPath } from "./lsp"; import { EditorView } from "@codemirror/view"; // export const openFiles: { [path: string]: OpenFile } = {}; export const openFiles: Map = new Map(); export class OpenFile implements WorkspaceFile { // Helper: find an open file instance by path static findOpenFile(path?: string): OpenFile | undefined { if (!path) return undefined; return openFiles.get(path); } filePath: State; editors: Editor[]; rootState: State; lastSaved: State; expectedDiskContent: State; knownDiskContent: State; diskDiscrepancyMessage: State; constructor(cfg: EditorStateConfig) { this.filePath = van.state(null); this.editors = []; this.rootState = van.state( EditorState.create(cfg).update({ effects: [StateEffect.appendConfig.of([history()])], }).state, ); this.lastSaved = van.state(this.rootState.val.doc); this.expectedDiskContent = van.state(null); this.knownDiskContent = van.state(null); // LSP version counter: starts at 1 when document is first created/opened this.version = 1; this.diskDiscrepancyMessage = van.derive(() => { const expected = this.expectedDiskContent.val; const known = this.knownDiskContent.val; if (known === null) { return "File has been removed from disk."; } else if (expected === null) { return "File has been created on disk."; } else if (expected !== known) { return "File has been changed on disk."; } return null; }); } static async openFile(filePath?: string): Promise { if (filePath && openFiles.has(filePath)) { return openFiles.get(filePath)!; } const { content, path } = await window.electronAPI.readFile(filePath); const file = new OpenFile({ doc: content }); file.expectedDiskContent.val = content; file.knownDiskContent.val = content; file.setPath(path); return file; } private setPath(path: string) { if (this.filePath.val) { openFiles.delete(this.filePath.val); } this.filePath.val = path; openFiles.set(path, this); // TODO: what if openFiles[path] already exists? } async saveFile() { if (this.filePath.val) { const doc = this.rootState.val.doc.toString(); await window.electronAPI.saveFile(doc, this.filePath.val); this.lastSaved.val = this.rootState.val.doc; this.expectedDiskContent.val = doc; // Notify LSP clients that the file was saved. The lsp plugin typically // listens to EditorView changes and save events; nudging the views // ensures any listeners pick up the final document state. this.notifyLspSave(); } else { await this.saveAs(); } } async saveAs(filePath?: string) { const doc = this.rootState.val.doc.toString(); const { path } = await window.electronAPI.saveFile(doc, filePath); this.setPath(path); this.lastSaved.val = this.rootState.val.doc; this.expectedDiskContent.val = doc; this.notifyLspSave(); } // Function to create and return a new EditorView for this file createEditor(): Editor { const editor = new Editor(this); this.editors.push(editor); return editor; } // Function to remove an editor and clean up if no more editors exist async removeEditor(editor: Editor, callback: () => void) { const index = this.editors.indexOf(editor); if (index == -1) return; // If this is the last editor and the file is dirty, confirm before closing if (this.editors.length === 1 && this.isDirty()) { const confirmed = await this.confirmClose(); if (!confirmed) { return; } } // Remove the editor from the list this.editors.splice(index, 1); // If no more editors, remove from openFiles dictionary if (this.editors.length === 0) { // Notify LSP that the document is closed this.notifyLspClose(); openFiles.delete(this.filePath.val); } callback(); } // Function to confirm closing of dirty file private async confirmClose(): Promise { const fileName = this.filePath.val ? this.filePath.val.split("/").pop() : "untitled"; // TODO: change message based on whether file exists on disk // e.g. if it was removed or changed const message = `Do you want to save the changes to ${fileName}?`; const result = await window.electronAPI.showConfirmDialog( message, "Save Changes?", ["Save", "Don't Save", "Cancel"], ); if (result === "Save") { await this.saveFile(); return true; } else if (result === "Don't Save") { return true; } else { return false; } } dispatch(trs: TransactionSpec, origin?: Editor) { const transaction = this.rootState.val.update(trs); this.rootState.val = transaction.state; // If the transaction introduced document changes, increment version if (transaction.changes && !transaction.changes.empty) { this.version = (this.version || 0) + 1; // TODO: call LSP didChange notification helper here } if (origin) { const es = this.editors.filter((e) => e !== origin); es.forEach((e) => e.dispatch(e.view.state.update(trs), true)); } else { this.editors.forEach((e) => { const changes = transaction.changes; const userEvent = transaction.annotation(Transaction.userEvent); const annotations = userEvent ? [Transaction.userEvent.of(userEvent)] : []; e.dispatch(e.view.state.update({ changes, annotations }), true); }); } } get target() { return { state: this.rootState.val, dispatch: (tr: TransactionSpec) => this.dispatch(tr), }; } isDirty(): boolean { return !this.lastSaved.val.eq(this.rootState.val.doc); } // LSP stuff version: number; get uri(): string | null { if (!this.filePath.val) return null; return `file://${this.filePath.val}`; } get languageId(): string { return inferLanguageFromPath(this.filePath.val || "") || ""; } get doc(): Text { return this.rootState.val.doc; } // Return an EditorView to be used by the LSP Workspace for position mapping. // If `main` is provided and belongs to this open file, return it. Otherwise // return the first available editor view, or null if none exist. getView(main?: EditorView): EditorView | null { if (main) { const found = this.editors.find((e) => e.view === main); if (found) return main; } if (this.editors.length > 0) return this.editors[0].view; return null; } // Lightweight helper to nudge LSP plugins on views after a save. This // triggers a no-op dispatch on each view so that any view-bound listeners // (including lsp-client's save/didSave handling) can observe the new state. notifyLspSave() { this.editors.forEach((e) => { try { // dispatch an empty transaction to trigger plugin observers e.view.dispatch({}); } catch (err) { console.warn("Failed to notify LSP of save for view:", err); } }); } notifyLspClose() { // Some language clients respond to EditorView disposal/transactions; to be // conservative, dispatch a no-op and then attempt to remove the LSP // extension from each view so the plugin can observe closure. this.editors.forEach((e) => { try { e.view.dispatch({}); // Attempt to remove the LSP compartment extension if available. // We cannot directly mutate another module's compartments here, // but leaving an empty dispatch is a safe, low-impact notification. } catch (err) { console.warn("Failed to notify LSP of close for view:", err); } }); } }