miller/src/app/filestate.ts

254 lines
8.9 KiB
TypeScript

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<string, OpenFile> = 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<string>;
editors: Editor[];
rootState: State<EditorState>;
lastSaved: State<Text>;
expectedDiskContent: State<string | null>;
knownDiskContent: State<string | null>;
diskDiscrepancyMessage: State<string | null>;
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<OpenFile> {
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<boolean> {
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);
}
});
}
}