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,
|
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;
|
||||||
|
|
|
@ -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're editing a <code css={tw`font-mono bg-black rounded py-px px-1`}>.pteroignore</code>{' '}
|
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>).
|
<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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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)) : '/';
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
Loading…
Reference in a new issue