Implement basic file and folder watching

This commit is contained in:
Quinten Kock 2025-11-26 13:33:10 +01:00
parent 238ca8c812
commit d020f41b89
7 changed files with 182 additions and 17 deletions

View File

@ -1,4 +1,10 @@
import { Transaction, Compartment, Extension } from "@codemirror/state"; import {
Transaction,
Compartment,
Extension,
StateEffect,
StateField,
} from "@codemirror/state";
import { import {
EditorView, EditorView,
keymap, keymap,
@ -10,6 +16,7 @@ import {
dropCursor, dropCursor,
rectangularSelection, rectangularSelection,
crosshairCursor, crosshairCursor,
showPanel,
} from "@codemirror/view"; } from "@codemirror/view";
import { defaultKeymap, undo, redo } from "@codemirror/commands"; import { defaultKeymap, undo, redo } from "@codemirror/commands";
import { oneDark } from "@codemirror/theme-one-dark"; import { oneDark } from "@codemirror/theme-one-dark";
@ -41,6 +48,29 @@ const fixedHeightEditor = EditorView.theme({
".cm-scroller": { overflow: "auto scroll" }, ".cm-scroller": { overflow: "auto scroll" },
}); });
const FileStatusEffect = StateEffect.define<string | null>();
const FileStatusField = StateField.define<string | null>({
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 { export class Editor implements Displayable {
view: EditorView; view: EditorView;
file: OpenFile; file: OpenFile;
@ -92,6 +122,8 @@ export class Editor implements Displayable {
oneDark, oneDark,
fixedHeightEditor, fixedHeightEditor,
kmap, kmap,
FileStatusField,
this.wordWrapCompartment.of(EditorView.lineWrapping), this.wordWrapCompartment.of(EditorView.lineWrapping),
this.languageCompartment.of([]), this.languageCompartment.of([]),
lineNumbers(), lineNumbers(),
@ -122,6 +154,14 @@ export class Editor implements Displayable {
this.view.dispatch({ effects: [eff] }); 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() { get dom() {

View File

@ -13,10 +13,18 @@ import van, { State } from "vanjs-core";
const openFiles: { [path: string]: OpenFile } = {}; const openFiles: { [path: string]: OpenFile } = {};
export class 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<string>; filePath: State<string>;
editors: Editor[]; editors: Editor[];
rootState: State<EditorState>; rootState: State<EditorState>;
lastSaved?: State<Text>; lastSaved: State<Text>;
expectedDiskContent: State<string | null>;
knownDiskContent: State<string | null>;
diskDiscrepancyMessage: State<string | null>;
constructor(cfg: EditorStateConfig) { constructor(cfg: EditorStateConfig) {
this.filePath = van.state(null); this.filePath = van.state(null);
@ -27,6 +35,21 @@ export class OpenFile {
}).state, }).state,
); );
this.lastSaved = van.state(this.rootState.val.doc); 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<OpenFile> { static async openFile(filePath?: string): Promise<OpenFile> {
@ -35,12 +58,16 @@ export class OpenFile {
} }
const { content, path } = await window.electronAPI.readFile(filePath); const { content, path } = await window.electronAPI.readFile(filePath);
const file = new OpenFile({ doc: content }); const file = new OpenFile({ doc: content });
file.expectedDiskContent.val = content;
file.knownDiskContent.val = content;
file.setPath(path); file.setPath(path);
return file; return file;
} }
private setPath(path: string) { private setPath(path: string) {
if (this.filePath.val) {
delete openFiles[this.filePath.val]; delete openFiles[this.filePath.val];
}
this.filePath.val = path; this.filePath.val = path;
openFiles[path] = this; openFiles[path] = this;
// TODO: what if openFiles[path] already exists? // TODO: what if openFiles[path] already exists?
@ -48,23 +75,21 @@ export class OpenFile {
async saveFile() { async saveFile() {
if (this.filePath.val) { if (this.filePath.val) {
await window.electronAPI.saveFile( const doc = this.rootState.val.doc.toString();
this.rootState.val.doc.toString(), await window.electronAPI.saveFile(doc, this.filePath.val);
this.filePath.val,
);
this.lastSaved.val = this.rootState.val.doc; this.lastSaved.val = this.rootState.val.doc;
this.expectedDiskContent.val = doc;
} else { } else {
await this.saveAs(); await this.saveAs();
} }
} }
async saveAs(filePath?: string) { async saveAs(filePath?: string) {
const { path } = await window.electronAPI.saveFile( const doc = this.rootState.val.doc.toString();
this.rootState.val.doc.toString(), const { path } = await window.electronAPI.saveFile(doc, filePath);
filePath,
);
this.setPath(path); this.setPath(path);
this.lastSaved.val = this.rootState.val.doc; this.lastSaved.val = this.rootState.val.doc;
this.expectedDiskContent.val = doc;
} }
// Function to create and return a new EditorView for this file // Function to create and return a new EditorView for this file
@ -103,6 +128,8 @@ export class OpenFile {
const fileName = this.filePath.val const fileName = this.filePath.val
? this.filePath.val.split("/").pop() ? this.filePath.val.split("/").pop()
: "untitled"; : "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 message = `Do you want to save the changes to ${fileName}?`;
const result = await window.electronAPI.showConfirmDialog( const result = await window.electronAPI.showConfirmDialog(
message, message,

View File

@ -14,6 +14,48 @@ async function openFolder() {
folderTreeState.val = folderTree; 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 = () => { export const FolderTreeView = () => {
if (!folderTreeState.val) { if (!folderTreeState.val) {
return v.div( return v.div(

View File

@ -19,6 +19,28 @@ type FolderTree = {
let currentWorkspaceRoot: string | null = null; let currentWorkspaceRoot: string | null = null;
let watcher: chokidar.FSWatcher | 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 // Track previously opened files outside the workspace
const openedFiles = new Set<string>(); const openedFiles = new Set<string>();
@ -58,12 +80,9 @@ export async function handleOpenFolder(
if (!result.canceled && result.filePaths.length > 0) { if (!result.canceled && result.filePaths.length > 0) {
const folderPath = result.filePaths[0]; const folderPath = result.filePaths[0];
currentWorkspaceRoot = folderPath; // Track the opened folder currentWorkspaceRoot = folderPath; // Track the opened folder
watcher = chokidar.watch(folderPath, { // Ensure watcher exists and add this folder to it
ignoreInitial: true, const w = ensureWatcher();
}); w.add(folderPath);
watcher.on("all", (event, path) => {
console.log("chokidar", event, path);
});
return { return {
name: path.basename(folderPath), name: path.basename(folderPath),
@ -284,6 +303,17 @@ export function getCurrentWorkspace(): { root: string | null } {
return { root: currentWorkspaceRoot }; return { root: currentWorkspaceRoot };
} }
// Return a fresh FolderTree for the current workspace (no dialogs)
export async function getWorkspaceTree(): Promise<FolderTree | null> {
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) // Utility function to get opened files (for debugging/info)
export function getOpenedFiles(): string[] { export function getOpenedFiles(): string[] {
return Array.from(openedFiles); return Array.from(openedFiles);

View File

@ -7,6 +7,7 @@ import {
getCurrentWorkspace, getCurrentWorkspace,
getOpenedFiles, getOpenedFiles,
showConfirmDialog, showConfirmDialog,
getWorkspaceTree,
} from "./fileOperations"; } from "./fileOperations";
import { terminalManager } from "./pty"; import { terminalManager } from "./pty";
import path from "node:path"; import path from "node:path";
@ -83,6 +84,11 @@ app.whenReady().then(() => {
return getOpenedFiles(); return getOpenedFiles();
}); });
// Return folder tree without showing dialogs
ipcMain.handle("workspace:getTree", async () => {
return await getWorkspaceTree();
});
ipcMain.handle( ipcMain.handle(
"dialog:confirm", "dialog:confirm",
async (event, message: string, title: string, buttons: string[]) => { async (event, message: string, title: string, buttons: string[]) => {

View File

@ -55,6 +55,10 @@ contextBridge.exposeInMainWorld("electronAPI", {
getOpenedFiles: () => getOpenedFiles: () =>
ipcRenderer.invoke("workspace:getOpenedFiles") as Promise<string[]>, ipcRenderer.invoke("workspace:getOpenedFiles") as Promise<string[]>,
// Get the full workspace tree without triggering dialogs
getWorkspaceTree: () =>
ipcRenderer.invoke("workspace:getTree") as Promise<FolderTree | null>,
showConfirmDialog: (message: string, title: string, buttons: string[]) => showConfirmDialog: (message: string, title: string, buttons: string[]) =>
ipcRenderer.invoke( ipcRenderer.invoke(
"dialog:confirm", "dialog:confirm",
@ -91,4 +95,14 @@ contextBridge.exposeInMainWorld("electronAPI", {
terminalExitCallbacks.set(id, callback); terminalExitCallbacks.set(id, callback);
return () => terminalExitCallbacks.delete(id); 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);
},
);
},
}); });

View File

@ -27,6 +27,8 @@ declare global {
// Workspace info // Workspace info
getCurrentWorkspace: () => Promise<{ root: string | null }>; getCurrentWorkspace: () => Promise<{ root: string | null }>;
getOpenedFiles: () => Promise<string[]>; getOpenedFiles: () => Promise<string[]>;
// Get workspace tree without dialogs
getWorkspaceTree: () => Promise<FolderTree | null>;
// Dialog operations // Dialog operations
showConfirmDialog: ( showConfirmDialog: (
@ -56,6 +58,10 @@ declare global {
callback: (exitCode: number) => void, callback: (exitCode: number) => void,
) => () => void; ) => () => void;
removeAllTerminalListeners: () => void; removeAllTerminalListeners: () => void;
// Filesystem events
onFsEvent: (
callback: (ev: { event: string; path: string }) => void,
) => void;
}; };
} }
} }