Compare commits
1 Commits
delete-net
...
watch-fs
Author | SHA1 | Date | |
---|---|---|---|
dcbfccc621 |
@ -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' },
|
||||
|
4
interface.d.ts
vendored
4
interface.d.ts
vendored
@ -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<fs.readFile>
|
||||
writeFile: (
|
||||
path: string,
|
||||
|
47
src/hooks/useFileSystemWatcher.tsx
Normal file
47
src/hooks/useFileSystemWatcher.tsx
Normal file
@ -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<Path[]>([])
|
||||
|
||||
// On component teardown obliterate all watchers.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
window.electron.watchFileObliterate()
|
||||
}
|
||||
}, [])
|
||||
|
||||
function difference<T>(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)
|
||||
}
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -23,6 +23,27 @@ const isMac = os.platform() === 'darwin'
|
||||
const isWindows = os.platform() === 'win32'
|
||||
const isLinux = os.platform() === 'linux'
|
||||
|
||||
let fsWatchListeners = new Map<string, { watcher: fsSync.FSWatcher, callback: (eventType: string, path: string) => 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,
|
||||
|
@ -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()
|
||||
|
Reference in New Issue
Block a user