ui(server): fix file editor and file manager

This commit is contained in:
Matthew Penner 2023-01-12 11:22:24 -07:00
parent 27e3eec5fc
commit 42d8f2cb82
No known key found for this signature in database
7 changed files with 106 additions and 74 deletions

View file

@ -7,8 +7,9 @@ import {
bracketMatching, bracketMatching,
foldGutter, foldGutter,
foldKeymap, foldKeymap,
LanguageDescription,
indentUnit, indentUnit,
LanguageDescription,
LanguageSupport,
} from '@codemirror/language'; } from '@codemirror/language';
import { languages } from '@codemirror/language-data'; import { languages } from '@codemirror/language-data';
import { lintKeymap } from '@codemirror/lint'; import { lintKeymap } from '@codemirror/lint';
@ -108,13 +109,15 @@ export function Editor(props: EditorProps) {
// eslint-disable-next-line react/hook-use-state // eslint-disable-next-line react/hook-use-state
const [keybindings] = useState(new Compartment()); const [keybindings] = useState(new Compartment());
const [languageSupport, setLanguageSupport] = useState<LanguageSupport>();
const createEditorState = () => const createEditorState = () =>
EditorState.create({ EditorState.create({
doc: props.initialContent, doc: props.initialContent,
extensions: [ extensions: [
defaultExtensions, defaultExtensions,
props.extensions === undefined ? [] : props.extensions, props.extensions === undefined ? [] : props.extensions,
languageConfig.of([]), languageConfig.of(languageSupport ?? []),
keybindings.of([]), keybindings.of([]),
], ],
}); });
@ -124,12 +127,18 @@ export function Editor(props: EditorProps) {
return; return;
} }
if (view === undefined) {
setView( setView(
new EditorView({ new EditorView({
state: createEditorState(), state: createEditorState(),
parent: ref.current, 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 () => { return () => {
if (view === undefined) { if (view === undefined) {
@ -139,15 +148,7 @@ export function Editor(props: EditorProps) {
view.destroy(); view.destroy();
setView(undefined); setView(undefined);
}; };
}, [ref]); }, [ref, view, props.initialContent]);
useEffect(() => {
if (view === undefined) {
return;
}
view.setState(createEditorState());
}, [props.initialContent]);
useEffect(() => { useEffect(() => {
if (view === undefined) { if (view === undefined) {
@ -159,10 +160,8 @@ export function Editor(props: EditorProps) {
return; return;
} }
void language.load().then(language => { void language.load().then(support => {
view.dispatch({ setLanguageSupport(support);
effects: languageConfig.reconfigure(language),
});
}); });
if (props.onLanguageChanged !== undefined) { if (props.onLanguageChanged !== undefined) {
@ -170,6 +169,16 @@ export function Editor(props: EditorProps) {
} }
}, [view, props.filename, props.language]); }, [view, props.filename, props.language]);
useEffect(() => {
if (languageSupport === undefined || view === undefined) {
return;
}
view.dispatch({
effects: languageConfig.reconfigure(languageSupport),
});
}, [view, languageSupport]);
useEffect(() => { useEffect(() => {
if (props.fetchContent === undefined) { if (props.fetchContent === undefined) {
return; return;

View file

@ -1,14 +1,15 @@
import type { LanguageDescription } from '@codemirror/language'; import type { LanguageDescription } from '@codemirror/language';
import { languages } from '@codemirror/language-data';
import { dirname } from 'pathe'; import { dirname } from 'pathe';
import { useEffect, useState } from 'react'; 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 tw from 'twin.macro';
import { httpErrorToHuman } from '@/api/http'; import { httpErrorToHuman } from '@/api/http';
import getFileContents from '@/api/server/files/getFileContents'; import getFileContents from '@/api/server/files/getFileContents';
import saveFileContents from '@/api/server/files/saveFileContents'; import saveFileContents from '@/api/server/files/saveFileContents';
import FlashMessageRender from '@/components/FlashMessageRender'; import FlashMessageRender from '@/components/FlashMessageRender';
import Button from '@/components/elements/Button'; import { Button } from '@/components/elements/button';
import Can from '@/components/elements/Can'; import Can from '@/components/elements/Can';
import Select from '@/components/elements/Select'; import Select from '@/components/elements/Select';
import PageContentBlock from '@/components/elements/PageContentBlock'; 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 FileNameModal from '@/components/server/files/FileNameModal';
import ErrorBoundary from '@/components/elements/ErrorBoundary'; import ErrorBoundary from '@/components/elements/ErrorBoundary';
import { Editor } from '@/components/elements/editor'; import { Editor } from '@/components/elements/editor';
import modes from '@/modes';
import useFlash from '@/plugins/useFlash'; import useFlash from '@/plugins/useFlash';
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';
import { encodePathSegments, hashToPath } from '@/helpers'; import { encodePathSegments } from '@/helpers';
export default () => { export default () => {
const [error, setError] = useState(''); 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 [loading, setLoading] = useState(action === 'edit');
const [content, setContent] = useState(''); const [content, setContent] = useState('');
const [modalVisible, setModalVisible] = useState(false); const [modalVisible, setModalVisible] = useState(false);
const [mode, setMode] = useState('text/plain');
const [language, setLanguage] = useState<LanguageDescription>(); const [language, setLanguage] = useState<LanguageDescription>();
const { hash } = useLocation(); const [filename, setFilename] = useState<string>('');
useEffect(() => {
setFilename(decodeURIComponent(rawFilename ?? ''));
}, [rawFilename]);
const navigate = useNavigate(); const navigate = useNavigate();
const id = ServerContext.useStoreState(state => state.server.data!.id); const id = ServerContext.useStoreState(state => state.server.data!.id);
@ -43,20 +47,25 @@ export default () => {
let fetchFileContent: null | (() => Promise<string>) = null; let fetchFileContent: null | (() => Promise<string>) = null;
useEffect(() => { useEffect(() => {
if (action === 'new') return; if (action === 'new') {
return;
}
if (filename === '') {
return;
}
setError(''); setError('');
setLoading(true); setLoading(true);
const path = hashToPath(hash); setDirectory(dirname(filename));
setDirectory(dirname(path)); getFileContents(uuid, filename)
getFileContents(uuid, path)
.then(setContent) .then(setContent)
.catch(error => { .catch(error => {
console.error(error); console.error(error);
setError(httpErrorToHuman(error)); setError(httpErrorToHuman(error));
}) })
.then(() => setLoading(false)); .then(() => setLoading(false));
}, [action, uuid, hash]); }, [action, uuid, filename]);
const save = (name?: string) => { const save = (name?: string) => {
if (!fetchFileContent) { if (!fetchFileContent) {
@ -66,10 +75,10 @@ export default () => {
setLoading(true); setLoading(true);
clearFlashes('files:view'); clearFlashes('files:view');
fetchFileContent() fetchFileContent()
.then(content => saveFileContents(uuid, name || hashToPath(hash), content)) .then(content => saveFileContents(uuid, name ?? filename, content))
.then(() => { .then(() => {
if (name) { if (name) {
navigate(`/server/${id}/files/edit#/${encodePathSegments(name)}`); navigate(`/server/${id}/files/edit/${encodePathSegments(name)}`);
return; return;
} }
@ -97,7 +106,7 @@ export default () => {
</div> </div>
</ErrorBoundary> </ErrorBoundary>
{hash.replace(/^#/, '').endsWith('.pteroignore') && ( {filename === '.pteroignore' ? (
<div css={tw`mb-4 p-4 border-l-4 bg-neutral-900 rounded border-cyan-400`}> <div css={tw`mb-4 p-4 border-l-4 bg-neutral-900 rounded border-cyan-400`}>
<p css={tw`text-neutral-300 text-sm`}> <p css={tw`text-neutral-300 text-sm`}>
You&apos;re editing a <code css={tw`font-mono bg-black rounded py-px px-1`}>.pteroignore</code>{' '} You&apos;re editing a <code css={tw`font-mono bg-black rounded py-px px-1`}>.pteroignore</code>{' '}
@ -107,7 +116,7 @@ export default () => {
<code css={tw`font-mono bg-black rounded py-px px-1`}>!</code>). <code css={tw`font-mono bg-black rounded py-px px-1`}>!</code>).
</p> </p>
</div> </div>
)} ) : null}
<FileNameModal <FileNameModal
visible={modalVisible} visible={modalVisible}
@ -121,10 +130,12 @@ export default () => {
<div css={tw`relative`}> <div css={tw`relative`}>
<SpinnerOverlay visible={loading} /> <SpinnerOverlay visible={loading} />
<Editor <Editor
filename={hash.replace(/^#/, '')} filename={filename}
initialContent={content} initialContent={content}
language={language} language={language}
onLanguageChanged={setLanguage} onLanguageChanged={l => {
setLanguage(l);
}}
fetchContent={value => { fetchContent={value => {
fetchFileContent = value; fetchFileContent = value;
}} }}
@ -140,10 +151,15 @@ export default () => {
<div css={tw`flex justify-end mt-4`}> <div css={tw`flex justify-end mt-4`}>
<div css={tw`flex-1 sm:flex-none rounded bg-neutral-900 mr-4`}> <div css={tw`flex-1 sm:flex-none rounded bg-neutral-900 mr-4`}>
<Select value={mode} onChange={e => setMode(e.currentTarget.value)}> <Select
{modes.map(mode => ( value={language?.name ?? ''}
<option key={`${mode.name}_${mode.mime}`} value={mode.mime}> onChange={e => {
{mode.name} setLanguage(languages.find(l => l.name === e.target.value));
}}
>
{languages.map(language => (
<option key={language.name} value={language.name}>
{language.name}
</option> </option>
))} ))}
</Select> </Select>

View file

@ -1,9 +1,10 @@
import { Fragment, useEffect, useState } from 'react'; import { Fragment, useEffect, useState } from 'react';
import { ServerContext } from '@/state/server'; import { NavLink, useParams } from 'react-router-dom';
import { NavLink, useLocation } from 'react-router-dom';
import { encodePathSegments, hashToPath } from '@/helpers';
import tw from 'twin.macro'; import tw from 'twin.macro';
import { encodePathSegments } from '@/helpers';
import { ServerContext } from '@/state/server';
interface Props { interface Props {
renderLeft?: JSX.Element; renderLeft?: JSX.Element;
withinFileEditor?: boolean; withinFileEditor?: boolean;
@ -11,22 +12,29 @@ interface Props {
} }
export default ({ renderLeft, withinFileEditor, isNewFile }: Props) => { export default ({ renderLeft, withinFileEditor, isNewFile }: Props) => {
const [file, setFile] = useState<string | null>(null);
const id = ServerContext.useStoreState(state => state.server.data!.id); const id = ServerContext.useStoreState(state => state.server.data!.id);
const directory = ServerContext.useStoreState(state => state.files.directory); const directory = ServerContext.useStoreState(state => state.files.directory);
const { hash } = useLocation();
const params = useParams<'*'>();
const [file, setFile] = useState<string>();
useEffect(() => { useEffect(() => {
const path = hashToPath(hash); if (!withinFileEditor || isNewFile) {
return;
if (withinFileEditor && !isNewFile) {
const name = path.split('/').pop() || null;
setFile(name);
} }
}, [withinFileEditor, isNewFile, hash]);
const breadcrumbs = (): { name: string; path?: string }[] => if (withinFileEditor && params['*'] !== undefined && !isNewFile) {
directory setFile(decodeURIComponent(params['*']).split('/').pop());
}
}, [withinFileEditor, isNewFile]);
const breadcrumbs = (): { name: string; path?: string }[] => {
if (directory === '.') {
return [];
}
return directory
.split('/') .split('/')
.filter(directory => !!directory) .filter(directory => !!directory)
.map((directory, index, dirs) => { .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 { name: directory, path: `/${dirs.slice(0, index + 1).join('/')}` };
}); });
};
return ( return (
<div css={tw`flex flex-grow-0 items-center text-sm text-neutral-500 overflow-x-hidden`}> <div css={tw`flex flex-grow-0 items-center text-sm text-neutral-500 overflow-x-hidden`}>
@ -50,6 +59,7 @@ export default ({ renderLeft, withinFileEditor, isNewFile }: Props) => {
<NavLink <NavLink
to={`/server/${id}/files#${encodePathSegments(crumb.path)}`} to={`/server/${id}/files#${encodePathSegments(crumb.path)}`}
css={tw`px-1 text-neutral-200 no-underline hover:text-neutral-100`} css={tw`px-1 text-neutral-200 no-underline hover:text-neutral-100`}
end
> >
{crumb.name} {crumb.name}
</NavLink> </NavLink>

View file

@ -1,11 +1,14 @@
import Modal, { RequiredModalProps } from '@/components/elements/Modal'; import type { FormikHelpers } from 'formik';
import { Form, Formik, FormikHelpers } from 'formik'; import { Form, Formik } from 'formik';
import { object, string } from 'yup';
import Field from '@/components/elements/Field';
import { ServerContext } from '@/state/server';
import { join } from 'pathe'; import { join } from 'pathe';
import tw from 'twin.macro'; 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 & { type Props = RequiredModalProps & {
onFileNamed: (name: string) => void; onFileNamed: (name: string) => void;
@ -19,7 +22,7 @@ export default ({ onFileNamed, onDismissed, ...props }: Props) => {
const directory = ServerContext.useStoreState(state => state.files.directory); const directory = ServerContext.useStoreState(state => state.files.directory);
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => { const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
onFileNamed(join(directory, values.fileName)); onFileNamed(join(directory, values.fileName).replace(/^\//, ''));
setSubmitting(false); setSubmitting(false);
}; };

View file

@ -8,10 +8,10 @@ import { NavLink } from 'react-router-dom';
import tw from 'twin.macro'; import tw from 'twin.macro';
import { join } from 'pathe'; import { join } from 'pathe';
import { encodePathSegments } from '@/helpers'; import type { FileObject } from '@/api/server/files/loadDirectory';
import { FileObject } from '@/api/server/files/loadDirectory';
import FileDropdownMenu from '@/components/server/files/FileDropdownMenu'; import FileDropdownMenu from '@/components/server/files/FileDropdownMenu';
import SelectFileCheckbox from '@/components/server/files/SelectFileCheckbox'; import SelectFileCheckbox from '@/components/server/files/SelectFileCheckbox';
import { encodePathSegments } from '@/helpers';
import { bytesToString } from '@/lib/formatters'; import { bytesToString } from '@/lib/formatters';
import { usePermissions } from '@/plugins/usePermissions'; import { usePermissions } from '@/plugins/usePermissions';
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';
@ -27,7 +27,7 @@ function Clickable({ file, children }: { file: FileObject; children: ReactNode }
) : ( ) : (
<NavLink <NavLink
className={styles.details} className={styles.details}
to={`/server/${id}/files${file.isFile ? '/edit' : ''}#${encodePathSegments(join(directory, file.name))}`} to={`/server/${id}/files${file.isFile ? '/edit' : '#'}${encodePathSegments(join(directory, file.name))}`}
> >
{children} {children}
</NavLink> </NavLink>

View file

@ -45,5 +45,5 @@ export function encodePathSegments(path: string): string {
} }
export function hashToPath(hash: string): string { export function hashToPath(hash: string): string {
return hash.length > 0 ? decodeURIComponent(hash.substr(1)) : '/'; return hash.length > 0 ? decodeURIComponent(hash.substring(1)) : '/';
} }

View file

@ -97,13 +97,7 @@ export default {
component: FileManagerContainer, component: FileManagerContainer,
}, },
{ {
route: 'files/edit/*', route: 'files/:action/*',
permission: 'file.*',
name: undefined,
component: FileEditContainer,
},
{
route: 'files/new/*',
permission: 'file.*', permission: 'file.*',
name: undefined, name: undefined,
component: FileEditContainer, component: FileEditContainer,