diff --git a/index.html b/index.html index ca782f7..2d54d4f 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - +
diff --git a/src/app/App.tsx b/src/app/App.tsx index a8bf6d8..8d2635d 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,76 +1,116 @@ -import { useEffect, useState } from "react"; -import { getWorkingDirectory } from "../support/nana"; +import { useEffect, useRef, useState } from "react"; + +import { Prompt } from "./Prompt"; +import { IResult } from "./Result"; -import { Card, ICard, CardPropTypes } from "./Card"; +import { ansiFormat } from "../support/formatting"; +import { getWorkingDirectory } from "../support/nana"; +import { ResultList } from "./ResultList"; export default () => { + const promptRef = useRef(null); const [history, setHistory] = useState([]); - const [cards, setCards] = useState([]); + const [activeHistoryIndex, setActiveHistoryIndex] = useState(-1); + const [input, setInput] = useState(""); + const [results, setResults] = useState([]); + + const [workingDir, setWorkingDir] = useState(""); + + const resetActiveHistoryIndex = () => { + setActiveHistoryIndex(history.length); + }; + + // reset the active history index when the history changes + useEffect(resetActiveHistoryIndex, [history.length]); + // auto-scroll to the prompt whenever a new result is added/removed useEffect(() => { - if (cards.length === 0) addEmptyCard(); - }, [cards.length]); + promptRef.current?.scrollIntoView({ block: "start" }); + }, [results.length]); - const addEmptyCard = async () => { - addCard({ - workingDir: await getWorkingDirectory(), - }); + // get working directory/bind global key presses on mount + useEffect(() => { + refreshWorkingDir(); + window.addEventListener("keydown", handleGlobalKeyDown); + () => { + window.removeEventListener("keydown", handleGlobalKeyDown); + }; + }, []); + + const handleGlobalKeyDown = (e: KeyboardEvent) => { + if (e.ctrlKey && e.key == "l") { + // ctrl + l + e.preventDefault(); + setResults([]); + } }; - const addCard = (props: CardPropTypes) => { - setCards((cards) => [...cards, { id: cards.length, ...props }]); + const refreshWorkingDir = async () => { + setWorkingDir(await getWorkingDirectory()); }; - const addToHistory = (input?: string) => { - if (!input) return; + const handleResult = async (output: string, duration: number) => { if (history.indexOf(input) === -1) { - setHistory((history) => [...history, input]); + setHistory((history) => [...history.slice(-25), input]); } - }; - const removeCard = (cardId: number) => { - setCards((cards) => cards.filter(({ id }) => id !== cardId)); + setResults((results) => [ + ...results.slice(-10), + { + id: results.length, + workingDir, + input, + duration, + output: JSON.parse(output), + }, + ]); + refreshWorkingDir(); + resetActiveHistoryIndex(); + setInput(""); }; - const updateCard = (cardId: number, props: CardPropTypes) => { - return setCards((cards) => - cards.map((orig) => { - if (orig.id === cardId) { - return { ...orig, ...props }; - } - return orig; - }) - ); + const handleError = async (output: string) => { + setResults((results) => [ + ...results, + { + id: results.length, + workingDir, + input, + output: ansiFormat(output), + }, + ]); + refreshWorkingDir(); + setInput(""); }; - const handleSubmit = async ( - cardId: number, - props: CardPropTypes, - isError: boolean - ) => { - const card = cards.find(({ id }) => id === cardId); - const alreadySubmitted = card && card.input !== undefined; - updateCard(cardId, props); - if (!isError && !alreadySubmitted) addEmptyCard(); - - addToHistory(props.input); + const handleHistory = (delta: number) => { + const index = activeHistoryIndex + delta; + if (index >= 0 && index < history.length) { + setInput(history[index]); + setActiveHistoryIndex(index); + } }; return ( -
- {cards.map((card) => ( - { - removeCard(card.id); - }} - onSubmit={(props: CardPropTypes, isError) => { - handleSubmit(card.id, props, isError); - }} - /> - ))} +
+ + + { + handleHistory(1); + }} + onHistoryDown={() => { + handleHistory(-1); + }} + onChangeInput={(value) => { + setInput(value); + }} + />
); }; diff --git a/src/app/Card.tsx b/src/app/Card.tsx deleted file mode 100644 index 5b4e464..0000000 --- a/src/app/Card.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { useEffect, useState } from "react"; -import { Output } from "./Output"; -import { Prompt } from "./Prompt"; -import { getWorkingDirectory } from "../support/nana"; -import { ansiFormat } from "../support/formatting"; -import { FaTimes } from "react-icons/fa"; - -export type CardPropTypes = { - workingDir?: string; - input?: string; - output?: string; -}; - -export type ICard = CardPropTypes & { - id: number; -}; - -export const Card = ({ - input: initialInput, - workingDir, - history, - output, - onClose, - onSubmit, -}: ICard & { - history: string[]; - onSubmit: (newProps: Partial, isError: boolean) => void; - onClose: () => void; -}) => { - const [input, setInput] = useState(initialInput); - const [activeHistoryIndex, setActiveHistoryIndex] = useState(-1); - - const resetActiveHistoryIndex = () => { - setActiveHistoryIndex(history.length); - }; - - useEffect(resetActiveHistoryIndex, [history.length]); - - const handleSubmit = async (out: string) => { - onSubmit( - { - input, - workingDir: await getWorkingDirectory(), - output: JSON.parse(out), - }, - false - ); - resetActiveHistoryIndex(); - }; - - const handleError = async (output: string) => { - onSubmit( - { - input, - workingDir: await getWorkingDirectory(), - output: ansiFormat(output), - }, - true - ); - }; - - const handleHistory = (delta: number) => { - const index = activeHistoryIndex + delta; - if (index >= 0 && index < history.length) { - setInput(history[index]); - setActiveHistoryIndex(index); - } - }; - - return ( -
-
- {workingDir} -   - - - -
- -
- - - {output !== undefined && ( -
- -
- )} -
-
- ); -}; diff --git a/src/app/CompletionList.tsx b/src/app/CompletionList.tsx new file mode 100644 index 0000000..7303a4c --- /dev/null +++ b/src/app/CompletionList.tsx @@ -0,0 +1,72 @@ +import classNames from "classnames"; +import { useEffect, useRef } from "react"; + +export type ICompletion = { + completion: string; + start: number; +}; + +const CompletionItem = ({ + active, + completion, + ...props +}: { + active: boolean; + completion: ICompletion; +}) => { + const ref = useRef(null); + + useEffect(() => { + if (active) { + ref.current?.scrollIntoView({ + block: "nearest", + // behavior: "smooth", + }); + } + }, [active]); + + return ( +
  • + {completion.completion} +
  • + ); +}; + +export const CompletionList = ({ + completions, + activeIndex, + positionUp, +}: { + activeIndex: number; + completions: ICompletion[]; + positionUp: boolean; +}) => { + const ref = useRef(null); + + return ( +
      + {completions.map((completion, index) => ( + + ))} +
    + ); +}; diff --git a/src/app/Output.tsx b/src/app/Output.tsx index f56fa45..36da1c1 100644 --- a/src/app/Output.tsx +++ b/src/app/Output.tsx @@ -1,9 +1,34 @@ +import classNames from "classnames"; +import { HTMLAttributes } from "react"; import { humanDuration, humanFileSize, UInt8ArrayToString, } from "../support/formatting"; +const defaultCellClassName = + "default:border default:border-neutral-600 default:p-2"; + +const Table = ({ className, ...props }: HTMLAttributes) => ( + +); + +const Row = ({ className, ...props }: HTMLAttributes) => ( + +); + const Image = ({ value, type }: { value: any; type: string }) => ( { } }; +const Pre = ({ + children, + className, + ...props +}: HTMLAttributes): any => { + if (typeof children === "string") { + if (children.split("\n").length == 1) { + return children; + } + } + return ( +
    +            {children}
    +        
    + ); +}; + const Record = ({ value: { cols, vals } }: { value: any }) => ( -
    +
    {vals.map((val: any, i: number) => ( - - - + - + ))} -
    {cols[i]} + + {cols[i]}
    + ); const List = ({ value: { vals } }: { value: any }): any => { @@ -41,30 +89,42 @@ const List = ({ value: { vals } }: { value: any }): any => { const isRecordList = vals.every((v: any) => v.Record); const cols = isRecordList ? vals[0].Record.cols : []; return ( - +
    - + {cols.map((col: string, i: number) => ( - + ))} - + + {vals.map((value: any, i: number) => ( - + {isRecordList ? ( value.Record.vals.map((v: any, j: number) => ( - )) ) : ( - )} - + ))} -
    {col} + {col} +
    + +
    + ); } @@ -81,7 +141,7 @@ export const Output = ({ value }: { value: any }): any => { } else if (value.Bool) { return value.Bool.val.toString(); } else if (value.String) { - return value.String.val ?
    {value.String.val}
    : <> ; + return value.String.val ?
    {value.String.val}
    : <> ; } else if (value.Filesize) { return humanFileSize(value.Filesize.val); } else if (value.Duration) { @@ -97,5 +157,5 @@ export const Output = ({ value }: { value: any }): any => { } // todo: use rehype-sanitize - return
    ;
    +    return 
    ;
     };
    diff --git a/src/app/Prompt.tsx b/src/app/Prompt.tsx
    index d81f2eb..8dc9662 100644
    --- a/src/app/Prompt.tsx
    +++ b/src/app/Prompt.tsx
    @@ -1,105 +1,211 @@
    -import { KeyboardEvent, useRef } from "react";
    +import { forwardRef, KeyboardEvent, useRef, useState } from "react";
     import { complete, simpleCommandWithResult } from "../support/nana";
    -
    -export type ICompletion = {
    -    completion: string;
    -    start: number;
    -};
    +import { CompletionList, ICompletion } from "./CompletionList";
    +import { Spinner } from "./Spinner";
     
     type PromptPropType = {
         input: string;
    +    workingDir: string | null;
         onChangeInput: (input: string) => void;
    -    onSubmit: (output: string) => void;
    +    onSubmit: (output: string, duration: number) => void;
         onSubmitError: (output: string) => void;
         onHistoryUp: () => void;
         onHistoryDown: () => void;
     };
     
    -export const Prompt = ({
    -    input,
    -    onChangeInput,
    -    onSubmit,
    -    onSubmitError,
    -    onHistoryUp,
    -    onHistoryDown,
    -}: PromptPropType) => {
    -    const inputRef = useRef(null);
    -
    -    const applyCompletion = (
    -        completion: ICompletion,
    -        selectionStart: number
    +export const Prompt = forwardRef(
    +    (
    +        {
    +            input,
    +            workingDir,
    +            onChangeInput: onChange,
    +            onSubmit,
    +            onSubmitError,
    +            onHistoryUp,
    +            onHistoryDown,
    +        }: PromptPropType,
    +        ref
         ) => {
    -        const before = input.slice(0, completion.start);
    -        const after = input.slice(
    -            completion.start + selectionStart - completion.start
    -        );
    -        return before + completion.completion + after;
    -    };
    +        const inputRef = useRef(null);
    +        const [isLoading, setLoading] = useState(false);
    +        const [completions, setCompletions] = useState([]);
    +        const [activeCompletionIndex, setActiveCompletionIndex] = useState(0);
     
    -    const handleChange = (input: string) => {
    -        onChangeInput(input);
    -    };
    +        const resetCompletions = () => {
    +            setCompletions([]);
    +            setActiveCompletionIndex(0);
    +        };
     
    -    const handleCompletion = async (position: number) => {
    -        const results: ICompletion[] = await complete({
    -            argument: input,
    -            position: position,
    -        });
    +        const applyCompletion = (
    +            completion: ICompletion,
    +            selectionStart: number
    +        ) => {
    +            const before = input.slice(0, completion.start);
    +            const after = input.slice(
    +                completion.start + selectionStart - completion.start
    +            );
    +            return before + completion.completion + after;
    +        };
     
    -        if (results.length > 0) {
    -            handleChange(applyCompletion(results[0], position));
    -        }
    -    };
    -
    -    const handleSubmit = async () => {
    -        if (input.length > 0) {
    -            try {
    -                onSubmit(await simpleCommandWithResult(input));
    -            } catch (err) {
    -                onSubmitError(err as string);
    -            } finally {
    +        const handleChange = (input: string) => {
    +            resetCompletions();
    +            onChange(input);
    +        };
    +
    +        const handleCompletion = async (position: number) => {
    +            const results: ICompletion[] = await complete({
    +                argument: input,
    +                position: position,
    +            });
    +
    +            if (results.length == 1) {
    +                handleChange(applyCompletion(results[0], position));
    +            } else if (results.length > 1) {
    +                setCompletions(results);
                 }
    -        }
    -    };
    -
    -    const handleKeyDown = (e: KeyboardEvent) => {
    -        switch (e.key) {
    -            case "ArrowUp":
    -                e.preventDefault();
    -                onHistoryUp();
    -                break;
    -            case "ArrowDown":
    -                e.preventDefault();
    -                onHistoryDown();
    -                break;
    -            case "Enter":
    -                e.preventDefault();
    -                handleSubmit();
    -                break;
    -            case "Tab":
    -                if (
    -                    e.target instanceof HTMLInputElement &&
    -                    e.target.selectionStart !== null
    -                ) {
    -                    e.preventDefault();
    -                    handleCompletion(e.target.selectionStart);
    +        };
    +
    +        const handleSubmit = async () => {
    +            if (input.length > 0) {
    +                const timeout = setTimeout(() => {
    +                    setLoading(true);
    +                }, 200);
    +                try {
    +                    const startTime = Date.now();
    +                    const response: string = await simpleCommandWithResult(
    +                        input
    +                    );
    +                    onSubmit(response, Date.now() - startTime);
    +                } catch (err) {
    +                    onSubmitError(err as string);
    +                } finally {
    +                    clearTimeout(timeout);
    +                    setLoading(false);
    +                }
    +            }
    +        };
    +
    +        const handleAcceptSuggestion = (index: number) => {
    +            if (index >= 0 && index < completions.length) {
    +                return handleAcceptCompletion(completions[index]);
    +            }
    +        };
    +
    +        const handlePrevCompletion = () => {
    +            if (activeCompletionIndex > 0) {
    +                setActiveCompletionIndex(activeCompletionIndex - 1);
    +            } else {
    +                setActiveCompletionIndex(completions.length - 1);
    +            }
    +        };
    +
    +        const handleNextCompletion = () => {
    +            if (activeCompletionIndex < completions.length - 1) {
    +                setActiveCompletionIndex(activeCompletionIndex + 1);
    +            } else {
    +                setActiveCompletionIndex(0);
    +            }
    +        };
    +
    +        const handleAcceptCompletion = (completion: ICompletion) => {
    +            if (inputRef.current && inputRef.current.selectionStart)
    +                handleChange(
    +                    applyCompletion(completion, inputRef.current.selectionStart)
    +                );
    +        };
    +
    +        const handleDismissCompletion = () => {
    +            resetCompletions();
    +        };
    +
    +        const handleKeyDown = (e: KeyboardEvent) => {
    +            if (completions.length > 0) {
    +                switch (e.key) {
    +                    case "ArrowUp":
    +                        e.preventDefault();
    +                        handlePrevCompletion();
    +                        break;
    +                    case "ArrowDown":
    +                    case "Tab":
    +                        e.preventDefault();
    +                        handleNextCompletion();
    +                        break;
    +                    case "Enter":
    +                        e.preventDefault();
    +                        handleAcceptSuggestion(activeCompletionIndex);
    +                        break;
    +                    case "Escape":
    +                        e.preventDefault();
    +                        handleDismissCompletion();
    +                        break;
                     }
    -                break;
    +
    +                return;
    +            }
    +
    +            switch (e.key) {
    +                case "ArrowUp":
    +                    e.preventDefault();
    +                    onHistoryUp();
    +                    break;
    +                case "ArrowDown":
    +                    e.preventDefault();
    +                    onHistoryDown();
    +                    break;
    +                case "Enter":
    +                    e.preventDefault();
    +                    handleSubmit();
    +                    break;
    +                case "Tab":
    +                    if (
    +                        e.target instanceof HTMLInputElement &&
    +                        e.target.selectionStart !== null
    +                    ) {
    +                        e.preventDefault();
    +                        handleCompletion(e.target.selectionStart);
    +                    }
    +                    break;
    +            }
    +        };
    +
    +        let hasTopCleareance = false;
    +
    +        // todo: use popper.js
    +        if (inputRef.current) {
    +            const rect = inputRef.current.getBoundingClientRect();
    +            hasTopCleareance = rect.top >= 100;
             }
    -    };
    -
    -    return (
    -         {
    -                handleChange(e.target.value);
    -            }}
    -        />
    -    );
    -};
    +
    +        return (
    +            
    +
    + {workingDir} +
    +
    +
    + {isLoading && } +
    + { + handleChange(e.target.value); + }} + /> + {completions.length > 0 && ( + + )} +
    +
    + ); + } +); diff --git a/src/app/Result.tsx b/src/app/Result.tsx new file mode 100644 index 0000000..30b6400 --- /dev/null +++ b/src/app/Result.tsx @@ -0,0 +1,54 @@ +import classNames from "classnames"; +import { HTMLAttributes } from "react"; +import { humanDuration } from "../support/formatting"; +import { Output } from "./Output"; + +export type IResult = { + id: number; + workingDir: string; + input: string; + output?: string; + duration?: number; +}; + +const Indicator = ({ className }: HTMLAttributes) => ( + +); + +export const Result = ({ + workingDir, + input, + output, + duration, +}: Omit, "id"> & IResult) => ( +
    +
    +
    + +
    + {input} + {duration ? ( + + {humanDuration(duration)} + + ) : null} +
    +
    + +
    + {workingDir} +
    +
    + +
    +); diff --git a/src/app/ResultList.tsx b/src/app/ResultList.tsx new file mode 100644 index 0000000..e730421 --- /dev/null +++ b/src/app/ResultList.tsx @@ -0,0 +1,13 @@ +import { IResult, Result } from "./Result"; + +export const ResultList = ({ results }: { results: IResult[] }) => { + if (results.length === 0) return null; + + return ( +
    + {results.map(({ ...props }) => ( + + ))} +
    + ); +}; diff --git a/src/app/Spinner.tsx b/src/app/Spinner.tsx new file mode 100644 index 0000000..9c3a812 --- /dev/null +++ b/src/app/Spinner.tsx @@ -0,0 +1,7 @@ +import classNames from "classnames"; +import { IconBaseProps } from "react-icons"; +import { CgSpinner } from "react-icons/cg"; + +export const Spinner = ({ className }: IconBaseProps) => ( + +); diff --git a/src/main.css b/src/main.css index 9fa5a8a..c06af22 100644 --- a/src/main.css +++ b/src/main.css @@ -2,87 +2,6 @@ @tailwind components; @tailwind utilities; -html, -body { - position: relative; - width: 100%; - height: 100%; -} - -body { - color: #333; - margin: 0; - padding: 8px; - box-sizing: border-box; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, - Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; -} - -a { - color: rgb(0, 100, 200); - text-decoration: none; -} - -a:hover { - text-decoration: underline; -} - -a:visited { - color: rgb(0, 80, 160); -} - -label { - display: block; -} - -input, -button, -select, -textarea { - font-family: inherit; - font-size: inherit; - -webkit-padding: 0.4em 0; - padding: 0.4em; - margin: 0 0 0.5em 0; - box-sizing: border-box; - border: 1px solid #ccc; - border-radius: 2px; -} - -input:disabled { - color: #ccc; -} - -button { - color: #333; - background-color: #f4f4f4; - outline: none; -} - -button:disabled { - color: #999; -} - -button:not(:disabled):active { - background-color: #ddd; -} - -button:focus { - border-color: #666; -} - -table.styled-table { - @apply min-w-full max-w-full; -} - -.styled-table tbody tr { - @apply text-solarized-base03 dark:text-solarized-base3; - @apply odd:bg-solarized-base3 even:bg-solarized-base2; - @apply dark:odd:bg-solarized-base03 dark:even:bg-solarized-base02; - @apply border-b border-solarized-base1 last:border-b-0 dark:border-solarized-base01; -} - -.styled-table th, -.styled-table td { - @apply border-collapse border-r border-solarized-base1 pl-1 last:border-r-0 dark:border-solarized-base01; +html { + font-size: 13px; } diff --git a/src/support/nana.ts b/src/support/nana.ts index dc36441..fe67171 100644 --- a/src/support/nana.ts +++ b/src/support/nana.ts @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/tauri"; -import { ICompletion } from "../app/Prompt"; +import { ICompletion } from "../app/CompletionList"; export function simpleCommandWithResult(argument: string): Promise { return invoke("simple_command_with_result", { diff --git a/tailwind.config.js b/tailwind.config.js index 482add7..0482c54 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -26,5 +26,9 @@ module.exports = { }, }, }, - plugins: [], + plugins: [ + ({ addVariant }) => { + addVariant("default", "html :where(&)"); + }, + ], };