diff --git a/web/package.json b/web/package.json index 7d1e2bc2..f77246bb 100644 --- a/web/package.json +++ b/web/package.json @@ -20,6 +20,7 @@ "file-saver": "^2.0.5", "monaco-editor": "^0.33.0", "monaco-editor-webpack-plugin": "^7.0.1", + "monaco-vim": "^0.3.4", "re-resizable": "^6.9.1", "react": "^17.0.2", "react-dom": "^17.0.2", diff --git a/web/src/components/core/StatusBar/StatusBar.tsx b/web/src/components/core/StatusBar/StatusBar.tsx index bafd3787..501cfb56 100644 --- a/web/src/components/core/StatusBar/StatusBar.tsx +++ b/web/src/components/core/StatusBar/StatusBar.tsx @@ -1,11 +1,12 @@ import React, { useState } from 'react'; import { connect } from 'react-redux'; -import {editor} from "monaco-editor"; +import {editor} from 'monaco-editor'; +import {newEnvironmentChangeAction} from '~/store'; import config, {RuntimeType} from '~/services/config'; import EllipsisText from '~/components/utils/EllipsisText'; import StatusBarItem from '~/components/core/StatusBar/StatusBarItem'; import EnvironmentSelectModal from '~/components/modals/EnvironmentSelectModal'; -import {newEnvironmentChangeAction} from "~/store"; +import VimStatusBarItem from '~/plugins/vim/VimStatusBarItem'; import './StatusBar.css'; interface Props { @@ -19,7 +20,7 @@ interface Props { const getStatusItem = ({loading, lastError}) => { if (loading) { return ( - + Loading @@ -30,7 +31,7 @@ const getStatusItem = ({loading, lastError}) => { if (lastError) { return ( @@ -51,17 +52,18 @@ const StatusBar: React.FC = ({
{markers?.length ?? 0} Errors {getStatusItem({loading, lastError})} +
setRunSelectorModalVisible(true)} @@ -71,7 +73,7 @@ const StatusBar: React.FC = ({ {RuntimeType.toString(runtime)} ( +const getIcon = (icon: string | React.ComponentType) => ( + typeof icon === 'string' ? ( + + ) : ( + React.createElement(icon as React.ComponentType, { + className: 'StatusBarItem__icon' + }) + ) +) + +const getItemContents = ({icon, iconOnly, imageSrc, title, children}) => ( <> { - iconName && ( - - ) + icon && getIcon(icon) } { imageSrc && ( @@ -37,11 +46,23 @@ const getItemContents = ({iconName, iconOnly, imageSrc, title, children}) => ( ) -const StatusBarItem: React.FC = ({ - title, iconName, iconOnly, imageSrc, hideTextOnMobile, - href, button, children, ...props +const StatusBarItem: React.FC = ({ + title, + icon, + iconOnly, + imageSrc, + hideTextOnMobile, + href, + button, + children, + hidden, + ...props }) => { - const content = getItemContents({iconName, iconOnly, children, imageSrc, title}); + if (hidden) { + return null; + } + + const content = getItemContents({icon, iconOnly, children, imageSrc, title}); const className = hideTextOnMobile ? ( 'StatusBarItem StatusBarItem--hideOnMobile' ) : 'StatusBarItem'; diff --git a/web/src/components/editor/CodeEditor.tsx b/web/src/components/editor/CodeEditor.tsx index 28dc3a78..e2de7eeb 100644 --- a/web/src/components/editor/CodeEditor.tsx +++ b/web/src/components/editor/CodeEditor.tsx @@ -1,8 +1,13 @@ import React from 'react'; import MonacoEditor from 'react-monaco-editor'; -import { editor } from 'monaco-editor'; +import {editor, IKeyboardEvent} from 'monaco-editor'; import * as monaco from 'monaco-editor'; -import { attachCustomCommands } from '@components/editor/commands'; +import { + VimModeKeymap, + createVimModeAdapter, + StatusBarAdapter +} from '~/plugins/vim/editor'; +import { attachCustomCommands } from './commands'; import { Connect, @@ -13,7 +18,6 @@ import { newMarkerAction } from '~/store'; import { Analyzer } from '~/services/analyzer'; - import { LANGUAGE_GOLANG, stateToOptions } from './props'; const ANALYZE_DEBOUNCE_TIME = 500; @@ -26,16 +30,33 @@ interface CodeEditorState { @Connect(s => ({ code: s.editor.code, darkMode: s.settings.darkMode, + vimModeEnabled: s.settings.enableVimMode, loading: s.status?.loading, options: s.monaco, + vim: s.vim, })) export default class CodeEditor extends React.Component { - analyzer?: Analyzer; - _previousTimeout: any; - editorInstance?: editor.IStandaloneCodeEditor; + private analyzer?: Analyzer; + private _previousTimeout: any; + private editorInstance?: editor.IStandaloneCodeEditor; + private vimAdapter?: VimModeKeymap; + private vimCommandAdapter?: StatusBarAdapter; editorDidMount(editorInstance: editor.IStandaloneCodeEditor, _: monaco.editor.IEditorConstructionOptions) { this.editorInstance = editorInstance; + editorInstance.onKeyDown(e => this.onKeyDown(e)); + const [ vimAdapter, statusAdapter ] = createVimModeAdapter( + this.props.dispatch, + editorInstance + ); + this.vimAdapter = vimAdapter; + this.vimCommandAdapter = statusAdapter; + + if (this.props.vimModeEnabled) { + console.log('Vim mode enabled'); + this.vimAdapter.attach(); + } + if (Analyzer.supported()) { this.analyzer = new Analyzer(); } else { @@ -81,11 +102,27 @@ export default class CodeEditor extends React.Component { editorInstance.focus(); } + componentDidUpdate(prevProps) { + if (prevProps?.vimModeEnabled === this.props.vimModeEnabled) { + return + } + + if (this.props.vimModeEnabled) { + console.log('Vim mode enabled'); + this.vimAdapter?.attach(); + return; + } + + console.log('Vim mode disabled'); + this.vimAdapter?.dispose(); + } + componentWillUnmount() { this.analyzer?.dispose(); + this.vimAdapter?.dispose(); } - onChange(newValue: string, e: editor.IModelContentChangedEvent) { + onChange(newValue: string, _: editor.IModelContentChangedEvent) { this.props.dispatch(newFileChangeAction(newValue)); if (this.analyzer) { @@ -111,15 +148,26 @@ export default class CodeEditor extends React.Component { }, ANALYZE_DEBOUNCE_TIME); } + private onKeyDown(e: IKeyboardEvent) { + const {vimModeEnabled, vim} = this.props; + if (!vimModeEnabled || !vim?.commandStarted) { + return; + } + + this.vimCommandAdapter?.handleKeyDownEvent(e, vim?.keyBuffer); + } + render() { const options = stateToOptions(this.props.options); - return this.onChange(newVal, e)} - editorDidMount={(e, m: any) => this.editorDidMount(e, m)} - />; + return ( + this.onChange(newVal, e)} + editorDidMount={(e, m: any) => this.editorDidMount(e, m)} + /> + ); } } diff --git a/web/src/components/settings/SettingsModal.tsx b/web/src/components/settings/SettingsModal.tsx index 7fd9d4c9..8074325b 100644 --- a/web/src/components/settings/SettingsModal.tsx +++ b/web/src/components/settings/SettingsModal.tsx @@ -151,122 +151,62 @@ export default class SettingsModal extends ThemeableComponent { - this.touchMonacoProperty('fontFamily', num?.key); - }} - />} + control={( + { + this.touchMonacoProperty('fontFamily', num?.key); + }} + /> + )} /> { - this.touchSettingsProperty({ - useSystemTheme: val - }); - }} - />} + control={( + { + this.touchSettingsProperty({ + useSystemTheme: val + }); + }} + /> + )} /> { - this.touchMonacoProperty('fontLigatures', val); - }} - />} - /> - { - this.touchMonacoProperty('cursorBlinking', num?.key); - }} - />} - /> - { - this.touchMonacoProperty('cursorStyle', num?.key); - }} - />} - /> - { - this.touchMonacoProperty('cursorStyle', val); - }} - />} - /> - { - this.touchMonacoProperty('minimap', val); - }} - />} - /> - { - this.touchMonacoProperty('contextMenu', val); - }} - />} - /> - { - this.touchMonacoProperty('smoothScrolling', val); - }} - />} + control={( + { + this.touchMonacoProperty('fontLigatures', val); + }} + /> + )} /> { - this.touchMonacoProperty('mouseWheelZoom', val); - }} - />} + key='enableVimMode' + title='Enable Vim Mode' + control={( + { + this.touchSettingsProperty({enableVimMode: val}) + }} + /> + )} /> - + } /> + + { + this.touchMonacoProperty('cursorBlinking', num?.key); + }} + /> + )} + /> + { + this.touchMonacoProperty('cursorStyle', num?.key); + }} + /> + )} + /> + { + this.touchMonacoProperty('cursorStyle', val); + }} + /> + )} + /> + { + this.touchMonacoProperty('minimap', val); + }} + /> + )} + /> + { + this.touchMonacoProperty('contextMenu', val); + }} + /> + )} + /> + { + this.touchMonacoProperty('smoothScrolling', val); + }} + /> + )} + /> + { + this.touchMonacoProperty('mouseWheelZoom', val); + }} + /> + )} + /> +
diff --git a/web/src/components/utils/SharePopup.tsx b/web/src/components/utils/SharePopup.tsx index 87682997..b3112e54 100644 --- a/web/src/components/utils/SharePopup.tsx +++ b/web/src/components/utils/SharePopup.tsx @@ -1,4 +1,4 @@ -import React, {FC, useContext, useMemo} from 'react'; +import React, {FC, useMemo} from 'react'; import copy from 'copy-to-clipboard'; import { Target } from '@fluentui/react-hooks'; import { diff --git a/web/src/plugins/vim/VimStatusBarItem.tsx b/web/src/plugins/vim/VimStatusBarItem.tsx new file mode 100644 index 00000000..f5f62972 --- /dev/null +++ b/web/src/plugins/vim/VimStatusBarItem.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { SiVim } from 'react-icons/si'; +import { State } from '~/store'; +import { Nullable } from '~/utils/types'; +import { VimMode, VimState, VimSubMode } from '~/store/vim/state'; +import StatusBarItem from '~/components/core/StatusBar/StatusBarItem'; + +interface Props { + vimState?: Nullable +} + +const getItemText = (state: Nullable): Nullable => { + if (!state) return null; + const { mode, subMode, keyBuffer, commandStarted, confirmMessage } = state; + if (confirmMessage) { + return confirmMessage; + } + + if (commandStarted) { + return keyBuffer!; + } + + if (mode !== VimMode.Visual) { + return `-- ${mode.toUpperCase()} --`; + } + + switch (subMode) { + case VimSubMode.Linewise: + return '-- VISUAL LINE --'; + case VimSubMode.Blockwise: + return '-- VISUAL BLOCK --'; + default: + return '-- VISUAL --'; + } +} + +const VimStatusBarItem: React.FC = ({vimState}) => { + if (!vimState) { + return null; + } + + return ( + + {getItemText(vimState)} + + ) +} + +export default connect( + ({vim}: State) => ({ vimState: vim}) +)(VimStatusBarItem); diff --git a/web/src/plugins/vim/editor.ts b/web/src/plugins/vim/editor.ts new file mode 100644 index 00000000..85ae6836 --- /dev/null +++ b/web/src/plugins/vim/editor.ts @@ -0,0 +1,150 @@ +import {editor, IKeyboardEvent, KeyCode} from 'monaco-editor'; +import VimModeKeymap from 'monaco-vim/lib/cm/keymap_vim'; +import {Dispatch} from '~/store/vim/state'; + +import { + newVimCommandDoneAction, + newVimCommandStartAction, + newVimConfirmAction, + newVimDisposeAction, + newVimInitAction, newVimKeyDeleteAction, + newVimKeyPressAction, + newVimModeChangeAction, + VimModeChangeArgs +} from '~/store/vim/actions'; +import {Nullable} from "~/utils/types"; + +// This implementation is quite hacky, but still better than +// having a huge list of all possible keys except printable. +const regularKeyRegex = /^.$/; +const isPrintableKey = (key: string) => regularKeyRegex.test(key); + +interface CommandInputOpts { + bottom?: boolean, + selectValueOnOpen?: boolean, + closeOnEnter?: boolean, + onKeyDown?: (e: KeyboardEvent, input: HTMLInputElement, closeFn: Function) => void + onKeyUp?: (e: KeyboardEvent, input: HTMLInputElement, closeFn: Function) => void, + value: string +} + +class VimModeKeymapAdapter extends VimModeKeymap { + constructor( + // "dispatch" is reserved method in inner class. + private dispatchFunc: Dispatch, + editorInstance: editor.IStandaloneCodeEditor + ) { + super(editorInstance); + } + + attach() { + this.dispatchFunc(newVimInitAction()); + super.attach(); + } +} + +export { VimModeKeymap }; + +export class StatusBarAdapter { + private commandResultCallback?: Nullable<((val: string) => void)>; + private currentOpts?: Nullable; + + constructor( + private dispatchFn: Dispatch, + private editor: editor.IStandaloneCodeEditor + ) {} + + /** + * Method called on command result, usually an error. + * + * Library passes pre-formated styled HTML element for display. + * @param result + */ + showNotification(result: string|HTMLElement) { + const msg = result instanceof HTMLElement ? result.innerText : result; + console.log('showNotification', msg); + this.dispatchFn(newVimConfirmAction(msg)); + this.commandResultCallback = null; + } + + /** + * Called by VimModeKeymap on command start + * @param text DocumentFragment which contains info about command character + * @param callback Callback to submit command + * @param options Command handle arguments (unused) + */ + setSec(text: DocumentFragment, callback: (val: string) => void, options: CommandInputOpts) { + this.currentOpts = options; + this.commandResultCallback = callback; + + // Initial character is hidden inside an array of 2 spans as content of 1 span. + // Idk who thought that this is a good idea, but we have to deal with it. + const commandChar = text.firstChild?.textContent; + this.dispatchFn(newVimCommandStartAction(commandChar)); + } + + onPromptClose(value: string) { + this.commandResultCallback?.(value.substring(1)); + this.commandResultCallback = null; + } + + handleKeyDownEvent(e: IKeyboardEvent, currentData: string) { + e.preventDefault(); + e.stopPropagation(); + + switch (e.keyCode) { + case KeyCode.Enter: + this.onPromptClose(currentData); + return; + case KeyCode.Backspace: + this.dispatchFn(newVimKeyDeleteAction()); + return; + default: + break; + } + + if (isPrintableKey(e.browserEvent.key)) { + this.dispatchFn(newVimKeyPressAction(e.browserEvent.key)); + } + } + + private closeInput() { + console.log('clear input'); + // this?.inputRef.current?.blur(); + this?.editor?.focus(); + this.currentOpts = null; + // this.commandResultCallback = null; + } +} + +/** + * Creates a vim-mode adapter attached to state dispatcher and editor instance + * @param dispatch State dispatch function + * @param editorInstance Monaco editor instance + */ +export const createVimModeAdapter = ( + dispatch: Dispatch, + editorInstance: editor.IStandaloneCodeEditor +): [VimModeKeymap, StatusBarAdapter] => { + const vimAdapter: VimModeKeymap = new VimModeKeymapAdapter(dispatch, editorInstance); + const statusAdapter = new StatusBarAdapter(dispatch, editorInstance); + + vimAdapter.setStatusBar(statusAdapter); + vimAdapter.on('vim-mode-change', (mode: VimModeChangeArgs) => { + dispatch(newVimModeChangeAction(mode)); + }); + + vimAdapter.on('vim-keypress', (key: string) => { + dispatch(newVimKeyPressAction(key)); + }); + + vimAdapter.on('vim-command-done', () => { + dispatch(newVimCommandDoneAction()); + }); + + vimAdapter.on('dispose', () => { + dispatch(newVimDisposeAction()); + }); + + return [vimAdapter, statusAdapter]; +}; diff --git a/web/src/services/config.ts b/web/src/services/config.ts index 7010e14b..5ccf0a19 100644 --- a/web/src/services/config.ts +++ b/web/src/services/config.ts @@ -8,6 +8,7 @@ import {supportsPreferColorScheme} from "~/utils/theme"; const DARK_THEME_KEY = 'ui.darkTheme.enabled'; const USE_SYSTEM_THEME_KEY = 'ui.darkTheme.useSystem'; const RUNTIME_TYPE_KEY = 'go.build.runtime'; +const ENABLE_VIM_MODE_KEY = 'ms.monaco.vimModeEnabled'; const AUTOFORMAT_KEY = 'go.build.autoFormat'; const MONACO_SETTINGS = 'ms.monaco.settings'; const PANEL_SETTINGS = 'ui.layout.panel'; @@ -103,6 +104,19 @@ const Config = { localStorage.setItem(USE_SYSTEM_THEME_KEY, val.toString()); }, + get enableVimMode() { + if (this._cache[ENABLE_VIM_MODE_KEY]) { + return this._cache[ENABLE_VIM_MODE_KEY]; + } + + return this.getBoolean(ENABLE_VIM_MODE_KEY, false); + }, + + set enableVimMode(val: boolean) { + this._cache[ENABLE_VIM_MODE_KEY] = val; + localStorage.setItem(ENABLE_VIM_MODE_KEY, val.toString()); + }, + get runtimeType(): RuntimeType { if (this._cache[RUNTIME_TYPE_KEY]) { return this._cache[RUNTIME_TYPE_KEY]; diff --git a/web/src/store/actions.ts b/web/src/store/actions.ts index 3b9ca91a..8fe6d8da 100644 --- a/web/src/store/actions.ts +++ b/web/src/store/actions.ts @@ -24,8 +24,8 @@ export enum ActionType { EVAL_FINISH = 'EVAL_FINISH' } -export interface Action { - type: ActionType +export interface Action { + type: A payload: T } diff --git a/web/src/store/dispatch.ts b/web/src/store/dispatch.ts index 14802863..aeb80d13 100644 --- a/web/src/store/dispatch.ts +++ b/web/src/store/dispatch.ts @@ -77,6 +77,10 @@ export const newSettingsChangeDispatcher = (changes: Partial): Di config.darkThemeEnabled = !!changes.darkMode; } + if ('enableVimMode' in changes) { + config.enableVimMode = !!changes.enableVimMode; + } + dispatch(newSettingsChangeAction(changes)); } ); @@ -89,7 +93,6 @@ export function newBuildParamsChangeDispatcher(runtime: RuntimeType, autoFormat: }; } - export function newSnippetLoadDispatcher(snippetID?: string): Dispatcher { return async (dispatch: DispatchFn, _: StateProvider) => { if (!snippetID) { diff --git a/web/src/store/reducers.ts b/web/src/store/reducers.ts index 5a7c29f9..126d1225 100644 --- a/web/src/store/reducers.ts +++ b/web/src/store/reducers.ts @@ -1,12 +1,14 @@ import { connectRouter } from 'connected-react-router'; import { combineReducers } from 'redux'; -import {editor} from "monaco-editor"; +import {editor} from 'monaco-editor'; -import { Action, ActionType, FileImportArgs, BuildParamsArgs, MonacoParamsChanges } from './actions'; import { RunResponse, EvalEvent } from '~/services/api'; -import localConfig, { MonacoSettings, RuntimeType } from '~/services/config' +import config, { MonacoSettings, RuntimeType } from '~/services/config' + +import vimReducers from './vim/reducers'; +import { Action, ActionType, FileImportArgs, BuildParamsArgs, MonacoParamsChanges } from './actions'; import { mapByAction } from './helpers'; -import config from '~/services/config'; + import { EditorState, SettingsState, @@ -82,7 +84,7 @@ const reducers = { settings: mapByAction({ [ActionType.TOGGLE_THEME]: (s: SettingsState, a: Action) => { s.darkMode = !s.darkMode; - localConfig.darkThemeEnabled = s.darkMode; + config.darkThemeEnabled = s.darkMode; return s; }, [ActionType.BUILD_PARAMS_CHANGE]: (s: SettingsState, a: Action) => { @@ -95,10 +97,11 @@ const reducers = { ...s, ...payload }) }, { - darkMode: localConfig.darkThemeEnabled, + darkMode: config.darkThemeEnabled, autoFormat: true, runtime: RuntimeType.GoPlayground, - useSystemTheme: localConfig.useSystemTheme, + useSystemTheme: config.useSystemTheme, + enableVimMode: config.enableVimMode }), monaco: mapByAction({ [ActionType.MONACO_SETTINGS_CHANGE]: (s: MonacoSettings, a: Action) => { @@ -128,7 +131,8 @@ const reducers = { return { ...s, ...payload }; } - }, {}) + }, {}), + vim: vimReducers }; export const getInitialState = (): State => ({ @@ -140,13 +144,15 @@ export const getInitialState = (): State => ({ code: '' }, settings: { - darkMode: localConfig.darkThemeEnabled, - autoFormat: localConfig.autoFormat, - runtime: localConfig.runtimeType, - useSystemTheme: localConfig.useSystemTheme, + darkMode: config.darkThemeEnabled, + autoFormat: config.autoFormat, + runtime: config.runtimeType, + useSystemTheme: config.useSystemTheme, + enableVimMode: config.enableVimMode }, monaco: config.monacoSettings, - panel: localConfig.panelLayout + panel: config.panelLayout, + vim: null }); export const createRootReducer = history => combineReducers({ diff --git a/web/src/store/state.ts b/web/src/store/state.ts index 3881f515..1c1bb2d1 100644 --- a/web/src/store/state.ts +++ b/web/src/store/state.ts @@ -3,6 +3,7 @@ import {editor} from "monaco-editor"; import { EvalEvent } from '~/services/api'; import { MonacoSettings, RuntimeType } from '~/services/config'; import {LayoutType} from '~/styles/layout'; +import { VimState } from '~/store/vim/state'; export interface UIState { shareCreated?: boolean @@ -26,6 +27,7 @@ export interface SettingsState { useSystemTheme: boolean autoFormat: boolean, runtime: RuntimeType, + enableVimMode: boolean } export interface PanelState { @@ -42,6 +44,7 @@ export interface State { monaco: MonacoSettings panel: PanelState ui?: UIState + vim?: VimState | null } export function Connect(fn: (state: State) => any) { diff --git a/web/src/store/vim/actions.ts b/web/src/store/vim/actions.ts new file mode 100644 index 00000000..6cc04f95 --- /dev/null +++ b/web/src/store/vim/actions.ts @@ -0,0 +1,61 @@ +import { VimState } from '~/store/vim/state'; +import {Nullable} from "~/utils/types"; + +export enum ActionType { + VIM_INIT = 'VIM_INIT', + VIM_DISPOSE = 'VIM_DISPOSE', + VIM_MODE_CHANGE = 'VIM_MODE_CHANGE', + VIM_KEYPRESS = 'VIM_KEYPRESS', + VIM_KEYDEL = 'VIM_KEYDEL', + VIM_COMMAND_START = 'VIM_COMMAND_START', + VIM_COMMAND_DONE = 'VIM_COMMAND_DONE', + VIM_SHOW_CONFIRM = 'VIM_SHOW_CONFIRM' +} + +/** + * VimModeChangeArgs represents current selected mode and sub-mode. + * + * @see monaco-vim/lib/statusbar.js + */ +export type VimModeChangeArgs = Pick; + +export interface VimKeyPressArgs { + key: string + replaceContents: boolean +} + +export const newVimInitAction = () => ({ + type: ActionType.VIM_INIT +}); + +export const newVimDisposeAction = () => ({ + type: ActionType.VIM_DISPOSE +}); + +export const newVimModeChangeAction = (payload: VimModeChangeArgs) => ({ + type: ActionType.VIM_MODE_CHANGE, + payload +}); + +export const newVimKeyPressAction = (key: string, replaceContents = false) => ({ + type: ActionType.VIM_KEYPRESS, + payload: {key, replaceContents} +}); + +export const newVimKeyDeleteAction = () => ({ + type: ActionType.VIM_KEYDEL +}); + +export const newVimCommandStartAction = (commandSuffix?: Nullable) => ({ + type: ActionType.VIM_COMMAND_START, + payload: commandSuffix ?? '' +}) + +export const newVimCommandDoneAction = () => ({ + type: ActionType.VIM_COMMAND_DONE +}); + +export const newVimConfirmAction = (msg: string) => ({ + type: ActionType.VIM_SHOW_CONFIRM, + payload: msg +}); diff --git a/web/src/store/vim/reducers.ts b/web/src/store/vim/reducers.ts new file mode 100644 index 00000000..c2a675b2 --- /dev/null +++ b/web/src/store/vim/reducers.ts @@ -0,0 +1,57 @@ +import { mapByAction} from '~/store/helpers'; +import { Nullable } from '~/utils/types'; +import { Action } from '~/store/actions'; +import { VimState, VimMode } from './state'; +import {ActionType, VimKeyPressArgs, VimModeChangeArgs} from './actions'; + +const initialState: VimState = { mode: VimMode.Normal } + +const reducers = mapByAction>({ + [ActionType.VIM_INIT]: () => initialState, + [ActionType.VIM_DISPOSE]: () => null, + [ActionType.VIM_MODE_CHANGE]: (s: Nullable, {payload: {mode, subMode}}: Action) => ( + s ? { ...s, mode, subMode} : { mode, subMode } + ), + [ActionType.VIM_KEYPRESS]: (s: Nullable, {payload: {key, replaceContents}}: Action) => { + const state = s ?? initialState; + if (!state.commandStarted) { + return state; + } + + const { keyBuffer } = state; + const newContent = replaceContents ? key : keyBuffer + key; + return { ...state, commandStarted: true, keyBuffer: newContent}; + }, + [ActionType.VIM_KEYDEL]: (s: Nullable) => { + const state = s ?? initialState; + const keyBuffer = state.keyBuffer?.slice(0, -1); + if (!keyBuffer) { + const { mode, subMode } = state; + return { mode, subMode }; + } + + return { ...state, keyBuffer}; + }, + [ActionType.VIM_COMMAND_START]: (s: Nullable, {payload}: Action) => { + const state = s ?? initialState; + const { mode, subMode } = state; + return { mode, subMode, commandStarted: true, keyBuffer: payload ?? ''}; + }, + [ActionType.VIM_COMMAND_DONE]: (s: Nullable) => { + if (!s) { + return initialState; + } + + const { mode, subMode } = s; + return { mode, subMode }; + }, + [ActionType.VIM_SHOW_CONFIRM]: (s: Nullable, {payload}: Action) => { + const {mode, subMode} = s ?? initialState; + return { + mode, subMode, + confirmMessage: payload, + } + } +}, null); + +export default reducers; diff --git a/web/src/store/vim/state.ts b/web/src/store/vim/state.ts new file mode 100644 index 00000000..c1c7d9cf --- /dev/null +++ b/web/src/store/vim/state.ts @@ -0,0 +1,21 @@ +export enum VimMode { + Visual = 'visual', + Normal = 'normal', + Insert = 'insert', + Replace = 'replace' +} + +export enum VimSubMode { + Linewise = 'linewise', + Blockwise = 'blockwise' +} + +export type Dispatch = (v:{type: T, payload?: V}) => void; + +export interface VimState { + mode: VimMode + subMode?: VimSubMode + keyBuffer?: string + commandStarted?: boolean + confirmMessage?: string +} diff --git a/web/src/types/monaco-vim/index.d.ts b/web/src/types/monaco-vim/index.d.ts new file mode 100644 index 00000000..9beefec8 --- /dev/null +++ b/web/src/types/monaco-vim/index.d.ts @@ -0,0 +1,38 @@ +import { editor } from 'monaco-editor'; + +type Mode = 'visual' | 'normal' | 'insert' | 'replace'; + +interface StatusBar { + setMode(mode: Mode) + setSec(html: string, callback: () => void, options: any) + showNotification(html: string) + setKeyBuffer(val: string) + setText(txt: string) + toggleVisibility(isVisible: boolean) + closeInput() + clear() +} + +declare module 'monaco-vim/lib/cm/keymap_vim' { + type Callback = (val: T) => void; + export default class VimMode { + constructor(editorInstance: editor.IStandaloneCodeEditor, statusBar?: StatusBar); + on(eventName: 'vim-mode-change', cb: Callback<{mode: Mode, subMode?: string}>); + on(eventName: 'vim-keypress', cb: Callback); + on(eventName: 'vim-command-done', cb: () => void); + on(eventName: 'dispose', cb: () => void); + setStatusBar(bar: StatusBar); + attach(); + dispose(); + } +} + +declare module 'monaco-vim' { + export { StatusBar }; + export default function initVimMode( + editorInstance: editor.IStandaloneCodeEditor, + statusBarNode?: HTMLElement | null = null, + StatusBarClass?: StatusBar, + sanitizer?: (val: string) => string + ); +} diff --git a/web/src/utils/types.ts b/web/src/utils/types.ts new file mode 100644 index 00000000..94074243 --- /dev/null +++ b/web/src/utils/types.ts @@ -0,0 +1,24 @@ +export type Nullable = T | null; + +/** + * Returns a new object without specified keys + * @param obj Object + * @param keys List of keys to exclude + */ +export const excludeKeys = >(obj: T, ...keys: Array): R => { + if (!obj) return (obj as unknown) as R; + switch (keys.length) { + case 0: + return (obj as unknown) as R; + case 1: + const newObj = { ...obj }; + const [ key ] = keys; + delete newObj[key]; + return (obj as unknown) as R; + default: + const keysList = new Set(keys as string[]); + return Object.fromEntries( + Object.entries(obj).filter(([key]) => !keysList.has(key)) + ) as R; + } +} diff --git a/web/tsconfig.json b/web/tsconfig.json index 98d2a8c9..b83bcef9 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -22,6 +22,10 @@ "noImplicitAny": false, "experimentalDecorators": true, "noFallthroughCasesInSwitch": true, + "typeRoots": [ + "src/types/monaco-vim", + "node_modules/@types" + ] }, "include": [ "src" diff --git a/web/yarn.lock b/web/yarn.lock index c54c3a58..b1235d98 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -6254,6 +6254,11 @@ monaco-editor@^0.33.0: resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.33.0.tgz#842e244f3750a2482f8a29c676b5684e75ff34af" integrity sha512-VcRWPSLIUEgQJQIE0pVT8FcGBIgFoxz7jtqctE+IiCxWugD0DwgyQBcZBhdSrdMC84eumoqMZsGl2GTreOzwqw== +monaco-vim@^0.3.4: + version "0.3.4" + resolved "https://registry.yarnpkg.com/monaco-vim/-/monaco-vim-0.3.4.tgz#141d3e1129a563e63a7286a1472ccfe72b758abe" + integrity sha512-IVogmrQ6fRwW2PD9XRHFysOZBFj8qcRhgabu7GlGiNR14ApKMHz3pYOL1hDq1ctVPj1DS6hZd98vZrFxWr5d6A== + ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"