Skip to content

Instantly share code, notes, and snippets.

@michaelfester
Last active April 18, 2022 20:26
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save michaelfester/9f68fbfe14204435f311104754e21a62 to your computer and use it in GitHub Desktop.
Save michaelfester/9f68fbfe14204435f311104754e21a62 to your computer and use it in GitHub Desktop.
Yjs + File System Access API
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createContainer } from 'unstated-next'
import ProjectProvider from '@context/project'
import { File, Folder, Project } from '@shared/types'
import { getHandles, HandleWithPath, isIgnoredPath } from '@lib/fs/fs-helpers'
import { useStore } from '@context/store'
import { set as idbSet, get as idbGet } from 'idb-keyval'
import { getNormalizedAbsolutePathFromPathComponents } from '@lib/tree-utils'
import { useDeepCompareEffectNoCheck } from '@lib/hooks/use-deep-compare-effect'
import { areArraysEqualSets } from '@lib/utils/utils'
import isEmpty from 'lodash.isempty'
const STORE_KEY_DIRECTORY_HANDLE_BY_PROJECT =
'STORE_KEY_DIRECTORY_HANDLE_BY_PROJECT'
const useFSHandles = () => {
const {
currentProject,
currentProjectIdPathMap,
currentProjectFolderIdPathMap,
currentProjectFileIdFileMap,
currentProjectFolderIdFolderMap,
} = ProjectProvider.useContainer()
const { autoSyncByProject } = useStore()
const [state, setState] = useState<{
didLoadHandlesInitially: boolean
handlesByProject?: Record<Project['id'], FileSystemDirectoryHandle>
}>({ didLoadHandlesInitially: false })
const [
isSyncPermissionsGrantedForCurrentProject,
setSyncPermissionsGrantedForCurrentProject,
] = useState(false)
const allHandlesInProjectFolder = useRef<HandleWithPath[]>([])
const fileIdHandleMap = useRef<Record<File['id'], FileSystemFileHandle>>({})
const folderIdHandleMap = useRef<
Record<Folder['id'], FileSystemDirectoryHandle>
>({})
const currentProjectDirectoryHandle = useMemo(() => {
if (!currentProject?.id) {
return undefined
}
return state.handlesByProject?.[currentProject.id]
}, [currentProject?.id, state.handlesByProject])
const isAutoSyncEnabledForCurrentProject = !!(
currentProjectDirectoryHandle &&
currentProject?.id &&
autoSyncByProject?.[currentProject.id]
)
const isSyncEnabledAndGrantedForCurrentProject =
isAutoSyncEnabledForCurrentProject &&
isSyncPermissionsGrantedForCurrentProject
const refreshHandlesInProjectFolder = useCallback(async () => {
if (
!currentProjectDirectoryHandle ||
!isSyncEnabledAndGrantedForCurrentProject
) {
return
}
const handles = await getHandles(currentProjectDirectoryHandle)
allHandlesInProjectFolder.current = handles
if (!handles) {
fileIdHandleMap.current = {}
folderIdHandleMap.current = {}
} else {
for (const handle of allHandlesInProjectFolder.current) {
if (isIgnoredPath(handle.path)) {
continue
}
const path = getNormalizedAbsolutePathFromPathComponents(handle.path)
if (handle.type === 'file') {
const fileId = Object.keys(currentProjectIdPathMap).find((k) => {
return currentProjectIdPathMap[k] === path
})
if (fileId) {
fileIdHandleMap.current[fileId] =
handle.handle as FileSystemFileHandle
}
} else if (handle.type === 'directory') {
const folderId = Object.keys(currentProjectFolderIdPathMap).find(
(k) => {
return currentProjectFolderIdPathMap[k] === path
}
)
if (folderId) {
folderIdHandleMap.current[folderId] =
handle.handle as FileSystemDirectoryHandle
}
}
}
}
}, [
currentProjectDirectoryHandle,
isSyncEnabledAndGrantedForCurrentProject,
currentProjectIdPathMap,
currentProjectFolderIdPathMap,
])
useEffect(() => {
refreshHandlesInProjectFolder()
}, [
refreshHandlesInProjectFolder,
currentProjectDirectoryHandle,
isSyncEnabledAndGrantedForCurrentProject,
currentProjectIdPathMap,
currentProjectFolderIdPathMap,
])
useEffect(() => {
const loadInitial = async () => {
const handles = await idbGet(STORE_KEY_DIRECTORY_HANDLE_BY_PROJECT)
setState({
didLoadHandlesInitially: true,
handlesByProject: handles,
})
}
loadInitial()
}, [])
useDeepCompareEffectNoCheck(() => {
const save = async () => {
if (!state.handlesByProject) {
return
}
// We safe-load the previous version from cache, to ensure we don't
// accidentally overwrite with an empty value, e.g. during initialization
// phase where variables are not yet set.
const saveHandles = await idbGet(STORE_KEY_DIRECTORY_HANDLE_BY_PROJECT)
idbSet(STORE_KEY_DIRECTORY_HANDLE_BY_PROJECT, {
...saveHandles,
...state.handlesByProject,
})
}
save()
}, [state.handlesByProject])
const setCurrentProjectDirectoryHandle = useCallback(
async (handle: FileSystemDirectoryHandle) => {
if (!currentProject?.id) {
return
}
setState((s) => ({
...s,
handlesByProject: {
...(s.handlesByProject || {}),
[currentProject.id]: handle,
},
}))
},
[currentProject?.id]
)
const getProjectFileParentDirectoryHandle = useCallback(
(fileId: File['id']) => {
// Given a project file, look at its project parentFolderId
// and return the matching folder handle.
const parentFolderId = currentProjectFileIdFileMap[fileId]?.parentFolderId
if (!parentFolderId) {
return currentProjectDirectoryHandle
}
return folderIdHandleMap.current?.[parentFolderId]
},
[
folderIdHandleMap,
currentProjectDirectoryHandle,
currentProjectFileIdFileMap,
]
)
const getProjectFolderParentDirectoryHandle = useCallback(
(folderId: Folder['id']) => {
// Given a project folder, look at its project parentFolderId
// and return the matching folder handle.
const parentFolderId =
currentProjectFolderIdFolderMap[folderId]?.parentFolderId
if (!parentFolderId) {
return currentProjectDirectoryHandle
}
return folderIdHandleMap.current?.[parentFolderId]
},
[
folderIdHandleMap,
currentProjectDirectoryHandle,
currentProjectFolderIdFolderMap,
]
)
const resolveHandleWithPath = useCallback(
async (handle: FileSystemHandle) => {
for (const h of allHandlesInProjectFolder.current) {
if (await (h.handle as any).isSameEntry(handle)) {
return h
}
}
return undefined
},
[allHandlesInProjectFolder]
)
const getHandleWithPathAtPath = useCallback(
(path: string[]): HandleWithPath | undefined => {
if (isEmpty(path) && !!currentProjectDirectoryHandle) {
return {
path: [],
handle: currentProjectDirectoryHandle,
type: 'directory',
}
}
for (const h of allHandlesInProjectFolder.current) {
if (areArraysEqualSets(h.path, path)) {
return h
}
}
return undefined
},
[currentProjectDirectoryHandle]
)
const getFileFSParentDirectoryHandle = useCallback(
async (fileId: File['id']) => {
// Given a project file, look at its FS parent directory
// and return the matching folder handle.
const fileHandle = fileIdHandleMap.current?.[fileId]
if (!fileHandle) {
return undefined
}
const fileHandleWithPath = await resolveHandleWithPath(fileHandle)
if (!fileHandleWithPath) {
return undefined
}
const parentHandlePath = fileHandleWithPath.path?.slice(0, -1)
if (!parentHandlePath || isEmpty(parentHandlePath)) {
return currentProjectDirectoryHandle
}
return getHandleWithPathAtPath(parentHandlePath)
?.handle as FileSystemDirectoryHandle
},
[
resolveHandleWithPath,
getHandleWithPathAtPath,
currentProjectDirectoryHandle,
]
)
const getFolderFSParentDirectoryHandle = useCallback(
async (folderId: Folder['id']) => {
// Given a project folder, look at its FS parent directory
// and return the matching folder handle.
const folderHandle = folderIdHandleMap.current?.[folderId]
if (!folderHandle) {
return undefined
}
const folderHandleWithPath = await resolveHandleWithPath(folderHandle)
if (!folderHandleWithPath) {
return undefined
}
const parentHandlePath = folderHandleWithPath.path?.slice(0, -1)
if (!parentHandlePath || isEmpty(parentHandlePath)) {
return currentProjectDirectoryHandle
}
return getHandleWithPathAtPath(parentHandlePath)
?.handle as FileSystemDirectoryHandle
},
[
resolveHandleWithPath,
getHandleWithPathAtPath,
currentProjectDirectoryHandle,
]
)
const getFileIdFromHandle = useCallback(async (handle: FileSystemHandle) => {
for (const id of Object.keys(fileIdHandleMap.current)) {
if (await fileIdHandleMap.current?.[id]?.isSameEntry(handle)) {
return id
}
}
return undefined
}, [])
const getFolderIdFromHandle = useCallback(
async (handle: FileSystemHandle) => {
for (const id of Object.keys(folderIdHandleMap.current)) {
if (await folderIdHandleMap.current?.[id]?.isSameEntry(handle)) {
return id
}
}
return undefined
},
[]
)
return {
currentProjectDirectoryHandle,
fileIdHandleMap,
folderIdHandleMap,
setCurrentProjectDirectoryHandle,
isAutoSyncEnabledForCurrentProject,
isSyncEnabledAndGrantedForCurrentProject,
didLoadHandlesInitially: state.didLoadHandlesInitially,
getProjectFileParentDirectoryHandle,
getProjectFolderParentDirectoryHandle,
getFileFSParentDirectoryHandle,
getFolderFSParentDirectoryHandle,
refreshHandlesInProjectFolder,
isSyncPermissionsGrantedForCurrentProject,
setSyncPermissionsGrantedForCurrentProject,
allHandlesInProjectFolder,
getFileIdFromHandle,
getFolderIdFromHandle,
getHandleWithPathAtPath,
}
}
export default createContainer(useFSHandles)
import isEmpty from 'lodash.isempty'
export type HandleWithPath = {
handle: FileSystemHandle
path: string[]
type: 'file' | 'directory'
}
const readWriteOptions = { mode: 'readwrite' }
export const isReadWritePermissionGranted = async (
handle: FileSystemFileHandle | FileSystemDirectoryHandle
) => {
return (await (handle as any).queryPermission(readWriteOptions)) === 'granted'
}
export const askReadWritePermissionsIfNeeded = async (
handle: FileSystemFileHandle | FileSystemDirectoryHandle
) => {
if (await isReadWritePermissionGranted(handle)) {
return true
}
const permission = await (handle as any).requestPermission(readWriteOptions)
return permission === 'granted'
}
const createEmptyFileInFolder = async (
parentDirectoryHandle: FileSystemDirectoryHandle,
name: string
): Promise<FileSystemFileHandle> => {
return await parentDirectoryHandle.getFileHandle(name, { create: true })
}
export const createFolderInFolder = async (
parentDirectoryHandle: FileSystemDirectoryHandle,
name: string
): Promise<FileSystemDirectoryHandle> => {
return await parentDirectoryHandle.getDirectoryHandle(name, { create: true })
}
export const writeContentToFile = async (
fileHandle: FileSystemFileHandle,
content: string
) => {
const writable = await (fileHandle as any).createWritable()
await writable.write(content)
await writable.close()
}
export const writeContentToFSFileIfChanged = async (
fsFile: globalThis.File,
fileHandle: FileSystemFileHandle,
content: string
) => {
const fsFileContent = await fsFile.text()
if (fsFileContent === content) {
return
}
await writeContentToFile(fileHandle, content)
}
export const renameFile = async (
fsFile: globalThis.File,
parentDirectoryHandle: FileSystemDirectoryHandle,
name: string
) => {
// Move and rename is not currently supported by the FileSystem
// Access API so we need to do this they manual way by creating
// a new file and deleting the old one.
const content = await fsFile.text()
await createFileWithContentInFolder(parentDirectoryHandle, name, content)
await deleteFile(parentDirectoryHandle, fsFile.name)
}
export const moveFile = async (
fsFile: globalThis.File,
sourceDirectoryHandle: FileSystemDirectoryHandle,
destinationDirectoryHandle: FileSystemDirectoryHandle
) => {
// Same comment as renameFile
const content = await fsFile.text()
await createFileWithContentInFolder(
destinationDirectoryHandle,
fsFile.name,
content
)
await deleteFile(sourceDirectoryHandle, fsFile.name)
}
export const moveFolderContent = async (
sourceFolderHandle: FileSystemDirectoryHandle,
destinationFolderHandle: FileSystemDirectoryHandle
) => {
for await (const handle of (sourceFolderHandle as any).values()) {
if (handle.kind === 'file') {
const fsFile = await (handle as FileSystemFileHandle).getFile()
await moveFile(fsFile, sourceFolderHandle, destinationFolderHandle)
} else if (handle.kind === 'directory') {
await moveFolder(handle, sourceFolderHandle, destinationFolderHandle)
}
}
}
export const moveFolder = async (
folderHandle: FileSystemDirectoryHandle,
parentDirectoryHandle: FileSystemDirectoryHandle,
destinationFolderHandle: FileSystemDirectoryHandle
) => {
const newFolderHandle = await createFolderInFolder(
destinationFolderHandle,
folderHandle.name
)
await moveFolderContent(folderHandle, newFolderHandle)
await deleteFolder(folderHandle.name, parentDirectoryHandle)
}
export const renameFolder = async (
folderHandle: FileSystemDirectoryHandle,
parentDirectoryHandle: FileSystemDirectoryHandle,
newName: string
) => {
const newFolderHandle = await createFolderInFolder(
parentDirectoryHandle,
newName
)
try {
await moveFolderContent(folderHandle, newFolderHandle)
await deleteFolder(folderHandle.name, parentDirectoryHandle)
} catch {
// Do nothing
}
}
export const createFileWithContentInFolder = async (
parentDirectoryHandle: FileSystemDirectoryHandle,
name: string,
content: string
): Promise<FileSystemFileHandle> => {
const newFileHandle = await createEmptyFileInFolder(
parentDirectoryHandle,
name
)
await writeContentToFile(newFileHandle, content)
return newFileHandle
}
export const deleteFile = async (
parentDirectoryHandle: FileSystemDirectoryHandle,
name: string
) => {
await parentDirectoryHandle.removeEntry(name)
}
export const deleteFolder = async (
name: string,
parentDirectoryHandle: FileSystemDirectoryHandle
) => {
await parentDirectoryHandle.removeEntry(name, {
recursive: true,
})
}
export const getHandles = async (
directoryHandle: FileSystemDirectoryHandle,
savePath?: string[]
): Promise<HandleWithPath[]> => {
let handles: HandleWithPath[] = []
for await (const handle of (directoryHandle as any).values()) {
const relativePath = (await directoryHandle.resolve(handle)) || []
if (isIgnoredPath(relativePath)) {
continue
}
const fullPath = [...(savePath || []), ...relativePath]
handles = [
...handles,
{
handle,
path: fullPath,
type: handle.kind === 'file' ? 'file' : 'directory',
},
]
if (handle.kind === 'directory') {
handles = [...handles, ...(await getHandles(handle, fullPath))]
}
}
return handles
}
export const isHandlesEqual = async (
handle: FileSystemHandle | undefined,
otherHandle: FileSystemHandle | undefined
) => {
if (!handle && !otherHandle) {
return true
}
if (handle && otherHandle) {
return await (handle as any)?.isSameEntry(otherHandle)
}
return false
}
export const isIgnoredPath = (path: string[]): boolean => {
// Return true if the file at the given path should be ignored for
// syncing. This is the case currently if the path contains a component
// that starts with a period, e.g. ".git" or ".DS_Store".
return !!path.find((p) => p.startsWith('.') || p.endsWith('.crswap'))
}
export const isTextMimeType = (file: globalThis.File) => {
// Reference: https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types
return file.type.startsWith('text/') || isEmpty(file.type)
}
import { createContainer } from 'unstated-next'
import ProjectProvider from '@context/project'
import FSHandlesProvider from '@context/fs-handles'
import { File, FileTreeNode, Folder } from '@shared/types'
import { useCallback, useEffect, useRef, useState } from 'react'
import { set as idbSet, get as idbGet } from 'idb-keyval'
import {
askReadWritePermissionsIfNeeded,
createFileWithContentInFolder,
createFolderInFolder,
deleteFile,
deleteFolder,
isHandlesEqual,
isReadWritePermissionGranted,
isTextMimeType,
moveFile,
moveFolder,
renameFile,
renameFolder,
writeContentToFSFileIfChanged,
} from '@lib/fs/fs-helpers'
import {
getDeltaOperations,
toTextContent,
updateDocWithDeltas,
} from '@lib/yjs/ydoc'
import isEmpty from 'lodash.isempty'
import emitter, {
EVENT_NOTIFY_COMMAND_S,
EVENT_NOTIFY_CONTENT_UPDATE,
EVENT_PROGRAMMATIC_DOC_UPDATE,
} from '@lib/events'
import { sortBy, uniq } from 'lodash-es'
const STORE_KEY_CACHED_FS_FILE = 'STORE_KEY_CACHED_FS_FILE'
const STORE_KEY_TRASHED_FILE_IDS = 'STORE_KEY_TRASHED_FILE_IDS'
const STORE_KEY_TRASHED_FOLDER_IDS = 'STORE_KEY_TRASHED_FOLDER_IDS'
type CachedFSFileData = {
name: string
content: string
lastModified: number
}
const getCachedFSFileKey = (id: File['id']) => {
return `${STORE_KEY_CACHED_FS_FILE}-${id}`
}
const writeProjectFileToFSDataCache = async (
projectFileId: File['id'],
projectFileName: string,
projectFileContent: string,
lastModified: number
) => {
await idbSet(
getCachedFSFileKey(projectFileId),
JSON.stringify({
name: projectFileName,
content: projectFileContent,
lastModified,
})
)
}
const useFS = () => {
const [trashedFileIds, setTrashedFileIds] = useState<File['id'][]>([])
const [trashedFolderIds, setTrashedFolderIds] = useState<Folder['id'][]>([])
const {
currentProjectFiles,
currentProjectFileIdFileMap,
currentProjectFolderIdFolderMap,
currentProjectFolders,
updateFileContent,
createFileWithPathInCurrentProject,
createFolderWithPathInCurrentProject,
currentProjectFolderIdPathMap,
currentProjectTrashedFiles,
currentProjectTrashedFolders,
} = ProjectProvider.useContainer()
const {
currentProjectDirectoryHandle,
setCurrentProjectDirectoryHandle,
isAutoSyncEnabledForCurrentProject,
isSyncEnabledAndGrantedForCurrentProject,
didLoadHandlesInitially,
fileIdHandleMap,
folderIdHandleMap,
getProjectFileParentDirectoryHandle,
getProjectFolderParentDirectoryHandle,
getFileFSParentDirectoryHandle,
getFolderFSParentDirectoryHandle,
refreshHandlesInProjectFolder,
isSyncPermissionsGrantedForCurrentProject,
setSyncPermissionsGrantedForCurrentProject,
getFileIdFromHandle,
getFolderIdFromHandle,
allHandlesInProjectFolder,
} = FSHandlesProvider.useContainer()
const isPerformingOperation = useRef<boolean>(false)
useEffect(() => {
const loadTrashedFilesAndFolders = async () => {
const trashedFiles = await idbGet(STORE_KEY_TRASHED_FILE_IDS)
if (trashedFiles) {
try {
setTrashedFileIds(JSON.parse(trashedFiles) || [])
} catch {
// Do nothing
}
}
const trashedFolders = await idbGet(STORE_KEY_TRASHED_FOLDER_IDS)
if (trashedFolders) {
try {
setTrashedFolderIds(JSON.parse(trashedFolders) || [])
} catch {
// Do nothing
}
}
}
loadTrashedFilesAndFolders()
}, [])
useEffect(() => {
const save = async () => {
// We safe-load the previous version from cache, to ensure we don't
// accidentally overwrite with an empty array, e.g. during initialization
// phase where variables are not yet set.
let saveTrashedFiles: string[] = []
try {
const fileIds = await idbGet(STORE_KEY_TRASHED_FILE_IDS)
saveTrashedFiles = JSON.parse(fileIds)
} catch {
// Do nothing
}
idbSet(
STORE_KEY_TRASHED_FILE_IDS,
JSON.stringify(uniq([...saveTrashedFiles, ...trashedFileIds]))
)
}
save()
}, [trashedFileIds])
useEffect(() => {
const save = async () => {
// We safe-load the previous version from cache, to ensure we don't
// accidentally overwrite with an empty array, e.g. during initialization
// phase where variables are not yet set.
let saveTrashedFolders: string[] = []
try {
const folderIds = await idbGet(STORE_KEY_TRASHED_FOLDER_IDS)
saveTrashedFolders = JSON.parse(folderIds)
} catch {
// Do nothing
}
idbSet(
STORE_KEY_TRASHED_FOLDER_IDS,
JSON.stringify(uniq([...saveTrashedFolders, ...trashedFolderIds]))
)
}
save()
}, [trashedFolderIds])
const getFileFromFS = useCallback(
async (id: File['id']): Promise<globalThis.File | undefined> => {
const handle = fileIdHandleMap.current?.[id]
if (!handle || handle.kind !== 'file') {
return undefined
}
try {
return await (handle as FileSystemFileHandle)?.getFile()
} catch {
// Do nothing
}
return undefined
},
[fileIdHandleMap]
)
const getCachedFSFileData = useCallback(
async (id: File['id']): Promise<CachedFSFileData | undefined> => {
const jsonFile = await idbGet(getCachedFSFileKey(id))
if (jsonFile) {
return JSON.parse(jsonFile)
}
return undefined
},
[]
)
const createFSFolderFromProjectFolder = useCallback(
async (
folderId: Folder['id']
): Promise<FileSystemDirectoryHandle | undefined> => {
// IMPORTANT: keep in mind that some of these files may belong
// to folders that have been trashed, and thus deleted from the
// file system. So we cannot assumt that the parent folder exists
// in the file system.
const folder = currentProjectFolderIdFolderMap[folderId]
if (!folder) {
return undefined
}
let parentFolderHandle: FileSystemDirectoryHandle | undefined = undefined
if (!folder?.parentFolderId) {
parentFolderHandle = currentProjectDirectoryHandle
} else {
const p = folderIdHandleMap.current?.[folder.parentFolderId]
if (p) {
parentFolderHandle = p
} else {
parentFolderHandle = await createFSFolderFromProjectFolder(
folder.parentFolderId
)
// Immediately update the folderIdHandleMap
if (parentFolderHandle) {
folderIdHandleMap.current = {
...folderIdHandleMap.current,
[folder.parentFolderId]: parentFolderHandle,
}
}
}
}
if (!parentFolderHandle) {
return undefined
}
try {
return await createFolderInFolder(parentFolderHandle, folder.name)
} catch {
// Do nothing
}
return undefined
},
[
currentProjectFolderIdFolderMap,
currentProjectDirectoryHandle,
folderIdHandleMap,
]
)
const writeProjectFileToNewFileInFS = useCallback(
async (file: File) => {
// IMPORTANT: keep in mind that some of these files may belong
// to folders that have been trashed, and thus deleted from the
// file system. So we cannot assumt that the parent folder exists
// in the file system.
let folderHandle: FileSystemDirectoryHandle | undefined = undefined
if (!file.parentFolderId) {
// Only if the parentFolderId is explicitly null do we set
// folderHandle to currentProjectDirectoryHandle. Otherwise
// we risk writing all files to the root folder.
folderHandle = currentProjectDirectoryHandle
} else if (file.parentFolderId in folderIdHandleMap.current) {
folderHandle = folderIdHandleMap.current?.[file.parentFolderId]
} else {
folderHandle = await createFSFolderFromProjectFolder(
file.parentFolderId
)
}
if (!folderHandle) {
return
}
return await createFileWithContentInFolder(
folderHandle,
file.name,
toTextContent(file.encodedYDoc)
)
},
[
currentProjectDirectoryHandle,
folderIdHandleMap,
createFSFolderFromProjectFolder,
]
)
const syncFile = useCallback(
async (id: File['id'], isCurrentFile: boolean) => {
const writeProjectFileToFSAndFSDataCache = async (
projectFileId: File['id'],
projectFileName: string,
projectFileContent: string,
fsFile: globalThis.File
) => {
const fileHandle = fileIdHandleMap.current?.[projectFileId]
if (!fileHandle) {
return
}
await writeContentToFSFileIfChanged(
fsFile,
fileHandle,
projectFileContent
)
// File name has changed
if (fsFile.name !== projectFileName && !isEmpty(projectFileName)) {
const parentDirectoryHandle =
getProjectFileParentDirectoryHandle(projectFileId)
if (parentDirectoryHandle) {
await renameFile(fsFile, parentDirectoryHandle, projectFileName)
await refreshHandlesInProjectFolder()
return
}
}
// Check if file has moved
const projectParentFolderHandle = getProjectFileParentDirectoryHandle(
projectFile.id
)
const fsParentFolderHandle = await getFileFSParentDirectoryHandle(
projectFile.id
)
const isProjectAndFSParentsSame = await isHandlesEqual(
fsParentFolderHandle,
projectParentFolderHandle
)
if (
!isProjectAndFSParentsSame &&
fsParentFolderHandle &&
projectParentFolderHandle
) {
// File has moved in project, so move it in file system
await moveFile(
fsFile,
fsParentFolderHandle,
projectParentFolderHandle
)
}
// We use the file lastModified timestamp, and not just
// client datetime, to ensure we have matching timestamps
// between file and cache, for later comparison. In order
// to retrieve the value of the timestamp, we need to
// retrieve the file again - just reading it from fsFile
// won't give us that latest value.
const updatedFile = await getFileFromFS(projectFileId)
if (updatedFile) {
await writeProjectFileToFSDataCache(
projectFileId,
projectFileName,
projectFileContent,
updatedFile.lastModified
)
}
}
const updateProjectFileAndNotifyEditor = async (
fileId: File['id'],
encodedYDoc: string,
content: string,
triggerCompile: boolean
) => {
await updateFileContent(fileId, { encodedYDoc })
// This event tells the editor to merge the active
// yDoc with the newly created one.
emitter.emit(EVENT_PROGRAMMATIC_DOC_UPDATE, { fileId, encodedYDoc })
if (triggerCompile) {
// This events tells the editor to trigger an `onContentUpdate`
// callback back to the FileView, which will then take
// care of compiling and rendering to preview, automatically,
// of via Cmd+S sent below.
emitter.emit(EVENT_NOTIFY_CONTENT_UPDATE, { fileId, content })
// Todo: find a way to bypass setting shouldCompile.current in case
// where auto-render is disabled, so that we don't need to send the
// save event to trigger an update of the preview.
setTimeout(() => {
emitter.emit(EVENT_NOTIFY_COMMAND_S)
}, 100)
}
}
const projectFile = currentProjectFileIdFileMap[id]
const fsFile = await getFileFromFS(id)
// If file is not present in the FS, just write the project file as is.
if (!fsFile) {
const newFileHandle = await writeProjectFileToNewFileInFS(projectFile)
if (newFileHandle) {
try {
const newFile = await newFileHandle?.getFile()
if (newFile) {
await writeProjectFileToFSDataCache(
projectFile.id,
projectFile.name,
toTextContent(projectFile.encodedYDoc),
newFile.lastModified
)
}
} catch {
// Do nothing
}
}
return
}
const cachedFSFileData = await getCachedFSFileData(id)
// Cached version does not exist. This should never happen. Indeed,
// even if the user clears the app data, the project folder handle will be
// cleared as well, so the user will be asked to select a folder again,
// in which case a hard overwrite will happen, and the FSFileData cache
// will be populated. So in case `cachedFSFileData` does not exist, we
// can consider this situation as similar to the initial file dump
// situation and simply overwrite the FS file.
if (!cachedFSFileData) {
await writeProjectFileToFSAndFSDataCache(
projectFile.id,
projectFile.name,
toTextContent(projectFile.encodedYDoc),
fsFile
)
return
}
// Cached version exists. This allows us to see the changes in the
// local file, and compute the diff which in turn gives us as
// state update vector for our CRDT. We can then apply it
// to the project file for a seamless merging of the two versions.
if (fsFile.lastModified === cachedFSFileData.lastModified) {
// File has not changed in the file system. Since the FS file cache
// is only set when a project file is synced, this means that the
// only option is that the project file has changed, in which
// case it should be written to the FS file.
await writeProjectFileToFSAndFSDataCache(
projectFile.id,
projectFile.name,
toTextContent(projectFile.encodedYDoc),
fsFile
)
return
}
const fsFileContent = await fsFile.text()
const cachedFSFileContent = cachedFSFileData.content
const fsDiffDeltas = getDeltaOperations(
cachedFSFileContent,
fsFileContent
)
if (isEmpty(fsDiffDeltas)) {
// Same comment as above: no difference between FS file and cached
// FS file.
await writeProjectFileToFSAndFSDataCache(
projectFile.id,
projectFile.name,
toTextContent(projectFile.encodedYDoc),
fsFile
)
return
}
if (!projectFile.encodedYDoc) {
return
}
// A change has happened on the local file, since it differs
// from the cached version. So we merge it with the project yDoc.
const newEncodedYDoc = updateDocWithDeltas(
projectFile.encodedYDoc,
fsDiffDeltas
)
const newEncodedYDocContent = toTextContent(newEncodedYDoc)
// We now have a new master doc, which has merged the FS content
// with the (potentially also updated) project file content.
// Since changes can come from both source, the FS content
// might also need to be updated, so we perform three updated:
// - the FS version
// - the cached FS version
// - the project version
await writeProjectFileToFSAndFSDataCache(
projectFile.id,
projectFile.name,
newEncodedYDocContent,
fsFile
)
await updateProjectFileAndNotifyEditor(
projectFile.id,
newEncodedYDoc,
newEncodedYDocContent,
isCurrentFile
)
},
[
currentProjectFileIdFileMap,
fileIdHandleMap,
getCachedFSFileData,
getFileFromFS,
writeProjectFileToNewFileInFS,
updateFileContent,
getProjectFileParentDirectoryHandle,
getFileFSParentDirectoryHandle,
refreshHandlesInProjectFolder,
]
)
const createFolderInFSIfNeeded = useCallback(
async (folderId: Folder['id']) => {
const fsFolderHandle = folderIdHandleMap.current[folderId]
if (fsFolderHandle) {
// Folder already exists
return
}
const projectFolder = currentProjectFolderIdFolderMap[folderId]
if (!projectFolder) {
return
}
const projectParentFolderHandle =
await getProjectFolderParentDirectoryHandle(folderId)
if (projectParentFolderHandle) {
await createFolderInFolder(
projectParentFolderHandle,
projectFolder.name
)
} else if (projectFolder.parentFolderId) {
// If parentFolderId is not null but projectParentFolderHandle, this
// means that the FS parent folder does not exist, so create it.
await createFolderInFSIfNeeded(projectFolder.parentFolderId)
}
},
[
folderIdHandleMap,
currentProjectFolderIdFolderMap,
getProjectFolderParentDirectoryHandle,
]
)
const syncFolder = useCallback(
async (folderId: Folder['id']) => {
const projectFolder = currentProjectFolderIdFolderMap[folderId]
const fsFolderHandle = folderIdHandleMap.current[folderId]
const projectParentFolderHandle =
await getProjectFolderParentDirectoryHandle(folderId)
if (projectFolder.name !== fsFolderHandle.name) {
// Folder has been renamed
if (projectParentFolderHandle) {
await renameFolder(
fsFolderHandle,
projectParentFolderHandle,
projectFolder.name
)
await refreshHandlesInProjectFolder()
}
}
// Check if folder has moved
const fsParentFolderHandle = await getFolderFSParentDirectoryHandle(
folderId
)
const isProjectAndFSParentsSame = await isHandlesEqual(
fsParentFolderHandle,
projectParentFolderHandle
)
if (
!isProjectAndFSParentsSame &&
fsParentFolderHandle &&
projectParentFolderHandle
) {
// Folder has moved in project, so move it in file system
await moveFolder(
fsFolderHandle,
fsParentFolderHandle,
projectParentFolderHandle
)
}
},
[
currentProjectFolderIdFolderMap,
folderIdHandleMap,
getProjectFolderParentDirectoryHandle,
refreshHandlesInProjectFolder,
getFolderFSParentDirectoryHandle,
]
)
const purgeTrashedProjectFilesFromFS = useCallback(
async (trashedFiles: File[]) => {
// If a project file has been trashed, remove it from
// the file system, but *only* if a newer, untrashed
// version is not present in the project, in which case
// the file needs to remain.
const notYetDeleted = trashedFiles.filter(
(f) => !trashedFileIds.includes(f.id)
)
const newlyDeletedTrashedFileIds: File['id'][] = []
let didDeleteFileInFS = false
for (const file of notYetDeleted) {
// Check that a newer, untrashed, version with the same name
// does not exist
const isAlsoNotTrashed = currentProjectFiles.find(
(f) =>
f.name === file.name && f.parentFolderId === file.parentFolderId
)
if (isAlsoNotTrashed) {
// An untrashed version exists, so don't delete, just
// mark the file id as deleted. This
// happens e.g. if a file was deleted, and another one with
// the same parent and name was subsequently created.
newlyDeletedTrashedFileIds.push(file.id)
} else {
let parentDirectoryHandle = undefined
if (!file.parentFolderId) {
parentDirectoryHandle = currentProjectDirectoryHandle
} else {
parentDirectoryHandle =
folderIdHandleMap.current?.[file.parentFolderId]
}
// Note that we are not setting parentDirectoryHandle to
// currentProjectDirectoryHandle unless parentFolderId is explicitly
// undefined. Otherwise we may end up in a situation where
// the file to be deleted has a non-null parent, but we fallback
// to deleting a root-level file, which of course is not the
// intention.
if (parentDirectoryHandle) {
try {
await deleteFile(parentDirectoryHandle, file.name)
didDeleteFileInFS = true
} catch {
// Do nothing
}
}
newlyDeletedTrashedFileIds.push(file.id)
}
}
setTrashedFileIds((t) => [...t, ...newlyDeletedTrashedFileIds])
if (didDeleteFileInFS) {
await refreshHandlesInProjectFolder()
}
},
[
currentProjectDirectoryHandle,
currentProjectFiles,
folderIdHandleMap,
trashedFileIds,
refreshHandlesInProjectFolder,
]
)
const purgeTrashedProjectFoldersFromFS = useCallback(
async (trashedFolders: Folder[]) => {
// If a project folder has been trashed, remove it from
// the file system, but *only* if a newer, untrashed
// version is not present in the project, in which case
// the folder needs to remain.
const notYetDeleted = trashedFolders.filter(
(f) => !trashedFolderIds.includes(f.id)
)
const newlyDeletedTrashedFolderIds: Folder['id'][] = []
let didDeleteFolderInFS = false
for (const folder of notYetDeleted) {
// Check that a newer, untrashed, version with the same name
// does not exist
const isAlsoNotTrashed = currentProjectFolders.find(
(f) =>
f.name === folder.name && f.parentFolderId === folder.parentFolderId
)
if (isAlsoNotTrashed) {
// An untrashed version exists, so don't delete, just
// mark the id of the folder that we try to delete as deleted. This
// happens e.g. if a folder was deleted, and another one with
// the same parent and name was subsequently created.
newlyDeletedTrashedFolderIds.push(folder.id)
} else {
let parentDirectoryHandle = undefined
if (!folder.parentFolderId) {
parentDirectoryHandle = currentProjectDirectoryHandle
} else {
parentDirectoryHandle =
folderIdHandleMap.current?.[folder.parentFolderId]
}
// Note that we are not setting parentDirectoryHandle to
// currentProjectDirectoryHandle unless parentFolderId is explicitly
// undefined. Otherwise we may end up in a situation where
// the folder to be deleted has a non-null parent, but we fallback
// to deleting a root-level folder, which of course is not the
// intention.
if (parentDirectoryHandle) {
try {
await deleteFolder(folder.name, parentDirectoryHandle)
didDeleteFolderInFS = true
} catch {
// Do nothing
}
}
newlyDeletedTrashedFolderIds.push(folder.id)
}
}
setTrashedFolderIds((t) => [...t, ...newlyDeletedTrashedFolderIds])
if (didDeleteFolderInFS) {
await refreshHandlesInProjectFolder()
}
},
[
currentProjectDirectoryHandle,
currentProjectFolders,
folderIdHandleMap,
trashedFolderIds,
refreshHandlesInProjectFolder,
]
)
const importFSFilesToProject = useCallback(async () => {
const saveFolderIdPathMap = currentProjectFolderIdPathMap
for (const h of allHandlesInProjectFolder.current) {
const fileId = await getFileIdFromHandle(h.handle)
if (fileId) {
// File exists, skip it
continue
}
const folderId = await getFolderIdFromHandle(h.handle)
if (folderId) {
// Folder exists, skip it
continue
}
if (h.type === 'file') {
const fsFile = await (h.handle as FileSystemFileHandle).getFile()
if (fsFile && isTextMimeType(fsFile)) {
try {
const content = await fsFile.text()
await createFileWithPathInCurrentProject(
h.path,
content,
saveFolderIdPathMap
)
} catch {
// Do nothing
}
}
} else if (h.type === 'directory') {
await createFolderWithPathInCurrentProject(h.path, saveFolderIdPathMap)
}
}
}, [
getFileIdFromHandle,
getFolderIdFromHandle,
createFolderWithPathInCurrentProject,
createFileWithPathInCurrentProject,
currentProjectFolderIdPathMap,
allHandlesInProjectFolder,
])
useEffect(() => {
if (!currentProjectDirectoryHandle) {
return
}
const checkIsSyncPermissionGrantedForProject = async () => {
const granted =
currentProjectDirectoryHandle &&
(await isReadWritePermissionGranted(currentProjectDirectoryHandle))
if (granted) {
setSyncPermissionsGrantedForCurrentProject(true)
}
}
checkIsSyncPermissionGrantedForProject()
}, [
currentProjectDirectoryHandle,
setSyncPermissionsGrantedForCurrentProject,
])
const askToGrantSyncPermissionsForProject = useCallback(async () => {
if (!currentProjectDirectoryHandle) {
return false
}
const granted = await askReadWritePermissionsIfNeeded(
currentProjectDirectoryHandle
)
setSyncPermissionsGrantedForCurrentProject(granted)
return granted
}, [
currentProjectDirectoryHandle,
setSyncPermissionsGrantedForCurrentProject,
])
const writeFileTreeNodeToFS = useCallback(
async (handle: FileSystemDirectoryHandle, nodes: FileTreeNode[]) => {
if (!currentProjectFiles) {
return
}
for (const node of nodes.filter((n) => n.type === 'file')) {
const file = currentProjectFiles.find((f) => f.id === node.id)
if (file) {
await createFileWithContentInFolder(
handle,
node.name,
toTextContent(file.encodedYDoc)
)
}
}
for (const node of nodes.filter((n) => n.type === 'folder')) {
const folder = currentProjectFolders.find((f) => f.id === node.id)
if (folder) {
const folderHandle = await createFolderInFolder(handle, node.name)
if (node.children) {
await writeFileTreeNodeToFS(folderHandle, node.children)
}
}
}
},
[currentProjectFiles, currentProjectFolders]
)
const bulkSyncAndCleanupFilesFolders = useCallback(
async (files: File[]) => {
if (isPerformingOperation.current === true) {
// Skip when an long operation is going on.
return
}
isPerformingOperation.current = true
await refreshHandlesInProjectFolder()
for (const folder of sortBy(currentProjectFolders, (f) => {
return currentProjectFolderIdPathMap[f.id]?.split('/')?.length
})) {
// Make sure all non-trashed project folders exist
// in the file system, as their existence is required
// for the next operations, such as moving a folder
// to another folder. Note that we do a little trick
// here: we order the folders by length of the paths.
// This avoids the situation where we first try
// to create a folder within a deep path, before its
// parent has been created.
try {
await createFolderInFSIfNeeded(folder.id)
} catch (e) {
// Do nothing
}
}
for (const file of files) {
try {
await syncFile(file.id, false)
} catch (e) {
// Do nothing
}
}
for (const folder of currentProjectFolders) {
try {
await syncFolder(folder.id)
} catch (e) {
// Do nothing
}
}
// Sync trashed files and folders, i.e. delete them from FS if present.
await purgeTrashedProjectFilesFromFS(currentProjectTrashedFiles)
await purgeTrashedProjectFoldersFromFS(currentProjectTrashedFolders)
// Bring in newly created files from FS to project. Note
// that we need to run this after the purge operation, so
// that we don't bring in files to the project which have
// just been deleted from the project while waiting for them
// to be deleted in the file system.
await importFSFilesToProject()
isPerformingOperation.current = false
},
[
currentProjectFolders,
currentProjectTrashedFiles,
currentProjectTrashedFolders,
currentProjectFolderIdPathMap,
syncFile,
syncFolder,
purgeTrashedProjectFilesFromFS,
purgeTrashedProjectFoldersFromFS,
importFSFilesToProject,
refreshHandlesInProjectFolder,
createFolderInFSIfNeeded,
]
)
return {
askToGrantSyncPermissionsForProject,
currentProjectDirectoryHandle,
setCurrentProjectDirectoryHandle,
isSyncEnabledAndGrantedForCurrentProject,
isAutoSyncEnabledForCurrentProject,
isSyncPermissionsGrantedForCurrentProject,
didLoadHandlesInitially,
syncFile,
syncFolder,
bulkSyncAndCleanupFilesFolders,
purgeTrashedProjectFilesFromFS,
purgeTrashedProjectFoldersFromFS,
importFSFilesToProject,
}
}
export default createContainer(useFS)
import { useInterval } from '@lib/hooks/use-interval'
import React, { FC, useCallback } from 'react'
import FSProvider from '@context/fs'
import FSHandlesProvider from '@context/fs-handles'
import ProjectProvider from '@context/project'
import FileProvider from '@context/file'
const FSSyncer: FC = () => {
const { currentProjectFiles } = ProjectProvider.useContainer()
const { currentFile } = FileProvider.useContainer()
const {
isSyncEnabledAndGrantedForCurrentProject,
syncFile,
bulkSyncAndCleanupFilesFolders,
} = FSProvider.useContainer()
const { refreshHandlesInProjectFolder } = FSHandlesProvider.useContainer()
const syncCurrentFile = useCallback(async () => {
if (!currentFile?.id) {
return
}
try {
await refreshHandlesInProjectFolder()
await syncFile(currentFile?.id, true)
} catch {
// Do nothing
}
}, [currentFile?.id, syncFile, refreshHandlesInProjectFolder])
const syncOtherFiles = useCallback(async () => {
await bulkSyncAndCleanupFilesFolders(
currentProjectFiles.filter((f) => f.id !== currentFile?.id)
)
}, [currentProjectFiles, bulkSyncAndCleanupFilesFolders, currentFile])
useInterval(
syncCurrentFile,
isSyncEnabledAndGrantedForCurrentProject ? 3009 : null
)
useInterval(
syncOtherFiles,
isSyncEnabledAndGrantedForCurrentProject ? 7000 : null
)
return <></>
}
export default FSSyncer
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment