Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 159 additions & 4 deletions src/components/FolderTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
VStack,
} from "@hope-ui/solid"
import { BiSolidRightArrow, BiSolidFolderOpen } from "solid-icons/bi"
import { TbX, TbCheck } from "solid-icons/tb"
import {
Accessor,
createContext,
Expand All @@ -28,21 +29,27 @@ import {
createEffect,
on,
JSXElement,
onMount,
} from "solid-js"
import { useFetch, useT, useUtil } from "~/hooks"
import { getMainColor, password } from "~/store"
import { Obj } from "~/types"
import {
pathBase,
handleResp,
handleRespWithNotifySuccess,
hoverColor,
pathJoin,
fsDirs,
createMatcher,
fsMkdir,
validateFilename,
notify,
} from "~/utils"

export type FolderTreeHandler = {
setPath: Setter<string>
startCreateFolder: () => void
}
export interface FolderTreeProps {
onChange: (path: string) => void
Expand All @@ -54,11 +61,25 @@ export interface FolderTreeProps {
}
interface FolderTreeContext extends Omit<FolderTreeProps, "handle"> {
value: Accessor<string>
creatingFolderPath: Accessor<string | null>
setCreatingFolderPath: Setter<string | null>
}
const context = createContext<FolderTreeContext>()
export const FolderTree = (props: FolderTreeProps) => {
const [path, setPath] = createSignal("/")
props.handle?.({ setPath })
const [creatingFolderPath, setCreatingFolderPath] = createSignal<
string | null
>(null)

const startCreateFolder = () => {
setCreatingFolderPath(path())
}

props.handle?.({
setPath,
startCreateFolder,
})

return (
<Box class="folder-tree-box" w="$full" overflowX="auto">
<context.Provider
Expand All @@ -72,6 +93,8 @@ export const FolderTree = (props: FolderTreeProps) => {
forceRoot: props.forceRoot ?? false,
showEmptyIcon: props.showEmptyIcon ?? false,
showHiddenFolder: props.showHiddenFolder ?? true,
creatingFolderPath,
setCreatingFolderPath,
}}
>
<FolderTreeNode path="/" />
Expand All @@ -90,15 +113,17 @@ const FolderTreeNode = (props: { path: string }) => {
autoOpen,
showEmptyIcon,
showHiddenFolder,
creatingFolderPath,
setCreatingFolderPath,
} = useContext(context)!
const emptyIconVisible = () =>
Boolean(showEmptyIcon && children() !== undefined && !children()?.length)
const [loading, fetchDirs] = useFetch(() =>
fsDirs(props.path, password(), forceRoot),
)
let isLoaded = false
const load = async () => {
if (children()?.length) return
const load = async (force = false) => {
if (!force && children()?.length) return
const resp = await fetchDirs() // this api may return null
handleResp(
resp,
Expand All @@ -122,6 +147,14 @@ const FolderTreeNode = (props: { path: string }) => {
}
}
createEffect(on(value, checkIfShouldOpen))

createEffect(() => {
if (creatingFolderPath() === props.path) {
if (!isOpen()) onToggle()
if (!isLoaded) load()
}
})

const isHiddenFolder = () =>
isHidePath(props.path) && !isMatchedFolder(value())
return (
Expand Down Expand Up @@ -179,13 +212,129 @@ const FolderTreeNode = (props: { path: string }) => {
<FolderTreeNode path={pathJoin(props.path, item.name)} />
)}
</For>
<Show when={creatingFolderPath() === props.path}>
<FolderNameInput
parentPath={props.path}
onCancel={() => setCreatingFolderPath(null)}
onSuccess={(fullPath) => {
setCreatingFolderPath(null)
onChange(fullPath)
load(true)
}}
/>
</Show>
</VStack>
</Show>
</Box>
</Show>
)
}

const FOCUS_DELAY_MS = 0 // allow DOM to mount before focusing

const FolderNameInput = (props: {
parentPath: string
onCancel: () => void
onSuccess: (fullPath: string) => void
}) => {
const t = useT()
const [folderName, setFolderName] = createSignal("")
const [loading, mkdir] = useFetch(fsMkdir)

const handleSubmit = async () => {
const name = folderName().trim()
if (!name || loading()) return

const validation = validateFilename(name)
if (!validation.valid) {
notify.warning(t(`global.${validation.error}`))
return
}

const fullPath = pathJoin(props.parentPath, name)
const resp = await mkdir(fullPath)
handleRespWithNotifySuccess(
resp,
() => {
props.onSuccess(fullPath)
},
() => {
props.onCancel()
},
)
}

let inputRef: HTMLInputElement | undefined

onMount(() => {
setTimeout(() => {
inputRef?.focus()
inputRef?.select()
}, FOCUS_DELAY_MS)
})

return (
<HStack spacing="$2" w="$full" pl="$4" alignItems="center">
<Icon color={getMainColor()} as={BiSolidFolderOpen} />
<Input
ref={(el) => (inputRef = el)}
value={folderName()}
onInput={(e) => setFolderName(e.currentTarget.value)}
placeholder={t("home.toolbar.input_dir_name")}
size="sm"
flex="1"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleSubmit()
} else if (e.key === "Escape") {
props.onCancel()
}
}}
onBlur={(e) => {
if (loading()) return
const next = e.relatedTarget as HTMLElement | null
if (next?.dataset.folderAction === "true") return
if (!folderName().trim()) {
props.onCancel()
}
}}
/>
<Show
when={!loading()}
fallback={<Spinner size="sm" color={getMainColor()} />}
>
<Button
aria-label={t("global.ok")}
size="sm"
variant="ghost"
rounded="$md"
p="$1"
color="$success9"
onClick={handleSubmit}
tabIndex={0}
data-folder-action="true"
>
<Icon as={TbCheck} boxSize="$6" />
</Button>
</Show>
<Button
aria-label={t("global.cancel")}
size="sm"
variant="ghost"
rounded="$md"
p="$1"
color="$danger9"
onClick={props.onCancel}
tabIndex={0}
data-folder-action="true"
>
<Icon as={TbX} boxSize="$6" />
</Button>
</HStack>
)
}

export type ModalFolderChooseProps = {
opened: boolean
onClose: () => void
Expand All @@ -194,6 +343,7 @@ export type ModalFolderChooseProps = {
defaultValue?: string
loading?: boolean
footerSlot?: JSXElement
headerSlot?: (handler: FolderTreeHandler | undefined) => JSXElement
children?: JSXElement
header: string
}
Expand All @@ -216,7 +366,12 @@ export const ModalFolderChoose = (props: ModalFolderChooseProps) => {
<ModalContent>
{/* <ModalCloseButton /> */}
<ModalHeader w="$full" css={{ overflowWrap: "break-word" }}>
{props.header}
<HStack w="$full" justifyContent="space-between" alignItems="center">
<Box css={{ overflowWrap: "break-word" }}>{props.header}</Box>
<Show when={props.headerSlot && handler()}>
{props.headerSlot!(handler()!)}
</Show>
</HStack>
</ModalHeader>
<ModalBody>
{props.children}
Expand Down
20 changes: 18 additions & 2 deletions src/pages/home/toolbar/CopyMove.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
import { Checkbox, createDisclosure, VStack } from "@hope-ui/solid"
import { Checkbox, createDisclosure,VStack, Button } from "@hope-ui/solid"
import { createSignal, onCleanup } from "solid-js"
import { ModalFolderChoose } from "~/components"
import { ModalFolderChoose, FolderTreeHandler } from "~/components"
import { useFetch, usePath, useRouter, useT } from "~/hooks"
import { selectedObjs } from "~/store"
import { bus, fsCopy, fsMove, handleRespWithNotifySuccess } from "~/utils"
import { CgFolderAdd } from "solid-icons/cg"

const CreateFolderButton = (props: { handler?: FolderTreeHandler }) => {
const t = useT()
return (
<Button
leftIcon={<CgFolderAdd />}
size="sm"
onClick={() => props.handler?.startCreateFolder()}
>
{t("home.toolbar.mkdir")}
</Button>
)
}

export const Copy = () => {
const t = useT()
Expand All @@ -30,6 +44,7 @@ export const Copy = () => {
opened={isOpen()}
onClose={onClose}
loading={loading()}
headerSlot={(handler) => <CreateFolderButton handler={handler} />}
footerSlot={
<VStack w="$full" spacing="$2">
<Checkbox
Expand Down Expand Up @@ -110,6 +125,7 @@ export const Move = () => {
opened={isOpen()}
onClose={onClose}
loading={loading()}
headerSlot={(handler) => <CreateFolderButton handler={handler} />}
footerSlot={
<VStack w="$full" spacing="$2">
<Checkbox
Expand Down