mirror of https://git.sr.ht/~ashkeel/strimertul
feat: Extension UI, wip
This commit is contained in:
parent
c20728b268
commit
b3dea0fe53
|
@ -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 };
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -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/*"]
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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": {
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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', {});
|
||||
|
|
|
@ -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, {
|
||||
|
|
Loading…
Reference in New Issue