272 lines
8.4 KiB
TypeScript
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);
|
|
}
|