first commit
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownMenu,
|
||||
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
|
||||
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
|
||||
import { useFileTreeMainContext } from '../contexts/file-tree-main'
|
||||
|
||||
import FileTreeItemMenuItems from './file-tree-item/file-tree-item-menu-items'
|
||||
|
||||
function FileTreeContextMenu() {
|
||||
const { fileTreeReadOnly } = useFileTreeData()
|
||||
const { contextMenuCoords, setContextMenuCoords } = useFileTreeMainContext()
|
||||
const toggleButtonRef = useRef<HTMLButtonElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (contextMenuCoords) {
|
||||
toggleButtonRef.current = document.querySelector(
|
||||
'.entity-menu-toggle'
|
||||
) as HTMLButtonElement | null
|
||||
}
|
||||
}, [contextMenuCoords])
|
||||
|
||||
if (!contextMenuCoords || fileTreeReadOnly) return null
|
||||
|
||||
function close() {
|
||||
setContextMenuCoords(null)
|
||||
if (toggleButtonRef.current) {
|
||||
// A11y - Move the focus back to the toggle button when the context menu closes by pressing the Esc key
|
||||
toggleButtonRef.current.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggle(wantOpen: boolean) {
|
||||
if (!wantOpen) close()
|
||||
}
|
||||
|
||||
// A11y - Close the context menu when the user presses the Tab key
|
||||
// Focus should move to the next element in the filetree
|
||||
function handleKeyDown(event: React.KeyboardEvent<Element>) {
|
||||
if (event.key === 'Tab') {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div style={contextMenuCoords} className="context-menu">
|
||||
<Dropdown
|
||||
show
|
||||
drop={
|
||||
document.body.offsetHeight / contextMenuCoords.top < 2 &&
|
||||
document.body.offsetHeight - contextMenuCoords.top < 250
|
||||
? 'up'
|
||||
: 'down'
|
||||
}
|
||||
focusFirstItemOnShow // A11y - Focus the first item in the context menu when it opens since the menu is rendered at the root level
|
||||
onKeyDown={handleKeyDown}
|
||||
onToggle={handleToggle}
|
||||
>
|
||||
<DropdownMenu
|
||||
className="dropdown-menu-sm-width"
|
||||
id="dropdown-file-tree-context-menu"
|
||||
>
|
||||
<FileTreeItemMenuItems />
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
export default FileTreeContextMenu
|
@@ -0,0 +1,43 @@
|
||||
import { FileTreeMainProvider } from '../contexts/file-tree-main'
|
||||
import { FileTreeActionableProvider } from '../contexts/file-tree-actionable'
|
||||
import { FileTreeSelectableProvider } from '../contexts/file-tree-selectable'
|
||||
import { FileTreeDraggableProvider } from '../contexts/file-tree-draggable'
|
||||
import { FC } from 'react'
|
||||
|
||||
// renders all the contexts needed for the file tree:
|
||||
// FileTreeMain: generic store
|
||||
// FileTreeActionable: global UI state for actions (rename, delete, etc.)
|
||||
// FileTreeMutable: provides entities mutation operations
|
||||
// FileTreeSelectable: handles selection and multi-selection
|
||||
const FileTreeContext: FC<{
|
||||
refProviders: Record<string, boolean>
|
||||
setRefProviderEnabled: (provider: string, value: boolean) => void
|
||||
setStartedFreeTrial: (value: boolean) => void
|
||||
onSelect: () => void
|
||||
fileTreeContainer?: HTMLDivElement
|
||||
}> = ({
|
||||
refProviders,
|
||||
setRefProviderEnabled,
|
||||
setStartedFreeTrial,
|
||||
onSelect,
|
||||
fileTreeContainer,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<FileTreeMainProvider
|
||||
refProviders={refProviders}
|
||||
setRefProviderEnabled={setRefProviderEnabled}
|
||||
setStartedFreeTrial={setStartedFreeTrial}
|
||||
>
|
||||
<FileTreeSelectableProvider onSelect={onSelect}>
|
||||
<FileTreeActionableProvider>
|
||||
<FileTreeDraggableProvider fileTreeContainer={fileTreeContainer}>
|
||||
{children}
|
||||
</FileTreeDraggableProvider>
|
||||
</FileTreeActionableProvider>
|
||||
</FileTreeSelectableProvider>
|
||||
</FileTreeMainProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileTreeContext
|
@@ -0,0 +1,9 @@
|
||||
import OLNotification from '@/features/ui/components/ol/ol-notification'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
export default function DangerMessage({ children }) {
|
||||
return <OLNotification type="error" content={children} />
|
||||
}
|
||||
DangerMessage.propTypes = {
|
||||
children: PropTypes.any.isRequired,
|
||||
}
|
@@ -0,0 +1,124 @@
|
||||
import PropTypes from 'prop-types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FetchError } from '../../../../infrastructure/fetch-json'
|
||||
import RedirectToLogin from './redirect-to-login'
|
||||
import {
|
||||
BlockedFilenameError,
|
||||
DuplicateFilenameError,
|
||||
InvalidFilenameError,
|
||||
} from '../../errors'
|
||||
import DangerMessage from './danger-message'
|
||||
|
||||
export default function ErrorMessage({ error }) {
|
||||
const { t } = useTranslation()
|
||||
const fileNameLimit = 150
|
||||
|
||||
// the error is a string
|
||||
// TODO: translate? always? is this a key or a message?
|
||||
if (typeof error === 'string') {
|
||||
switch (error) {
|
||||
case 'name-exists':
|
||||
return <DangerMessage>{t('file_already_exists')}</DangerMessage>
|
||||
|
||||
case 'too-many-files':
|
||||
return <DangerMessage>{t('project_has_too_many_files')}</DangerMessage>
|
||||
|
||||
case 'remote-service-error':
|
||||
return <DangerMessage>{t('remote_service_error')}</DangerMessage>
|
||||
|
||||
case 'folder_not_found':
|
||||
return (
|
||||
<DangerMessage>
|
||||
{t('the_target_folder_could_not_be_found')}
|
||||
</DangerMessage>
|
||||
)
|
||||
|
||||
case 'invalid_filename':
|
||||
return (
|
||||
<DangerMessage>
|
||||
{t('invalid_filename', {
|
||||
nameLimit: fileNameLimit,
|
||||
})}
|
||||
</DangerMessage>
|
||||
)
|
||||
|
||||
case 'duplicate_file_name':
|
||||
return (
|
||||
<DangerMessage>
|
||||
{t('file_or_folder_name_already_exists')}
|
||||
</DangerMessage>
|
||||
)
|
||||
|
||||
case 'rate-limit-hit':
|
||||
return (
|
||||
<DangerMessage>
|
||||
{t('too_many_files_uploaded_throttled_short_period')}
|
||||
</DangerMessage>
|
||||
)
|
||||
|
||||
case 'not-logged-in':
|
||||
return (
|
||||
<DangerMessage>
|
||||
<RedirectToLogin />
|
||||
</DangerMessage>
|
||||
)
|
||||
|
||||
case 'linked-project-compile-error':
|
||||
return (
|
||||
<DangerMessage>
|
||||
{t('generic_linked_file_compile_error')}
|
||||
</DangerMessage>
|
||||
)
|
||||
|
||||
default:
|
||||
// TODO: convert error.response.data to an error key and try again?
|
||||
// return error
|
||||
return (
|
||||
<DangerMessage>{t('generic_something_went_wrong')}</DangerMessage>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// the error is an object
|
||||
// TODO: error.name?
|
||||
switch (error.constructor) {
|
||||
case FetchError: {
|
||||
const message = error.data?.message
|
||||
|
||||
if (message) {
|
||||
return <DangerMessage>{message.text || message}</DangerMessage>
|
||||
}
|
||||
|
||||
// TODO: translations
|
||||
switch (error.response?.status) {
|
||||
case 400:
|
||||
return <DangerMessage>{t('invalid_request')}</DangerMessage>
|
||||
|
||||
case 403:
|
||||
return <DangerMessage>{t('session_error')}</DangerMessage>
|
||||
|
||||
case 429:
|
||||
return <DangerMessage>{t('too_many_attempts')}</DangerMessage>
|
||||
|
||||
default:
|
||||
return (
|
||||
<DangerMessage>{t('something_went_wrong_server')}</DangerMessage>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// these are handled by the filename input component
|
||||
case DuplicateFilenameError:
|
||||
case InvalidFilenameError:
|
||||
case BlockedFilenameError:
|
||||
return null
|
||||
|
||||
// a generic error message
|
||||
default:
|
||||
// return error.message
|
||||
return <DangerMessage>{t('generic_something_went_wrong')}</DangerMessage>
|
||||
}
|
||||
}
|
||||
ErrorMessage.propTypes = {
|
||||
error: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired,
|
||||
}
|
@@ -0,0 +1,129 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useFileTreeCreateName } from '../../contexts/file-tree-create-name'
|
||||
import PropTypes from 'prop-types'
|
||||
import {
|
||||
BlockedFilenameError,
|
||||
DuplicateFilenameError,
|
||||
InvalidFilenameError,
|
||||
} from '../../errors'
|
||||
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
|
||||
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
|
||||
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
|
||||
import OLNotification from '@/features/ui/components/ol/ol-notification'
|
||||
|
||||
/**
|
||||
* A form component that renders a text input with label,
|
||||
* plus a validation warning and/or an error message when needed
|
||||
*/
|
||||
export default function FileTreeCreateNameInput({
|
||||
label,
|
||||
focusName = false,
|
||||
classes = {},
|
||||
placeholder,
|
||||
error,
|
||||
inFlight,
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// the value is stored in a context provider, so it's available elsewhere in the form
|
||||
const { name, setName, touchedName, validName } = useFileTreeCreateName()
|
||||
|
||||
// focus the first part of the filename if needed
|
||||
const inputRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef.current && focusName) {
|
||||
window.requestAnimationFrame(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
inputRef.current.setSelectionRange(
|
||||
0,
|
||||
inputRef.current.value.lastIndexOf('.')
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [focusName])
|
||||
|
||||
return (
|
||||
<OLFormGroup controlId="new-doc-name" className={classes.formGroup}>
|
||||
<OLFormLabel>{label || t('file_name')}</OLFormLabel>
|
||||
|
||||
<OLFormControl
|
||||
type="text"
|
||||
placeholder={placeholder || t('file_name')}
|
||||
required
|
||||
value={name}
|
||||
onChange={event => setName(event.target.value)}
|
||||
ref={inputRef}
|
||||
disabled={inFlight}
|
||||
/>
|
||||
|
||||
{touchedName && !validName && (
|
||||
<OLNotification
|
||||
type="error"
|
||||
className="row-spaced-small"
|
||||
content={t('files_cannot_include_invalid_characters')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && <ErrorMessage error={error} />}
|
||||
</OLFormGroup>
|
||||
)
|
||||
}
|
||||
|
||||
FileTreeCreateNameInput.propTypes = {
|
||||
focusName: PropTypes.bool,
|
||||
label: PropTypes.string,
|
||||
classes: PropTypes.shape({
|
||||
formGroup: PropTypes.string,
|
||||
}),
|
||||
placeholder: PropTypes.string,
|
||||
error: PropTypes.oneOfType([PropTypes.object, PropTypes.string]),
|
||||
inFlight: PropTypes.bool.isRequired,
|
||||
}
|
||||
|
||||
function ErrorMessage({ error }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// if (typeof error === 'string') {
|
||||
// return error
|
||||
// }
|
||||
|
||||
switch (error.constructor) {
|
||||
case DuplicateFilenameError:
|
||||
return (
|
||||
<OLNotification
|
||||
type="error"
|
||||
className="row-spaced-small"
|
||||
content={t('file_already_exists')}
|
||||
/>
|
||||
)
|
||||
|
||||
case InvalidFilenameError:
|
||||
return (
|
||||
<OLNotification
|
||||
type="error"
|
||||
className="row-spaced-small"
|
||||
content={t('files_cannot_include_invalid_characters')}
|
||||
/>
|
||||
)
|
||||
|
||||
case BlockedFilenameError:
|
||||
return (
|
||||
<OLNotification
|
||||
type="error"
|
||||
className="row-spaced-small"
|
||||
content={t('blocked_filename')}
|
||||
/>
|
||||
)
|
||||
|
||||
default:
|
||||
// return <Trans i18nKey="generic_something_went_wrong" />
|
||||
return null // other errors are displayed elsewhere
|
||||
}
|
||||
}
|
||||
ErrorMessage.propTypes = {
|
||||
error: PropTypes.oneOfType([PropTypes.object, PropTypes.string]).isRequired,
|
||||
}
|
@@ -0,0 +1,114 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import FileTreeCreateNewDoc from './modes/file-tree-create-new-doc'
|
||||
import FileTreeImportFromUrl from './modes/file-tree-import-from-url'
|
||||
import FileTreeImportFromProject from './modes/file-tree-import-from-project'
|
||||
import FileTreeModalCreateFileMode from './file-tree-modal-create-file-mode'
|
||||
import FileTreeCreateNameProvider from '../../contexts/file-tree-create-name'
|
||||
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
|
||||
import { useFileTreeData } from '../../../../shared/context/file-tree-data-context'
|
||||
|
||||
import importOverleafModules from '../../../../../macros/import-overleaf-module.macro'
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
const createFileModeModules = importOverleafModules('createFileModes')
|
||||
|
||||
const FileTreeUploadDoc = lazy(() => import('./modes/file-tree-upload-doc'))
|
||||
|
||||
export default function FileTreeModalCreateFileBody() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { newFileCreateMode } = useFileTreeActionable()
|
||||
const { fileCount } = useFileTreeData()
|
||||
const {
|
||||
hasLinkedProjectFileFeature,
|
||||
hasLinkedProjectOutputFileFeature,
|
||||
hasLinkUrlFeature,
|
||||
} = getMeta('ol-ExposedSettings')
|
||||
|
||||
if (!fileCount || fileCount.status === 'error') {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="modal-new-file-list">
|
||||
<ul className="list-unstyled">
|
||||
<FileTreeModalCreateFileMode
|
||||
mode="doc"
|
||||
icon="description"
|
||||
label={t('new_file')}
|
||||
/>
|
||||
|
||||
<FileTreeModalCreateFileMode
|
||||
mode="upload"
|
||||
icon="upload"
|
||||
label={t('upload')}
|
||||
/>
|
||||
|
||||
{(hasLinkedProjectFileFeature ||
|
||||
hasLinkedProjectOutputFileFeature) && (
|
||||
<FileTreeModalCreateFileMode
|
||||
mode="project"
|
||||
icon="folder_open"
|
||||
label={t('from_another_project')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasLinkUrlFeature && (
|
||||
<FileTreeModalCreateFileMode
|
||||
mode="url"
|
||||
icon="globe"
|
||||
label={t('from_external_url')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{createFileModeModules.map(
|
||||
({ import: { CreateFileMode }, path }) => (
|
||||
<CreateFileMode key={path} />
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
</td>
|
||||
|
||||
<td
|
||||
className={`modal-new-file-body modal-new-file-body-${newFileCreateMode}`}
|
||||
>
|
||||
{newFileCreateMode === 'doc' && (
|
||||
<FileTreeCreateNameProvider initialName="name.tex">
|
||||
<FileTreeCreateNewDoc />
|
||||
</FileTreeCreateNameProvider>
|
||||
)}
|
||||
|
||||
{newFileCreateMode === 'url' && (
|
||||
<FileTreeCreateNameProvider>
|
||||
<FileTreeImportFromUrl />
|
||||
</FileTreeCreateNameProvider>
|
||||
)}
|
||||
|
||||
{newFileCreateMode === 'project' && (
|
||||
<FileTreeCreateNameProvider>
|
||||
<FileTreeImportFromProject />
|
||||
</FileTreeCreateNameProvider>
|
||||
)}
|
||||
|
||||
{newFileCreateMode === 'upload' && (
|
||||
<Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
|
||||
<FileTreeUploadDoc />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{createFileModeModules.map(
|
||||
({ import: { CreateFilePane }, path }) => (
|
||||
<CreateFilePane key={path} />
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
@@ -0,0 +1,86 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useFileTreeCreateForm } from '../../contexts/file-tree-create-form'
|
||||
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
|
||||
import { useFileTreeData } from '../../../../shared/context/file-tree-data-context'
|
||||
import PropTypes from 'prop-types'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
import OLNotification from '@/features/ui/components/ol/ol-notification'
|
||||
|
||||
export default function FileTreeModalCreateFileFooter() {
|
||||
const { valid } = useFileTreeCreateForm()
|
||||
const { newFileCreateMode, inFlight, cancel } = useFileTreeActionable()
|
||||
const { fileCount } = useFileTreeData()
|
||||
|
||||
return (
|
||||
<FileTreeModalCreateFileFooterContent
|
||||
valid={valid}
|
||||
cancel={cancel}
|
||||
newFileCreateMode={newFileCreateMode}
|
||||
inFlight={inFlight}
|
||||
fileCount={fileCount}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function FileTreeModalCreateFileFooterContent({
|
||||
valid,
|
||||
fileCount,
|
||||
inFlight,
|
||||
newFileCreateMode,
|
||||
cancel,
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<>
|
||||
{fileCount.status === 'warning' && (
|
||||
<div className="modal-footer-left approaching-file-limit">
|
||||
{t('project_approaching_file_limit')} ({fileCount.value}/
|
||||
{fileCount.limit})
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileCount.status === 'error' && (
|
||||
<OLNotification
|
||||
type="error"
|
||||
className="at-file-limit"
|
||||
content={t('project_has_too_many_files')}
|
||||
>
|
||||
{/* TODO: add parameter for fileCount.limit */}
|
||||
</OLNotification>
|
||||
)}
|
||||
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={inFlight}
|
||||
onClick={cancel}
|
||||
>
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
|
||||
{newFileCreateMode !== 'upload' && (
|
||||
<OLButton
|
||||
variant="primary"
|
||||
type="submit"
|
||||
form="create-file"
|
||||
disabled={inFlight || !valid}
|
||||
isLoading={inFlight}
|
||||
>
|
||||
{t('create')}
|
||||
</OLButton>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
FileTreeModalCreateFileFooterContent.propTypes = {
|
||||
cancel: PropTypes.func.isRequired,
|
||||
fileCount: PropTypes.shape({
|
||||
limit: PropTypes.number.isRequired,
|
||||
status: PropTypes.string.isRequired,
|
||||
value: PropTypes.number.isRequired,
|
||||
}).isRequired,
|
||||
inFlight: PropTypes.bool.isRequired,
|
||||
newFileCreateMode: PropTypes.string,
|
||||
valid: PropTypes.bool.isRequired,
|
||||
}
|
@@ -0,0 +1,35 @@
|
||||
import classnames from 'classnames'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
|
||||
import * as eventTracking from '../../../../infrastructure/event-tracking'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
|
||||
export default function FileTreeModalCreateFileMode({ mode, icon, label }) {
|
||||
const { newFileCreateMode, startCreatingFile } = useFileTreeActionable()
|
||||
|
||||
const handleClick = () => {
|
||||
startCreatingFile(mode)
|
||||
eventTracking.sendMB('file-modal-click', { method: mode })
|
||||
}
|
||||
|
||||
return (
|
||||
<li className={classnames({ active: newFileCreateMode === mode })}>
|
||||
<OLButton
|
||||
variant="link"
|
||||
onClick={handleClick}
|
||||
className="modal-new-file-mode"
|
||||
>
|
||||
<MaterialIcon type={icon} />
|
||||
|
||||
{label}
|
||||
</OLButton>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
FileTreeModalCreateFileMode.propTypes = {
|
||||
mode: PropTypes.string.isRequired,
|
||||
icon: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
}
|
@@ -0,0 +1,155 @@
|
||||
import { FileTreeEntity } from '../../../../../../types/file-tree-entity'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import { useCallback } from 'react'
|
||||
import { syncDelete } from '@/features/file-tree/util/sync-mutation'
|
||||
import { TFunction } from 'i18next'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
|
||||
export type Conflict = {
|
||||
entity: FileTreeEntity
|
||||
type: 'file' | 'folder'
|
||||
}
|
||||
|
||||
const getConflictText = (conflicts: Conflict[], t: TFunction) => {
|
||||
const hasFolderConflict = conflicts.some(
|
||||
conflict => conflict.type === 'folder'
|
||||
)
|
||||
|
||||
const hasFileConflict = conflicts.some(conflict => conflict.type === 'file')
|
||||
|
||||
if (hasFolderConflict && hasFileConflict) {
|
||||
return t('the_following_files_and_folders_already_exist_in_this_project')
|
||||
}
|
||||
|
||||
if (hasFolderConflict) {
|
||||
return t('the_following_folder_already_exists_in_this_project', {
|
||||
count: conflicts.length,
|
||||
})
|
||||
}
|
||||
|
||||
return t('the_following_files_already_exist_in_this_project')
|
||||
}
|
||||
|
||||
export function FileUploadConflicts({
|
||||
cancel,
|
||||
conflicts,
|
||||
handleOverwrite,
|
||||
}: {
|
||||
cancel: () => void
|
||||
conflicts: Conflict[]
|
||||
handleOverwrite: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Don't allow overwriting folders with files
|
||||
const hasFolderConflict = conflicts.some(
|
||||
conflict => conflict.type === 'folder'
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="small modal-new-file-body-conflict">
|
||||
{conflicts.length > 0 && (
|
||||
<>
|
||||
<p className="text-center mb-0">{getConflictText(conflicts, t)}</p>
|
||||
|
||||
<ul className="text-center list-unstyled row-spaced-small mt-1">
|
||||
{conflicts.map((conflict, index) => (
|
||||
<li key={index}>
|
||||
<strong>{conflict.entity.name}</strong>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!hasFolderConflict && (
|
||||
<p className="text-center row-spaced-small">
|
||||
{t('do_you_want_to_overwrite_them')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-center">
|
||||
<OLButton variant="secondary" onClick={cancel}>
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
|
||||
{!hasFolderConflict && (
|
||||
<OLButton variant="danger" onClick={handleOverwrite}>
|
||||
{t('overwrite')}
|
||||
</OLButton>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FolderUploadConflicts({
|
||||
cancel,
|
||||
handleOverwrite,
|
||||
conflicts,
|
||||
setError,
|
||||
}: {
|
||||
cancel: () => void
|
||||
handleOverwrite: () => void
|
||||
conflicts: Conflict[]
|
||||
setError: (error: string) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { _id: projectId } = useProjectContext()
|
||||
|
||||
// Don't allow overwriting files with a folder
|
||||
const hasFileConflict = conflicts.some(conflict => conflict.type === 'file')
|
||||
|
||||
const deleteAndRetry = useCallback(async () => {
|
||||
// TODO: confirm deletion?
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
conflicts.map(conflict =>
|
||||
syncDelete(projectId, 'folder', conflict.entity._id)
|
||||
)
|
||||
)
|
||||
|
||||
handleOverwrite()
|
||||
} catch (error: any) {
|
||||
setError(error.message)
|
||||
}
|
||||
}, [setError, conflicts, handleOverwrite, projectId])
|
||||
|
||||
return (
|
||||
<div className="small modal-new-file-body-conflict">
|
||||
<p className="text-center mb-0">{getConflictText(conflicts, t)}</p>
|
||||
|
||||
<ul className="text-center list-unstyled row-spaced-small mt-1">
|
||||
{conflicts.map((conflict, index) => (
|
||||
<li key={index}>
|
||||
<strong>{conflict.entity.name}</strong>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{!hasFileConflict && (
|
||||
<p className="text-center row-spaced-small">
|
||||
{t('overwriting_the_original_folder')}
|
||||
<br />
|
||||
{t('do_you_want_to_overwrite_it', {
|
||||
count: conflicts.length,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-center">
|
||||
<OLButton variant="secondary" onClick={cancel}>
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
|
||||
{!hasFileConflict && (
|
||||
<OLButton variant="danger" onClick={deleteAndRetry}>
|
||||
{t('overwrite')}
|
||||
</OLButton>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
@@ -0,0 +1,38 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import FileTreeCreateNameInput from '../file-tree-create-name-input'
|
||||
import { useFileTreeActionable } from '../../../contexts/file-tree-actionable'
|
||||
import { useFileTreeCreateName } from '../../../contexts/file-tree-create-name'
|
||||
import { useFileTreeCreateForm } from '../../../contexts/file-tree-create-form'
|
||||
import * as eventTracking from '../../../../../infrastructure/event-tracking'
|
||||
import ErrorMessage from '../error-message'
|
||||
|
||||
export default function FileTreeCreateNewDoc() {
|
||||
const { name, validName } = useFileTreeCreateName()
|
||||
const { setValid } = useFileTreeCreateForm()
|
||||
const { error, finishCreatingDoc, inFlight } = useFileTreeActionable()
|
||||
// form validation: name is valid
|
||||
useEffect(() => {
|
||||
setValid(validName)
|
||||
}, [setValid, validName])
|
||||
|
||||
// form submission: create an empty doc with this name
|
||||
const handleSubmit = useCallback(
|
||||
event => {
|
||||
event.preventDefault()
|
||||
|
||||
finishCreatingDoc({ name })
|
||||
eventTracking.sendMB('new-file-created', {
|
||||
method: 'doc',
|
||||
extension: name.split('.').length > 1 ? name.split('.').pop() : '',
|
||||
})
|
||||
},
|
||||
[finishCreatingDoc, name]
|
||||
)
|
||||
|
||||
return (
|
||||
<form noValidate id="create-file" onSubmit={handleSubmit}>
|
||||
<FileTreeCreateNameInput focusName error={error} inFlight={inFlight} />
|
||||
{error && <ErrorMessage error={error} />}
|
||||
</form>
|
||||
)
|
||||
}
|
@@ -0,0 +1,373 @@
|
||||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
FormEventHandler,
|
||||
} from 'react'
|
||||
import FileTreeCreateNameInput from '../file-tree-create-name-input'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useUserProjects } from '../../../hooks/use-user-projects'
|
||||
import { Entity, useProjectEntities } from '../../../hooks/use-project-entities'
|
||||
import { useProjectOutputFiles } from '../../../hooks/use-project-output-files'
|
||||
import { useFileTreeActionable } from '../../../contexts/file-tree-actionable'
|
||||
import { useFileTreeCreateName } from '../../../contexts/file-tree-create-name'
|
||||
import { useFileTreeCreateForm } from '../../../contexts/file-tree-create-form'
|
||||
import { useProjectContext } from '../../../../../shared/context/project-context'
|
||||
import ErrorMessage from '../error-message'
|
||||
import * as eventTracking from '../../../../../infrastructure/event-tracking'
|
||||
import { File } from '@/features/source-editor/utils/file'
|
||||
import { Project } from '../../../../../../../types/project'
|
||||
import getMeta from '@/utils/meta'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
|
||||
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
|
||||
import OLForm from '@/features/ui/components/ol/ol-form'
|
||||
import OLFormSelect from '@/features/ui/components/ol/ol-form-select'
|
||||
import { Spinner } from 'react-bootstrap-5'
|
||||
|
||||
export default function FileTreeImportFromProject() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { hasLinkedProjectFileFeature, hasLinkedProjectOutputFileFeature } =
|
||||
getMeta('ol-ExposedSettings')
|
||||
const canSwitchOutputFilesMode =
|
||||
hasLinkedProjectFileFeature && hasLinkedProjectOutputFileFeature
|
||||
|
||||
const { name, setName, validName } = useFileTreeCreateName()
|
||||
const { setValid } = useFileTreeCreateForm()
|
||||
const { error, finishCreatingLinkedFile, inFlight } = useFileTreeActionable()
|
||||
|
||||
const [selectedProject, setSelectedProject] = useState<Project>()
|
||||
const [selectedProjectEntity, setSelectedProjectEntity] = useState<Entity>()
|
||||
const [selectedProjectOutputFile, setSelectedProjectOutputFile] = useState<
|
||||
File & { build: string; clsiServerId: string }
|
||||
>()
|
||||
const [isOutputFilesMode, setOutputFilesMode] = useState(
|
||||
// default to project file mode, unless the feature is not enabled
|
||||
!hasLinkedProjectFileFeature
|
||||
)
|
||||
|
||||
// use the basename of a path as the file name
|
||||
const setNameFromPath = useCallback(
|
||||
path => {
|
||||
const filename = path.split('/').pop()
|
||||
|
||||
if (filename) {
|
||||
setName(filename)
|
||||
}
|
||||
},
|
||||
[setName]
|
||||
)
|
||||
|
||||
// update the name when an output file is selected
|
||||
useEffect(() => {
|
||||
if (selectedProjectOutputFile) {
|
||||
if (
|
||||
selectedProjectOutputFile.path === 'output.pdf' &&
|
||||
selectedProject!.name
|
||||
) {
|
||||
// if the output PDF is selected, use the project's name as the filename
|
||||
setName(`${selectedProject!.name}.pdf`)
|
||||
} else {
|
||||
setNameFromPath(selectedProjectOutputFile.path)
|
||||
}
|
||||
}
|
||||
}, [selectedProject, selectedProjectOutputFile, setName, setNameFromPath])
|
||||
|
||||
// update the name when an entity is selected
|
||||
useEffect(() => {
|
||||
if (selectedProjectEntity) {
|
||||
setNameFromPath(selectedProjectEntity.path)
|
||||
}
|
||||
}, [selectedProjectEntity, setNameFromPath])
|
||||
|
||||
// form validation: name is valid and entity or output file is selected
|
||||
useEffect(() => {
|
||||
const hasSelectedEntity = Boolean(
|
||||
isOutputFilesMode ? selectedProjectOutputFile : selectedProjectEntity
|
||||
)
|
||||
|
||||
setValid(validName && hasSelectedEntity)
|
||||
}, [
|
||||
setValid,
|
||||
validName,
|
||||
isOutputFilesMode,
|
||||
selectedProjectEntity,
|
||||
selectedProjectOutputFile,
|
||||
])
|
||||
|
||||
// form submission: create a linked file with this name, from this entity or output file
|
||||
const handleSubmit: FormEventHandler = event => {
|
||||
event.preventDefault()
|
||||
eventTracking.sendMB('new-file-created', {
|
||||
method: 'project',
|
||||
extension: name.split('.').length > 1 ? name.split('.').pop() : '',
|
||||
})
|
||||
|
||||
if (isOutputFilesMode) {
|
||||
finishCreatingLinkedFile({
|
||||
name,
|
||||
provider: 'project_output_file',
|
||||
data: {
|
||||
source_project_id: selectedProject!._id,
|
||||
source_output_file_path: selectedProjectOutputFile!.path,
|
||||
build_id: selectedProjectOutputFile!.build,
|
||||
clsiServerId: selectedProjectOutputFile!.clsiServerId,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
finishCreatingLinkedFile({
|
||||
name,
|
||||
provider: 'project_file',
|
||||
data: {
|
||||
source_project_id: selectedProject!._id,
|
||||
source_entity_path: selectedProjectEntity!.path,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<OLForm id="create-file" onSubmit={handleSubmit}>
|
||||
<SelectProject
|
||||
selectedProject={selectedProject}
|
||||
setSelectedProject={setSelectedProject}
|
||||
/>
|
||||
|
||||
{isOutputFilesMode ? (
|
||||
<SelectProjectOutputFile
|
||||
selectedProjectId={selectedProject?._id}
|
||||
selectedProjectOutputFile={selectedProjectOutputFile}
|
||||
setSelectedProjectOutputFile={setSelectedProjectOutputFile}
|
||||
/>
|
||||
) : (
|
||||
<SelectProjectEntity
|
||||
selectedProjectId={selectedProject?._id}
|
||||
selectedProjectEntity={selectedProjectEntity}
|
||||
setSelectedProjectEntity={setSelectedProjectEntity}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canSwitchOutputFilesMode && (
|
||||
<div className="toggle-file-type-button">
|
||||
or
|
||||
<OLButton
|
||||
variant="link"
|
||||
type="button"
|
||||
onClick={() => setOutputFilesMode(value => !value)}
|
||||
>
|
||||
<span>
|
||||
{isOutputFilesMode
|
||||
? t('select_from_source_files')
|
||||
: t('select_from_output_files')}
|
||||
</span>
|
||||
</OLButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FileTreeCreateNameInput
|
||||
label={t('file_name_in_this_project')}
|
||||
classes={{
|
||||
formGroup: 'form-controls row-spaced-small',
|
||||
}}
|
||||
placeholder="example.tex"
|
||||
error={error}
|
||||
inFlight={inFlight}
|
||||
/>
|
||||
|
||||
{error && <ErrorMessage error={error} />}
|
||||
</OLForm>
|
||||
)
|
||||
}
|
||||
|
||||
type SelectProjectProps = {
|
||||
selectedProject?: any
|
||||
setSelectedProject(project: any): void
|
||||
}
|
||||
|
||||
function SelectProject({
|
||||
selectedProject,
|
||||
setSelectedProject,
|
||||
}: SelectProjectProps) {
|
||||
const { t } = useTranslation()
|
||||
const { _id: projectId } = useProjectContext()
|
||||
|
||||
const { data, error, loading } = useUserProjects()
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
return data.filter(item => item._id !== projectId)
|
||||
}, [data, projectId])
|
||||
|
||||
if (error) {
|
||||
return <ErrorMessage error={error} />
|
||||
}
|
||||
|
||||
return (
|
||||
<OLFormGroup controlId="project-select">
|
||||
<OLFormLabel>{t('select_a_project')}</OLFormLabel>
|
||||
|
||||
{loading && (
|
||||
<span>
|
||||
|
||||
<Spinner
|
||||
animation="border"
|
||||
aria-hidden="true"
|
||||
size="sm"
|
||||
role="status"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<OLFormSelect
|
||||
disabled={!data}
|
||||
value={selectedProject ? selectedProject._id : ''}
|
||||
onChange={event => {
|
||||
const projectId = (event.target as HTMLSelectElement).value
|
||||
const project = data!.find(item => item._id === projectId)
|
||||
setSelectedProject(project)
|
||||
}}
|
||||
>
|
||||
<option disabled value="">
|
||||
- {t('please_select_a_project')}
|
||||
</option>
|
||||
|
||||
{filteredData &&
|
||||
filteredData.map(project => (
|
||||
<option key={project._id} value={project._id}>
|
||||
{project.name}
|
||||
</option>
|
||||
))}
|
||||
</OLFormSelect>
|
||||
|
||||
{filteredData && !filteredData.length && (
|
||||
<small>{t('no_other_projects_found')}</small>
|
||||
)}
|
||||
</OLFormGroup>
|
||||
)
|
||||
}
|
||||
|
||||
type SelectProjectOutputFileProps = {
|
||||
selectedProjectId?: string
|
||||
selectedProjectOutputFile?: any
|
||||
setSelectedProjectOutputFile(file: any): void
|
||||
}
|
||||
|
||||
function SelectProjectOutputFile({
|
||||
selectedProjectId,
|
||||
selectedProjectOutputFile,
|
||||
setSelectedProjectOutputFile,
|
||||
}: SelectProjectOutputFileProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { data, error, loading } = useProjectOutputFiles(selectedProjectId)
|
||||
|
||||
if (error) {
|
||||
return <ErrorMessage error={error} />
|
||||
}
|
||||
|
||||
return (
|
||||
<OLFormGroup
|
||||
className="row-spaced-small"
|
||||
controlId="project-output-file-select"
|
||||
>
|
||||
<OLFormLabel>{t('select_an_output_file')}</OLFormLabel>
|
||||
|
||||
{loading && (
|
||||
<span>
|
||||
|
||||
<Spinner
|
||||
animation="border"
|
||||
aria-hidden="true"
|
||||
size="sm"
|
||||
role="status"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<OLFormSelect
|
||||
disabled={!data}
|
||||
value={selectedProjectOutputFile?.path || ''}
|
||||
onChange={event => {
|
||||
const path = (event.target as unknown as HTMLSelectElement).value
|
||||
const file = data?.find(item => item.path === path)
|
||||
setSelectedProjectOutputFile(file)
|
||||
}}
|
||||
>
|
||||
<option disabled value="">
|
||||
- {t('please_select_an_output_file')}
|
||||
</option>
|
||||
|
||||
{data &&
|
||||
data.map(file => (
|
||||
<option key={file.path} value={file.path}>
|
||||
{file.path}
|
||||
</option>
|
||||
))}
|
||||
</OLFormSelect>
|
||||
</OLFormGroup>
|
||||
)
|
||||
}
|
||||
|
||||
type SelectProjectEntityProps = {
|
||||
selectedProjectId?: string
|
||||
selectedProjectEntity?: any
|
||||
setSelectedProjectEntity(entity: any): void
|
||||
}
|
||||
|
||||
function SelectProjectEntity({
|
||||
selectedProjectId,
|
||||
selectedProjectEntity,
|
||||
setSelectedProjectEntity,
|
||||
}: SelectProjectEntityProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { data, error, loading } = useProjectEntities(selectedProjectId)
|
||||
|
||||
if (error) {
|
||||
return <ErrorMessage error={error} />
|
||||
}
|
||||
|
||||
return (
|
||||
<OLFormGroup className="row-spaced-small" controlId="project-entity-select">
|
||||
<OLFormLabel>{t('select_a_file')}</OLFormLabel>
|
||||
|
||||
{loading && (
|
||||
<span>
|
||||
|
||||
<Spinner
|
||||
animation="border"
|
||||
aria-hidden="true"
|
||||
size="sm"
|
||||
role="status"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<OLFormSelect
|
||||
disabled={!data}
|
||||
value={selectedProjectEntity?.path || ''}
|
||||
onChange={event => {
|
||||
const path = (event.target as HTMLSelectElement).value
|
||||
const entity = data!.find(item => item.path === path)
|
||||
setSelectedProjectEntity(entity)
|
||||
}}
|
||||
>
|
||||
<option disabled value="">
|
||||
- {t('please_select_a_file')}
|
||||
</option>
|
||||
|
||||
{data &&
|
||||
data.map(entity => (
|
||||
<option key={entity.path} value={entity.path}>
|
||||
{entity.path.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</OLFormSelect>
|
||||
</OLFormGroup>
|
||||
)
|
||||
}
|
@@ -0,0 +1,80 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import FileTreeCreateNameInput from '../file-tree-create-name-input'
|
||||
import { useFileTreeActionable } from '../../../contexts/file-tree-actionable'
|
||||
import { useFileTreeCreateName } from '../../../contexts/file-tree-create-name'
|
||||
import { useFileTreeCreateForm } from '../../../contexts/file-tree-create-form'
|
||||
import ErrorMessage from '../error-message'
|
||||
import * as eventTracking from '../../../../../infrastructure/event-tracking'
|
||||
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
|
||||
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
|
||||
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
|
||||
|
||||
export default function FileTreeImportFromUrl() {
|
||||
const { t } = useTranslation()
|
||||
const { name, setName, validName } = useFileTreeCreateName()
|
||||
const { setValid } = useFileTreeCreateForm()
|
||||
const { finishCreatingLinkedFile, error, inFlight } = useFileTreeActionable()
|
||||
|
||||
const [url, setUrl] = useState('')
|
||||
|
||||
const handleChange = useCallback(event => {
|
||||
setUrl(event.target.value)
|
||||
}, [])
|
||||
|
||||
// set the name when the URL changes
|
||||
useEffect(() => {
|
||||
if (url) {
|
||||
const matches = url.match(/^\s*https?:\/\/.+\/([^/]+\.(\w+))\s*$/)
|
||||
setName(matches ? matches[1] : '')
|
||||
}
|
||||
}, [setName, url])
|
||||
|
||||
// form validation: URL is set and name is valid
|
||||
useEffect(() => {
|
||||
setValid(validName && !!url)
|
||||
}, [setValid, validName, url])
|
||||
|
||||
// form submission: create a linked file with this name, from this URL
|
||||
const handleSubmit = event => {
|
||||
event.preventDefault()
|
||||
eventTracking.sendMB('new-file-created', {
|
||||
method: 'url',
|
||||
extension: name.split('.').length > 1 ? name.split('.').pop() : '',
|
||||
})
|
||||
finishCreatingLinkedFile({
|
||||
name,
|
||||
provider: 'url',
|
||||
data: { url: url.trim() },
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="form-controls"
|
||||
id="create-file"
|
||||
noValidate
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<OLFormGroup controlId="import-from-url">
|
||||
<OLFormLabel>{t('url_to_fetch_the_file_from')}</OLFormLabel>
|
||||
<OLFormControl
|
||||
type="url"
|
||||
placeholder="https://example.com/my-file.png"
|
||||
required
|
||||
value={url}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
|
||||
<FileTreeCreateNameInput
|
||||
label={t('file_name_in_this_project')}
|
||||
placeholder="my_file"
|
||||
error={error}
|
||||
inFlight={inFlight}
|
||||
/>
|
||||
|
||||
{error && <ErrorMessage error={error} />}
|
||||
</form>
|
||||
)
|
||||
}
|
@@ -0,0 +1,339 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import Uppy from '@uppy/core'
|
||||
import XHRUpload from '@uppy/xhr-upload'
|
||||
import { Dashboard } from '@uppy/react'
|
||||
import { useFileTreeActionable } from '../../../contexts/file-tree-actionable'
|
||||
import { useProjectContext } from '../../../../../shared/context/project-context'
|
||||
import * as eventTracking from '../../../../../infrastructure/event-tracking'
|
||||
import '@uppy/core/dist/style.css'
|
||||
import '@uppy/dashboard/dist/style.css'
|
||||
import { refreshProjectMetadata } from '../../../util/api'
|
||||
import ErrorMessage from '../error-message'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { isAcceptableFile } from '@/features/file-tree/util/is-acceptable-file'
|
||||
import {
|
||||
findFileByNameInFolder,
|
||||
findFolderByNameInFolder,
|
||||
} from '@/features/file-tree/util/is-name-unique-in-folder'
|
||||
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
|
||||
import {
|
||||
Conflict,
|
||||
FileUploadConflicts,
|
||||
FolderUploadConflicts,
|
||||
} from '@/features/file-tree/components/file-tree-create/file-tree-upload-conflicts'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
export default function FileTreeUploadDoc() {
|
||||
const { parentFolderId, cancel, droppedFiles, setDroppedFiles } =
|
||||
useFileTreeActionable()
|
||||
const { fileTreeData } = useFileTreeData()
|
||||
const { _id: projectId } = useProjectContext()
|
||||
|
||||
const [error, setError] = useState<string>()
|
||||
|
||||
const [conflicts, setConflicts] = useState<Conflict[]>([])
|
||||
const [folderConflicts, setFolderConflicts] = useState<Conflict[]>([])
|
||||
const [overwrite, setOverwrite] = useState(false)
|
||||
|
||||
const maxNumberOfFiles = 180
|
||||
const maxFileSize = getMeta('ol-ExposedSettings').maxUploadSize
|
||||
|
||||
// calculate conflicts
|
||||
const buildConflicts = (files: Record<string, any>) => {
|
||||
const conflicts: Conflict[] = []
|
||||
|
||||
for (const file of Object.values(files)) {
|
||||
const { name, relativePath } = file.meta
|
||||
|
||||
if (!relativePath) {
|
||||
const targetFolderId = file.meta.targetFolderId ?? parentFolderId
|
||||
const duplicateFile = findFileByNameInFolder(
|
||||
fileTreeData,
|
||||
targetFolderId,
|
||||
name
|
||||
)
|
||||
if (duplicateFile) {
|
||||
conflicts.push({
|
||||
entity: duplicateFile,
|
||||
type: 'file',
|
||||
})
|
||||
}
|
||||
|
||||
const duplicateFolder = findFolderByNameInFolder(
|
||||
fileTreeData,
|
||||
targetFolderId,
|
||||
name
|
||||
)
|
||||
if (duplicateFolder) {
|
||||
conflicts.push({
|
||||
entity: duplicateFolder,
|
||||
type: 'folder',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return conflicts
|
||||
}
|
||||
|
||||
const buildFolderConflicts = (files: Record<string, any>) => {
|
||||
const conflicts: Conflict[] = []
|
||||
|
||||
for (const file of Object.values(files)) {
|
||||
const { relativePath } = file.meta
|
||||
|
||||
if (relativePath) {
|
||||
const [rootName] = relativePath.replace(/^\//, '').split('/')
|
||||
|
||||
const targetFolderId = file.meta.targetFolderId ?? parentFolderId
|
||||
const duplicateFile = findFileByNameInFolder(
|
||||
fileTreeData,
|
||||
targetFolderId,
|
||||
rootName
|
||||
)
|
||||
if (duplicateFile) {
|
||||
conflicts.push({
|
||||
entity: duplicateFile,
|
||||
type: 'file',
|
||||
})
|
||||
}
|
||||
|
||||
const duplicateFolder = findFolderByNameInFolder(
|
||||
fileTreeData,
|
||||
targetFolderId,
|
||||
rootName
|
||||
)
|
||||
if (duplicateFolder) {
|
||||
conflicts.push({
|
||||
entity: duplicateFolder,
|
||||
type: 'folder',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return conflicts
|
||||
}
|
||||
|
||||
const buildEndpoint = (projectId: string, targetFolderId: string) => {
|
||||
let endpoint = `/project/${projectId}/upload`
|
||||
|
||||
if (targetFolderId) {
|
||||
endpoint += `?folder_id=${targetFolderId}`
|
||||
}
|
||||
|
||||
return endpoint
|
||||
}
|
||||
|
||||
// initialise the Uppy object
|
||||
const [uppy] = useState(() => {
|
||||
const endpoint = buildEndpoint(projectId, parentFolderId)
|
||||
|
||||
return (
|
||||
new Uppy<{ relativePath?: string; targetFolderId: string }>({
|
||||
// logger: Uppy.debugLogger,
|
||||
allowMultipleUploadBatches: false,
|
||||
restrictions: {
|
||||
maxNumberOfFiles,
|
||||
maxFileSize: maxFileSize || null,
|
||||
},
|
||||
onBeforeFileAdded(file) {
|
||||
if (
|
||||
!isAcceptableFile(
|
||||
file.name,
|
||||
file.meta.relativePath as string | undefined
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
},
|
||||
onBeforeUpload(files) {
|
||||
const conflicts = buildConflicts(files)
|
||||
const folderConflicts = buildFolderConflicts(files)
|
||||
setConflicts(conflicts)
|
||||
setFolderConflicts(folderConflicts)
|
||||
return conflicts.length === 0 && folderConflicts.length === 0
|
||||
},
|
||||
autoProceed: true,
|
||||
locale: {
|
||||
strings: {
|
||||
youCanOnlyUploadX:
|
||||
'You can only upload %{smart_count} files at a time',
|
||||
},
|
||||
},
|
||||
})
|
||||
// use the basic XHR uploader
|
||||
.use(XHRUpload, {
|
||||
endpoint,
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': getMeta('ol-csrfToken'),
|
||||
},
|
||||
// limit: maxConnections || 1,
|
||||
limit: 1,
|
||||
fieldName: 'qqfile', // "qqfile" field inherited from FineUploader
|
||||
})
|
||||
// close the modal when all the uploads completed successfully
|
||||
.on('complete', result => {
|
||||
if (!result.failed.length) {
|
||||
// $scope.$emit('done', { name: name })
|
||||
cancel()
|
||||
}
|
||||
})
|
||||
// broadcast doc metadata after each successful upload
|
||||
.on('upload-success', (file, response) => {
|
||||
eventTracking.sendMB('new-file-created', {
|
||||
method: 'upload',
|
||||
extension:
|
||||
file?.name && file?.name.split('.').length > 1
|
||||
? file?.name.split('.').pop()
|
||||
: '',
|
||||
})
|
||||
if (response.body.entity_type === 'doc') {
|
||||
window.setTimeout(() => {
|
||||
refreshProjectMetadata(projectId, response.body.entity_id)
|
||||
}, 250)
|
||||
}
|
||||
})
|
||||
// handle upload errors
|
||||
.on('upload-error', (file, error, response) => {
|
||||
switch (response?.status) {
|
||||
case 429:
|
||||
setError('rate-limit-hit')
|
||||
break
|
||||
|
||||
case 403:
|
||||
setError('not-logged-in')
|
||||
break
|
||||
|
||||
default:
|
||||
debugConsole.error(error)
|
||||
setError(response?.body?.error || 'generic_something_went_wrong')
|
||||
break
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (uppy && droppedFiles) {
|
||||
uppy.setOptions({
|
||||
autoProceed: false,
|
||||
})
|
||||
for (const file of droppedFiles.files) {
|
||||
const fileId = uppy.addFile({
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
data: file,
|
||||
source: 'Local',
|
||||
isRemote: false,
|
||||
meta: {
|
||||
relativePath: (file as any).relativePath,
|
||||
targetFolderId: droppedFiles.targetFolderId,
|
||||
},
|
||||
})
|
||||
const uppyFile = uppy.getFile(fileId)
|
||||
uppy.setFileState(fileId, {
|
||||
xhrUpload: {
|
||||
...(uppyFile as any).xhrUpload,
|
||||
endpoint: buildEndpoint(projectId, droppedFiles.targetFolderId),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
setDroppedFiles(null)
|
||||
}
|
||||
}, [uppy, droppedFiles, setDroppedFiles, projectId])
|
||||
|
||||
// handle forced overwriting of conflicting files
|
||||
const handleOverwrite = useCallback(() => {
|
||||
setOverwrite(true)
|
||||
uppy.setOptions({
|
||||
onBeforeUpload() {
|
||||
// don't check for file conflicts
|
||||
return true
|
||||
},
|
||||
})
|
||||
uppy.upload()
|
||||
}, [uppy])
|
||||
|
||||
const showFolderUploadConflicts = !overwrite && folderConflicts.length > 0
|
||||
const showFileUploadConfilcts =
|
||||
!overwrite && !showFolderUploadConflicts && conflicts.length > 0
|
||||
const showDashboard = !showFileUploadConfilcts && !showFolderUploadConflicts
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && (
|
||||
<UploadErrorMessage error={error} maxNumberOfFiles={maxNumberOfFiles} />
|
||||
)}
|
||||
{showFolderUploadConflicts && (
|
||||
<FolderUploadConflicts
|
||||
cancel={cancel}
|
||||
conflicts={folderConflicts}
|
||||
handleOverwrite={handleOverwrite}
|
||||
setError={setError}
|
||||
/>
|
||||
)}
|
||||
{showFileUploadConfilcts && (
|
||||
<FileUploadConflicts
|
||||
cancel={cancel}
|
||||
conflicts={conflicts}
|
||||
handleOverwrite={handleOverwrite}
|
||||
/>
|
||||
)}
|
||||
{showDashboard && (
|
||||
<Dashboard
|
||||
uppy={uppy}
|
||||
showProgressDetails
|
||||
// note={`Up to ${maxNumberOfFiles} files, up to ${maxFileSize / (1024 * 1024)}MB`}
|
||||
height={400}
|
||||
width="100%"
|
||||
showLinkToFileUploadResult={false}
|
||||
proudlyDisplayPoweredByUppy={false}
|
||||
// allow files or folders to be selected
|
||||
fileManagerSelectionType="both"
|
||||
locale={{
|
||||
strings: {
|
||||
// Text to show on the droppable area.
|
||||
// `%{browse}` is replaced with a link that opens the system file selection dialog.
|
||||
// TODO: 'drag_here' or 'drop_files_here_to_upload'?
|
||||
// dropHereOr: `${t('drag_here')} ${t('or')} %{browse}`,
|
||||
dropPasteBoth: `Drop or paste your files, folder, or images here. %{browseFiles} or %{browseFolders} from your computer.`,
|
||||
// Used as the label for the link that opens the system file selection dialog.
|
||||
// browseFiles: t('select_from_your_computer')
|
||||
browseFiles: 'Select files',
|
||||
browseFolders: 'select a folder',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function UploadErrorMessage({
|
||||
error,
|
||||
maxNumberOfFiles,
|
||||
}: {
|
||||
error: string
|
||||
maxNumberOfFiles: number
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
switch (error) {
|
||||
case 'too-many-files':
|
||||
return (
|
||||
<>
|
||||
{t('maximum_files_uploaded_together', {
|
||||
max: maxNumberOfFiles,
|
||||
})}
|
||||
</>
|
||||
)
|
||||
|
||||
default:
|
||||
return <ErrorMessage error={error} />
|
||||
}
|
||||
}
|
@@ -0,0 +1,36 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useProjectContext } from '../../../../shared/context/project-context'
|
||||
import { useLocation } from '../../../../shared/hooks/use-location'
|
||||
|
||||
// handle "not-logged-in" errors by redirecting to the login page
|
||||
export default function RedirectToLogin() {
|
||||
const { t } = useTranslation()
|
||||
const { _id: projectId } = useProjectContext()
|
||||
const [secondsToRedirect, setSecondsToRedirect] = useState(10)
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
setSecondsToRedirect(10)
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
setSecondsToRedirect(value => {
|
||||
if (value === 0) {
|
||||
window.clearInterval(timer)
|
||||
location.assign(`/login?redir=/project/${projectId}`)
|
||||
return 0
|
||||
}
|
||||
|
||||
return value - 1
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
return () => {
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [projectId, location])
|
||||
|
||||
return t('session_expired_redirecting_to_login', {
|
||||
seconds: secondsToRedirect,
|
||||
})
|
||||
}
|
@@ -0,0 +1,43 @@
|
||||
import { useSelectableEntity } from '../contexts/file-tree-selectable'
|
||||
import FileTreeIcon from './file-tree-icon'
|
||||
import FileTreeItemInner from './file-tree-item/file-tree-item-inner'
|
||||
|
||||
function FileTreeDoc({
|
||||
name,
|
||||
id,
|
||||
isFile,
|
||||
isLinkedFile,
|
||||
}: {
|
||||
name: string
|
||||
id: string
|
||||
isFile?: boolean
|
||||
isLinkedFile?: boolean
|
||||
}) {
|
||||
const type = isFile ? 'file' : 'doc'
|
||||
|
||||
const { isSelected, props: selectableEntityProps } = useSelectableEntity(
|
||||
id,
|
||||
type
|
||||
)
|
||||
|
||||
return (
|
||||
<li
|
||||
// eslint-disable-next-line jsx-a11y/role-has-required-aria-props
|
||||
role="treeitem"
|
||||
// aria-selected is provided in selectableEntityProps
|
||||
{...selectableEntityProps}
|
||||
aria-label={name}
|
||||
tabIndex={0}
|
||||
>
|
||||
<FileTreeItemInner
|
||||
id={id}
|
||||
name={name}
|
||||
type={type}
|
||||
isSelected={isSelected}
|
||||
icons={<FileTreeIcon isLinkedFile={isLinkedFile} name={name} />}
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileTreeDoc
|
@@ -0,0 +1,78 @@
|
||||
import { useRef } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import classNames from 'classnames'
|
||||
|
||||
// a custom component rendered on top of a draggable area that renders the
|
||||
// dragged item. See
|
||||
// https://react-dnd.github.io/react-dnd/examples/drag-around/custom-drag-layer
|
||||
// for more details.
|
||||
// Also used to display a container border when hovered.
|
||||
function FileTreeDraggablePreviewLayer({
|
||||
isOver,
|
||||
isDragging,
|
||||
item,
|
||||
clientOffset,
|
||||
}) {
|
||||
const ref = useRef()
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classNames('dnd-draggable-preview-layer', {
|
||||
'dnd-droppable-hover': isOver,
|
||||
})}
|
||||
>
|
||||
{isDragging && item?.title && (
|
||||
<div
|
||||
style={getItemStyle(
|
||||
clientOffset,
|
||||
ref.current?.getBoundingClientRect()
|
||||
)}
|
||||
>
|
||||
<DraggablePreviewItem title={item.title} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
FileTreeDraggablePreviewLayer.propTypes = {
|
||||
isOver: PropTypes.bool.isRequired,
|
||||
isDragging: PropTypes.bool.isRequired,
|
||||
item: PropTypes.shape({
|
||||
title: PropTypes.string,
|
||||
}),
|
||||
clientOffset: PropTypes.shape({
|
||||
x: PropTypes.number,
|
||||
y: PropTypes.number,
|
||||
}),
|
||||
}
|
||||
|
||||
function DraggablePreviewItem({ title }) {
|
||||
return <div className="dnd-draggable-preview-item">{title}</div>
|
||||
}
|
||||
|
||||
DraggablePreviewItem.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
}
|
||||
|
||||
// makes the preview item follow the cursor.
|
||||
// See https://react-dnd.github.io/react-dnd/docs/api/drag-layer-monitor
|
||||
function getItemStyle(clientOffset, containerOffset) {
|
||||
if (!containerOffset || !clientOffset) {
|
||||
return {
|
||||
display: 'none',
|
||||
}
|
||||
}
|
||||
const { x: containerX, y: containerY } = containerOffset
|
||||
const { x: clientX, y: clientY } = clientOffset
|
||||
const posX = clientX - containerX - 15
|
||||
const posY = clientY - containerY - 15
|
||||
const transform = `translate(${posX}px, ${posY}px)`
|
||||
return {
|
||||
transform,
|
||||
WebkitTransform: transform,
|
||||
}
|
||||
}
|
||||
|
||||
export default FileTreeDraggablePreviewLayer
|
@@ -0,0 +1,20 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocation } from '../../../shared/hooks/use-location'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
|
||||
function FileTreeError() {
|
||||
const { t } = useTranslation()
|
||||
const { reload: handleClick } = useLocation()
|
||||
|
||||
return (
|
||||
<div className="file-tree-error">
|
||||
<p>{t('generic_something_went_wrong')}</p>
|
||||
<p>{t('please_refresh')}</p>
|
||||
<OLButton variant="primary" onClick={handleClick}>
|
||||
{t('refresh')}
|
||||
</OLButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileTreeError
|
@@ -0,0 +1,51 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils'
|
||||
|
||||
function FileTreeFolderIcons({
|
||||
expanded,
|
||||
onExpandCollapseClick,
|
||||
}: {
|
||||
expanded: boolean
|
||||
onExpandCollapseClick: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const newEditor = useIsNewEditorEnabled()
|
||||
|
||||
if (newEditor) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="folder-expand-collapse-button"
|
||||
onClick={onExpandCollapseClick}
|
||||
aria-label={expanded ? t('collapse') : t('expand')}
|
||||
>
|
||||
<MaterialIcon
|
||||
type={expanded ? 'expand_more' : 'chevron_right'}
|
||||
className="file-tree-expand-icon"
|
||||
/>
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={onExpandCollapseClick}
|
||||
aria-label={expanded ? t('collapse') : t('expand')}
|
||||
>
|
||||
<MaterialIcon
|
||||
type={expanded ? 'expand_more' : 'chevron_right'}
|
||||
className="file-tree-expand-icon"
|
||||
/>
|
||||
</button>
|
||||
<MaterialIcon
|
||||
type={expanded ? 'folder_open' : 'folder'}
|
||||
className="file-tree-folder-icon"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileTreeFolderIcons
|
@@ -0,0 +1,84 @@
|
||||
import classNames from 'classnames'
|
||||
|
||||
import FileTreeDoc from './file-tree-doc'
|
||||
import FileTreeFolder from './file-tree-folder'
|
||||
import { fileCollator } from '../util/file-collator'
|
||||
import { Folder } from '../../../../../types/folder'
|
||||
import { Doc } from '../../../../../types/doc'
|
||||
import { FileRef } from '../../../../../types/file-ref'
|
||||
import { ConnectDropTarget } from 'react-dnd'
|
||||
|
||||
type ExtendedFileRef = FileRef & { isFile: true }
|
||||
|
||||
function FileTreeFolderList({
|
||||
folders,
|
||||
docs,
|
||||
files,
|
||||
classes = {},
|
||||
dropRef = null,
|
||||
children,
|
||||
dataTestId,
|
||||
}: {
|
||||
folders: Folder[]
|
||||
docs: Doc[]
|
||||
files: FileRef[]
|
||||
classes?: { root?: string }
|
||||
dropRef?: ConnectDropTarget | null
|
||||
children?: React.ReactNode
|
||||
dataTestId?: string
|
||||
}) {
|
||||
files = files.map(file => ({ ...file, isFile: true }))
|
||||
const docsAndFiles: (Doc | ExtendedFileRef)[] = [...docs, ...files]
|
||||
|
||||
return (
|
||||
<ul
|
||||
className={classNames(
|
||||
'list-unstyled',
|
||||
'file-tree-folder-list',
|
||||
classes.root
|
||||
)}
|
||||
role="tree"
|
||||
ref={dropRef}
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
<div className="file-tree-folder-list-inner">
|
||||
{folders.sort(compareFunction).map(folder => {
|
||||
return (
|
||||
<FileTreeFolder
|
||||
key={folder._id}
|
||||
name={folder.name}
|
||||
id={folder._id}
|
||||
folders={folder.folders}
|
||||
docs={folder.docs}
|
||||
files={folder.fileRefs}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{docsAndFiles.sort(compareFunction).map(doc => {
|
||||
if ('isFile' in doc) {
|
||||
return (
|
||||
<FileTreeDoc
|
||||
key={doc._id}
|
||||
name={doc.name}
|
||||
id={doc._id}
|
||||
isFile={doc.isFile}
|
||||
isLinkedFile={
|
||||
doc.linkedFileData && !!doc.linkedFileData.provider
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <FileTreeDoc key={doc._id} name={doc.name} id={doc._id} />
|
||||
})}
|
||||
{children}
|
||||
</div>
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function compareFunction(one: { name: string }, two: { name: string }) {
|
||||
return fileCollator.compare(one.name, two.name)
|
||||
}
|
||||
|
||||
export default FileTreeFolderList
|
@@ -0,0 +1,96 @@
|
||||
import { useEffect } from 'react'
|
||||
import classNames from 'classnames'
|
||||
|
||||
import {
|
||||
useFileTreeSelectable,
|
||||
useSelectableEntity,
|
||||
} from '../contexts/file-tree-selectable'
|
||||
import { useDroppable } from '../contexts/file-tree-draggable'
|
||||
|
||||
import FileTreeItemInner from './file-tree-item/file-tree-item-inner'
|
||||
import FileTreeFolderList from './file-tree-folder-list'
|
||||
import usePersistedState from '../../../shared/hooks/use-persisted-state'
|
||||
import { Folder } from '../../../../../types/folder'
|
||||
import { Doc } from '../../../../../types/doc'
|
||||
import { FileRef } from '../../../../../types/file-ref'
|
||||
import FileTreeFolderIcons from './file-tree-folder-icons'
|
||||
|
||||
function FileTreeFolder({
|
||||
name,
|
||||
id,
|
||||
folders,
|
||||
docs,
|
||||
files,
|
||||
}: {
|
||||
name: string
|
||||
id: string
|
||||
folders: Folder[]
|
||||
docs: Doc[]
|
||||
files: FileRef[]
|
||||
}) {
|
||||
const { isSelected, props: selectableEntityProps } = useSelectableEntity(
|
||||
id,
|
||||
'folder'
|
||||
)
|
||||
|
||||
const { selectedEntityParentIds } = useFileTreeSelectable()
|
||||
|
||||
const [expanded, setExpanded] = usePersistedState(
|
||||
`folder.${id}.expanded`,
|
||||
false
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedEntityParentIds.has(id)) {
|
||||
setExpanded(true)
|
||||
}
|
||||
}, [id, selectedEntityParentIds, setExpanded])
|
||||
|
||||
function handleExpandCollapseClick() {
|
||||
setExpanded(!expanded)
|
||||
}
|
||||
|
||||
const { isOver: isOverRoot, dropRef: dropRefRoot } = useDroppable(id)
|
||||
const { isOver: isOverList, dropRef: dropRefList } = useDroppable(id)
|
||||
|
||||
return (
|
||||
<>
|
||||
<li
|
||||
// eslint-disable-next-line jsx-a11y/role-has-required-aria-props
|
||||
role="treeitem"
|
||||
// aria-selected is provided in selectableEntityProps
|
||||
{...selectableEntityProps}
|
||||
aria-expanded={expanded}
|
||||
aria-label={name}
|
||||
tabIndex={0}
|
||||
ref={dropRefRoot}
|
||||
className={classNames(selectableEntityProps.className, {
|
||||
'dnd-droppable-hover': isOverRoot || isOverList,
|
||||
})}
|
||||
>
|
||||
<FileTreeItemInner
|
||||
id={id}
|
||||
name={name}
|
||||
type="folder"
|
||||
isSelected={isSelected}
|
||||
icons={
|
||||
<FileTreeFolderIcons
|
||||
expanded={expanded}
|
||||
onExpandCollapseClick={handleExpandCollapseClick}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
{expanded ? (
|
||||
<FileTreeFolderList
|
||||
folders={folders}
|
||||
docs={docs}
|
||||
files={files}
|
||||
dropRef={dropRefList}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileTreeFolder
|
@@ -0,0 +1,60 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import iconTypeFromName, {
|
||||
newEditorIconTypeFromName,
|
||||
} from '../util/icon-type-from-name'
|
||||
import classnames from 'classnames'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils'
|
||||
|
||||
function FileTreeIcon({
|
||||
isLinkedFile,
|
||||
name,
|
||||
}: {
|
||||
name: string
|
||||
isLinkedFile?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const className = classnames('file-tree-icon', {
|
||||
'linked-file-icon': isLinkedFile,
|
||||
})
|
||||
|
||||
const newEditor = useIsNewEditorEnabled()
|
||||
|
||||
if (newEditor) {
|
||||
return (
|
||||
<>
|
||||
<MaterialIcon
|
||||
unfilled
|
||||
type={newEditorIconTypeFromName(name)}
|
||||
className={className}
|
||||
/>
|
||||
{isLinkedFile && (
|
||||
<MaterialIcon
|
||||
type="open_in_new"
|
||||
modifier="rotate-180"
|
||||
className="linked-file-highlight"
|
||||
accessibilityLabel={t('linked_file')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
<MaterialIcon type={iconTypeFromName(name)} className={className} />
|
||||
{isLinkedFile && (
|
||||
<MaterialIcon
|
||||
type="open_in_new"
|
||||
modifier="rotate-180"
|
||||
className="linked-file-highlight"
|
||||
accessibilityLabel={t('linked_file')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileTreeIcon
|
@@ -0,0 +1,27 @@
|
||||
import { useFileTreeSelectable } from '../contexts/file-tree-selectable'
|
||||
import { FC, useCallback } from 'react'
|
||||
|
||||
const FileTreeInner: FC = ({ children }) => {
|
||||
const { setIsRootFolderSelected, selectedEntityIds, select } =
|
||||
useFileTreeSelectable()
|
||||
|
||||
const handleFileTreeClick = useCallback(() => {
|
||||
setIsRootFolderSelected(true)
|
||||
if (selectedEntityIds.size > 1) {
|
||||
select([])
|
||||
}
|
||||
}, [select, selectedEntityIds.size, setIsRootFolderSelected])
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
className="file-tree-inner"
|
||||
onClick={handleFileTreeClick}
|
||||
data-testid="file-tree-inner"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileTreeInner
|
@@ -0,0 +1,99 @@
|
||||
import { ReactNode, useEffect, useRef } from 'react'
|
||||
import classNames from 'classnames'
|
||||
import scrollIntoViewIfNeeded from 'scroll-into-view-if-needed'
|
||||
|
||||
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
|
||||
import { useFileTreeMainContext } from '../../contexts/file-tree-main'
|
||||
import { useDraggable } from '../../contexts/file-tree-draggable'
|
||||
|
||||
import FileTreeItemName from './file-tree-item-name'
|
||||
import FileTreeItemMenu from './file-tree-item-menu'
|
||||
import { useFileTreeSelectable } from '../../contexts/file-tree-selectable'
|
||||
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
|
||||
import { useDragDropManager } from 'react-dnd'
|
||||
|
||||
function FileTreeItemInner({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
isSelected,
|
||||
icons,
|
||||
}: {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
isSelected: boolean
|
||||
icons?: ReactNode
|
||||
}) {
|
||||
const { fileTreeReadOnly } = useFileTreeData()
|
||||
const { setContextMenuCoords } = useFileTreeMainContext()
|
||||
const { isRenaming } = useFileTreeActionable()
|
||||
|
||||
const { selectedEntityIds } = useFileTreeSelectable()
|
||||
|
||||
const hasMenu =
|
||||
!fileTreeReadOnly && isSelected && selectedEntityIds.size === 1
|
||||
|
||||
const { dragRef, setIsDraggable } = useDraggable(id)
|
||||
|
||||
const dragDropItem = useDragDropManager().getMonitor().getItem()
|
||||
|
||||
const itemRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const item = itemRef.current
|
||||
if (isSelected && item) {
|
||||
// we're delaying scrolling due to a race condition with other elements,
|
||||
// mainly the Outline, being resized inside the same panel, causing the
|
||||
// FileTree to have its viewport shrinked after the selected item is
|
||||
// scrolled into the view, hiding it again.
|
||||
// See `left-pane-resize-all` in `file-tree-controller` for more information.
|
||||
setTimeout(() => {
|
||||
if (item) {
|
||||
scrollIntoViewIfNeeded(item, {
|
||||
scrollMode: 'if-needed',
|
||||
})
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
}, [isSelected, itemRef])
|
||||
|
||||
function handleContextMenu(ev: React.MouseEvent<HTMLDivElement>) {
|
||||
ev.preventDefault()
|
||||
|
||||
setContextMenuCoords({
|
||||
top: ev.pageY,
|
||||
left: ev.pageX,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('entity', {
|
||||
'file-tree-entity-dragging': dragDropItem?.draggedEntityIds?.has(id),
|
||||
})}
|
||||
role="presentation"
|
||||
ref={dragRef}
|
||||
draggable={!isRenaming}
|
||||
onContextMenu={handleContextMenu}
|
||||
data-file-id={id}
|
||||
data-file-type={type}
|
||||
>
|
||||
<div
|
||||
className="entity-name entity-name-react"
|
||||
role="presentation"
|
||||
ref={itemRef}
|
||||
>
|
||||
{icons}
|
||||
<FileTreeItemName
|
||||
name={name}
|
||||
isSelected={isSelected}
|
||||
setIsDraggable={setIsDraggable}
|
||||
/>
|
||||
{hasMenu ? <FileTreeItemMenu id={id} name={name} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileTreeItemInner
|
@@ -0,0 +1,94 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import * as eventTracking from '../../../../infrastructure/event-tracking'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
|
||||
import {
|
||||
DropdownDivider,
|
||||
DropdownItem,
|
||||
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
|
||||
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
|
||||
|
||||
function FileTreeItemMenuItems() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const {
|
||||
canRename,
|
||||
canDelete,
|
||||
canCreate,
|
||||
startRenaming,
|
||||
startDeleting,
|
||||
startCreatingFolder,
|
||||
startCreatingDocOrFile,
|
||||
startUploadingDocOrFile,
|
||||
downloadPath,
|
||||
selectedFileName,
|
||||
} = useFileTreeActionable()
|
||||
|
||||
const { owner } = useProjectContext()
|
||||
|
||||
const downloadWithAnalytics = useCallback(() => {
|
||||
// we are only interested in downloads of bib files WRT analytics, for the purposes of promoting the tpr integrations
|
||||
if (selectedFileName?.endsWith('.bib')) {
|
||||
eventTracking.sendMB('download-bib-file', { projectOwner: owner._id })
|
||||
}
|
||||
}, [selectedFileName, owner])
|
||||
|
||||
const createWithAnalytics = useCallback(() => {
|
||||
eventTracking.sendMB('new-file-click', { location: 'file-menu' })
|
||||
startCreatingDocOrFile()
|
||||
}, [startCreatingDocOrFile])
|
||||
|
||||
const uploadWithAnalytics = useCallback(() => {
|
||||
eventTracking.sendMB('upload-click', { location: 'file-menu' })
|
||||
startUploadingDocOrFile()
|
||||
}, [startUploadingDocOrFile])
|
||||
|
||||
return (
|
||||
<>
|
||||
{canRename ? (
|
||||
<li role="none">
|
||||
<DropdownItem onClick={startRenaming}>{t('rename')}</DropdownItem>
|
||||
</li>
|
||||
) : null}
|
||||
{downloadPath ? (
|
||||
<li role="none">
|
||||
<DropdownItem
|
||||
href={downloadPath}
|
||||
onClick={downloadWithAnalytics}
|
||||
download={selectedFileName ?? undefined}
|
||||
>
|
||||
{t('download')}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
) : null}
|
||||
{canDelete ? (
|
||||
<li role="none">
|
||||
<DropdownItem onClick={startDeleting}>{t('delete')}</DropdownItem>
|
||||
</li>
|
||||
) : null}
|
||||
{canCreate ? (
|
||||
<>
|
||||
<DropdownDivider />
|
||||
<li role="none">
|
||||
<DropdownItem onClick={createWithAnalytics}>
|
||||
{t('new_file')}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
<li role="none">
|
||||
<DropdownItem onClick={startCreatingFolder}>
|
||||
{t('new_folder')}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
<li role="none">
|
||||
<DropdownItem onClick={uploadWithAnalytics}>
|
||||
{t('upload')}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileTreeItemMenuItems
|
@@ -0,0 +1,43 @@
|
||||
import { useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useFileTreeMainContext } from '../../contexts/file-tree-main'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
|
||||
function FileTreeItemMenu({ id, name }: { id: string; name: string }) {
|
||||
const { t } = useTranslation()
|
||||
const { contextMenuCoords, setContextMenuCoords } = useFileTreeMainContext()
|
||||
const menuButtonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const isMenuOpen = Boolean(contextMenuCoords)
|
||||
|
||||
function handleClick(event: React.MouseEvent) {
|
||||
event.stopPropagation()
|
||||
if (!contextMenuCoords && menuButtonRef.current) {
|
||||
const target = menuButtonRef.current.getBoundingClientRect()
|
||||
setContextMenuCoords({
|
||||
top: target.top + target.height / 2,
|
||||
left: target.right,
|
||||
})
|
||||
} else {
|
||||
setContextMenuCoords(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="menu-button btn-group">
|
||||
<button
|
||||
className="entity-menu-toggle btn btn-sm"
|
||||
id={`menu-button-${id}`}
|
||||
onClick={handleClick}
|
||||
ref={menuButtonRef}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={isMenuOpen}
|
||||
aria-label={t('open_action_menu', { name })}
|
||||
>
|
||||
<MaterialIcon type="more_vert" accessibilityLabel={t('menu')} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileTreeItemMenu
|
@@ -0,0 +1,133 @@
|
||||
import { useState, useEffect, RefObject } from 'react'
|
||||
import { useRefWithAutoFocus } from '../../../../shared/hooks/use-ref-with-auto-focus'
|
||||
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
|
||||
|
||||
function FileTreeItemName({
|
||||
name,
|
||||
isSelected,
|
||||
setIsDraggable,
|
||||
}: {
|
||||
name: string
|
||||
isSelected: boolean
|
||||
setIsDraggable: (isDraggable: boolean) => void
|
||||
}) {
|
||||
const { isRenaming, startRenaming, finishRenaming, error, cancel } =
|
||||
useFileTreeActionable()
|
||||
|
||||
const isRenamingEntity = isRenaming && isSelected && !error
|
||||
|
||||
useEffect(() => {
|
||||
setIsDraggable(!isRenamingEntity)
|
||||
}, [setIsDraggable, isRenamingEntity])
|
||||
|
||||
if (isRenamingEntity) {
|
||||
return (
|
||||
<InputName
|
||||
initialValue={name}
|
||||
finishRenaming={finishRenaming}
|
||||
cancel={cancel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<DisplayName
|
||||
name={name}
|
||||
isSelected={isSelected}
|
||||
startRenaming={startRenaming}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DisplayName({
|
||||
name,
|
||||
isSelected,
|
||||
startRenaming,
|
||||
}: {
|
||||
name: string
|
||||
isSelected: boolean
|
||||
startRenaming: () => void
|
||||
}) {
|
||||
const [clicksInSelectedCount, setClicksInSelectedCount] = useState(0)
|
||||
|
||||
function onClick() {
|
||||
setClicksInSelectedCount(clicksInSelectedCount + 1)
|
||||
if (!isSelected) setClicksInSelectedCount(0)
|
||||
}
|
||||
|
||||
function onDoubleClick() {
|
||||
// only start renaming if the button got two or more clicks while the item
|
||||
// was selected. This is to prevent starting a rename on an unselected item.
|
||||
// When the item is being selected it can trigger a loss of focus which
|
||||
// causes UI problems.
|
||||
if (clicksInSelectedCount < 2) return
|
||||
startRenaming()
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className="item-name-button"
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
>
|
||||
<span>{name}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function InputName({
|
||||
initialValue,
|
||||
finishRenaming,
|
||||
cancel,
|
||||
}: {
|
||||
initialValue: string
|
||||
finishRenaming: (value: string) => void
|
||||
cancel: () => void
|
||||
}) {
|
||||
const [value, setValue] = useState(initialValue)
|
||||
|
||||
// The react-bootstrap Dropdown re-focuses on the Dropdown.Toggle
|
||||
// after a menu item is clicked, following the ARIA authoring practices:
|
||||
// https://www.w3.org/TR/wai-aria-practices/examples/menu-button/menu-button-links.html
|
||||
// To improve UX, we want to auto-focus to the input when renaming. We use
|
||||
// requestAnimationFrame to immediately move focus to the input after it is
|
||||
// shown
|
||||
const { autoFocusedRef } = useRefWithAutoFocus()
|
||||
|
||||
function handleFocus(ev: React.FocusEvent<HTMLInputElement>) {
|
||||
const lastDotIndex = ev.target.value.lastIndexOf('.')
|
||||
ev.target.setSelectionRange(0, lastDotIndex)
|
||||
}
|
||||
|
||||
function handleChange(ev: React.ChangeEvent<HTMLInputElement>) {
|
||||
setValue(ev.target.value)
|
||||
}
|
||||
|
||||
function handleKeyDown(ev: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (ev.key === 'Enter') {
|
||||
finishRenaming(value)
|
||||
}
|
||||
if (ev.key === 'Escape') {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
finishRenaming(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="rename-input">
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
ref={autoFocusedRef as RefObject<HTMLInputElement>}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileTreeItemName
|
@@ -0,0 +1,149 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import withErrorBoundary from '../../../infrastructure/error-boundary'
|
||||
import { useProjectContext } from '../../../shared/context/project-context'
|
||||
import { useFileTreeData } from '../../../shared/context/file-tree-data-context'
|
||||
import FileTreeContext from './file-tree-context'
|
||||
import FileTreeDraggablePreviewLayer from './file-tree-draggable-preview-layer'
|
||||
import FileTreeFolderList from './file-tree-folder-list'
|
||||
import FileTreeToolbar from './file-tree-toolbar'
|
||||
import FileTreeToolbarNew from '@/features/ide-redesign/components/file-tree-toolbar'
|
||||
import FileTreeModalDelete from './modals/file-tree-modal-delete'
|
||||
import FileTreeModalCreateFolder from './modals/file-tree-modal-create-folder'
|
||||
import FileTreeModalError from './modals/file-tree-modal-error'
|
||||
import FileTreeContextMenu from './file-tree-context-menu'
|
||||
import FileTreeError from './file-tree-error'
|
||||
import { useDroppable } from '../contexts/file-tree-draggable'
|
||||
import { useFileTreeSocketListener } from '../hooks/file-tree-socket-listener'
|
||||
import FileTreeModalCreateFile from './modals/file-tree-modal-create-file'
|
||||
import FileTreeInner from './file-tree-inner'
|
||||
import { useDragLayer } from 'react-dnd'
|
||||
import classnames from 'classnames'
|
||||
import { pathInFolder } from '@/features/file-tree/util/path'
|
||||
import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils'
|
||||
|
||||
const FileTreeRoot = React.memo<{
|
||||
onSelect: () => void
|
||||
onDelete: () => void
|
||||
onInit: () => void
|
||||
isConnected: boolean
|
||||
setRefProviderEnabled: () => void
|
||||
setStartedFreeTrial: () => void
|
||||
refProviders: Record<string, boolean>
|
||||
}>(function FileTreeRoot({
|
||||
refProviders,
|
||||
setRefProviderEnabled,
|
||||
setStartedFreeTrial,
|
||||
onSelect,
|
||||
onInit,
|
||||
onDelete,
|
||||
isConnected,
|
||||
}) {
|
||||
const [fileTreeContainer, setFileTreeContainer] =
|
||||
useState<HTMLDivElement | null>(null)
|
||||
const { _id: projectId } = useProjectContext()
|
||||
const { fileTreeData } = useFileTreeData()
|
||||
const isReady = Boolean(projectId && fileTreeData)
|
||||
const newEditor = useIsNewEditorEnabled()
|
||||
|
||||
useEffect(() => {
|
||||
if (fileTreeContainer) {
|
||||
const listener = (event: DragEvent) => {
|
||||
if (event.dataTransfer) {
|
||||
// store the dragged entity in dataTransfer
|
||||
const { dataset } = event.target as HTMLDivElement
|
||||
if (
|
||||
dataset.fileId &&
|
||||
dataset.fileType &&
|
||||
dataset.fileType !== 'folder'
|
||||
) {
|
||||
event.dataTransfer.setData(
|
||||
'application/x-overleaf-file-id',
|
||||
dataset.fileId
|
||||
)
|
||||
|
||||
const filePath = pathInFolder(fileTreeData, dataset.fileId)
|
||||
if (filePath) {
|
||||
event.dataTransfer.setData(
|
||||
'application/x-overleaf-file-path',
|
||||
filePath
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileTreeContainer.addEventListener('dragstart', listener)
|
||||
|
||||
return () => {
|
||||
fileTreeContainer.removeEventListener('dragstart', listener)
|
||||
}
|
||||
}
|
||||
}, [fileTreeContainer, fileTreeData])
|
||||
|
||||
useEffect(() => {
|
||||
if (isReady) onInit()
|
||||
}, [isReady, onInit])
|
||||
if (!isReady) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="file-tree"
|
||||
data-testid="file-tree"
|
||||
ref={setFileTreeContainer}
|
||||
>
|
||||
{fileTreeContainer && (
|
||||
<FileTreeContext
|
||||
refProviders={refProviders}
|
||||
setRefProviderEnabled={setRefProviderEnabled}
|
||||
setStartedFreeTrial={setStartedFreeTrial}
|
||||
onSelect={onSelect}
|
||||
fileTreeContainer={fileTreeContainer}
|
||||
>
|
||||
{isConnected ? null : <div className="disconnected-overlay" />}
|
||||
{newEditor ? <FileTreeToolbarNew /> : <FileTreeToolbar />}
|
||||
<FileTreeContextMenu />
|
||||
<FileTreeInner>
|
||||
<FileTreeRootFolder onDelete={onDelete} />
|
||||
</FileTreeInner>
|
||||
<FileTreeModalDelete />
|
||||
<FileTreeModalCreateFile />
|
||||
<FileTreeModalCreateFolder />
|
||||
<FileTreeModalError />
|
||||
</FileTreeContext>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
function FileTreeRootFolder({ onDelete }: { onDelete: () => void }) {
|
||||
useFileTreeSocketListener(onDelete)
|
||||
const { fileTreeData } = useFileTreeData()
|
||||
|
||||
const { isOver, dropRef } = useDroppable(fileTreeData._id)
|
||||
|
||||
const dragLayer = useDragLayer(monitor => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
item: monitor.getItem(),
|
||||
clientOffset: monitor.getClientOffset(),
|
||||
}))
|
||||
|
||||
return (
|
||||
<>
|
||||
<FileTreeDraggablePreviewLayer isOver={isOver} {...dragLayer} />
|
||||
<FileTreeFolderList
|
||||
folders={fileTreeData.folders}
|
||||
docs={fileTreeData.docs}
|
||||
files={fileTreeData.fileRefs}
|
||||
classes={{
|
||||
root: classnames('file-tree-list', {
|
||||
'file-tree-dragging': dragLayer.isDragging,
|
||||
}),
|
||||
}}
|
||||
dropRef={dropRef}
|
||||
dataTestId="file-tree-list-root"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default withErrorBoundary(FileTreeRoot, FileTreeError)
|
@@ -0,0 +1,126 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import * as eventTracking from '../../../infrastructure/event-tracking'
|
||||
import { useFileTreeActionable } from '../contexts/file-tree-actionable'
|
||||
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
|
||||
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import OLButtonToolbar from '@/features/ui/components/ol/ol-button-toolbar'
|
||||
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
|
||||
import React, { ElementType } from 'react'
|
||||
|
||||
const fileTreeToolbarComponents = importOverleafModules(
|
||||
'fileTreeToolbarComponents'
|
||||
) as { import: { default: ElementType }; path: string }[]
|
||||
|
||||
function FileTreeToolbar() {
|
||||
const { fileTreeReadOnly } = useFileTreeData()
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (fileTreeReadOnly) return null
|
||||
|
||||
return (
|
||||
<OLButtonToolbar
|
||||
className="toolbar toolbar-filetree"
|
||||
aria-label={t('project_files')}
|
||||
>
|
||||
<FileTreeToolbarLeft />
|
||||
<FileTreeToolbarRight />
|
||||
</OLButtonToolbar>
|
||||
)
|
||||
}
|
||||
|
||||
function FileTreeToolbarLeft() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
canCreate,
|
||||
startCreatingFolder,
|
||||
startCreatingDocOrFile,
|
||||
startUploadingDocOrFile,
|
||||
} = useFileTreeActionable()
|
||||
|
||||
const createWithAnalytics = () => {
|
||||
eventTracking.sendMB('new-file-click', { location: 'toolbar' })
|
||||
startCreatingDocOrFile()
|
||||
}
|
||||
|
||||
const uploadWithAnalytics = () => {
|
||||
eventTracking.sendMB('upload-click', { location: 'toolbar' })
|
||||
startUploadingDocOrFile()
|
||||
}
|
||||
|
||||
if (!canCreate) return null
|
||||
|
||||
return (
|
||||
<div className="toolbar-left">
|
||||
<OLTooltip
|
||||
id="new-file"
|
||||
description={t('new_file')}
|
||||
overlayProps={{ placement: 'bottom' }}
|
||||
>
|
||||
<button className="btn" onClick={createWithAnalytics}>
|
||||
<MaterialIcon type="description" accessibilityLabel={t('new_file')} />
|
||||
</button>
|
||||
</OLTooltip>
|
||||
<OLTooltip
|
||||
id="new-folder"
|
||||
description={t('new_folder')}
|
||||
overlayProps={{ placement: 'bottom' }}
|
||||
>
|
||||
<button className="btn" onClick={startCreatingFolder} tabIndex={-1}>
|
||||
<MaterialIcon type="folder" accessibilityLabel={t('new_folder')} />
|
||||
</button>
|
||||
</OLTooltip>
|
||||
<OLTooltip
|
||||
id="upload"
|
||||
description={t('upload')}
|
||||
overlayProps={{ placement: 'bottom' }}
|
||||
>
|
||||
<button className="btn" onClick={uploadWithAnalytics} tabIndex={-1}>
|
||||
<MaterialIcon type="upload" accessibilityLabel={t('upload')} />
|
||||
</button>
|
||||
</OLTooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FileTreeToolbarRight() {
|
||||
const { t } = useTranslation()
|
||||
const { canRename, canDelete, startRenaming, startDeleting } =
|
||||
useFileTreeActionable()
|
||||
|
||||
return (
|
||||
<div className="toolbar-right">
|
||||
{fileTreeToolbarComponents.map(
|
||||
({ import: { default: Component }, path }) => (
|
||||
<Component key={path} />
|
||||
)
|
||||
)}
|
||||
|
||||
{canRename ? (
|
||||
<OLTooltip
|
||||
id="rename"
|
||||
description={t('rename')}
|
||||
overlayProps={{ placement: 'bottom' }}
|
||||
>
|
||||
<button className="btn" onClick={startRenaming} tabIndex={-1}>
|
||||
<MaterialIcon type="edit" accessibilityLabel={t('rename')} />
|
||||
</button>
|
||||
</OLTooltip>
|
||||
) : null}
|
||||
|
||||
{canDelete ? (
|
||||
<OLTooltip
|
||||
id="delete"
|
||||
description={t('delete')}
|
||||
overlayProps={{ placement: 'bottom' }}
|
||||
>
|
||||
<button className="btn" onClick={startDeleting} tabIndex={-1}>
|
||||
<MaterialIcon type="delete" accessibilityLabel={t('delete')} />
|
||||
</button>
|
||||
</OLTooltip>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileTreeToolbar
|
@@ -0,0 +1,39 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
|
||||
import FileTreeCreateFormProvider from '../../contexts/file-tree-create-form'
|
||||
import FileTreeModalCreateFileBody from '../file-tree-create/file-tree-modal-create-file-body'
|
||||
import FileTreeModalCreateFileFooter from '../file-tree-create/file-tree-modal-create-file-footer'
|
||||
import OLModal, {
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
} from '@/features/ui/components/ol/ol-modal'
|
||||
|
||||
export default function FileTreeModalCreateFile() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { isCreatingFile, cancel } = useFileTreeActionable()
|
||||
|
||||
if (!isCreatingFile) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<FileTreeCreateFormProvider>
|
||||
<OLModal size="lg" onHide={cancel} show>
|
||||
<OLModalHeader closeButton>
|
||||
<OLModalTitle>{t('add_files')}</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
|
||||
<OLModalBody className="modal-new-file">
|
||||
<FileTreeModalCreateFileBody />
|
||||
</OLModalBody>
|
||||
|
||||
<OLModalFooter>
|
||||
<FileTreeModalCreateFileFooter />
|
||||
</OLModalFooter>
|
||||
</OLModal>
|
||||
</FileTreeCreateFormProvider>
|
||||
)
|
||||
}
|
@@ -0,0 +1,151 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRefWithAutoFocus } from '../../../../shared/hooks/use-ref-with-auto-focus'
|
||||
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
|
||||
import { DuplicateFilenameError } from '../../errors'
|
||||
import { isCleanFilename } from '../../util/safe-path'
|
||||
import OLModal, {
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
} from '@/features/ui/components/ol/ol-modal'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
|
||||
function FileTreeModalCreateFolder() {
|
||||
const { t } = useTranslation()
|
||||
const [name, setName] = useState('')
|
||||
const [validName, setValidName] = useState(true)
|
||||
|
||||
const { isCreatingFolder, inFlight, finishCreatingFolder, cancel, error } =
|
||||
useFileTreeActionable()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCreatingFolder) {
|
||||
// clear the input when the modal is closed
|
||||
setName('')
|
||||
}
|
||||
}, [isCreatingFolder])
|
||||
|
||||
if (!isCreatingFolder) return null // the modal will not be rendered; return early
|
||||
|
||||
function handleHide() {
|
||||
cancel()
|
||||
}
|
||||
|
||||
function handleCreateFolder() {
|
||||
finishCreatingFolder(name)
|
||||
}
|
||||
|
||||
function errorMessage() {
|
||||
switch (error.constructor) {
|
||||
case DuplicateFilenameError:
|
||||
return t('file_already_exists')
|
||||
default:
|
||||
return t('generic_something_went_wrong')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<OLModal show onHide={handleHide}>
|
||||
<OLModalHeader>
|
||||
<OLModalTitle>{t('new_folder')}</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
|
||||
<OLModalBody>
|
||||
<InputName
|
||||
name={name}
|
||||
setName={setName}
|
||||
validName={validName}
|
||||
setValidName={setValidName}
|
||||
handleCreateFolder={handleCreateFolder}
|
||||
/>
|
||||
{!validName ? (
|
||||
<div
|
||||
role="alert"
|
||||
aria-label={t('files_cannot_include_invalid_characters')}
|
||||
className="alert alert-danger file-tree-modal-alert"
|
||||
>
|
||||
{t('files_cannot_include_invalid_characters')}
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
aria-label={errorMessage()}
|
||||
className="alert alert-danger file-tree-modal-alert"
|
||||
>
|
||||
{errorMessage()}
|
||||
</div>
|
||||
) : null}
|
||||
</OLModalBody>
|
||||
|
||||
<OLModalFooter>
|
||||
{inFlight ? (
|
||||
<OLButton variant="primary" disabled isLoading={inFlight} />
|
||||
) : (
|
||||
<>
|
||||
<OLButton variant="secondary" onClick={handleHide}>
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
<OLButton
|
||||
variant="primary"
|
||||
onClick={handleCreateFolder}
|
||||
disabled={!validName}
|
||||
>
|
||||
{t('create')}
|
||||
</OLButton>
|
||||
</>
|
||||
)}
|
||||
</OLModalFooter>
|
||||
</OLModal>
|
||||
)
|
||||
}
|
||||
|
||||
function InputName({
|
||||
name,
|
||||
setName,
|
||||
validName,
|
||||
setValidName,
|
||||
handleCreateFolder,
|
||||
}) {
|
||||
const { autoFocusedRef } = useRefWithAutoFocus()
|
||||
|
||||
function handleFocus(ev) {
|
||||
ev.target.setSelectionRange(0, -1)
|
||||
}
|
||||
|
||||
function handleChange(ev) {
|
||||
setValidName(isCleanFilename(ev.target.value.trim()))
|
||||
setName(ev.target.value)
|
||||
}
|
||||
|
||||
function handleKeyDown(ev) {
|
||||
if (ev.key === 'Enter' && validName) {
|
||||
handleCreateFolder()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={autoFocusedRef}
|
||||
className="form-control"
|
||||
type="text"
|
||||
value={name}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
InputName.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
setName: PropTypes.func.isRequired,
|
||||
validName: PropTypes.bool.isRequired,
|
||||
setValidName: PropTypes.func.isRequired,
|
||||
handleCreateFolder: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
export default FileTreeModalCreateFolder
|
@@ -0,0 +1,74 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
|
||||
import OLModal, {
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
} from '@/features/ui/components/ol/ol-modal'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
import OLNotification from '@/features/ui/components/ol/ol-notification'
|
||||
|
||||
function FileTreeModalDelete() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const {
|
||||
isDeleting,
|
||||
inFlight,
|
||||
finishDeleting,
|
||||
actionedEntities,
|
||||
cancel,
|
||||
error,
|
||||
} = useFileTreeActionable()
|
||||
|
||||
if (!isDeleting) return null // the modal will not be rendered; return early
|
||||
|
||||
function handleHide() {
|
||||
cancel()
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
finishDeleting()
|
||||
}
|
||||
|
||||
return (
|
||||
<OLModal show onHide={handleHide}>
|
||||
<OLModalHeader>
|
||||
<OLModalTitle>{t('delete')}</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
|
||||
<OLModalBody>
|
||||
<p>{t('sure_you_want_to_delete')}</p>
|
||||
<ul>
|
||||
{actionedEntities.map(entity => (
|
||||
<li key={entity._id}>{entity.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
{error && (
|
||||
<OLNotification
|
||||
type="error"
|
||||
content={t('generic_something_went_wrong')}
|
||||
/>
|
||||
)}
|
||||
</OLModalBody>
|
||||
|
||||
<OLModalFooter>
|
||||
{inFlight ? (
|
||||
<OLButton variant="danger" disabled isLoading />
|
||||
) : (
|
||||
<>
|
||||
<OLButton variant="secondary" onClick={handleHide}>
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
<OLButton variant="danger" onClick={handleDelete}>
|
||||
{t('delete')}
|
||||
</OLButton>
|
||||
</>
|
||||
)}
|
||||
</OLModalFooter>
|
||||
</OLModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileTreeModalDelete
|
@@ -0,0 +1,89 @@
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
|
||||
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
|
||||
|
||||
import {
|
||||
InvalidFilenameError,
|
||||
BlockedFilenameError,
|
||||
DuplicateFilenameError,
|
||||
DuplicateFilenameMoveError,
|
||||
} from '../../errors'
|
||||
import OLModal, {
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
} from '@/features/ui/components/ol/ol-modal'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
|
||||
function FileTreeModalError() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { isRenaming, isMoving, cancel, error } = useFileTreeActionable()
|
||||
|
||||
// the modal will not be rendered; return early
|
||||
if (!error) return null
|
||||
if (!isRenaming && !isMoving) return null
|
||||
|
||||
function handleHide() {
|
||||
cancel()
|
||||
}
|
||||
|
||||
function errorTitle() {
|
||||
switch (error.constructor) {
|
||||
case DuplicateFilenameError:
|
||||
case DuplicateFilenameMoveError:
|
||||
return t('duplicate_file')
|
||||
case InvalidFilenameError:
|
||||
case BlockedFilenameError:
|
||||
return t('invalid_file_name')
|
||||
default:
|
||||
return t('error')
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage() {
|
||||
switch (error.constructor) {
|
||||
case DuplicateFilenameError:
|
||||
return t('file_already_exists')
|
||||
case DuplicateFilenameMoveError:
|
||||
return (
|
||||
<Trans
|
||||
i18nKey="file_already_exists_in_this_location"
|
||||
components={[<strong />]} // eslint-disable-line react/jsx-key
|
||||
values={{ fileName: error.entityName }}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
/>
|
||||
)
|
||||
case InvalidFilenameError:
|
||||
return t('files_cannot_include_invalid_characters')
|
||||
case BlockedFilenameError:
|
||||
return t('blocked_filename')
|
||||
default:
|
||||
return t('generic_something_went_wrong')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<OLModal show onHide={handleHide}>
|
||||
<OLModalHeader>
|
||||
<OLModalTitle>{errorTitle()}</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
|
||||
<OLModalBody>
|
||||
<div role="alert" aria-label={errorMessage()}>
|
||||
{errorMessage()}
|
||||
</div>
|
||||
</OLModalBody>
|
||||
|
||||
<OLModalFooter>
|
||||
<OLButton onClick={handleHide} variant="primary">
|
||||
{t('ok')}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</OLModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileTreeModalError
|
@@ -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()
|
||||
}
|
||||
}
|
@@ -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
|
@@ -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
|
@@ -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)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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
|
||||
}
|
@@ -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
|
||||
}
|
||||
}
|
23
services/web/frontend/js/features/file-tree/errors.js
Normal file
23
services/web/frontend/js/features/file-tree/errors.js
Normal file
@@ -0,0 +1,23 @@
|
||||
export class InvalidFilenameError extends Error {
|
||||
constructor() {
|
||||
super('invalid filename')
|
||||
}
|
||||
}
|
||||
|
||||
export class BlockedFilenameError extends Error {
|
||||
constructor() {
|
||||
super('blocked filename')
|
||||
}
|
||||
}
|
||||
|
||||
export class DuplicateFilenameError extends Error {
|
||||
constructor() {
|
||||
super('duplicate filename')
|
||||
}
|
||||
}
|
||||
|
||||
export class DuplicateFilenameMoveError extends Error {
|
||||
constructor() {
|
||||
super('duplicate filename on move')
|
||||
}
|
||||
}
|
@@ -0,0 +1,155 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
|
||||
import { useUserContext } from '../../../shared/context/user-context'
|
||||
import { useFileTreeData } from '../../../shared/context/file-tree-data-context'
|
||||
import { useFileTreeSelectable } from '../contexts/file-tree-selectable'
|
||||
import { findInTree, findInTreeOrThrow } from '../util/find-in-tree'
|
||||
import { useIdeContext } from '@/shared/context/ide-context'
|
||||
import { useSnapshotContext } from '@/features/ide-react/context/snapshot-context'
|
||||
|
||||
export function useFileTreeSocketListener(onDelete: (entity: any) => void) {
|
||||
const user = useUserContext()
|
||||
const {
|
||||
dispatchRename,
|
||||
dispatchDelete,
|
||||
dispatchMove,
|
||||
dispatchCreateFolder,
|
||||
dispatchCreateDoc,
|
||||
dispatchCreateFile,
|
||||
fileTreeData,
|
||||
} = useFileTreeData()
|
||||
const { selectedEntityIds, selectedEntityParentIds, select, unselect } =
|
||||
useFileTreeSelectable()
|
||||
const { socket } = useIdeContext()
|
||||
const { fileTreeFromHistory } = useSnapshotContext()
|
||||
|
||||
const selectEntityIfCreatedByUser = useCallback(
|
||||
// hack to automatically re-open refreshed linked files
|
||||
(entityId, entityName, userId) => {
|
||||
// If the created entity's user exists and is the current user
|
||||
if (userId && user?.id === userId) {
|
||||
// And we're expecting a refreshed socket for this entity
|
||||
if (window.expectingLinkedFileRefreshedSocketFor === entityName) {
|
||||
// Then select it
|
||||
select(entityId)
|
||||
window.expectingLinkedFileRefreshedSocketFor = null
|
||||
}
|
||||
}
|
||||
},
|
||||
[user, select]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (fileTreeFromHistory) return
|
||||
function handleDispatchRename(entityId: string, name: string) {
|
||||
dispatchRename(entityId, name)
|
||||
}
|
||||
if (socket) socket.on('reciveEntityRename', handleDispatchRename)
|
||||
return () => {
|
||||
if (socket)
|
||||
socket.removeListener('reciveEntityRename', handleDispatchRename)
|
||||
}
|
||||
}, [socket, dispatchRename, fileTreeFromHistory])
|
||||
|
||||
useEffect(() => {
|
||||
if (fileTreeFromHistory) return
|
||||
function handleDispatchDelete(entityId: string) {
|
||||
const entity = findInTree(fileTreeData, entityId)
|
||||
unselect(entityId)
|
||||
if (selectedEntityParentIds.has(entityId)) {
|
||||
// we're deleting a folder with a selected children so we need to
|
||||
// unselect its selected children first
|
||||
for (const selectedEntityId of selectedEntityIds) {
|
||||
if (
|
||||
findInTreeOrThrow(fileTreeData, selectedEntityId).path.includes(
|
||||
entityId
|
||||
)
|
||||
) {
|
||||
unselect(selectedEntityId)
|
||||
}
|
||||
}
|
||||
}
|
||||
dispatchDelete(entityId)
|
||||
if (onDelete) {
|
||||
onDelete(entity)
|
||||
}
|
||||
}
|
||||
if (socket) socket.on('removeEntity', handleDispatchDelete)
|
||||
return () => {
|
||||
if (socket) socket.removeListener('removeEntity', handleDispatchDelete)
|
||||
}
|
||||
}, [
|
||||
socket,
|
||||
unselect,
|
||||
dispatchDelete,
|
||||
fileTreeData,
|
||||
selectedEntityIds,
|
||||
selectedEntityParentIds,
|
||||
onDelete,
|
||||
fileTreeFromHistory,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (fileTreeFromHistory) return
|
||||
function handleDispatchMove(entityId: string, toFolderId: string) {
|
||||
dispatchMove(entityId, toFolderId)
|
||||
}
|
||||
if (socket) socket.on('reciveEntityMove', handleDispatchMove)
|
||||
return () => {
|
||||
if (socket) socket.removeListener('reciveEntityMove', handleDispatchMove)
|
||||
}
|
||||
}, [socket, dispatchMove, fileTreeFromHistory])
|
||||
|
||||
useEffect(() => {
|
||||
if (fileTreeFromHistory) return
|
||||
function handleDispatchCreateFolder(parentFolderId: string, folder: any) {
|
||||
dispatchCreateFolder(parentFolderId, folder)
|
||||
}
|
||||
if (socket) socket.on('reciveNewFolder', handleDispatchCreateFolder)
|
||||
return () => {
|
||||
if (socket)
|
||||
socket.removeListener('reciveNewFolder', handleDispatchCreateFolder)
|
||||
}
|
||||
}, [socket, dispatchCreateFolder, fileTreeFromHistory])
|
||||
|
||||
useEffect(() => {
|
||||
if (fileTreeFromHistory) return
|
||||
function handleDispatchCreateDoc(
|
||||
parentFolderId: string,
|
||||
doc: any,
|
||||
_source: unknown
|
||||
) {
|
||||
dispatchCreateDoc(parentFolderId, doc)
|
||||
}
|
||||
if (socket) socket.on('reciveNewDoc', handleDispatchCreateDoc)
|
||||
return () => {
|
||||
if (socket) socket.removeListener('reciveNewDoc', handleDispatchCreateDoc)
|
||||
}
|
||||
}, [socket, dispatchCreateDoc, fileTreeFromHistory])
|
||||
|
||||
useEffect(() => {
|
||||
if (fileTreeFromHistory) return
|
||||
function handleDispatchCreateFile(
|
||||
parentFolderId: string,
|
||||
file: any,
|
||||
_source: unknown,
|
||||
linkedFileData: any,
|
||||
userId: string
|
||||
) {
|
||||
dispatchCreateFile(parentFolderId, file)
|
||||
if (linkedFileData) {
|
||||
selectEntityIfCreatedByUser(file._id, file.name, userId)
|
||||
}
|
||||
}
|
||||
if (socket) socket.on('reciveNewFile', handleDispatchCreateFile)
|
||||
return () => {
|
||||
if (socket)
|
||||
socket.removeListener('reciveNewFile', handleDispatchCreateFile)
|
||||
}
|
||||
}, [
|
||||
socket,
|
||||
dispatchCreateFile,
|
||||
selectEntityIfCreatedByUser,
|
||||
fileTreeFromHistory,
|
||||
])
|
||||
}
|
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getJSON } from '../../../infrastructure/fetch-json'
|
||||
import { fileCollator } from '../util/file-collator'
|
||||
import useAbortController from '../../../shared/hooks/use-abort-controller'
|
||||
|
||||
export type Entity = {
|
||||
path: string
|
||||
}
|
||||
|
||||
const alphabetical = (a: Entity, b: Entity) =>
|
||||
fileCollator.compare(a.path, b.path)
|
||||
|
||||
export function useProjectEntities(projectId?: string) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [data, setData] = useState<Entity[] | null>(null)
|
||||
const [error, setError] = useState<any>(false)
|
||||
|
||||
const { signal } = useAbortController()
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
setLoading(true)
|
||||
setError(false)
|
||||
setData(null)
|
||||
|
||||
getJSON(`/project/${projectId}/entities`, { signal })
|
||||
.then(data => {
|
||||
setData(data.entities.sort(alphabetical))
|
||||
})
|
||||
.catch(error => setError(error))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
}, [projectId, signal])
|
||||
|
||||
return { loading, data, error }
|
||||
}
|
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { postJSON } from '../../../infrastructure/fetch-json'
|
||||
import { fileCollator } from '../util/file-collator'
|
||||
import useAbortController from '../../../shared/hooks/use-abort-controller'
|
||||
|
||||
export type OutputEntity = {
|
||||
path: string
|
||||
clsiServerId: string
|
||||
compileGroup: string
|
||||
build: string
|
||||
}
|
||||
|
||||
const alphabetical = (a: OutputEntity, b: OutputEntity) =>
|
||||
fileCollator.compare(a.path, b.path)
|
||||
|
||||
export function useProjectOutputFiles(projectId?: string) {
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
const [data, setData] = useState<OutputEntity[] | null>(null)
|
||||
const [error, setError] = useState<any>(false)
|
||||
|
||||
const { signal } = useAbortController()
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
setLoading(true)
|
||||
setError(false)
|
||||
setData(null)
|
||||
|
||||
postJSON(`/project/${projectId}/compile`, {
|
||||
body: {
|
||||
check: 'silent',
|
||||
draft: false,
|
||||
incrementalCompilesEnabled: false,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
const filteredFiles = data.outputFiles.filter(
|
||||
(file: OutputEntity) =>
|
||||
file.path.match(/.*\.(pdf|png|jpeg|jpg|gif)/)
|
||||
)
|
||||
data.outputFiles.forEach((file: OutputEntity) => {
|
||||
file.clsiServerId = data.clsiServerId
|
||||
file.compileGroup = data.compileGroup
|
||||
})
|
||||
setData(filteredFiles.sort(alphabetical))
|
||||
} else {
|
||||
setError('linked-project-compile-error')
|
||||
}
|
||||
})
|
||||
.catch(error => setError(error))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
}, [projectId, signal])
|
||||
|
||||
return { loading, data, error }
|
||||
}
|
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getJSON } from '../../../infrastructure/fetch-json'
|
||||
import { fileCollator } from '../util/file-collator'
|
||||
import useAbortController from '../../../shared/hooks/use-abort-controller'
|
||||
|
||||
export type Project = {
|
||||
_id: string
|
||||
name: string
|
||||
accessLevel: string
|
||||
}
|
||||
|
||||
const alphabetical = (a: Project, b: Project) =>
|
||||
fileCollator.compare(a.name, b.name)
|
||||
|
||||
export function useUserProjects() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [data, setData] = useState<Project[] | null>(null)
|
||||
const [error, setError] = useState<any>(false)
|
||||
|
||||
const { signal } = useAbortController()
|
||||
|
||||
useEffect(() => {
|
||||
getJSON('/user/projects', { signal })
|
||||
.then(data => {
|
||||
setData(data.projects.sort(alphabetical))
|
||||
})
|
||||
.catch(error => setError(error))
|
||||
.finally(() => setLoading(false))
|
||||
}, [signal])
|
||||
|
||||
return { loading, data, error }
|
||||
}
|
4
services/web/frontend/js/features/file-tree/util/api.ts
Normal file
4
services/web/frontend/js/features/file-tree/util/api.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { postJSON } from '../../../infrastructure/fetch-json'
|
||||
|
||||
export const refreshProjectMetadata = (projectId: string, entityId: string) =>
|
||||
postJSON(`/project/${projectId}/doc/${entityId}/metadata`)
|
@@ -0,0 +1,65 @@
|
||||
import getMeta from '@/utils/meta'
|
||||
import { Folder } from '../../../../../types/folder'
|
||||
|
||||
type FileCountStatus = 'success' | 'warning' | 'error'
|
||||
|
||||
type FileCount = {
|
||||
value: number
|
||||
status: FileCountStatus
|
||||
limit: number
|
||||
}
|
||||
|
||||
export function countFiles(fileTreeData: Folder | undefined): 0 | FileCount {
|
||||
if (!fileTreeData) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const value = _countElements(fileTreeData)
|
||||
|
||||
const limit = getMeta('ol-ExposedSettings').maxEntitiesPerProject
|
||||
const status = fileCountStatus(value, limit, Math.ceil(limit / 20))
|
||||
|
||||
return { value, status, limit }
|
||||
}
|
||||
|
||||
function fileCountStatus(
|
||||
value: number,
|
||||
limit: number,
|
||||
range: number
|
||||
): FileCountStatus {
|
||||
if (value >= limit) {
|
||||
return 'error'
|
||||
}
|
||||
|
||||
if (value >= limit - range) {
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
return 'success'
|
||||
}
|
||||
|
||||
// Copied and adapted from ProjectEntityMongoUpdateHandler
|
||||
function _countElements(rootFolder: Folder): number {
|
||||
function countFolder(folder: Folder) {
|
||||
if (folder == null) {
|
||||
return 0
|
||||
}
|
||||
|
||||
let total = 0
|
||||
if (folder.folders) {
|
||||
total += folder.folders.length
|
||||
for (const subfolder of folder.folders) {
|
||||
total += countFolder(subfolder)
|
||||
}
|
||||
}
|
||||
if (folder.docs) {
|
||||
total += folder.docs.length
|
||||
}
|
||||
if (folder.fileRefs) {
|
||||
total += folder.fileRefs.length
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
return countFolder(rootFolder)
|
||||
}
|
@@ -0,0 +1,31 @@
|
||||
import { Folder } from '../../../../../types/folder'
|
||||
import { DocId, MainDocument } from '../../../../../types/project-settings'
|
||||
|
||||
function findAllDocsInFolder(folder: Folder, path = '') {
|
||||
const docs = folder.docs.map<MainDocument>(doc => ({
|
||||
doc: { id: doc._id as DocId, name: doc.name },
|
||||
path: path + doc.name,
|
||||
}))
|
||||
for (const subFolder of folder.folders) {
|
||||
docs.push(...findAllDocsInFolder(subFolder, `${path}${subFolder.name}/`))
|
||||
}
|
||||
return docs
|
||||
}
|
||||
|
||||
export function docsInFolder(folder: Folder) {
|
||||
const docsInTree = findAllDocsInFolder(folder)
|
||||
docsInTree.sort(function (a, b) {
|
||||
const aDepth = (a.path.match(/\//g) || []).length
|
||||
const bDepth = (b.path.match(/\//g) || []).length
|
||||
if (aDepth - bDepth !== 0) {
|
||||
return -(aDepth - bDepth) // Deeper path == folder first
|
||||
} else if (a.path < b.path) {
|
||||
return -1
|
||||
} else if (a.path > b.path) {
|
||||
return 1
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
})
|
||||
return docsInTree
|
||||
}
|
@@ -0,0 +1,11 @@
|
||||
// The collator used to sort files docs and folders in the tree.
|
||||
// Uses English as base language for consistency.
|
||||
// Options used:
|
||||
// numeric: true so 10 comes after 2
|
||||
// sensitivity: 'variant' so case and accent are not equal
|
||||
// caseFirst: 'upper' so upper-case letters come first
|
||||
export const fileCollator = new Intl.Collator('en', {
|
||||
numeric: true,
|
||||
sensitivity: 'variant',
|
||||
caseFirst: 'upper',
|
||||
})
|
@@ -0,0 +1,95 @@
|
||||
import OError from '@overleaf/o-error'
|
||||
import { Folder } from '../../../../../types/folder'
|
||||
import { FileTreeFindResult } from '@/features/ide-react/types/file-tree'
|
||||
|
||||
export function findInTreeOrThrow(tree: Folder, id: string) {
|
||||
const found = findInTree(tree, id)
|
||||
if (found) return found
|
||||
throw new OError('Entity not found in tree', { entityId: id })
|
||||
}
|
||||
|
||||
export function findAllInTreeOrThrow(
|
||||
tree: Folder,
|
||||
ids: Set<string>
|
||||
): Set<FileTreeFindResult> {
|
||||
const list: Set<FileTreeFindResult> = new Set()
|
||||
ids.forEach(id => {
|
||||
list.add(findInTreeOrThrow(tree, id))
|
||||
})
|
||||
return list
|
||||
}
|
||||
|
||||
export function findAllFolderIdsInFolder(folder: Folder): Set<string> {
|
||||
const list = new Set([folder._id])
|
||||
for (const index in folder.folders) {
|
||||
const subFolder = folder.folders[index]
|
||||
findAllFolderIdsInFolder(subFolder).forEach(subFolderId => {
|
||||
list.add(subFolderId)
|
||||
})
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
export function findAllFolderIdsInFolders(folders: Set<Folder>): Set<string> {
|
||||
const list: Set<string> = new Set()
|
||||
folders.forEach(folder => {
|
||||
findAllFolderIdsInFolder(folder).forEach(folderId => {
|
||||
list.add(folderId)
|
||||
})
|
||||
})
|
||||
return list
|
||||
}
|
||||
|
||||
export function findInTree(
|
||||
tree: Folder,
|
||||
id: string,
|
||||
path?: string[]
|
||||
): FileTreeFindResult | null {
|
||||
if (!path) {
|
||||
path = [tree._id]
|
||||
}
|
||||
for (const index in tree.docs) {
|
||||
const doc = tree.docs[index]
|
||||
if (doc._id === id) {
|
||||
return {
|
||||
entity: doc,
|
||||
type: 'doc',
|
||||
parent: tree.docs,
|
||||
parentFolderId: tree._id,
|
||||
path,
|
||||
index: Number(index),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const index in tree.fileRefs) {
|
||||
const file = tree.fileRefs[index]
|
||||
if (file._id === id) {
|
||||
return {
|
||||
entity: file,
|
||||
type: 'fileRef',
|
||||
parent: tree.fileRefs,
|
||||
parentFolderId: tree._id,
|
||||
path,
|
||||
index: Number(index),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const index in tree.folders) {
|
||||
const folder = tree.folders[index]
|
||||
if (folder._id === id) {
|
||||
return {
|
||||
entity: folder,
|
||||
type: 'folder',
|
||||
parent: tree.folders,
|
||||
parentFolderId: tree._id,
|
||||
path,
|
||||
index: Number(index),
|
||||
}
|
||||
}
|
||||
const found = findInTree(folder, id, path.concat(folder._id))
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
@@ -0,0 +1,37 @@
|
||||
import { AvailableUnfilledIcon } from '@/shared/components/material-icon'
|
||||
|
||||
// TODO ide-redesign-cleanup: Make this the default export and remove the legacy version
|
||||
export const newEditorIconTypeFromName = (
|
||||
name: string
|
||||
): AvailableUnfilledIcon => {
|
||||
let ext = name.split('.').pop()
|
||||
ext = ext ? ext.toLowerCase() : ext
|
||||
|
||||
if (ext && ['png', 'pdf', 'jpg', 'jpeg', 'gif'].includes(ext)) {
|
||||
return 'image'
|
||||
} else if (ext && ['csv', 'xls', 'xlsx'].includes(ext)) {
|
||||
return 'table_chart'
|
||||
} else if (ext && ['py', 'r'].includes(ext)) {
|
||||
return 'code'
|
||||
} else if (ext && ['bib'].includes(ext)) {
|
||||
return 'book_5'
|
||||
}
|
||||
return 'description'
|
||||
}
|
||||
|
||||
export default function iconTypeFromName(name: string): string {
|
||||
let ext = name.split('.').pop()
|
||||
ext = ext ? ext.toLowerCase() : ext
|
||||
|
||||
if (ext && ['png', 'pdf', 'jpg', 'jpeg', 'gif'].includes(ext)) {
|
||||
return 'image'
|
||||
} else if (ext && ['csv', 'xls', 'xlsx'].includes(ext)) {
|
||||
return 'table_chart'
|
||||
} else if (ext && ['py', 'r'].includes(ext)) {
|
||||
return 'code'
|
||||
} else if (ext && ['bib'].includes(ext)) {
|
||||
return 'menu_book'
|
||||
} else {
|
||||
return 'description'
|
||||
}
|
||||
}
|
@@ -0,0 +1,26 @@
|
||||
import { Minimatch } from 'minimatch'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
let fileIgnoreMatcher: Minimatch
|
||||
|
||||
export const isAcceptableFile = (name?: string, relativePath?: string) => {
|
||||
if (!fileIgnoreMatcher) {
|
||||
fileIgnoreMatcher = new Minimatch(
|
||||
getMeta('ol-ExposedSettings').fileIgnorePattern,
|
||||
{ nocase: true, dot: true }
|
||||
)
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
// the file must have a name, of course
|
||||
return false
|
||||
}
|
||||
|
||||
if (!relativePath) {
|
||||
// uploading an individual file, so allow anything
|
||||
return true
|
||||
}
|
||||
|
||||
// uploading a file in a folder, so exclude unwanted file paths
|
||||
return !fileIgnoreMatcher.match(relativePath + '/' + name)
|
||||
}
|
@@ -0,0 +1,42 @@
|
||||
import { findInTreeOrThrow } from '../util/find-in-tree'
|
||||
import { Folder } from '../../../../../types/folder'
|
||||
import { Doc } from '../../../../../types/doc'
|
||||
import { FileRef } from '../../../../../types/file-ref'
|
||||
|
||||
export function isNameUniqueInFolder(
|
||||
tree: Folder,
|
||||
parentFolderId: string,
|
||||
name: string
|
||||
): boolean {
|
||||
return !(
|
||||
findFileByNameInFolder(tree, parentFolderId, name) ||
|
||||
findFolderByNameInFolder(tree, parentFolderId, name)
|
||||
)
|
||||
}
|
||||
|
||||
export function findFileByNameInFolder(
|
||||
tree: Folder,
|
||||
parentFolderId: string,
|
||||
name: string
|
||||
): Doc | FileRef | undefined {
|
||||
if (tree._id !== parentFolderId) {
|
||||
tree = findInTreeOrThrow(tree, parentFolderId).entity as Folder
|
||||
}
|
||||
|
||||
return (
|
||||
tree.docs.find(entity => entity.name === name) ||
|
||||
tree.fileRefs.find(entity => entity.name === name)
|
||||
)
|
||||
}
|
||||
|
||||
export function findFolderByNameInFolder(
|
||||
tree: Folder,
|
||||
parentFolderId: string,
|
||||
name: string
|
||||
): Folder | undefined {
|
||||
if (tree._id !== parentFolderId) {
|
||||
tree = findInTreeOrThrow(tree, parentFolderId).entity as Folder
|
||||
}
|
||||
|
||||
return tree.folders.find(entity => entity.name === name)
|
||||
}
|
@@ -0,0 +1,74 @@
|
||||
import { findInTreeOrThrow } from './find-in-tree'
|
||||
|
||||
export function renameInTree(tree, id, { newName }) {
|
||||
return mutateInTree(tree, id, (parent, entity, index) => {
|
||||
const newParent = Object.assign([], parent)
|
||||
const newEntity = {
|
||||
...entity,
|
||||
name: newName,
|
||||
}
|
||||
newParent[index] = newEntity
|
||||
return newParent
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteInTree(tree, id) {
|
||||
return mutateInTree(tree, id, (parent, entity, index) => {
|
||||
return [...parent.slice(0, index), ...parent.slice(index + 1)]
|
||||
})
|
||||
}
|
||||
|
||||
export function moveInTree(tree, entityId, toFolderId) {
|
||||
const found = findInTreeOrThrow(tree, entityId)
|
||||
if (found.parentFolderId === toFolderId) {
|
||||
// nothing to do (the entity was probably already moved)
|
||||
return tree
|
||||
}
|
||||
const newFileTreeData = deleteInTree(tree, entityId)
|
||||
return createEntityInTree(newFileTreeData, toFolderId, {
|
||||
...found.entity,
|
||||
type: found.type,
|
||||
})
|
||||
}
|
||||
|
||||
export function createEntityInTree(tree, parentFolderId, newEntityData) {
|
||||
const { type, ...newEntity } = newEntityData
|
||||
if (!type) throw new Error('Entity has no type')
|
||||
const entityType = `${type}s`
|
||||
|
||||
return mutateInTree(tree, parentFolderId, (parent, folder, index) => {
|
||||
parent[index] = {
|
||||
...folder,
|
||||
[entityType]: [...folder[entityType], newEntity],
|
||||
}
|
||||
return parent
|
||||
})
|
||||
}
|
||||
|
||||
function mutateInTree(tree, id, mutationFunction) {
|
||||
if (!id || tree._id === id) {
|
||||
// covers the root folder case: it has no parent so in order to use
|
||||
// mutationFunction we pass an empty array as the parent and return the
|
||||
// mutated tree directly
|
||||
const [newTree] = mutationFunction([], tree, 0)
|
||||
return newTree
|
||||
}
|
||||
|
||||
for (const entityType of ['docs', 'fileRefs', 'folders']) {
|
||||
for (let index = 0; index < tree[entityType].length; index++) {
|
||||
const entity = tree[entityType][index]
|
||||
if (entity._id === id) {
|
||||
return {
|
||||
...tree,
|
||||
[entityType]: mutationFunction(tree[entityType], entity, index),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newFolders = tree.folders.map(folder =>
|
||||
mutateInTree(folder, id, mutationFunction)
|
||||
)
|
||||
|
||||
return { ...tree, folders: newFolders }
|
||||
}
|
139
services/web/frontend/js/features/file-tree/util/path.ts
Normal file
139
services/web/frontend/js/features/file-tree/util/path.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { Folder } from '../../../../../types/folder'
|
||||
import { FileTreeEntity } from '../../../../../types/file-tree-entity'
|
||||
import { Doc } from '../../../../../types/doc'
|
||||
import { FileRef } from '../../../../../types/file-ref'
|
||||
import { PreviewPath } from '../../../../../types/preview-path'
|
||||
import { fileUrl } from '../../utils/fileUrl'
|
||||
|
||||
type DocFindResult = {
|
||||
entity: Doc
|
||||
type: 'doc'
|
||||
}
|
||||
|
||||
type FolderFindResult = {
|
||||
entity: Folder
|
||||
type: 'folder'
|
||||
}
|
||||
|
||||
type FileRefFindResult = {
|
||||
entity: FileRef
|
||||
type: 'fileRef'
|
||||
}
|
||||
|
||||
export type FindResult = DocFindResult | FolderFindResult | FileRefFindResult
|
||||
|
||||
// Finds the entity with a given ID in the tree represented by `folder` and
|
||||
// returns a path to that entity, represented by an array of folders starting at
|
||||
// the root plus the entity itself
|
||||
function pathComponentsInFolder(
|
||||
folder: Folder,
|
||||
id: string,
|
||||
ancestors: FileTreeEntity[] = []
|
||||
): FileTreeEntity[] | null {
|
||||
const docOrFileRef =
|
||||
folder.docs.find(doc => doc._id === id) ||
|
||||
folder.fileRefs.find(fileRef => fileRef._id === id)
|
||||
if (docOrFileRef) {
|
||||
return ancestors.concat([docOrFileRef])
|
||||
}
|
||||
|
||||
for (const subfolder of folder.folders) {
|
||||
if (subfolder._id === id) {
|
||||
return ancestors.concat([subfolder])
|
||||
} else {
|
||||
const path = pathComponentsInFolder(
|
||||
subfolder,
|
||||
id,
|
||||
ancestors.concat([subfolder])
|
||||
)
|
||||
if (path !== null) {
|
||||
return path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Finds the entity with a given ID in the tree represented by `folder` and
|
||||
// returns a path to that entity as a string
|
||||
export function pathInFolder(folder: Folder, id: string): string | null {
|
||||
return (
|
||||
pathComponentsInFolder(folder, id)
|
||||
?.map(entity => entity.name)
|
||||
.join('/') || null
|
||||
)
|
||||
}
|
||||
|
||||
export function findEntityByPath(
|
||||
folder: Folder,
|
||||
path: string
|
||||
): FindResult | null {
|
||||
if (path === '') {
|
||||
return { entity: folder, type: 'folder' }
|
||||
}
|
||||
|
||||
const parts = path.split('/')
|
||||
const name = parts.shift()
|
||||
const rest = parts.join('/')
|
||||
|
||||
if (name === '.') {
|
||||
return findEntityByPath(folder, rest)
|
||||
}
|
||||
|
||||
const doc = folder.docs.find(doc => doc.name === name)
|
||||
if (doc) {
|
||||
return { entity: doc, type: 'doc' }
|
||||
}
|
||||
|
||||
const fileRef = folder.fileRefs.find(fileRef => fileRef.name === name)
|
||||
if (fileRef) {
|
||||
return { entity: fileRef, type: 'fileRef' }
|
||||
}
|
||||
|
||||
for (const subfolder of folder.folders) {
|
||||
if (subfolder.name === name) {
|
||||
if (rest === '') {
|
||||
return { entity: subfolder, type: 'folder' }
|
||||
} else {
|
||||
return findEntityByPath(subfolder, rest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function previewByPath(
|
||||
folder: Folder,
|
||||
projectId: string,
|
||||
path: string
|
||||
): PreviewPath | null {
|
||||
for (const suffix of [
|
||||
'',
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.pdf',
|
||||
'.PNG',
|
||||
'.JPG',
|
||||
'.JPEG',
|
||||
'.PDF',
|
||||
]) {
|
||||
const result = findEntityByPath(folder, path + suffix)
|
||||
|
||||
if (result?.type === 'fileRef') {
|
||||
const { name, _id: id, hash } = result.entity
|
||||
return {
|
||||
url: fileUrl(projectId, id, hash),
|
||||
extension: name.slice(name.lastIndexOf('.') + 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function dirname(fileTreeData: Folder, id: string) {
|
||||
const path = pathInFolder(fileTreeData, id)
|
||||
return path?.split('/').slice(0, -1).join('/') || null
|
||||
}
|
110
services/web/frontend/js/features/file-tree/util/safe-path.ts
Normal file
110
services/web/frontend/js/features/file-tree/util/safe-path.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
// This file is shared between the frontend and server code of web, so that
|
||||
// filename validation is the same in both implementations.
|
||||
// The logic in all copies must be kept in sync:
|
||||
// app/src/Features/Project/SafePath.js
|
||||
// frontend/js/ide/directives/SafePath.js
|
||||
// frontend/js/features/file-tree/util/safe-path.js
|
||||
// eslint-disable-next-line prefer-regex-literals
|
||||
const BADCHAR_RX = new RegExp(
|
||||
`\
|
||||
[\
|
||||
\\/\
|
||||
\\\\\
|
||||
\\*\
|
||||
\\u0000-\\u001F\
|
||||
\\u007F\
|
||||
\\u0080-\\u009F\
|
||||
\\uD800-\\uDFFF\
|
||||
]\
|
||||
`,
|
||||
'g'
|
||||
)
|
||||
// eslint-disable-next-line prefer-regex-literals
|
||||
const BADFILE_RX = new RegExp(
|
||||
`\
|
||||
(^\\.$)\
|
||||
|(^\\.\\.$)\
|
||||
|(^\\s+)\
|
||||
|(\\s+$)\
|
||||
`,
|
||||
'g'
|
||||
)
|
||||
|
||||
// Put a block on filenames which match javascript property names, as they
|
||||
// can cause exceptions where the code puts filenames into a hash. This is a
|
||||
// temporary workaround until the code in other places is made safe against
|
||||
// property names.
|
||||
//
|
||||
// The list of property names is taken from
|
||||
// ['prototype'].concat(Object.getOwnPropertyNames(Object.prototype))
|
||||
// eslint-disable-next-line prefer-regex-literals
|
||||
const BLOCKEDFILE_RX = new RegExp(`\
|
||||
^(\
|
||||
prototype\
|
||||
|constructor\
|
||||
|toString\
|
||||
|toLocaleString\
|
||||
|valueOf\
|
||||
|hasOwnProperty\
|
||||
|isPrototypeOf\
|
||||
|propertyIsEnumerable\
|
||||
|__defineGetter__\
|
||||
|__lookupGetter__\
|
||||
|__defineSetter__\
|
||||
|__lookupSetter__\
|
||||
|__proto__\
|
||||
)$\
|
||||
`)
|
||||
|
||||
const MAX_PATH = 1024 // Maximum path length, in characters. This is fairly arbitrary.
|
||||
|
||||
export function clean(filename: string): string {
|
||||
filename = filename.replace(BADCHAR_RX, '_')
|
||||
// for BADFILE_RX replace any matches with an equal number of underscores
|
||||
filename = filename.replace(BADFILE_RX, match =>
|
||||
new Array(match.length + 1).join('_')
|
||||
)
|
||||
// replace blocked filenames 'prototype' with '@prototype'
|
||||
filename = filename.replace(BLOCKEDFILE_RX, '@$1')
|
||||
return filename
|
||||
}
|
||||
|
||||
export function isCleanFilename(filename: string): boolean {
|
||||
return (
|
||||
isAllowedLength(filename) &&
|
||||
!filename.match(BADCHAR_RX) &&
|
||||
!filename.match(BADFILE_RX)
|
||||
)
|
||||
}
|
||||
|
||||
export function isBlockedFilename(filename: string): boolean {
|
||||
return BLOCKEDFILE_RX.test(filename)
|
||||
}
|
||||
|
||||
// returns whether a full path is 'clean' - e.g. is a full or relative path
|
||||
// that points to a file, and each element passes the rules in 'isCleanFilename'
|
||||
export function isCleanPath(path: string): boolean {
|
||||
const elements = path.split('/')
|
||||
|
||||
const lastElementIsEmpty = elements[elements.length - 1].length === 0
|
||||
if (lastElementIsEmpty) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const element of Array.from(elements)) {
|
||||
if (element.length > 0 && !isCleanFilename(element)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// check for a top-level reserved name
|
||||
if (BLOCKEDFILE_RX.test(path.replace(/^\/?/, ''))) {
|
||||
return false
|
||||
} // remove leading slash if present
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function isAllowedLength(pathname: string): boolean {
|
||||
return pathname.length > 0 && pathname.length <= MAX_PATH
|
||||
}
|
@@ -0,0 +1,64 @@
|
||||
import { postJSON, deleteJSON } from '../../../infrastructure/fetch-json'
|
||||
|
||||
export function syncRename(
|
||||
projectId: string,
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
newName: string
|
||||
) {
|
||||
return postJSON(
|
||||
`/project/${projectId}/${getEntityPathName(entityType)}/${entityId}/rename`,
|
||||
{
|
||||
body: {
|
||||
name: newName,
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function syncDelete(
|
||||
projectId: string,
|
||||
entityType: string,
|
||||
entityId: string
|
||||
) {
|
||||
return deleteJSON(
|
||||
`/project/${projectId}/${getEntityPathName(entityType)}/${entityId}`
|
||||
)
|
||||
}
|
||||
|
||||
export function syncMove(
|
||||
projectId: string,
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
toFolderId: string
|
||||
) {
|
||||
return postJSON(
|
||||
`/project/${projectId}/${getEntityPathName(entityType)}/${entityId}/move`,
|
||||
{
|
||||
body: {
|
||||
folder_id: toFolderId,
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function syncCreateEntity(
|
||||
projectId: string,
|
||||
parentFolderId: string,
|
||||
newEntityData: {
|
||||
endpoint: 'doc' | 'folder' | 'linked-file'
|
||||
[key: string]: unknown
|
||||
}
|
||||
) {
|
||||
const { endpoint, ...newEntity } = newEntityData
|
||||
return postJSON(`/project/${projectId}/${endpoint}`, {
|
||||
body: {
|
||||
parent_folder_id: parentFolderId,
|
||||
...newEntity,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function getEntityPathName(entityType: string) {
|
||||
return entityType === 'fileRef' ? 'file' : entityType
|
||||
}
|
Reference in New Issue
Block a user