Files
multica/packages/views/settings/components/github-tab.tsx
Bohan Jiang b343e13ec4 fix(settings): remove orphan repo count from GitHub tab shortcut card (#3342)
The bare "N" under the Repositories shortcut had no label and was
adjacent to a sentence ("Repository URLs live in the Repositories
tab") that has no semantic link to a number, so users read it as a
typo. The card is a navigation shortcut, not a status panel — the
actual count is visible after clicking through.

MUL-2725

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 13:32:21 +08:00

398 lines
15 KiB
TypeScript

"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { ExternalLink, GitCommitHorizontal, Link2, PanelRight } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { Label } from "@multica/ui/components/ui/label";
import { Switch } from "@multica/ui/components/ui/switch";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@multica/ui/components/ui/alert-dialog";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceId } from "@multica/core/hooks";
import { useCurrentWorkspace } from "@multica/core/paths";
import { memberListOptions, workspaceKeys } from "@multica/core/workspace/queries";
import {
deriveGitHubSettings,
githubInstallationsOptions,
} from "@multica/core/github";
import { api } from "@multica/core/api";
import type { Workspace } from "@multica/core/types";
import { useNavigation } from "../../navigation";
import { useT } from "../../i18n";
import { GitHubMark } from "./github-mark";
type SettingsKey =
| "github_enabled"
| "github_pr_sidebar_enabled"
| "co_authored_by_enabled"
| "github_auto_link_prs_enabled";
export function GitHubTab() {
const { t } = useT("settings");
const workspace = useCurrentWorkspace();
const wsId = useWorkspaceId();
const qc = useQueryClient();
const navigation = useNavigation();
const user = useAuthStore((s) => s.user);
const { data: members = [] } = useQuery(memberListOptions(wsId));
const currentMember = members.find((m) => m.user_id === user?.id) ?? null;
// `canView` gates the read-only installation list (every workspace member
// sees it after MUL-2413); `canManage` gates the Connect / Disconnect
// actions and comes from the backend response (`can_manage`) so the
// frontend never claims management rights the server would reject.
const canView = !!currentMember;
const { data: installationData } = useQuery({
...githubInstallationsOptions(wsId),
enabled: !!wsId && canView,
});
const installations = installationData?.installations ?? [];
const configured = installationData?.configured ?? false;
const canManage = installationData?.can_manage === true;
const connected = installations.length > 0;
const primaryInstallation = installations[0] ?? null;
const flags = deriveGitHubSettings(workspace);
const [savingKey, setSavingKey] = useState<SettingsKey | null>(null);
const [connecting, setConnecting] = useState(false);
const [disconnectTarget, setDisconnectTarget] = useState<string | null>(null);
const [disconnecting, setDisconnecting] = useState(false);
async function persistSetting(key: SettingsKey, next: boolean) {
if (!workspace || savingKey) return;
setSavingKey(key);
try {
const merged = {
...((workspace.settings as Record<string, unknown>) ?? {}),
[key]: next,
};
const updated = await api.updateWorkspace(workspace.id, { settings: merged });
qc.setQueryData(workspaceKeys.list(), (old: Workspace[] | undefined) =>
old?.map((ws) => (ws.id === updated.id ? updated : ws)),
);
} catch (e) {
toast.error(e instanceof Error ? e.message : t(($) => $.github.toast_failed));
} finally {
setSavingKey(null);
}
}
async function handleConnect() {
setConnecting(true);
try {
const resp = await api.getGitHubConnectURL(wsId);
if (!resp.configured || !resp.url) {
toast.error(t(($) => $.github.toast_not_configured));
return;
}
window.open(resp.url, "_blank", "noopener");
} catch (e) {
toast.error(e instanceof Error ? e.message : t(($) => $.github.toast_open_failed));
} finally {
setConnecting(false);
}
}
async function handleDisconnect() {
if (!disconnectTarget || disconnecting) return;
setDisconnecting(true);
try {
await api.deleteGitHubInstallation(wsId, disconnectTarget);
await qc.invalidateQueries({ queryKey: ["github", wsId] });
toast.success(t(($) => $.github.toast_disconnected));
setDisconnectTarget(null);
} catch (e) {
toast.error(e instanceof Error ? e.message : t(($) => $.github.toast_disconnect_failed));
} finally {
setDisconnecting(false);
}
}
if (!workspace) return null;
const repositoriesHref = `${navigation.pathname}?tab=repositories`;
return (
<div className="space-y-8">
<section className="space-y-1">
<p className="text-sm text-muted-foreground">
{t(($) => $.github.page_description)}
</p>
</section>
<section className="space-y-3">
<Card>
<CardContent>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<div className="rounded-md border bg-muted/50 p-2 text-muted-foreground">
<GitHubMark className="h-4 w-4" />
</div>
<div className="space-y-1">
<Label htmlFor="github-master" className="text-sm font-medium">
{t(($) => $.github.section_master)}
</Label>
<p className="text-sm text-muted-foreground">
{flags.enabled
? t(($) => $.github.master_description_on)
: t(($) => $.github.master_description_off)}
</p>
</div>
</div>
<Switch
id="github-master"
checked={flags.enabled}
onCheckedChange={(v) => persistSetting("github_enabled", v)}
disabled={!canManage || savingKey === "github_enabled"}
/>
</div>
</CardContent>
</Card>
</section>
<section className="space-y-3">
<h2 className="text-sm font-semibold">{t(($) => $.github.section_connection)}</h2>
<Card>
<CardContent className="space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<GitHubMark className="h-6 w-6 mt-0.5 shrink-0" />
<div className="space-y-1">
<p className="text-sm font-medium">{t(($) => $.github.connection_title)}</p>
{connected ? (
<>
<p className="text-xs text-muted-foreground">
{t(($) => $.github.connected_to, {
login: installations.map((i) => i.account_login).join(", "),
})}
</p>
{primaryInstallation?.connected_by && (
<p className="text-xs text-muted-foreground">
{t(($) => $.github.connected_by, {
name: primaryInstallation.connected_by!,
})}
</p>
)}
</>
) : canManage ? (
<p className="text-xs text-muted-foreground">
{t(($) => $.github.connection_description_prefix)}{" "}
<code className="rounded bg-muted px-1 py-0.5 text-[10px]">
{t(($) => $.github.connection_identifier_example)}
</code>{" "}
{t(($) => $.github.connection_description_suffix)}{" "}
<strong>{t(($) => $.github.connection_description_done)}</strong>.
</p>
) : (
<p className="text-xs text-muted-foreground">
{t(($) => $.github.contact_admin_to_connect)}
</p>
)}
</div>
</div>
{canManage && (
<div className="flex items-center gap-2">
{connected && primaryInstallation ? (
// Disconnect must stay reachable even when the master switch
// is off — disconnect is a separate intent (revoke the App
// grant) from hiding the feature.
<Button
variant="outline"
size="sm"
onClick={() => setDisconnectTarget(primaryInstallation.id)}
>
{t(($) => $.github.disconnect)}
</Button>
) : (
<Button
size="sm"
onClick={handleConnect}
disabled={connecting || !configured}
title={
!configured
? t(($) => $.github.connect_disabled_tooltip)
: undefined
}
>
{connecting
? t(($) => $.github.connect_opening)
: t(($) => $.github.connect_github)}
</Button>
)}
</div>
)}
</div>
{canManage && !configured && (
<p className="text-xs text-muted-foreground">
{t(($) => $.github.not_configured)}{" "}
<code className="rounded bg-muted px-1 py-0.5 text-[10px]">GITHUB_APP_SLUG</code>{" "}
{t(($) => $.github.not_configured_and)}{" "}
<code className="rounded bg-muted px-1 py-0.5 text-[10px]">GITHUB_WEBHOOK_SECRET</code>.
</p>
)}
{!canManage && connected && (
<p className="text-xs text-muted-foreground">
{t(($) => $.github.read_only_hint)}
</p>
)}
</CardContent>
</Card>
</section>
<section className="space-y-3">
<h2 className="text-sm font-semibold">{t(($) => $.github.section_features)}</h2>
<Card>
<CardContent className="space-y-4">
<FeatureRow
id="github-pr-sidebar"
icon={<PanelRight className="h-4 w-4" />}
label={t(($) => $.github.feature_pr_sidebar_label)}
description={
<p className="text-sm text-muted-foreground">
{t(($) => $.github.feature_pr_sidebar_description)}
</p>
}
checked={flags.prSidebar}
disabled={!canManage || !flags.enabled || savingKey === "github_pr_sidebar_enabled"}
onCheckedChange={(v) => persistSetting("github_pr_sidebar_enabled", v)}
/>
<FeatureRow
id="github-coauthor"
icon={<GitCommitHorizontal className="h-4 w-4" />}
label={t(($) => $.github.feature_co_author_label)}
description={
<p className="text-sm text-muted-foreground">
{t(($) => $.github.feature_co_author_description_prefix)}{" "}
<code className="rounded bg-muted px-1 py-0.5 text-xs">
{"Co-authored-by: multica-agent <github@multica.ai>"}
</code>{" "}
{t(($) => $.github.feature_co_author_description_suffix)}
</p>
}
checked={flags.coAuthor}
disabled={!canManage || !flags.enabled || savingKey === "co_authored_by_enabled"}
onCheckedChange={(v) => persistSetting("co_authored_by_enabled", v)}
/>
<FeatureRow
id="github-auto-link"
icon={<Link2 className="h-4 w-4" />}
label={t(($) => $.github.feature_auto_link_label)}
description={
<p className="text-sm text-muted-foreground">
{t(($) => $.github.feature_auto_link_description)}
</p>
}
checked={flags.autoLinkPRs}
disabled={!canManage || !flags.enabled || savingKey === "github_auto_link_prs_enabled"}
onCheckedChange={(v) => persistSetting("github_auto_link_prs_enabled", v)}
/>
</CardContent>
</Card>
</section>
<section className="space-y-3">
<h2 className="text-sm font-semibold">{t(($) => $.github.section_repositories)}</h2>
<Card>
<CardContent>
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm font-medium">
{t(($) => $.github.repositories_shortcut_label)}
</p>
<Button
variant="outline"
size="sm"
onClick={() => navigation.push(repositoriesHref)}
>
<ExternalLink className="h-3 w-3" />
{t(($) => $.github.repositories_shortcut_link)}
</Button>
</div>
</CardContent>
</Card>
</section>
<AlertDialog
open={!!disconnectTarget}
onOpenChange={(v) => {
if (!v && !disconnecting) setDisconnectTarget(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t(($) => $.github.disconnect_confirm_title)}
</AlertDialogTitle>
<AlertDialogDescription>
{t(($) => $.github.disconnect_confirm_description)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={disconnecting}>
{t(($) => $.github.disconnect_confirm_cancel)}
</AlertDialogCancel>
<AlertDialogAction onClick={handleDisconnect} disabled={disconnecting}>
{disconnecting
? t(($) => $.github.disconnecting)
: t(($) => $.github.disconnect_confirm_action)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
function FeatureRow({
id,
icon,
label,
description,
checked,
disabled,
onCheckedChange,
}: {
id: string;
icon: React.ReactNode;
label: string;
description: React.ReactNode;
checked: boolean;
disabled: boolean;
onCheckedChange: (v: boolean) => void;
}) {
return (
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<div className="rounded-md border bg-muted/50 p-2 text-muted-foreground">{icon}</div>
<div className="space-y-1">
<Label htmlFor={id} className="text-sm font-medium">
{label}
</Label>
{description}
</div>
</div>
<Switch
id={id}
checked={checked}
disabled={disabled}
onCheckedChange={onCheckedChange}
/>
</div>
);
}