Files
kandev/apps/web/components/task/file-browser-toolbar.tsx
T
2026-09-20 11:33:30 +01:00

418 lines
13 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, type ReactNode, type Ref, type RefObject } from "react";
import {
IconSearch,
IconListTree,
IconFolderOpen,
IconCopy,
IconCheck,
IconFilePlus,
IconFolderUp,
IconPlus,
IconUpload,
IconDots,
IconLoader2,
} from "@tabler/icons-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { Skeleton } from "@kandev/ui/skeleton";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { cn } from "@/lib/utils";
import { PanelHeaderBarSplit } from "./panel-primitives";
import { useTranslation } from "react-i18next";
function ToolbarButton({
onClick,
label,
icon,
}: {
onClick: () => void;
label: string;
icon: ReactNode;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground cursor-pointer max-md:h-11 [@media(pointer:coarse)]:h-11 max-md:w-11 [@media(pointer:coarse)]:w-11"
aria-label={label}
onClick={onClick}
>
{icon}
</button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
type FileBrowserToolbarProps = {
displayPath: string;
fullPath: string;
copied: boolean;
expandedPathsSize: number;
onCopyPath: (text: string) => void;
onStartCreate?: () => void;
onOpenFolder: (opener?: HTMLButtonElement) => void;
isOpeningFolder?: boolean;
isFolderDisabled?: boolean;
onStartSearch: () => void;
onCollapseAll: () => void;
showCreateButton: boolean;
onUploadFiles?: (mode: "files" | "folder") => void;
onAddSources?: (opener: HTMLButtonElement) => void;
addSourcesButtonRef?: Ref<HTMLButtonElement>;
addSourcesDisabledReason?: string;
};
function CopyWorkspacePathButton({
fullPath,
copied,
onCopyPath,
}: Pick<FileBrowserToolbarProps, "fullPath" | "copied" | "onCopyPath">) {
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger asChild>
<button
className="relative inline-flex h-6 w-6 shrink-0 items-center justify-center cursor-pointer max-md:h-11 [@media(pointer:coarse)]:h-11 max-md:w-11 [@media(pointer:coarse)]:w-11"
aria-label={t("task:copyWorkspacePath")}
onClick={() => {
if (fullPath) void onCopyPath(fullPath);
}}
>
<IconFolderOpen
className={cn(
"h-3.5 w-3.5 text-muted-foreground transition-opacity",
copied ? "opacity-0" : "group-hover/header:opacity-0",
)}
/>
{copied ? (
<IconCheck className="absolute inset-0 h-3.5 w-3.5 text-green-600/70" />
) : (
<IconCopy className="absolute inset-0 h-3.5 w-3.5 text-muted-foreground opacity-0 group-hover/header:opacity-100 hover:text-foreground transition-opacity" />
)}
</button>
</TooltipTrigger>
<TooltipContent>{t("task:copyWorkspacePath")}</TooltipContent>
</Tooltip>
);
}
function useMobileDrawerFocusRestoration(triggerRef: RefObject<HTMLButtonElement | null>) {
const { isMobile } = useResponsiveBreakpoint();
const restoreAfterDrawerCloseRef = useRef(false);
useEffect(() => {
const restoreFocus = (event: AnimationEvent) => {
if (!restoreAfterDrawerCloseRef.current) return;
const drawer = event.target;
if (
!(drawer instanceof HTMLElement) ||
drawer.dataset.testid !== "add-workspace-sources-drawer" ||
drawer.dataset.state !== "closed"
)
return;
restoreAfterDrawerCloseRef.current = false;
requestAnimationFrame(() => {
if (triggerRef.current?.isConnected && !triggerRef.current.disabled) {
triggerRef.current.focus();
}
});
};
document.addEventListener("animationend", restoreFocus, true);
return () => document.removeEventListener("animationend", restoreFocus, true);
}, [triggerRef]);
return useCallback(() => {
restoreAfterDrawerCloseRef.current = isMobile;
}, [isMobile]);
}
function CreateMenu({
onStartCreate,
onUploadFiles,
}: {
onStartCreate: () => void;
onUploadFiles?: (mode: "files" | "folder") => void;
}) {
const { t } = useTranslation();
const createAfterCloseRef = useRef(false);
const handleCreateSelect = useCallback(() => {
createAfterCloseRef.current = true;
}, []);
const handleCloseAutoFocus = useCallback(
(event: Event) => {
if (!createAfterCloseRef.current) return;
event.preventDefault();
createAfterCloseRef.current = false;
onStartCreate();
},
[onStartCreate],
);
// Without an upload handler there is only one action, so keep the original
// one-click button rather than burying New File behind a menu.
if (!onUploadFiles) {
return (
<ToolbarButton
onClick={onStartCreate}
label={t("task:newFile")}
icon={<IconPlus className="h-3.5 w-3.5" />}
/>
);
}
return (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={t("task:addToWorkspace")}
data-testid="files-create-menu"
className="inline-flex size-6 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground cursor-pointer max-md:size-11 [@media(pointer:coarse)]:size-11"
>
<IconPlus className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>{t("task:addToWorkspace")}</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="w-56" onCloseAutoFocus={handleCloseAutoFocus}>
<DropdownMenuItem
className="min-h-8 cursor-pointer gap-2 max-md:min-h-11 [@media(pointer:coarse)]:min-h-11"
onSelect={handleCreateSelect}
>
<IconFilePlus className="h-3.5 w-3.5" />
{t("task:newFile")}
</DropdownMenuItem>
<DropdownMenuItem
className="min-h-8 cursor-pointer gap-2 max-md:min-h-11 [@media(pointer:coarse)]:min-h-11"
onSelect={() => onUploadFiles("files")}
>
<IconUpload className="h-3.5 w-3.5" />
{t("task:uploadFiles")}
</DropdownMenuItem>
<DropdownMenuItem
className="min-h-8 cursor-pointer gap-2 max-md:min-h-11 [@media(pointer:coarse)]:min-h-11"
onSelect={() => onUploadFiles("folder")}
>
<IconFolderUp className="h-3.5 w-3.5" />
{t("task:uploadFolder")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function OpenWorkspaceFolderMenuItem({
isOpening,
disabled,
onSelect,
}: {
isOpening?: boolean;
disabled?: boolean;
onSelect: () => void;
}) {
const { t } = useTranslation();
return (
<DropdownMenuItem
className="min-h-8 cursor-pointer gap-2 max-md:min-h-11 [@media(pointer:coarse)]:min-h-11"
disabled={disabled || isOpening}
aria-busy={isOpening}
onSelect={onSelect}
>
{isOpening ? (
<IconLoader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<IconFolderOpen className="h-3.5 w-3.5" aria-hidden />
)}
{t("task:openWorkspaceFolder")}
</DropdownMenuItem>
);
}
function WorkspaceActionsMenu({
onAddSources,
onOpenFolder,
isOpeningFolder,
isFolderDisabled,
addSourcesButtonRef,
addSourcesDisabledReason,
}: Pick<
FileBrowserToolbarProps,
| "onAddSources"
| "onOpenFolder"
| "isOpeningFolder"
| "isFolderDisabled"
| "addSourcesButtonRef"
| "addSourcesDisabledReason"
>) {
const { t } = useTranslation();
const triggerRef = useRef<HTMLButtonElement>(null);
const openSourcesAfterCloseRef = useRef(false);
const openFolderAfterCloseRef = useRef(false);
const restoreMobileFocusAfterDrawerClose = useMobileDrawerFocusRestoration(triggerRef);
const setTriggerRef = useCallback(
(node: HTMLButtonElement | null) => {
triggerRef.current = node;
if (typeof addSourcesButtonRef === "function") {
addSourcesButtonRef(node);
} else if (addSourcesButtonRef) {
(addSourcesButtonRef as { current: HTMLButtonElement | null }).current = node;
}
},
[addSourcesButtonRef],
);
const disabledReason =
addSourcesDisabledReason ??
(onAddSources ? undefined : t("task:taskNeedsRepositoryForSources"));
return (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
ref={setTriggerRef}
type="button"
aria-label={t("task:workspaceActions")}
data-testid="files-workspace-actions"
className="inline-flex size-6 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground cursor-pointer max-md:size-11 [@media(pointer:coarse)]:size-11"
>
<IconDots className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>{t("task:workspaceActions")}</TooltipContent>
</Tooltip>
<DropdownMenuContent
align="end"
className="w-72"
onCloseAutoFocus={(event) => {
if (openFolderAfterCloseRef.current) {
event.preventDefault();
openFolderAfterCloseRef.current = false;
triggerRef.current?.focus();
onOpenFolder(triggerRef.current ?? undefined);
return;
}
if (!openSourcesAfterCloseRef.current) return;
event.preventDefault();
openSourcesAfterCloseRef.current = false;
if (triggerRef.current && onAddSources) onAddSources(triggerRef.current);
}}
>
<DropdownMenuItem
disabled={Boolean(disabledReason)}
className="min-h-8 cursor-pointer gap-2 max-md:min-h-11 [@media(pointer:coarse)]:min-h-11"
onSelect={() => {
openSourcesAfterCloseRef.current = true;
restoreMobileFocusAfterDrawerClose();
}}
>
<IconPlus className="h-3.5 w-3.5" />
<span className="min-w-0">
<span className="block">{t("task:addRepositoriesToWorkspace")}</span>
{disabledReason && (
<span className="block text-[10px] text-muted-foreground normal-case">
{disabledReason}
</span>
)}
</span>
</DropdownMenuItem>
<OpenWorkspaceFolderMenuItem
isOpening={isOpeningFolder}
disabled={isFolderDisabled}
onSelect={() => {
openFolderAfterCloseRef.current = true;
}}
/>
</DropdownMenuContent>
</DropdownMenu>
);
}
export function FileBrowserToolbar({
displayPath,
fullPath,
copied,
expandedPathsSize,
onCopyPath,
onStartCreate,
onOpenFolder,
isOpeningFolder,
isFolderDisabled,
onStartSearch,
onCollapseAll,
showCreateButton,
onUploadFiles,
onAddSources,
addSourcesButtonRef,
addSourcesDisabledReason,
}: FileBrowserToolbarProps) {
const { t } = useTranslation();
return (
<PanelHeaderBarSplit
className="group/header"
left={
<>
<CopyWorkspacePathButton fullPath={fullPath} copied={copied} onCopyPath={onCopyPath} />
{displayPath ? (
<Tooltip>
<TooltipTrigger asChild>
<span
className="min-w-0 truncate text-xs font-medium text-muted-foreground"
data-testid="file-browser-workspace-path"
>
{displayPath}
</span>
</TooltipTrigger>
<TooltipContent className="break-all max-w-[min(420px,90vw)]">
{fullPath || displayPath}
</TooltipContent>
</Tooltip>
) : (
<Skeleton className="h-3 w-24" />
)}
</>
}
right={
<>
{showCreateButton && onStartCreate && (
<CreateMenu onStartCreate={onStartCreate} onUploadFiles={onUploadFiles} />
)}
<WorkspaceActionsMenu
onAddSources={onAddSources}
onOpenFolder={onOpenFolder}
isOpeningFolder={isOpeningFolder}
isFolderDisabled={isFolderDisabled}
addSourcesButtonRef={addSourcesButtonRef}
addSourcesDisabledReason={addSourcesDisabledReason}
/>
<ToolbarButton
onClick={onStartSearch}
label={t("task:searchFiles")}
icon={<IconSearch className="h-3.5 w-3.5" />}
/>
{expandedPathsSize > 0 && (
<ToolbarButton
onClick={onCollapseAll}
label={t("task:collapseAll")}
icon={<IconListTree className="h-3.5 w-3.5" />}
/>
)}
</>
}
/>
);
}