diff --git a/.eslintrc.js b/.eslintrc.js index 3f5ea2f3..88caf32e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -3,6 +3,7 @@ module.exports = { extends: [ 'eslint:recommended', 'plugin:react/recommended', + 'plugin:react-hooks/recommended', 'plugin:@typescript-eslint/eslint-recommended', 'plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended', @@ -25,6 +26,7 @@ module.exports = { 'no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 'sort-imports': ['error', { ignoreDeclarationSort: true }], 'import/order': ['error', { alphabetize: { order: 'asc' } }], + 'react-hooks/exhaustive-deps': 'error', }, settings: { react: { version: 'detect' }, diff --git a/src/activities/hooks.ts b/src/activities/hooks.ts index 955e4256..ff2cec68 100644 --- a/src/activities/hooks.ts +++ b/src/activities/hooks.ts @@ -39,7 +39,7 @@ export function useActivitiesSelectedActivity(): ReturnType< useEffect(() => { setLastSelectedActivity(selectedActivity); - }, [selectedActivity]); + }, [selectedActivity, setLastSelectedActivity]); return [selectedActivity, setSelectedActivity]; } diff --git a/src/app/hooks.ts b/src/app/hooks.ts index 34ecdc7a..faddd2da 100644 --- a/src/app/hooks.ts +++ b/src/app/hooks.ts @@ -27,25 +27,29 @@ export function useAppLastDocsPageSetting() { // mirror session storage value to local storage useEffect(() => { setLastPageGlobalSetting(lastPageSessionSetting); - }, [lastPageSessionSetting]); + }, [lastPageSessionSetting, setLastPageGlobalSetting]); // the way the docs control works, we only provide the initial page, then // it manages navigation after that, so we only want the initial of this // value when the app first starts - const initialDocsPage = useMemo(() => { - try { - const url = new URL(lastPageSessionSetting); + const initialDocsPage = useMemo( + () => { + try { + const url = new URL(lastPageSessionSetting); - // in case someone is hacking the storage value directly - if (!url.pathname.startsWith(`/${docsPathPrefix}`)) { - throw new Error('invalid or outdated path'); + // in case someone is hacking the storage value directly + if (!url.pathname.startsWith(`/${docsPathPrefix}`)) { + throw new Error('invalid or outdated path'); + } + + return lastPageSessionSetting; + } catch { + return defaultPage; } - - return lastPageSessionSetting; - } catch { - return defaultPage; - } - }, []); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [], // no deps so that we only get the initial value + ); return { initialDocsPage, setLastDocsPage: setLastPageSessionSetting }; } diff --git a/src/components/toolbar/aria.ts b/src/components/toolbar/aria.ts index ed89336a..baf1e84f 100644 --- a/src/components/toolbar/aria.ts +++ b/src/components/toolbar/aria.ts @@ -39,7 +39,7 @@ type ToolbarItemFocusAria = { */ export function useToolbarItemFocus(props: { id: string }): ToolbarItemFocusAria { const { id } = props; - const state = useContext(ToolbarStateContext); + const { lastFocusedItem, setLastFocusedItem } = useContext(ToolbarStateContext); const focusManager = useFocusManager(); const onKeyDown = useCallback>( @@ -66,11 +66,12 @@ export function useToolbarItemFocus(props: { id: string }): ToolbarItemFocusAria const onFocus = useCallback>( (e) => { - state.setLastFocusedItem(e.target.id); + setLastFocusedItem(e.target.id); }, - [state.setLastFocusedItem], + [setLastFocusedItem], ); - const excludeFromTabOrder = id !== state.lastFocusedItem; + + const excludeFromTabOrder = id !== lastFocusedItem; return { toolbarItemFocusProps: { onKeyDown, onFocus }, excludeFromTabOrder }; } diff --git a/src/editor/Editor.tsx b/src/editor/Editor.tsx index 0e33879d..27fb0aca 100644 --- a/src/editor/Editor.tsx +++ b/src/editor/Editor.tsx @@ -343,7 +343,8 @@ function useEditor( } return callback(maybeEditor); - }, [maybeEditor, ...deps]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [maybeEditor, callback, ...deps]); } /** diff --git a/src/editor/Welcome.tsx b/src/editor/Welcome.tsx index 6915a215..6ea8be9a 100644 --- a/src/editor/Welcome.tsx +++ b/src/editor/Welcome.tsx @@ -81,13 +81,15 @@ const Welcome: React.VoidFunctionComponent = ({ isVisible }) => { return; } + const element = elementRef.current; + // istanbul ignore if: should not happen - if (!elementRef.current) { - console.error('elementRef was null!'); + if (!element) { + console.error('elementRef.current was null!'); return; } - const two = new Two({ fitted: true }).appendTo(elementRef.current); + const two = new Two({ fitted: true }).appendTo(element); const logo = two.load(logoSvg, (g) => { g.center(); @@ -113,7 +115,7 @@ const Welcome: React.VoidFunctionComponent = ({ isVisible }) => { two.fit(); }); - observer.observe(elementRef.current); + observer.observe(element); const handleClick = (e: Event) => { e.stopPropagation(); @@ -149,7 +151,7 @@ const Welcome: React.VoidFunctionComponent = ({ isVisible }) => { two.renderer.domElement.removeEventListener('pointerdown', handleClick); observer.disconnect(); two.removeEventListener('update'); - elementRef.current?.removeChild(two.renderer.domElement); + element.removeChild(two.renderer.domElement); two.clear(); }; }, [isVisible]); diff --git a/src/explorer/Explorer.tsx b/src/explorer/Explorer.tsx index 405b9195..781abb7f 100644 --- a/src/explorer/Explorer.tsx +++ b/src/explorer/Explorer.tsx @@ -36,7 +36,12 @@ import { useToolbarItemFocus } from '../components/toolbar/aria'; import { UUID } from '../fileStorage'; import { useFileStorageMetadata } from '../fileStorage/hooks'; import { isMacOS } from '../utils/os'; -import { TreeItemContext, TreeItemData, renderers } from '../utils/tree-renderer'; +import { + RenderProps, + TreeItemContext, + TreeItemData, + renderers, +} from '../utils/tree-renderer'; import { explorerArchiveAllFiles, explorerCreateNewFile, @@ -257,7 +262,9 @@ function useLiveDescriptors(): LiveDescriptors { * REVISIT: maybe there will be a better way to do this some day: * https://github.com/lukasbach/react-complex-tree/issues/47 */ -const renderTreeContainer: typeof renderers.renderTreeContainer = (props) => { +const TreeContainer: React.VoidFunctionComponent> = ( + props, +) => { const dispatch = useDispatch(); const { treeId } = useTree(); const environment = useTreeEnvironment(); @@ -272,28 +279,28 @@ const renderTreeContainer: typeof renderers.renderTreeContainer = (props) => { const fileName = environment.getItemTitle(environment.items[focusedItem]); dispatch(explorerRenameFile(fileName)); } - }, [environment]); + }, [focusedItem, environment, dispatch]); const handleDuplicateKeyDown = useCallback(() => { if (focusedItem !== undefined) { const fileName = environment.getItemTitle(environment.items[focusedItem]); dispatch(explorerDuplicateFile(fileName)); } - }, [environment]); + }, [focusedItem, environment, dispatch]); const handleDeleteKeyDown = useCallback(() => { if (focusedItem !== undefined) { const fileName = environment.getItemTitle(environment.items[focusedItem]); dispatch(explorerDeleteFile(fileName, focusedItem as UUID)); } - }, [environment]); + }, [focusedItem, environment, dispatch]); const handleExportKeyDown = useCallback(() => { if (focusedItem !== undefined) { const fileName = environment.getItemTitle(environment.items[focusedItem]); dispatch(explorerExportFile(fileName)); } - }, [environment]); + }, [focusedItem, environment, dispatch]); const hotkeys = useMemo( () => [ @@ -330,7 +337,13 @@ const renderTreeContainer: typeof renderers.renderTreeContainer = (props) => { onKeyDown: handleExportKeyDown, }, ], - [hotKeyActive, handleDeleteKeyDown], + [ + hotKeyActive, + handleDeleteKeyDown, + handleExportKeyDown, + handleDuplicateKeyDown, + handleRenameKeyDown, + ], ); const { handleKeyDown } = useHotkeys(hotkeys); @@ -340,43 +353,45 @@ const renderTreeContainer: typeof renderers.renderTreeContainer = (props) => { const FileTree: React.VoidFunctionComponent = () => { const [focusedItem, setFocusedItem] = useState(); - const files = useFileStorageMetadata() ?? []; + const files = useFileStorageMetadata(); const liveDescriptors = useLiveDescriptors(); const rootItemIndex = 'root'; - const treeItems = useMemo( - () => - files.reduce( - (obj, file) => { - const index = file.uuid; + const treeItems = useMemo(() => { + if (files === undefined) { + return {}; + } - obj[index] = { - index, - data: { - fileName: file.path, - icon: 'document', - secondaryLabel: ( - - {(item) => } - - ), - }, - }; + return files.reduce>( + (obj, file) => { + const index = file.uuid; - return obj; - }, - { - [rootItemIndex]: { - index: rootItemIndex, - data: { fileName: '/' }, - isFolder: true, - children: [...files].map((f) => f.uuid), + obj[index] = { + index, + data: { + fileName: file.path, + icon: 'document', + secondaryLabel: ( + + {(item) => } + + ), }, - } as Record, - ), - [files], - ); + }; + + return obj; + }, + { + [rootItemIndex]: { + index: rootItemIndex, + data: { fileName: '/' }, + isFolder: true, + children: [...files].map((f) => f.uuid), + }, + }, + ); + }, [files]); const getItemTitle = useCallback((item: FileTreeItem) => item.data.fileName, []); @@ -406,7 +421,7 @@ const FileTree: React.VoidFunctionComponent = () => { return ( {...renderers} - renderTreeContainer={renderTreeContainer} + renderTreeContainer={TreeContainer} items={treeItems} getItemTitle={getItemTitle} viewState={viewState} diff --git a/src/explorer/newFileWizard/NewFileWizard.tsx b/src/explorer/newFileWizard/NewFileWizard.tsx index 771d7c9e..6a561cf2 100644 --- a/src/explorer/newFileWizard/NewFileWizard.tsx +++ b/src/explorer/newFileWizard/NewFileWizard.tsx @@ -58,7 +58,7 @@ const NewFileWizard: React.VoidFunctionComponent = () => { ), ); }, - [dispatch, fileName, pythonFileExtension, hubType, useTemplate], + [dispatch, fileName, hubType, useTemplate], ); const handleClose = useCallback(() => { diff --git a/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx b/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx index 085aed4d..27f124ad 100644 --- a/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx +++ b/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx @@ -187,12 +187,15 @@ const SelectHubPanel: React.VoidFunctionComponent = ({ const i18n = useI18n(); const dispatch = useDispatch(); - const onDrop = useCallback((acceptedFiles: File[]) => { - // should only be one file since multiple={false} - acceptedFiles.forEach((f) => { - onCustomFirmwareZip(f); - }); - }, []); + const onDrop = useCallback( + (acceptedFiles: File[]) => { + // should only be one file since multiple={false} + acceptedFiles.forEach((f) => { + onCustomFirmwareZip(f); + }); + }, + [onCustomFirmwareZip], + ); const onClick = useCallback(async () => { try { @@ -218,7 +221,7 @@ const SelectHubPanel: React.VoidFunctionComponent = ({ ); } } - }, []); + }, [dispatch, onCustomFirmwareZip]); const onKeyDown = useCallback( (e: React.KeyboardEvent) => { diff --git a/src/firmware/installPybricksDialog/hooks.ts b/src/firmware/installPybricksDialog/hooks.ts index 64df3c85..a9f9ff5d 100644 --- a/src/firmware/installPybricksDialog/hooks.ts +++ b/src/firmware/installPybricksDialog/hooks.ts @@ -202,7 +202,7 @@ export function useCustomFirmware(zipFile: File | undefined) { }; readFile(); - }, [zipFile, isMounted]); + }, [zipFile, isMounted, reduxDispatch]); const isCustomFirmwareRequested = useMemo( () => state.firmwareData !== undefined, diff --git a/src/settings/hooks.ts b/src/settings/hooks.ts index 51d77d8c..4c518c9a 100644 --- a/src/settings/hooks.ts +++ b/src/settings/hooks.ts @@ -23,7 +23,7 @@ export function useSettingIsShowDocsEnabled(): { useEffect(() => { setIsLastSettingShowDocsEnabled(isSettingShowDocsEnabled); - }, [isSettingShowDocsEnabled]); + }, [isSettingShowDocsEnabled, setIsLastSettingShowDocsEnabled]); const toggleIsSettingShowDocsEnabled = useCallback( () => setIsSettingShowDocsEnabled((x) => !x), diff --git a/src/terminal/Terminal.tsx b/src/terminal/Terminal.tsx index eba3bbf6..138b63b3 100644 --- a/src/terminal/Terminal.tsx +++ b/src/terminal/Terminal.tsx @@ -52,7 +52,7 @@ function createXTerm(): { xterm: XTerm; fitAddon: FitAddon } { function createContextMenu( xterm: XTerm, ): (props: ContextMenu2ContentProps) => JSX.Element { - const contextMenu = (_props: ContextMenu2ContentProps): JSX.Element => { + const ContextMenu = (_props: ContextMenu2ContentProps): JSX.Element => { const i18n = useI18n(); return ( @@ -92,7 +92,7 @@ function createContextMenu( ); }; - return contextMenu; + return ContextMenu; } const Terminal: React.FC = (_props) => { @@ -120,7 +120,7 @@ const Terminal: React.FC = (_props) => { xterm.textarea?.setAttribute('tabindex', '-1'); return () => xterm.dispose(); - }, [xterm]); + }, [xterm, fitAddon]); // wire up isDarkMode to terminal useEffect(() => { @@ -133,7 +133,7 @@ const Terminal: React.FC = (_props) => { ? 'rgb(81,81,81,0.5)' : 'rgba(181,213,255,0.5)', // this should match editor theme }; - }, [isDarkMode]); + }, [isDarkMode, xterm]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent): void => { @@ -166,18 +166,15 @@ const Terminal: React.FC = (_props) => { }); return () => subscription.unsubscribe(); - }, [terminalStream]); + }, [terminalStream, xterm]); // wire terminal input to actions useEffect(() => { const onDataHandle = xterm.onData((d) => dispatch(receiveData(d))); return () => onDataHandle.dispose(); - }, [dispatch]); + }, [dispatch, xterm]); - const contextMenu = useMemo( - () => createContextMenu(xterm), - [createContextMenu, xterm], - ); + const contextMenu = useMemo(() => createContextMenu(xterm), [xterm]); useEffect(() => { const listener = () => { diff --git a/src/toolbar/ActionButton.tsx b/src/toolbar/ActionButton.tsx index f30cbad7..8e6e3fe6 100644 --- a/src/toolbar/ActionButton.tsx +++ b/src/toolbar/ActionButton.tsx @@ -83,7 +83,7 @@ const ActionButton: React.VoidFunctionComponent = ({ }, }, ]; - }, [keyboardShortcut, tooltip, enabled, label, onAction]); + }, [keyboardShortcut, enabled, label, onAction]); useHotkeys(hotkeys); diff --git a/src/tour/Tour.tsx b/src/tour/Tour.tsx index b6173068..cdc1a932 100644 --- a/src/tour/Tour.tsx +++ b/src/tour/Tour.tsx @@ -172,7 +172,7 @@ const Tour: React.VoidFunctionComponent = () => { disableBeacon: true, }, ], - [selectedActivity, i18n], + [selectedActivity], ); const styles = useMemo( @@ -231,14 +231,7 @@ const Tour: React.VoidFunctionComponent = () => { setStepIndex(nextIndex); } }, - [ - dispatch, - selectedActivity, - setSelectedActivity, - stepIndex, - setStepIndex, - steps, - ], + [dispatch, setSelectedActivity, stepIndex, setStepIndex, steps], ); // automatically show the tour on the first run only diff --git a/src/utils/tree-renderer.tsx b/src/utils/tree-renderer.tsx index 972b90f2..5d847cf5 100644 --- a/src/utils/tree-renderer.tsx +++ b/src/utils/tree-renderer.tsx @@ -39,186 +39,220 @@ export const TreeItemContext = createContext>({ data: {}, }); +type RendererFuncs = Omit; + +/** + * The type of the `props` parameter of a react-complex-tree render function. + * @typeParam T The name of the render function. + */ +export type RenderProps = Parameters< + RendererFuncs[T] +>[0]; + +const TreeContainer: React.VoidFunctionComponent> = ( + props, +) => { + // work around https://github.com/lukasbach/react-complex-tree/issues/195 + const { treeLabel } = useTree(); + + return ( +
+ {props.children} +
+ ); +}; + +const ItemsContainer: React.VoidFunctionComponent< + RenderProps<'renderTreeContainer'> +> = (props) => ( +
    + {props.children} +
+); + +const Item: React.VoidFunctionComponent> = (props) => { + const { + value: isHover, + setTrue: setIsHoverTrue, + setFalse: setIsHoverFalse, + } = useBoolean(false); + + return ( + +
  • e.stopPropagation()} + onMouseEnter={setIsHoverTrue} + onMouseLeave={setIsHoverFalse} + {...props.context.itemContainerWithChildrenProps} + {...props.context.interactiveElementProps} + > +
    + {props.item.isFolder ? ( + props.arrow + ) : ( + + )} + + {props.title} + {props.item.data.secondaryLabel && isHover && ( + + {props.item.data.secondaryLabel} + + )} +
    + {props.context.isExpanded && props.children} +
  • +
    + ); +}; + +const ItemArrow: React.VoidFunctionComponent> = ( + props, +) => ( + , + 'title' | 'children' + >)} + /> +); + +const ItemTitle: React.VoidFunctionComponent> = ({ + title, + context, + info, +}) => { + if (!info.isSearching || !context.isSearchMatching || !info.search) { + return {title}; + } else { + const startIndex = title.toLowerCase().indexOf(info.search.toLowerCase()); + return ( + + {startIndex > 0 && {title.slice(0, startIndex)}} + + {title.slice(startIndex, startIndex + info.search.length)} + + {startIndex + info.search.length < title.length && ( + + {title.slice(startIndex + info.search.length, title.length)} + + )} + + ); + } +}; + +const DragBetweenLine: React.VoidFunctionComponent< + RenderProps<'renderDragBetweenLine'> +> = ({ draggingPosition, lineProps }) => ( +
    +); + +const RenameInput: React.VoidFunctionComponent> = ( + props, +) => ( +
    + + + + +