Reload FileTree and File when changed externally
This commit is contained in:
3
interface.d.ts
vendored
3
interface.d.ts
vendored
@ -20,9 +20,10 @@ export interface IElectronAPI {
|
|||||||
version: typeof process.env.version
|
version: typeof process.env.version
|
||||||
watchFileOn: (
|
watchFileOn: (
|
||||||
path: string,
|
path: string,
|
||||||
|
key: string,
|
||||||
callback: (eventType: string, path: string) => void
|
callback: (eventType: string, path: string) => void
|
||||||
) => void
|
) => void
|
||||||
watchFileOff: (path: string) => void
|
watchFileOff: (path: string, key: string) => void
|
||||||
readFile: (path: string) => ReturnType<fs.readFile>
|
readFile: (path: string) => ReturnType<fs.readFile>
|
||||||
writeFile: (
|
writeFile: (
|
||||||
path: string,
|
path: string,
|
||||||
|
@ -2,7 +2,7 @@ import type { IndexLoaderData } from 'lib/types'
|
|||||||
import { PATHS } from 'lib/paths'
|
import { PATHS } from 'lib/paths'
|
||||||
import { ActionButton } from './ActionButton'
|
import { ActionButton } from './ActionButton'
|
||||||
import Tooltip from './Tooltip'
|
import Tooltip from './Tooltip'
|
||||||
import { Dispatch, useCallback, useEffect, useRef, useState } from 'react'
|
import { Dispatch, useCallback, useRef, useState } from 'react'
|
||||||
import { useNavigate, useRouteLoaderData } from 'react-router-dom'
|
import { useNavigate, useRouteLoaderData } from 'react-router-dom'
|
||||||
import { Disclosure } from '@headlessui/react'
|
import { Disclosure } from '@headlessui/react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
@ -13,7 +13,6 @@ import { sortProject } from 'lib/desktopFS'
|
|||||||
import { FILE_EXT } from 'lib/constants'
|
import { FILE_EXT } from 'lib/constants'
|
||||||
import { CustomIcon } from './CustomIcon'
|
import { CustomIcon } from './CustomIcon'
|
||||||
import { codeManager, kclManager } from 'lib/singletons'
|
import { codeManager, kclManager } from 'lib/singletons'
|
||||||
import { useDocumentHasFocus } from 'hooks/useDocumentHasFocus'
|
|
||||||
import { useLspContext } from './LspProvider'
|
import { useLspContext } from './LspProvider'
|
||||||
import useHotkeyWrapper from 'lib/hotkeyWrapper'
|
import useHotkeyWrapper from 'lib/hotkeyWrapper'
|
||||||
import { useModelingContext } from 'hooks/useModelingContext'
|
import { useModelingContext } from 'hooks/useModelingContext'
|
||||||
@ -22,6 +21,7 @@ import { ContextMenu, ContextMenuItem } from './ContextMenu'
|
|||||||
import usePlatform from 'hooks/usePlatform'
|
import usePlatform from 'hooks/usePlatform'
|
||||||
import { FileEntry } from 'lib/project'
|
import { FileEntry } from 'lib/project'
|
||||||
import { useFileSystemWatcher } from 'hooks/useFileSystemWatcher'
|
import { useFileSystemWatcher } from 'hooks/useFileSystemWatcher'
|
||||||
|
import { normalizeLineEndings } from 'lib/codeEditor'
|
||||||
|
|
||||||
function getIndentationCSS(level: number) {
|
function getIndentationCSS(level: number) {
|
||||||
return `calc(1rem * ${level + 1})`
|
return `calc(1rem * ${level + 1})`
|
||||||
@ -126,13 +126,29 @@ const FileTreeItem = ({
|
|||||||
level?: number
|
level?: number
|
||||||
}) => {
|
}) => {
|
||||||
const { send: fileSend, context: fileContext } = useFileContext()
|
const { send: fileSend, context: fileContext } = useFileContext()
|
||||||
const openDirectoriesRef = useRef<string[]>([])
|
|
||||||
const { onFileOpen, onFileClose } = useLspContext()
|
const { onFileOpen, onFileClose } = useLspContext()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false)
|
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false)
|
||||||
const isCurrentFile = fileOrDir.path === currentFile?.path
|
const isCurrentFile = fileOrDir.path === currentFile?.path
|
||||||
const itemRef = useRef(null)
|
const itemRef = useRef(null)
|
||||||
|
|
||||||
|
// Since every file or directory gets its own FileTreeItem, we can do this.
|
||||||
|
// Because subtrees only render when they are opened, that means this
|
||||||
|
// only listens when they open. Because this acts like a useEffect, when
|
||||||
|
// the ReactNodes are destroyed, so is this listener :)
|
||||||
|
useFileSystemWatcher(
|
||||||
|
async (eventType, path) => {
|
||||||
|
// Don't try to read a file that was removed.
|
||||||
|
if (isCurrentFile && eventType !== 'unlink') {
|
||||||
|
let code = await window.electron.readFile(path)
|
||||||
|
code = normalizeLineEndings(code)
|
||||||
|
codeManager.updateCodeStateEditor(code)
|
||||||
|
}
|
||||||
|
fileSend({ type: 'Refresh' })
|
||||||
|
},
|
||||||
|
[fileOrDir.path]
|
||||||
|
)
|
||||||
|
|
||||||
const isRenaming = fileContext.itemsBeingRenamed.includes(fileOrDir.path)
|
const isRenaming = fileContext.itemsBeingRenamed.includes(fileOrDir.path)
|
||||||
const removeCurrentItemFromRenaming = useCallback(
|
const removeCurrentItemFromRenaming = useCallback(
|
||||||
() =>
|
() =>
|
||||||
@ -156,21 +172,7 @@ const FileTreeItem = ({
|
|||||||
})
|
})
|
||||||
}, [fileContext.itemsBeingRenamed, fileOrDir.path, fileSend])
|
}, [fileContext.itemsBeingRenamed, fileOrDir.path, fileSend])
|
||||||
|
|
||||||
useFileSystemWatcher(async (path) => {
|
|
||||||
console.log(path)
|
|
||||||
}, openDirectoriesRef.current)
|
|
||||||
|
|
||||||
const clickDirectory = () => {
|
const clickDirectory = () => {
|
||||||
console.log("Before", openDirectoriesRef.current)
|
|
||||||
|
|
||||||
const index = openDirectoriesRef.current.indexOf(fileOrDir.path)
|
|
||||||
if (index >= 0) {
|
|
||||||
openDirectoriesRef.current.splice(index, 1)
|
|
||||||
} else {
|
|
||||||
openDirectoriesRef.current.push(fileOrDir.path)
|
|
||||||
}
|
|
||||||
console.log("After", openDirectoriesRef.current)
|
|
||||||
|
|
||||||
fileSend({
|
fileSend({
|
||||||
type: 'Set selected directory',
|
type: 'Set selected directory',
|
||||||
directory: fileOrDir,
|
directory: fileOrDir,
|
||||||
@ -477,30 +479,39 @@ export const FileTreeInner = ({
|
|||||||
}: {
|
}: {
|
||||||
onNavigateToFile?: () => void
|
onNavigateToFile?: () => void
|
||||||
}) => {
|
}) => {
|
||||||
|
const navigate = useNavigate()
|
||||||
const loaderData = useRouteLoaderData(PATHS.FILE) as IndexLoaderData
|
const loaderData = useRouteLoaderData(PATHS.FILE) as IndexLoaderData
|
||||||
const { send: fileSend, context: fileContext } = useFileContext()
|
const { send: fileSend, context: fileContext } = useFileContext()
|
||||||
const { send: modelingSend } = useModelingContext()
|
const { send: modelingSend } = useModelingContext()
|
||||||
const documentHasFocus = useDocumentHasFocus()
|
|
||||||
|
|
||||||
// Refresh the file tree when the document gets focus
|
// Refresh the file tree when there are changes.
|
||||||
useEffect(() => {
|
useFileSystemWatcher(
|
||||||
|
async (eventType, path) => {
|
||||||
|
if (eventType === 'unlinkDir' && path === loaderData?.project?.path) {
|
||||||
|
navigate(PATHS.HOME)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
fileSend({ type: 'Refresh' })
|
fileSend({ type: 'Refresh' })
|
||||||
}, [documentHasFocus])
|
},
|
||||||
|
[loaderData?.project?.path, fileContext.selectedDirectory.path].filter(
|
||||||
|
(x: string | undefined) => x !== undefined
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const clickDirectory = () => {
|
||||||
|
fileSend({
|
||||||
|
type: 'Set selected directory',
|
||||||
|
directory: fileContext.project,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="overflow-auto pb-12 absolute inset-0"
|
className="overflow-auto pb-12 absolute inset-0"
|
||||||
data-testid="file-pane-scroll-container"
|
data-testid="file-pane-scroll-container"
|
||||||
>
|
>
|
||||||
<ul
|
<ul className="m-0 p-0 text-sm" onClick={clickDirectory}>
|
||||||
className="m-0 p-0 text-sm"
|
|
||||||
onClickCapture={(e) => {
|
|
||||||
fileSend({
|
|
||||||
type: 'Set selected directory',
|
|
||||||
directory: fileContext.project,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{sortProject(fileContext.project?.children || []).map((fileOrDir) => (
|
{sortProject(fileContext.project?.children || []).map((fileOrDir) => (
|
||||||
<FileTreeItem
|
<FileTreeItem
|
||||||
project={fileContext.project}
|
project={fileContext.project}
|
||||||
|
@ -228,7 +228,9 @@ export const SettingsAuthProviderBase = ({
|
|||||||
doNotPersist: true,
|
doNotPersist: true,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
settingsPath ? [settingsPath] : []
|
[settingsPath, loadedProject?.project?.path].filter(
|
||||||
|
(x: string | undefined) => x !== undefined
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add settings commands to the command bar
|
// Add settings commands to the command bar
|
||||||
|
@ -12,35 +12,51 @@ type Path = string
|
|||||||
// watcher.addListener(() => { ... }).
|
// watcher.addListener(() => { ... }).
|
||||||
|
|
||||||
export const useFileSystemWatcher = (
|
export const useFileSystemWatcher = (
|
||||||
callback: (path: Path) => Promise<void>,
|
callback: (eventType: string, path: Path) => Promise<void>,
|
||||||
dependencyArray: Path[]
|
paths: Path[]
|
||||||
): void => {
|
): void => {
|
||||||
// Track a ref to the callback. This is how we get the callback updated
|
// Used to track this instance of useFileSystemWatcher.
|
||||||
// across the NodeJS<->Browser boundary.
|
// Assign to ref so it doesn't change between renders.
|
||||||
const callbackRef = useRef<{ fn: (path: Path) => Promise<void> }>({
|
const key = useRef(Math.random().toString())
|
||||||
fn: async (_path) => {},
|
|
||||||
})
|
const [output, setOutput] = useState<
|
||||||
|
{ eventType: string; path: string } | undefined
|
||||||
|
>(undefined)
|
||||||
|
|
||||||
|
// Used to track if paths list changes.
|
||||||
|
const [pathsTracked, setPathsTracked] = useState<Path[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
callbackRef.current.fn = callback
|
if (!output) return
|
||||||
}, [callback])
|
callback(output.eventType, output.path).catch(reportRejection)
|
||||||
|
}, [output])
|
||||||
// Used to track if dependencyArrray changes.
|
|
||||||
const [dependencyArrayTracked, setDependencyArrayTracked] = useState<Path[]>(
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
// On component teardown obliterate all watchers.
|
// On component teardown obliterate all watchers.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// The hook is useless on web.
|
// The hook is useless on web.
|
||||||
if (!isDesktop()) return
|
if (!isDesktop()) return
|
||||||
|
|
||||||
|
const cbWatcher = (eventType: string, path: string) => {
|
||||||
|
setOutput({ eventType, path })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let path of pathsTracked) {
|
||||||
|
// Because functions don't retain refs between NodeJS-Browser I need to
|
||||||
|
// pass an identifying key so we can later remove it.
|
||||||
|
// A way to think of the function call is:
|
||||||
|
// "For this path, add a new handler with this key"
|
||||||
|
// "There can be many keys (functions) per path"
|
||||||
|
// Again if refs were preserved, we wouldn't need to do this. Keys
|
||||||
|
// gives us uniqueness.
|
||||||
|
window.electron.watchFileOn(path, key.current, cbWatcher)
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
for (let path of dependencyArray) {
|
for (let path of pathsTracked) {
|
||||||
window.electron.watchFileOff(path)
|
window.electron.watchFileOff(path, key.current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [])
|
}, [pathsTracked])
|
||||||
|
|
||||||
function difference<T>(l1: T[], l2: T[]): [T[], T[]] {
|
function difference<T>(l1: T[], l2: T[]): [T[], T[]] {
|
||||||
return [
|
return [
|
||||||
@ -49,8 +65,7 @@ export const useFileSystemWatcher = (
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasDiff =
|
const hasDiff = difference(paths, pathsTracked)[0].length !== 0
|
||||||
difference(dependencyArray, dependencyArrayTracked)[0].length !== 0
|
|
||||||
|
|
||||||
// Removing 1 watcher at a time is only possible because in a filesystem,
|
// 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).
|
// a path is unique (there can never be two paths with the same name).
|
||||||
@ -61,19 +76,8 @@ export const useFileSystemWatcher = (
|
|||||||
|
|
||||||
if (!hasDiff) return
|
if (!hasDiff) return
|
||||||
|
|
||||||
const [pathsRemoved, pathsRemaining] = difference(
|
const [, pathsRemaining] = difference(pathsTracked, paths)
|
||||||
dependencyArrayTracked,
|
const [pathsAdded] = difference(paths, pathsTracked)
|
||||||
dependencyArray
|
setPathsTracked(pathsRemaining.concat(pathsAdded))
|
||||||
)
|
|
||||||
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) => {
|
|
||||||
callbackRef.current.fn(path).catch(reportRejection)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
setDependencyArrayTracked(pathsRemaining.concat(pathsAdded))
|
|
||||||
}, [hasDiff])
|
}, [hasDiff])
|
||||||
}
|
}
|
||||||
|
3
src/lib/codeEditor.ts
Normal file
3
src/lib/codeEditor.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export const normalizeLineEndings = (str: string, normalized = '\n') => {
|
||||||
|
return str.replace(/\r?\n/g, normalized)
|
||||||
|
}
|
@ -14,6 +14,7 @@ import { codeManager } from 'lib/singletons'
|
|||||||
import { fileSystemManager } from 'lang/std/fileSystemManager'
|
import { fileSystemManager } from 'lang/std/fileSystemManager'
|
||||||
import { getProjectInfo } from './desktop'
|
import { getProjectInfo } from './desktop'
|
||||||
import { createSettings } from './settings/initialSettings'
|
import { createSettings } from './settings/initialSettings'
|
||||||
|
import { normalizeLineEndings } from 'lib/codeEditor'
|
||||||
|
|
||||||
// The root loader simply resolves the settings and any errors that
|
// The root loader simply resolves the settings and any errors that
|
||||||
// occurred during the settings load
|
// occurred during the settings load
|
||||||
@ -182,7 +183,3 @@ export const homeLoader: LoaderFunction = async (): Promise<
|
|||||||
}
|
}
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeLineEndings = (str: string, normalized = '\n') => {
|
|
||||||
return str.replace(/\r?\n/g, normalized)
|
|
||||||
}
|
|
||||||
|
@ -37,8 +37,6 @@ if (!process.env.NODE_ENV)
|
|||||||
// dotenv override when present
|
// dotenv override when present
|
||||||
dotenv.config({ path: [`.env.${NODE_ENV}.local`, `.env.${NODE_ENV}`] })
|
dotenv.config({ path: [`.env.${NODE_ENV}.local`, `.env.${NODE_ENV}`] })
|
||||||
|
|
||||||
console.log(process.env)
|
|
||||||
|
|
||||||
process.env.VITE_KC_API_WS_MODELING_URL ??=
|
process.env.VITE_KC_API_WS_MODELING_URL ??=
|
||||||
'wss://api.zoo.dev/ws/modeling/commands'
|
'wss://api.zoo.dev/ws/modeling/commands'
|
||||||
process.env.VITE_KC_API_BASE_URL ??= 'https://api.zoo.dev'
|
process.env.VITE_KC_API_BASE_URL ??= 'https://api.zoo.dev'
|
||||||
|
@ -29,20 +29,53 @@ const isMac = os.platform() === 'darwin'
|
|||||||
const isWindows = os.platform() === 'win32'
|
const isWindows = os.platform() === 'win32'
|
||||||
const isLinux = os.platform() === 'linux'
|
const isLinux = os.platform() === 'linux'
|
||||||
|
|
||||||
let fsWatchListeners = new Map<string, ReturnType<typeof chokidar.watch>>()
|
let fsWatchListeners = new Map<
|
||||||
|
string,
|
||||||
const watchFileOn = (path: string, callback: (event: string, path: string) => void) => {
|
Map<
|
||||||
const watcherMaybe = fsWatchListeners.get(path)
|
string,
|
||||||
if (watcherMaybe) return
|
{
|
||||||
const watcher = chokidar.watch(path)
|
watcher: ReturnType<typeof chokidar.watch>
|
||||||
watcher.on('all', callback)
|
callback: (eventType: string, path: string) => void
|
||||||
fsWatchListeners.set(path, watcher)
|
|
||||||
}
|
}
|
||||||
const watchFileOff = (path: string) => {
|
>
|
||||||
const watcher = fsWatchListeners.get(path)
|
>()
|
||||||
if (!watcher) return
|
|
||||||
watcher.unwatch(path)
|
const watchFileOn = (
|
||||||
|
path: string,
|
||||||
|
key: string,
|
||||||
|
callback: (eventType: string, path: string) => void
|
||||||
|
) => {
|
||||||
|
let watchers = fsWatchListeners.get(path)
|
||||||
|
if (!watchers) {
|
||||||
|
watchers = new Map()
|
||||||
|
}
|
||||||
|
console.log('watchers', watchers)
|
||||||
|
const watcher = chokidar.watch(path, { depth: 1 })
|
||||||
|
watcher.on('all', callback)
|
||||||
|
watchers.set(key, { watcher, callback })
|
||||||
|
fsWatchListeners.set(path, watchers)
|
||||||
|
}
|
||||||
|
const watchFileOff = (path: string, key: string) => {
|
||||||
|
console.log('unmounting', path)
|
||||||
|
const watchers = fsWatchListeners.get(path)
|
||||||
|
if (!watchers) return
|
||||||
|
const data = watchers.get(key)
|
||||||
|
if (!data) {
|
||||||
|
console.warn(
|
||||||
|
"Trying to remove a watcher, callback that doesn't exist anymore. Suspicious."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log('watchers before remove', watchers)
|
||||||
|
const { watcher, callback } = data
|
||||||
|
watcher.off('all', callback)
|
||||||
|
watchers.delete(key)
|
||||||
|
if (watchers.size === 0) {
|
||||||
fsWatchListeners.delete(path)
|
fsWatchListeners.delete(path)
|
||||||
|
} else {
|
||||||
|
fsWatchListeners.set(path, watchers)
|
||||||
|
}
|
||||||
|
console.log('watchers after remove', watchers)
|
||||||
}
|
}
|
||||||
const readFile = (path: string) => fs.readFile(path, 'utf-8')
|
const readFile = (path: string) => fs.readFile(path, 'utf-8')
|
||||||
// It seems like from the node source code this does not actually block but also
|
// It seems like from the node source code this does not actually block but also
|
||||||
|
Reference in New Issue
Block a user