"use client"; import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { ChevronRight, FolderGit, FolderOpen, Pencil, Plus, Search, Trash2, } from "lucide-react"; import { toast } from "sonner"; import { projectResourcesOptions, useCreateProjectResource, useDeleteProjectResource, useUpdateProjectResource, } from "@multica/core/projects"; import { useWorkspaceId } from "@multica/core/hooks"; import { useCurrentWorkspace } from "@multica/core/paths"; import type { GithubRepoResourceRef, LocalDirectoryResourceRef, ProjectResource, } from "@multica/core/types"; import { Button } from "@multica/ui/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger, } from "@multica/ui/components/ui/popover"; import { Tooltip, TooltipTrigger, TooltipContent, } from "@multica/ui/components/ui/tooltip"; import { isDesktopShell, pickDirectory, useLocalDaemonStatus, validateLocalDirectory, type ValidateLocalDirectoryResult, } from "../../platform"; import { useT } from "../../i18n"; // Project Resources sidebar section. // // Type-dispatched at the row + add-flow level. Add a new resource_type by: // (1) extending the server validator // (2) extending ProjectResourceType in @multica/core/types // (3) adding a render case in ResourceRow and an add-control here function isGithubRef(r: ProjectResource): r is ProjectResource & { resource_ref: GithubRepoResourceRef; } { return r.resource_type === "github_repo"; } function isLocalDirectoryRef(r: ProjectResource): r is ProjectResource & { resource_ref: LocalDirectoryResourceRef; } { return r.resource_type === "local_directory"; } export function ProjectResourcesSection({ projectId }: { projectId: string }) { const { t } = useT("projects"); const wsId = useWorkspaceId(); const workspace = useCurrentWorkspace(); const daemonStatus = useLocalDaemonStatus(); const [open, setOpen] = useState(true); const [addOpen, setAddOpen] = useState(false); const [repoSearch, setRepoSearch] = useState(""); const [picking, setPicking] = useState(false); const { data: resources = [] } = useQuery( projectResourcesOptions(wsId, projectId), ); const createResource = useCreateProjectResource(wsId, projectId); const updateResource = useUpdateProjectResource(wsId, projectId); const deleteResource = useDeleteProjectResource(wsId, projectId); // Desktop-only entry points. We hide (not just disable) on web so users // there don't see an action they can never complete — the spec calls for // read-only on web because the daemon-id check can't be performed in the // browser. const desktopMode = isDesktopShell(); const localDaemonId = daemonStatus.daemonId; const attachedUrls = new Set( resources.filter(isGithubRef).map((r) => r.resource_ref.url), ); const attachedLocalPaths = new Set( resources .filter(isLocalDirectoryRef) .filter((r) => r.resource_ref.daemon_id === localDaemonId) .map((r) => r.resource_ref.local_path), ); // Per (project, daemon) we allow at most one local_directory — the // daemon-side resolver picks the first match by daemon_id, so two rows // on the same daemon would silently route the agent into one of them. // The server enforces this at the API boundary; the UI mirrors the // restriction by hiding the "Add" affordance once a row exists for the // current daemon, otherwise users would only discover the limit on a // 409 toast. const hasLocalDirectoryForCurrentDaemon = localDaemonId !== null && attachedLocalPaths.size > 0; const repoQuery = repoSearch.trim().toLowerCase(); const filteredRepos = workspace?.repos?.filter((repo) => repo.url.toLowerCase().includes(repoQuery)) ?? []; const handleAttach = async (url: string) => { try { await createResource.mutateAsync({ resource_type: "github_repo", resource_ref: { url }, }); toast.success(t(($) => $.resources.toast_attached)); } catch (err) { const msg = err instanceof Error ? err.message : t(($) => $.resources.toast_attach_failed); toast.error(msg); } }; const handleAttachLocalDirectory = async () => { if (picking) return; setPicking(true); try { if (!localDaemonId || !daemonStatus.running) { toast.error(t(($) => $.resources.toast_local_daemon_not_running)); return; } // Race guard: the button gates on this already, but if the picker // is opened while a concurrent resource-create lands the user // would otherwise see a 409. Surface a clearer message instead. if (attachedLocalPaths.size > 0) { toast.error(t(($) => $.resources.toast_local_daemon_already_attached)); return; } const picked = await pickDirectory(); if (!picked.ok) { if (picked.reason && picked.reason !== "cancelled") { toast.error( picked.error ?? t(($) => $.resources.toast_local_pick_failed), ); } return; } const path = picked.path ?? ""; const fallbackLabel = picked.basename ?? path; if (attachedLocalPaths.has(path)) { toast.error(t(($) => $.resources.toast_local_already_attached)); return; } const validation = await validateLocalDirectory(path); if (!validation.ok) { toast.error( localValidationMessage(validation, { not_absolute: t(($) => $.resources.local_validate_not_absolute), not_found: t(($) => $.resources.local_validate_not_found), not_a_directory: t(($) => $.resources.local_validate_not_a_directory), not_readable: t(($) => $.resources.local_validate_not_readable), not_writable: t(($) => $.resources.local_validate_not_writable), unsupported: t(($) => $.resources.local_validate_unsupported), fallback: t(($) => $.resources.toast_local_pick_failed), }), ); return; } await createResource.mutateAsync({ resource_type: "local_directory", resource_ref: { local_path: path, daemon_id: localDaemonId, label: fallbackLabel, }, }); toast.success(t(($) => $.resources.toast_local_attached)); setAddOpen(false); } catch (err) { const msg = err instanceof Error ? err.message : t(($) => $.resources.toast_local_pick_failed); toast.error(msg); } finally { setPicking(false); } }; const handleRemove = async (resource: ProjectResource) => { try { await deleteResource.mutateAsync(resource.id); toast.success(t(($) => $.resources.toast_removed)); } catch (err) { toast.error( err instanceof Error && err.message ? err.message : t(($) => $.resources.toast_remove_failed), ); } }; const handleRenameLocalDirectory = async ( resource: ProjectResource & { resource_ref: LocalDirectoryResourceRef }, nextLabel: string, ) => { const trimmed = nextLabel.trim(); const previous = resource.resource_ref.label ?? resource.label ?? ""; if (trimmed === previous.trim()) return; try { await updateResource.mutateAsync({ resourceId: resource.id, data: { resource_ref: { ...resource.resource_ref, label: trimmed, }, }, }); toast.success(t(($) => $.resources.toast_local_renamed)); } catch (err) { const msg = err instanceof Error ? err.message : t(($) => $.resources.toast_local_rename_failed); toast.error(msg); } }; return (
{t(($) => $.resources.empty)}
)} {resources.length > 0 && ({t(($) => $.resources.repos_search_empty)}
)} {filteredRepos.map((repo) => { const isAttached = attachedUrls.has(repo.url); const isDisabled = isAttached || createResource.isPending; return ( // Use aria-disabled instead of the native `disabled` attribute so // hover events still reach the tooltip trigger on attached rows // (browsers suppress pointer events on disabled form controls). ); })}{t(($) => $.resources.local_daemon_offline_hint)}
)} {daemonStatus.running && hasLocalDirectoryForCurrentDaemon && ({t(($) => $.resources.local_daemon_already_attached_hint)}
)}