strimertul/frontend/src/ui/pages/Extensions.tsx

667 lines
20 KiB
TypeScript

import Editor, { Monaco, useMonaco } from '@monaco-editor/react';
import {
ExclamationTriangleIcon,
InfoCircledIcon,
InputIcon,
PilcrowIcon,
PlusIcon,
} from '@radix-ui/react-icons';
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { blankTemplate } from '~/lib/extensions/extension';
import { kilovoltDefinition } from '~/lib/extensions/helpers';
import { parseExtensionMetadata } from '~/lib/extensions/metadata';
import { ExtensionStatus } from '~/lib/extensions/types';
import slug from '~/lib/slug';
import * as HoverCard from '@radix-ui/react-hover-card';
import { useAppDispatch, useAppSelector } from '~/store';
import extensionsReducer, {
currentFile,
ExtensionEntry,
isUnsaved,
removeExtension,
renameExtension,
saveCurrentExtension,
saveExtension,
startExtension,
stopExtension,
} from '~/store/extensions/reducer';
import { useModule } from '~/lib/react';
import { modules } from '~/store/api/reducer';
import AlertContent from '../components/AlertContent';
import DialogContent from '../components/DialogContent';
import Loading from '../components/Loading';
import {
Button,
ComboBox,
ControlledInputBox,
Dialog,
DialogActions,
Field,
FlexRow,
getTheme,
InputBox,
Label,
MultiButton,
PageContainer,
PageHeader,
PageTitle,
styled,
TabButton,
TabContainer,
TabContent,
TabList,
} from '../theme';
import { Alert, AlertTrigger } from '../theme/alert';
const ExtensionRow = styled('article', {
marginBottom: '0.4rem',
backgroundColor: '$gray2',
margin: '0.5rem 0',
padding: '0.3rem 0.5rem',
borderLeft: '5px solid $teal8',
borderRadius: '0.25rem',
borderBottom: '1px solid $gray4',
transition: 'all 50ms',
'&:hover': {
backgroundColor: '$gray3',
},
variants: {
status: {
enabled: {},
disabled: {
borderLeftColor: '$red6',
backgroundColor: '$gray3',
color: '$gray10',
},
},
},
});
const ExtensionName = styled('div', {
flex: '1',
display: 'flex',
gap: '0.5rem',
alignItems: 'baseline',
});
const ExtensionStatusNote = styled('div', {
textTransform: 'uppercase',
fontSize: '10pt',
color: '$gray10',
variants: {
color: {
active: {
color: '$teal11',
},
error: {
color: '$red9',
},
},
},
});
const ExtensionActions = styled('div', {
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
});
const ExtensionInfoCard = styled(HoverCard.Content, {
borderRadius: 6,
display: 'flex',
padding: '0.5rem',
width: '300px',
gap: '0.5rem',
flexDirection: 'column',
border: '2px solid $gray6',
backgroundColor: '$gray2',
alignItems: 'flex-start',
boxShadow: '0px 5px 20px rgba(0,0,0,0.4)',
animationDuration: '400ms',
animationTimingFunction: 'cubic-bezier(0.16, 1, 0.3, 1)',
willChange: 'transform, opacity',
});
const isRunning = (status: ExtensionStatus) =>
status === ExtensionStatus.Running || status === ExtensionStatus.Finished;
const colorByStatus = (status: ExtensionStatus) => {
if (isRunning(status)) {
return 'active';
}
if (status === ExtensionStatus.Error) {
return 'error';
}
return null;
};
type ExtensionListItemProps = {
enabled: boolean;
entry: ExtensionEntry;
status: ExtensionStatus;
error?: Error | ErrorEvent;
onEdit: () => void;
onRemove: () => void;
onToggleEnable: () => void;
onToggleStatus: () => void;
};
function ExtensionListItem(props: ExtensionListItemProps) {
const { t } = useTranslation();
const metadata = parseExtensionMetadata(props.entry.source);
const version = useAppSelector((state) => state.server.version?.release);
const isDev = version && version.startsWith('v0.0.0');
const showIncompatibleWarning =
!isDev && version && version < `v${metadata.apiversion}`;
return (
<ExtensionRow
status={props.enabled && isRunning(props.status) ? 'enabled' : 'disabled'}
>
<FlexRow>
<ExtensionName>
{metadata ? (
<HoverCard.Root>
<HoverCard.Trigger asChild>
<FlexRow css={{ gap: '0.3rem' }}>
{props.entry.name}
<InfoCircledIcon />
</FlexRow>
</HoverCard.Trigger>
<HoverCard.Portal>
<ExtensionInfoCard sideOffset={5}>
<div>
<b>{metadata.name || 'Unnamed extension'}</b>{' '}
{metadata.version ? `v${metadata.version}` : null}
{metadata.author ? ` by ${metadata.author}` : null}
</div>
{metadata.description ? (
<small>{metadata.description}</small>
) : null}
</ExtensionInfoCard>
</HoverCard.Portal>
</HoverCard.Root>
) : (
props.entry.name
)}
{props.enabled ? (
<>
<ExtensionStatusNote color={colorByStatus(props.status)}>
{t(`pages.extensions.statuses.${props.status}`)}
</ExtensionStatusNote>
{showIncompatibleWarning ? (
<Alert>
<AlertTrigger asChild>
<Button variation="warning" size="small">
<ExclamationTriangleIcon />
</Button>
</AlertTrigger>
<AlertContent
title={t('pages.extensions.incompatible-warning')}
showCancel={false}
>
{t('pages.extensions.incompatible-body', {
version: metadata.apiversion,
appversion: version,
})}
</AlertContent>
</Alert>
) : null}
{props.error ? (
<Alert>
<AlertTrigger asChild>
<Button variation="danger" size="small">
<ExclamationTriangleIcon />
</Button>
</AlertTrigger>
<AlertContent
variation="danger"
title={t('pages.extensions.error-alert', {
name: props.entry.name,
})}
actionButtonProps={{
variation: 'danger',
}}
showCancel={false}
>
<code>{props.error.message}</code>
</AlertContent>
</Alert>
) : null}
</>
) : null}
</ExtensionName>
<ExtensionActions>
<MultiButton>
<Button
styling="multi"
size="small"
onClick={() => props.onToggleEnable()}
>
{props.entry.options.enabled
? t('form-actions.disable')
: t('form-actions.enable')}
</Button>
{props.enabled ? (
<>
<Button
styling="multi"
size="small"
onClick={() => props.onToggleStatus()}
>
{isRunning(props.status)
? t('form-actions.stop')
: t('form-actions.start')}
</Button>
</>
) : null}
<Button styling="multi" size="small" onClick={() => props.onEdit()}>
{t('form-actions.edit')}
</Button>
<Alert>
<AlertTrigger asChild>
<Button styling="multi" size="small">
{t('form-actions.delete')}
</Button>
</AlertTrigger>
<AlertContent
variation="danger"
title={t('pages.extensions.remove-alert', {
name: props.entry.name,
})}
description={t('form-actions.warning-delete')}
actionText={t('form-actions.delete')}
actionButtonProps={{ variation: 'danger' }}
showCancel={true}
onAction={() => props.onRemove()}
/>
</Alert>
</MultiButton>
</ExtensionActions>
</FlexRow>
</ExtensionRow>
);
}
interface ExtensionListProps {
onNew: () => void;
onEdit: (name: string) => void;
}
function ExtensionList({ onNew, onEdit }: ExtensionListProps) {
const extensions = useAppSelector((state) => state.extensions);
const dispatch = useAppDispatch();
const { t } = useTranslation();
const [filter, setFilter] = useState('');
const filterLC = filter.toLowerCase();
return (
<PageContainer spacing="narrow">
<PageHeader>
<PageTitle>{t('pages.extensions.title')}</PageTitle>
</PageHeader>
<Field size="fullWidth" spacing="none">
<FlexRow css={{ flex: 1, alignItems: 'stretch' }} spacing="1">
<Button variation="primary" onClick={() => onNew()}>
<PlusIcon /> {t('pages.extensions.create')}
</Button>
<InputBox
css={{ flex: 1 }}
placeholder={t('pages.extensions.search')}
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</FlexRow>
</Field>
{Object.values(extensions.installed)
?.filter((r) => r.name.toLowerCase().includes(filterLC))
.map((e) => (
<ExtensionListItem
key={e.name}
entry={e}
enabled={e.options.enabled}
status={extensions.status[e.name]}
error={extensions.running[e.name]?.error}
onEdit={() => onEdit(e.name)}
onRemove={() => {
// Toggle enabled status
void dispatch(removeExtension(e.name));
}}
onToggleEnable={() => {
// Toggle enabled status
void dispatch(
saveExtension({
...e,
options: {
...e.options,
enabled: !e.options.enabled,
},
}),
);
}}
onToggleStatus={() => {
if (isRunning(extensions.status[e.name])) {
void dispatch(stopExtension(e.name));
} else {
void dispatch(startExtension(e.name));
}
}}
/>
))}
</PageContainer>
);
}
const EditorButton = styled(Button, {
borderRadius: '0',
border: 'none',
'&:disabled': {
border: '0',
backgroundColor: '$gray5',
color: '$gray9',
cursor: 'not-allowed',
},
});
const EditorDropdown = styled(ComboBox, {
borderRadius: '0',
border: 'none',
padding: '0.3rem 0.5rem',
fontSize: '0.9rem',
});
const setupLibrary = (monaco: Monaco, source: string, url: string) => {
// Prevent model from being added twice
const models = monaco.editor.getModels();
if (models.some((lt) => lt.uri.toString() === url)) {
return;
}
monaco.languages.typescript.typescriptDefaults.addExtraLib(source, url);
monaco.editor.createModel(source, 'typescript', monaco.Uri.parse(url));
};
function ExtensionEditor() {
const [dialogRename, setDialogRename] = useState({ open: false, name: '' });
const extensions = useAppSelector((state) => state.extensions);
const { t } = useTranslation();
const dispatch = useAppDispatch();
const monaco = useMonaco();
const editor = useRef(null);
useEffect(() => {
if (monaco) {
monaco.languages.typescript.typescriptDefaults.setEagerModelSync(true);
monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
noSemanticValidation: false,
noSyntaxValidation: false,
diagnosticCodesToIgnore: [1375, 2792],
});
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
target: monaco.languages.typescript.ScriptTarget.ES2020,
module: monaco.languages.typescript.ModuleKind.ESNext,
allowNonTsExtensions: true,
});
setupLibrary(monaco, kilovoltDefinition, 'ts:index.d.ts');
}
}, [monaco]);
// Normally you can't navigate here without this being set but there is an instant
// where you can and it messes up the dropdown, so don't render anything for that
// split second
if (!extensions.editorCurrentFile) {
return <></>;
}
const isModified = isUnsaved(extensions);
const currentFileSource = currentFile(extensions);
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
height: 'calc(100vh - 40px)',
}}
>
<FlexRow
css={{ alignItems: 'stretch', borderBottom: '1px solid $gray5' }}
align="left"
>
<EditorDropdown
value={extensions.editorCurrentFile}
onChange={(ev) => {
void dispatch(
extensionsReducer.actions.editorSelectedFile(ev.target.value),
);
}}
css={{ flex: '1' }}
>
{Object.values(extensions.installed)
.filter((ext) => !(ext.name in extensions.unsaved)) // Hide those with changes
.map((ext) => (
<option key={ext.name} value={ext.name}>
{ext.name}
</option>
))}
{Object.keys(extensions.unsaved).map((ext) => (
<option key={ext} value={ext}>
{ext}
{isModified ? '*' : ''}
</option>
))}
</EditorDropdown>
<EditorButton
size="small"
title={t('pages.extensions.rename')}
onClick={() =>
setDialogRename({ open: true, name: extensions.editorCurrentFile })
}
>
<InputIcon />
</EditorButton>
<EditorButton
size="small"
title={t('pages.extensions.format')}
onClick={() => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
editor.current.getAction('editor.action.formatDocument').run();
}}
>
<PilcrowIcon />
</EditorButton>
<EditorButton
size="small"
disabled={!isModified}
onClick={() => {
void dispatch(saveCurrentExtension());
}}
>
{t('form-actions.save')}
</EditorButton>
</FlexRow>
<Editor
language="typescript"
theme="vs-dark"
options={{
autoIndent: 'full',
formatOnPaste: true,
formatOnType: true,
wordWrap: 'on',
automaticLayout: true,
}}
value={currentFileSource}
onChange={(ev) => {
void dispatch(extensionsReducer.actions.extensionSourceChanged(ev));
}}
onMount={(instance, m) => {
editor.current = instance;
instance.addCommand(
// eslint-disable-next-line no-bitwise
m.KeyMod.CtrlCmd | m.KeyCode.KeyS,
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
() => {
void dispatch(saveCurrentExtension());
},
);
}}
/>
<Dialog
open={dialogRename.open}
onOpenChange={(state) =>
setDialogRename({ ...dialogRename, open: state })
}
>
<DialogContent
title={t('pages.extensions.rename-dialog', {
name: extensions.editorCurrentFile,
})}
closeButton={true}
>
<form
onSubmit={(e) => {
e.preventDefault();
if (!(e.target as HTMLFormElement).checkValidity()) {
return;
}
// Only rename if it changed
if (extensions.editorCurrentFile !== dialogRename.name) {
void dispatch(
renameExtension({
from: extensions.editorCurrentFile,
to: dialogRename.name,
}),
);
}
setDialogRename({ ...dialogRename, open: false });
}}
>
<Field size="fullWidth" spacing="narrow">
<Label htmlFor="renamed">
{t('pages.extensions.rename-new-name')}
</Label>
<ControlledInputBox
id="renamed"
type="text"
required
value={dialogRename.name}
onChange={(e) => {
setDialogRename({
...dialogRename,
name: e.target.value,
});
if (
Object.values(extensions.installed).find(
(r) => r.name === e.target.value,
)
) {
(e.target as HTMLInputElement).setCustomValidity(
t('pages.extensions.name-already-in-use'),
);
} else {
(e.target as HTMLInputElement).setCustomValidity('');
}
}}
/>
</Field>
<DialogActions>
<Button variation="primary" type="submit">
{t('form-actions.rename')}
</Button>
<Button
type="button"
onClick={() =>
setDialogRename({ ...dialogRename, open: false })
}
>
{t('form-actions.cancel')}
</Button>
</DialogActions>
</form>
</DialogContent>
</Dialog>
</div>
);
}
export default function ExtensionsPage(): React.ReactElement {
const [uiConfig] = useModule(modules.uiConfig);
const { t } = useTranslation();
const extensions = useAppSelector((state) => state.extensions);
const dispatch = useAppDispatch();
const [currentTab, setCurrentTab] = useState('list');
const newClicked = () => {
// Create new empty file
let defaultName = '';
do {
defaultName = slug();
} while (
defaultName in extensions.installed ||
defaultName in extensions.unsaved
);
// Add as draft
dispatch(
extensionsReducer.actions.extensionDrafted({
name: defaultName,
source: blankTemplate(defaultName),
options: { enabled: false },
}),
);
// Set it as current file in editor
dispatch(extensionsReducer.actions.editorSelectedFile(defaultName));
setCurrentTab('editor');
};
const editClicked = (name: string) => {
// Set it as current file in editor
dispatch(extensionsReducer.actions.editorSelectedFile(name));
setCurrentTab('editor');
};
if (!extensions.ready) {
const theme = getTheme(
uiConfig?.theme ?? localStorage.getItem('theme') ?? 'dark',
);
return (
<PageContainer>
<PageHeader>
<PageTitle>{t('pages.extensions.title')}</PageTitle>
</PageHeader>
<Loading
theme={theme}
size="fill"
message={t('pages.extensions.loading')}
/>
</PageContainer>
);
}
return (
<TabContainer
value={currentTab}
onValueChange={(newval) => setCurrentTab(newval)}
>
<TabList>
<TabButton value="list">{t('pages.extensions.tab-manage')}</TabButton>
<TabButton value="editor" disabled={!extensions.editorCurrentFile}>
{t('pages.extensions.tab-editor')}
</TabButton>
</TabList>
<TabContent css={{ paddingTop: '1rem' }} value="list">
<ExtensionList
onNew={() => newClicked()}
onEdit={(name) => editClicked(name)}
/>
</TabContent>
<TabContent css={{ paddingTop: '0' }} value="editor">
<ExtensionEditor />
</TabContent>
</TabContainer>
);
}