first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,662 @@
import {
createContext,
useCallback,
useMemo,
useReducer,
useContext,
useEffect,
useState,
FC,
} from 'react'
import { mapSeries } from '../../../infrastructure/promise'
import {
syncRename,
syncDelete,
syncMove,
syncCreateEntity,
} from '../util/sync-mutation'
import { findInTree, findInTreeOrThrow } from '../util/find-in-tree'
import { isNameUniqueInFolder } from '../util/is-name-unique-in-folder'
import { isBlockedFilename, isCleanFilename } from '../util/safe-path'
import { useProjectContext } from '../../../shared/context/project-context'
import { useFileTreeData } from '../../../shared/context/file-tree-data-context'
import { useFileTreeSelectable } from './file-tree-selectable'
import {
InvalidFilenameError,
BlockedFilenameError,
DuplicateFilenameError,
DuplicateFilenameMoveError,
} from '../errors'
import { Folder } from '../../../../../types/folder'
import { useReferencesContext } from '@/features/ide-react/context/references-context'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import { fileUrl } from '@/features/utils/fileUrl'
type DroppedFile = File & {
relativePath?: string
}
type DroppedFiles = {
files: DroppedFile[]
targetFolderId: string
}
const FileTreeActionableContext = createContext<
| {
isDeleting: boolean
isRenaming: boolean
isCreatingFile: boolean
isCreatingFolder: boolean
isMoving: boolean
inFlight: boolean
actionedEntities: any | null
newFileCreateMode: any | null
error: any | null
canDelete: boolean
canRename: boolean
canCreate: boolean
parentFolderId: string
selectedFileName: string | null | undefined
isDuplicate: (parentFolderId: string, name: string) => boolean
startRenaming: any
finishRenaming: any
startDeleting: any
finishDeleting: any
finishMoving: any
startCreatingFile: any
startCreatingFolder: any
finishCreatingFolder: any
startCreatingDocOrFile: any
startUploadingDocOrFile: any
finishCreatingDoc: any
finishCreatingLinkedFile: any
cancel: () => void
droppedFiles: { files: File[]; targetFolderId: string } | null
setDroppedFiles: (value: DroppedFiles | null) => void
downloadPath?: string
}
| undefined
>(undefined)
/* eslint-disable no-unused-vars */
enum ACTION_TYPES {
START_RENAME = 'START_RENAME',
START_DELETE = 'START_DELETE',
DELETING = 'DELETING',
START_CREATE_FILE = 'START_CREATE_FILE',
START_CREATE_FOLDER = 'START_CREATE_FOLDER',
CREATING_FILE = 'CREATING_FILE',
CREATING_FOLDER = 'CREATING_FOLDER',
MOVING = 'MOVING',
CANCEL = 'CANCEL',
CLEAR = 'CLEAR',
ERROR = 'ERROR',
}
/* eslint-enable no-unused-vars */
type State = {
isDeleting: boolean
isRenaming: boolean
isCreatingFile: boolean
isCreatingFolder: boolean
isMoving: boolean
inFlight: boolean
actionedEntities: any | null
newFileCreateMode: any | null
error: unknown | null
}
const defaultState: State = {
isDeleting: false,
isRenaming: false,
isCreatingFile: false,
isCreatingFolder: false,
isMoving: false,
inFlight: false,
actionedEntities: null,
newFileCreateMode: null,
error: null,
}
function fileTreeActionableReadOnlyReducer(state: State) {
return state
}
type Action =
| {
type: ACTION_TYPES.START_RENAME
}
| {
type: ACTION_TYPES.START_DELETE
actionedEntities: any | null
}
| {
type: ACTION_TYPES.START_CREATE_FILE
newFileCreateMode: any | null
}
| {
type: ACTION_TYPES.START_CREATE_FOLDER
}
| {
type: ACTION_TYPES.CREATING_FILE
}
| {
type: ACTION_TYPES.CREATING_FOLDER
}
| {
type: ACTION_TYPES.DELETING
}
| {
type: ACTION_TYPES.MOVING
}
| {
type: ACTION_TYPES.CLEAR
}
| {
type: ACTION_TYPES.CANCEL
}
| {
type: ACTION_TYPES.ERROR
error: unknown
}
function fileTreeActionableReducer(state: State, action: Action) {
switch (action.type) {
case ACTION_TYPES.START_RENAME:
return { ...defaultState, isRenaming: true }
case ACTION_TYPES.START_DELETE:
return {
...defaultState,
isDeleting: true,
actionedEntities: action.actionedEntities,
}
case ACTION_TYPES.START_CREATE_FILE:
return {
...defaultState,
isCreatingFile: true,
newFileCreateMode: action.newFileCreateMode,
}
case ACTION_TYPES.START_CREATE_FOLDER:
return { ...defaultState, isCreatingFolder: true }
case ACTION_TYPES.CREATING_FILE:
return {
...defaultState,
isCreatingFile: true,
newFileCreateMode: state.newFileCreateMode,
inFlight: true,
}
case ACTION_TYPES.CREATING_FOLDER:
return { ...defaultState, isCreatingFolder: true, inFlight: true }
case ACTION_TYPES.DELETING:
// keep `actionedEntities` so the entities list remains displayed in the
// delete modal
return {
...defaultState,
isDeleting: true,
inFlight: true,
actionedEntities: state.actionedEntities,
}
case ACTION_TYPES.MOVING:
return {
...defaultState,
isMoving: true,
inFlight: true,
}
case ACTION_TYPES.CLEAR:
return { ...defaultState }
case ACTION_TYPES.CANCEL:
if (state.inFlight) return state
return { ...defaultState }
case ACTION_TYPES.ERROR:
return { ...state, inFlight: false, error: action.error }
default:
throw new Error(`Unknown user action type: ${(action as Action).type}`)
}
}
export const FileTreeActionableProvider: FC = ({ children }) => {
const { _id: projectId } = useProjectContext()
const { fileTreeReadOnly } = useFileTreeData()
const { indexAllReferences } = useReferencesContext()
const { write } = usePermissionsContext()
const [state, dispatch] = useReducer(
fileTreeReadOnly
? fileTreeActionableReadOnlyReducer
: fileTreeActionableReducer,
defaultState
)
const { fileTreeData, dispatchRename, dispatchMove } = useFileTreeData()
const { selectedEntityIds, isRootFolderSelected } = useFileTreeSelectable()
const [droppedFiles, setDroppedFiles] = useState<DroppedFiles | null>(null)
const startRenaming = useCallback(() => {
dispatch({ type: ACTION_TYPES.START_RENAME })
}, [])
// update the entity with the new name immediately in the tree, but revert to
// the old name if the sync fails
const finishRenaming = useCallback(
(newName: string) => {
const selectedEntityId = Array.from(selectedEntityIds)[0]
const found = findInTreeOrThrow(fileTreeData, selectedEntityId)
const oldName = found.entity.name
if (newName === oldName) {
return dispatch({ type: ACTION_TYPES.CLEAR })
}
const error = validateRename(fileTreeData, found, newName)
if (error) return dispatch({ type: ACTION_TYPES.ERROR, error })
dispatch({ type: ACTION_TYPES.CLEAR })
dispatchRename(selectedEntityId, newName)
return syncRename(projectId, found.type, found.entity._id, newName).catch(
error => {
dispatchRename(selectedEntityId, oldName)
// The state from this error action isn't used anywhere right now
// but we need to handle the error for linting
dispatch({ type: ACTION_TYPES.ERROR, error })
}
)
},
[dispatchRename, fileTreeData, projectId, selectedEntityIds]
)
const isDuplicate = useCallback(
(parentFolderId: string, name: string) => {
return !isNameUniqueInFolder(fileTreeData, parentFolderId, name)
},
[fileTreeData]
)
// init deletion flow (this will open the delete modal).
// A copy of the selected entities is set as `actionedEntities` so it is kept
// unchanged as the entities are deleted and the selection is updated
const startDeleting = useCallback(() => {
const actionedEntities = Array.from(selectedEntityIds).map(
entityId => findInTreeOrThrow(fileTreeData, entityId).entity
)
dispatch({ type: ACTION_TYPES.START_DELETE, actionedEntities })
}, [fileTreeData, selectedEntityIds])
// deletes entities in series. Tree will be updated via the socket event
const finishDeleting = useCallback(() => {
dispatch({ type: ACTION_TYPES.DELETING })
let shouldReindexReferences = false
return (
mapSeries(Array.from(selectedEntityIds), id => {
const found = findInTreeOrThrow(fileTreeData, id)
shouldReindexReferences =
shouldReindexReferences || /\.bib$/.test(found.entity.name)
return syncDelete(projectId, found.type, found.entity._id).catch(
error => {
// throw unless 404
if (error.info.statusCode !== 404) {
throw error
}
}
)
})
// @ts-ignore (TODO: improve mapSeries types)
.then(() => {
if (shouldReindexReferences) {
indexAllReferences(true)
}
dispatch({ type: ACTION_TYPES.CLEAR })
})
.catch((error: Error) => {
// set an error and allow user to retry
dispatch({ type: ACTION_TYPES.ERROR, error })
})
)
}, [fileTreeData, projectId, selectedEntityIds, indexAllReferences])
// moves entities. Tree is updated immediately and data are sync'd after.
const finishMoving = useCallback(
(toFolderId: string, draggedEntityIds: Set<string>) => {
dispatch({ type: ACTION_TYPES.MOVING })
// find entities and filter out no-ops and nested files
const founds = Array.from(draggedEntityIds)
.map(draggedEntityId =>
findInTreeOrThrow(fileTreeData, draggedEntityId)
)
.filter(
found =>
found.parentFolderId !== toFolderId &&
!draggedEntityIds.has(found.parentFolderId)
)
// make sure all entities can be moved, return early otherwise
const isMoveToRoot = toFolderId === fileTreeData._id
const validationError = founds
.map(found =>
validateMove(fileTreeData, toFolderId, found, isMoveToRoot)
)
.find(error => error)
if (validationError) {
return dispatch({ type: ACTION_TYPES.ERROR, error: validationError })
}
// keep track of old parent folder ids so we can revert entities if sync fails
const oldParentFolderIds: Record<string, string> = {}
let isMoveFailed = false
// dispatch moves immediately
founds.forEach(found => {
oldParentFolderIds[found.entity._id] = found.parentFolderId
dispatchMove(found.entity._id, toFolderId)
})
// sync dispatched moves after
return (
mapSeries(founds, async found => {
try {
await syncMove(projectId, found.type, found.entity._id, toFolderId)
} catch (error) {
isMoveFailed = true
dispatchMove(found.entity._id, oldParentFolderIds[found.entity._id])
dispatch({ type: ACTION_TYPES.ERROR, error })
}
})
// @ts-ignore (TODO: improve mapSeries types)
.then(() => {
if (!isMoveFailed) {
dispatch({ type: ACTION_TYPES.CLEAR })
}
})
)
},
[dispatchMove, fileTreeData, projectId]
)
const startCreatingFolder = useCallback(() => {
dispatch({ type: ACTION_TYPES.START_CREATE_FOLDER })
}, [])
const parentFolderId = useMemo(() => {
return getSelectedParentFolderId(
fileTreeData,
selectedEntityIds,
isRootFolderSelected
)
}, [fileTreeData, selectedEntityIds, isRootFolderSelected])
// return the name of the selected file or doc if there is only one selected
const selectedFileName = useMemo(() => {
if (selectedEntityIds.size === 1) {
const [selectedEntityId] = selectedEntityIds
const selectedEntity = findInTree(fileTreeData, selectedEntityId)
return selectedEntity?.entity?.name
}
return null
}, [fileTreeData, selectedEntityIds])
const finishCreatingEntity = useCallback(
entity => {
const error = validateCreate(fileTreeData, parentFolderId, entity)
if (error) {
return Promise.reject(error)
}
return syncCreateEntity(projectId, parentFolderId, entity)
},
[fileTreeData, parentFolderId, projectId]
)
const finishCreatingFolder = useCallback(
name => {
dispatch({ type: ACTION_TYPES.CREATING_FOLDER })
return finishCreatingEntity({ endpoint: 'folder', name })
.then(() => {
dispatch({ type: ACTION_TYPES.CLEAR })
})
.catch(error => {
dispatch({ type: ACTION_TYPES.ERROR, error })
})
},
[finishCreatingEntity]
)
const startCreatingFile = useCallback(newFileCreateMode => {
dispatch({ type: ACTION_TYPES.START_CREATE_FILE, newFileCreateMode })
}, [])
const startCreatingDocOrFile = useCallback(() => {
startCreatingFile('doc')
}, [startCreatingFile])
const startUploadingDocOrFile = useCallback(() => {
startCreatingFile('upload')
}, [startCreatingFile])
const finishCreatingDocOrFile = useCallback(
entity => {
dispatch({ type: ACTION_TYPES.CREATING_FILE })
return finishCreatingEntity(entity)
.then(() => {
dispatch({ type: ACTION_TYPES.CLEAR })
})
.catch(error => {
dispatch({ type: ACTION_TYPES.ERROR, error })
})
},
[finishCreatingEntity]
)
const finishCreatingDoc = useCallback(
entity => {
entity.endpoint = 'doc'
return finishCreatingDocOrFile(entity)
},
[finishCreatingDocOrFile]
)
const finishCreatingLinkedFile = useCallback(
entity => {
entity.endpoint = 'linked_file'
return finishCreatingDocOrFile(entity)
},
[finishCreatingDocOrFile]
)
const cancel = useCallback(() => {
dispatch({ type: ACTION_TYPES.CANCEL })
}, [])
// listen for `file-tree.start-creating` events
useEffect(() => {
function handleEvent(event: Event) {
dispatch({
type: ACTION_TYPES.START_CREATE_FILE,
newFileCreateMode: (event as CustomEvent<{ mode: string }>).detail.mode,
})
}
window.addEventListener('file-tree.start-creating', handleEvent)
return () => {
window.removeEventListener('file-tree.start-creating', handleEvent)
}
}, [])
// build the path for downloading a single file or doc
const downloadPath = useMemo(() => {
if (selectedEntityIds.size === 1) {
const [selectedEntityId] = selectedEntityIds
const selectedEntity = findInTree(fileTreeData, selectedEntityId)
if (selectedEntity?.type === 'fileRef') {
return fileUrl(projectId, selectedEntityId, selectedEntity.entity.hash)
}
if (selectedEntity?.type === 'doc') {
return `/project/${projectId}/doc/${selectedEntityId}/download`
}
}
}, [fileTreeData, projectId, selectedEntityIds])
const value = useMemo(
() => ({
canDelete: write && selectedEntityIds.size > 0 && !isRootFolderSelected,
canRename: write && selectedEntityIds.size === 1 && !isRootFolderSelected,
canCreate: write && selectedEntityIds.size < 2,
...state,
parentFolderId,
selectedFileName,
isDuplicate,
startRenaming,
finishRenaming,
startDeleting,
finishDeleting,
finishMoving,
startCreatingFile,
startCreatingFolder,
finishCreatingFolder,
startCreatingDocOrFile,
startUploadingDocOrFile,
finishCreatingDoc,
finishCreatingLinkedFile,
cancel,
droppedFiles,
setDroppedFiles,
downloadPath,
}),
[
cancel,
downloadPath,
droppedFiles,
finishCreatingDoc,
finishCreatingFolder,
finishCreatingLinkedFile,
finishDeleting,
finishMoving,
finishRenaming,
isDuplicate,
isRootFolderSelected,
parentFolderId,
selectedEntityIds.size,
selectedFileName,
startCreatingDocOrFile,
startCreatingFile,
startCreatingFolder,
startDeleting,
startRenaming,
startUploadingDocOrFile,
state,
write,
]
)
return (
<FileTreeActionableContext.Provider value={value}>
{children}
</FileTreeActionableContext.Provider>
)
}
export function useFileTreeActionable() {
const context = useContext(FileTreeActionableContext)
if (!context) {
throw new Error(
'useFileTreeActionable is only available inside FileTreeActionableProvider'
)
}
return context
}
function getSelectedParentFolderId(
fileTreeData: Folder,
selectedEntityIds: Set<string>,
isRootFolderSelected: boolean
) {
if (isRootFolderSelected) {
return fileTreeData._id
}
// we expect only one entity to be selected in that case, so we pick the first
const selectedEntityId = Array.from(selectedEntityIds)[0]
if (!selectedEntityId) {
// in some cases no entities are selected. Return the root folder id then.
return fileTreeData._id
}
const found = findInTree(fileTreeData, selectedEntityId)
if (!found) {
// if the entity isn't in the tree, return the root folder id.
return fileTreeData._id
}
return found.type === 'folder' ? found.entity._id : found.parentFolderId
}
function validateCreate(
fileTreeData: Folder,
parentFolderId: string,
entity: { name: string; endpoint: string }
) {
if (!isCleanFilename(entity.name)) {
return new InvalidFilenameError()
}
if (!isNameUniqueInFolder(fileTreeData, parentFolderId, entity.name)) {
return new DuplicateFilenameError()
}
// check that the name of a file is allowed, if creating in the root folder
const isMoveToRoot = parentFolderId === fileTreeData._id
const isFolder = entity.endpoint === 'folder'
if (isMoveToRoot && !isFolder && isBlockedFilename(entity.name)) {
return new BlockedFilenameError()
}
}
function validateRename(
fileTreeData: Folder,
found: { parentFolderId: string; path: string[]; type: string },
newName: string
) {
if (!isCleanFilename(newName)) {
return new InvalidFilenameError()
}
if (!isNameUniqueInFolder(fileTreeData, found.parentFolderId, newName)) {
return new DuplicateFilenameError()
}
const isTopLevel = found.path.length === 1
const isFolder = found.type === 'folder'
if (isTopLevel && !isFolder && isBlockedFilename(newName)) {
return new BlockedFilenameError()
}
}
function validateMove(
fileTreeData: Folder,
toFolderId: string,
found: { entity: { name: string }; type: string },
isMoveToRoot: boolean
) {
if (!isNameUniqueInFolder(fileTreeData, toFolderId, found.entity.name)) {
const error = new DuplicateFilenameMoveError()
;(error as DuplicateFilenameMoveError & { entityName: string }).entityName =
found.entity.name
return error
}
const isFolder = found.type === 'folder'
if (isMoveToRoot && !isFolder && isBlockedFilename(found.entity.name)) {
return new BlockedFilenameError()
}
}

View File

@@ -0,0 +1,30 @@
import { createContext, FC, useContext, useState } from 'react'
const FileTreeCreateFormContext = createContext<
{ valid: boolean; setValid: (value: boolean) => void } | undefined
>(undefined)
export const useFileTreeCreateForm = () => {
const context = useContext(FileTreeCreateFormContext)
if (!context) {
throw new Error(
'useFileTreeCreateForm is only available inside FileTreeCreateFormProvider'
)
}
return context
}
const FileTreeCreateFormProvider: FC = ({ children }) => {
// is the form valid
const [valid, setValid] = useState(false)
return (
<FileTreeCreateFormContext.Provider value={{ valid, setValid }}>
{children}
</FileTreeCreateFormContext.Provider>
)
}
export default FileTreeCreateFormProvider

View File

@@ -0,0 +1,58 @@
import { createContext, FC, useContext, useMemo, useReducer } from 'react'
import { isCleanFilename } from '../util/safe-path'
const FileTreeCreateNameContext = createContext<
| {
name: string
touchedName: boolean
validName: boolean
setName: (name: string) => void
}
| undefined
>(undefined)
export const useFileTreeCreateName = () => {
const context = useContext(FileTreeCreateNameContext)
if (!context) {
throw new Error(
'useFileTreeCreateName is only available inside FileTreeCreateNameProvider'
)
}
return context
}
type State = {
name: string
touchedName: boolean
}
const FileTreeCreateNameProvider: FC<{ initialName?: string }> = ({
children,
initialName = '',
}) => {
const [state, setName] = useReducer(
(state: State, name: string) => ({
name, // the file name
touchedName: true, // whether the name has been edited
}),
{
name: initialName,
touchedName: false,
}
)
// validate the file name
const validName = useMemo(() => isCleanFilename(state.name.trim()), [state])
return (
<FileTreeCreateNameContext.Provider
value={{ ...state, setName, validName }}
>
{children}
</FileTreeCreateNameContext.Provider>
)
}
export default FileTreeCreateNameProvider

View File

@@ -0,0 +1,183 @@
import { useEffect, useState, FC, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import getDroppedFiles from '@uppy/utils/lib/getDroppedFiles'
import { DndProvider, DragSourceMonitor, useDrag, useDrop } from 'react-dnd'
import {
HTML5Backend,
getEmptyImage,
NativeTypes,
} from 'react-dnd-html5-backend'
import {
findAllInTreeOrThrow,
findAllFolderIdsInFolders,
} from '../util/find-in-tree'
import { useFileTreeActionable } from './file-tree-actionable'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { useFileTreeSelectable } from '../contexts/file-tree-selectable'
import { isAcceptableFile } from '@/features/file-tree/util/is-acceptable-file'
import { FileTreeFindResult } from '@/features/ide-react/types/file-tree'
const DRAGGABLE_TYPE = 'ENTITY'
export const FileTreeDraggableProvider: FC<{
fileTreeContainer?: HTMLDivElement
}> = ({ fileTreeContainer, children }) => {
const options = useMemo(
() => ({ rootElement: fileTreeContainer }),
[fileTreeContainer]
)
return (
<DndProvider backend={HTML5Backend} options={options}>
{children}
</DndProvider>
)
}
type DragObject = {
type: string
title: string
forbiddenFolderIds: Set<string>
draggedEntityIds: Set<string>
}
type DropResult = {
targetEntityId: string
dropEffect: DataTransfer['dropEffect']
}
export function useDraggable(draggedEntityId: string) {
const { t } = useTranslation()
const { fileTreeData, fileTreeReadOnly } = useFileTreeData()
const { selectedEntityIds, isRootFolderSelected } = useFileTreeSelectable()
const { finishMoving } = useFileTreeActionable()
const [isDraggable, setIsDraggable] = useState(true)
const [, dragRef, preview] = useDrag({
type: DRAGGABLE_TYPE,
item() {
const draggedEntityIds = getDraggedEntityIds(
isRootFolderSelected ? new Set() : selectedEntityIds,
draggedEntityId
)
const draggedItems = findAllInTreeOrThrow(fileTreeData, draggedEntityIds)
return {
type: DRAGGABLE_TYPE,
title: getDraggedTitle(draggedItems, t),
forbiddenFolderIds: getForbiddenFolderIds(draggedItems),
draggedEntityIds,
}
},
canDrag() {
return !fileTreeReadOnly && isDraggable
},
end(item: DragObject, monitor: DragSourceMonitor<DragObject, DropResult>) {
if (monitor.didDrop()) {
const result = monitor.getDropResult()
if (result) {
finishMoving(result.targetEntityId, item.draggedEntityIds) // TODO: use result.dropEffect
}
}
},
})
// remove the automatic preview as we're using a custom preview via
// FileTreeDraggablePreviewLayer
useEffect(() => {
preview(getEmptyImage())
}, [preview])
return { dragRef, setIsDraggable }
}
export function useDroppable(targetEntityId: string) {
const { setDroppedFiles, startUploadingDocOrFile } = useFileTreeActionable()
const [{ isOver }, dropRef] = useDrop({
accept: [DRAGGABLE_TYPE, NativeTypes.FILE],
canDrop(item: DragObject, monitor) {
if (!monitor.isOver({ shallow: true })) {
return false
}
return !(
item.type === DRAGGABLE_TYPE &&
item.forbiddenFolderIds.has(targetEntityId)
)
},
drop(item, monitor) {
// monitor.didDrop() returns true if the drop was already handled by a nested child
if (monitor.didDrop()) {
return
}
// item(s) dragged within the file tree
if (item.type === DRAGGABLE_TYPE) {
return { targetEntityId }
}
// native file(s) dragged in from outside
getDroppedFiles(item as unknown as DataTransfer)
.then(files =>
files.filter(file =>
// note: getDroppedFiles normalises webkitRelativePath to relativePath
isAcceptableFile(file.name, (file as any).relativePath)
)
)
.then(files => {
setDroppedFiles({ files, targetFolderId: targetEntityId })
startUploadingDocOrFile()
})
},
collect(monitor) {
return {
isOver: monitor.canDrop(),
}
},
})
return { dropRef, isOver }
}
// Get the list of dragged entity ids. If the dragged entity is one of the
// selected entities then all the selected entites are dragged entities,
// otherwise it's the dragged entity only.
function getDraggedEntityIds(
selectedEntityIds: Set<string>,
draggedEntityId: string
) {
if (selectedEntityIds.size > 1 && selectedEntityIds.has(draggedEntityId)) {
// dragging the multi-selected entities
return new Set(selectedEntityIds)
} else {
// not dragging the selection; only the current item
return new Set([draggedEntityId])
}
}
// Get the draggable title. This is the name of the dragged entities if there's
// only one, otherwise it's the number of dragged entities.
function getDraggedTitle(
draggedItems: Set<any>,
t: (key: string, options: Record<string, any>) => void
) {
if (draggedItems.size === 1) {
const draggedItem = Array.from(draggedItems)[0]
return draggedItem.entity.name
}
return t('n_items', { count: draggedItems.size })
}
// Get all children folder ids of any of the dragged items.
function getForbiddenFolderIds(draggedItems: Set<FileTreeFindResult>) {
const draggedFoldersArray = Array.from(draggedItems)
.filter(draggedItem => {
return draggedItem.type === 'folder'
})
.map(draggedItem => draggedItem.entity)
const draggedFolders = new Set(draggedFoldersArray)
return findAllFolderIdsInFolders(draggedFolders)
}

View File

@@ -0,0 +1,54 @@
import { createContext, FC, useContext, useState } from 'react'
type ContextMenuCoords = { top: number; left: number }
const FileTreeMainContext = createContext<
| {
refProviders: object
setRefProviderEnabled: (provider: string, value: boolean) => void
setStartedFreeTrial: (value: boolean) => void
contextMenuCoords: ContextMenuCoords | null
setContextMenuCoords: (value: ContextMenuCoords | null) => void
}
| undefined
>(undefined)
export function useFileTreeMainContext() {
const context = useContext(FileTreeMainContext)
if (!context) {
throw new Error(
'useFileTreeMainContext is only available inside FileTreeMainProvider'
)
}
return context
}
export const FileTreeMainProvider: FC<{
refProviders: object
setRefProviderEnabled: (provider: string, value: boolean) => void
setStartedFreeTrial: (value: boolean) => void
}> = ({
refProviders,
setRefProviderEnabled,
setStartedFreeTrial,
children,
}) => {
const [contextMenuCoords, setContextMenuCoords] =
useState<ContextMenuCoords | null>(null)
return (
<FileTreeMainContext.Provider
value={{
refProviders,
setRefProviderEnabled,
setStartedFreeTrial,
contextMenuCoords,
setContextMenuCoords,
}}
>
{children}
</FileTreeMainContext.Provider>
)
}

View File

@@ -0,0 +1,81 @@
import { createContext, FC, useCallback, useContext, useMemo } from 'react'
import { Folder } from '../../../../../types/folder'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import getMeta from '@/utils/meta'
import {
findEntityByPath,
previewByPath,
dirname,
FindResult,
pathInFolder,
} from '@/features/file-tree/util/path'
import { PreviewPath } from '../../../../../types/preview-path'
type FileTreePathContextValue = {
pathInFolder: (id: string) => string | null
findEntityByPath: (path: string) => FindResult | null
previewByPath: (path: string) => PreviewPath | null
dirname: (id: string) => string | null
}
export const FileTreePathContext = createContext<
FileTreePathContextValue | undefined
>(undefined)
export const FileTreePathProvider: FC = ({ children }) => {
const { fileTreeData }: { fileTreeData: Folder } = useFileTreeData()
const projectId = getMeta('ol-project_id')
const pathInFileTree = useCallback(
(id: string) => pathInFolder(fileTreeData, id),
[fileTreeData]
)
const findEntityByPathInFileTree = useCallback(
(path: string) => findEntityByPath(fileTreeData, path),
[fileTreeData]
)
const previewByPathInFileTree = useCallback(
(path: string) => previewByPath(fileTreeData, projectId, path),
[fileTreeData, projectId]
)
const dirnameInFileTree = useCallback(
(id: string) => dirname(fileTreeData, id),
[fileTreeData]
)
const value = useMemo<FileTreePathContextValue>(
() => ({
pathInFolder: pathInFileTree,
findEntityByPath: findEntityByPathInFileTree,
previewByPath: previewByPathInFileTree,
dirname: dirnameInFileTree,
}),
[
pathInFileTree,
findEntityByPathInFileTree,
previewByPathInFileTree,
dirnameInFileTree,
]
)
return (
<FileTreePathContext.Provider value={value}>
{children}
</FileTreePathContext.Provider>
)
}
export function useFileTreePathContext(): FileTreePathContextValue {
const context = useContext(FileTreePathContext)
if (!context) {
throw new Error(
'useFileTreePathContext is only available inside FileTreePathProvider'
)
}
return context
}

View File

@@ -0,0 +1,418 @@
import {
createContext,
useCallback,
useContext,
useReducer,
useEffect,
useMemo,
useState,
FC,
} from 'react'
import classNames from 'classnames'
import _ from 'lodash'
import { findInTree, findInTreeOrThrow } from '../util/find-in-tree'
import { useFileTreeData } from '../../../shared/context/file-tree-data-context'
import { useProjectContext } from '../../../shared/context/project-context'
import { useLayoutContext } from '../../../shared/context/layout-context'
import usePersistedState from '../../../shared/hooks/use-persisted-state'
import usePreviousValue from '../../../shared/hooks/use-previous-value'
import { useFileTreeMainContext } from '@/features/file-tree/contexts/file-tree-main'
import { FindResult } from '@/features/file-tree/util/path'
import { fileCollator } from '@/features/file-tree/util/file-collator'
import { Folder } from '../../../../../types/folder'
import { FileTreeEntity } from '../../../../../types/file-tree-entity'
import { isMac } from '@/shared/utils/os'
import useEventListener from '@/shared/hooks/use-event-listener'
const FileTreeSelectableContext = createContext<
| {
selectedEntityIds: Set<string>
isRootFolderSelected: boolean
selectOrMultiSelectEntity: (
id: string | string[],
multiple?: boolean
) => void
setIsRootFolderSelected: (value: boolean) => void
selectedEntityParentIds: Set<string>
select: (id: string | string[]) => void
unselect: (id: string) => void
}
| undefined
>(undefined)
/* eslint-disable no-unused-vars */
enum ACTION_TYPES {
SELECT = 'SELECT',
MULTI_SELECT = 'MULTI_SELECT',
UNSELECT = 'UNSELECT',
}
/* eslint-enable no-unused-vars */
type Action =
| {
type: ACTION_TYPES.SELECT
id: string
}
| {
type: ACTION_TYPES.MULTI_SELECT
id: string
}
| {
type: ACTION_TYPES.UNSELECT
id: string
}
function fileTreeSelectableReadWriteReducer(
selectedEntityIds: Set<string>,
action: Action
) {
switch (action.type) {
case ACTION_TYPES.SELECT: {
// reset selection
return new Set(Array.isArray(action.id) ? action.id : [action.id])
}
case ACTION_TYPES.MULTI_SELECT: {
const selectedEntityIdsCopy = new Set(selectedEntityIds)
if (selectedEntityIdsCopy.has(action.id)) {
// entity already selected
if (selectedEntityIdsCopy.size > 1) {
// entity already multi-selected; remove from set
selectedEntityIdsCopy.delete(action.id)
}
} else {
// entity not selected: add to set
selectedEntityIdsCopy.add(action.id)
}
return selectedEntityIdsCopy
}
case ACTION_TYPES.UNSELECT: {
const selectedEntityIdsCopy = new Set(selectedEntityIds)
selectedEntityIdsCopy.delete(action.id)
return selectedEntityIdsCopy
}
default:
throw new Error(
`Unknown selectable action type: ${(action as Action).type}`
)
}
}
function fileTreeSelectableReadOnlyReducer(
selectedEntityIds: Set<string>,
action: Action
) {
switch (action.type) {
case ACTION_TYPES.SELECT:
return new Set([action.id])
case ACTION_TYPES.MULTI_SELECT:
case ACTION_TYPES.UNSELECT:
return selectedEntityIds
default:
throw new Error(
`Unknown selectable action type: ${(action as Action).type}`
)
}
}
export const FileTreeSelectableProvider: FC<{
onSelect: (value: FindResult[]) => void
}> = ({ onSelect, children }) => {
const { _id: projectId, rootDocId } = useProjectContext()
const [initialSelectedEntityId] = usePersistedState(
`doc.open_id.${projectId}`,
rootDocId
)
const { fileTreeData, setSelectedEntities, fileTreeReadOnly } =
useFileTreeData()
const [isRootFolderSelected, setIsRootFolderSelected] = useState(false)
const [selectedEntityIds, dispatch] = useReducer(
fileTreeReadOnly
? fileTreeSelectableReadOnlyReducer
: fileTreeSelectableReadWriteReducer,
null,
() => {
if (!initialSelectedEntityId) return new Set<string>()
// the entity with id=initialSelectedEntityId might not exist in the tree
// anymore. This checks that it exists before initialising the reducer
// with the id.
if (findInTree(fileTreeData, initialSelectedEntityId))
return new Set([initialSelectedEntityId])
// the entity doesn't exist anymore; don't select any files
return new Set<string>()
}
)
const [selectedEntityParentIds, setSelectedEntityParentIds] = useState<
Set<string>
>(new Set())
// fills `selectedEntityParentIds` set
useEffect(() => {
const ids = new Set<string>()
selectedEntityIds.forEach(id => {
const found = findInTree(fileTreeData, id)
if (found) {
found.path.forEach((pathItem: any) => ids.add(pathItem))
}
})
setSelectedEntityParentIds(ids)
}, [fileTreeData, selectedEntityIds])
// calls `onSelect` on entities selection
const previousSelectedEntityIds = usePreviousValue(selectedEntityIds)
useEffect(() => {
if (_.isEqual(selectedEntityIds, previousSelectedEntityIds)) {
return
}
const _selectedEntities = Array.from(selectedEntityIds)
.map(id => findInTree(fileTreeData, id))
.filter(entity => entity !== null)
onSelect(_selectedEntities)
setSelectedEntities(_selectedEntities)
}, [
fileTreeData,
selectedEntityIds,
previousSelectedEntityIds,
onSelect,
setSelectedEntities,
])
// Synchronize the file tree when openFileWithId or openDocWithId is called on the editor
// manager context from elsewhere. If the file tree does change, it will
// trigger the onSelect handler in this component, which will update the local
// state.
useEventListener(
'entity:opened',
useCallback(
(event: CustomEvent<string>) => {
const found = findInTree(fileTreeData, event.detail)
if (!found) return
dispatch({ type: ACTION_TYPES.SELECT, id: found.entity._id })
},
[fileTreeData]
)
)
const select = useCallback(id => {
dispatch({ type: ACTION_TYPES.SELECT, id })
}, [])
const unselect = useCallback(id => {
dispatch({ type: ACTION_TYPES.UNSELECT, id })
}, [])
const selectOrMultiSelectEntity = useCallback((id, isMultiSelect) => {
const actionType = isMultiSelect
? ACTION_TYPES.MULTI_SELECT
: ACTION_TYPES.SELECT
dispatch({ type: actionType, id })
}, [])
// TODO: wrap in useMemo
const value = {
selectedEntityIds,
selectedEntityParentIds,
select,
unselect,
selectOrMultiSelectEntity,
isRootFolderSelected,
setIsRootFolderSelected,
}
return (
<FileTreeSelectableContext.Provider value={value}>
{children}
</FileTreeSelectableContext.Provider>
)
}
export function useSelectableEntity(id: string, type: string) {
const { view, setView } = useLayoutContext()
const { setContextMenuCoords } = useFileTreeMainContext()
const { fileTreeData } = useFileTreeData()
const {
selectedEntityIds,
selectOrMultiSelectEntity,
isRootFolderSelected,
setIsRootFolderSelected,
} = useFileTreeSelectable()
const isSelected = selectedEntityIds.has(id)
const buildSelectedRange = useCallback(
id => {
const selected = []
let started = false
for (const itemId of sortedItems(fileTreeData)) {
if (itemId === id) {
selected.push(itemId)
if (started) {
break
} else {
started = true
}
} else if (selectedEntityIds.has(itemId)) {
// TODO: should only look at latest ("main") selected item
selected.push(itemId)
if (started) {
break
} else {
started = true
}
} else if (started) {
selected.push(itemId)
}
}
return selected
},
[fileTreeData, selectedEntityIds]
)
const chooseView = useCallback(() => {
for (const id of selectedEntityIds) {
const selectedEntity = findInTreeOrThrow(fileTreeData, id)
if (selectedEntity.type === 'doc') {
return 'editor'
}
if (selectedEntity.type === 'fileRef') {
return 'file'
}
if (selectedEntity.type === 'folder') {
return view
}
}
return null
}, [fileTreeData, selectedEntityIds, view])
const handleEvent = useCallback(
ev => {
ev.stopPropagation()
// use Command (macOS) or Ctrl (other OS) to select multiple items,
// as long as the root folder wasn't selected
const multiSelect =
!isRootFolderSelected && (isMac ? ev.metaKey : ev.ctrlKey)
setIsRootFolderSelected(false)
if (ev.shiftKey) {
// use Shift to select a range of items
selectOrMultiSelectEntity(buildSelectedRange(id))
} else {
selectOrMultiSelectEntity(id, multiSelect)
}
if (type === 'file') {
setView('file')
} else if (type === 'doc') {
setView('editor')
} else if (type === 'folder') {
setView(chooseView())
}
},
[
id,
isRootFolderSelected,
setIsRootFolderSelected,
selectOrMultiSelectEntity,
setView,
type,
buildSelectedRange,
chooseView,
]
)
const handleClick = useCallback(
ev => {
handleEvent(ev)
if (!ev.ctrlKey && !ev.metaKey) {
setContextMenuCoords(null)
}
},
[handleEvent, setContextMenuCoords]
)
const handleKeyPress = useCallback(
ev => {
if (ev.key === 'Enter' || ev.key === ' ') {
handleEvent(ev)
}
},
[handleEvent]
)
const handleContextMenu = useCallback(
ev => {
// make sure the right-clicked entity gets selected
if (!selectedEntityIds.has(id)) {
handleEvent(ev)
}
},
[id, handleEvent, selectedEntityIds]
)
const isVisuallySelected =
!isRootFolderSelected && isSelected && view !== 'pdf'
const props = useMemo(
() => ({
className: classNames({ selected: isVisuallySelected }),
'aria-selected': isVisuallySelected,
onClick: handleClick,
onContextMenu: handleContextMenu,
onKeyPress: handleKeyPress,
}),
[handleClick, handleContextMenu, handleKeyPress, isVisuallySelected]
)
return { isSelected, props }
}
export function useFileTreeSelectable() {
const context = useContext(FileTreeSelectableContext)
if (!context) {
throw new Error(
`useFileTreeSelectable is only available inside FileTreeSelectableProvider`
)
}
return context
}
const alphabetical = (a: FileTreeEntity, b: FileTreeEntity) =>
fileCollator.compare(a.name, b.name)
function* sortedItems(folder: Folder): Generator<string> {
yield folder._id
const folders = [...folder.folders].sort(alphabetical)
for (const subfolder of folders) {
for (const id of sortedItems(subfolder)) {
yield id
}
}
const files = [...folder.docs, ...folder.fileRefs].sort(alphabetical)
for (const file of files) {
yield file._id
}
}