diff --git a/Safari/Gitako/Gitako.xcodeproj/project.pbxproj b/Safari/Gitako/Gitako.xcodeproj/project.pbxproj index e929ef8..7aba918 100644 --- a/Safari/Gitako/Gitako.xcodeproj/project.pbxproj +++ b/Safari/Gitako/Gitako.xcodeproj/project.pbxproj @@ -579,7 +579,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.14; - MARKETING_VERSION = 3.9.0; + MARKETING_VERSION = 3.10.0; PRODUCT_BUNDLE_IDENTIFIER = enixcoda.Gitako.Extension; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -604,7 +604,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.14; - MARKETING_VERSION = 3.9.0; + MARKETING_VERSION = 3.10.0; PRODUCT_BUNDLE_IDENTIFIER = enixcoda.Gitako.Extension; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -631,7 +631,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.14; - MARKETING_VERSION = 3.9.0; + MARKETING_VERSION = 3.10.0; PRODUCT_BUNDLE_IDENTIFIER = enixcoda.Gitako; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "Developer Sign for Distribution"; @@ -657,7 +657,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.14; - MARKETING_VERSION = 3.9.0; + MARKETING_VERSION = 3.10.0; PRODUCT_BUNDLE_IDENTIFIER = enixcoda.Gitako; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "Developer Sign for Distribution"; diff --git a/package.json b/package.json index 9b925f7..ccea425 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gitako", - "version": "3.9.0", + "version": "3.10.0", "description": "File tree for GitHub, and more than that.", "repository": "https://github.com/EnixCoda/Gitako", "author": "EnixCoda", diff --git a/scripts/fix-deps/@primer__behaviors.js b/scripts/fix-deps/@primer__behaviors.js new file mode 100644 index 0000000..8e4bdbe --- /dev/null +++ b/scripts/fix-deps/@primer__behaviors.js @@ -0,0 +1,23 @@ +const { fixDep } = require('.') + +const targetFilePath = `@primer/behaviors/dist/esm/anchored-position.js` +const pairs = [ + [ + `if (parentNode === document.body)`, + `if (parentNode === document) break + if (parentNode === document.body)`, + ], + [ + `const clippingNode = parentNode === document.body || !(parentNode instanceof HTMLElement) ? document.body : parentNode;`, // prettier-ignore + `const clippingNode = parentNode === document ? document.documentElement : parentNode === document.body || !(parentNode instanceof HTMLElement) ? document.body : parentNode;`, // prettier-ignore + ], +] + +exports.fix = async () => { + try { + await fixDep(targetFilePath, pairs) + } catch (err) { + console.error((err && err.message) || err) + process.exit(1) + } +} diff --git a/scripts/fix-deps/index.js b/scripts/fix-deps/index.js index 94097ad..c02733f 100644 --- a/scripts/fix-deps/index.js +++ b/scripts/fix-deps/index.js @@ -13,10 +13,6 @@ function modify(source = '', pairs = []) { } else { throw new Error(`Original string not found: ${JSON.stringify(original)}`) } - - if (source.includes(original)) { - throw new Error(`More than one original string found`, JSON.stringify(original)) - } } return source @@ -33,6 +29,7 @@ exports.fixDep = async function fixDep(targetFilePath, pairs) { console.log(`${filePath} has been fixed, skipping.`) return } + console.log(`Fixing ${targetFilePath}`) const modified = modify(source, pairs) + MODIFIED_MARK await fs.writeFile(filePath, modified, 'utf-8') } @@ -42,6 +39,7 @@ async function fixDeps() { require('./pjax-api').fix, require('./styled-components').fix, require('./webext-domain-permission-toggle').fix, + require('./@primer__behaviors').fix, ]) { await fix() } diff --git a/src/components/FileExplorer/ListView.tsx b/src/components/FileExplorer/ListView.tsx deleted file mode 100644 index 6dcc8f4..0000000 --- a/src/components/FileExplorer/ListView.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { useConfigs } from 'containers/ConfigsContext' -import * as React from 'react' -import { useStateIO } from 'utils/hooks/useStateIO' -import { NodeRendererContext } from '.' -import { Node } from './Node' -import { AlignMode, useVirtualScroll } from './useVirtualScroll' - -type ListViewProps = { - height: number - width: number - nodeRendererContext: NodeRendererContext -} - -export function ListView({ width, height, nodeRendererContext }: ListViewProps) { - const { onNodeClick, onNodeFocus, renderLabelText, renderActions, visibleNodes } = - nodeRendererContext - const { focusedNode, nodes, expandedNodes, depths, loading } = visibleNodes - - const { compactFileTree } = useConfigs().value - - const rowHeight = compactFileTree ? 24 : 37 - const totalAmount = visibleNodes.nodes.length - const { onScroll, visibleRows, containerStyle, scrollToItem, ref } = - useVirtualScroll({ - totalAmount, - rowHeight, - viewportHeight: height, - overScan: 10, - }) - - const $mode = useStateIO('top') - const enableScroll = width * height > 0 // these can be 0 on first render - - const index = React.useMemo( - () => - width && height && focusedNode?.path - ? nodes.findIndex(node => node.path === focusedNode.path) - : -1, - [focusedNode?.path, nodes, width, height], - ) - - React.useEffect(() => { - // - init loading - // - "top" - // - NO immediate call - // - jump to file - // - "top" - // - NO immediate call - // - click file/folder - // - not invoke - // - navigate with keyboard - // - "lazy" - // - immediate call - if (enableScroll && index !== -1) { - scrollToItem?.(index, $mode.value) - } - }, [enableScroll, $mode.value, index, scrollToItem]) - - React.useEffect(() => { - if (enableScroll && $mode.value === 'top') $mode.onChange('lazy') - }, [enableScroll, $mode.value]) // eslint-disable-line react-hooks/exhaustive-deps - - return ( -
-
- {visibleRows.map(({ row, style }) => { - const node = nodes[row] - return ( - - ) - })} -
-
- ) -} diff --git a/src/components/FileExplorer/Node.tsx b/src/components/FileExplorer/Node.tsx index be7a443..a202622 100644 --- a/src/components/FileExplorer/Node.tsx +++ b/src/components/FileExplorer/Node.tsx @@ -2,6 +2,7 @@ import { useConfigs } from 'containers/ConfigsContext' import { platform } from 'platforms' import * as React from 'react' import { cx } from 'utils/cx' +import { cancelEvent } from 'utils/DOMHelper' import { getFileIconURL, getFolderIconURL } from 'utils/parseIconMapCSV' import { Icon } from '../Icon' @@ -42,6 +43,7 @@ export const Node = React.memo(function Node({ onFocus, }: Props) { const { compactFileTree: compact } = useConfigs().value + const ref = React.useRef(null) return (
{renderLabelText(node)}
- {renderActions &&
{renderActions(node)}
} + {renderActions && ( +
{ + // exclude elements mounted outside but still bubbles event through react to here + if (e.target instanceof Element && ref.current?.contains(e.target)) cancelEvent(e) + }} + > + {renderActions(node)} +
+ )}
) }) diff --git a/src/components/FileExplorer/VirtualNode.tsx b/src/components/FileExplorer/VirtualNode.tsx deleted file mode 100644 index f5cbebb..0000000 --- a/src/components/FileExplorer/VirtualNode.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Node } from 'components/FileExplorer/Node' -import * as React from 'react' -import { ListChildComponentProps } from 'react-window' -import { NodeRendererContext } from '.' - -export const VirtualNode = React.memo(function VirtualNode({ - index, - style, - data, -}: Override) { - const { onNodeClick, onNodeFocus, renderLabelText, renderActions, visibleNodes } = data - if (!visibleNodes) return null - - const { nodes, focusedNode, expandedNodes, loading, depths } = visibleNodes - const node = nodes[index] - - return ( - - ) -}) diff --git a/src/components/FileExplorer/hooks/useNodeRenderers.tsx b/src/components/FileExplorer/hooks/useNodeRenderers.tsx index 118fc57..58552e4 100644 --- a/src/components/FileExplorer/hooks/useNodeRenderers.tsx +++ b/src/components/FileExplorer/hooks/useNodeRenderers.tsx @@ -1,11 +1,23 @@ -import { CommentIcon } from '@primer/octicons-react' +import { + CheckIcon, + CommentIcon, + CrossReferenceIcon, + KebabHorizontalIcon, +} from '@primer/octicons-react' +import { ActionList, AnchoredOverlay } from '@primer/react' import { useConfigs } from 'containers/ConfigsContext' +import { PortalContext } from 'containers/PortalContext' +import { platform } from 'platforms' import * as React from 'react' +import { useCopyToClipboard } from 'react-use' +import { cx } from 'utils/cx' +import { cancelEvent, onEnterKeyDown } from 'utils/DOMHelper' import { is } from 'utils/is' import { Icon } from '../../Icon' import { SearchMode } from '../../searchModes' import { DiffStatText } from '../DiffStatText' import { DiffStatGraph } from './../DiffStatGraph' +import { VisibleNodesGeneratorMethods } from './useVisibleNodesGeneratorMethods' export type NodeRenderer = (node: TreeNode) => React.ReactNode @@ -38,6 +50,173 @@ export function useRenderFileStatus() { ) } +function renderNodeContextMenu(node: TreeNode, methods: VisibleNodesGeneratorMethods) { + return +} +export function useRenderMoreActions(methods: VisibleNodesGeneratorMethods) { + return (node: TreeNode) => renderNodeContextMenu(node, methods) +} + +function NodeContextMenu({ + node, + visibleNodesGeneratorMethods: { toggleExpansion }, +}: { + node: TreeNode + visibleNodesGeneratorMethods: VisibleNodesGeneratorMethods +}) { + const [isOpen, setIsOpen] = React.useState(false) + const [copied, setCopied] = React.useState(null) + const [copyState, copyToClipboard] = useCopyToClipboard() + const portalName = React.useContext(PortalContext) + const actionElements = { + copyPermalink: + node.permalink && + (() => { + const mark = 'permalink' + const onTrigger = (e: React.SyntheticEvent) => { + cancelEvent(e) + if (node.permalink) { + copyToClipboard(node.permalink) + setCopied(mark) + } + } + return ( + + Copy permalink + {copyState.value && copied === mark ? ( + + + + ) : null} + + ) + })(), + copyLink: + node.url && + (() => { + const mark = 'link' + const onTrigger = (e: React.SyntheticEvent) => { + cancelEvent(e) + if (node.url) { + copyToClipboard(node.url) + setCopied(mark) + } + } + return ( + + Copy link + {copyState.value && copied === mark ? ( + + + + ) : null} + + ) + })(), + copyRelativePath: (() => { + const mark = 'path' + const onTrigger = (e: React.SyntheticEvent) => { + cancelEvent(e) + setCopied(mark) + copyToClipboard(node.path) + } + return ( + + Copy relative path + {copyState.value && copied === mark ? ( + + + + ) : null} + + ) + })(), + openRawContent: node.rawLink && ( + + onEnterKeyDown(e, () => e.target instanceof HTMLElement && e.target.click()) + } + href={node.rawLink} + target="_blank" + rel="noopener noreferrer" + onClick={() => setIsOpen(false)} + > + Open raw content + + + + + ), + goToDirectory: node.type === 'tree' && node.url && ( + + onEnterKeyDown(e, () => e.target instanceof HTMLElement && e.target.click()) + } + href={node.url} + data-gitako-bypass-click + rel="noopener noreferrer" + {...platform.delegateFastRedirectAnchorProps?.({ node })} + onClick={() => setIsOpen(false)} + > + Go to directory + + ), + toggleFolderRecursively: + node.type === 'tree' && + (() => { + const trigger = (e: React.SyntheticEvent) => { + cancelEvent(e) + toggleExpansion(node, { recursive: true }) + setIsOpen(false) + } + return ( + + Toggle folder recursively + + ) + })(), + } + + return ( + ( + + )} + open={isOpen} + onOpen={() => setIsOpen(true)} + onClose={() => setIsOpen(false)} + overlayProps={{ + portalContainerName: portalName || undefined, + onKeyDown: e => cancelEvent(e), + }} + > + + {actionElements.copyPermalink} + {actionElements.copyLink} + {actionElements.copyRelativePath} + + {(actionElements.openRawContent || + actionElements.goToDirectory || + actionElements.toggleFolderRecursively) && } + + {actionElements.openRawContent} + {actionElements.goToDirectory} + {actionElements.toggleFolderRecursively} + + + ) +} + export function useRenderFileCommentAmounts() { function renderFileCommentAmounts(node: TreeNode) { return node.comments?.active ? ( @@ -69,11 +248,7 @@ export function useRenderFindInFolderButton( @@ -93,11 +268,7 @@ export function useRenderGoToButton(searched: boolean, goTo: (path: string[]) => @@ -107,3 +278,8 @@ export function useRenderGoToButton(searched: boolean, goTo: (path: string[]) => [searched, goTo], ) } + +const getTriggerProps = (onTrigger: (e: React.SyntheticEvent) => void) => ({ + onClick: onTrigger, + onKeyDown: (e: React.KeyboardEvent) => onEnterKeyDown(e, onTrigger), +}) diff --git a/src/components/FileExplorer/hooks/useOnNodeClick.tsx b/src/components/FileExplorer/hooks/useOnNodeClick.tsx index aa00538..7647227 100644 --- a/src/components/FileExplorer/hooks/useOnNodeClick.tsx +++ b/src/components/FileExplorer/hooks/useOnNodeClick.tsx @@ -23,6 +23,15 @@ export function useHandleNodeClick( // giving recursive toggle action higher priority than default action if (!recursive && isOpenInNewWindowClick(event)) return + // check if clicked inside an element which has `data-gitako-bypass-click` set + if (event.target instanceof HTMLElement) { + let e = event.target + while (e.parentElement) { + if (e.dataset.gitakoBypassClick) return + e = e.parentElement + } + } + event.preventDefault() toggleExpansion(node, { recursive }) break diff --git a/src/components/FileExplorer/hooks/useVisibleNodesGenerator.tsx b/src/components/FileExplorer/hooks/useVisibleNodesGenerator.tsx index 56b2666..e2298d5 100644 --- a/src/components/FileExplorer/hooks/useVisibleNodesGenerator.tsx +++ b/src/components/FileExplorer/hooks/useVisibleNodesGenerator.tsx @@ -2,7 +2,7 @@ import { useConfigs } from 'containers/ConfigsContext' import { platform } from 'platforms' import { useCallback, useState } from 'react' import { useAbortableEffect } from 'utils/hooks/useAbortableEffect' -import { useCatchNetworkError } from 'utils/hooks/useCatchNetworkError' +import { useHandleNetworkError } from 'utils/hooks/useHandleNetworkError' import { useLoadedContext } from 'utils/hooks/useLoadedContext' import { VisibleNodesGenerator } from 'utils/VisibleNodesGenerator' import { SideBarStateContext } from '../../../containers/SideBarState' @@ -12,55 +12,58 @@ export function useVisibleNodesGenerator(metaData: MetaData | null) { null, ) - const catchNetworkErrors = useCatchNetworkError() const config = useConfigs().value const setStateContext = useLoadedContext(SideBarStateContext).onChange + const handleNetworkError = useHandleNetworkError() // Only run when metadata or accessToken changes + const createVNG = useCallback( + async function* createVNG() { + if (!metaData) return + + setStateContext('tree-loading') + const { userName, repoName, branchName } = metaData + try { + const { root: treeRoot, defer = false } = yield await platform.getTreeData( + { + branchName, + userName, + repoName, + }, + '/', + true, + config.accessToken, + ) + setStateContext('tree-rendering') + + setVisibleNodesGenerator( + new VisibleNodesGenerator({ + root: treeRoot, + defer, + compress: config.compressSingletonFolder, + async getTreeData(path) { + const { root } = await platform.getTreeData(metaData, path, false, config.accessToken) + return root + }, + }), + ) + + setStateContext('tree-rendered') + } catch (err) { + if (err instanceof Error) handleNetworkError(err) + else throw err + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [metaData, config.accessToken], + ) + useAbortableEffect( useCallback( - signal => { - catchNetworkErrors(async () => { - if (!metaData) return - if (signal.aborted) return - - setStateContext('tree-loading') - const { userName, repoName, branchName } = metaData - const { root: treeRoot, defer = false } = await platform.getTreeData( - { - branchName, - userName, - repoName, - }, - '/', - true, - config.accessToken, - ) - if (signal.aborted) return - - setStateContext('tree-rendering') - - setVisibleNodesGenerator( - new VisibleNodesGenerator({ - root: treeRoot, - defer, - compress: config.compressSingletonFolder, - async getTreeData(path) { - const { root } = await platform.getTreeData( - metaData, - path, - false, - config.accessToken, - ) - return root - }, - }), - ) - - setStateContext('tree-rendered') - }) - }, - [metaData, config.accessToken], // eslint-disable-line react-hooks/exhaustive-deps + () => ({ + getAsyncGenerator: createVNG, + }), + [createVNG], ), ) diff --git a/src/components/FileExplorer/index.tsx b/src/components/FileExplorer/index.tsx index 4e59e9a..6700901 100644 --- a/src/components/FileExplorer/index.tsx +++ b/src/components/FileExplorer/index.tsx @@ -1,12 +1,13 @@ -import { Label, Text } from '@primer/react' +import { Label, registerPortalRoot, Text } from '@primer/react' import { useFocusOnPendingTarget } from 'components/FocusTarget' import { LoadingIndicator } from 'components/LoadingIndicator' import { SearchBar } from 'components/SearchBar' import { useConfigs } from 'containers/ConfigsContext' +import { PortalContext } from 'containers/PortalContext' import { RepoContext } from 'containers/RepoContext' import { platform } from 'platforms' import * as React from 'react' -import { usePrevious } from 'react-use' +import { usePrevious, useUpdateEffect } from 'react-use' import { cx } from 'utils/cx' import { run } from 'utils/general' import { useElementSize } from 'utils/hooks/useElementSize' @@ -24,6 +25,7 @@ import { useRenderFileStatus, useRenderFindInFolderButton, useRenderGoToButton, + useRenderMoreActions, } from './hooks/useNodeRenderers' import { useHandleNodeClick } from './hooks/useOnNodeClick' import { useOnSearch } from './hooks/useOnSearch' @@ -84,16 +86,24 @@ function LoadedFileExplorer({ visibleNodesGenerator: VisibleNodesGenerator visibleNodes: VisibleNodes }) { + const config = useConfigs().value + const [searchKey, updateSearchKey] = React.useState('') const searched = !!searchKey const onSearch = useOnSearch(updateSearchKey, visibleNodesGenerator) const { focusedNode, nodes, expandedNodes, depths, loading } = visibleNodes + // re-search when the compress option update + const { compressSingletonFolder } = config + useUpdateEffect(() => { + visibleNodesGenerator.setCompression(compressSingletonFolder) + }, [compressSingletonFolder]) + const { ref: filesRef, size: [, height], } = useElementSize() - const { compactFileTree } = useConfigs().value + const { compactFileTree } = config const { ref: scrollElementRef, onScroll, @@ -107,6 +117,12 @@ function LoadedFileExplorer({ overScan: 10, }) + const portalName = React.useMemo(() => `${Math.random()}`, []) + React.useEffect(() => { + const current = scrollElementRef.current + if (current) registerPortalRoot(current, portalName) + }, [scrollElementRef, portalName]) + // - init loading // - "top" // - jump to file @@ -149,6 +165,7 @@ function LoadedFileExplorer({ useRenderGoToButton(searched, goTo), useRenderFindInFolderButton(onSearch), useRenderFileCommentAmounts(), + useRenderMoreActions(methods), useRenderFileStatus(), ]) const renderLabelText = useRenderLabelText(searchKey) @@ -211,26 +228,30 @@ function LoadedFileExplorer({ onScroll={onScroll} tabIndex={-1} // prevent getting focus via tab key on GitHub > -
- {visibleRows.map(({ row, style }) => { - const node = nodes[row] - return ( - - ) - })} -
+ +
+ {visibleRows + .map(({ row, style }) => ({ + node: nodes[row], + style, + })) + .map(({ node, style }) => ( + + ))} +
+
diff --git a/src/components/IconButton.tsx b/src/components/IconButton.tsx index 86838d5..2472713 100644 --- a/src/components/IconButton.tsx +++ b/src/components/IconButton.tsx @@ -1,11 +1,11 @@ import { IconProps } from '@primer/octicons-react' import { Box, merge, SxProp, useTheme } from '@primer/react' -import { getBaseStyles, getSizeStyles, getVariantStyles } from '@primer/react/lib/Button/styles' +import { getBaseStyles, getSizeStyles, getVariantStyles } from '@primer/react/lib-esm/Button/styles' import { IconButtonProps as PrimerIconButtonProps, StyledButton, -} from '@primer/react/lib/Button/types' -import React from 'react' +} from '@primer/react/lib-esm/Button/types' +import React, { forwardRef } from 'react' import { is } from 'utils/is' export type IconButtonProps = PrimerIconButtonProps & { @@ -13,10 +13,13 @@ export type IconButtonProps = PrimerIconButtonProps & { iconColor?: string } -// Modified version of @primer/react/lib/Button/Button.tsx +// Modified version of @primer/react/lib-esm/Button/Button.tsx // Added better support of colors & size -export function IconButton(props: IconButtonProps) { +export const IconButton = forwardRef(function IconButton( + props: IconButtonProps, + ref: React.ForwardedRef, +) { const { variant = 'default', size = 'medium', @@ -40,10 +43,10 @@ export function IconButton(props: IconButtonProps) { ].filter(is.not.undefined), ) return ( - + ) -} +}) diff --git a/src/components/RoundIconButton.tsx b/src/components/RoundIconButton.tsx index ddc2178..49ea825 100644 --- a/src/components/RoundIconButton.tsx +++ b/src/components/RoundIconButton.tsx @@ -1,9 +1,13 @@ -import React from 'react' +import React, { ForwardedRef, forwardRef } from 'react' import { IconButton, IconButtonProps } from './IconButton' -export function RoundIconButton(props: IconButtonProps) { +export const RoundIconButton = forwardRef(function RoundIconButton( + props: IconButtonProps, + ref: ForwardedRef, +) { return ( ) -} +}) diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index f3fc148..0eba914 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -1,4 +1,4 @@ -import { SearchIcon } from '@primer/octicons-react' +import { SearchIcon, XIcon } from '@primer/octicons-react' import { TextInput, TextInputProps } from '@primer/react' import { useConfigs } from 'containers/ConfigsContext' import * as React from 'react' @@ -48,20 +48,26 @@ export function SearchBar({ onSearch, onFocus, value }: Props) { value={value} validationStatus={validationStatus} trailingAction={ - { - const newMode = searchMode === 'regex' ? 'fuzzy' : 'regex' - configs.onChange({ - searchMode: newMode, - }) - // Skip search if no input to prevent resetting folder expansions - if (value) onSearch(value, newMode) - }} - > - {searchMode === 'regex' ? '.*$' : 'a/b'} - + <> + onSearch('', searchMode)} + icon={XIcon} + aria-label="Clear" + /> + { + const newMode = searchMode === 'regex' ? 'fuzzy' : 'regex' + configs.onChange({ searchMode: newMode }) + // Skip search if no input to prevent resetting folder expansions + if (value) onSearch(value, newMode) + }} + > + {searchMode === 'regex' ? '.*$' : 'a/b'} + + } /> ) diff --git a/src/components/SideBar.tsx b/src/components/SideBar.tsx index 8c77102..cdc2179 100644 --- a/src/components/SideBar.tsx +++ b/src/components/SideBar.tsx @@ -34,7 +34,7 @@ import { SideBarResizeHandler } from './SideBarResizeHandler' export function SideBar() { usePJAXAPI() platform.usePlatformHooks?.() - useMarkGitakoReadyState() + useMarkGitakoGlobalAttributes() const error = useLoadedContext(SideBarErrorContext).value @@ -181,10 +181,19 @@ function useFocusSidebarOnExpand(shouldExpand: boolean) { }, [shouldExpand]) } -function useMarkGitakoReadyState() { +function useMarkGitakoGlobalAttributes() { React.useEffect(() => { + const detach = DOMHelper.attachStickyGitakoPlatform() + DOMHelper.markGitakoPlatform() + return () => detach() + }, []) + React.useEffect(() => { + const detach = DOMHelper.attachStickyGitakoReadyState() DOMHelper.markGitakoReadyState(true) - return () => DOMHelper.markGitakoReadyState(false) + return () => { + detach() + DOMHelper.markGitakoReadyState(false) + } }, []) } @@ -199,9 +208,13 @@ function useLogoContainerElement() { function useUpdateBodyIndentOnStateUpdate(shouldExpand: boolean) { const { sidebarToggleMode } = useConfigs().value React.useEffect(() => { - if (sidebarToggleMode === 'persistent' && shouldExpand) { - DOMHelper.setBodyIndent(true) - return () => DOMHelper.setBodyIndent(false) + if (!(sidebarToggleMode === 'persistent' && shouldExpand)) return + + const detach = DOMHelper.attachStickyBodyIndent() + DOMHelper.setBodyIndent(true) + return () => { + detach() + DOMHelper.setBodyIndent(false) } }, [sidebarToggleMode, shouldExpand]) } diff --git a/src/components/SideBarResizeHandler.tsx b/src/components/SideBarResizeHandler.tsx index 1d73ec5..4d91b2a 100644 --- a/src/components/SideBarResizeHandler.tsx +++ b/src/components/SideBarResizeHandler.tsx @@ -1,22 +1,13 @@ import { useConfigs } from 'containers/ConfigsContext' import * as React from 'react' -import { useDebounce, useWindowSize } from 'react-use' +import { useDebounce, useLatest, useWindowSize } from 'react-use' import { getDefaultConfigs } from 'utils/config/helper' import * as DOMHelper from 'utils/DOMHelper' import { useAfterRedirect } from 'utils/hooks/useFastRedirect' +import { getSafeWidth } from '../utils/getSafeWidth' import { ResizeHandler } from './ResizeHandler' import { Size, Size2D } from './Size' -const MINIMAL_CONTENT_VIEWPORT_WIDTH = 100 -const MINIMAL_WIDTH = 240 - -function getSafeWidth(width: Size, windowWidth: number) { - if (width > windowWidth - MINIMAL_CONTENT_VIEWPORT_WIDTH) - return windowWidth - MINIMAL_CONTENT_VIEWPORT_WIDTH - if (width < MINIMAL_WIDTH) return MINIMAL_WIDTH - return width -} - function useSidebarWidth() { const configContext = useConfigs() @@ -36,6 +27,12 @@ function useSidebarWidth() { React.useLayoutEffect(() => DOMHelper.setGitakoWidthCSSVariable(width), [width]) + const widthRef = useLatest(width) + React.useEffect(() => { + const detach = DOMHelper.attachStickyGitakoWidthCSSVariable(() => widthRef.current) + return () => detach() + }, [widthRef]) + // Keep variable when directing from PR to repo home via meta bar useAfterRedirect(React.useCallback(() => DOMHelper.setGitakoWidthCSSVariable(width), [width])) diff --git a/src/components/settings/KeyboardShortcutSetting.tsx b/src/components/settings/KeyboardShortcutSetting.tsx index 44f5067..28dfffc 100644 --- a/src/components/settings/KeyboardShortcutSetting.tsx +++ b/src/components/settings/KeyboardShortcutSetting.tsx @@ -1,6 +1,7 @@ import { Box, Button, FormControl, TextInput } from '@primer/react' import * as React from 'react' import { useUpdateEffect } from 'react-use' +import { cancelEvent } from 'utils/DOMHelper' import { friendlyFormatShortcut, noop } from 'utils/general' import { useStateIO } from 'utils/hooks/useStateIO' import * as keyHelper from 'utils/keyHelper' @@ -39,8 +40,7 @@ export function KeyboardShortcutSetting({ label, value, onChange }: Props) { $shortcut.onChange(undefined) return default: - e.preventDefault() - e.stopPropagation() + cancelEvent(e) } $shortcut.onChange(keyHelper.parseEvent(e)) }} diff --git a/src/containers/PortalContext.tsx b/src/containers/PortalContext.tsx new file mode 100644 index 0000000..f259aa8 --- /dev/null +++ b/src/containers/PortalContext.tsx @@ -0,0 +1,5 @@ +import * as React from 'react' + +export type PortalContextShape = string | null + +export const PortalContext = React.createContext(null) diff --git a/src/containers/RepoContext.tsx b/src/containers/RepoContext.tsx index 9298fb7..c6f088c 100644 --- a/src/containers/RepoContext.tsx +++ b/src/containers/RepoContext.tsx @@ -2,11 +2,12 @@ import { PropsWithChildren } from 'common' import { useConfigs } from 'containers/ConfigsContext' import { platform } from 'platforms' import * as React from 'react' +import { useAbortableEffect } from 'utils/hooks/useAbortableEffect' import { useEffectOnSerializableUpdates } from 'utils/hooks/useEffectOnSerializableUpdates' import { useAfterRedirect } from 'utils/hooks/useFastRedirect' +import { useHandleNetworkError } from 'utils/hooks/useHandleNetworkError' import { useLoadedContext } from 'utils/hooks/useLoadedContext' import { useStateIO } from 'utils/hooks/useStateIO' -import { useCatchNetworkError } from '../utils/hooks/useCatchNetworkError' import { SideBarStateContext } from './SideBarState' import { useInspector } from './StateInspector' @@ -14,17 +15,15 @@ export const RepoContext = React.createContext(null) export function RepoContextWrapper({ children }: PropsWithChildren) { const partialMetaData = usePartialMetaData() - const defaultBranch = useDefaultBranch(partialMetaData) - const metaData = useMetaData(partialMetaData, defaultBranch) + const metaData = useMetaData(partialMetaData) useInspector( 'RepoContext', React.useMemo( () => ({ partialMetaData, - defaultBranch, metaData, }), - [partialMetaData, defaultBranch, metaData], + [partialMetaData, metaData], ), ) const state = useLoadedContext(SideBarStateContext).value @@ -33,13 +32,14 @@ export function RepoContextWrapper({ children }: PropsWithChildren) { return {children} } -function resolvePartialMetaData() { +function resolvePartialMetaData(): PartialMetaData | null { const partialMetaData = platform.resolvePartialMetaData() if (partialMetaData) { - const { userName, repoName, type } = partialMetaData + const { userName, repoName, branchName, type } = partialMetaData return { userName, repoName, + branchName, type: type === 'pull' ? type : undefined, } } @@ -49,7 +49,8 @@ function resolvePartialMetaData() { function usePartialMetaData(): PartialMetaData | null { const $state = useLoadedContext(SideBarStateContext) - const isGettingAccessToken = $state.value === 'getting-access-token' // will be false after getting access token and trigger meta-resolve progress + // will be false after getting access token and trigger meta-resolve progress + const isGettingAccessToken = $state.value === 'getting-access-token' // sync along URL and DOM const $partialMetaData = useStateIO(isGettingAccessToken ? null : resolvePartialMetaData) const $committedPartialMetaData = useStateIO($partialMetaData.value) @@ -74,58 +75,57 @@ function usePartialMetaData(): PartialMetaData | null { return $committedPartialMetaData.value } -function useBranchName(): MetaData['branchName'] | null { - // sync along URL and DOM - const $branchName = useStateIO(() => platform.resolvePartialMetaData()?.branchName || null) - useAfterRedirect(() => - $branchName.onChange(platform.resolvePartialMetaData()?.branchName || null), - ) - return $branchName.value -} +function useMetaData(partialMetaData: PartialMetaData | null) { + const [metaData, changeMetaData] = React.useState(null) + const changeLoadedState = useLoadedContext(SideBarStateContext).onChange + const handleNetworkError = useHandleNetworkError() -function useDefaultBranch(partialMetaData: PartialMetaData | null) { const { accessToken } = useConfigs().value - const $state = useLoadedContext(SideBarStateContext) - const $defaultBranch = useStateIO(null) - const catchNetworkError = useCatchNetworkError() - React.useEffect(() => { - catchNetworkError(async () => { + const loadRepoMetaData = React.useCallback( + async function* loadRepoMetaData() { if (!partialMetaData) return - $state.onChange('meta-loading') + const { userName, repoName } = partialMetaData if (!userName || !repoName) return - const defaultBranch = await platform.getDefaultBranchName({ userName, repoName }, accessToken) - $defaultBranch.onChange(defaultBranch) - }) - }, [partialMetaData, accessToken]) // eslint-disable-line react-hooks/exhaustive-deps - return $defaultBranch.value -} + changeLoadedState('meta-loading') + let { branchName } = partialMetaData + if (!branchName) { + try { + const defaultBranchName = yield await platform.getDefaultBranchName( + { userName, repoName }, + accessToken, + ) + branchName = defaultBranchName as string + } catch (err) { + // state will be updated in the network error handler + if (err instanceof Error) return handleNetworkError(err) + else throw err + } + } -function useMetaData( - partialMetaData: PartialMetaData | null, - defaultBranchName: MetaData['defaultBranchName'] | null, -) { - const $state = useLoadedContext(SideBarStateContext) - const $metaData = useStateIO(null) - const branchName = useBranchName() - const theBranch = branchName && branchName !== defaultBranchName ? branchName : defaultBranchName - React.useEffect(() => { - if (partialMetaData && defaultBranchName && theBranch) { - const { userName, repoName } = partialMetaData - if (!userName || !repoName) return - - const safeMetaData: MetaData = { + changeMetaData({ userName, repoName, - branchName: theBranch, - defaultBranchName, - } - $metaData.onChange(safeMetaData) - $state.onChange('meta-loaded') - } else { - $metaData.onChange(null) - } - }, [partialMetaData, defaultBranchName, theBranch]) // eslint-disable-line react-hooks/exhaustive-deps - return $metaData.value + branchName, + }) + changeLoadedState('meta-loaded') + }, + [partialMetaData, changeLoadedState, accessToken, handleNetworkError], + ) + + useAbortableEffect( + React.useCallback( + () => ({ + getAsyncGenerator: loadRepoMetaData, + cancel: () => { + changeLoadedState('disabled') + changeMetaData(null) + }, + }), + [loadRepoMetaData, changeLoadedState, changeMetaData], + ), + ) + + return metaData } diff --git a/src/containers/StateInspector.tsx b/src/containers/StateInspector.tsx index 876c4ff..7680d51 100644 --- a/src/containers/StateInspector.tsx +++ b/src/containers/StateInspector.tsx @@ -29,6 +29,9 @@ export const InspectorContextWrapper = IN_PRODUCTION_MODE flexDirection: 'column', }} > +
+ +
                 {JSON.stringify($.value, null, 2)}
               
-
- -
) : (
diff --git a/src/global.d.ts b/src/global.d.ts index 1182762..c907441 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -3,15 +3,11 @@ type AnyArray = any[] // eslint-disable-line @typescript-eslint/no-explicit-any type MetaData = { userName: string repoName: string - defaultBranchName: string branchName: string type?: EnumString<'tree' | 'blob' | 'pull' | 'commit'> } -type PartialMetaData = Omit< - MakeOptional, - 'defaultBranchName' -> +type PartialMetaData = MakeOptional type TreeNode = { name: string @@ -19,6 +15,8 @@ type TreeNode = { path: string type: 'tree' | 'blob' | 'commit' url?: string + permalink?: string + rawLink?: string sha?: string accessDenied?: boolean comments?: { diff --git a/src/platforms/GitHub/DOMHelper.ts b/src/platforms/GitHub/DOMHelper.ts index c12d209..c4b537a 100644 --- a/src/platforms/GitHub/DOMHelper.ts +++ b/src/platforms/GitHub/DOMHelper.ts @@ -1,7 +1,8 @@ import { raiseError } from 'analytics' import { Clippy, ClippyClassName } from 'components/Clippy' import * as React from 'react' -import { $, formatClass, parseIntFromElement } from 'utils/DOMHelper' +import { $ } from 'utils/$' +import { formatClass, parseIntFromElement } from 'utils/DOMHelper' import { renderReact, run } from 'utils/general' import { CopyFileButton, copyFileButtonClassName } from './CopyFileButton' diff --git a/src/platforms/GitHub/URLHelper.ts b/src/platforms/GitHub/URLHelper.ts index a4c3644..ac63f15 100644 --- a/src/platforms/GitHub/URLHelper.ts +++ b/src/platforms/GitHub/URLHelper.ts @@ -92,3 +92,19 @@ export function getCurrentPath(branchName = '') { } return [] } + +export function getItemUrl( + userName: string, + repoName: string, + branchName: string, + type = 'blob', + path = '', +) { + // Modern browsers have great support for handling unsafe URL, + // It may be possible to sanitize path with + // `path => path.includes('#') ? path.replace(/#/g, '%23') : '...' + return `${window.location.origin}/${userName}/${repoName}/${type}/${branchName}/${path + .split('/') + .map(encodeURIComponent) + .join('/')}` +} diff --git a/src/platforms/GitHub/getPullRequestTreeData.ts b/src/platforms/GitHub/getPullRequestTreeData.ts index d7c5852..470882e 100644 --- a/src/platforms/GitHub/getPullRequestTreeData.ts +++ b/src/platforms/GitHub/getPullRequestTreeData.ts @@ -55,13 +55,24 @@ export async function getPullRequestTreeData( url.pathname = `/${userName}/${repoName}/pull/${pullId}/files` const commentsMap = getCommentsMap(commentData) const nodes: TreeNode[] = treeData.map( - ({ filename, sha, additions, deletions, changes, status }) => { + ({ + filename, + sha, + additions, + deletions, + changes, + status, + raw_url: rawLink, + blob_url: permalink, + }) => { url.hash = map.get(filename) || '' return { path: filename || '', type: 'blob', name: filename?.split('/').pop() || '', url: `${url}`, + permalink, + rawLink, sha, comments: commentsMap.get(filename), diff: { diff --git a/src/platforms/GitHub/hooks/useEnterpriseStatBarStyleFix.ts b/src/platforms/GitHub/hooks/useEnterpriseStatBarStyleFix.ts index 8c8838c..7fb3e71 100644 --- a/src/platforms/GitHub/hooks/useEnterpriseStatBarStyleFix.ts +++ b/src/platforms/GitHub/hooks/useEnterpriseStatBarStyleFix.ts @@ -1,5 +1,5 @@ import { useEffect } from 'react' -import { $ } from 'utils/DOMHelper' +import { $ } from 'utils/$' import * as DOMHelper from '../DOMHelper' import { GitHub } from '../index' diff --git a/src/platforms/GitHub/index.ts b/src/platforms/GitHub/index.ts index 298e798..c16f809 100644 --- a/src/platforms/GitHub/index.ts +++ b/src/platforms/GitHub/index.ts @@ -1,8 +1,8 @@ import { useConfigs } from 'containers/ConfigsContext' import { GITHUB_OAUTH } from 'env' import { Base64 } from 'js-base64' +import { $ } from 'utils/$' import { configRef } from 'utils/config/helper' -import { $ } from 'utils/DOMHelper' import { resolveGitModules } from 'utils/gitSubmodule' import { sortFoldersToFront } from 'utils/treeParser' import * as API from './API' @@ -66,22 +66,6 @@ export function processTree(tree: TreeNode[]): TreeNode { return root } -function getUrlForRedirect( - userName: string, - repoName: string, - branchName: string, - type = 'blob', - path = '', -) { - // Modern browsers have great support for handling unsafe URL, - // It may be possible to sanitize path with - // `path => path.includes('#') ? path.replace(/#/g, '%23') : '...' - return `${window.location.origin}/${userName}/${repoName}/${type}/${branchName}/${path - .split('/') - .map(encodeURIComponent) - .join('/')}` -} - export function isEnterprise() { return ( (window.location.host !== 'github.com' && @@ -212,19 +196,19 @@ export const GitHub: Platform = { useGitHubCodeFold(codeFolding) useEnterpriseStatBarStyleFix() }, - delegateFastRedirectAnchorProps: options => { - if (configRef.pjaxMode === 'native' && (!options?.node || options.node.type === 'blob')) { - const pjaxContainerSelector = 'main' - const turboContainerId = 'repo-content-turbo-frame' + delegateFastRedirectAnchorProps() { + if (configRef.pjaxMode !== 'native') return - return { - 'data-pjax': pjaxContainerSelector, - 'data-turbo-frame': - URLHelper.isInPullPage() || URLHelper.isInCommitPage() ? undefined : turboContainerId, - onClick() { - /* Overwriting default onClick */ - }, - } + const pjaxContainerSelector = 'main' + const turboContainerId = 'repo-content-turbo-frame' + + return { + 'data-pjax': pjaxContainerSelector, + 'data-turbo-frame': + URLHelper.isInPullPage() || URLHelper.isInCommitPage() ? undefined : turboContainerId, + onClick() { + /* Overwriting default onClick */ + }, } }, loadWithFastRedirect: (url, element) => { @@ -274,7 +258,12 @@ async function getRepositoryTreeData( name: item.path?.split('/').pop() || '', url: item.url && item.type && item.path - ? getUrlForRedirect(userName, repoName, branchName, item.type, item.path) + ? URLHelper.getItemUrl(userName, repoName, branchName, item.type, item.path) + : undefined, + permalink: URLHelper.getItemUrl(userName, repoName, treeData.sha, item.type, item.path), + rawLink: + item.url && item.type === 'blob' && item.path + ? URLHelper.getItemUrl(userName, repoName, branchName, 'raw', item.path) : undefined, contents: item.type === 'tree' ? [] : undefined, sha: item.sha, diff --git a/src/platforms/Gitea/DOMHelper.ts b/src/platforms/Gitea/DOMHelper.ts index acd7043..d82e027 100644 --- a/src/platforms/Gitea/DOMHelper.ts +++ b/src/platforms/Gitea/DOMHelper.ts @@ -1,5 +1,5 @@ import { raiseError } from 'analytics' -import { $ } from 'utils/DOMHelper' +import { $ } from 'utils/$' export function isInRepoPage() { const repoHeaderSelector = '.repo-header' diff --git a/src/platforms/Gitee/DOMHelper.ts b/src/platforms/Gitee/DOMHelper.ts index 144837e..13dacb3 100644 --- a/src/platforms/Gitee/DOMHelper.ts +++ b/src/platforms/Gitee/DOMHelper.ts @@ -1,7 +1,8 @@ import { raiseError } from 'analytics' import { Clippy, ClippyClassName } from 'components/Clippy' import * as React from 'react' -import { $, formatClass } from 'utils/DOMHelper' +import { $ } from 'utils/$' +import { formatClass } from 'utils/DOMHelper' import { renderReact } from 'utils/general' export function isInRepoPage() { diff --git a/src/styles/clippy.scss b/src/styles/clippy.scss new file mode 100644 index 0000000..d99da50 --- /dev/null +++ b/src/styles/clippy.scss @@ -0,0 +1,45 @@ +@mixin clippy { + .clippy-wrapper { + position: relative; + width: 0; + height: 0; + top: 8px; + left: calc(100% - 40px); + z-index: 1; + + .clippy { + width: 32px; + height: 32px; + border: 1px solid var(--color-border-default); + border-radius: 4px; + + @include interactive-background; + .icon { + width: 100%; + height: 100%; + display: block; + background-image: url('~@primer/octicons-react/build/svg/copy-16.svg?inline'); + background-position: center; + background-repeat: no-repeat; + &.success { + background-image: url('~@primer/octicons-react/build/svg/check-16.svg?inline'); + } + &.fail { + background-image: url('~@primer/octicons-react/build/svg/x-16.svg?inline'); + } + } + } + } +} + +// TODO: use react to render the button content and set color with CSS variables +@media (prefers-color-scheme: dark) { + :root[data-color-mode='auto'] .markdown-body .clippy .icon { + filter: invert(0.7); // hack to make it looks like a normal color :P + } +} +:root[data-color-mode='dark'] { + .markdown-body .clippy .icon { + filter: invert(0.7); // hack to make it looks like a normal color :P + } +} diff --git a/src/styles/code-folding.scss b/src/styles/code-folding.scss new file mode 100644 index 0000000..7a7d7cc --- /dev/null +++ b/src/styles/code-folding.scss @@ -0,0 +1,72 @@ +@mixin code-folding { + .blob-wrapper table .blob-num { + position: relative; // for positioning + min-width: 60px; + padding-right: 20px; + } + + // hide code fold handler if not enabled + .gitako-code-fold-handler { + display: none; + } + + .gitako-code-fold-attached:not(.gitako-code-fold-attached-disabled) { + tr { + .gitako-code-fold-handler { + @include hide-for-print(); + + display: initial; + position: absolute; + top: 0px; + right: 0px; + width: 20px; + height: 20px; + display: flex; + justify-content: center; + align-items: center; + + &::before { + width: 16px; + height: 20px; + transition: 0.25s ease; + @include pseudo-primer-icon('chevron-down-16'); + } + + @include interactive-background-on-before( + var(--color-fg-subtle), + var(--color-fg-default), + var(--color-fg-muted) + ); + } + + &.gitako-code-fold-active { + background-color: var(--color-neutral-muted); + + .gitako-code-fold-handler { + &::before { + transform: rotate(-90deg); + } + + @include interactive-background-on-before( + var(--color-fg-muted), + var(--color-fg-default), + var(--color-fg-subtle) + ); + } + + .blob-code::after { + color: var(--color-fg-muted); + content: '⋯'; + margin: 0.1em 0.2em 0px; + } + } + + // hide folded sections, except for print + &.gitako-code-fold-hidden { + @media screen { + display: none; + } + } + } + } +} diff --git a/src/styles/gitee.scss b/src/styles/gitee.scss new file mode 100644 index 0000000..0762bf4 --- /dev/null +++ b/src/styles/gitee.scss @@ -0,0 +1,47 @@ +$gitee-header-z-index: 1002; + +@mixin gitee($name) { + [data-gitako-platform='Gitee'] { + &[data-#{$name}-ready='true'] { + #git-header-nav { + position: static; + padding-left: 0; + padding-right: 0; + } + + // reset styles + .#{$name}-side-bar { + input[type='text'], + input[type='password'], + .ui-autocomplete-input, + textarea, + .uneditable-input { + padding: initial; + border: none; + } + } + + padding-top: 0; + + .#{$name}-side-bar { + h1, + h2, + h3, + h4, + h5, + h6 { + margin: 0; + } + } + } + + &[data-with-gitako-spacing='true'] { + body { + width: auto; // shrink width + .site-content { + min-width: 1040px; + } + } + } + } +} diff --git a/src/styles/github.scss b/src/styles/github.scss new file mode 100644 index 0000000..0063545 --- /dev/null +++ b/src/styles/github.scss @@ -0,0 +1,9 @@ +@mixin github($github-content-width) { + [data-gitako-platform='GitHub'] { + @media (min-width: $github-content-width) { + body { + min-width: $github-content-width; + } + } + } +} diff --git a/src/styles/index.scss b/src/styles/index.scss index 194c608..30b4e56 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -1,6 +1,13 @@ @import '~nprogress/nprogress.css'; @import './themes.scss'; +@import './layout.scss'; +@import './primer-like.scss'; +@import './code-folding.scss'; +@import './clippy.scss'; +@import './gitee.scss'; +@import './github.scss'; +@import './keyframes.scss'; $name: gitako; @@ -13,222 +20,23 @@ $github-header-z-index: 32; $github-pull-request-float-header-z-index: 110; $github-notifications-center-header-z-index: 999; $minimal-z-index: max( + $gitee-header-z-index, $github-header-z-index, $github-pull-request-float-header-z-index, $github-notifications-center-header-z-index ) + 1; -@mixin interactive-frame() { - @include interactive-border; - @include interactive-background; -} - -@mixin interactive-border( - $default: var(--color-btn-border), - $hover: var(--color-btn-hover-border), - $active: var(--color-btn-active-border), - $focus: $hover -) { - border: 1px solid $default; - &:hover { - border: 1px solid $hover; - } - &:focus { - border: 1px solid $focus; - } - &:active { - border: 1px solid $active; - } -} - -@mixin interactive-background( - $default: var(--color-btn-bg), - $hover: var(--color-btn-hover-bg), - $active: var(--color-btn-active-bg), - $focus: $hover -) { - background-color: $default; - &:hover { - background-color: $hover; - } - &:focus { - background-color: $focus; - } - &:active { - background-color: $active; - } -} - -@mixin interactive-background-on-before($default, $hover, $active, $focus: $hover) { - &::before { - background-color: $default; - } - &:hover { - &::before { - background-color: $hover; - } - } - &:active { - &::before { - background-color: $active; - } - } - &:hover { - &::before { - background-color: $hover; - } - } -} - @mixin hide-for-print { @media print { display: none; } } -@mixin pseudo-primer-icon($icon-name) { - content: ''; - display: block; - cursor: pointer; - user-select: none; - -webkit-mask-image: url('~@primer/octicons-react/build/svg/' + $icon-name + '.svg?inline'); - mask-image: url('~@primer/octicons-react/build/svg/' + $icon-name + '.svg?inline'); - -webkit-mask-size: contain; - mask-size: contain; - -webkit-mask-position: center; - mask-position: center; -} - [data-#{$name}-ready='true'] { - // github - // code folding start - .blob-wrapper table .blob-num { - position: relative; // for positioning - min-width: 60px; - padding-right: 20px; - } + @include code-folding; - // hide code fold handler if not enabled - .gitako-code-fold-handler { - display: none; - } - - .gitako-code-fold-attached:not(.gitako-code-fold-attached-disabled) { - tr { - .gitako-code-fold-handler { - @include hide-for-print(); - - display: initial; - position: absolute; - top: 0px; - right: 0px; - width: 20px; - height: 20px; - display: flex; - justify-content: center; - align-items: center; - - &::before { - width: 16px; - height: 20px; - transition: 0.25s ease; - @include pseudo-primer-icon('chevron-down-16'); - } - - @include interactive-background-on-before( - var(--color-fg-subtle), - var(--color-fg-default), - var(--color-fg-muted) - ); - } - - &.gitako-code-fold-active { - background-color: var(--color-neutral-muted); - - .gitako-code-fold-handler { - &::before { - transform: rotate(-90deg); - } - - @include interactive-background-on-before( - var(--color-fg-muted), - var(--color-fg-default), - var(--color-fg-subtle) - ); - } - - .blob-code::after { - color: var(--color-fg-muted); - content: '⋯'; - margin: 0.1em 0.2em 0px; - } - } - - // hide folded sections, except for print - &.gitako-code-fold-hidden { - @media screen { - display: none; - } - } - } - } - // code folding end - - // clippy button .markdown-body { - .clippy-wrapper { - position: relative; - width: 0; - height: 0; - top: 8px; - left: calc(100% - 40px); - z-index: 1; - - .clippy { - width: 32px; - height: 32px; - border: 1px solid var(--color-border-default); - border-radius: 4px; - - @include interactive-background; - .icon { - width: 100%; - height: 100%; - display: block; - background-image: url('~@primer/octicons-react/build/svg/copy-16.svg?inline'); - background-position: center; - background-repeat: no-repeat; - &.success { - background-image: url('~@primer/octicons-react/build/svg/check-16.svg?inline'); - } - &.fail { - background-image: url('~@primer/octicons-react/build/svg/x-16.svg?inline'); - } - } - } - } - } - - // gitee - &.git-project { - #git-header-nav { - position: static; - padding-left: 0; - padding-right: 0; - } - - padding-top: 0; - - .#{$name}-side-bar { - h1, - h2, - h3, - h4, - h5, - h6 { - margin: 0; - } - } + @include clippy; } } @@ -237,35 +45,12 @@ html[data-with-gitako-spacing='true'] { @media screen { margin-left: var(--gitako-width); } - - // gitee - &.git-project { - width: auto; // shrink width - .site-content { - min-width: 1040px; - } - } } } -@media (min-width: $github-content-width) { - // github - body.env-production { - min-width: $github-content-width; - } -} - -// TODO: use react to render the button content and set color with CSS variables -@media (prefers-color-scheme: dark) { - :root[data-color-mode='auto'] .markdown-body .clippy .icon { - filter: invert(0.7); // hack to make it looks like a normal color :P - } -} -:root[data-color-mode='dark'] { - .markdown-body .clippy .icon { - filter: invert(0.7); // hack to make it looks like a normal color :P - } -} +// platform-specific overrides +@include gitee($name); +@include github($github-content-width); .progress-pjax-loader.is-loading { left: 0; /* reposition progress bar of GitHub */ @@ -275,39 +60,6 @@ html[data-with-gitako-spacing='true'] { display: none; } -@mixin flex($justify: center, $align: center, $inline: true) { - @if $inline { - display: inline-flex; - } @else { - display: flex; - } - - @if $justify { - justify-content: $justify; - } - @if $align { - align-items: $align; - } -} - -@mixin flex-center { - @include flex(); -} - -@mixin icon-button( - $default: transparent, - $hover: var(--color-btn-hover-bg), - $active: var(--color-btn-focus-bg), - $focus: $hover -) { - @include flex-center(); - cursor: pointer; - padding: 0; - border: none; - - @include interactive-background($default, $hover, $active, $focus); -} - ##{$name}-root { @include enableThemes; } @@ -735,6 +487,7 @@ html[data-with-gitako-spacing='true'] { } } + .context-menu, .go-to-button, .find-in-folder-button { @include icon-button(); @@ -749,6 +502,7 @@ html[data-with-gitako-spacing='true'] { } &.compact { + .context-menu, .go-to-button, .find-in-folder-button { border-radius: 4px; @@ -758,6 +512,7 @@ html[data-with-gitako-spacing='true'] { } &:not(:hover) { + .context-menu:not(.active), .go-to-button, .find-in-folder-button { display: none; @@ -822,7 +577,7 @@ html[data-with-gitako-spacing='true'] { } } - .gitako-footer { + .#{$name}-footer { flex-shrink: 0; display: flex; justify-content: space-between; @@ -836,51 +591,3 @@ html[data-with-gitako-spacing='true'] { } } } - -// gitee -.git-project { - // reset styles - .#{$name}-side-bar { - input[type='text'], - input[type='password'], - .ui-autocomplete-input, - textarea, - .uneditable-input { - padding: initial; - border: none; - } - } -} - -@keyframes rotate { - from { - transform: rotateZ(0); - } - to { - transform: rotateZ(360deg); - } -} - -@keyframes pulse-rotate { - 0% { - transform: rotateZ(0); - } - 20% { - transform: rotateZ(190deg); - } - 30% { - transform: rotateZ(175deg); - } - 50% { - transform: rotateZ(180deg); - } - 70% { - transform: rotateZ(370deg); - } - 80% { - transform: rotateZ(355deg); - } - 100% { - transform: rotateZ(360deg); - } -} diff --git a/src/styles/keyframes.scss b/src/styles/keyframes.scss new file mode 100644 index 0000000..bad0e71 --- /dev/null +++ b/src/styles/keyframes.scss @@ -0,0 +1,32 @@ +@keyframes rotate { + from { + transform: rotateZ(0); + } + to { + transform: rotateZ(360deg); + } +} + +@keyframes pulse-rotate { + 0% { + transform: rotateZ(0); + } + 20% { + transform: rotateZ(190deg); + } + 30% { + transform: rotateZ(175deg); + } + 50% { + transform: rotateZ(180deg); + } + 70% { + transform: rotateZ(370deg); + } + 80% { + transform: rotateZ(355deg); + } + 100% { + transform: rotateZ(360deg); + } +} diff --git a/src/styles/layout.scss b/src/styles/layout.scss new file mode 100644 index 0000000..0dba2c1 --- /dev/null +++ b/src/styles/layout.scss @@ -0,0 +1,18 @@ +@mixin flex($justify: center, $align: center, $inline: true) { + @if $inline { + display: inline-flex; + } @else { + display: flex; + } + + @if $justify { + justify-content: $justify; + } + @if $align { + align-items: $align; + } +} + +@mixin flex-center { + @include flex(); +} diff --git a/src/styles/primer-like.scss b/src/styles/primer-like.scss new file mode 100644 index 0000000..190a3c6 --- /dev/null +++ b/src/styles/primer-like.scss @@ -0,0 +1,91 @@ +// primer-like styles +@import './themes.scss'; + +@mixin interactive-frame() { + @include interactive-border; + @include interactive-background; +} + +@mixin interactive-border( + $default: var(--color-btn-border), + $hover: var(--color-btn-hover-border), + $active: var(--color-btn-active-border), + $focus: $hover +) { + border: 1px solid $default; + &:hover { + border: 1px solid $hover; + } + &:focus { + border: 1px solid $focus; + } + &:active { + border: 1px solid $active; + } +} + +@mixin interactive-background( + $default: var(--color-btn-bg), + $hover: var(--color-btn-hover-bg), + $active: var(--color-btn-active-bg), + $focus: $hover +) { + background-color: $default; + &:hover { + background-color: $hover; + } + &:focus { + background-color: $focus; + } + &:active { + background-color: $active; + } +} + +@mixin interactive-background-on-before($default, $hover, $active, $focus: $hover) { + &::before { + background-color: $default; + } + &:hover { + &::before { + background-color: $hover; + } + } + &:active { + &::before { + background-color: $active; + } + } + &:hover { + &::before { + background-color: $hover; + } + } +} + +@mixin pseudo-primer-icon($icon-name) { + content: ''; + display: block; + cursor: pointer; + user-select: none; + -webkit-mask-image: url('~@primer/octicons-react/build/svg/' + $icon-name + '.svg?inline'); + mask-image: url('~@primer/octicons-react/build/svg/' + $icon-name + '.svg?inline'); + -webkit-mask-size: contain; + mask-size: contain; + -webkit-mask-position: center; + mask-position: center; +} + +@mixin icon-button( + $default: transparent, + $hover: var(--color-btn-hover-bg), + $active: var(--color-btn-focus-bg), + $focus: $hover +) { + @include flex-center(); + cursor: pointer; + padding: 0; + border: none; + + @include interactive-background($default, $hover, $active, $focus); +} diff --git a/src/utils/$.ts b/src/utils/$.ts new file mode 100644 index 0000000..b5d0241 --- /dev/null +++ b/src/utils/$.ts @@ -0,0 +1,20 @@ +export function $(selector: string): HTMLElement | null +export function $(selector: string, existCallback: (element: HTMLElement) => T1): T1 | null +export function $( + selector: string, + existCallback: (element: HTMLElement) => T1, + otherwise: () => T2, +): T1 | T2 +export function $( + selector: string, + existCallback: undefined | null, + otherwise: () => T2, +): HTMLElement | T2 +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function $(selector: string, existCallback?: any, otherwise?: any) { + const element = document.querySelector(selector) + if (element) { + return existCallback ? existCallback(element) : element + } + return otherwise ? otherwise() : null +} diff --git a/src/utils/DOMHelper.ts b/src/utils/DOMHelper.ts index 1f42159..fdad55f 100644 --- a/src/utils/DOMHelper.ts +++ b/src/utils/DOMHelper.ts @@ -2,48 +2,92 @@ * this helper helps manipulating DOM */ +import { platformName } from 'platforms' +import { $ } from './$' + export const rootElementID = 'gitako-root' export const gitakoDescriptionTarget = document.documentElement +// Some custom attributes added to GitHub html would be removed by GitHub when some events happen +function attachStickyAttribute( + target: Node, + shouldAttach: (mutation: MutationRecord) => boolean, + attach: (mutation: MutationRecord) => void, + mutationOptions?: MutationObserverInit, +) { + const observer = new MutationObserver(mutations => { + for (const mutation of mutations) if (shouldAttach(mutation)) attach(mutation) + }) + + observer.observe(target, { + attributeOldValue: true, + attributes: true, + ...mutationOptions, + }) + + return () => observer.disconnect() +} + +export const attachStickyDataAttribute = ( + target: HTMLElement, + attributeName: string, + attach: (mutation: MutationRecord) => void, +) => + attachStickyAttribute(target, () => !target.getAttribute(attributeName), attach, { + attributeFilter: [attributeName], + }) + +export const attachStickyStyle = ( + target: HTMLElement, + styleName: string, + attach: (mutation: MutationRecord) => void, +) => + attachStickyAttribute( + target, + () => !target.style.getPropertyValue(styleName), // `''` if not exist + attach, + { attributeFilter: ['style'] }, + ) + /** - * when gitako is ready, make page's header narrower - * or cancel it + * when gitako is ready, attach attribute to activate CSS selectors + * e.g. make page's header narrower on pin sidebar */ +const readyDataAttributeName = 'data-gitako-ready' +export const attachStickyGitakoReadyState = () => + attachStickyDataAttribute(gitakoDescriptionTarget, readyDataAttributeName, ({ oldValue }) => + markGitakoReadyState(oldValue === 'true'), + ) export function markGitakoReadyState(ready: boolean) { - const readyAttributeName = 'data-gitako-ready' - return gitakoDescriptionTarget.setAttribute(readyAttributeName, `${ready}`) + return gitakoDescriptionTarget.setAttribute(readyDataAttributeName, `${ready}`) +} + +/** + * indicate current platform to activate specific CSS styles + */ +const platformDataAttributeName = 'data-gitako-platform' +export const attachStickyGitakoPlatform = () => + attachStickyDataAttribute(gitakoDescriptionTarget, platformDataAttributeName, () => + markGitakoPlatform(), + ) +export function markGitakoPlatform() { + if (platformName) + return gitakoDescriptionTarget.setAttribute(platformDataAttributeName, platformName) } /** * if should show gitako, then move body right to make space for showing gitako * otherwise, hide the space */ -export const spacingAttributeName = 'data-with-gitako-spacing' +const spacingAttributeName = 'data-with-gitako-spacing' +export const attachStickyBodyIndent = () => + attachStickyDataAttribute(gitakoDescriptionTarget, spacingAttributeName, ({ oldValue }) => + setBodyIndent(oldValue === 'true'), + ) export function setBodyIndent(shouldShowGitako: boolean) { gitakoDescriptionTarget.setAttribute(spacingAttributeName, `${shouldShowGitako}`) } -export function $(selector: string): HTMLElement | null -export function $(selector: string, existCallback: (element: HTMLElement) => T1): T1 | null -export function $( - selector: string, - existCallback: (element: HTMLElement) => T1, - otherwise: () => T2, -): T1 | T2 -export function $( - selector: string, - existCallback: undefined | null, - otherwise: () => T2, -): HTMLElement | T2 -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function $(selector: string, existCallback?: any, otherwise?: any) { - const element = document.querySelector(selector) - if (element) { - return existCallback ? existCallback(element) : element - } - return otherwise ? otherwise() : null -} - /** * DOM Structure after calling the `insert*MountPoint` functions * @@ -141,8 +185,13 @@ export function setCSSVariable(name: string, value: string | undefined, element: else element.style.setProperty(name, value) } +const gitakoWidthVariable = '--gitako-width' +export const attachStickyGitakoWidthCSSVariable = (getLatestSize: () => number) => + attachStickyStyle(gitakoDescriptionTarget, gitakoWidthVariable, () => { + setGitakoWidthCSSVariable(getLatestSize()) + }) export const setGitakoWidthCSSVariable = (size: number) => { - setCSSVariable('--gitako-width', `${size}px`, gitakoDescriptionTarget) + setCSSVariable(gitakoWidthVariable, `${size}px`, gitakoDescriptionTarget) } export function formatID(id: string) { @@ -157,7 +206,14 @@ export function parseIntFromElement(e: HTMLElement): number { return parseInt((e.innerText || '').replace(/[^0-9]/g, '')) } -export function cancelEvent(e: KeyboardEvent): void { +export function cancelEvent(e: Event | React.BaseSyntheticEvent): void { e.stopPropagation() e.preventDefault() } + +export function onEnterKeyDown( + e: React.KeyboardEvent, + callback: (e: React.KeyboardEvent) => void, +) { + if (e.key === 'Enter') callback(e) +} diff --git a/src/utils/VisibleNodesGenerator/BaseLayer.ts b/src/utils/VisibleNodesGenerator/BaseLayer.ts new file mode 100644 index 0000000..d4db586 --- /dev/null +++ b/src/utils/VisibleNodesGenerator/BaseLayer.ts @@ -0,0 +1,49 @@ +import { EventHub } from '../EventHub' +import { findNode } from '../general' +import { Options } from './index' + +function mergeNodes(target: TreeNode, source: TreeNode) { + for (const node of source.contents || []) { + const dup = target.contents?.find($node => $node.path === node.path) + if (dup) { + mergeNodes(dup, node) + } else { + if (!target.contents) target.contents = [] + target.contents.push(node) + } + } +} + +export class BaseLayer { + baseRoot: TreeNode + getTreeData: (path: string) => Async + loading: Set = new Set() + defer: boolean + + baseHub = new EventHub<{ + emit: BaseLayer['baseRoot'] + loadingChange: BaseLayer['loading'] + }>() + + constructor({ root, getTreeData, defer = false }: Options) { + this.baseRoot = root + this.getTreeData = getTreeData + this.defer = defer + } + + loadTreeData = async (path: string) => { + const node = await findNode(this.baseRoot, path) + if (node && node.type !== 'tree') return node + if (node?.contents?.length) return node // check in memory + if (this.loading.has(path)) return + + this.loading.add(path) + this.baseHub.emit('loadingChange', this.loading) + mergeNodes(this.baseRoot, await this.getTreeData(path)) + this.loading.delete(path) + this.baseHub.emit('loadingChange', this.loading) + this.baseHub.emit('emit', this.baseRoot) + + return await findNode(this.baseRoot, path) + } +} diff --git a/src/utils/VisibleNodesGenerator/CompressLayer.ts b/src/utils/VisibleNodesGenerator/CompressLayer.ts new file mode 100644 index 0000000..226403f --- /dev/null +++ b/src/utils/VisibleNodesGenerator/CompressLayer.ts @@ -0,0 +1,79 @@ +import { EventHub } from '../EventHub' +import { withEffect } from '../general' +import { Options } from './index' +import { ShakeLayer } from './ShakeLayer' + +function compressTree(root: TreeNode, prefix: string[] = []): TreeNode { + if (root.contents) { + if (root.contents.length === 1) { + const [singleton] = root.contents + if (singleton.type === 'tree') { + return compressTree(singleton, [...prefix, root.name]) + } + } + + let compressed = false + const contents = [] + for (const node of root.contents) { + const $node = compressTree(node) + if ($node !== node) compressed = true + contents.push($node) + } + if (compressed) + return { + ...root, + name: [...prefix, root.name].join('/'), + contents, + } + } + return prefix.length + ? { + ...root, + name: [...prefix, root.name].join('/'), + } + : root +} + +export class CompressLayer extends ShakeLayer { + private compress: boolean + depths = new Map() + compressedRoot: TreeNode | null = null + compressHub = new EventHub<{ emit: TreeNode | null }>() + + constructor(options: Options) { + super(options) + this.compress = Boolean(options.compress) + + this.shakeHub.addEventListener('emit', () => this.compressTree()) + } + + setCompression(compress: CompressLayer['compress']) { + this.compress = compress + this.compressTree() + } + + private compressTree = withEffect( + () => { + this.compressedRoot = + this.compress && this.shackedRoot + ? { + ...this.shackedRoot, + contents: this.shackedRoot.contents?.map(node => compressTree(node)), + } + : this.shackedRoot + + if (this.compressedRoot) { + const depths = new Map() + const recordDepth = (node: TreeNode, depth = 0) => { + depths.set(node, depth) + for (const $node of node.contents || []) { + recordDepth($node, depth + 1) + } + } + recordDepth(this.compressedRoot, -1) + this.depths = depths + } + }, + () => this.compressHub.emit('emit', this.compressedRoot), + ) +} diff --git a/src/utils/VisibleNodesGenerator/FlattenLayer.ts b/src/utils/VisibleNodesGenerator/FlattenLayer.ts new file mode 100644 index 0000000..f26c454 --- /dev/null +++ b/src/utils/VisibleNodesGenerator/FlattenLayer.ts @@ -0,0 +1,140 @@ +import { EventHub } from '../EventHub' +import { findNode, traverse, withEffect } from '../general' +import { CompressLayer } from './CompressLayer' +import { Options, SearchParams } from './index' + +export class FlattenLayer extends CompressLayer { + focusedNode: TreeNode | null = null + nodes: TreeNode[] = [] + expandedNodes: Set = new Set() + backupExpandedNodes: Set = new Set() + flattenHub = new EventHub<{ emit: null }>() + + constructor(options: Options) { + super(options) + + this.compressHub.addEventListener('emit', () => this.generateVisibleNodes()) + } + + generateVisibleNodes = withEffect( + async () => { + const nodes: TreeNode[] = [] + const focusedNode = this.focusedNode + + if ( + focusedNode && + this.compressedRoot && + !(await findNode(this.compressedRoot, focusedNode.path)) + ) { + // rescue the focus after expanding async singleton folder + await traverse( + this.compressedRoot.contents, + node => { + if (node.type === 'tree' && node.path.startsWith(focusedNode.path)) { + this.focusNode(node) + } + + return node.type === 'tree' && this.expandedNodes.has(node.path) + }, + node => node.contents || [], + ) + } + + await traverse( + this.compressedRoot?.contents, + node => { + nodes.push(node) + return node.type === 'tree' && this.expandedNodes.has(node.path) + }, + node => node.contents || [], + ) + this.nodes = nodes + }, + () => this.flattenHub.emit('emit', null), + ) + + focusNode = (node: TreeNode | null) => { + if (this.focusedNode !== node) { + this.focusedNode = node + this.flattenHub.emit('emit', null) + } + } + + barelySetExpand = (node: TreeNode, expand: boolean) => { + // expanding non-tree node could cause unexpected UX + if (node.type === 'tree') { + if (expand) this.expandedNodes.add(node.path) + else this.expandedNodes.delete(node.path) + } + } + + $setExpand = (node: TreeNode, expand: boolean) => { + this.barelySetExpand(node, expand) + // The `node.contents?.length` condition is critical to search performance as it reduces lots of function calls + if (expand && node.type === 'tree' && !node.contents?.length) + return this.loadTreeData(node.path) + } + setExpand = withEffect(this.$setExpand, this.generateVisibleNodes) + + toggleExpand = withEffect(async (node: TreeNode, recursive = false) => { + const expand = !this.expandedNodes.has(node.path) + await traverse( + [node], + node => { + this.$setExpand(node, expand) + return recursive + }, + node => node.contents || [], + ) + }, this.generateVisibleNodes) + + expandTo = withEffect(async (path: string) => { + const rootNode = this.compressedRoot + if (rootNode) { + await traverse( + rootNode.contents, + async node => { + const overflowChar = path[node.path.length] + const match = path.startsWith(node.path) && (overflowChar === '/' || !overflowChar) + if (match) { + if (node.path === path) { + // do not wait for expansion for the exact node as that will block "jumping from search" + this.$setExpand(node, true) + } else await this.$setExpand(node, true) + } + return match + }, + node => node?.contents || [], + ) + + const node = await findNode(rootNode, path) + return node + } + }, this.generateVisibleNodes) + + search = ( + searchParams: Pick | null, + restoreExpandedFolders?: boolean, + ) => { + if (searchParams) { + if (!this.isSearching) { + // backup expansion when start search + this.backupExpandedNodes.clear() + this.expandedNodes.forEach(path => this.backupExpandedNodes.add(path)) + } + this.expandedNodes.clear() // Reset expansion on every search, ensure cleanest search result expansion + this.shake({ + matchNode: searchParams.matchNode, + onChildMatch: node => this.$setExpand(node, true), + }) + } else { + this.shake(null) + // collapse all nodes on clearing search key + this.expandedNodes.clear() + if (restoreExpandedFolders) { + this.backupExpandedNodes.forEach(path => this.expandedNodes.add(path)) + this.backupExpandedNodes.clear() + } + } + } +} diff --git a/src/utils/VisibleNodesGenerator/ShakeLayer.ts b/src/utils/VisibleNodesGenerator/ShakeLayer.ts new file mode 100644 index 0000000..e85ab55 --- /dev/null +++ b/src/utils/VisibleNodesGenerator/ShakeLayer.ts @@ -0,0 +1,66 @@ +import { EventHub } from '../EventHub' +import { withEffect } from '../general' +import { BaseLayer } from './BaseLayer' +import { Options, SearchParams } from './index' + +function search( + root: TreeNode, + match: (node: TreeNode) => boolean, + onChildMatch: (node: TreeNode) => void, +): TreeNode | null { + // go traverse no matter whether root matches to make sure find & expand the related nodes + // The `related nodes` are the nodes that either itself matches or any of direct or indirect children match + const contents = [] + + if (root.type === 'tree' && root.contents) { + for (const node of root.contents) { + const $node = search(node, match, onChildMatch) + if ($node) contents.push($node) + } + + if (contents.length) onChildMatch(root) + } + + // Return root if itself matches + if (match(root)) return root + + // Otherwise, but when deeper nodes match, return partial root + if (contents.length) { + return { + ...root, + contents, + } + } + + return null +} + +export class ShakeLayer extends BaseLayer { + shackedRoot: TreeNode | null = this.baseRoot + lastSearchParams: SearchParams | null = null + shakeHub = new EventHub<{ emit: TreeNode | null }>() + + get isSearching() { + return this.lastSearchParams !== null + } + + constructor(options: Options) { + super(options) + + this.baseHub.addEventListener('emit', () => this.shake(this.lastSearchParams)) + } + + shake = withEffect( + (searchParams: ShakeLayer['lastSearchParams']) => { + this.lastSearchParams = searchParams + + let root: ShakeLayer['shackedRoot'] = this.baseRoot + if (searchParams) { + const { matchNode, onChildMatch } = searchParams + root = search(this.baseRoot, matchNode, onChildMatch) + } + this.shackedRoot = root + }, + () => this.shakeHub.emit('emit', this.shackedRoot), + ) +} diff --git a/src/utils/VisibleNodesGenerator/index.ts b/src/utils/VisibleNodesGenerator/index.ts index ae1202a..12f7fde 100644 --- a/src/utils/VisibleNodesGenerator/index.ts +++ b/src/utils/VisibleNodesGenerator/index.ts @@ -1,326 +1,14 @@ import { EventHub } from '../EventHub' -import { findNode, traverse, withEffect } from '../general' - -function search( - root: TreeNode, - match: (node: TreeNode) => boolean, - onChildMatch: (node: TreeNode) => void, -): TreeNode | null { - // go traverse no matter whether root matches to make sure find & expand the related nodes - // The `related nodes` are the nodes that either itself matches or any of direct or indirect children match - const contents = [] - - if (root.type === 'tree' && root.contents) { - for (const node of root.contents) { - const $node = search(node, match, onChildMatch) - if ($node) contents.push($node) - } - - if (contents.length) onChildMatch(root) - } - - // Return root if itself matches - if (match(root)) return root - - // Otherwise, but when deeper nodes match, return partial root - if (contents.length) { - return { - ...root, - contents, - } - } - - return null -} - -function compressTree(root: TreeNode, prefix: string[] = []): TreeNode { - if (root.contents) { - if (root.contents.length === 1) { - const [singleton] = root.contents - if (singleton.type === 'tree') { - return compressTree(singleton, [...prefix, root.name]) - } - } - - let compressed = false - const contents = [] - for (const node of root.contents) { - const $node = compressTree(node) - if ($node !== node) compressed = true - contents.push($node) - } - if (compressed) - return { - ...root, - name: [...prefix, root.name].join('/'), - contents, - } - } - return prefix.length - ? { - ...root, - name: [...prefix, root.name].join('/'), - } - : root -} - -function mergeNodes(target: TreeNode, source: TreeNode) { - for (const node of source.contents || []) { - const dup = target.contents?.find($node => $node.path === node.path) - if (dup) { - mergeNodes(dup, node) - } else { - if (!target.contents) target.contents = [] - target.contents.push(node) - } - } -} - -class BaseLayer { - baseRoot: TreeNode - getTreeData: (path: string) => Async - loading: Set = new Set() - defer: boolean - - baseHub = new EventHub<{ - emit: BaseLayer['baseRoot'] - loadingChange: BaseLayer['loading'] - }>() - - constructor({ root, getTreeData, defer = false }: Options) { - this.baseRoot = root - this.getTreeData = getTreeData - this.defer = defer - } - - loadTreeData = async (path: string) => { - const node = await findNode(this.baseRoot, path) - if (node && node.type !== 'tree') return node - if (node?.contents?.length) return node // check in memory - if (this.loading.has(path)) return - - this.loading.add(path) - this.baseHub.emit('loadingChange', this.loading) - mergeNodes(this.baseRoot, await this.getTreeData(path)) - this.loading.delete(path) - this.baseHub.emit('loadingChange', this.loading) - this.baseHub.emit('emit', this.baseRoot) - - return await findNode(this.baseRoot, path) - } -} - -class ShakeLayer extends BaseLayer { - shackedRoot: TreeNode | null = this.baseRoot - lastSearchParams: SearchParams | null = null - shakeHub = new EventHub<{ emit: TreeNode | null }>() - - get isSearching() { - return this.lastSearchParams !== null - } - - constructor(options: Options) { - super(options) - - this.baseHub.addEventListener('emit', () => this.shake(this.lastSearchParams)) - } - - shake = withEffect( - (searchParams: ShakeLayer['lastSearchParams']) => { - this.lastSearchParams = searchParams - - let root: ShakeLayer['shackedRoot'] = this.baseRoot - if (searchParams) { - const { matchNode, onChildMatch } = searchParams - root = search(this.baseRoot, matchNode, onChildMatch) - } - this.shackedRoot = root - }, - () => this.shakeHub.emit('emit', this.shackedRoot), - ) -} - -class CompressLayer extends ShakeLayer { - private compress: boolean - depths = new Map() - compressedRoot: TreeNode | null = null - compressHub = new EventHub<{ emit: TreeNode | null }>() - - constructor(options: Options) { - super(options) - this.compress = Boolean(options.compress) - - this.shakeHub.addEventListener('emit', () => this.compressTree()) - } - - private compressTree = withEffect( - () => { - this.compressedRoot = - this.shackedRoot && this.compress - ? { - ...this.shackedRoot, - contents: this.shackedRoot.contents?.map(node => compressTree(node)), - } - : this.shackedRoot - - if (this.compressedRoot) { - const depths = new Map() - const recordDepth = (node: TreeNode, depth = 0) => { - depths.set(node, depth) - for (const $node of node.contents || []) { - recordDepth($node, depth + 1) - } - } - recordDepth(this.compressedRoot, -1) - this.depths = depths - } - }, - () => this.compressHub.emit('emit', this.compressedRoot), - ) -} +import { BaseLayer } from './BaseLayer' +import { CompressLayer } from './CompressLayer' +import { FlattenLayer } from './FlattenLayer' export type SearchParams = { matchNode(node: TreeNode): boolean onChildMatch(node: TreeNode): void } -class FlattenLayer extends CompressLayer { - focusedNode: TreeNode | null = null - nodes: TreeNode[] = [] - expandedNodes: Set = new Set() - backupExpandedNodes: Set = new Set() - flattenHub = new EventHub<{ emit: null }>() - - constructor(options: Options) { - super(options) - - this.compressHub.addEventListener('emit', () => this.generateVisibleNodes()) - } - - generateVisibleNodes = withEffect( - async () => { - const nodes: TreeNode[] = [] - const focusedNode = this.focusedNode - - if ( - focusedNode && - this.compressedRoot && - !(await findNode(this.compressedRoot, focusedNode.path)) - ) { - // rescue the focus after expanding async singleton folder - await traverse( - this.compressedRoot.contents, - node => { - if (node.type === 'tree' && node.path.startsWith(focusedNode.path)) { - this.focusNode(node) - } - - return node.type === 'tree' && this.expandedNodes.has(node.path) - }, - node => node.contents || [], - ) - } - - await traverse( - this.compressedRoot?.contents, - node => { - nodes.push(node) - return node.type === 'tree' && this.expandedNodes.has(node.path) - }, - node => node.contents || [], - ) - this.nodes = nodes - }, - () => this.flattenHub.emit('emit', null), - ) - - focusNode = (node: TreeNode | null) => { - if (this.focusedNode !== node) { - this.focusedNode = node - this.flattenHub.emit('emit', null) - } - } - - barelySetExpand = (node: TreeNode, expand: boolean) => { - // expanding non-tree node could cause unexpected UX - if (node.type === 'tree') { - if (expand) this.expandedNodes.add(node.path) - else this.expandedNodes.delete(node.path) - } - } - - $setExpand = (node: TreeNode, expand: boolean) => { - this.barelySetExpand(node, expand) - // The `node.contents?.length` condition is critical to search performance as it reduces lots of function calls - if (expand && node.type === 'tree' && !node.contents?.length) - return this.loadTreeData(node.path) - } - setExpand = withEffect(this.$setExpand, this.generateVisibleNodes) - - toggleExpand = withEffect(async (node: TreeNode, recursive = false) => { - const expand = !this.expandedNodes.has(node.path) - await traverse( - [node], - node => { - this.$setExpand(node, expand) - return recursive - }, - node => node.contents || [], - ) - }, this.generateVisibleNodes) - - expandTo = withEffect(async (path: string) => { - const rootNode = this.compressedRoot - if (rootNode) { - await traverse( - rootNode.contents, - async node => { - const overflowChar = path[node.path.length] - const match = path.startsWith(node.path) && (overflowChar === '/' || !overflowChar) - if (match) { - if (node.path === path) { - // do not wait for expansion for the exact node as that will block "jumping from search" - this.$setExpand(node, true) - } else await this.$setExpand(node, true) - } - return match - }, - node => node?.contents || [], - ) - - const node = await findNode(rootNode, path) - return node - } - }, this.generateVisibleNodes) - - search = ( - searchParams: Pick | null, - restoreExpandedFolders?: boolean, - ) => { - if (searchParams) { - if (!this.isSearching) { - // backup expansion when start search - this.backupExpandedNodes.clear() - this.expandedNodes.forEach(path => this.backupExpandedNodes.add(path)) - } - this.expandedNodes.clear() // Reset expansion on every search, ensure cleanest search result expansion - this.shake({ - matchNode: searchParams.matchNode, - onChildMatch: node => this.$setExpand(node, true), - }) - } else { - this.shake(null) - // collapse all nodes on clearing search key - this.expandedNodes.clear() - if (restoreExpandedFolders) { - this.backupExpandedNodes.forEach(path => this.expandedNodes.add(path)) - this.backupExpandedNodes.clear() - } - } - } -} - -type Options = { +export type Options = { root: BaseLayer['baseRoot'] defer?: BaseLayer['defer'] getTreeData: BaseLayer['getTreeData'] @@ -339,6 +27,7 @@ export class VisibleNodesGenerator extends FlattenLayer { hub = new EventHub<{ emit: VisibleNodes }>() + constructor(options: Options) { super(options) diff --git a/src/utils/general.ts b/src/utils/general.ts index bfd8fe8..3587c47 100644 --- a/src/utils/general.ts +++ b/src/utils/general.ts @@ -163,7 +163,7 @@ export function hasUpperCase(input: string) { } export async function renderReact(element: ReactElement) { - return new Promise(resolve => { + return new Promise(resolve => { const mount = document.createElement('div') ReactDOM.render(element, mount, () => { resolve(mount.childNodes[0]) diff --git a/src/utils/getSafeWidth.test.ts b/src/utils/getSafeWidth.test.ts new file mode 100644 index 0000000..3fa04f6 --- /dev/null +++ b/src/utils/getSafeWidth.test.ts @@ -0,0 +1,28 @@ +import { getSafeWidth, MINIMAL_CONTENT_VIEWPORT_WIDTH, MINIMAL_WIDTH } from './getSafeWidth' + +it(`should shrink when window is being resized smaller`, () => { + const randomGrow = 100 * Math.random() + expect( + getSafeWidth( + MINIMAL_WIDTH + MINIMAL_CONTENT_VIEWPORT_WIDTH + randomGrow * 2, + MINIMAL_WIDTH + MINIMAL_CONTENT_VIEWPORT_WIDTH + randomGrow, + ), + ).toBe(MINIMAL_WIDTH + randomGrow) +}) + +it(`should not shrink when window is being resized smaller than minimal size`, () => { + const randomGrow = 100 * Math.random() + expect(getSafeWidth(0, MINIMAL_WIDTH + MINIMAL_CONTENT_VIEWPORT_WIDTH - randomGrow)).toBe( + MINIMAL_WIDTH, + ) +}) + +it(`should return user-preferred size if not reaching bounds`, () => { + const randomGrow = 100 * Math.random() + expect( + getSafeWidth( + MINIMAL_WIDTH + randomGrow, + MINIMAL_WIDTH + MINIMAL_CONTENT_VIEWPORT_WIDTH + randomGrow * 2, + ), + ).toBe(MINIMAL_WIDTH + randomGrow) +}) diff --git a/src/utils/getSafeWidth.ts b/src/utils/getSafeWidth.ts new file mode 100644 index 0000000..4fec42c --- /dev/null +++ b/src/utils/getSafeWidth.ts @@ -0,0 +1,14 @@ +import { Size } from '../components/Size' + +export const MINIMAL_CONTENT_VIEWPORT_WIDTH = 100 +export const MINIMAL_WIDTH = 240 + +export function getSafeWidth(width: Size, windowWidth: number) { + // if window width is too small, prevent reducing anymore + if (windowWidth < MINIMAL_WIDTH + MINIMAL_CONTENT_VIEWPORT_WIDTH) return MINIMAL_WIDTH + // if trying to enlarge to much, leave some space + if (width > windowWidth - MINIMAL_CONTENT_VIEWPORT_WIDTH) + return windowWidth - MINIMAL_CONTENT_VIEWPORT_WIDTH + if (width < MINIMAL_WIDTH) return MINIMAL_WIDTH + return width +} diff --git a/src/utils/hooks/useAbortableEffect.ts b/src/utils/hooks/useAbortableEffect.ts index 2e35c5a..3bde6d1 100644 --- a/src/utils/hooks/useAbortableEffect.ts +++ b/src/utils/hooks/useAbortableEffect.ts @@ -4,16 +4,32 @@ import { useEffect } from 'react' * This effect addresses such a problem: * the later effect ends earlier than the previous one, and the previous effect overlaps later effect's result. */ -export function useAbortableEffect( - effect: (shouldAbort: AbortSignal) => (() => void | undefined) | void, +export function useAbortableEffect( + effect: () => { + getAsyncGenerator: () => AsyncGenerator + cancel?: () => void + }, ) { useEffect(() => { const abortController = new AbortController() // The previous effect should stop running if the signal indicates should abort - const defect = effect(abortController.signal) + const { getAsyncGenerator, cancel } = effect() + runAbortableAsyncGenerator(getAsyncGenerator(), abortController.signal) return () => { + cancel?.() abortController.abort() - defect?.() } }, [effect]) } + +async function runAbortableAsyncGenerator( + generator: AsyncGenerator, + signal?: AbortSignal, +) { + let latestResult: IteratorResult | undefined + do { + if (signal?.aborted) return + latestResult = await generator.next(await latestResult?.value) + } while (!latestResult.done) + return latestResult.value +} diff --git a/src/utils/hooks/useCatchNetworkError.ts b/src/utils/hooks/useCatchNetworkError.ts deleted file mode 100644 index 6885c3b..0000000 --- a/src/utils/hooks/useCatchNetworkError.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { useConfigs } from 'containers/ConfigsContext' -import { errors, platformName } from 'platforms' -import { useCallback } from 'react' -import { assert } from 'utils/assert' -import { useLoadedContext } from 'utils/hooks/useLoadedContext' -import { SideBarErrorContext } from '../../containers/ErrorContext' -import { SideBarStateContext } from '../../containers/SideBarState' - -export function useCatchNetworkError() { - const { accessToken } = useConfigs().value - const stateContext = useLoadedContext(SideBarStateContext) - const errorContext = useLoadedContext(SideBarErrorContext) - - return useCallback( - async function (fn: () => T) { - try { - return await fn() // keep the await so that catch block can catch async errors - } catch (err) { - assert(err instanceof Error) - - if (err.message === errors.EMPTY_PROJECT) { - errorContext.onChange('This project seems to be empty.') - } else if (err.message === errors.BLOCKED_PROJECT) { - errorContext.onChange('Access to the project is blocked.') - } else if ( - err.message === errors.NOT_FOUND || - err.message === errors.BAD_CREDENTIALS || - err.message === errors.API_RATE_LIMIT - ) { - stateContext.onChange('error-due-to-auth') - } else if (err.message === errors.CONNECTION_BLOCKED) { - if (accessToken) { - errorContext.onChange(`Cannot connect to ${platformName}.`) - } else { - stateContext.onChange('error-due-to-auth') - } - } else if (err.message === errors.SERVER_FAULT) { - errorContext.onChange(`${platformName} server went down.`) - } else { - stateContext.onChange('disabled') - errorContext.onChange('Something unexpected happened.') - throw err - } - } - }, - [accessToken /* , stateContext.value, errorContext.value */], // eslint-disable-line react-hooks/exhaustive-deps - ) -} diff --git a/src/utils/hooks/useHandleNetworkError.ts b/src/utils/hooks/useHandleNetworkError.ts new file mode 100644 index 0000000..2e768ad --- /dev/null +++ b/src/utils/hooks/useHandleNetworkError.ts @@ -0,0 +1,52 @@ +import { useConfigs } from 'containers/ConfigsContext' +import { errors, platformName } from 'platforms' +import { useCallback } from 'react' +import { useLoadedContext } from 'utils/hooks/useLoadedContext' +import { SideBarErrorContext } from '../../containers/ErrorContext' +import { SideBarStateContext } from '../../containers/SideBarState' + +export function useHandleNetworkError() { + const { accessToken } = useConfigs().value + const changeErrorContext = useLoadedContext(SideBarErrorContext).onChange + const changeStateContext = useLoadedContext(SideBarStateContext).onChange + + return useCallback( + function handleNetworkError(err: Error) { + if (err.message === errors.EMPTY_PROJECT) { + changeErrorContext('This project seems to be empty.') + return + } + + if (err.message === errors.BLOCKED_PROJECT) { + changeErrorContext('Access to the project is blocked.') + return + } + + if ( + err.message === errors.NOT_FOUND || + err.message === errors.BAD_CREDENTIALS || + err.message === errors.API_RATE_LIMIT + ) { + changeStateContext('error-due-to-auth') + return + } + + if (err.message === errors.CONNECTION_BLOCKED) { + if (accessToken) changeErrorContext(`Cannot connect to ${platformName}.`) + else changeStateContext('error-due-to-auth') + + return + } + + if (err.message === errors.SERVER_FAULT) { + changeErrorContext(`${platformName} server went down.`) + return + } + + changeStateContext('disabled') + changeErrorContext('Something unexpected happened.') + throw err + }, + [accessToken, changeErrorContext, changeStateContext], + ) +}