Merge branch 'develop' into manifest-v3

# Conflicts:
#	scripts/fix-deps/index.js
This commit is contained in:
EnixCoda 2023-01-01 21:42:35 +08:00
commit 305c6889cb
49 changed files with 1406 additions and 1064 deletions

View file

@ -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";

View file

@ -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",

View file

@ -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)
}
}

View file

@ -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()
}

View file

@ -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<HTMLDivElement>({
totalAmount,
rowHeight,
viewportHeight: height,
overScan: 10,
})
const $mode = useStateIO<AlignMode>('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 (
<div
style={{
height,
width: '100%',
overflow: 'auto',
}}
ref={ref}
onScroll={onScroll}
>
<div style={containerStyle}>
{visibleRows.map(({ row, style }) => {
const node = nodes[row]
return (
<Node
key={node.path}
node={node}
style={style}
depth={depths.get(node) || 0}
focused={focusedNode?.path === node.path}
loading={loading.has(node.path)}
expanded={expandedNodes.has(node.path)}
onClick={onNodeClick}
onFocus={onNodeFocus}
renderLabelText={renderLabelText}
renderActions={renderActions}
/>
)
})}
</div>
</div>
)
}

View file

@ -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<HTMLDivElement>(null)
return (
<a
href={node.url}
@ -52,13 +54,24 @@ export const Node = React.memo(function Node({
title={node.path}
target={node.type === 'commit' ? '_blank' : undefined}
rel="noopener noreferrer"
{...platform.delegateFastRedirectAnchorProps?.({ node })}
{...(node.type === 'blob' ? platform.delegateFastRedirectAnchorProps?.({ node }) : null)}
>
<div className={'node-item-label'}>
<NodeItemIcon node={node} open={expanded} loading={loading} />
{renderLabelText(node)}
</div>
{renderActions && <div className={'actions'}>{renderActions(node)}</div>}
{renderActions && (
<div
ref={ref}
className={'actions'}
onClick={e => {
// 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)}
</div>
)}
</a>
)
})

View file

@ -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<ListChildComponentProps, { data: NodeRendererContext }>) {
const { onNodeClick, onNodeFocus, renderLabelText, renderActions, visibleNodes } = data
if (!visibleNodes) return null
const { nodes, focusedNode, expandedNodes, loading, depths } = visibleNodes
const node = nodes[index]
return (
<Node
style={style}
key={node.path}
node={node}
depth={depths.get(node) || 0}
focused={focusedNode?.path === node.path}
loading={loading.has(node.path)}
expanded={expandedNodes.has(node.path)}
onClick={onNodeClick}
onFocus={onNodeFocus}
renderLabelText={renderLabelText}
renderActions={renderActions}
/>
)
})

View file

@ -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 <NodeContextMenu node={node} visibleNodesGeneratorMethods={methods} />
}
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<string | null>(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 (
<ActionList.Item {...getTriggerProps(onTrigger)}>
Copy permalink
{copyState.value && copied === mark ? (
<ActionList.TrailingVisual>
<CheckIcon />
</ActionList.TrailingVisual>
) : null}
</ActionList.Item>
)
})(),
copyLink:
node.url &&
(() => {
const mark = 'link'
const onTrigger = (e: React.SyntheticEvent) => {
cancelEvent(e)
if (node.url) {
copyToClipboard(node.url)
setCopied(mark)
}
}
return (
<ActionList.Item {...getTriggerProps(onTrigger)}>
Copy link
{copyState.value && copied === mark ? (
<ActionList.TrailingVisual>
<CheckIcon />
</ActionList.TrailingVisual>
) : null}
</ActionList.Item>
)
})(),
copyRelativePath: (() => {
const mark = 'path'
const onTrigger = (e: React.SyntheticEvent) => {
cancelEvent(e)
setCopied(mark)
copyToClipboard(node.path)
}
return (
<ActionList.Item {...getTriggerProps(onTrigger)}>
Copy relative path
{copyState.value && copied === mark ? (
<ActionList.TrailingVisual>
<CheckIcon />
</ActionList.TrailingVisual>
) : null}
</ActionList.Item>
)
})(),
openRawContent: node.rawLink && (
<ActionList.LinkItem
onKeyDown={e =>
onEnterKeyDown(e, () => e.target instanceof HTMLElement && e.target.click())
}
href={node.rawLink}
target="_blank"
rel="noopener noreferrer"
onClick={() => setIsOpen(false)}
>
Open raw content
<ActionList.TrailingVisual>
<CrossReferenceIcon />
</ActionList.TrailingVisual>
</ActionList.LinkItem>
),
goToDirectory: node.type === 'tree' && node.url && (
<ActionList.LinkItem
onKeyDown={e =>
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
</ActionList.LinkItem>
),
toggleFolderRecursively:
node.type === 'tree' &&
(() => {
const trigger = (e: React.SyntheticEvent) => {
cancelEvent(e)
toggleExpansion(node, { recursive: true })
setIsOpen(false)
}
return (
<ActionList.LinkItem
{...getTriggerProps(trigger)}
href={node.url}
rel="noopener noreferrer"
>
Toggle folder recursively
</ActionList.LinkItem>
)
})(),
}
return (
<AnchoredOverlay
renderAnchor={anchorProps => (
<button
{...anchorProps}
aria-label={`More actions`}
className={cx('context-menu', anchorProps.className, { active: isOpen })}
>
<Icon IconComponent={KebabHorizontalIcon} />
</button>
)}
open={isOpen}
onOpen={() => setIsOpen(true)}
onClose={() => setIsOpen(false)}
overlayProps={{
portalContainerName: portalName || undefined,
onKeyDown: e => cancelEvent(e),
}}
>
<ActionList>
{actionElements.copyPermalink}
{actionElements.copyLink}
{actionElements.copyRelativePath}
{(actionElements.openRawContent ||
actionElements.goToDirectory ||
actionElements.toggleFolderRecursively) && <ActionList.Divider />}
{actionElements.openRawContent}
{actionElements.goToDirectory}
{actionElements.toggleFolderRecursively}
</ActionList>
</AnchoredOverlay>
)
}
export function useRenderFileCommentAmounts() {
function renderFileCommentAmounts(node: TreeNode) {
return node.comments?.active ? (
@ -69,11 +248,7 @@ export function useRenderFindInFolderButton(
<button
title={'Find in folder...'}
className={'find-in-folder-button'}
onClick={e => {
e.stopPropagation()
e.preventDefault()
onSearch(node.path + '/', searchMode)
}}
onClick={() => onSearch(node.path + '/', searchMode)}
>
<Icon type="search" />
</button>
@ -93,11 +268,7 @@ export function useRenderGoToButton(searched: boolean, goTo: (path: string[]) =>
<button
title={'Reveal in file tree'}
className={'go-to-button'}
onClick={e => {
e.stopPropagation()
e.preventDefault()
goTo(node.path.split('/'))
}}
onClick={() => goTo(node.path.split('/'))}
>
<Icon type="go-to" />
</button>
@ -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<HTMLElement>) => onEnterKeyDown(e, onTrigger),
})

View file

@ -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

View file

@ -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],
),
)

View file

@ -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<HTMLDivElement>()
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
>
<div style={containerStyle}>
{visibleRows.map(({ row, style }) => {
const node = nodes[row]
return (
<Node
key={node.path}
node={node}
style={style}
depth={depths.get(node) || 0}
focused={focusedNode?.path === node.path}
loading={loading.has(node.path)}
expanded={expandedNodes.has(node.path)}
onClick={handleNodeClick}
onFocus={handleNodeFocus}
renderLabelText={renderLabelText}
renderActions={renderActions}
/>
)
})}
</div>
<PortalContext.Provider value={portalName}>
<div style={containerStyle}>
{visibleRows
.map(({ row, style }) => ({
node: nodes[row],
style,
}))
.map(({ node, style }) => (
<Node
key={node.path}
node={node}
style={style}
depth={depths.get(node) || 0}
focused={focusedNode?.path === node.path}
loading={loading.has(node.path)}
expanded={expandedNodes.has(node.path)}
onClick={handleNodeClick}
onFocus={handleNodeFocus}
renderLabelText={renderLabelText}
renderActions={renderActions}
/>
))}
</div>
</PortalContext.Provider>
</div>
</div>
</div>

View file

@ -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<HTMLButtonElement>,
) {
const {
variant = 'default',
size = 'medium',
@ -40,10 +43,10 @@ export function IconButton(props: IconButtonProps) {
].filter(is.not.undefined),
)
return (
<StyledButton sx={sxStyles} {...rest}>
<StyledButton sx={sxStyles} ref={ref} {...rest}>
<Box as="span" sx={{ display: 'inline-block' }}>
<Icon size={iconSize} />
</Box>
</StyledButton>
)
}
})

View file

@ -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<HTMLButtonElement>,
) {
return (
<IconButton
ref={ref}
variant="invisible"
title={props['aria-label']}
{...props}
@ -13,4 +17,4 @@ export function RoundIconButton(props: IconButtonProps) {
}}
/>
)
}
})

View file

@ -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={
<TextInput.Action
aria-label={toggleButtonDescription}
sx={{ color: 'fg.subtle' }}
onClick={() => {
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'}
</TextInput.Action>
<>
<TextInput.Action
disabled={!value}
onClick={() => onSearch('', searchMode)}
icon={XIcon}
aria-label="Clear"
/>
<TextInput.Action
aria-label={toggleButtonDescription}
sx={{ color: 'fg.subtle' }}
onClick={() => {
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'}
</TextInput.Action>
</>
}
/>
)

View file

@ -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])
}

View file

@ -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]))

View file

@ -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))
}}

View file

@ -0,0 +1,5 @@
import * as React from 'react'
export type PortalContextShape = string | null
export const PortalContext = React.createContext<PortalContextShape>(null)

View file

@ -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<MetaData | null>(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 <RepoContext.Provider value={metaData}>{children}</RepoContext.Provider>
}
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<MetaData | null>(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<string | null>(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<MetaData | null>(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
}

View file

@ -29,6 +29,9 @@ export const InspectorContextWrapper = IN_PRODUCTION_MODE
flexDirection: 'column',
}}
>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button onClick={() => setShow(false)}></button>
</div>
<pre
style={{
flex: 1,
@ -37,15 +40,12 @@ export const InspectorContextWrapper = IN_PRODUCTION_MODE
>
{JSON.stringify($.value, null, 2)}
</pre>
<div>
<button onClick={() => setShow(false)}></button>
</div>
</div>
) : (
<div
style={{
position: 'fixed',
bottom: '0',
top: '0',
right: '0',
}}
>

8
src/global.d.ts vendored
View file

@ -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<MetaData, 'repoName' | 'userName' | 'branchName'>,
'defaultBranchName'
>
type PartialMetaData = MakeOptional<MetaData, 'repoName' | 'userName' | 'branchName'>
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?: {

View file

@ -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'

View file

@ -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('/')}`
}

View file

@ -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: {

View file

@ -1,5 +1,5 @@
import { useEffect } from 'react'
import { $ } from 'utils/DOMHelper'
import { $ } from 'utils/$'
import * as DOMHelper from '../DOMHelper'
import { GitHub } from '../index'

View file

@ -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,

View file

@ -1,5 +1,5 @@
import { raiseError } from 'analytics'
import { $ } from 'utils/DOMHelper'
import { $ } from 'utils/$'
export function isInRepoPage() {
const repoHeaderSelector = '.repo-header'

View file

@ -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() {

45
src/styles/clippy.scss Normal file
View file

@ -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
}
}

View file

@ -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;
}
}
}
}
}

47
src/styles/gitee.scss Normal file
View file

@ -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;
}
}
}
}
}

9
src/styles/github.scss Normal file
View file

@ -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;
}
}
}
}

View file

@ -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);
}
}

32
src/styles/keyframes.scss Normal file
View file

@ -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);
}
}

18
src/styles/layout.scss Normal file
View file

@ -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();
}

View file

@ -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);
}

20
src/utils/$.ts Normal file
View file

@ -0,0 +1,20 @@
export function $(selector: string): HTMLElement | null
export function $<T1>(selector: string, existCallback: (element: HTMLElement) => T1): T1 | null
export function $<T1, T2>(
selector: string,
existCallback: (element: HTMLElement) => T1,
otherwise: () => T2,
): T1 | T2
export function $<T2>(
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
}

View file

@ -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 $<T1>(selector: string, existCallback: (element: HTMLElement) => T1): T1 | null
export function $<T1, T2>(
selector: string,
existCallback: (element: HTMLElement) => T1,
otherwise: () => T2,
): T1 | T2
export function $<T2>(
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 extends HTMLElement>(
e: React.KeyboardEvent<E>,
callback: (e: React.KeyboardEvent<E>) => void,
) {
if (e.key === 'Enter') callback(e)
}

View file

@ -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<TreeNode>
loading: Set<TreeNode['path']> = 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)
}
}

View file

@ -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<TreeNode, number>()
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<TreeNode, number>()
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),
)
}

View file

@ -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<TreeNode['path']> = new Set()
backupExpandedNodes: Set<TreeNode['path']> = 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<SearchParams, 'matchNode'> | 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()
}
}
}
}

View file

@ -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),
)
}

View file

@ -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<TreeNode>
loading: Set<TreeNode['path']> = 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<TreeNode, number>()
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<TreeNode, number>()
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<TreeNode['path']> = new Set()
backupExpandedNodes: Set<TreeNode['path']> = 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<SearchParams, 'matchNode'> | 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)

View file

@ -163,7 +163,7 @@ export function hasUpperCase(input: string) {
}
export async function renderReact(element: ReactElement) {
return new Promise<Node>(resolve => {
return new Promise<ChildNode>(resolve => {
const mount = document.createElement('div')
ReactDOM.render(element, mount, () => {
resolve(mount.childNodes[0])

View file

@ -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)
})

14
src/utils/getSafeWidth.ts Normal file
View file

@ -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
}

View file

@ -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<T, TReturn, TNext>(
effect: () => {
getAsyncGenerator: () => AsyncGenerator<T, TReturn, TNext>
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<T>(
generator: AsyncGenerator<unknown, T, unknown>,
signal?: AbortSignal,
) {
let latestResult: IteratorResult<unknown> | undefined
do {
if (signal?.aborted) return
latestResult = await generator.next(await latestResult?.value)
} while (!latestResult.done)
return latestResult.value
}

View file

@ -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 <T>(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
)
}

View file

@ -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],
)
}