aboutsummaryrefslogtreecommitdiff
path: root/src/console
diff options
context:
space:
mode:
authorShin'ya Ueoka <ueokande@i-beam.org>2022-05-08 13:33:46 +0000
committerGitHub <noreply@github.com>2022-05-08 13:33:46 +0000
commit9279fff1351406a1f6c37c074cc1997e6a9e97e3 (patch)
treecc8e53f783efc5eb737af31bb9dd15c9e6474782 /src/console
parent4468afca7a8c9893f71f7e042b25f6c46ba49678 (diff)
parent2a6d6b0967c6f6e269c3eedf4bd6002aee26b9da (diff)
Merge pull request #1418 from ueokande/await-completions
Await fetching completions done completely
Diffstat (limited to 'src/console')
-rw-r--r--src/console/App.tsx51
-rw-r--r--src/console/Completions.ts6
-rw-r--r--src/console/completion/actions.ts16
-rw-r--r--src/console/completion/hooks.ts238
-rw-r--r--src/console/completion/hooks/clients.ts23
-rw-r--r--src/console/completion/reducer.ts8
-rw-r--r--src/console/components/CommandPrompt.tsx36
-rw-r--r--src/console/components/console/Completion.tsx8
-rw-r--r--src/console/components/console/CompletionItem.tsx14
-rw-r--r--src/console/hooks/useDebounce.ts19
-rw-r--r--src/console/index.tsx70
11 files changed, 249 insertions, 240 deletions
diff --git a/src/console/App.tsx b/src/console/App.tsx
new file mode 100644
index 0000000..6b45418
--- /dev/null
+++ b/src/console/App.tsx
@@ -0,0 +1,51 @@
+import * as messages from "../shared/messages";
+import Console from "./components/Console";
+import React from "react";
+import {
+ useCommandMode,
+ useFindMode,
+ useInfoMessage,
+ useErrorMessage,
+ useHide,
+} from "./app/hooks";
+
+const App: React.FC = () => {
+ const hide = useHide();
+ const { show: showCommand } = useCommandMode();
+ const { show: showFind } = useFindMode();
+ const { show: showError } = useErrorMessage();
+ const { show: showInfo } = useInfoMessage();
+
+ React.useEffect(() => {
+ const onMessage = async (message: any): Promise<any> => {
+ const msg = messages.valueOf(message);
+ switch (msg.type) {
+ case messages.CONSOLE_SHOW_COMMAND:
+ showCommand(msg.command);
+ break;
+ case messages.CONSOLE_SHOW_FIND:
+ showFind();
+ break;
+ case messages.CONSOLE_SHOW_ERROR:
+ showError(msg.text);
+ break;
+ case messages.CONSOLE_SHOW_INFO:
+ showInfo(msg.text);
+ break;
+ case messages.CONSOLE_HIDE:
+ hide();
+ break;
+ }
+ };
+
+ browser.runtime.onMessage.addListener(onMessage);
+ const port = browser.runtime.connect(undefined, {
+ name: "vimvixen-console",
+ });
+ port.onMessage.addListener(onMessage);
+ }, []);
+
+ return <Console />;
+};
+
+export default App;
diff --git a/src/console/Completions.ts b/src/console/Completions.ts
index a18f160..b04e480 100644
--- a/src/console/Completions.ts
+++ b/src/console/Completions.ts
@@ -1,9 +1,9 @@
type Completions = {
readonly name: string;
readonly items: {
- readonly caption?: string;
- readonly content?: string;
- readonly url?: string;
+ readonly primary?: string;
+ readonly secondary?: string;
+ readonly value?: string;
readonly icon?: string;
}[];
}[];
diff --git a/src/console/completion/actions.ts b/src/console/completion/actions.ts
index 59d1a04..0c5e1f1 100644
--- a/src/console/completion/actions.ts
+++ b/src/console/completion/actions.ts
@@ -1,17 +1,10 @@
-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;
@@ -31,20 +24,11 @@ export interface CompletionPrevAction {
}
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 => {
diff --git a/src/console/completion/hooks.ts b/src/console/completion/hooks.ts
index 4402b70..cc6cd30 100644
--- a/src/console/completion/hooks.ts
+++ b/src/console/completion/hooks.ts
@@ -11,6 +11,7 @@ import CommandLineParser, {
import { UnknownCommandError } from "../commandline/CommandParser";
import Completions from "../Completions";
import CompletionType from "../../shared/CompletionType";
+import { useGetCompletionTypes } from "./hooks/clients";
const commandDocs = {
[Command.Set]: "Set a value of the property",
@@ -35,48 +36,13 @@ const propertyDocs: { [key: string]: string } = {
const completionClient = new CompletionClient();
-const useDelayedCallback = <T extends unknown, U extends unknown>(
- callback: (arg1: T, arg2: U) => void,
- timeout: number
-) => {
- const [timer, setTimer] = React.useState<
- ReturnType<typeof setTimeout> | undefined
- >();
- const [enabled, setEnabled] = React.useState(false);
-
- const enableDelay = React.useCallback(() => {
- setEnabled(true);
- }, [setEnabled]);
-
- const delayedCallback = React.useCallback(
- (arg1: T, arg2: U) => {
- if (enabled) {
- if (typeof timer !== "undefined") {
- clearTimeout(timer);
- }
- const id = setTimeout(() => {
- callback(arg1, arg2);
- clearTimeout(timer!);
- setTimer(undefined);
- }, timeout);
- setTimer(id);
- } else {
- callback(arg1, arg2);
- }
- },
- [enabled, timer]
- );
-
- return { enableDelay, delayedCallback };
-};
-
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,
+ primary: name,
+ secondary: doc,
+ value: name,
}));
return [
{
@@ -102,8 +68,8 @@ const getOpenCompletions = async (
completions.push({
name: "Search Engines",
items: items.map((key) => ({
- caption: key.title,
- content: command + " " + key.title,
+ primary: key.title,
+ value: command + " " + key.title,
})),
});
break;
@@ -116,9 +82,9 @@ const getOpenCompletions = async (
completions.push({
name: "History",
items: items.map((item) => ({
- caption: item.title,
- content: command + " " + item.url,
- url: item.url,
+ primary: item.title,
+ secondary: item.url,
+ value: command + " " + item.url,
})),
});
break;
@@ -131,9 +97,9 @@ const getOpenCompletions = async (
completions.push({
name: "Bookmarks",
items: items.map((item) => ({
- caption: item.title,
- content: command + " " + item.url,
- url: item.url,
+ primary: item.title,
+ secondary: item.url,
+ value: command + " " + item.url,
})),
});
break;
@@ -157,11 +123,11 @@ export const getTabCompletions = async (
{
name: "Buffers",
items: items.map((item) => ({
- content: command + " " + item.url,
- caption: `${item.index}: ${
+ primary: `${item.index}: ${
item.flag != TabFlag.None ? item.flag : " "
} ${item.title}`,
- url: item.url,
+ secondary: item.url,
+ value: command + " " + item.url,
icon: item.faviconUrl,
})),
},
@@ -179,117 +145,117 @@ export const getPropertyCompletions = async (
if (item.type === "boolean") {
return [
{
- caption: item.name,
- content: command + " " + item.name,
- url: "Enable " + desc,
+ primary: item.name,
+ secondary: "Enable " + desc,
+ value: command + " " + item.name,
},
{
- caption: "no" + item.name,
- content: command + " no" + item.name,
- url: "Disable " + desc,
+ primary: "no" + item.name,
+ secondary: "Disable " + desc,
+ value: command + " no" + item.name,
},
];
} else {
return [
{
- caption: item.name,
- content: command + " " + item.name,
- url: "Set " + desc,
+ primary: item.name,
+ secondary: "Set " + desc,
+ value: command + " " + item.name,
},
];
}
})
.reduce((acc, val) => acc.concat(val), [])
- .filter((item) => item.caption.startsWith(query));
+ .filter((item) => item.primary.startsWith(query));
return [{ name: "Properties", items }];
};
-export const useCompletions = () => {
+export const useCompletions = (source: string) => {
const state = React.useContext(CompletionStateContext);
const dispatch = React.useContext(CompletionDispatchContext);
const commandLineParser = React.useMemo(() => new CommandLineParser(), []);
+ const [completionTypes] = useGetCompletionTypes();
+ const [loading, setLoading] = React.useState(false);
- 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));
- });
- }, []);
-
- const { delayedCallback: queryCompletions, enableDelay } = useDelayedCallback(
- React.useCallback(
- (text: string, completionTypes?: CompletionType[]) => {
- 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;
- }
+ const queryCompletions = React.useCallback(
+ (text: string, completionTypes: CompletionType[]) => {
+ 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 (!completionTypes) {
- initCompletion(text);
- return;
- }
+ }
- getOpenCompletions(cmd.command, cmd.args, 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;
- }
- enableDelay();
+ setLoading(true);
+ switch (cmd?.command) {
+ case Command.Open:
+ case Command.TabOpen:
+ case Command.WindowOpen:
+ getOpenCompletions(cmd.command, cmd.args, completionTypes).then(
+ (completions) => {
+ dispatch(actions.setCompletions(completions));
+ setLoading(false);
+ }
+ );
+ break;
+ case Command.Buffer:
+ getTabCompletions(cmd.command, cmd.args, false).then(
+ (completions) => {
+ dispatch(actions.setCompletions(completions));
+ setLoading(false);
+ }
+ );
+ break;
+ case Command.BufferDelete:
+ case Command.BuffersDelete:
+ getTabCompletions(cmd.command, cmd.args, true).then(
+ (completions) => {
+ dispatch(actions.setCompletions(completions));
+ setLoading(false);
+ }
+ );
+ break;
+ case Command.BufferDeleteForce:
+ case Command.BuffersDeleteForce:
+ getTabCompletions(cmd.command, cmd.args, false).then(
+ (completions) => {
+ dispatch(actions.setCompletions(completions));
+ setLoading(false);
+ }
+ );
+ break;
+ case Command.Set:
+ getPropertyCompletions(cmd.command, cmd.args).then(
+ (completions) => {
+ dispatch(actions.setCompletions(completions));
+ setLoading(false);
+ }
+ );
+ break;
}
- },
- [dispatch]
- ),
- 100
+ }
+ },
+ [dispatch, source]
);
React.useEffect(() => {
- queryCompletions(state.completionSource, state.completionTypes);
- }, [state.completionSource, state.completionTypes]);
+ dispatch(actions.setCompletionSource(source));
- return {
- completions: state.completions,
- updateCompletions,
- initCompletion,
- };
+ if (typeof completionTypes === "undefined") {
+ return;
+ }
+ queryCompletions(source, completionTypes);
+ }, [source, completionTypes]);
+
+ return { completions: state.completions, loading };
};
export const useSelectCompletion = () => {
@@ -308,8 +274,8 @@ export const useSelectCompletion = () => {
return state.completionSource;
}
const items = state.completions.map((g) => g.items).flat();
- return items[state.select]?.content || "";
- }, [state.completionSource, state.select]);
+ return items[state.select]?.value || "";
+ }, [state.completionSource, state.select, state.completions]);
return {
select: state.select,
diff --git a/src/console/completion/hooks/clients.ts b/src/console/completion/hooks/clients.ts
new file mode 100644
index 0000000..49deb0c
--- /dev/null
+++ b/src/console/completion/hooks/clients.ts
@@ -0,0 +1,23 @@
+import React from "react";
+import CompletionClient from "../../clients/CompletionClient";
+import CompletionType from "../../../shared/CompletionType";
+
+const completionClient = new CompletionClient();
+
+export const useGetCompletionTypes = (): [
+ CompletionType[] | undefined,
+ boolean
+] => {
+ type State = {
+ loading: boolean;
+ result?: CompletionType[];
+ };
+ const [state, setState] = React.useState<State>({ loading: true });
+
+ React.useEffect(() => {
+ completionClient.getCompletionTypes().then((result) => {
+ setState({ loading: false, result });
+ });
+ }, []);
+ return [state.result, state.loading];
+};
diff --git a/src/console/completion/reducer.ts b/src/console/completion/reducer.ts
index 905451f..0b34114 100644
--- a/src/console/completion/reducer.ts
+++ b/src/console/completion/reducer.ts
@@ -1,7 +1,6 @@
import Completions from "../Completions";
import CompletionType from "../../shared/CompletionType";
import {
- INIT_COMPLETIONS,
SET_COMPLETION_SOURCE,
SET_COMPLETIONS,
COMPLETION_NEXT,
@@ -58,13 +57,6 @@ export default function reducer(
action: CompletionAction
): State {
switch (action.type) {
- case INIT_COMPLETIONS:
- return {
- ...state,
- completionTypes: action.completionTypes,
- completions: [],
- select: -1,
- };
case SET_COMPLETION_SOURCE:
return {
...state,
diff --git a/src/console/components/CommandPrompt.tsx b/src/console/components/CommandPrompt.tsx
index 0e2506c..5d4cb6e 100644
--- a/src/console/components/CommandPrompt.tsx
+++ b/src/console/components/CommandPrompt.tsx
@@ -3,6 +3,7 @@ import Completion from "./console/Completion";
import Input from "./console//Input";
import styled from "styled-components";
import { useCompletions, useSelectCompletion } from "../completion/hooks";
+import useDebounce from "../hooks/useDebounce";
import useAutoResize from "../hooks/useAutoResize";
import { CompletionProvider } from "../completion/provider";
import { useExecCommand, useHide } from "../app/hooks";
@@ -17,14 +18,23 @@ interface Props {
initialInputValue: string;
}
+enum SelectQueueType {
+ SelectNext,
+ SelectPrev,
+}
+
const CommandPromptInner: React.FC<Props> = ({ initialInputValue }) => {
const hide = useHide();
const [inputValue, setInputValue] = React.useState(initialInputValue);
- const { completions, updateCompletions } = useCompletions();
+ const debouncedValue = useDebounce(inputValue, 100);
+ const { completions, loading } = useCompletions(debouncedValue);
const { select, currentValue, selectNext, selectPrev } =
useSelectCompletion();
const execCommand = useExecCommand();
+ // The value is set after the user presses Tab (or Shift+Tab) key and waiting the completion
+ const [selecting, setSelecting] = React.useState<SelectQueueType>();
+
useAutoResize();
const onBlur = () => {
@@ -65,9 +75,9 @@ const CommandPromptInner: React.FC<Props> = ({ initialInputValue }) => {
execCommand(value);
hide();
} else if (isNextKey(e)) {
- selectNext();
+ setSelecting(SelectQueueType.SelectNext);
} else if (isPrevKey(e)) {
- selectPrev();
+ setSelecting(SelectQueueType.SelectPrev);
} else {
return;
}
@@ -76,15 +86,25 @@ const CommandPromptInner: React.FC<Props> = ({ initialInputValue }) => {
e.preventDefault();
};
+ React.useEffect(() => {
+ if (inputValue !== debouncedValue || loading) {
+ // The completions of the latest input value are not fetched
+ return;
+ }
+ if (selecting === SelectQueueType.SelectNext) {
+ selectNext();
+ setSelecting(undefined);
+ } else if (selecting === SelectQueueType.SelectPrev) {
+ selectPrev();
+ setSelecting(undefined);
+ }
+ }, [inputValue, debouncedValue, selecting, loading]);
+
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value;
setInputValue(text);
};
- React.useEffect(() => {
- updateCompletions(inputValue);
- }, [inputValue]);
-
return (
<ConsoleWrapper>
<Completion
@@ -97,7 +117,7 @@ const CommandPromptInner: React.FC<Props> = ({ initialInputValue }) => {
onBlur={onBlur}
onKeyDown={onKeyDown}
onChange={onChange}
- value={select == -1 ? inputValue : currentValue}
+ value={select == -1 || loading ? inputValue : currentValue}
/>
</ConsoleWrapper>
);
diff --git a/src/console/components/console/Completion.tsx b/src/console/components/console/Completion.tsx
index ed271aa..6a58a40 100644
--- a/src/console/components/console/Completion.tsx
+++ b/src/console/components/console/Completion.tsx
@@ -4,8 +4,8 @@ import CompletionTitle from "./CompletionTitle";
interface Item {
icon?: string;
- caption?: string;
- url?: string;
+ primary?: string;
+ secondary?: string;
}
interface Group {
@@ -75,8 +75,8 @@ const Completion: React.FC<Props> = ({ select, size, completions }) => {
shown={viewOffset <= viewIndex && viewIndex < viewOffset + size}
key={`item-${itemIndex}`}
icon={item.icon}
- caption={item.caption}
- url={item.url}
+ primary={item.primary}
+ secondary={item.secondary}
highlight={itemIndex === select}
aria-selected={itemIndex === select}
role="menuitem"
diff --git a/src/console/components/console/CompletionItem.tsx b/src/console/components/console/CompletionItem.tsx
index 2de1375..394af04 100644
--- a/src/console/components/console/CompletionItem.tsx
+++ b/src/console/components/console/CompletionItem.tsx
@@ -23,14 +23,14 @@ const Container = styled.li<{
white-space: pre;
`;
-const Caption = styled.span`
+const Primary = styled.span`
display: inline-block;
width: 40%;
text-overflow: ellipsis;
overflow: hidden;
`;
-const Description = styled.span`
+const Secondary = styled.span`
display: inline-block;
color: ${({ theme }) => theme.completionItemDescriptionForeground};
width: 60%;
@@ -41,19 +41,19 @@ const Description = styled.span`
interface Props extends React.HTMLAttributes<HTMLElement> {
shown: boolean;
highlight: boolean;
- caption?: string;
- url?: string;
+ primary?: string;
+ secondary?: string;
icon?: string;
}
const CompletionItem: React.FC<Props> = (props) => (
<Container
icon={props.icon || ""}
- aria-labelledby={`completion-item-${props.caption}`}
+ aria-labelledby={`completion-item-${props.primary}`}
{...props}
>
- <Caption id={`completion-item-${props.caption}`}>{props.caption}</Caption>
- <Description>{props.url}</Description>
+ <Primary id={`completion-item-${props.primary}`}>{props.primary}</Primary>
+ <Secondary>{props.secondary}</Secondary>
</Container>
);
diff --git a/src/console/hooks/useDebounce.ts b/src/console/hooks/useDebounce.ts
new file mode 100644
index 0000000..838ff34
--- /dev/null
+++ b/src/console/hooks/useDebounce.ts
@@ -0,0 +1,19 @@
+import React from "react";
+
+const useDebounce = <T extends unknown>(value: T, delay: number) => {
+ const [debouncedValue, setDebouncedValue] = React.useState(value);
+
+ React.useEffect(() => {
+ const timer = setTimeout(() => {
+ setDebouncedValue(value);
+ }, delay);
+
+ return () => {
+ clearTimeout(timer);
+ };
+ }, [value, delay]);
+
+ return debouncedValue;
+};
+
+export default useDebounce;
diff --git a/src/console/index.tsx b/src/console/index.tsx
index 29fa11f..5b97917 100644
--- a/src/console/index.tsx
+++ b/src/console/index.tsx
@@ -1,66 +1,20 @@
-import * as messages from "../shared/messages";
-import Console from "./components/Console";
-import "./index.css";
import React from "react";
import ReactDOM from "react-dom";
import ColorSchemeProvider from "./colorscheme/providers";
import { AppProvider } from "./app/provider";
-import {
- useCommandMode,
- useFindMode,
- useInfoMessage,
- useErrorMessage,
- useHide,
-} from "./app/hooks";
-
-const RootComponent: React.FC = () => {
- const hide = useHide();
- const { show: showCommand } = useCommandMode();
- const { show: showFind } = useFindMode();
- const { show: showError } = useErrorMessage();
- const { show: showInfo } = useInfoMessage();
-
- React.useEffect(() => {
- const onMessage = async (message: any): Promise<any> => {
- const msg = messages.valueOf(message);
- switch (msg.type) {
- case messages.CONSOLE_SHOW_COMMAND:
- showCommand(msg.command);
- break;
- case messages.CONSOLE_SHOW_FIND:
- showFind();
- break;
- case messages.CONSOLE_SHOW_ERROR:
- showError(msg.text);
- break;
- case messages.CONSOLE_SHOW_INFO:
- showInfo(msg.text);
- break;
- case messages.CONSOLE_HIDE:
- hide();
- break;
- }
- };
-
- browser.runtime.onMessage.addListener(onMessage);
- const port = browser.runtime.connect(undefined, {
- name: "vimvixen-console",
- });
- port.onMessage.addListener(onMessage);
- }, []);
-
- return <Console />;
-};
-
-const App: React.FC = () => (
- <AppProvider>
- <ColorSchemeProvider>
- <RootComponent />
- </ColorSchemeProvider>
- </AppProvider>
-);
+import App from "./App";
+import "./index.css";
window.addEventListener("DOMContentLoaded", () => {
const wrapper = document.getElementById("vimvixen-console");
- ReactDOM.render(<App />, wrapper);
+ ReactDOM.render(
+ <React.StrictMode>
+ <AppProvider>
+ <ColorSchemeProvider>
+ <App />
+ </ColorSchemeProvider>
+ </AppProvider>
+ </React.StrictMode>,
+ wrapper
+ );
});