diff --git a/services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-other-project-source.tsx b/services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-other-project-source.tsx index c3b51abe95..f6d846e300 100644 --- a/services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-other-project-source.tsx +++ b/services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-other-project-source.tsx @@ -20,6 +20,8 @@ import { postJSON } from '../../../../../infrastructure/fetch-json' import { useProjectContext } from '../../../../../shared/context/project-context' import { FileRelocator } from '../file-relocator' import { useTranslation } from 'react-i18next' +import { waitForFileTreeUpdate } from '../../../extensions/figure-modal' +import { useCodeMirrorViewContext } from '../../codemirror-editor' function suggestName(path: string) { const parts = path.split('/') @@ -28,6 +30,7 @@ function suggestName(path: string) { export const FigureModalOtherProjectSource: FC = () => { const { t } = useTranslation() + const view = useCodeMirrorViewContext() const { dispatch } = useFigureModalContext() const { _id: projectId } = useProjectContext() const { loading: projectsLoading, data: projects, error } = useUserProjects() @@ -108,9 +111,11 @@ export const FigureModalOtherProjectSource: FC = () => { dispatch({ getPath: async () => { + const fileTreeUpdate = waitForFileTreeUpdate(view) await postJSON(`/project/${projectId}/linked_file`, { body, }) + await fileTreeUpdate.withTimeout(500) return targetFolder.path === '' && targetFolder.name === 'rootFolder' ? `${newName}` : `${targetFolder.path ? targetFolder.path + '/' : ''}${ diff --git a/services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-upload-source.tsx b/services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-upload-source.tsx index 412f119220..ba8784318d 100644 --- a/services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-upload-source.tsx +++ b/services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-upload-source.tsx @@ -14,6 +14,8 @@ import classNames from 'classnames' import { Button } from 'react-bootstrap' import { FileRelocator } from '../file-relocator' import { useTranslation } from 'react-i18next' +import { useCodeMirrorViewContext } from '../../codemirror-editor' +import { waitForFileTreeUpdate } from '../../../extensions/figure-modal' const maxFileSize = window.ExposedSettings.maxUploadSize @@ -28,6 +30,7 @@ export enum FileUploadStatus { export const FigureModalUploadFileSource: FC = () => { const { t } = useTranslation() + const view = useCodeMirrorViewContext() const { dispatch } = useFigureModalContext() const { _id: projectId } = useProjectContext() const [, rootFolder] = useCurrentProjectFolders() @@ -68,7 +71,9 @@ export const FigureModalUploadFileSource: FC = () => { } dispatch({ getPath: async () => { + const fileTreeUpdate = waitForFileTreeUpdate(view) const uploadResult = await uppy.upload() + await fileTreeUpdate.withTimeout(500) if (!uploadResult.successful) { throw new Error('Upload failed') } @@ -81,7 +86,7 @@ export const FigureModalUploadFileSource: FC = () => { }, }) }, - [dispatch, rootFolder, uppy] + [dispatch, rootFolder, uppy, view] ) useEffect(() => { diff --git a/services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-url-source.tsx b/services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-url-source.tsx index d722ce9f89..c222e2551e 100644 --- a/services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-url-source.tsx +++ b/services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-url-source.tsx @@ -6,14 +6,19 @@ import { File } from '../../../utils/file' import { useCurrentProjectFolders } from '../../../hooks/useCurrentProjectFolders' import { FileRelocator } from '../file-relocator' import { useTranslation } from 'react-i18next' +import { useCodeMirrorViewContext } from '../../codemirror-editor' +import { EditorView } from '@codemirror/view' +import { waitForFileTreeUpdate } from '../../../extensions/figure-modal' function generateLinkedFileFetcher( projectId: string, url: string, name: string, - folder: File + folder: File, + view: EditorView ) { return async () => { + const fileTreeUpdate = waitForFileTreeUpdate(view) await postJSON(`/project/${projectId}/linked_file`, { body: { parent_folder_id: folder.id, @@ -24,6 +29,8 @@ function generateLinkedFileFetcher( }, }, }) + await fileTreeUpdate.withTimeout(500) + return folder.path === '' && folder.name === 'rootFolder' ? `${name}` : `${folder.path ? folder.path + '/' : ''}${folder.name}/${name}` @@ -31,6 +38,7 @@ function generateLinkedFileFetcher( } export const FigureModalUrlSource: FC = () => { + const view = useCodeMirrorViewContext() const { t } = useTranslation() const [url, setUrl] = useState('') const [nameDirty, setNameDirty] = useState(false) @@ -53,7 +61,8 @@ export const FigureModalUrlSource: FC = () => { projectId, newUrl, newName, - folder ?? rootFile + folder ?? rootFile, + view ), }) } else if (getPath) { diff --git a/services/web/frontend/js/features/source-editor/extensions/effect-listeners.ts b/services/web/frontend/js/features/source-editor/extensions/effect-listeners.ts new file mode 100644 index 0000000000..04d4d72aaa --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/effect-listeners.ts @@ -0,0 +1,73 @@ +import { StateEffect, StateEffectType, StateField } from '@codemirror/state' +import { EditorView } from '@codemirror/view' + +type EffectListenerOptions = { + once: boolean +} + +type EffectListener = { + effect: StateEffectType + callback: (value: any) => any + options?: EffectListenerOptions +} + +const addEffectListenerEffect = StateEffect.define() +const removeEffectListenerEffect = StateEffect.define() + +export const effectListeners = () => [effectListenersField] + +const effectListenersField = StateField.define({ + create: () => [], + update(value, transaction) { + for (const effect of transaction.effects) { + if (effect.is(addEffectListenerEffect)) { + value.push(effect.value) + } + if (effect.is(removeEffectListenerEffect)) { + value = value.filter( + listener => + !( + listener.effect === effect.value.effect && + listener.callback === effect.value.callback + ) + ) + } + for (let i = 0; i < value.length; ++i) { + const listener = value[i] + if (effect.is(listener.effect)) { + // Invoke the callback after the transaction + setTimeout(() => listener.callback(effect.value)) + if (listener.options?.once) { + // Remove the effectListener + value.splice(i, 1) + // Keep index the same for the next iteration, since we've removed + // an element + --i + } + } + } + } + return value + }, +}) + +export const addEffectListener = ( + view: EditorView, + effect: StateEffectType, + callback: (value: T) => any, + options?: EffectListenerOptions +) => { + view.dispatch({ + effects: addEffectListenerEffect.of({ effect, callback, options }), + }) +} + +export const removeEffectListener = ( + view: EditorView, + effect: StateEffectType, + callback: (value: T) => any +) => { + view.dispatch({ + effects: removeEffectListenerEffect.of({ effect, callback }), + }) +} diff --git a/services/web/frontend/js/features/source-editor/extensions/figure-modal.ts b/services/web/frontend/js/features/source-editor/extensions/figure-modal.ts index 9861f7d274..34b8a0105c 100644 --- a/services/web/frontend/js/features/source-editor/extensions/figure-modal.ts +++ b/services/web/frontend/js/features/source-editor/extensions/figure-modal.ts @@ -4,6 +4,9 @@ import { StateEffect, StateField, } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { addEffectListener, removeEffectListener } from './effect-listeners' +import { setMetadataEffect } from './language' type NestedReadonly = { readonly [P in keyof T]: NestedReadonly @@ -127,3 +130,31 @@ export const editFigureData = StateField.define({ }) export const figureModal = (): Extension => [editFigureData] + +export function waitForFileTreeUpdate(view: EditorView) { + const abortController = new AbortController() + const promise = new Promise(resolve => { + const abort = () => { + console.warn('Aborting wait for file tree update') + removeEffectListener(view, setMetadataEffect, listener) + resolve() + } + function listener() { + if (abortController.signal.aborted) { + // We've already handled this + return + } + abortController.signal.removeEventListener('abort', abort) + resolve() + } + abortController.signal.addEventListener('abort', abort, { once: true }) + addEffectListener(view, setMetadataEffect, listener, { once: true }) + }) + return { + withTimeout(afterMs = 500) { + setTimeout(() => abortController.abort(), afterMs) + return promise + }, + promise, + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/index.ts b/services/web/frontend/js/features/source-editor/extensions/index.ts index f1ec1887f0..4cf4658a04 100644 --- a/services/web/frontend/js/features/source-editor/extensions/index.ts +++ b/services/web/frontend/js/features/source-editor/extensions/index.ts @@ -44,6 +44,7 @@ import { indentationMarkers } from './indentation-markers' import { codemirrorDevTools } from '../languages/latex/codemirror-dev-tools' import { keymaps } from './keymaps' import { shortcuts } from './shortcuts' +import { effectListeners } from './effect-listeners' const moduleExtensions: Array<() => Extension> = importOverleafModules( 'sourceEditorExtensions' @@ -117,4 +118,5 @@ export const createExtensions = (options: Record): Extension[] => [ exceptionLogger(), moduleExtensions.map(extension => extension()), thirdPartyExtensions(), + effectListeners(), ]