mirror of https://git.sr.ht/~ashkeel/strimertul
667 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|