diff options
author | Shin'ya Ueoka <ueokande@i-beam.org> | 2021-04-11 18:00:51 +0900 |
---|---|---|
committer | Shin'ya Ueoka <ueokande@i-beam.org> | 2021-04-11 22:34:14 +0900 |
commit | 618fb497c443662531eb3befe7696a04efe9651d (patch) | |
tree | 530af78bf75f03e7ffd71e4ca5ad1cf864584be0 /src/console/completion | |
parent | 21f863d76fbb5ed752ad529f8fbe33e75460027e (diff) |
Replace completion state with Custom Hooks
Diffstat (limited to 'src/console/completion')
-rw-r--r-- | src/console/completion/actions.ts | 76 | ||||
-rw-r--r-- | src/console/completion/context.ts | 9 | ||||
-rw-r--r-- | src/console/completion/hooks.ts | 277 | ||||
-rw-r--r-- | src/console/completion/provider.tsx | 25 | ||||
-rw-r--r-- | src/console/completion/reducer.ts | 96 |
5 files changed, 483 insertions, 0 deletions
diff --git a/src/console/completion/actions.ts b/src/console/completion/actions.ts new file mode 100644 index 0000000..59d1a04 --- /dev/null +++ b/src/console/completion/actions.ts @@ -0,0 +1,76 @@ +import CompletionType from "../../shared/CompletionType"; +import Completions from "../Completions"; + +export const INIT_COMPLETIONS = "reset.completions"; +export const SET_COMPLETION_SOURCE = "set.completion.source"; +export const SET_COMPLETIONS = "set.completions"; +export const COMPLETION_NEXT = "completion.next"; +export const COMPLETION_PREV = "completion.prev"; + +export interface InitCompletionAction { + type: typeof INIT_COMPLETIONS; + completionTypes: CompletionType[]; +} + +export interface SetCompletionSourceAction { + type: typeof SET_COMPLETION_SOURCE; + completionSource: string; +} + +export interface SetCompletionsAction { + type: typeof SET_COMPLETIONS; + completions: Completions; +} + +export interface CompletionNextAction { + type: typeof COMPLETION_NEXT; +} + +export interface CompletionPrevAction { + type: typeof COMPLETION_PREV; +} + +export type CompletionAction = + | InitCompletionAction + | SetCompletionSourceAction + | SetCompletionsAction + | CompletionNextAction + | CompletionPrevAction; + +export const initCompletion = ( + completionTypes: CompletionType[] +): InitCompletionAction => { + return { + type: INIT_COMPLETIONS, + completionTypes, + }; +}; +export const setCompletionSource = ( + query: string +): SetCompletionSourceAction => { + return { + type: SET_COMPLETION_SOURCE, + completionSource: query, + }; +}; + +export const setCompletions = ( + completions: Completions +): SetCompletionsAction => { + return { + type: SET_COMPLETIONS, + completions, + }; +}; + +export const selectNext = (): CompletionNextAction => { + return { + type: COMPLETION_NEXT, + }; +}; + +export const selectPrev = (): CompletionPrevAction => { + return { + type: COMPLETION_PREV, + }; +}; diff --git a/src/console/completion/context.ts b/src/console/completion/context.ts new file mode 100644 index 0000000..2fafcf8 --- /dev/null +++ b/src/console/completion/context.ts @@ -0,0 +1,9 @@ +import React from "react"; +import { State, defaultState } from "./reducer"; +import { CompletionAction } from "./actions"; + +export const CompletionStateContext = React.createContext<State>(defaultState); + +export const CompletionDispatchContext = React.createContext< + (action: CompletionAction) => void +>(() => {}); diff --git a/src/console/completion/hooks.ts b/src/console/completion/hooks.ts new file mode 100644 index 0000000..aac431b --- /dev/null +++ b/src/console/completion/hooks.ts @@ -0,0 +1,277 @@ +import React from "react"; +import * as actions from "./actions"; +import { Command } from "../../shared/Command"; +import TabFlag from "../../shared/TabFlag"; +import { CompletionStateContext, CompletionDispatchContext } from "./context"; +import CompletionClient from "../clients/CompletionClient"; +import CommandLineParser, { + CommandLine, + InputPhase, +} from "../commandline/CommandLineParser"; +import { UnknownCommandError } from "../commandline/CommandParser"; +import Completions from "../Completions"; +import CompletionType from "../../shared/CompletionType"; + +const commandDocs = { + [Command.Set]: "Set a value of the property", + [Command.Open]: "Open a URL or search by keywords in current tab", + [Command.TabOpen]: "Open a URL or search by keywords in new tab", + [Command.WindowOpen]: "Open a URL or search by keywords in new window", + [Command.Buffer]: "Select tabs by matched keywords", + [Command.BufferDelete]: "Close a certain tab matched by keywords", + [Command.BuffersDelete]: "Close all tabs matched by keywords", + [Command.Quit]: "Close the current tab", + [Command.QuitAll]: "Close all tabs", + [Command.AddBookmark]: "Add current page to bookmarks", + [Command.Help]: "Open Vim Vixen help in new tab", +}; + +const propertyDocs: { [key: string]: string } = { + hintchars: "hint characters on follow mode", + smoothscroll: "smooth scroll", + complete: "which are completed at the open page", + colorscheme: "color scheme of the console", +}; + +const completionClient = new CompletionClient(); + +const getCommandCompletions = async (query: string): Promise<Completions> => { + const items = Object.entries(commandDocs) + .filter(([name]) => name.startsWith(query)) + .map(([name, doc]) => ({ + caption: name, + content: name, + url: doc, + })); + return [ + { + name: "Console Command", + items, + }, + ]; +}; + +const getOpenCompletions = async ( + command: string, + query: string, + completionTypes: CompletionType[] +): Promise<Completions> => { + const completions: Completions = []; + for (const type of completionTypes) { + switch (type) { + case CompletionType.SearchEngines: { + const items = await completionClient.requestSearchEngines(query); + if (items.length === 0) { + break; + } + completions.push({ + name: "Search Engines", + items: items.map((key) => ({ + caption: key.title, + content: command + " " + key.title, + })), + }); + break; + } + case CompletionType.History: { + const items = await completionClient.requestHistory(query); + if (items.length === 0) { + break; + } + completions.push({ + name: "History", + items: items.map((item) => ({ + caption: item.title, + content: command + " " + item.url, + url: item.url, + })), + }); + break; + } + case CompletionType.Bookmarks: { + const items = await completionClient.requestBookmarks(query); + if (items.length === 0) { + break; + } + completions.push({ + name: "Bookmarks", + items: items.map((item) => ({ + caption: item.title, + content: command + " " + item.url, + url: item.url, + })), + }); + break; + } + } + } + return completions; +}; + +export const getTabCompletions = async ( + command: string, + query: string, + excludePinned: boolean +): Promise<Completions> => { + const items = await completionClient.requestTabs(query, excludePinned); + if (items.length === 0) { + return []; + } + + return [ + { + name: "Buffers", + items: items.map((item) => ({ + content: command + " " + item.url, + caption: `${item.index}: ${ + item.flag != TabFlag.None ? item.flag : " " + } ${item.title}`, + url: item.url, + icon: item.faviconUrl, + })), + }, + ]; +}; + +export const getPropertyCompletions = async ( + command: string, + query: string +): Promise<Completions> => { + const properties = await completionClient.getProperties(); + const items = properties + .map((item) => { + const desc = propertyDocs[item.name] || ""; + if (item.type === "boolean") { + return [ + { + caption: item.name, + content: command + " " + item.name, + url: "Enable " + desc, + }, + { + caption: "no" + item.name, + content: command + " no" + item.name, + url: "Disable " + desc, + }, + ]; + } else { + return [ + { + caption: item.name, + content: command + " " + item.name, + url: "Set " + desc, + }, + ]; + } + }) + .reduce((acc, val) => acc.concat(val), []) + .filter((item) => item.caption.startsWith(query)); + return [{ name: "Properties", items }]; +}; + +export const useCompletions = () => { + const state = React.useContext(CompletionStateContext); + const dispatch = React.useContext(CompletionDispatchContext); + const commandLineParser = React.useMemo(() => new CommandLineParser(), []); + + const updateCompletions = React.useCallback((source: string) => { + dispatch(actions.setCompletionSource(source)); + }, []); + + const initCompletion = React.useCallback((source: string) => { + completionClient.getCompletionTypes().then((completionTypes) => { + dispatch(actions.initCompletion(completionTypes)); + dispatch(actions.setCompletionSource(source)); + }); + }, []); + + React.useEffect(() => { + const text = state.completionSource; + const phase = commandLineParser.inputPhase(text); + if (phase === InputPhase.OnCommand) { + getCommandCompletions(text).then((completions) => + dispatch(actions.setCompletions(completions)) + ); + } else { + let cmd: CommandLine | null = null; + try { + cmd = commandLineParser.parse(text); + } catch (e) { + if (e instanceof UnknownCommandError) { + return; + } + } + switch (cmd?.command) { + case Command.Open: + case Command.TabOpen: + case Command.WindowOpen: + if (!state.completionTypes) { + initCompletion(text); + return; + } + + getOpenCompletions( + cmd.command, + cmd.args, + state.completionTypes + ).then((completions) => + dispatch(actions.setCompletions(completions)) + ); + break; + case Command.Buffer: + getTabCompletions(cmd.command, cmd.args, false).then((completions) => + dispatch(actions.setCompletions(completions)) + ); + break; + case Command.BufferDelete: + case Command.BuffersDelete: + getTabCompletions(cmd.command, cmd.args, true).then((completions) => + dispatch(actions.setCompletions(completions)) + ); + break; + case Command.BufferDeleteForce: + case Command.BuffersDeleteForce: + getTabCompletions(cmd.command, cmd.args, false).then((completions) => + dispatch(actions.setCompletions(completions)) + ); + break; + case Command.Set: + getPropertyCompletions(cmd.command, cmd.args).then((completions) => + dispatch(actions.setCompletions(completions)) + ); + break; + } + } + }, [state.completionSource, state.completionTypes]); + + return { + completions: state.completions, + updateCompletions, + initCompletion, + }; +}; + +export const useSelectCompletion = () => { + const state = React.useContext(CompletionStateContext); + const dispatch = React.useContext(CompletionDispatchContext); + const next = React.useCallback(() => dispatch(actions.selectNext()), [ + dispatch, + ]); + const prev = React.useCallback(() => dispatch(actions.selectPrev()), [ + dispatch, + ]); + const currentValue = React.useMemo(() => { + if (state.select < 0) { + return state.completionSource; + } + const items = state.completions.map((g) => g.items).flat(); + return items[state.select]?.content || ""; + }, [state.completionSource, state.select]); + + return { + select: state.select, + currentValue, + selectNext: next, + selectPrev: prev, + }; +}; diff --git a/src/console/completion/provider.tsx b/src/console/completion/provider.tsx new file mode 100644 index 0000000..c0de250 --- /dev/null +++ b/src/console/completion/provider.tsx @@ -0,0 +1,25 @@ +import reducer, { defaultState } from "./reducer"; +import React from "react"; +import { CompletionDispatchContext, CompletionStateContext } from "./context"; + +interface Props { + initialInputValue: string; +} + +export const CompletionProvider: React.FC<Props> = ({ + initialInputValue, + children, +}) => { + const initialState = { + ...defaultState, + completionSource: initialInputValue, + }; + const [state, dispatch] = React.useReducer(reducer, initialState); + return ( + <CompletionStateContext.Provider value={state}> + <CompletionDispatchContext.Provider value={dispatch}> + {children} + </CompletionDispatchContext.Provider> + </CompletionStateContext.Provider> + ); +}; diff --git a/src/console/completion/reducer.ts b/src/console/completion/reducer.ts new file mode 100644 index 0000000..905451f --- /dev/null +++ b/src/console/completion/reducer.ts @@ -0,0 +1,96 @@ +import Completions from "../Completions"; +import CompletionType from "../../shared/CompletionType"; +import { + INIT_COMPLETIONS, + SET_COMPLETION_SOURCE, + SET_COMPLETIONS, + COMPLETION_NEXT, + COMPLETION_PREV, + CompletionAction, +} from "./actions"; + +export interface State { + completionTypes?: CompletionType[]; + completionSource: string; + completions: Completions; + select: number; +} + +export const defaultState = { + completionTypes: undefined, + completionSource: "", + completions: [], + select: -1, +}; + +const nextSelection = (state: State): number => { + const length = state.completions + .map((g) => g.items.length) + .reduce((x, y) => x + y, 0); + if (length === 0) { + return -1; + } + if (state.select < 0) { + return 0; + } + if (state.select + 1 < length) { + return state.select + 1; + } + return -1; +}; + +const prevSelection = (state: State): number => { + if (state.completions.length === 0) { + return -1; + } + const length = state.completions + .map((g) => g.items.length) + .reduce((x, y) => x + y); + if (state.select < 0) { + return length - 1; + } + return state.select - 1; +}; + +// eslint-disable-next-line max-lines-per-function +export default function reducer( + state: State = defaultState, + action: CompletionAction +): State { + switch (action.type) { + case INIT_COMPLETIONS: + return { + ...state, + completionTypes: action.completionTypes, + completions: [], + select: -1, + }; + case SET_COMPLETION_SOURCE: + return { + ...state, + completionSource: action.completionSource, + select: -1, + }; + case SET_COMPLETIONS: + return { + ...state, + completions: action.completions, + }; + case COMPLETION_NEXT: { + const select = nextSelection(state); + return { + ...state, + select: select, + }; + } + case COMPLETION_PREV: { + const select = prevSelection(state); + return { + ...state, + select: select, + }; + } + default: + return state; + } +} |