"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 (
{open && (
{resources.length === 0 && (

{t(($) => $.resources.empty)}

)} {resources.length > 0 && (
{resources.map((resource) => ( handleRemove(resource)} onRenameLocalDirectory={handleRenameLocalDirectory} /> ))}
)} { setAddOpen(v); if (!v) setRepoSearch(""); }} > {t(($) => $.resources.add_button)} } />
{t(($) => $.resources.popover_title)}
{workspace?.repos && workspace.repos.length > 0 && ( <>
setRepoSearch(e.target.value)} aria-label={t(($) => $.resources.repos_search_placeholder)} placeholder={t(($) => $.resources.repos_search_placeholder)} className="h-8 w-full rounded-md border bg-transparent pl-7 pr-2 text-xs outline-none placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring" />
{filteredRepos.length === 0 && repoQuery && (

{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). ); })}
)} { await handleAttach(url); setAddOpen(false); }} />
{desktopMode && (
{!daemonStatus.running && (

{t(($) => $.resources.local_daemon_offline_hint)}

)} {daemonStatus.running && hasLocalDirectoryForCurrentDaemon && (

{t(($) => $.resources.local_daemon_already_attached_hint)}

)}
)}
)}
); } interface ResourceRowProps { resource: ProjectResource; localDaemonId: string | null; canEdit: boolean; onRemove: () => void; onRenameLocalDirectory: ( resource: ProjectResource & { resource_ref: LocalDirectoryResourceRef }, nextLabel: string, ) => Promise; } function ResourceRow({ resource, localDaemonId, canEdit, onRemove, onRenameLocalDirectory, }: ResourceRowProps) { const { t } = useT("projects"); if (isGithubRef(resource)) { const ref = resource.resource_ref; const display = resource.label || (ref.ref ? `${ref.url} @ ${ref.ref}` : ref.url); const tooltip = ref.ref ? `${ref.url}\nref: ${ref.ref}` : ref.url; return (
{display} } /> {tooltip}
); } if (isLocalDirectoryRef(resource)) { return ( ); } return (
{resource.label || resource.resource_type}
); } interface LocalDirectoryRowProps { resource: ProjectResource & { resource_ref: LocalDirectoryResourceRef }; localDaemonId: string | null; canEdit: boolean; onRemove: () => void; onRename: ( resource: ProjectResource & { resource_ref: LocalDirectoryResourceRef }, nextLabel: string, ) => Promise; } function LocalDirectoryRow({ resource, localDaemonId, canEdit, onRemove, onRename, }: LocalDirectoryRowProps) { const { t } = useT("projects"); const ref = resource.resource_ref; const display = (ref.label || resource.label || ref.local_path).trim() || ref.local_path; const isForeignDaemon = localDaemonId !== null && ref.daemon_id !== localDaemonId; const isLocalUnknown = localDaemonId === null; // "disabled" in the spec sense — visual de-emphasis + no chat hint, and // rename is hidden on foreign / unknown-daemon rows because the label // belongs to the owning device. Delete stays available so the user can // drop a stale registration from any device. const mismatch = isForeignDaemon || isLocalUnknown; const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(display); const startEdit = () => { setDraft(display); setEditing(true); }; const commit = async () => { setEditing(false); await onRename(resource, draft); }; const cancel = () => { setEditing(false); setDraft(display); }; return (
{editing ? ( setDraft(e.target.value)} onBlur={() => void commit()} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void commit(); } else if (e.key === "Escape") { e.preventDefault(); cancel(); } }} className="flex-1 min-w-0 rounded-sm border bg-transparent px-1 py-0.5 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring" aria-label={t(($) => $.resources.local_rename_label)} /> ) : ( {display} } />
{ref.local_path}
{mismatch && (
{isLocalUnknown ? t(($) => $.resources.local_no_daemon_tooltip) : t(($) => $.resources.local_other_machine_tooltip)}
)}
)} {canEdit && !mismatch && !editing && ( )}
); } function CustomRepoForm({ onSubmit, }: { onSubmit: (url: string) => Promise | void; }) { const { t } = useT("projects"); const [url, setUrl] = useState(""); const [submitting, setSubmitting] = useState(false); const handle = async (e: React.FormEvent) => { e.preventDefault(); const trimmed = url.trim(); if (!trimmed) return; setSubmitting(true); try { await onSubmit(trimmed); setUrl(""); } finally { setSubmitting(false); } }; return (
setUrl(e.target.value)} placeholder={t(($) => $.resources.url_placeholder)} className="flex-1 bg-transparent text-xs px-2 py-1 outline-none placeholder:text-muted-foreground" />
); } function localValidationMessage( result: ValidateLocalDirectoryResult, strings: { not_absolute: string; not_found: string; not_a_directory: string; not_readable: string; not_writable: string; unsupported: string; fallback: string; }, ): string { switch (result.reason) { case "not_absolute": return strings.not_absolute; case "not_found": return strings.not_found; case "not_a_directory": return strings.not_a_directory; case "not_readable": return strings.not_readable; case "not_writable": return strings.not_writable; case "unsupported": return strings.unsupported; case "error": default: return result.error ?? strings.fallback; } }