mirror of
https://github.com/EnixCoda/Gitako.git
synced 2026-03-11 08:54:44 +00:00
Merge branch 'develop' into manifest-v3
# Conflicts: # scripts/fix-deps/index.js
This commit is contained in:
commit
305c6889cb
49 changed files with 1406 additions and 1064 deletions
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
23
scripts/fix-deps/@primer__behaviors.js
Normal file
23
scripts/fix-deps/@primer__behaviors.js
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
|
@ -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),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]))
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}}
|
||||
|
|
|
|||
5
src/containers/PortalContext.tsx
Normal file
5
src/containers/PortalContext.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import * as React from 'react'
|
||||
|
||||
export type PortalContextShape = string | null
|
||||
|
||||
export const PortalContext = React.createContext<PortalContextShape>(null)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
8
src/global.d.ts
vendored
|
|
@ -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?: {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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('/')}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect } from 'react'
|
||||
import { $ } from 'utils/DOMHelper'
|
||||
import { $ } from 'utils/$'
|
||||
import * as DOMHelper from '../DOMHelper'
|
||||
import { GitHub } from '../index'
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { raiseError } from 'analytics'
|
||||
import { $ } from 'utils/DOMHelper'
|
||||
import { $ } from 'utils/$'
|
||||
|
||||
export function isInRepoPage() {
|
||||
const repoHeaderSelector = '.repo-header'
|
||||
|
|
|
|||
|
|
@ -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
45
src/styles/clippy.scss
Normal 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
|
||||
}
|
||||
}
|
||||
72
src/styles/code-folding.scss
Normal file
72
src/styles/code-folding.scss
Normal 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
47
src/styles/gitee.scss
Normal 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
9
src/styles/github.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
32
src/styles/keyframes.scss
Normal 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
18
src/styles/layout.scss
Normal 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();
|
||||
}
|
||||
91
src/styles/primer-like.scss
Normal file
91
src/styles/primer-like.scss
Normal 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
20
src/utils/$.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
49
src/utils/VisibleNodesGenerator/BaseLayer.ts
Normal file
49
src/utils/VisibleNodesGenerator/BaseLayer.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
79
src/utils/VisibleNodesGenerator/CompressLayer.ts
Normal file
79
src/utils/VisibleNodesGenerator/CompressLayer.ts
Normal 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),
|
||||
)
|
||||
}
|
||||
140
src/utils/VisibleNodesGenerator/FlattenLayer.ts
Normal file
140
src/utils/VisibleNodesGenerator/FlattenLayer.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
66
src/utils/VisibleNodesGenerator/ShakeLayer.ts
Normal file
66
src/utils/VisibleNodesGenerator/ShakeLayer.ts
Normal 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),
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
28
src/utils/getSafeWidth.test.ts
Normal file
28
src/utils/getSafeWidth.test.ts
Normal 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
14
src/utils/getSafeWidth.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
52
src/utils/hooks/useHandleNetworkError.ts
Normal file
52
src/utils/hooks/useHandleNetworkError.ts
Normal 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],
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue