Implement basic file and folder watching
This commit is contained in:
parent
238ca8c812
commit
d020f41b89
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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[]) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue