ui(server): fix file editor and file manager
This commit is contained in:
parent
27e3eec5fc
commit
42d8f2cb82
7 changed files with 106 additions and 74 deletions
|
@ -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<LanguageSupport>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
|
@ -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<LanguageDescription>();
|
||||
|
||||
const { hash } = useLocation();
|
||||
const [filename, setFilename] = useState<string>('');
|
||||
|
||||
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<string>) = 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 () => {
|
|||
</div>
|
||||
</ErrorBoundary>
|
||||
|
||||
{hash.replace(/^#/, '').endsWith('.pteroignore') && (
|
||||
{filename === '.pteroignore' ? (
|
||||
<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`}>
|
||||
You'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>).
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<FileNameModal
|
||||
visible={modalVisible}
|
||||
|
@ -121,10 +130,12 @@ export default () => {
|
|||
<div css={tw`relative`}>
|
||||
<SpinnerOverlay visible={loading} />
|
||||
<Editor
|
||||
filename={hash.replace(/^#/, '')}
|
||||
filename={filename}
|
||||
initialContent={content}
|
||||
language={language}
|
||||
onLanguageChanged={setLanguage}
|
||||
onLanguageChanged={l => {
|
||||
setLanguage(l);
|
||||
}}
|
||||
fetchContent={value => {
|
||||
fetchFileContent = value;
|
||||
}}
|
||||
|
@ -140,10 +151,15 @@ export default () => {
|
|||
|
||||
<div css={tw`flex justify-end mt-4`}>
|
||||
<div css={tw`flex-1 sm:flex-none rounded bg-neutral-900 mr-4`}>
|
||||
<Select value={mode} onChange={e => setMode(e.currentTarget.value)}>
|
||||
{modes.map(mode => (
|
||||
<option key={`${mode.name}_${mode.mime}`} value={mode.mime}>
|
||||
{mode.name}
|
||||
<Select
|
||||
value={language?.name ?? ''}
|
||||
onChange={e => {
|
||||
setLanguage(languages.find(l => l.name === e.target.value));
|
||||
}}
|
||||
>
|
||||
{languages.map(language => (
|
||||
<option key={language.name} value={language.name}>
|
||||
{language.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
|
|
@ -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<string | null>(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<string>();
|
||||
|
||||
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 (
|
||||
<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
|
||||
to={`/server/${id}/files#${encodePathSegments(crumb.path)}`}
|
||||
css={tw`px-1 text-neutral-200 no-underline hover:text-neutral-100`}
|
||||
end
|
||||
>
|
||||
{crumb.name}
|
||||
</NavLink>
|
||||
|
|
|
@ -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<Values>) => {
|
||||
onFileNamed(join(directory, values.fileName));
|
||||
onFileNamed(join(directory, values.fileName).replace(/^\//, ''));
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
|
|
|
@ -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 }
|
|||
) : (
|
||||
<NavLink
|
||||
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}
|
||||
</NavLink>
|
||||
|
|
|
@ -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)) : '/';
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in a new issue