diff --git a/src/app/editor.ts b/src/app/editor.ts index 7b0850c..823dad7 100644 --- a/src/app/editor.ts +++ b/src/app/editor.ts @@ -1,4 +1,10 @@ -import { Transaction, Compartment, Extension } from "@codemirror/state"; +import { + Transaction, + Compartment, + Extension, + StateEffect, + StateField, +} from "@codemirror/state"; import { EditorView, keymap, @@ -10,6 +16,7 @@ import { dropCursor, rectangularSelection, crosshairCursor, + showPanel, } from "@codemirror/view"; import { defaultKeymap, undo, redo } from "@codemirror/commands"; import { oneDark } from "@codemirror/theme-one-dark"; @@ -41,6 +48,29 @@ const fixedHeightEditor = EditorView.theme({ ".cm-scroller": { overflow: "auto scroll" }, }); +const FileStatusEffect = StateEffect.define(); +const FileStatusField = StateField.define({ + create: () => null, + update(value, tr): string | null { + for (const effect of tr.effects) { + if (effect.is(FileStatusEffect)) { + value = effect.value; + } + } + return value; + }, + provide: (f) => + showPanel.from(f, (state) => (state ? fileStatusPanel : null)), +}); + +function fileStatusPanel(view: EditorView) { + const dom = document.createElement("div"); + dom.textContent = view.state.field(FileStatusField); + dom.style = + "padding: 2px 10px; font-size: 12px; color: white; background: #900;"; + return { top: true, dom }; +} + export class Editor implements Displayable { view: EditorView; file: OpenFile; @@ -92,6 +122,8 @@ export class Editor implements Displayable { oneDark, fixedHeightEditor, kmap, + FileStatusField, + this.wordWrapCompartment.of(EditorView.lineWrapping), this.languageCompartment.of([]), lineNumbers(), @@ -122,6 +154,14 @@ export class Editor implements Displayable { this.view.dispatch({ effects: [eff] }); }); }); + + van.derive(() => { + const effects = FileStatusEffect.of( + file.diskDiscrepancyMessage.val, + ); + const tr = this.view.state.update({ effects }); + this.dispatch(tr, true); + }); } get dom() { diff --git a/src/app/filestate.ts b/src/app/filestate.ts index 7867416..3e035a4 100644 --- a/src/app/filestate.ts +++ b/src/app/filestate.ts @@ -13,10 +13,18 @@ import van, { State } from "vanjs-core"; const openFiles: { [path: string]: OpenFile } = {}; export class OpenFile { + // Helper: find an open file instance by path + static findOpenFile(path?: string): OpenFile | undefined { + if (!path) return undefined; + return openFiles[path]; + } filePath: State; editors: Editor[]; rootState: State; - lastSaved?: State; + lastSaved: State; + expectedDiskContent: State; + knownDiskContent: State; + diskDiscrepancyMessage: State; constructor(cfg: EditorStateConfig) { this.filePath = van.state(null); @@ -27,6 +35,21 @@ export class OpenFile { }).state, ); this.lastSaved = van.state(this.rootState.val.doc); + this.expectedDiskContent = van.state(null); + this.knownDiskContent = van.state(null); + + 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 { @@ -35,12 +58,16 @@ export class OpenFile { } 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) { - delete openFiles[this.filePath.val]; + if (this.filePath.val) { + delete openFiles[this.filePath.val]; + } this.filePath.val = path; openFiles[path] = this; // TODO: what if openFiles[path] already exists? @@ -48,23 +75,21 @@ export class OpenFile { async saveFile() { if (this.filePath.val) { - await window.electronAPI.saveFile( - this.rootState.val.doc.toString(), - 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; } else { await this.saveAs(); } } async saveAs(filePath?: string) { - const { path } = await window.electronAPI.saveFile( - this.rootState.val.doc.toString(), - filePath, - ); + 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; } // Function to create and return a new EditorView for this file @@ -103,6 +128,8 @@ export class OpenFile { 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, diff --git a/src/app/foldernav.ts b/src/app/foldernav.ts index 98e584e..a0329e3 100644 --- a/src/app/foldernav.ts +++ b/src/app/foldernav.ts @@ -14,6 +14,48 @@ async function openFolder() { folderTreeState.val = folderTree; } +// Refresh the current folder tree from main (re-open) +async function refreshFolder() { + const folderTree = await window.electronAPI.getWorkspaceTree().catch(alert); + if (!folderTree) return; + folderTreeState.val = folderTree; +} + +// Subscribe to filesystem events and refresh tree when directories change +window.electronAPI.onFsEvent(async (ev: { event: string; path: string }) => { + // If no workspace is loaded ignore + if (!folderTreeState.val) return; + const workspaceRoot = folderTreeState.val.path; + if (!ev.path.startsWith(workspaceRoot)) return; + + // For directory-level changes or create/unlink/rename, refresh the tree + if ( + ev.event === "addDir" || + ev.event === "unlinkDir" || + ev.event === "add" || + ev.event === "unlink" + ) { + // Debounce-ish: schedule a refresh + setTimeout(() => refreshFolder(), 50); + } + + // If a file changed on disk and it's open, show disk version panels + if (ev.event === "change" || ev.event === "add" || ev.event === "unlink") { + const openFile = OpenFile.findOpenFile(ev.path); + if (!openFile) return; + // Read latest contents from disk + const data = await window.electronAPI + .readFile(ev.path) + .catch(() => null); + if (!data) return; + if (ev.event === "unlink") { + openFile.knownDiskContent.val = null; + } else { + openFile.knownDiskContent.val = data.content; + } + } +}); + export const FolderTreeView = () => { if (!folderTreeState.val) { return v.div( diff --git a/src/main/fileOperations.ts b/src/main/fileOperations.ts index 3a40759..d7f4147 100644 --- a/src/main/fileOperations.ts +++ b/src/main/fileOperations.ts @@ -19,6 +19,28 @@ type FolderTree = { let currentWorkspaceRoot: string | null = null; let watcher: chokidar.FSWatcher | null = null; +// Helper to (re)create watcher and wire up IPC notifications to renderer +function ensureWatcher() { + if (watcher) return watcher; + watcher = chokidar.watch([], { ignoreInitial: true }); + + watcher.on("all", (event, filePath) => { + console.log("chokidar", event, filePath); + // Broadcast to all renderer windows + try { + BrowserWindow.getAllWindows().forEach((w) => + w.webContents.send("fs:event", { event, path: filePath }), + ); + } catch (err) { + console.error("Failed to send fs:event to renderer:", err); + } + }); + + watcher.on("error", (err) => console.error("Watcher error:", err)); + + return watcher; +} + // Track previously opened files outside the workspace const openedFiles = new Set(); @@ -58,12 +80,9 @@ export async function handleOpenFolder( if (!result.canceled && result.filePaths.length > 0) { const folderPath = result.filePaths[0]; currentWorkspaceRoot = folderPath; // Track the opened folder - watcher = chokidar.watch(folderPath, { - ignoreInitial: true, - }); - watcher.on("all", (event, path) => { - console.log("chokidar", event, path); - }); + // Ensure watcher exists and add this folder to it + const w = ensureWatcher(); + w.add(folderPath); return { name: path.basename(folderPath), @@ -284,6 +303,17 @@ export function getCurrentWorkspace(): { root: string | null } { return { root: currentWorkspaceRoot }; } +// Return a fresh FolderTree for the current workspace (no dialogs) +export async function getWorkspaceTree(): Promise { + if (!currentWorkspaceRoot) return null; + return { + name: path.basename(currentWorkspaceRoot), + path: currentWorkspaceRoot, + type: "directory", + children: await readTree(currentWorkspaceRoot), + }; +} + // Utility function to get opened files (for debugging/info) export function getOpenedFiles(): string[] { return Array.from(openedFiles); diff --git a/src/main/main.ts b/src/main/main.ts index 5b0384a..eb52403 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -7,6 +7,7 @@ import { getCurrentWorkspace, getOpenedFiles, showConfirmDialog, + getWorkspaceTree, } from "./fileOperations"; import { terminalManager } from "./pty"; import path from "node:path"; @@ -83,6 +84,11 @@ app.whenReady().then(() => { return getOpenedFiles(); }); + // Return folder tree without showing dialogs + ipcMain.handle("workspace:getTree", async () => { + return await getWorkspaceTree(); + }); + ipcMain.handle( "dialog:confirm", async (event, message: string, title: string, buttons: string[]) => { diff --git a/src/preload.ts b/src/preload.ts index 56b24dd..63b4c69 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -55,6 +55,10 @@ contextBridge.exposeInMainWorld("electronAPI", { getOpenedFiles: () => ipcRenderer.invoke("workspace:getOpenedFiles") as Promise, + // Get the full workspace tree without triggering dialogs + getWorkspaceTree: () => + ipcRenderer.invoke("workspace:getTree") as Promise, + showConfirmDialog: (message: string, title: string, buttons: string[]) => ipcRenderer.invoke( "dialog:confirm", @@ -91,4 +95,14 @@ contextBridge.exposeInMainWorld("electronAPI", { terminalExitCallbacks.set(id, callback); return () => terminalExitCallbacks.delete(id); }, + + // FS events subscription + onFsEvent: (callback: (ev: { event: string; path: string }) => void) => { + ipcRenderer.on( + "fs:event", + (_ev, payload: { event: string; path: string }) => { + callback(payload); + }, + ); + }, }); diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 6470c2c..b54cb18 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -27,6 +27,8 @@ declare global { // Workspace info getCurrentWorkspace: () => Promise<{ root: string | null }>; getOpenedFiles: () => Promise; + // Get workspace tree without dialogs + getWorkspaceTree: () => Promise; // Dialog operations showConfirmDialog: ( @@ -56,6 +58,10 @@ declare global { callback: (exitCode: number) => void, ) => () => void; removeAllTerminalListeners: () => void; + // Filesystem events + onFsEvent: ( + callback: (ev: { event: string; path: string }) => void, + ) => void; }; } }