feat: Extension UI, wip

This commit is contained in:
Ash Keel 2023-01-31 02:20:09 +01:00
parent c20728b268
commit b3dea0fe53
No known key found for this signature in database
GPG Key ID: BAD8D93E7314ED3E
8 changed files with 312 additions and 130 deletions

View File

@ -6,6 +6,14 @@ import {
ExtensionHostCommand,
} from './types';
export const blankTemplate = (slug: string) => `// ==Extension==
// @name ${slug}
// @version 1.0
// @author Put your name here!
// @description A new extension for strimertul
// ==/Extension==
`;
export class Extension extends EventTarget {
private readonly worker: Worker;
@ -16,7 +24,7 @@ export class Extension extends EventTarget {
constructor(
public readonly name: string,
public readonly source: string,
options: ExtensionOptions,
public readonly options: ExtensionOptions,
dependencies: ExtensionDependencies,
) {
super();
@ -92,6 +100,10 @@ export class Extension extends EventTarget {
new CustomEvent('statusChanged', { detail: this.workerStatus }),
);
}
dispose() {
this.stop();
}
}
export default { Extension };

16
frontend/src/lib/slug.ts Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "esnext",
"module": "es2022",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"jsx": "react-jsx",
"lib": ["es2019", "WebWorker"],
"resolveJsonModule": true,
"baseUrl": ".",
"paths": {
"@wailsapp/*": ["./wailsjs/*"],
"~/*": ["./src/*"]
}
}
}

View File

@ -312,7 +312,12 @@
"partial-translation": "Partial translation"
},
"extensions": {
"title": "Extensions"
"title": "Extensions",
"loading": "Just one second, the extension subsystem is still getting ready!",
"create": "Create new",
"search": "Search by name",
"tab-manage": "Manage",
"tab-editor": "Editor"
}
},
"form-actions": {

View File

@ -11,6 +11,8 @@ import { HTTPConfig } from '../api/types';
interface ExtensionsState {
ready: boolean;
installed: Record<string, Extension>;
unsaved: Record<string, string>;
editorCurrentFile: string;
dependencies: ExtensionDependencies;
}
@ -23,6 +25,8 @@ export interface ExtensionEntry {
const initialState: ExtensionsState = {
ready: false,
installed: {},
unsaved: {},
editorCurrentFile: null,
dependencies: {
kilovolt: { address: '' },
},
@ -36,7 +40,37 @@ const extensionsReducer = createSlice({
state.dependencies = payload;
state.ready = true;
},
editorSelectedFile(state, { payload }: PayloadAction<string>) {
state.editorCurrentFile = payload;
},
extensionDrafted(state, { payload }: PayloadAction<ExtensionEntry>) {
state.unsaved[payload.name] = payload.source;
// If we don't have a file selected in the editor, set a default as soon as possible
if (!state.editorCurrentFile) {
state.editorCurrentFile = payload.name;
}
},
extensionSourceChanged(state, { payload }: PayloadAction<string>) {
state.unsaved[state.editorCurrentFile] = payload;
},
extensionAdded(state, { payload }: PayloadAction<ExtensionEntry>) {
// Remove from unsaved
if (payload.name in state.unsaved) {
delete state.unsaved[payload.name];
}
// If running, terminate running instance
if (payload.name in state.installed) {
state.installed[payload.name]?.dispose();
}
// If we don't have a file selected in the editor, set a default as soon as possible
if (!state.editorCurrentFile) {
state.editorCurrentFile = payload.name;
}
// Create new instance with stored code
state.installed[payload.name] = new Extension(
payload.name,
payload.source,
@ -49,6 +83,8 @@ const extensionsReducer = createSlice({
},
});
const extensionPrefix = 'ui/extensions/installed/';
export const initializeExtensions = createAsyncThunk(
'extensions/initialize',
async (_: void, { getState, dispatch }) => {
@ -67,6 +103,36 @@ export const initializeExtensions = createAsyncThunk(
},
}),
);
// Become reactive to extension changes
await api.client.subscribePrefix(extensionPrefix, (newValue, newKey) => {
dispatch(
extensionsReducer.actions.extensionAdded({
...(JSON.parse(newValue) as ExtensionEntry),
name: newKey.substring(extensionPrefix.length),
}),
);
});
// Get installed extensions
const extensions = await api.client.getKeysByPrefix(extensionPrefix);
Object.entries(extensions).forEach(([extName, extContent]) =>
dispatch(
extensionsReducer.actions.extensionAdded({
...(JSON.parse(extContent) as ExtensionEntry),
name: extName.substring(extensionPrefix.length),
}),
),
);
},
);
export const saveExtension = createAsyncThunk(
'extensions/save',
async (entry: ExtensionEntry, { getState }) => {
// Get kv client
const { api } = getState() as RootState;
await api.client.putJSON(extensionPrefix + entry.name, entry);
},
);

View File

@ -1,35 +1,182 @@
import Editor from '@monaco-editor/react';
import { PlusIcon } from '@radix-ui/react-icons';
import { InputIcon, PlusIcon } from '@radix-ui/react-icons';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { blankTemplate, Extension } from '~/lib/extensions/extension';
import slug from '~/lib/slug';
import { useAppDispatch, useAppSelector } from '~/store';
import extensionsReducer, { ExtensionEntry } from '~/store/extensions/reducer';
import DialogContent from '../components/DialogContent';
import extensionsReducer, { saveExtension } from '~/store/extensions/reducer';
import Loading from '../components/Loading';
import {
Button,
Dialog,
DialogActions,
ComboBox,
Field,
FlexRow,
InputBox,
Label,
PageContainer,
PageHeader,
PageTitle,
styled,
TabButton,
TabContainer,
TabContent,
TabList,
} from '../theme';
interface ExtensionListProps {
extensions: Record<string, Extension>;
onNewClicked: () => void;
}
function ExtensionList({ extensions, onNewClicked }: ExtensionListProps) {
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={() => onNewClicked()}>
<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)
?.filter((r) => r.name.toLowerCase().includes(filterLC))
.map((e) => (
<div key={e.name}>{e.name}</div>
))}
</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',
});
function ExtensionEditor() {
const extensions = useAppSelector((state) => state.extensions);
const dispatch = useAppDispatch();
const currentFile =
extensions.editorCurrentFile in extensions.unsaved
? extensions.unsaved[extensions.editorCurrentFile]
: extensions.installed[extensions.editorCurrentFile].source;
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}
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}*
</option>
))}
</EditorDropdown>
<EditorButton size="small" title="rename script">
<InputIcon />
</EditorButton>
<EditorButton
size="small"
disabled={!(extensions.editorCurrentFile in extensions.unsaved)}
onClick={() => {
void dispatch(
saveExtension({
name: extensions.editorCurrentFile,
source: currentFile,
options:
extensions.editorCurrentFile in extensions.installed
? extensions.installed[extensions.editorCurrentFile].options
: { autostart: false },
}),
);
}}
>
Save
</EditorButton>
</FlexRow>
<Editor
defaultLanguage="javascript"
theme="vs-dark"
options={{}}
value={currentFile}
onChange={(ev) => {
void dispatch(extensionsReducer.actions.extensionSourceChanged(ev));
}}
/>
</div>
);
}
export default function ExtensionsPage(): React.ReactElement {
const { t } = useTranslation();
const extensions = useAppSelector((state) => state.extensions);
const dispatch = useAppDispatch();
const [currentExtension, setCurrentExtension] = useState<{
open: boolean;
new: boolean;
entry: ExtensionEntry;
}>({ open: false, new: false, entry: null });
const [filter, setFilter] = useState('');
const filterLC = filter.toLowerCase();
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: { autostart: false },
}),
);
// Set it as current file in editor
dispatch(extensionsReducer.actions.editorSelectedFile(defaultName));
setCurrentTab('editor');
};
if (!extensions.ready) {
return (
@ -37,124 +184,31 @@ export default function ExtensionsPage(): React.ReactElement {
<PageHeader>
<PageTitle>{t('pages.extensions.title')}</PageTitle>
</PageHeader>
<Loading
size="fill"
message={'one second, the extension subsystem is still not ready'}
/>
<Loading size="fill" message={t('pages.extensions.loading')} />
</PageContainer>
);
}
return (
<PageContainer>
<PageHeader>
<PageTitle>{t('pages.extensions.title')}</PageTitle>
</PageHeader>
<div>
<Field size="fullWidth" spacing="none">
<FlexRow css={{ flex: 1, alignItems: 'stretch' }} spacing="1">
<Button
variation="primary"
onClick={() => {
setCurrentExtension({
open: true,
new: true,
entry: {
name: '',
source: '',
options: {
autostart: true,
},
},
});
}}
>
<PlusIcon /> new extension
</Button>
<InputBox
css={{ flex: 1 }}
placeholder="search by name"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</FlexRow>
</Field>
{Object.values(extensions.installed)
?.filter((r) => r.name.toLowerCase().includes(filterLC))
.map((e) => (
<div key={e.name}>{e.name}</div>
))}
</div>
<Dialog
open={currentExtension.open}
onOpenChange={(state) =>
setCurrentExtension({ ...currentExtension, open: state })
}
>
<DialogContent
title={currentExtension.new ? 'new extension' : 'edit extension'}
closeButton={true}
>
<form
onSubmit={(e) => {
e.preventDefault();
if ((e.target as HTMLFormElement).checkValidity()) {
dispatch(
extensionsReducer.actions.extensionAdded(
currentExtension.entry,
),
);
setCurrentExtension({ ...currentExtension, open: false });
}
}}
>
<Field size="fullWidth" spacing="narrow">
<Label htmlFor="d-name">name</Label>
<InputBox
disabled={!currentExtension.new}
id="d-name"
value={currentExtension.entry?.name ?? ''}
required={true}
onChange={(e) =>
setCurrentExtension({
...currentExtension,
entry: { ...currentExtension.entry, name: e.target.value },
})
}
/>
</Field>
<Field size="fullWidth" spacing="narrow">
<Label>source</Label>
<Editor
height="50vh"
defaultLanguage="javascript"
defaultValue="// some comment"
theme="vs-dark"
value={currentExtension.entry?.source}
onChange={(e) =>
setCurrentExtension({
...currentExtension,
entry: { ...currentExtension.entry, source: e },
})
}
/>
</Field>
<DialogActions>
<Button variation="primary" type="submit">
{t('form-actions.save')}
</Button>
<Button
onClick={() =>
setCurrentExtension({ ...currentExtension, open: false })
}
type="button"
>
{t('form-actions.cancel')}
</Button>
</DialogActions>
</form>
</DialogContent>
</Dialog>
</PageContainer>
<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
extensions={extensions.installed}
onNewClicked={() => newClicked()}
/>
</TabContent>
<TabContent css={{ paddingTop: '0' }} value="editor">
<ExtensionEditor />
</TabContent>
</TabContainer>
);
}

View File

@ -6,6 +6,14 @@ export const PageContainer = styled('div', {
maxWidth: '1000px',
width: '100%',
margin: '0 auto',
variants: {
spacing: {
narrow: {
padding: '0 2rem',
paddingTop: '0',
},
},
},
});
export const PageHeader = styled('header', {});

View File

@ -1,7 +1,9 @@
import * as Tabs from '@radix-ui/react-tabs';
import { styled } from './theme';
export const TabContainer = styled(Tabs.Root, {});
export const TabContainer = styled(Tabs.Root, {
width: '100%',
});
export const TabList = styled(Tabs.List, {
borderBottom: '1px solid $gray6',
@ -17,6 +19,9 @@ export const TabButton = styled(Tabs.Trigger, {
borderBottom: '2px solid $teal9',
},
marginBottom: '-1px',
'&:disabled': {
opacity: 0.3,
},
});
export const TabContent = styled(Tabs.Content, {