diff --git a/e2e/playwright/projects.spec.ts b/e2e/playwright/projects.spec.ts index 650773a07..9d52957f9 100644 --- a/e2e/playwright/projects.spec.ts +++ b/e2e/playwright/projects.spec.ts @@ -17,6 +17,43 @@ test.afterEach(async ({ page }, testInfo) => { await tearDown(page, testInfo) }) +test( + 'projects reload if a new one is created, deleted, or renamed externally', + { tag: '@electron' }, + async ({ browserName }, testInfo) => { + const externalCreatedProjectName = 'external-created-project' + + let targetDir = '' + + const { electronApp, page } = await setupElectron({ + testInfo, + folderSetupFn: async (dir) => { + targetDir = dir + setTimeout(async () => { + const myDir = join(dir, externalCreatedProjectName) + await fsp.mkdir(myDir) + }, 1000) + }, + }) + + await page.setViewportSize({ width: 1200, height: 500 }) + + const projectLinks = page.getByTestId('project-link') + + await projectLinks.first().waitFor() + await expect(projectLinks).toHaveText(externalCreatedProjectName) + + await fsp.rename(join(targetDir, externalCreatedProjectName), join(targetDir, externalCreatedProjectName + '1')) + await expect(projectLinks).toHaveText(externalCreatedProjectName + '1') + + await fsp.rm(join(targetDir, externalCreatedProjectName), { recursive: true, force: true }) + const projectsTotal = await projectLinks.count() + await expect(projectsTotal).toBe(0) + + await electronApp.close() + } +) + test( 'click help/keybindings from home page', { tag: '@electron' }, diff --git a/interface.d.ts b/interface.d.ts index 265b6bf2f..7d5af75f0 100644 --- a/interface.d.ts +++ b/interface.d.ts @@ -1,4 +1,5 @@ import fs from 'node:fs/promises' +import fsSync from 'node:fs' import path from 'path' import { dialog, shell } from 'electron' import { MachinesListing } from 'lib/machineManager' @@ -17,6 +18,9 @@ export interface IElectronAPI { platform: typeof process.env.platform arch: typeof process.env.arch version: typeof process.env.version + watchFileOn: (path: string, callback: (eventType: string, path: string) => void)=> void + watchFileOff: (path: string) => void + watchFileObliterate: () => void readFile: (path: string) => ReturnType writeFile: ( path: string, diff --git a/src/hooks/useFileSystemWatcher.tsx b/src/hooks/useFileSystemWatcher.tsx new file mode 100644 index 000000000..58018ae6d --- /dev/null +++ b/src/hooks/useFileSystemWatcher.tsx @@ -0,0 +1,47 @@ +import { useCallback, useEffect, useState } from 'react' +import fs from 'node:fs' + +type Path = string + +type WatcherCallback = (eventType: string, path: string) => void + +// Not having access to NodeJS functions has influenced the design a little. +// There is some indirection going on because we can only pass data between +// the NodeJS<->Browser boundary. The actual functions need to run on the +// NodeJS side. Because EventEmitters come bundled with their listener +// methods it complicates things because we can't just do +// watcher.addListener(() => { ... }). + +export const useFileSystemWatcher = (callback: (path: Path) => void, dependencyArray: Path[]): void => { + // Used to track if dependencyArrray changes. + const [dependencyArrayTracked, setDependencyArrayTracked] = useState([]) + + // On component teardown obliterate all watchers. + useEffect(() => { + return () => { + window.electron.watchFileObliterate() + } + }, []) + + function difference(l1: T[], l2: T[]): [T[], T[]] { + return [ + l1.filter((x) => Boolean(!l2.find((x2) => x2 === x))), + l1.filter((x) => Boolean(l2.find((x2) => x2 === x))) + ] + } + + // Removing 1 watcher at a time is only possible because in a filesystem, + // a path is unique (there can never be two paths with the same name). + // Otherwise we would have to obliterate() the whole list and reconstruct it. + useEffect(() => { + const [pathsRemoved, pathsRemaining] = difference(dependencyArrayTracked, dependencyArray) + for (let path of pathsRemoved) { + window.electron.watchFileOff(path) + } + const [pathsAdded] = difference(dependencyArray, dependencyArrayTracked) + for (let path of pathsAdded) { + window.electron.watchFileOn(path, (_eventType: string, path: Path) => callback(path)) + } + setDependencyArrayTracked(pathsRemaining.concat(pathsAdded)) + }, dependencyArray) +} diff --git a/src/lib/routeLoaders.ts b/src/lib/routeLoaders.ts index 67a50488f..a340b98c3 100644 --- a/src/lib/routeLoaders.ts +++ b/src/lib/routeLoaders.ts @@ -185,7 +185,6 @@ export const homeLoader: LoaderFunction = async (): Promise< return redirect(PATHS.FILE + '/%2F' + BROWSER_PROJECT_NAME) } const { configuration } = await loadAndValidateSettings() - const projectDir = await ensureProjectDirectoryExists(configuration) if (projectDir) { @@ -193,10 +192,12 @@ export const homeLoader: LoaderFunction = async (): Promise< return { projects, + projectDir, } } else { return { projects: [], + projectDir: undefined, } } } diff --git a/src/lib/types.ts b/src/lib/types.ts index 228e95ae2..4b21f05cf 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -14,6 +14,7 @@ export type FileLoaderData = { export type HomeLoaderData = { projects: Project[] + projectDir?: string } // From the very helpful @jcalz on StackOverflow: https://stackoverflow.com/a/58436959/22753272 diff --git a/src/preload.ts b/src/preload.ts index 2d5e3eec4..2ff355838 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -23,6 +23,27 @@ const isMac = os.platform() === 'darwin' const isWindows = os.platform() === 'win32' const isLinux = os.platform() === 'linux' +let fsWatchListeners = new Map void }>() + +const watchFileOn = (path: string, callback: (eventType: string, path: string) => void) => { + const watcher = fsSync.watch(path) + watcher.on('change', callback) + fsWatchListeners.set(path, { watcher, callback }) +} +const watchFileOff = (path: string) => { + const entry = fsWatchListeners.get(path) + if (!entry) return + const { watcher, callback } = entry + watcher.off('change', callback) + watcher.close() + fsWatchListeners.delete(path) +} +const watchFileObliterate = () => { + for (let [pathAsKey] of fsWatchListeners) { + watchFileOff(pathAsKey) + } + fsWatchListeners = new Map() +} const readFile = (path: string) => fs.readFile(path, 'utf-8') // It seems like from the node source code this does not actually block but also // don't trust me on that (jess). @@ -71,6 +92,9 @@ contextBridge.exposeInMainWorld('electron', { // Passing fs directly is not recommended since it gives a lot of power // to the browser side / potential malicious code. We restrict what is // exported. + watchFileOn, + watchFileOff, + watchFileObliterate, readFile, writeFile, exists, diff --git a/src/routes/Home.tsx b/src/routes/Home.tsx index 34c045b68..a50385fc7 100644 --- a/src/routes/Home.tsx +++ b/src/routes/Home.tsx @@ -38,11 +38,12 @@ import { } from 'lib/desktop' import { ProjectSearchBar, useProjectSearch } from 'components/ProjectSearchBar' import { Project } from 'lib/project' +import { useFileSystemWatcher } from 'hooks/useFileSystemWatcher' // This route only opens in the desktop context for now, // as defined in Router.tsx, so we can use the desktop APIs and types. const Home = () => { - const { projects: loadedProjects } = useLoaderData() as HomeLoaderData + const { projects: loadedProjects, projectDir } = useLoaderData() as HomeLoaderData useRefreshSettings(PATHS.HOME + 'SETTINGS') const { commandBarSend } = useCommandsContext() const navigate = useNavigate() @@ -51,6 +52,9 @@ const Home = () => { } = useSettingsAuthContext() const { onProjectOpen } = useLspContext() + // Reload home / projects listing if the projectDir has any updates. + useFileSystemWatcher(() => { navigate(0) }, projectDir ? [projectDir] : []) + // Cancel all KCL executions while on the home page useEffect(() => { kclManager.cancelAllExecutions()