Files
kandev/apps/web/components/task/file-browser-toolbar.tsx
T
2026-09-06 11:36:25 +01:00

369 lines
11 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,
} 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="text-muted-foreground hover:bg-muted hover:text-foreground rounded p-1 cursor-pointer"
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: () => void;
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 shrink-0 cursor-pointer"
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-11 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground cursor-pointer sm:size-8"
>
<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-[44px] cursor-pointer gap-2 sm:min-h-8"
onSelect={handleCreateSelect}
>
<IconFilePlus className="h-3.5 w-3.5" />
{t("task:newFile")}
</DropdownMenuItem>
<DropdownMenuItem
className="min-h-[44px] cursor-pointer gap-2 sm:min-h-8"
onSelect={() => onUploadFiles("files")}
>
<IconUpload className="h-3.5 w-3.5" />
{t("task:uploadFiles")}
</DropdownMenuItem>
<DropdownMenuItem
className="min-h-[44px] cursor-pointer gap-2 sm:min-h-8"
onSelect={() => onUploadFiles("folder")}
>
<IconFolderUp className="h-3.5 w-3.5" />
{t("task:uploadFolder")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function WorkspaceActionsMenu({
onAddSources,
onOpenFolder,
addSourcesButtonRef,
addSourcesDisabledReason,
}: Pick<
FileBrowserToolbarProps,
"onAddSources" | "onOpenFolder" | "addSourcesButtonRef" | "addSourcesDisabledReason"
>) {
const { t } = useTranslation();
const triggerRef = useRef<HTMLButtonElement>(null);
const openSourcesAfterCloseRef = 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-11 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground cursor-pointer sm:size-8"
>
<IconDots className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>{t("task:workspaceActions")}</TooltipContent>
</Tooltip>
<DropdownMenuContent
align="end"
className="w-72"
onCloseAutoFocus={(event) => {
if (!openSourcesAfterCloseRef.current) return;
event.preventDefault();
openSourcesAfterCloseRef.current = false;
if (triggerRef.current && onAddSources) onAddSources(triggerRef.current);
}}
>
<DropdownMenuItem
disabled={Boolean(disabledReason)}
className="min-h-11 cursor-pointer gap-2 sm:min-h-8"
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>
<DropdownMenuItem
className="min-h-11 cursor-pointer gap-2 sm:min-h-8"
onSelect={onOpenFolder}
>
<IconFolderOpen className="h-3.5 w-3.5" />
{t("task:openWorkspaceFolder")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
export function FileBrowserToolbar({
displayPath,
fullPath,
copied,
expandedPathsSize,
onCopyPath,
onStartCreate,
onOpenFolder,
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}
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" />}
/>
)}
</>
}
/>
);
}