This commit is contained in:
		@ -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