diff --git a/resources/scripts/components/elements/editor/Editor.tsx b/resources/scripts/components/elements/editor/Editor.tsx index bc395d601..f089d6a84 100644 --- a/resources/scripts/components/elements/editor/Editor.tsx +++ b/resources/scripts/components/elements/editor/Editor.tsx @@ -7,8 +7,9 @@ import { bracketMatching, foldGutter, foldKeymap, - LanguageDescription, indentUnit, + LanguageDescription, + LanguageSupport, } from '@codemirror/language'; import { languages } from '@codemirror/language-data'; import { lintKeymap } from '@codemirror/lint'; @@ -108,13 +109,15 @@ export function Editor(props: EditorProps) { // eslint-disable-next-line react/hook-use-state const [keybindings] = useState(new Compartment()); + const [languageSupport, setLanguageSupport] = useState(); + const createEditorState = () => EditorState.create({ doc: props.initialContent, extensions: [ defaultExtensions, props.extensions === undefined ? [] : props.extensions, - languageConfig.of([]), + languageConfig.of(languageSupport ?? []), keybindings.of([]), ], }); @@ -124,12 +127,18 @@ export function Editor(props: EditorProps) { return; } - setView( - new EditorView({ - state: createEditorState(), - parent: ref.current, - }), - ); + if (view === undefined) { + setView( + new EditorView({ + state: createEditorState(), + parent: ref.current, + }), + ); + } else { + // Fully replace the state whenever the initial content changes, this prevents any unrelated + // history (for undo and redo) from being tracked. + view.setState(createEditorState()); + } return () => { if (view === undefined) { @@ -139,15 +148,7 @@ export function Editor(props: EditorProps) { view.destroy(); setView(undefined); }; - }, [ref]); - - useEffect(() => { - if (view === undefined) { - return; - } - - view.setState(createEditorState()); - }, [props.initialContent]); + }, [ref, view, props.initialContent]); useEffect(() => { if (view === undefined) { @@ -159,10 +160,8 @@ export function Editor(props: EditorProps) { return; } - void language.load().then(language => { - view.dispatch({ - effects: languageConfig.reconfigure(language), - }); + void language.load().then(support => { + setLanguageSupport(support); }); if (props.onLanguageChanged !== undefined) { @@ -170,6 +169,16 @@ export function Editor(props: EditorProps) { } }, [view, props.filename, props.language]); + useEffect(() => { + if (languageSupport === undefined || view === undefined) { + return; + } + + view.dispatch({ + effects: languageConfig.reconfigure(languageSupport), + }); + }, [view, languageSupport]); + useEffect(() => { if (props.fetchContent === undefined) { return; diff --git a/resources/scripts/components/server/files/FileEditContainer.tsx b/resources/scripts/components/server/files/FileEditContainer.tsx index 623c130c9..b4ffc0250 100644 --- a/resources/scripts/components/server/files/FileEditContainer.tsx +++ b/resources/scripts/components/server/files/FileEditContainer.tsx @@ -1,14 +1,15 @@ import type { LanguageDescription } from '@codemirror/language'; +import { languages } from '@codemirror/language-data'; import { dirname } from 'pathe'; import { useEffect, useState } from 'react'; -import { useLocation, useNavigate, useParams } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import tw from 'twin.macro'; import { httpErrorToHuman } from '@/api/http'; import getFileContents from '@/api/server/files/getFileContents'; import saveFileContents from '@/api/server/files/saveFileContents'; import FlashMessageRender from '@/components/FlashMessageRender'; -import Button from '@/components/elements/Button'; +import { Button } from '@/components/elements/button'; import Can from '@/components/elements/Can'; import Select from '@/components/elements/Select'; import PageContentBlock from '@/components/elements/PageContentBlock'; @@ -18,21 +19,24 @@ import FileManagerBreadcrumbs from '@/components/server/files/FileManagerBreadcr import FileNameModal from '@/components/server/files/FileNameModal'; import ErrorBoundary from '@/components/elements/ErrorBoundary'; import { Editor } from '@/components/elements/editor'; -import modes from '@/modes'; import useFlash from '@/plugins/useFlash'; import { ServerContext } from '@/state/server'; -import { encodePathSegments, hashToPath } from '@/helpers'; +import { encodePathSegments } from '@/helpers'; export default () => { const [error, setError] = useState(''); - const { action } = useParams<{ action: 'new' | string }>(); + const { action, '*': rawFilename } = useParams<{ action: 'edit' | 'new'; '*': string }>(); const [loading, setLoading] = useState(action === 'edit'); const [content, setContent] = useState(''); const [modalVisible, setModalVisible] = useState(false); - const [mode, setMode] = useState('text/plain'); const [language, setLanguage] = useState(); - const { hash } = useLocation(); + const [filename, setFilename] = useState(''); + + useEffect(() => { + setFilename(decodeURIComponent(rawFilename ?? '')); + }, [rawFilename]); + const navigate = useNavigate(); const id = ServerContext.useStoreState(state => state.server.data!.id); @@ -43,20 +47,25 @@ export default () => { let fetchFileContent: null | (() => Promise) = null; useEffect(() => { - if (action === 'new') return; + if (action === 'new') { + return; + } + + if (filename === '') { + return; + } setError(''); setLoading(true); - const path = hashToPath(hash); - setDirectory(dirname(path)); - getFileContents(uuid, path) + setDirectory(dirname(filename)); + getFileContents(uuid, filename) .then(setContent) .catch(error => { console.error(error); setError(httpErrorToHuman(error)); }) .then(() => setLoading(false)); - }, [action, uuid, hash]); + }, [action, uuid, filename]); const save = (name?: string) => { if (!fetchFileContent) { @@ -66,10 +75,10 @@ export default () => { setLoading(true); clearFlashes('files:view'); fetchFileContent() - .then(content => saveFileContents(uuid, name || hashToPath(hash), content)) + .then(content => saveFileContents(uuid, name ?? filename, content)) .then(() => { if (name) { - navigate(`/server/${id}/files/edit#/${encodePathSegments(name)}`); + navigate(`/server/${id}/files/edit/${encodePathSegments(name)}`); return; } @@ -97,7 +106,7 @@ export default () => { - {hash.replace(/^#/, '').endsWith('.pteroignore') && ( + {filename === '.pteroignore' ? (

You're editing a .pteroignore{' '} @@ -107,7 +116,7 @@ export default () => { !).

- )} + ) : null} {
{ + setLanguage(l); + }} fetchContent={value => { fetchFileContent = value; }} @@ -140,10 +151,15 @@ export default () => {
- { + setLanguage(languages.find(l => l.name === e.target.value)); + }} + > + {languages.map(language => ( + ))} diff --git a/resources/scripts/components/server/files/FileManagerBreadcrumbs.tsx b/resources/scripts/components/server/files/FileManagerBreadcrumbs.tsx index 36c2a5c75..1cb4025f4 100644 --- a/resources/scripts/components/server/files/FileManagerBreadcrumbs.tsx +++ b/resources/scripts/components/server/files/FileManagerBreadcrumbs.tsx @@ -1,9 +1,10 @@ import { Fragment, useEffect, useState } from 'react'; -import { ServerContext } from '@/state/server'; -import { NavLink, useLocation } from 'react-router-dom'; -import { encodePathSegments, hashToPath } from '@/helpers'; +import { NavLink, useParams } from 'react-router-dom'; import tw from 'twin.macro'; +import { encodePathSegments } from '@/helpers'; +import { ServerContext } from '@/state/server'; + interface Props { renderLeft?: JSX.Element; withinFileEditor?: boolean; @@ -11,22 +12,29 @@ interface Props { } export default ({ renderLeft, withinFileEditor, isNewFile }: Props) => { - const [file, setFile] = useState(null); const id = ServerContext.useStoreState(state => state.server.data!.id); const directory = ServerContext.useStoreState(state => state.files.directory); - const { hash } = useLocation(); + + const params = useParams<'*'>(); + + const [file, setFile] = useState(); useEffect(() => { - const path = hashToPath(hash); - - if (withinFileEditor && !isNewFile) { - const name = path.split('/').pop() || null; - setFile(name); + if (!withinFileEditor || isNewFile) { + return; } - }, [withinFileEditor, isNewFile, hash]); - const breadcrumbs = (): { name: string; path?: string }[] => - directory + if (withinFileEditor && params['*'] !== undefined && !isNewFile) { + setFile(decodeURIComponent(params['*']).split('/').pop()); + } + }, [withinFileEditor, isNewFile]); + + const breadcrumbs = (): { name: string; path?: string }[] => { + if (directory === '.') { + return []; + } + + return directory .split('/') .filter(directory => !!directory) .map((directory, index, dirs) => { @@ -36,6 +44,7 @@ export default ({ renderLeft, withinFileEditor, isNewFile }: Props) => { return { name: directory, path: `/${dirs.slice(0, index + 1).join('/')}` }; }); + }; return (
@@ -50,6 +59,7 @@ export default ({ renderLeft, withinFileEditor, isNewFile }: Props) => { {crumb.name} diff --git a/resources/scripts/components/server/files/FileNameModal.tsx b/resources/scripts/components/server/files/FileNameModal.tsx index adb6a7555..cd868d975 100644 --- a/resources/scripts/components/server/files/FileNameModal.tsx +++ b/resources/scripts/components/server/files/FileNameModal.tsx @@ -1,11 +1,14 @@ -import Modal, { RequiredModalProps } from '@/components/elements/Modal'; -import { Form, Formik, FormikHelpers } from 'formik'; -import { object, string } from 'yup'; -import Field from '@/components/elements/Field'; -import { ServerContext } from '@/state/server'; +import type { FormikHelpers } from 'formik'; +import { Form, Formik } from 'formik'; import { join } from 'pathe'; import tw from 'twin.macro'; -import Button from '@/components/elements/Button'; +import { object, string } from 'yup'; + +import { Button } from '@/components/elements/button'; +import Field from '@/components/elements/Field'; +import type { RequiredModalProps } from '@/components/elements/Modal'; +import Modal from '@/components/elements/Modal'; +import { ServerContext } from '@/state/server'; type Props = RequiredModalProps & { onFileNamed: (name: string) => void; @@ -19,7 +22,7 @@ export default ({ onFileNamed, onDismissed, ...props }: Props) => { const directory = ServerContext.useStoreState(state => state.files.directory); const submit = (values: Values, { setSubmitting }: FormikHelpers) => { - onFileNamed(join(directory, values.fileName)); + onFileNamed(join(directory, values.fileName).replace(/^\//, '')); setSubmitting(false); }; diff --git a/resources/scripts/components/server/files/FileObjectRow.tsx b/resources/scripts/components/server/files/FileObjectRow.tsx index d945c2a3a..dc3e94481 100644 --- a/resources/scripts/components/server/files/FileObjectRow.tsx +++ b/resources/scripts/components/server/files/FileObjectRow.tsx @@ -8,10 +8,10 @@ import { NavLink } from 'react-router-dom'; import tw from 'twin.macro'; import { join } from 'pathe'; -import { encodePathSegments } from '@/helpers'; -import { FileObject } from '@/api/server/files/loadDirectory'; +import type { FileObject } from '@/api/server/files/loadDirectory'; import FileDropdownMenu from '@/components/server/files/FileDropdownMenu'; import SelectFileCheckbox from '@/components/server/files/SelectFileCheckbox'; +import { encodePathSegments } from '@/helpers'; import { bytesToString } from '@/lib/formatters'; import { usePermissions } from '@/plugins/usePermissions'; import { ServerContext } from '@/state/server'; @@ -27,7 +27,7 @@ function Clickable({ file, children }: { file: FileObject; children: ReactNode } ) : ( {children} diff --git a/resources/scripts/helpers.ts b/resources/scripts/helpers.ts index a09a15fe8..49bd3b458 100644 --- a/resources/scripts/helpers.ts +++ b/resources/scripts/helpers.ts @@ -45,5 +45,5 @@ export function encodePathSegments(path: string): string { } export function hashToPath(hash: string): string { - return hash.length > 0 ? decodeURIComponent(hash.substr(1)) : '/'; + return hash.length > 0 ? decodeURIComponent(hash.substring(1)) : '/'; } diff --git a/resources/scripts/routers/routes.ts b/resources/scripts/routers/routes.ts index 5f1da1fe7..6fa52e182 100644 --- a/resources/scripts/routers/routes.ts +++ b/resources/scripts/routers/routes.ts @@ -97,13 +97,7 @@ export default { component: FileManagerContainer, }, { - route: 'files/edit/*', - permission: 'file.*', - name: undefined, - component: FileEditContainer, - }, - { - route: 'files/new/*', + route: 'files/:action/*', permission: 'file.*', name: undefined, component: FileEditContainer,