mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
fix(ui): harden repository catalog workflows
Restore the unprefixed route, surface manual and sync outcomes, improve discovery and relationship affordances, and make repository catalog/detail layouts usable on narrow viewports. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
e22e9aab41
commit
4613b36f58
10 files changed
+294
-93
No files matched your search
@@ -595,6 +595,7 @@ export function App() {
|
||||
<Route path="projects/:projectId/workspaces" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="projects/:projectId/workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="repositories" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="repositories/:repositoryId" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="workspaces" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="execution-workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Plus } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { Ref } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface EmptyStateProps {
|
||||
@@ -11,6 +12,7 @@ interface EmptyStateProps {
|
||||
description?: string;
|
||||
action?: string;
|
||||
onAction?: () => void;
|
||||
actionRef?: Ref<HTMLButtonElement>;
|
||||
/** Hide the leading "+" glyph on the action button (e.g. for a "Set up" CTA). */
|
||||
hideActionIcon?: boolean;
|
||||
}
|
||||
@@ -22,6 +24,7 @@ export function EmptyState({
|
||||
description,
|
||||
action,
|
||||
onAction,
|
||||
actionRef,
|
||||
hideActionIcon = false,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
@@ -41,7 +44,7 @@ export function EmptyState({
|
||||
</>
|
||||
)}
|
||||
{action && onAction && (
|
||||
<Button onClick={onAction}>
|
||||
<Button ref={actionRef} onClick={onAction}>
|
||||
{!hideActionIcon && <Plus className="h-4 w-4 mr-1.5" />}
|
||||
{action}
|
||||
</Button>
|
||||
|
||||
@@ -89,7 +89,7 @@ describe("RepositoryPicker", () => {
|
||||
]);
|
||||
const onChange = await render(["repo-app"]);
|
||||
expect(container.textContent).toContain("1 project");
|
||||
const docs = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("github.com/acme/docs"));
|
||||
const docs = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("acme/docs"));
|
||||
await act(() => docs!.click());
|
||||
expect(onChange).toHaveBeenCalledWith(["repo-app", "repo-docs"]);
|
||||
});
|
||||
|
||||
@@ -13,6 +13,10 @@ function repositoryLabel(repository: Pick<RepositoryCatalogItem, "host" | "owner
|
||||
return `${repository.host}/${repository.owner}/${repository.name}`;
|
||||
}
|
||||
|
||||
function repositoryName(repository: Pick<RepositoryCatalogItem, "owner" | "name">) {
|
||||
return `${repository.owner}/${repository.name}`;
|
||||
}
|
||||
|
||||
export function RepositoryPicker({
|
||||
companyId,
|
||||
value,
|
||||
@@ -74,9 +78,12 @@ export function RepositoryPicker({
|
||||
<div className="space-y-1">
|
||||
{selected.map((repository) => (
|
||||
<div key={repository.id} className="flex items-center justify-between gap-2 rounded-md border border-border px-2 py-1.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2" title={repositoryLabel(repository)}>
|
||||
<GitBranch className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-xs">{repositoryLabel(repository)}</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-xs">{repositoryName(repository)}</span>
|
||||
<span className="block truncate text-(length:--text-micro) text-muted-foreground">{repository.host}</span>
|
||||
</span>
|
||||
{repository.state !== "active" ? (
|
||||
<span className="text-(length:--text-micro) text-muted-foreground">{repository.state}</span>
|
||||
) : null}
|
||||
@@ -147,7 +154,10 @@ export function RepositoryPicker({
|
||||
<span className="flex h-4 w-4 shrink-0 items-center justify-center rounded border border-border">
|
||||
{checked ? <Check className="h-3 w-3" /> : null}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-xs">{repositoryLabel(repository)}</span>
|
||||
<span className="min-w-0 flex-1" title={repositoryLabel(repository)}>
|
||||
<span className="block truncate text-xs">{repositoryName(repository)}</span>
|
||||
<span className="block truncate text-(length:--text-micro) text-muted-foreground">{repository.host}</span>
|
||||
</span>
|
||||
{repository.projectCount > 0 ? (
|
||||
<span className="text-(length:--text-micro) text-muted-foreground">
|
||||
{repository.projectCount} {repository.projectCount === 1 ? "project" : "projects"}
|
||||
|
||||
@@ -156,7 +156,6 @@ describe("GitHubConnectionDialog", () => {
|
||||
repositoriesApiMock.discoverConnectionRepositories.mockResolvedValue(discoveryPage({ items: [], total: 0 }));
|
||||
|
||||
setInputValue(document.querySelector<HTMLInputElement>('input[aria-label="Search repositories"]')!, "zzz");
|
||||
clickByText("Search");
|
||||
await waitFor(() => {
|
||||
expect(repositoriesApiMock.discoverConnectionRepositories).toHaveBeenCalledWith("conn-1", {
|
||||
query: "zzz",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { useDeferredValue, useEffect, useRef, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
RepositoryConnection,
|
||||
RepositoryDiscoveryItem,
|
||||
RepositoryDiscoveryPage,
|
||||
} from "@paperclipai/shared";
|
||||
import { AlertCircle, ExternalLink, Github, Loader2 } from "lucide-react";
|
||||
import { AlertCircle, ExternalLink, Github, Loader2, X } from "lucide-react";
|
||||
import { repositoriesApi } from "../../api/repositories";
|
||||
import { queryKeys } from "../../lib/queryKeys";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -63,6 +63,8 @@ export function GitHubConnectionDialog({
|
||||
const [items, setItems] = useState<RepositoryDiscoveryItem[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const deferredSearch = useDeferredValue(search);
|
||||
const lastSearchRef = useRef("");
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [importedCount, setImportedCount] = useState(0);
|
||||
const [skippedCount, setSkippedCount] = useState(0);
|
||||
@@ -75,6 +77,7 @@ export function GitHubConnectionDialog({
|
||||
setItems([]);
|
||||
setNextCursor(null);
|
||||
setSearch("");
|
||||
lastSearchRef.current = "";
|
||||
setSelected(new Set());
|
||||
setImportedCount(0);
|
||||
setSkippedCount(0);
|
||||
@@ -124,6 +127,7 @@ export function GitHubConnectionDialog({
|
||||
onSuccess: async (result) => {
|
||||
setConnection(result.connection);
|
||||
setStep("select");
|
||||
lastSearchRef.current = "";
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.repositories.connections(companyId) });
|
||||
await discoverMutation.mutateAsync({ connectionId: result.connection.id, cursor: null, query: "" });
|
||||
},
|
||||
@@ -151,11 +155,14 @@ export function GitHubConnectionDialog({
|
||||
});
|
||||
};
|
||||
|
||||
const runSearch = () => {
|
||||
if (!connection) return;
|
||||
useEffect(() => {
|
||||
if (!open || step !== "select" || !connection) return;
|
||||
const query = deferredSearch.trim();
|
||||
if (query === lastSearchRef.current) return;
|
||||
lastSearchRef.current = query;
|
||||
setSelected(new Set());
|
||||
discoverMutation.mutate({ connectionId: connection.id, cursor: null, query: search });
|
||||
};
|
||||
discoverMutation.mutate({ connectionId: connection.id, cursor: null, query });
|
||||
}, [connection, deferredSearch, open, step]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
@@ -220,19 +227,26 @@ export function GitHubConnectionDialog({
|
||||
|
||||
{step === "select" ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Input
|
||||
aria-label="Search repositories"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") runSearch();
|
||||
}}
|
||||
placeholder="Search repositories"
|
||||
className={search ? "pr-8" : undefined}
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={runSearch} disabled={discoverMutation.isPending}>
|
||||
Search
|
||||
</Button>
|
||||
{search ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2"
|
||||
onClick={() => setSearch("")}
|
||||
aria-label="Clear repository search"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{discoverMutation.isError ? (
|
||||
@@ -272,7 +286,11 @@ export function GitHubConnectionDialog({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
connection && discoverMutation.mutate({ connectionId: connection.id, cursor: nextCursor, query: search })
|
||||
connection && discoverMutation.mutate({
|
||||
connectionId: connection.id,
|
||||
cursor: nextCursor,
|
||||
query: lastSearchRef.current,
|
||||
})
|
||||
}
|
||||
disabled={discoverMutation.isPending}
|
||||
>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { MemoryRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Repositories } from "./Repositories";
|
||||
import { ApiError } from "../api/client";
|
||||
|
||||
const companyState = vi.hoisted(() => ({ selectedCompanyId: "company-1" }));
|
||||
const breadcrumbState = vi.hoisted(() => ({ setBreadcrumbs: vi.fn() }));
|
||||
@@ -121,12 +122,14 @@ describe("Repositories catalog", () => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("shows an empty state with a connect action", async () => {
|
||||
it("keeps manual entry usable when no repository provider is configured", async () => {
|
||||
repositoriesApiMock.list.mockResolvedValue([]);
|
||||
repositoriesApiMock.listProviders.mockResolvedValue([]);
|
||||
render();
|
||||
await waitFor(() => {
|
||||
expect(document.body.textContent).toContain("No repositories yet");
|
||||
expect(document.body.textContent).toContain("Connect GitHub");
|
||||
expect(document.body.textContent).toContain("Add manually");
|
||||
expect(document.body.textContent).not.toContain("Connect GitHub");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -189,6 +192,72 @@ describe("Repositories catalog", () => {
|
||||
cloneUrl: "https://github.com/acme/manual.git",
|
||||
visibility: "unknown",
|
||||
});
|
||||
expect(document.body.textContent).toContain("Added acme/manual to the catalog");
|
||||
expect((document.activeElement as HTMLButtonElement | null)?.textContent).toContain("Add manually");
|
||||
});
|
||||
});
|
||||
|
||||
it("reports an idempotent manual add instead of silently closing", async () => {
|
||||
repositoriesApiMock.list.mockResolvedValue([makeRepo()]);
|
||||
repositoriesApiMock.createManual.mockResolvedValue(makeRepo());
|
||||
render();
|
||||
await waitFor(() => expect(document.body.textContent).toContain("acme/alpha"));
|
||||
[...document.querySelectorAll("button")].find((button) => button.textContent?.includes("Add manually"))!.click();
|
||||
await waitFor(() => expect(document.body.textContent).toContain("Add a repository manually"));
|
||||
const urlInput = document.querySelector<HTMLInputElement>("#manual-clone-url")!;
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!;
|
||||
setter.call(urlInput, "https://github.com/acme/alpha.git");
|
||||
urlInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
[...document.querySelectorAll("button")].find((button) => button.textContent?.includes("Add repository"))!.click();
|
||||
|
||||
await waitFor(() => expect(document.body.textContent).toContain("acme/alpha is already in the catalog"));
|
||||
});
|
||||
|
||||
it("shows a specific validation error and marks the manual URL invalid", async () => {
|
||||
repositoriesApiMock.list.mockResolvedValue([]);
|
||||
repositoriesApiMock.createManual.mockRejectedValue(new ApiError("Validation error", 422, {
|
||||
error: "Validation error",
|
||||
details: [{ path: ["cloneUrl"], message: "Clone URL must use HTTPS or SSH" }],
|
||||
}));
|
||||
render();
|
||||
await waitFor(() => expect(document.body.textContent).toContain("No repositories yet"));
|
||||
[...document.querySelectorAll("button")].find((button) => button.textContent?.includes("Add manually"))!.click();
|
||||
await waitFor(() => expect(document.body.textContent).toContain("Add a repository manually"));
|
||||
const urlInput = document.querySelector<HTMLInputElement>("#manual-clone-url")!;
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!;
|
||||
setter.call(urlInput, "file:///local/repo");
|
||||
urlInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
[...document.querySelectorAll("button")].find((button) => button.textContent?.includes("Add repository"))!.click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.body.textContent).toContain("Clone URL must use HTTPS or SSH");
|
||||
expect(urlInput.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(urlInput.getAttribute("aria-describedby")).toBe("manual-clone-url-error");
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces a failed sync and refreshes connection health", async () => {
|
||||
const connection = {
|
||||
id: "connection-1",
|
||||
provider: "github",
|
||||
host: "github.com",
|
||||
accountName: "acme",
|
||||
status: "active",
|
||||
syncStatus: "idle",
|
||||
syncError: null,
|
||||
};
|
||||
repositoriesApiMock.list.mockResolvedValue([makeRepo()]);
|
||||
repositoriesApiMock.listConnections
|
||||
.mockResolvedValueOnce([connection])
|
||||
.mockResolvedValue([{ ...connection, status: "error", syncStatus: "failed", syncError: "Provider unavailable" }]);
|
||||
repositoriesApiMock.syncConnection.mockRejectedValue(new Error("request failed"));
|
||||
render();
|
||||
await waitFor(() => expect(document.body.textContent).toContain("Idle"));
|
||||
[...document.querySelectorAll("button")].find((button) => button.textContent?.includes("Sync"))!.click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(repositoriesApiMock.listConnections.mock.calls.length).toBeGreaterThan(1);
|
||||
expect(document.body.textContent).toContain("Sync failed");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { RepositoryCatalogItem, RepositoryConnection } from "@paperclipai/shared";
|
||||
import { AlertCircle, GitBranch, Github, Loader2, Plus, RefreshCw } from "lucide-react";
|
||||
import { repositoriesApi } from "../api/repositories";
|
||||
import { ApiError } from "../api/client";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
@@ -52,6 +53,24 @@ function toneClass(tone: "success" | "warning" | "danger" | "neutral"): string {
|
||||
}
|
||||
}
|
||||
|
||||
function apiErrorMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof ApiError && error.body && typeof error.body === "object") {
|
||||
const details = (error.body as { details?: unknown }).details;
|
||||
if (Array.isArray(details)) {
|
||||
const message = details.find(
|
||||
(detail): detail is { message: string } =>
|
||||
Boolean(detail && typeof detail === "object" && typeof (detail as { message?: unknown }).message === "string"),
|
||||
)?.message;
|
||||
if (message) return message;
|
||||
}
|
||||
}
|
||||
if (error && typeof error === "object" && "message" in error) {
|
||||
const message = (error as { message?: unknown }).message;
|
||||
if (typeof message === "string" && message && message !== "Validation error") return message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function Repositories() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
@@ -61,6 +80,9 @@ export function Repositories() {
|
||||
const [githubHost, setGithubHost] = useState<string | null>(null);
|
||||
const [manualOpen, setManualOpen] = useState(false);
|
||||
const [manualUrl, setManualUrl] = useState("");
|
||||
const [catalogNotice, setCatalogNotice] = useState<string | null>(null);
|
||||
const githubTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const manualTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Repositories" }]);
|
||||
@@ -88,26 +110,53 @@ export function Repositories() {
|
||||
const githubProviders = (providersQuery.data ?? []).filter((entry) => entry.provider === "github");
|
||||
const defaultGithubHost = githubProviders.length === 1 ? githubProviders[0]!.host : null;
|
||||
|
||||
function openGithubDialog(host: string | null) {
|
||||
function restoreTrigger(ref: React.RefObject<HTMLButtonElement | null>) {
|
||||
queueMicrotask(() => ref.current?.focus());
|
||||
}
|
||||
|
||||
function openGithubDialog(host: string | null, trigger: HTMLButtonElement) {
|
||||
githubTriggerRef.current = trigger;
|
||||
setGithubHost(host);
|
||||
setGithubOpen(true);
|
||||
}
|
||||
|
||||
function handleGithubOpenChange(next: boolean) {
|
||||
setGithubOpen(next);
|
||||
if (!next) restoreTrigger(githubTriggerRef);
|
||||
}
|
||||
|
||||
function openManualDialog(trigger: HTMLButtonElement) {
|
||||
manualTriggerRef.current = trigger;
|
||||
setManualOpen(true);
|
||||
}
|
||||
|
||||
function handleManualOpenChange(next: boolean) {
|
||||
setManualOpen(next);
|
||||
if (!next) restoreTrigger(manualTriggerRef);
|
||||
}
|
||||
|
||||
const manualMutation = useMutation({
|
||||
mutationFn: (cloneUrl: string) =>
|
||||
repositoriesApi.createManual(selectedCompanyId!, { cloneUrl, visibility: "unknown" }),
|
||||
onSuccess: () => {
|
||||
onSuccess: (repository) => {
|
||||
const alreadyPresent = repositories.some((entry) => entry.id === repository.id);
|
||||
setCatalogNotice(alreadyPresent
|
||||
? `${repository.owner}/${repository.name} is already in the catalog.`
|
||||
: `Added ${repository.owner}/${repository.name} to the catalog.`);
|
||||
setManualOpen(false);
|
||||
setManualUrl("");
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.repositories.list(selectedCompanyId!) });
|
||||
restoreTrigger(manualTriggerRef);
|
||||
},
|
||||
});
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: (connectionId: string) => repositoriesApi.syncConnection(connectionId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.repositories.list(selectedCompanyId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.repositories.connections(selectedCompanyId!) });
|
||||
onSettled: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.repositories.list(selectedCompanyId!) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.repositories.connections(selectedCompanyId!) }),
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -145,28 +194,28 @@ export function Repositories() {
|
||||
}
|
||||
|
||||
const actions = (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => setManualOpen(true)}>
|
||||
<div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto">
|
||||
<Button variant="outline" onClick={(event) => openManualDialog(event.currentTarget)}>
|
||||
<Plus className="h-4 w-4" /> Add manually
|
||||
</Button>
|
||||
{githubProviders.length > 1 ? (
|
||||
githubProviders.map((entry) => (
|
||||
<Button key={entry.host} onClick={() => openGithubDialog(entry.host)}>
|
||||
<Button key={entry.host} onClick={(event) => openGithubDialog(entry.host, event.currentTarget)}>
|
||||
<Github className="h-4 w-4" /> Connect {entry.host}
|
||||
</Button>
|
||||
))
|
||||
) : (
|
||||
<Button onClick={() => openGithubDialog(defaultGithubHost)}>
|
||||
) : githubProviders.length === 1 ? (
|
||||
<Button onClick={(event) => openGithubDialog(defaultGithubHost, event.currentTarget)}>
|
||||
<Github className="h-4 w-4" /> Connect GitHub
|
||||
</Button>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex flex-col items-start gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-semibold text-foreground">Repositories</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Reusable git repositories available to projects and agents in this company.
|
||||
@@ -182,16 +231,23 @@ export function Repositories() {
|
||||
{connections.map((connection) => {
|
||||
const tone = connectionHealthTone(connection);
|
||||
return (
|
||||
<Card key={connection.id} className="flex items-center justify-between gap-3 px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Card key={connection.id} className="flex flex-row items-start justify-between gap-3 px-4 py-3 sm:items-center">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Github className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
<div className="min-w-0">
|
||||
<p className="break-words text-sm font-medium text-foreground">
|
||||
{connection.accountName ?? connection.host}
|
||||
</p>
|
||||
<p className={`text-xs ${toneClass(tone)}`}>
|
||||
{connectionHealthLabel(connection)}
|
||||
{connection.syncError ? ` — ${connection.syncError}` : ""}
|
||||
<p
|
||||
role={syncMutation.isError && syncMutation.variables === connection.id ? "alert" : undefined}
|
||||
className={`text-xs ${toneClass(tone)}`}
|
||||
>
|
||||
{syncMutation.isError && syncMutation.variables === connection.id
|
||||
? "Sync failed — status refreshed; try again"
|
||||
: connectionHealthLabel(connection)}
|
||||
{connection.syncError && !(syncMutation.isError && syncMutation.variables === connection.id)
|
||||
? ` — ${connection.syncError}`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -201,7 +257,7 @@ export function Repositories() {
|
||||
onClick={() => syncMutation.mutate(connection.id)}
|
||||
disabled={connection.status === "disconnected" || syncMutation.isPending}
|
||||
>
|
||||
{syncMutation.isPending ? (
|
||||
{syncMutation.isPending && syncMutation.variables === connection.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
@@ -215,14 +271,19 @@ export function Repositories() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{catalogNotice ? <p role="status" className="text-sm text-muted-foreground">{catalogNotice}</p> : null}
|
||||
|
||||
{repositories.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={GitBranch}
|
||||
title="No repositories yet"
|
||||
message="Connect GitHub to import repositories, or add one manually by its clone URL."
|
||||
action="Connect GitHub"
|
||||
message={githubProviders.length > 0
|
||||
? "Add a repository by clone URL, or connect GitHub to import from a provider."
|
||||
: "Add a repository by clone URL. Provider connections appear here when configured."}
|
||||
action="Add manually"
|
||||
actionRef={manualTriggerRef}
|
||||
hideActionIcon
|
||||
onAction={() => openGithubDialog(defaultGithubHost)}
|
||||
onAction={() => setManualOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -243,7 +304,7 @@ export function Repositories() {
|
||||
<li key={repo.id}>
|
||||
<Link
|
||||
to={repo.id}
|
||||
className="flex items-center justify-between gap-3 px-3 py-2.5 hover:bg-muted/50"
|
||||
className="flex flex-col items-start gap-2 px-3 py-2.5 hover:bg-muted/50 sm:flex-row sm:items-center sm:justify-between sm:gap-3"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<GitBranch className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
@@ -254,7 +315,7 @@ export function Repositories() {
|
||||
<p className="truncate text-xs text-muted-foreground">{repo.host}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 pl-7 sm:shrink-0 sm:pl-0">
|
||||
{repo.state !== "active" ? (
|
||||
<Badge variant="outline" className="text-warning">{repo.state}</Badge>
|
||||
) : null}
|
||||
@@ -278,11 +339,11 @@ export function Repositories() {
|
||||
companyId={selectedCompanyId!}
|
||||
open={githubOpen}
|
||||
host={githubHost}
|
||||
onOpenChange={setGithubOpen}
|
||||
onOpenChange={handleGithubOpenChange}
|
||||
onImported={() => refresh()}
|
||||
/>
|
||||
|
||||
<Dialog open={manualOpen} onOpenChange={setManualOpen}>
|
||||
<Dialog open={manualOpen} onOpenChange={handleManualOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add a repository manually</DialogTitle>
|
||||
@@ -294,14 +355,19 @@ export function Repositories() {
|
||||
<Label htmlFor="manual-clone-url">Clone URL</Label>
|
||||
<Input
|
||||
id="manual-clone-url"
|
||||
aria-invalid={manualMutation.isError}
|
||||
aria-describedby={manualMutation.isError ? "manual-clone-url-error" : undefined}
|
||||
value={manualUrl}
|
||||
onChange={(event) => setManualUrl(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setManualUrl(event.target.value);
|
||||
if (manualMutation.isError) manualMutation.reset();
|
||||
}}
|
||||
placeholder="https://github.com/owner/repo.git"
|
||||
/>
|
||||
{manualMutation.isError ? (
|
||||
<p role="alert" className="flex items-center gap-2 text-sm text-destructive">
|
||||
<p id="manual-clone-url-error" role="alert" className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{(manualMutation.error as { message?: string } | null)?.message ?? "Failed to add repository."}
|
||||
{apiErrorMessage(manualMutation.error, "Failed to add repository.")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -88,8 +88,9 @@ describe("RepositoryDetail", () => {
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 0)));
|
||||
|
||||
expect(container.textContent).toContain("This is guidance, not a boundary");
|
||||
expect(container.textContent).toContain("Direct grant · Also inherited through Project One");
|
||||
expect(container.textContent).toContain("Inherited through projects (1)");
|
||||
expect(container.textContent).toContain("Inherited through Project One · managed on those projects");
|
||||
expect(container.textContent).toContain("Inherited access");
|
||||
const revoke = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("Revoke direct grant"));
|
||||
await act(() => revoke!.click());
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 0)));
|
||||
|
||||
@@ -15,12 +15,13 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
function MetadataRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 py-2">
|
||||
<span className="text-sm text-muted-foreground">{label}</span>
|
||||
<span className="text-right text-sm text-foreground">{value}</span>
|
||||
<div className="flex flex-col gap-1 py-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
|
||||
<span className="shrink-0 text-sm text-muted-foreground">{label}</span>
|
||||
<span className="min-w-0 break-all text-left text-sm text-foreground sm:text-right">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -37,24 +38,42 @@ function SectionPicker({ label, options, onSelect }: {
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const filtered = options.filter((option) => option.label.toLowerCase().includes(search.trim().toLowerCase()));
|
||||
function handleOpenChange(next: boolean) {
|
||||
setOpen(next);
|
||||
if (!next) setSearch("");
|
||||
}
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="outline" size="xs" disabled={options.length === 0}>
|
||||
<Plus className="mr-1 h-3 w-3" />{label}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-1" align="end">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
className="w-full truncate rounded px-2 py-1.5 text-left text-xs hover:bg-accent"
|
||||
onClick={() => { onSelect(option.id); setOpen(false); }}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
<PopoverContent className="w-64 space-y-2 p-2" align="end">
|
||||
<Input
|
||||
aria-label={`Search ${label.toLowerCase()} options`}
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Search"
|
||||
/>
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
{filtered.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
title={option.label}
|
||||
className="w-full break-words rounded px-2 py-1.5 text-left text-xs hover:bg-accent"
|
||||
onClick={() => { onSelect(option.id); handleOpenChange(false); }}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
{filtered.length === 0 ? (
|
||||
<p className="px-2 py-3 text-center text-xs text-muted-foreground">No options match.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
@@ -128,6 +147,8 @@ export function RepositoryDetail() {
|
||||
const agentOptions = (agentsQuery.data ?? [])
|
||||
.filter((agent) => !directAgentIds.has(agent.id))
|
||||
.map((agent) => ({ id: agent.id, label: agent.name }));
|
||||
const directAgents = relationships?.agents.filter((agent) => agent.sources.direct) ?? [];
|
||||
const inheritedAgents = relationships?.agents.filter((agent) => !agent.sources.direct) ?? [];
|
||||
|
||||
if (repositoryQuery.isLoading || relationshipsQuery.isLoading) return <PageSkeleton />;
|
||||
if (repositoryQuery.isError || relationshipsQuery.isError || !repository || !relationships) {
|
||||
@@ -142,10 +163,10 @@ export function RepositoryDetail() {
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6 p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<GitBranch className="h-5 w-5 text-muted-foreground" aria-hidden />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-foreground">
|
||||
<div className="min-w-0">
|
||||
<h1 className="break-words text-lg font-semibold text-foreground">
|
||||
{repository.owner}/{repository.name}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">{repository.host}</p>
|
||||
@@ -173,8 +194,8 @@ export function RepositoryDetail() {
|
||||
<Separator />
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex flex-col items-start gap-3 sm:flex-row sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-medium">Project hints</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Attach this repository to every project likely to touch it. This is guidance, not a boundary.
|
||||
@@ -185,8 +206,8 @@ export function RepositoryDetail() {
|
||||
{relationships.projects.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No projects currently hint at this repository.</p>
|
||||
) : relationships.projects.map((project) => (
|
||||
<div key={project.id} className="flex items-center justify-between gap-3 border-b border-border py-2 last:border-b-0">
|
||||
<Link to={`/projects/${project.id}/configuration`} className="text-sm hover:underline">{project.name}</Link>
|
||||
<div key={project.id} className="flex flex-col items-start gap-2 border-b border-border py-2 last:border-b-0 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<Link to={`/projects/${project.id}/configuration`} className="break-words text-sm hover:underline">{project.name}</Link>
|
||||
<Button type="button" variant="ghost" size="xs" onClick={() => detachProject.mutate(project.id)}>
|
||||
Remove project hint
|
||||
</Button>
|
||||
@@ -197,34 +218,47 @@ export function RepositoryDetail() {
|
||||
<Separator />
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-medium">Direct agent access</h2>
|
||||
<p className="text-xs text-muted-foreground">These grants are in addition to access inherited through projects.</p>
|
||||
<div className="flex flex-col items-start gap-3 sm:flex-row sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-medium">Direct agent access ({directAgents.length})</h2>
|
||||
<p className="text-xs text-muted-foreground">Explicit grants managed on this repository.</p>
|
||||
</div>
|
||||
<SectionPicker label="Grant agent" options={agentOptions} onSelect={(id) => grantAgent.mutate(id)} />
|
||||
</div>
|
||||
{relationships.agents.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No agents currently have effective access.</p>
|
||||
) : relationships.agents.map((agent) => (
|
||||
<div key={agent.id} className="flex items-center justify-between gap-3 border-b border-border py-2 last:border-b-0">
|
||||
{directAgents.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No agents have a direct grant.</p>
|
||||
) : directAgents.map((agent) => (
|
||||
<div key={agent.id} className="flex flex-col items-start gap-2 border-b border-border py-2 last:border-b-0 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<div className="min-w-0">
|
||||
<Link to={`/agents/${agent.id}`} className="text-sm hover:underline">{agent.name}</Link>
|
||||
<p className="text-(length:--text-micro) text-muted-foreground">
|
||||
{agent.sources.projects.length > 0
|
||||
? `Inherited through ${agent.sources.projects.map((project) => project.name).join(", ")} · managed on those projects`
|
||||
? `Direct grant · Also inherited through ${agent.sources.projects.map((project) => project.name).join(", ")}`
|
||||
: "Direct grant"}
|
||||
</p>
|
||||
</div>
|
||||
{agent.sources.direct ? (
|
||||
<Button type="button" variant="ghost" size="xs" onClick={() => revokeAgent.mutate(agent.id)}>
|
||||
Revoke direct grant
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-(length:--text-micro) text-muted-foreground">Inherited access</span>
|
||||
)}
|
||||
<Button type="button" variant="ghost" size="xs" onClick={() => revokeAgent.mutate(agent.id)}>
|
||||
Revoke direct grant
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{inheritedAgents.length > 0 ? (
|
||||
<details className="border-t border-border pt-3">
|
||||
<summary className="cursor-pointer text-xs font-medium text-muted-foreground">
|
||||
Inherited through projects ({inheritedAgents.length})
|
||||
</summary>
|
||||
<div className="mt-2">
|
||||
{inheritedAgents.map((agent) => (
|
||||
<div key={agent.id} className="flex flex-col gap-1 border-b border-border py-2 last:border-b-0">
|
||||
<Link to={`/agents/${agent.id}`} className="text-sm hover:underline">{agent.name}</Link>
|
||||
<p className="text-(length:--text-micro) text-muted-foreground">
|
||||
Inherited through {agent.sources.projects.map((project) => project.name).join(", ")} · managed on those projects
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user