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 {
|
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() {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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[]) => {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue