Files
kandev/apps/web/app/office/projects/project-repository-picker.tsx
T
2026-09-10 18:14:05 +01:00

272 lines
8.4 KiB
TypeScript

"use client";
import { useCallback, useMemo, useState } from "react";
import { IconCode, IconPlus, IconWorld } from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@kandev/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { cn, formatUserHomePath } from "@/lib/utils";
import type { Repository } from "@/lib/types/http";
import { normalizeRepoValue, shouldShowCustomEntry } from "./repo-entry";
import { useDiscoveredRepositories } from "./use-discovered-repositories";
import { useTranslation } from "react-i18next";
import { RepositoryDiscoveryControls } from "@/components/repository-discovery-controls";
import { controlSizingClassName } from "@kandev/ui/control-sizing";
type Props = {
workspaceId: string | null;
/**
* The workspace's registered repositories. Supplied by the caller —
* which already loads them for chip labels — so mounting the picker
* does not issue a second repository-list fetch.
*/
repositories: Repository[];
/** Already-attached entries (raw stored values) — excluded from suggestions. */
exclude: string[];
onSelect: (value: string) => void;
/** Optional render override for the trigger. Defaults to "+ Add repository". */
triggerLabel?: string;
};
type RepoOption = { key: string; value: string; name: string; path: string };
/**
* Popover picker for adding a repository to a project. Mirrors the
* task-create dialog style (cmdk search, "on disk" badge, workspace
* group). The project model is a flat `string[]`, so:
*
* - selecting a workspace repo stores its `local_path`;
* - selecting a discovered on-disk repo stores its absolute path;
* - typing a URL or path offers an "Add custom" row storing the
* literal input, hidden only when the value exactly matches an
* existing entry (see `shouldShowCustomEntry`).
*
* On-disk discovery runs lazily the first time the popover opens.
*/
export function ProjectRepositoryPicker({
workspaceId,
repositories,
exclude,
onSelect,
triggerLabel,
}: Props) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const discovered = useDiscoveredRepositories(open, workspaceId);
const excludeSet = useMemo(() => new Set(exclude.map(normalizeRepoValue)), [exclude]);
const workspaceOptions = useMemo<RepoOption[]>(
() =>
repositories
.filter((r) => r.local_path && !excludeSet.has(normalizeRepoValue(r.local_path)))
.map((r) => ({
key: `ws-${r.id}`,
value: r.local_path,
name: r.name,
path: r.local_path,
})),
[repositories, excludeSet],
);
const discoveredOptions = useMemo<RepoOption[]>(() => {
if (!discovered) return [];
const wsPaths = new Set(workspaceOptions.map((o) => normalizeRepoValue(o.path)));
return discovered
.filter(
(r) =>
!wsPaths.has(normalizeRepoValue(r.path)) && !excludeSet.has(normalizeRepoValue(r.path)),
)
.map((r) => ({
key: `disc-${r.path}`,
value: r.path,
name: r.name || leafSegment(r.path),
path: r.path,
}));
}, [discovered, workspaceOptions, excludeSet]);
const handleSelect = useCallback(
(value: string) => {
onSelect(value);
setOpen(false);
setQuery("");
},
[onSelect],
);
const trimmed = query.trim();
const showCustom = shouldShowCustomEntry(
trimmed,
[...workspaceOptions, ...discoveredOptions].map((o) => o.value),
exclude,
);
return (
<Popover open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<PickerTriggerButton label={triggerLabel ?? t("office:addRepository")} />
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>{t("office:pickAWorkspaceRepoADiscovered")}</TooltipContent>
</Tooltip>
<PopoverContent className="w-[420px] p-0" align="start" portal={false}>
<RepositoryDiscoveryControls
workspaceId={workspaceId}
enabled={open}
presentation="picker"
/>
<Command>
<CommandInput
placeholder={t("office:searchOrPasteAUrlOr")}
value={query}
onValueChange={setQuery}
className={controlSizingClassName("standard")}
/>
<PickerCommandList
workspaceOptions={workspaceOptions}
discoveredOptions={discoveredOptions}
discoveryLoading={discovered === null}
showCustom={showCustom}
customQuery={trimmed}
onSelect={handleSelect}
/>
</Command>
</PopoverContent>
</Popover>
);
}
function PickerTriggerButton({ label, ...rest }: { label: string }) {
return (
<button
type="button"
data-testid="project-add-repository"
className={cn(
controlSizingClassName(
"standard",
"inline-flex items-center gap-1.5 rounded-md border border-input bg-input/20 dark:bg-input/30 px-2.5 text-xs cursor-pointer",
),
"hover:bg-muted/60",
)}
{...rest}
>
<IconPlus className="h-3.5 w-3.5" />
{label}
</button>
);
}
function PickerCommandList({
workspaceOptions,
discoveredOptions,
discoveryLoading,
showCustom,
customQuery,
onSelect,
}: {
workspaceOptions: RepoOption[];
discoveredOptions: RepoOption[];
discoveryLoading: boolean;
showCustom: boolean;
customQuery: string;
onSelect: (value: string) => void;
}) {
const { t } = useTranslation();
return (
<CommandList>
<CommandEmpty>
{discoveryLoading ? t("office:searchingYourMachine") : t("office:noMatches")}
</CommandEmpty>
{showCustom && (
<CommandGroup heading={t("office:addCustom")}>
<CommandItem
value={`__custom__:${customQuery}`}
onSelect={() => onSelect(customQuery)}
className="cursor-pointer"
data-testid="project-add-custom"
>
<IconWorld className="h-3.5 w-3.5 text-muted-foreground" />
<span className="flex flex-col min-w-0">
<span className="truncate">{t("office:useQuery", { query: customQuery })}</span>
<span className="text-[11px] text-muted-foreground">
{looksLikeUrl(customQuery)
? t("office:addAsRemoteUrl")
: t("office:addAsLocalPath")}
</span>
</span>
</CommandItem>
</CommandGroup>
)}
{workspaceOptions.length > 0 && (
<RepoGroup heading={t("common:workspace")} options={workspaceOptions} onSelect={onSelect} />
)}
{discoveredOptions.length > 0 && (
<RepoGroup
heading={t("office:onDisk")}
options={discoveredOptions}
onSelect={onSelect}
badge={t("office:onDiskBadge")}
/>
)}
</CommandList>
);
}
function RepoGroup({
heading,
options,
onSelect,
badge,
}: {
heading: string;
options: RepoOption[];
onSelect: (value: string) => void;
badge?: string;
}) {
return (
<CommandGroup heading={heading}>
{options.map((o) => (
<CommandItem
key={o.key}
value={o.value}
keywords={[o.name, o.path, formatUserHomePath(o.path)]}
onSelect={() => onSelect(o.value)}
className="cursor-pointer"
>
<IconCode className="h-3.5 w-3.5 text-muted-foreground" />
<span className="flex flex-col min-w-0 flex-1">
<span className="truncate">{o.name}</span>
<span className="truncate text-[11px] text-muted-foreground">
{formatUserHomePath(o.path)}
</span>
</span>
{badge && (
<Badge variant="outline" className="text-[10px] text-muted-foreground shrink-0">
{badge}
</Badge>
)}
</CommandItem>
))}
</CommandGroup>
);
}
function leafSegment(path: string): string {
const cleaned = path.replace(/\\/g, "/").replace(/\/+$/g, "");
const idx = cleaned.lastIndexOf("/");
return idx >= 0 ? cleaned.slice(idx + 1) : cleaned;
}
function looksLikeUrl(value: string): boolean {
return /^(https?:\/\/|git@|ssh:\/\/|git:\/\/)/i.test(value);
}