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 {
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<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 {
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() {

View File

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

View File

@ -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(

View File

@ -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<string>();
@ -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<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)
export function getOpenedFiles(): string[] {
return Array.from(openedFiles);

View File

@ -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[]) => {

View File

@ -55,6 +55,10 @@ contextBridge.exposeInMainWorld("electronAPI", {
getOpenedFiles: () =>
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[]) =>
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);
},
);
},
});

View File

@ -27,6 +27,8 @@ declare global {
// Workspace info
getCurrentWorkspace: () => Promise<{ root: string | null }>;
getOpenedFiles: () => Promise<string[]>;
// Get workspace tree without dialogs
getWorkspaceTree: () => Promise<FolderTree | null>;
// 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;
};
}
}