parent
e6e42e10fd
commit
7acc936c79
@ -1,2 +1,3 @@ |
||||
export * from './lib/remix-ui-terminal' |
||||
export * from './lib/remix-ui-terminal-wrapper' |
||||
export * from './lib/context' |
@ -1,100 +1,37 @@ |
||||
import { appPlatformTypes, platformContext } from '@remix-ui/app' |
||||
import { CustomTooltip } from '@remix-ui/helper' |
||||
import React, { useState, useEffect, useRef, useContext } from 'react' // eslint-disable-line
|
||||
import { FormattedMessage, useIntl } from 'react-intl' |
||||
import { listenOnNetworkAction } from '../actions/terminalAction' |
||||
import { TerminalContext } from '../context/context' |
||||
import { TerminalContext } from '../context' |
||||
import { RemixUiTerminalProps } from '../types/terminalTypes' |
||||
import { RemixUITerminalMenu } from './remix-ui-terminal-menu' |
||||
import { RemixUITerminalMenuToggle } from './remix-ui-terminal-menu-toggle' |
||||
import { RemixUIXtermMenu } from '../../../../xterm/src/lib/components/remix-ui-terminal-menu-xterm' |
||||
import { RemixUITerminalMenuButtons } from './remix-ui-terminal-menu-buttons' |
||||
|
||||
export const RemixUITerminalBar = (props: RemixUiTerminalProps) => { |
||||
const { newstate: state, dispatch } = useContext(TerminalContext) |
||||
const { terminalState, xtermState } = useContext(TerminalContext) |
||||
const platform = useContext(platformContext) |
||||
const intl = useIntl() |
||||
const terminalMenu = useRef(null) |
||||
|
||||
function handleToggleTerminal(event: any): void { |
||||
dispatch({ type: 'toggle' }) |
||||
} |
||||
|
||||
useEffect(() => { |
||||
props.plugin.call('layout', 'minimize', props.plugin.profile.name, !state.isOpen) |
||||
}, [state.isOpen]) |
||||
|
||||
useEffect(() => { |
||||
console.log('state change', state) |
||||
}, [state]) |
||||
|
||||
function handleClearConsole(event: any): void { |
||||
dispatch({ type: 'clearconsole', payload: [] }) |
||||
} |
||||
|
||||
function listenOnNetwork(event: any): void { |
||||
const isListening = event.target.checked |
||||
listenOnNetworkAction(props.plugin, isListening) |
||||
} |
||||
|
||||
function setSearchInput(arg0: string): void { |
||||
dispatch({ type: 'search', payload: arg0 }) |
||||
} |
||||
props.plugin.call('layout', 'minimize', props.plugin.profile.name, !terminalState.isOpen) |
||||
}, [terminalState.isOpen]) |
||||
|
||||
return (<> |
||||
<div className="remix_ui_terminal_bar d-flex"> |
||||
<div className="remix_ui_terminal_menu d-flex w-100 align-items-center position-relative border-top border-dark bg-light" ref={terminalMenu} data-id="terminalToggleMenu"> |
||||
<CustomTooltip |
||||
placement="top" |
||||
tooltipId="terminalToggle" |
||||
tooltipClasses="text-nowrap" |
||||
tooltipText={state.isOpen ? <FormattedMessage id="terminal.hideTerminal" /> : <FormattedMessage id="terminal.showTerminal" />} |
||||
> |
||||
<i |
||||
className={`mx-2 remix_ui_terminal_toggleTerminal fas ${state.isOpen ? 'fa-angle-double-down' : 'fa-angle-double-up'}`} |
||||
data-id="terminalToggleIcon" |
||||
onClick={handleToggleTerminal} |
||||
></i> |
||||
</CustomTooltip> |
||||
<div className="mx-2 remix_ui_terminal_console" id="clearConsole" data-id="terminalClearConsole" onClick={handleClearConsole}> |
||||
<CustomTooltip placement="top" tooltipId="terminalClear" tooltipClasses="text-nowrap" tooltipText={<FormattedMessage id="terminal.clearConsole" />}> |
||||
<i className="fas fa-ban" aria-hidden="true"></i> |
||||
</CustomTooltip> |
||||
</div> |
||||
<CustomTooltip placement="top" tooltipId="terminalClear" tooltipClasses="text-nowrap" tooltipText={<FormattedMessage id="terminal.pendingTransactions" />}> |
||||
<div className="mx-2">0</div> |
||||
</CustomTooltip> |
||||
<CustomTooltip |
||||
placement="top" |
||||
tooltipId="terminalClear" |
||||
tooltipClasses="text-nowrap" |
||||
tooltipText={intl.formatMessage({ id: state.isVM ? 'terminal.listenVM' : 'terminal.listenTitle' })} |
||||
> |
||||
<div className="h-80 mx-3 align-items-center remix_ui_terminal_listenOnNetwork custom-control custom-checkbox"> |
||||
<CustomTooltip placement="top" tooltipId="terminalClear" tooltipClasses="text-nowrap" tooltipText={intl.formatMessage({ id: 'terminal.listenTitle' })}> |
||||
<input |
||||
className="custom-control-input" |
||||
id="listenNetworkCheck" |
||||
onChange={listenOnNetwork} |
||||
type="checkbox" |
||||
disabled={state.isVM} |
||||
/> |
||||
</CustomTooltip> |
||||
<label |
||||
className="form-check-label custom-control-label text-nowrap" |
||||
style={{ paddingTop: '0.125rem' }} |
||||
htmlFor="listenNetworkCheck" |
||||
data-id="listenNetworkCheckInput" |
||||
> |
||||
<FormattedMessage id="terminal.listen" /> |
||||
</label> |
||||
</div> |
||||
</CustomTooltip> |
||||
<div className="remix_ui_terminal_search d-flex align-items-center h-100"> |
||||
<i className="remix_ui_terminal_searchIcon d-flex align-items-center justify-content-center fas fa-search bg-light" aria-hidden="true"></i> |
||||
<input |
||||
onChange={(event) => setSearchInput(event.target.value.trim())} |
||||
type="text" |
||||
className="remix_ui_terminal_filter border form-control" |
||||
id="searchInput" |
||||
placeholder={intl.formatMessage({ id: 'terminal.search' })} |
||||
data-id="terminalInputSearch" |
||||
/> |
||||
</div> |
||||
<RemixUITerminalMenuToggle {...props} /> |
||||
{platform === appPlatformTypes.desktop ? |
||||
<> |
||||
<RemixUITerminalMenuButtons {...props} /> |
||||
{xtermState.showOutput? <RemixUITerminalMenu {...props} />: <RemixUIXtermMenu {...props} />} |
||||
</> : |
||||
<RemixUITerminalMenu {...props} /> |
||||
} |
||||
|
||||
</div> |
||||
</div></>) |
||||
} |
@ -0,0 +1,25 @@ |
||||
import React, { useContext, useEffect } from 'react' // eslint-disable-line
|
||||
import { TerminalContext } from '../context' |
||||
import { RemixUiTerminalProps, SET_OPEN } from '../types/terminalTypes' |
||||
export const RemixUITerminalMenuButtons = (props: RemixUiTerminalProps) => { |
||||
const { xtermState, dispatchXterm, terminalState, dispatch } = useContext(TerminalContext) |
||||
|
||||
function selectOutput(event: any): void { |
||||
props.plugin.call('layout', 'minimize', props.plugin.profile.name, false) |
||||
dispatchXterm({ type: 'SHOW_OUTPUT', payload: true }) |
||||
dispatch({ type: SET_OPEN, payload: true }) |
||||
} |
||||
|
||||
function showTerminal(event: any): void { |
||||
props.plugin.call('layout', 'minimize', props.plugin.profile.name, false) |
||||
dispatchXterm({ type: 'SHOW_OUTPUT', payload: false }) |
||||
dispatch({ type: SET_OPEN, payload: true }) |
||||
} |
||||
|
||||
return ( |
||||
<div> |
||||
<button className={`btn btn-sm btn-secondary mr-2 ${!xtermState.showOutput ? 'xterm-btn-none' : 'xterm-btn-active'}`} onClick={selectOutput}>output</button> |
||||
<button className={`btn btn-sm btn-secondary ${xtermState.terminalsEnabled ? '' : 'd-none'} ${xtermState.showOutput ? 'xterm-btn-none' : 'xterm-btn-active'}`} onClick={showTerminal}><span className="far fa-terminal border-0 ml-1"></span></button> |
||||
</div> |
||||
) |
||||
} |
@ -0,0 +1,30 @@ |
||||
import { CustomTooltip } from '@remix-ui/helper' |
||||
import React, { useContext, useEffect } from 'react' // eslint-disable-line
|
||||
import { FormattedMessage } from 'react-intl' |
||||
import { TerminalContext } from '../context' |
||||
import { RemixUiTerminalProps, TOGGLE } from '../types/terminalTypes' |
||||
export const RemixUITerminalMenuToggle = (props: RemixUiTerminalProps) => { |
||||
|
||||
const { terminalState, dispatch } = useContext(TerminalContext) |
||||
|
||||
function handleToggleTerminal(event: any): void { |
||||
dispatch({ type: TOGGLE }) |
||||
} |
||||
|
||||
return ( |
||||
<> |
||||
<CustomTooltip |
||||
placement="top" |
||||
tooltipId="terminalToggle" |
||||
tooltipClasses="text-nowrap" |
||||
tooltipText={terminalState.isOpen ? <FormattedMessage id="terminal.hideTerminal" /> : <FormattedMessage id="terminal.showTerminal" />} |
||||
> |
||||
<i |
||||
className={`mx-2 remix_ui_terminal_toggleTerminal fas ${terminalState.isOpen ? 'fa-angle-double-down' : 'fa-angle-double-up'}`} |
||||
data-id="terminalToggleIcon" |
||||
onClick={handleToggleTerminal} |
||||
></i> |
||||
</CustomTooltip> |
||||
</> |
||||
) |
||||
} |
@ -0,0 +1,76 @@ |
||||
import { CustomTooltip } from '@remix-ui/helper' |
||||
import React, { useState, useEffect, useRef, useContext } from 'react' // eslint-disable-line
|
||||
import { FormattedMessage, useIntl } from 'react-intl' |
||||
import { listenOnNetworkAction } from '../actions/terminalAction' |
||||
import { TerminalContext } from '../context' |
||||
import { RemixUiTerminalProps } from '../types/terminalTypes' |
||||
|
||||
export const RemixUITerminalMenu = (props: RemixUiTerminalProps) => { |
||||
const { terminalState, dispatch } = useContext(TerminalContext) |
||||
const intl = useIntl() |
||||
|
||||
useEffect(() => { |
||||
props.plugin.call('layout', 'minimize', props.plugin.profile.name, !terminalState.isOpen) |
||||
}, [terminalState.isOpen]) |
||||
|
||||
function handleClearConsole(event: any): void { |
||||
dispatch({ type: 'clearconsole', payload: [] }) |
||||
} |
||||
|
||||
function listenOnNetwork(event: any): void { |
||||
const isListening = event.target.checked |
||||
listenOnNetworkAction(props.plugin, isListening) |
||||
} |
||||
|
||||
function setSearchInput(arg0: string): void { |
||||
dispatch({ type: 'search', payload: arg0 }) |
||||
} |
||||
|
||||
return (<> |
||||
<div className="mx-2 remix_ui_terminal_console" id="clearConsole" data-id="terminalClearConsole" onClick={handleClearConsole}> |
||||
<CustomTooltip placement="top" tooltipId="terminalClear" tooltipClasses="text-nowrap" tooltipText={<FormattedMessage id="terminal.clearConsole" />}> |
||||
<i className="fas fa-ban" aria-hidden="true"></i> |
||||
</CustomTooltip> |
||||
</div> |
||||
<CustomTooltip placement="top" tooltipId="terminalClear" tooltipClasses="text-nowrap" tooltipText={<FormattedMessage id="terminal.pendingTransactions" />}> |
||||
<div className="mx-2">0</div> |
||||
</CustomTooltip> |
||||
<CustomTooltip |
||||
placement="top" |
||||
tooltipId="terminalClear" |
||||
tooltipClasses="text-nowrap" |
||||
tooltipText={intl.formatMessage({ id: terminalState.isVM ? 'terminal.listenVM' : 'terminal.listenTitle' })} |
||||
> |
||||
<div className="h-80 mx-3 align-items-center remix_ui_terminal_listenOnNetwork custom-control custom-checkbox"> |
||||
<CustomTooltip placement="top" tooltipId="terminalClear" tooltipClasses="text-nowrap" tooltipText={intl.formatMessage({ id: 'terminal.listenTitle' })}> |
||||
<input |
||||
className="custom-control-input" |
||||
id="listenNetworkCheck" |
||||
onChange={listenOnNetwork} |
||||
type="checkbox" |
||||
disabled={terminalState.isVM} |
||||
/> |
||||
</CustomTooltip> |
||||
<label |
||||
className="form-check-label custom-control-label text-nowrap" |
||||
style={{ paddingTop: '0.125rem' }} |
||||
htmlFor="listenNetworkCheck" |
||||
data-id="listenNetworkCheckInput" |
||||
> |
||||
<FormattedMessage id="terminal.listen" /> |
||||
</label> |
||||
</div> |
||||
</CustomTooltip> |
||||
<div className="remix_ui_terminal_search d-flex align-items-center h-100"> |
||||
<i className="remix_ui_terminal_searchIcon d-flex align-items-center justify-content-center fas fa-search bg-light" aria-hidden="true"></i> |
||||
<input |
||||
onChange={(event) => setSearchInput(event.target.value.trim())} |
||||
type="text" |
||||
className="remix_ui_terminal_filter border form-control" |
||||
id="searchInput" |
||||
placeholder={intl.formatMessage({ id: 'terminal.search' })} |
||||
data-id="terminalInputSearch" |
||||
/> |
||||
</div> |
||||
</>) |
||||
} |
@ -1,3 +0,0 @@ |
||||
import React from 'react' |
||||
|
||||
export const TerminalContext = React.createContext(null) |
@ -0,0 +1,11 @@ |
||||
import { Actions, xTerminalUiState } from '@remix-ui/xterm' |
||||
import React, { Dispatch } from 'react' |
||||
|
||||
type terminalProviderContextType = { |
||||
terminalState: any, |
||||
dispatch: Dispatch<any>, |
||||
xtermState: xTerminalUiState, |
||||
dispatchXterm: Dispatch<Actions> |
||||
} |
||||
|
||||
export const TerminalContext = React.createContext<terminalProviderContextType>(null) |
@ -1,22 +1,33 @@ |
||||
import React, { useReducer } from 'react' // eslint-disable-line
|
||||
import { appPlatformTypes, platformContext } from '@remix-ui/app' |
||||
import { RemixUiXterminals, xTerminInitialState, xtermReducer } from '@remix-ui/xterm' |
||||
import React, { useContext, useReducer } from 'react' // eslint-disable-line
|
||||
import { RemixUITerminalBar } from './components/remix-ui-terminal-bar' |
||||
import { TerminalContext } from './context/context' |
||||
import { TerminalContext } from './context' |
||||
import { initialState, registerCommandReducer } from './reducers/terminalReducer' |
||||
import RemixUiTerminal from './remix-ui-terminal' |
||||
import { RemixUiTerminalProps } from './types/terminalTypes' |
||||
|
||||
export const RemixUITerminalWrapper = (props: RemixUiTerminalProps) => { |
||||
const [newstate, dispatch] = useReducer(registerCommandReducer, initialState) |
||||
|
||||
const [terminalState, dispatch] = useReducer(registerCommandReducer, initialState) |
||||
const [xtermState, dispatchXterm] = useReducer(xtermReducer, xTerminInitialState) |
||||
const platform = useContext(platformContext) |
||||
const providerState = { |
||||
newstate, |
||||
dispatch |
||||
terminalState, |
||||
dispatch, |
||||
xtermState, |
||||
dispatchXterm |
||||
} |
||||
|
||||
return (<> |
||||
<TerminalContext.Provider value={providerState}> |
||||
<RemixUITerminalBar {...props} /> |
||||
<RemixUiTerminal {...props} /> |
||||
{platform !== appPlatformTypes.desktop && <RemixUiTerminal {...props} />} |
||||
{platform === appPlatformTypes.desktop && |
||||
<> |
||||
<RemixUiTerminal visible={xtermState.showOutput} plugin={props.plugin} onReady={props.onReady} /> |
||||
<RemixUiXterminals {...props} /> |
||||
</> |
||||
} |
||||
</TerminalContext.Provider> |
||||
</>) |
||||
} |
@ -1,2 +1,5 @@ |
||||
export * from './lib/components/remix-ui-xterm' |
||||
export * from './lib/components/remix-ui-xterminals' |
||||
export * from './lib/reducer' |
||||
export * from './lib/types' |
||||
export * from './lib/actions' |
@ -0,0 +1,27 @@ |
||||
import { Actions } from "@remix-ui/xterm" |
||||
import { Plugin } from "@remixproject/engine" |
||||
|
||||
export const createTerminal = async (shell: string = '', plugin: Plugin, workingDir: string, dispatch: React.Dispatch<Actions>) => { |
||||
const shells: string[] = await plugin.call('xterm', 'getShells') |
||||
dispatch({ type: 'ADD_SHELLS', payload: shells }) |
||||
const pid = await plugin.call('xterm', 'createTerminal', workingDir, shell) |
||||
dispatch({ type: 'SHOW_OUTPUT', payload: false }) |
||||
dispatch({ type: 'HIDE_ALL_TERMINALS', payload: null }) |
||||
dispatch({ type: 'ADD_TERMINAL', payload: { pid, queue: '', timeStamp: Date.now(), ref: null, hidden: false } }) |
||||
|
||||
/* |
||||
setTerminals(prevState => { |
||||
// set all to hidden
|
||||
prevState.forEach(xtermState => { |
||||
xtermState.hidden = true |
||||
}) |
||||
return [...prevState, { |
||||
pid: pid, |
||||
queue: '', |
||||
timeStamp: Date.now(), |
||||
ref: null, |
||||
hidden: false |
||||
}] |
||||
}) |
||||
*/ |
||||
} |
@ -0,0 +1,55 @@ |
||||
import { CustomTooltip } from '@remix-ui/helper'; |
||||
import { TerminalContext } from '@remix-ui/terminal'; |
||||
import { createTerminal } from '@remix-ui/xterm'; |
||||
import React, { useState, useEffect, useRef, useContext } from 'react' // eslint-disable-line
|
||||
import { Dropdown, ButtonGroup } from 'react-bootstrap'; |
||||
import { FormattedMessage } from 'react-intl'; |
||||
import { RemixUiTerminalProps } from "../../../../terminal/src/lib/types/terminalTypes"; |
||||
|
||||
export const RemixUIXtermMenu = (props: RemixUiTerminalProps) => { |
||||
const { xtermState, dispatchXterm } = useContext(TerminalContext) |
||||
|
||||
function onClearTerminal(): void | PromiseLike<void> { |
||||
const terminal = xtermState.terminals.find(xtermState => xtermState.hidden === false) |
||||
if (terminal && terminal.ref && terminal.ref.terminal) |
||||
terminal.ref.terminal.clear() |
||||
} |
||||
|
||||
function onCreateTerminal(shell?: string): void | PromiseLike<void> { |
||||
createTerminal(shell, props.plugin, xtermState.workingDir, dispatchXterm) |
||||
} |
||||
|
||||
function onCloseTerminal(): void | PromiseLike<void> { |
||||
const pid = xtermState.terminals.find(xtermState => xtermState.hidden === false).pid |
||||
if (pid) |
||||
props.plugin.call('xterm', 'closeTerminal', pid) |
||||
} |
||||
|
||||
return (<> |
||||
<div className={`${xtermState.showOutput ? 'd-none' : ''}`}> |
||||
<button className="btn btn-sm btn-secondary mr-2" onClick={async () => onClearTerminal()}> |
||||
<CustomTooltip tooltipText={<FormattedMessage id='xterm.clear' defaultMessage='Clear terminal' />}> |
||||
<span className="far fa-ban border-0 p-0 m-0"></span> |
||||
</CustomTooltip> |
||||
</button> |
||||
<button className="btn btn-sm btn-secondary" onClick={async () => onCreateTerminal()}> |
||||
<CustomTooltip tooltipText={<FormattedMessage id='xterm.new' defaultMessage='New terminal' />}> |
||||
<span className="far fa-plus border-0 p-0 m-0"></span> |
||||
</CustomTooltip> |
||||
</button> |
||||
<Dropdown as={ButtonGroup}> |
||||
<Dropdown.Toggle split variant="secondary" id="dropdown-split-basic" /> |
||||
<Dropdown.Menu className='custom-dropdown-items remixui_menuwidth'> |
||||
{xtermState.shells.map((shell, index) => { |
||||
return (<Dropdown.Item key={index} onClick={async () => await onCreateTerminal(shell)}>{shell}</Dropdown.Item>) |
||||
})} |
||||
</Dropdown.Menu> |
||||
</Dropdown> |
||||
<button className="btn ml-2 btn-sm btn-secondary" onClick={onCloseTerminal}> |
||||
<CustomTooltip tooltipText={<FormattedMessage id='xterm.close' defaultMessage='Close terminal' />}> |
||||
<span className="far fa-trash border-0 ml-1"></span> |
||||
</CustomTooltip> |
||||
</button> |
||||
</div> |
||||
</>) |
||||
} |
@ -0,0 +1,69 @@ |
||||
import { Actions, xTerminalUiState } from "@remix-ui/xterm" |
||||
|
||||
export const xTerminInitialState: xTerminalUiState = { |
||||
terminalsEnabled: false, |
||||
terminals: [], |
||||
shells: [], |
||||
showOutput: true, |
||||
workingDir: '' |
||||
} |
||||
|
||||
export const xtermReducer = (state = xTerminInitialState, action: Actions) => { |
||||
switch (action.type) { |
||||
case 'ENABLE_TERMINALS': |
||||
return { |
||||
...state, |
||||
terminalsEnabled: true |
||||
} |
||||
case 'DISABLE_TERMINALS': |
||||
return { |
||||
...state, |
||||
terminalsEnabled: false |
||||
} |
||||
case 'ADD_TERMINAL': |
||||
return { |
||||
...state, |
||||
terminals: [...state.terminals, action.payload] |
||||
} |
||||
case 'HIDE_TERMINAL': |
||||
return { |
||||
...state, |
||||
terminals: state.terminals.map(terminal => terminal.pid === action.payload ? { ...terminal, hidden: true } : terminal) |
||||
} |
||||
case 'SHOW_TERMINAL': |
||||
return { |
||||
...state, |
||||
terminals: state.terminals.map(terminal => terminal.pid === action.payload ? { ...terminal, hidden: false } : terminal) |
||||
} |
||||
case 'HIDE_ALL_TERMINALS': |
||||
return { |
||||
...state, |
||||
terminals: state.terminals.map(terminal => ({ ...terminal, hidden: true })) |
||||
} |
||||
case 'REMOVE_TERMINAL': |
||||
const removed = state.terminals.filter(xtermState => xtermState.pid !== action.payload) |
||||
if (removed.length > 0) |
||||
removed[removed.length - 1].hidden = false |
||||
return { |
||||
...state, |
||||
terminals: removed |
||||
} |
||||
case 'ADD_SHELLS': |
||||
return { |
||||
...state, |
||||
shells: action.payload |
||||
} |
||||
case 'SHOW_OUTPUT': |
||||
return { |
||||
...state, |
||||
showOutput: action.payload |
||||
} |
||||
case 'SET_WORKING_DIR': |
||||
return { |
||||
...state, |
||||
workingDir: action.payload |
||||
} |
||||
default: |
||||
return state |
||||
} |
||||
} |
@ -0,0 +1,36 @@ |
||||
|
||||
export interface xtermState { |
||||
pid: number |
||||
queue: string |
||||
timeStamp: number |
||||
ref: any |
||||
hidden: boolean |
||||
} |
||||
|
||||
export interface xTerminalUiState { |
||||
terminalsEnabled: boolean |
||||
terminals: xtermState[] |
||||
shells: string[] |
||||
showOutput: boolean |
||||
workingDir: string |
||||
} |
||||
|
||||
export interface ActionPayloadTypes { |
||||
ENABLE_TERMINALS: undefined, |
||||
DISABLE_TERMINALS: undefined, |
||||
ADD_TERMINAL: xtermState, |
||||
HIDE_TERMINAL: number, |
||||
SHOW_TERMINAL: number, |
||||
HIDE_ALL_TERMINALS: undefined, |
||||
REMOVE_TERMINAL: number, |
||||
ADD_SHELLS: string[], |
||||
SHOW_OUTPUT: boolean |
||||
SET_WORKING_DIR: string |
||||
} |
||||
|
||||
export interface Action<T extends keyof ActionPayloadTypes> { |
||||
type: T, |
||||
payload: ActionPayloadTypes[T] |
||||
} |
||||
|
||||
export type Actions = {[A in keyof ActionPayloadTypes]: Action<A>}[keyof ActionPayloadTypes] |
Loading…
Reference in new issue