mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 18:22:38 +00:00
* feat(protocol): add forge check presentation metadata Expose optional rawStatus, isManual, and requiresAction fields while keeping older check payloads backward-compatible. * fix(gitea): model Actions jobs as tasks Gitea's /actions/tasks endpoint returns individual jobs rather than workflow runs. Preserve sibling tasks from the latest execution, avoid collapsing same-name jobs, and enrich blocked tasks with run-level approval metadata. * feat(app): distinguish forge check presentation states * fix(gitlab): preserve pipeline job semantics * refactor(app): centralize forge check presentation policy Use one canonical category order, count shape, visual policy, and translated copy mapping across sidebar, hover-card, PR-pane, and GitLab pipeline surfaces. * refactor: consolidate forge check handling * feat(forge): auto-attach pasted forge references * docs(compat): correct forge rollout metadata * refactor(forge): model check nuances as traits * fix(forge): harden checks from branch review Structural Gitea Actions shadow dedup by run id (verified upstream), rerun grouping without workflow_id, shared trait/status constants and Gitea state mapping in the protocol package, all cloud host aliases in pasted-link matching, logged glab job-detail failures, concurrent approvals/pipeline fetch, checks aggregate from the same pipeline as the job list, and removal of the unread check-level rawStatus. The released pipeline-level rawStatus is staged for removal under COMPAT(pipelineRawStatus). * fix(gitea): detect fork-PR Actions approval hold as action_required gitea.com represents a fork PR awaiting Actions approval as a run in status "waiting" with no tasks and pending shadow commit statuses; it does not expose need_approval. The previous mapping only set action_required from a task with blocked+need_approval, so the gate never surfaced (0 tasks -> early return; need_approval absent). Detect the hold from /actions/runs: a waiting/blocked run whose PR is cross-repo (head.repo.id != base.repo.id), or one that does expose need_approval (self-hosted). Tag the run's pending shadow statuses action_required. Runs are only fetched when a pending shadow has no covering task, keeping the common path at two requests. Verified live against gitea.com fork PR (run 715170, waiting, cross-repo). * fix(gitea): resolve cross-fork PRs by head sha The current-PR matcher gated candidates on the checkout owner (giteaHeadOwner === expectedHeadOwner), which excludes every fork PR: its head carries the fork owner while the checkout is the base repo, so a fork PR - the only shape that can be approval-held on Gitea - never resolved and its checks never showed in-app. GitLab has no such gate (it resolves fork MRs from the target project by source branch). Accept a recent PR whose head sha equals the workspace head sha regardless of owner; the sha is an unambiguous identity, so the anti-collision owner gate still applies when no sha match is available. Verified live against gitea.com fork PR #6 (resolves + renders action_required). * fix: resolve forge rebase drift * fix: type forge auto-attach boundary * fix(i18n): add Korean check status copy --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
1468 lines
45 KiB
TypeScript
1468 lines
45 KiB
TypeScript
import { useCallback, useMemo, useState } from "react";
|
|
import { Image, Pressable, ScrollView, Text, View, type ViewStyle } from "react-native";
|
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
|
import {
|
|
CircleCheck,
|
|
CircleX,
|
|
Copy,
|
|
ExternalLink,
|
|
GitMerge,
|
|
GitPullRequest,
|
|
GitPullRequestClosed,
|
|
GitPullRequestDraft,
|
|
MessageSquare,
|
|
MessageSquarePlus,
|
|
MoreHorizontal,
|
|
RotateCw,
|
|
} from "lucide-react-native";
|
|
import type { PressableStateCallbackType } from "react-native";
|
|
import { useTranslation } from "react-i18next";
|
|
import { openExternalUrl } from "@/utils/open-external-url";
|
|
import { Button } from "@/components/ui/button";
|
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
|
import { StatusBadge } from "@/components/ui/status-badge";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
|
import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment";
|
|
import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
|
|
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
|
import { useSessionStore } from "@/stores/session-store";
|
|
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
|
|
import { useToast } from "@/contexts/toast-context";
|
|
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
|
import { isNative } from "@/constants/platform";
|
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
import {
|
|
PaneContentToolbar,
|
|
paneContentToolbarIconSize,
|
|
paneContentToolbarIconButtonStyle,
|
|
} from "@/components/ui/pane-content-toolbar";
|
|
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
|
import { getForgePresentation } from "@/git/forge";
|
|
import { CLIENT_FORGE_VIEW_MODULES } from "@/git/forges/view";
|
|
import type { PaneNativeContribution } from "@/git/client-forge-module";
|
|
import { PrActivitySkeleton } from "./activity-skeleton";
|
|
import {
|
|
collapseActivity,
|
|
expandActivity,
|
|
getActivityState,
|
|
getCollapsedEntryIds,
|
|
getVisibleEntries,
|
|
} from "./activity-state";
|
|
import { formatPullRequestThreadPath } from "./activity-location";
|
|
import {
|
|
buildPullRequestCommentContextAttachment,
|
|
buildPullRequestCheckContextAttachment,
|
|
buildPullRequestReviewContextAttachment,
|
|
buildPullRequestThreadContextAttachment,
|
|
canAddPullRequestActivityToChat,
|
|
} from "./context-attachment";
|
|
import { ChecksSection, getCheckIdentity } from "./checks-section";
|
|
import { getActivityVerb, getStateLabel } from "./data";
|
|
import type { PrPaneActivity, PrPaneCheck, PrPaneData, PrState } from "./data";
|
|
import type { ForgeSpecificStatusFacts } from "@/git/merge-capability";
|
|
import { CheckPresentationIcon } from "@/git/check-presentation.view";
|
|
import {
|
|
buildPrTimeline,
|
|
type PrReviewEntry,
|
|
type PrThreadEntry,
|
|
type PrTimelineEntry,
|
|
} from "./timeline";
|
|
import {
|
|
Section,
|
|
SummaryPill,
|
|
dangerColorMapping,
|
|
foregroundMutedColorMapping,
|
|
sectionKitStyles,
|
|
successColorMapping,
|
|
} from "./section-kit";
|
|
|
|
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
|
const ThemedCircleX = withUnistyles(CircleX);
|
|
const ThemedCopy = withUnistyles(Copy);
|
|
const ThemedExternalLink = withUnistyles(ExternalLink);
|
|
const ThemedGitMerge = withUnistyles(GitMerge);
|
|
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
|
const ThemedGitPullRequestClosed = withUnistyles(GitPullRequestClosed);
|
|
const ThemedGitPullRequestDraft = withUnistyles(GitPullRequestDraft);
|
|
const ThemedMessageSquare = withUnistyles(MessageSquare);
|
|
const ThemedMessageSquarePlus = withUnistyles(MessageSquarePlus);
|
|
const ThemedMoreHorizontal = withUnistyles(MoreHorizontal);
|
|
const ThemedRotateCw = withUnistyles(RotateCw);
|
|
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
|
|
|
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
|
const mergedColorMapping = (theme: Theme) => ({ color: theme.colors.statusMerged });
|
|
|
|
const CLIENT_PANE_CONTRIBUTIONS: readonly PaneNativeContribution[] =
|
|
CLIENT_FORGE_VIEW_MODULES.flatMap((module) => module.paneContributions ?? []);
|
|
|
|
function resolvePaneContribution(
|
|
facts: ForgeSpecificStatusFacts | undefined,
|
|
): PaneNativeContribution | null {
|
|
if (!facts) {
|
|
return null;
|
|
}
|
|
return CLIENT_PANE_CONTRIBUTIONS.find((contribution) => contribution.guard(facts)) ?? null;
|
|
}
|
|
|
|
type IconColorMapping = typeof foregroundColorMapping;
|
|
|
|
interface PrStatePresentation {
|
|
Icon: typeof ThemedGitPullRequest;
|
|
iconColor: IconColorMapping;
|
|
}
|
|
|
|
const PR_STATE_PRESENTATION: Record<PrState, PrStatePresentation> = {
|
|
open: { Icon: ThemedGitPullRequest, iconColor: successColorMapping },
|
|
draft: { Icon: ThemedGitPullRequestDraft, iconColor: foregroundMutedColorMapping },
|
|
merged: { Icon: ThemedGitMerge, iconColor: mergedColorMapping },
|
|
closed: { Icon: ThemedGitPullRequestClosed, iconColor: dangerColorMapping },
|
|
};
|
|
|
|
const SUMMARY_COMMENT_ICON = (
|
|
<ThemedMessageSquare size={11} uniProps={foregroundMutedColorMapping} />
|
|
);
|
|
const SUMMARY_APPROVAL_ICON = <CheckPresentationIcon presentation="success" size={12} />;
|
|
const SUMMARY_CHANGES_REQUESTED_ICON = <CheckPresentationIcon presentation="failure" size={12} />;
|
|
const ADD_TO_CHAT_MENU_ICON = (
|
|
<ThemedMessageSquarePlus size={14} uniProps={foregroundMutedColorMapping} />
|
|
);
|
|
const COPY_MENU_ICON = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
|
|
const OPEN_MENU_ICON = <ThemedExternalLink size={14} uniProps={foregroundMutedColorMapping} />;
|
|
|
|
function handleMarkdownLinkPress(url: string): boolean {
|
|
void openExternalUrl(url);
|
|
return false;
|
|
}
|
|
|
|
function entryHeaderPressableStyle({ hovered }: { hovered?: boolean }) {
|
|
return [styles.entryHeaderPressable, Boolean(hovered) && styles.hoverable];
|
|
}
|
|
|
|
function kebabTriggerStyle({
|
|
hovered = false,
|
|
}: PressableStateCallbackType & { hovered?: boolean }) {
|
|
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
|
|
}
|
|
|
|
function refreshButtonStyle(
|
|
{ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean },
|
|
isCompact: boolean,
|
|
) {
|
|
return paneContentToolbarIconButtonStyle({ hovered, pressed }, false, isCompact);
|
|
}
|
|
|
|
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
|
|
return (
|
|
<ThemedMoreHorizontal
|
|
size={14}
|
|
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function addLoadingCheck(current: ReadonlySet<string>, checkKey: string): ReadonlySet<string> {
|
|
if (current.has(checkKey)) {
|
|
return current;
|
|
}
|
|
const next = new Set(current);
|
|
next.add(checkKey);
|
|
return next;
|
|
}
|
|
|
|
function removeLoadingCheck(current: ReadonlySet<string>, checkKey: string): ReadonlySet<string> {
|
|
if (!current.has(checkKey)) {
|
|
return current;
|
|
}
|
|
const next = new Set(current);
|
|
next.delete(checkKey);
|
|
return next;
|
|
}
|
|
|
|
export function PullRequestPane({
|
|
serverId,
|
|
cwd,
|
|
data,
|
|
activityLoading,
|
|
workspaceAttachmentScopeKey,
|
|
}: {
|
|
serverId: string;
|
|
cwd: string;
|
|
data: PrPaneData;
|
|
activityLoading: boolean;
|
|
workspaceAttachmentScopeKey?: string;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const isCompact = useIsCompactFormFactor();
|
|
const toolbarRefreshButtonStyle = useCallback(
|
|
(state: PressableStateCallbackType) => refreshButtonStyle(state, isCompact),
|
|
[isCompact],
|
|
);
|
|
const toast = useToast();
|
|
const daemonClient = useHostRuntimeClient(serverId);
|
|
// COMPAT(githubCheckDetailsRpc): recognize the legacy capability on daemons
|
|
// predating checkout.forge.get_check_details.*. Remove after 2027-01-17 once
|
|
// the supported daemon floor is >= v0.2.0.
|
|
const canFetchGitHubCheckDetails = useSessionStore(
|
|
(state) => state.sessions[serverId]?.serverInfo?.features?.githubCheckDetails === true,
|
|
);
|
|
const canFetchForgeCheckDetails = useSessionStore(
|
|
(state) => state.sessions[serverId]?.serverInfo?.features?.forgeCheckDetails === true,
|
|
);
|
|
const forgeProvidersEnabled = useSessionStore(
|
|
(state) => state.sessions[serverId]?.serverInfo?.features?.forgeProviders === true,
|
|
);
|
|
const addWorkspaceAttachment = useWorkspaceAttachmentsStore(
|
|
(state) => state.addWorkspaceAttachment,
|
|
);
|
|
const [checksOpen, setChecksOpen] = useState(true);
|
|
const [activityOpen, setActivityOpen] = useState(true);
|
|
const [activityState, setActivityState] = useState(getActivityState);
|
|
const [loadingCheckKeys, setLoadingCheckKeys] = useState<ReadonlySet<string>>(() => new Set());
|
|
|
|
const handleOpenPrUrl = useCallback(() => {
|
|
void openExternalUrl(data.url);
|
|
}, [data.url]);
|
|
|
|
const refreshSupported = useSessionStore(
|
|
(state) => state.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true,
|
|
);
|
|
const runRefresh = useCheckoutGitActionsStore((state) => state.refresh);
|
|
const isRefreshing =
|
|
useCheckoutGitActionsStore((state) =>
|
|
state.getStatus({ serverId, cwd, actionId: "refresh" }),
|
|
) === "pending";
|
|
|
|
const handleRefresh = useCallback(() => {
|
|
if (isRefreshing) {
|
|
return;
|
|
}
|
|
void runRefresh({ serverId, cwd }).catch((error) => {
|
|
toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh"));
|
|
});
|
|
}, [cwd, isRefreshing, runRefresh, serverId, t, toast]);
|
|
|
|
const handleToggleChecks = useCallback(() => {
|
|
setChecksOpen((open) => !open);
|
|
}, []);
|
|
|
|
const handleToggleActivity = useCallback(() => {
|
|
setActivityOpen((open) => !open);
|
|
}, []);
|
|
|
|
const approvals = data.activity.filter(
|
|
(item) => item.kind === "review" && item.reviewState === "approved",
|
|
).length;
|
|
const changesRequested = data.activity.filter(
|
|
(item) => item.kind === "review" && item.reviewState === "changes_requested",
|
|
).length;
|
|
const commentCount = data.activity.filter(
|
|
(item) =>
|
|
item.kind === "comment" || (item.kind === "review" && item.reviewState === "commented"),
|
|
).length;
|
|
|
|
const timelineEntries = useMemo(() => buildPrTimeline(data.activity), [data.activity]);
|
|
const visibleEntries = useMemo(
|
|
() =>
|
|
getVisibleEntries(activityState, {
|
|
prNumber: data.number,
|
|
entries: timelineEntries,
|
|
}),
|
|
[activityState, data.number, timelineEntries],
|
|
);
|
|
const collapsedEntryIds = useMemo(
|
|
() => getCollapsedEntryIds(activityState, { prNumber: data.number, entries: timelineEntries }),
|
|
[activityState, data.number, timelineEntries],
|
|
);
|
|
const attachEnabled = workspaceAttachmentScopeKey !== undefined;
|
|
|
|
const handleAddActivityToChat = useCallback(
|
|
(activity: PrPaneActivity) => {
|
|
if (!workspaceAttachmentScopeKey || !canAddPullRequestActivityToChat(activity)) {
|
|
return;
|
|
}
|
|
const input = {
|
|
provider: data.provider,
|
|
forge: data.forge,
|
|
pullRequest: { number: data.number, title: data.title, url: data.url },
|
|
activity,
|
|
};
|
|
const attachment =
|
|
activity.kind === "comment"
|
|
? buildPullRequestCommentContextAttachment(input)
|
|
: buildPullRequestReviewContextAttachment(input);
|
|
if (!attachment) {
|
|
return;
|
|
}
|
|
addWorkspaceAttachment({
|
|
scopeKey: workspaceAttachmentScopeKey,
|
|
attachment,
|
|
});
|
|
},
|
|
[
|
|
addWorkspaceAttachment,
|
|
data.forge,
|
|
data.number,
|
|
data.provider,
|
|
data.title,
|
|
data.url,
|
|
workspaceAttachmentScopeKey,
|
|
],
|
|
);
|
|
|
|
const handleAddThreadToChat = useCallback(
|
|
(thread: PrThreadEntry) => {
|
|
if (!workspaceAttachmentScopeKey) {
|
|
return;
|
|
}
|
|
const attachment = buildPullRequestThreadContextAttachment({
|
|
provider: data.provider,
|
|
forge: data.forge,
|
|
pullRequest: { number: data.number, title: data.title, url: data.url },
|
|
thread,
|
|
});
|
|
if (!attachment) {
|
|
return;
|
|
}
|
|
addWorkspaceAttachment({
|
|
scopeKey: workspaceAttachmentScopeKey,
|
|
attachment,
|
|
});
|
|
},
|
|
[
|
|
addWorkspaceAttachment,
|
|
data.forge,
|
|
data.number,
|
|
data.provider,
|
|
data.title,
|
|
data.url,
|
|
workspaceAttachmentScopeKey,
|
|
],
|
|
);
|
|
|
|
const handleAddAllToChat = useCallback(() => {
|
|
for (const { entry } of visibleEntries) {
|
|
if (entry.kind === "single") {
|
|
handleAddActivityToChat(entry.activity);
|
|
continue;
|
|
}
|
|
if (entry.kind === "review") {
|
|
handleAddActivityToChat(entry.review);
|
|
}
|
|
const threads = entry.kind === "thread" ? [entry] : entry.threads;
|
|
for (const thread of threads) {
|
|
if (thread.isResolved === true) {
|
|
continue;
|
|
}
|
|
handleAddThreadToChat(thread);
|
|
}
|
|
}
|
|
}, [handleAddActivityToChat, handleAddThreadToChat, visibleEntries]);
|
|
|
|
const handleAddCheckLogsToChat = useCallback(
|
|
async (check: PrPaneCheck) => {
|
|
if (!workspaceAttachmentScopeKey) {
|
|
return;
|
|
}
|
|
const checkKey = getCheckIdentity(check);
|
|
setLoadingCheckKeys((current) => addLoadingCheck(current, checkKey));
|
|
|
|
let details = null;
|
|
try {
|
|
const ref = check.detailRef;
|
|
// The neutral forge RPC fetches detail for any forge; fall back to the
|
|
// legacy github-only RPC only for GitHub against a daemon that predates
|
|
// it. A non-GitHub forge therefore needs the neutral capability present.
|
|
const canFetchDetail =
|
|
canFetchForgeCheckDetails || (check.provider === "github" && canFetchGitHubCheckDetails);
|
|
if (
|
|
canFetchDetail &&
|
|
daemonClient &&
|
|
(ref?.checkRunId !== undefined || ref?.workflowRunId !== undefined) &&
|
|
data.repoOwner &&
|
|
data.repoName
|
|
) {
|
|
try {
|
|
const request = {
|
|
cwd,
|
|
repoOwner: data.repoOwner,
|
|
repoName: data.repoName,
|
|
checkRunId: ref.checkRunId,
|
|
workflowRunId: ref.workflowRunId,
|
|
changeRequestNumber: data.number,
|
|
};
|
|
// COMPAT(githubCheckDetailsRpc): use the legacy GitHub RPC with
|
|
// daemons predating checkout.forge.get_check_details.*. Remove after
|
|
// 2027-01-17 once the supported daemon floor is >= v0.2.0.
|
|
const payload = canFetchForgeCheckDetails
|
|
? await daemonClient.checkoutForgeGetCheckDetails(request)
|
|
: await daemonClient.checkoutGithubGetCheckDetails(request);
|
|
details = payload.success ? payload.details : null;
|
|
} catch {
|
|
details = null;
|
|
}
|
|
}
|
|
const attachment = buildPullRequestCheckContextAttachment({
|
|
provider: data.provider,
|
|
forge: data.forge,
|
|
pullRequest: { number: data.number, title: data.title, url: data.url },
|
|
check,
|
|
githubDetails: details,
|
|
});
|
|
addWorkspaceAttachment({
|
|
scopeKey: workspaceAttachmentScopeKey,
|
|
attachment,
|
|
});
|
|
} catch {
|
|
// The check row should recover even if attachment formatting or insertion fails.
|
|
} finally {
|
|
setLoadingCheckKeys((current) => removeLoadingCheck(current, checkKey));
|
|
}
|
|
},
|
|
[
|
|
addWorkspaceAttachment,
|
|
canFetchForgeCheckDetails,
|
|
canFetchGitHubCheckDetails,
|
|
cwd,
|
|
daemonClient,
|
|
data.forge,
|
|
data.number,
|
|
data.provider,
|
|
data.repoName,
|
|
data.repoOwner,
|
|
data.title,
|
|
data.url,
|
|
workspaceAttachmentScopeKey,
|
|
],
|
|
);
|
|
|
|
const handleToggleEntryCollapsed = useCallback(
|
|
(entryId: string, collapsed: boolean) => {
|
|
setActivityState((current) => {
|
|
const identity = { prNumber: data.number, activityId: entryId };
|
|
return collapsed ? expandActivity(current, identity) : collapseActivity(current, identity);
|
|
});
|
|
},
|
|
[data.number],
|
|
);
|
|
|
|
const statePresentation = PR_STATE_PRESENTATION[data.state];
|
|
const StateIcon = statePresentation.Icon;
|
|
const forgePresentation = getForgePresentation(data.forge);
|
|
const repoIdentity =
|
|
data.projectPath ??
|
|
(data.repoOwner && data.repoName ? `${data.repoOwner}/${data.repoName}` : null);
|
|
|
|
// Native forge surfaces (e.g. GitLab approvals/pipeline) come from a registry
|
|
// keyed by the facts-family, so the central render has no per-forge branch.
|
|
const nativeContribution = resolvePaneContribution(data.forgeSpecific);
|
|
const nativeHeaderMeta = data.forgeSpecific
|
|
? nativeContribution?.renderHeaderMeta(data.forgeSpecific)
|
|
: null;
|
|
const nativeChecksSection = data.forgeSpecific
|
|
? nativeContribution?.renderChecksSection(data.forgeSpecific, {
|
|
serverId,
|
|
cwd,
|
|
changeRequestNumber: data.number,
|
|
open: checksOpen,
|
|
onToggle: handleToggleChecks,
|
|
enabled: forgeProvidersEnabled,
|
|
canFetchCheckDetails: canFetchForgeCheckDetails,
|
|
})
|
|
: null;
|
|
|
|
return (
|
|
<View style={styles.root} testID="pr-pane">
|
|
<ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}>
|
|
<PaneContentToolbar style={styles.toolbar} testID="pr-pane-toolbar">
|
|
<View style={styles.toolbarActions}>
|
|
<Button
|
|
variant="ghost"
|
|
size="xs"
|
|
leftIcon={ExternalLink}
|
|
onPress={handleOpenPrUrl}
|
|
style={styles.viewButton}
|
|
testID="pr-pane-view-pr"
|
|
>
|
|
{t("workspace.git.pr.actions.viewPullRequest")}
|
|
</Button>
|
|
</View>
|
|
{refreshSupported ? (
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel={
|
|
isRefreshing
|
|
? t("workspace.git.diff.refreshing")
|
|
: t("workspace.git.diff.refreshState", { brand: forgePresentation.brandLabel })
|
|
}
|
|
testID="pr-pane-refresh"
|
|
style={toolbarRefreshButtonStyle}
|
|
hitSlop={8}
|
|
onPress={handleRefresh}
|
|
disabled={isRefreshing}
|
|
>
|
|
<View style={styles.refreshIcon}>
|
|
{isRefreshing ? (
|
|
<ThemedLoadingSpinner
|
|
size={paneContentToolbarIconSize(isCompact)}
|
|
uniProps={foregroundMutedColorMapping}
|
|
/>
|
|
) : (
|
|
<ThemedRotateCw
|
|
size={paneContentToolbarIconSize(isCompact)}
|
|
uniProps={foregroundMutedColorMapping}
|
|
/>
|
|
)}
|
|
</View>
|
|
</Pressable>
|
|
) : null}
|
|
</PaneContentToolbar>
|
|
|
|
<Pressable onPress={handleOpenPrUrl} style={styles.header}>
|
|
{({ hovered }) => (
|
|
<>
|
|
<Text style={styles.title} testID="pr-pane-title">
|
|
{data.title}
|
|
<Text style={styles.titleNumber}>
|
|
{" "}
|
|
{forgePresentation.numberPrefix}
|
|
{data.number}
|
|
</Text>
|
|
</Text>
|
|
<View style={styles.metaLine}>
|
|
<StateIcon size={14} uniProps={statePresentation.iconColor} />
|
|
<Text style={stateLabelStyle(data.state)} testID="pr-pane-state">
|
|
{getStateLabel(data.state)}
|
|
</Text>
|
|
{nativeHeaderMeta}
|
|
{repoIdentity ? (
|
|
<Text style={styles.repoRef} numberOfLines={1}>
|
|
{repoIdentity}
|
|
</Text>
|
|
) : null}
|
|
<View style={hovered ? styles.headerLinkIcon : styles.headerLinkIconHidden}>
|
|
<ThemedExternalLink size={12} uniProps={foregroundMutedColorMapping} />
|
|
</View>
|
|
</View>
|
|
</>
|
|
)}
|
|
</Pressable>
|
|
|
|
{nativeChecksSection ?? (
|
|
<ChecksSection
|
|
checks={data.checks}
|
|
open={checksOpen}
|
|
onToggle={handleToggleChecks}
|
|
attachEnabled={attachEnabled}
|
|
loadingCheckKeys={loadingCheckKeys}
|
|
onAddLogsToChat={handleAddCheckLogsToChat}
|
|
/>
|
|
)}
|
|
|
|
<View style={styles.divider} />
|
|
|
|
<Section
|
|
title="Activity"
|
|
open={activityOpen}
|
|
onToggle={handleToggleActivity}
|
|
summary={
|
|
<>
|
|
<SummaryPill count={approvals} icon={SUMMARY_APPROVAL_ICON} variant="success" />
|
|
<SummaryPill
|
|
count={changesRequested}
|
|
icon={SUMMARY_CHANGES_REQUESTED_ICON}
|
|
variant="danger"
|
|
/>
|
|
<SummaryPill count={commentCount} icon={SUMMARY_COMMENT_ICON} variant="muted" />
|
|
</>
|
|
}
|
|
>
|
|
{timelineEntries.length > 0 && attachEnabled && visibleEntries.length > 0 ? (
|
|
<View style={styles.activityToolbar}>
|
|
<Button
|
|
variant="ghost"
|
|
size="xs"
|
|
leftIcon={MessageSquarePlus}
|
|
onPress={handleAddAllToChat}
|
|
disabled={activityLoading}
|
|
>
|
|
Add all to chat
|
|
</Button>
|
|
</View>
|
|
) : null}
|
|
{activityLoading ? <PrActivitySkeleton /> : null}
|
|
{!activityLoading && visibleEntries.length === 0 ? (
|
|
<Text style={sectionKitStyles.emptyText}>No activity yet</Text>
|
|
) : null}
|
|
{!activityLoading
|
|
? visibleEntries.map(({ entry, collapsed }) => (
|
|
<TimelineEntryCard
|
|
key={entry.id}
|
|
entry={entry}
|
|
collapsed={collapsed}
|
|
collapsedEntryIds={collapsedEntryIds}
|
|
attachEnabled={attachEnabled}
|
|
brandLabel={forgePresentation.brandLabel}
|
|
onAddToChat={handleAddActivityToChat}
|
|
onAddThreadToChat={handleAddThreadToChat}
|
|
onToggleCollapsed={handleToggleEntryCollapsed}
|
|
/>
|
|
))
|
|
: null}
|
|
</Section>
|
|
</ScrollView>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function stateLabelStyle(state: PrState) {
|
|
if (state === "open") return styles.stateLabelOpen;
|
|
if (state === "draft") return styles.stateLabelDraft;
|
|
if (state === "merged") return styles.stateLabelMerged;
|
|
return styles.stateLabelClosed;
|
|
}
|
|
|
|
interface TimelineEntryCallbacks {
|
|
attachEnabled: boolean;
|
|
brandLabel: string;
|
|
onAddToChat: (activity: PrPaneActivity) => void;
|
|
onAddThreadToChat: (thread: PrThreadEntry) => void;
|
|
onToggleCollapsed: (entryId: string, collapsed: boolean) => void;
|
|
}
|
|
|
|
function TimelineEntryCard({
|
|
entry,
|
|
collapsed,
|
|
collapsedEntryIds,
|
|
...callbacks
|
|
}: TimelineEntryCallbacks & {
|
|
entry: PrTimelineEntry;
|
|
collapsed: boolean;
|
|
collapsedEntryIds: ReadonlySet<string>;
|
|
}) {
|
|
if (entry.kind === "thread") {
|
|
return <ThreadCard entry={entry} collapsed={collapsed} {...callbacks} />;
|
|
}
|
|
if (entry.kind === "review") {
|
|
return (
|
|
<ReviewCard
|
|
entry={entry}
|
|
collapsed={collapsed}
|
|
collapsedEntryIds={collapsedEntryIds}
|
|
{...callbacks}
|
|
/>
|
|
);
|
|
}
|
|
return <SingleActivityCard entry={entry} collapsed={collapsed} {...callbacks} />;
|
|
}
|
|
|
|
function useRevealOnHover() {
|
|
const isCompact = useIsCompactFormFactor();
|
|
const [isHovered, setIsHovered] = useState(false);
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
|
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
|
const actionsVisible = isHovered || menuOpen || isNative || isCompact;
|
|
return { actionsVisible, handlePointerEnter, handlePointerLeave, setMenuOpen };
|
|
}
|
|
|
|
function ActivityKebab({
|
|
activity,
|
|
visible,
|
|
attachEnabled,
|
|
brandLabel,
|
|
onMenuOpenChange,
|
|
onAddToChat,
|
|
}: {
|
|
activity: PrPaneActivity;
|
|
visible: boolean;
|
|
attachEnabled: boolean;
|
|
brandLabel: string;
|
|
onMenuOpenChange: (open: boolean) => void;
|
|
onAddToChat: (activity: PrPaneActivity) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const handleAddToChat = useCallback(() => onAddToChat(activity), [activity, onAddToChat]);
|
|
const handleCopy = useCallback(() => {
|
|
void writeMarkdownToRichClipboard(activity.body, getDefaultMarkdownClipboardEnvironment());
|
|
}, [activity.body]);
|
|
const handleOpen = useCallback(() => {
|
|
void openExternalUrl(activity.url);
|
|
}, [activity.url]);
|
|
|
|
return (
|
|
<View style={kebabSlotStyle(visible)} pointerEvents={visible ? "auto" : "none"}>
|
|
<DropdownMenu onOpenChange={onMenuOpenChange}>
|
|
<DropdownMenuTrigger
|
|
hitSlop={8}
|
|
style={kebabTriggerStyle}
|
|
accessibilityLabel="Comment actions"
|
|
>
|
|
{renderKebabTriggerIcon}
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" width={200}>
|
|
{attachEnabled && canAddPullRequestActivityToChat(activity) ? (
|
|
<DropdownMenuItem leading={ADD_TO_CHAT_MENU_ICON} onSelect={handleAddToChat}>
|
|
Add to chat
|
|
</DropdownMenuItem>
|
|
) : null}
|
|
{activity.body.trim() !== "" ? (
|
|
<DropdownMenuItem leading={COPY_MENU_ICON} onSelect={handleCopy}>
|
|
Copy
|
|
</DropdownMenuItem>
|
|
) : null}
|
|
<DropdownMenuItem leading={OPEN_MENU_ICON} onSelect={handleOpen}>
|
|
{t("workspace.git.pr.actions.openOn", { brand: brandLabel })}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function kebabSlotStyle(visible: boolean) {
|
|
return visible ? styles.kebabSlot : styles.kebabSlotHidden;
|
|
}
|
|
|
|
function ActivityAvatar({ activity, size }: { activity: PrPaneActivity; size: number }) {
|
|
const frameStyle = useMemo(
|
|
() => [
|
|
styles.avatar,
|
|
{
|
|
width: size,
|
|
height: size,
|
|
borderRadius: size / 2,
|
|
backgroundColor: activity.avatarColor,
|
|
},
|
|
],
|
|
[activity.avatarColor, size],
|
|
);
|
|
const imageStyle = useMemo(() => ({ width: size, height: size, borderRadius: size / 2 }), [size]);
|
|
const imageSource = useMemo(
|
|
() => (activity.avatarUrl ? { uri: activity.avatarUrl } : null),
|
|
[activity.avatarUrl],
|
|
);
|
|
return (
|
|
<View style={frameStyle}>
|
|
{imageSource ? (
|
|
<Image source={imageSource} style={imageStyle} />
|
|
) : (
|
|
<Text style={styles.avatarText}>{activity.author.slice(0, 1).toUpperCase()}</Text>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function ActivityVerb({ activity }: { activity: PrPaneActivity }) {
|
|
const verb = getActivityVerb(activity).toLowerCase();
|
|
if (activity.kind === "review" && activity.reviewState === "approved") {
|
|
return (
|
|
<View style={styles.verbGroup}>
|
|
<ThemedCircleCheck size={12} uniProps={successColorMapping} />
|
|
<Text style={styles.verbSuccess}>{verb}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
if (activity.kind === "review" && activity.reviewState === "changes_requested") {
|
|
return (
|
|
<View style={styles.verbGroup}>
|
|
<ThemedCircleX size={12} uniProps={dangerColorMapping} />
|
|
<Text style={styles.verbDanger}>{verb}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
return <Text style={styles.verbMuted}>{verb}</Text>;
|
|
}
|
|
|
|
function ActivityHeader({
|
|
activity,
|
|
avatarSize,
|
|
children,
|
|
}: {
|
|
activity: PrPaneActivity;
|
|
avatarSize: number;
|
|
children?: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<>
|
|
<ActivityAvatar activity={activity} size={avatarSize} />
|
|
<Text style={styles.authorText} numberOfLines={1}>
|
|
{activity.author}
|
|
</Text>
|
|
<ActivityVerb activity={activity} />
|
|
<View style={styles.headerTrailing}>
|
|
<Text style={styles.ageText}>{activity.age}</Text>
|
|
{children}
|
|
</View>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function SingleActivityCard({
|
|
entry,
|
|
collapsed,
|
|
attachEnabled,
|
|
brandLabel,
|
|
onAddToChat,
|
|
onToggleCollapsed,
|
|
}: TimelineEntryCallbacks & {
|
|
entry: Extract<PrTimelineEntry, { kind: "single" }>;
|
|
collapsed: boolean;
|
|
}) {
|
|
const { activity } = entry;
|
|
const { actionsVisible, handlePointerEnter, handlePointerLeave, setMenuOpen } =
|
|
useRevealOnHover();
|
|
const hasBody = activity.body.trim() !== "";
|
|
const handleAddToChat = useCallback(() => onAddToChat(activity), [activity, onAddToChat]);
|
|
const handleHeaderPress = useCallback(() => {
|
|
if (hasBody) {
|
|
onToggleCollapsed(entry.id, collapsed);
|
|
return;
|
|
}
|
|
void openExternalUrl(activity.url);
|
|
}, [activity.url, collapsed, entry.id, hasBody, onToggleCollapsed]);
|
|
|
|
if (!hasBody) {
|
|
return (
|
|
<View
|
|
style={styles.eventRow}
|
|
onPointerEnter={handlePointerEnter}
|
|
onPointerLeave={handlePointerLeave}
|
|
testID="pr-pane-activity-row"
|
|
>
|
|
<Pressable onPress={handleHeaderPress} style={entryHeaderPressableStyle}>
|
|
<ActivityHeader activity={activity} avatarSize={20}>
|
|
<ActivityKebab
|
|
activity={activity}
|
|
visible={actionsVisible}
|
|
attachEnabled={attachEnabled}
|
|
brandLabel={brandLabel}
|
|
onMenuOpenChange={setMenuOpen}
|
|
onAddToChat={onAddToChat}
|
|
/>
|
|
</ActivityHeader>
|
|
</Pressable>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View
|
|
style={styles.card}
|
|
onPointerEnter={handlePointerEnter}
|
|
onPointerLeave={handlePointerLeave}
|
|
testID="pr-pane-activity-row"
|
|
>
|
|
<Pressable onPress={handleHeaderPress} style={entryHeaderPressableStyle}>
|
|
<ActivityHeader activity={activity} avatarSize={20}>
|
|
<ActivityKebab
|
|
activity={activity}
|
|
visible={actionsVisible}
|
|
attachEnabled={attachEnabled}
|
|
brandLabel={brandLabel}
|
|
onMenuOpenChange={setMenuOpen}
|
|
onAddToChat={onAddToChat}
|
|
/>
|
|
</ActivityHeader>
|
|
</Pressable>
|
|
{collapsed ? null : (
|
|
<>
|
|
<View style={styles.cardBody}>
|
|
<MarkdownRenderer text={activity.body} compact onLinkPress={handleMarkdownLinkPress} />
|
|
</View>
|
|
{attachEnabled && canAddPullRequestActivityToChat(activity) ? (
|
|
<View style={styles.cardFooter}>
|
|
<Button
|
|
variant="ghost"
|
|
size="xs"
|
|
leftIcon={MessageSquarePlus}
|
|
onPress={handleAddToChat}
|
|
>
|
|
Add to chat
|
|
</Button>
|
|
</View>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function ThreadCard({
|
|
entry,
|
|
collapsed,
|
|
attachEnabled,
|
|
brandLabel,
|
|
onAddToChat,
|
|
onAddThreadToChat,
|
|
onToggleCollapsed,
|
|
}: TimelineEntryCallbacks & {
|
|
entry: PrThreadEntry;
|
|
collapsed: boolean;
|
|
}) {
|
|
return (
|
|
<View style={styles.card} testID="pr-pane-activity-row">
|
|
<ThreadBlock
|
|
thread={entry}
|
|
collapsed={collapsed}
|
|
attachEnabled={attachEnabled}
|
|
brandLabel={brandLabel}
|
|
onAddToChat={onAddToChat}
|
|
onAddThreadToChat={onAddThreadToChat}
|
|
onToggleCollapsed={onToggleCollapsed}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function ReviewCard({
|
|
entry,
|
|
collapsed,
|
|
collapsedEntryIds,
|
|
attachEnabled,
|
|
brandLabel,
|
|
onAddToChat,
|
|
onAddThreadToChat,
|
|
onToggleCollapsed,
|
|
}: TimelineEntryCallbacks & {
|
|
entry: PrReviewEntry;
|
|
collapsed: boolean;
|
|
collapsedEntryIds: ReadonlySet<string>;
|
|
}) {
|
|
const { review, threads } = entry;
|
|
const { actionsVisible, handlePointerEnter, handlePointerLeave, setMenuOpen } =
|
|
useRevealOnHover();
|
|
const hasBody = review.body.trim() !== "";
|
|
const handleAddToChat = useCallback(() => onAddToChat(review), [onAddToChat, review]);
|
|
const handleHeaderPress = useCallback(() => {
|
|
onToggleCollapsed(entry.id, collapsed);
|
|
}, [collapsed, entry.id, onToggleCollapsed]);
|
|
|
|
return (
|
|
<View
|
|
style={styles.card}
|
|
onPointerEnter={handlePointerEnter}
|
|
onPointerLeave={handlePointerLeave}
|
|
testID="pr-pane-activity-row"
|
|
>
|
|
<Pressable onPress={handleHeaderPress} style={entryHeaderPressableStyle}>
|
|
<ActivityHeader activity={review} avatarSize={20}>
|
|
{collapsed && attachEnabled && canAddPullRequestActivityToChat(review) ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="xs"
|
|
leftIcon={MessageSquarePlus}
|
|
onPress={handleAddToChat}
|
|
style={styles.checkAddButton}
|
|
>
|
|
Add to chat
|
|
</Button>
|
|
) : null}
|
|
{collapsed ? (
|
|
<View style={styles.threadCount}>
|
|
<ThemedMessageSquare size={11} uniProps={foregroundMutedColorMapping} />
|
|
<Text style={styles.ageText}>{threads.length}</Text>
|
|
</View>
|
|
) : null}
|
|
<ActivityKebab
|
|
activity={review}
|
|
visible={actionsVisible}
|
|
attachEnabled={attachEnabled}
|
|
brandLabel={brandLabel}
|
|
onMenuOpenChange={setMenuOpen}
|
|
onAddToChat={onAddToChat}
|
|
/>
|
|
</ActivityHeader>
|
|
</Pressable>
|
|
{collapsed ? null : (
|
|
<>
|
|
{hasBody ? (
|
|
<View style={styles.cardBody}>
|
|
<MarkdownRenderer text={review.body} compact onLinkPress={handleMarkdownLinkPress} />
|
|
</View>
|
|
) : null}
|
|
{attachEnabled && canAddPullRequestActivityToChat(review) ? (
|
|
<View style={styles.cardFooter}>
|
|
<Button
|
|
variant="ghost"
|
|
size="xs"
|
|
leftIcon={MessageSquarePlus}
|
|
onPress={handleAddToChat}
|
|
>
|
|
Add to chat
|
|
</Button>
|
|
</View>
|
|
) : null}
|
|
{threads.length > 0 ? (
|
|
<View style={styles.nestedThreadsContainer}>
|
|
{threads.map((thread) => (
|
|
<View key={thread.id} style={styles.nestedThread}>
|
|
<ThreadBlock
|
|
thread={thread}
|
|
collapsed={collapsedEntryIds.has(thread.id)}
|
|
attachEnabled={attachEnabled}
|
|
brandLabel={brandLabel}
|
|
onAddToChat={onAddToChat}
|
|
onAddThreadToChat={onAddThreadToChat}
|
|
onToggleCollapsed={onToggleCollapsed}
|
|
/>
|
|
</View>
|
|
))}
|
|
</View>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function ThreadBlock({
|
|
thread,
|
|
collapsed,
|
|
attachEnabled,
|
|
brandLabel,
|
|
onAddToChat,
|
|
onAddThreadToChat,
|
|
onToggleCollapsed,
|
|
}: {
|
|
thread: PrThreadEntry;
|
|
collapsed: boolean;
|
|
attachEnabled: boolean;
|
|
brandLabel: string;
|
|
onAddToChat: (activity: PrPaneActivity) => void;
|
|
onAddThreadToChat: (thread: PrThreadEntry) => void;
|
|
onToggleCollapsed: (entryId: string, collapsed: boolean) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const { actionsVisible, handlePointerEnter, handlePointerLeave, setMenuOpen } =
|
|
useRevealOnHover();
|
|
const handleHeaderPress = useCallback(() => {
|
|
onToggleCollapsed(thread.id, collapsed);
|
|
}, [collapsed, onToggleCollapsed, thread.id]);
|
|
const handleAddThreadToChat = useCallback(
|
|
() => onAddThreadToChat(thread),
|
|
[onAddThreadToChat, thread],
|
|
);
|
|
const handleOpenThread = useCallback(() => {
|
|
void openExternalUrl(thread.comments[0].url);
|
|
}, [thread.comments]);
|
|
|
|
const [root, ...replies] = thread.comments;
|
|
|
|
return (
|
|
<View onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave}>
|
|
<Pressable onPress={handleHeaderPress} style={threadHeaderPressableStyle}>
|
|
<Text style={styles.threadPath} numberOfLines={1}>
|
|
{thread.location
|
|
? formatPullRequestThreadPath(thread.location)
|
|
: t("workspace.git.pr.thread.discussion")}
|
|
</Text>
|
|
{thread.isResolved ? <StatusBadge label="Resolved" variant="success" /> : null}
|
|
{thread.location?.isOutdated ? <StatusBadge label="Outdated" /> : null}
|
|
<View style={styles.headerTrailing}>
|
|
{collapsed ? (
|
|
<View style={styles.threadCount}>
|
|
<ThemedMessageSquare size={11} uniProps={foregroundMutedColorMapping} />
|
|
<Text style={styles.ageText}>{thread.comments.length}</Text>
|
|
</View>
|
|
) : null}
|
|
<View
|
|
style={kebabSlotStyle(actionsVisible)}
|
|
pointerEvents={actionsVisible ? "auto" : "none"}
|
|
>
|
|
<DropdownMenu onOpenChange={setMenuOpen}>
|
|
<DropdownMenuTrigger
|
|
hitSlop={8}
|
|
style={kebabTriggerStyle}
|
|
accessibilityLabel="Thread actions"
|
|
>
|
|
{renderKebabTriggerIcon}
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" width={200}>
|
|
<DropdownMenuItem leading={OPEN_MENU_ICON} onSelect={handleOpenThread}>
|
|
{t("workspace.git.pr.actions.openOn", { brand: brandLabel })}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</View>
|
|
</View>
|
|
</Pressable>
|
|
{collapsed ? null : (
|
|
<>
|
|
<ThreadComment
|
|
comment={root}
|
|
attachEnabled={attachEnabled}
|
|
brandLabel={brandLabel}
|
|
onAddToChat={onAddToChat}
|
|
/>
|
|
{replies.length > 0 ? (
|
|
<View style={styles.replyRail}>
|
|
{replies.map((reply) => (
|
|
<View key={reply.id} style={styles.replyCard}>
|
|
<ThreadComment
|
|
comment={reply}
|
|
attachEnabled={attachEnabled}
|
|
brandLabel={brandLabel}
|
|
onAddToChat={onAddToChat}
|
|
contentStyle={styles.replyThreadComment}
|
|
/>
|
|
</View>
|
|
))}
|
|
</View>
|
|
) : null}
|
|
{attachEnabled ? (
|
|
<View style={styles.cardFooter}>
|
|
<Button
|
|
variant="ghost"
|
|
size="xs"
|
|
leftIcon={MessageSquarePlus}
|
|
onPress={handleAddThreadToChat}
|
|
>
|
|
Add to chat
|
|
</Button>
|
|
</View>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function threadHeaderPressableStyle({ hovered }: { hovered?: boolean }) {
|
|
return [styles.threadHeader, Boolean(hovered) && styles.hoverable];
|
|
}
|
|
|
|
function threadCommentStyle(contentStyle?: ViewStyle) {
|
|
return contentStyle ? [styles.threadComment, contentStyle] : styles.threadComment;
|
|
}
|
|
|
|
function ThreadComment({
|
|
comment,
|
|
attachEnabled,
|
|
brandLabel,
|
|
onAddToChat,
|
|
contentStyle,
|
|
}: {
|
|
comment: PrPaneActivity;
|
|
attachEnabled: boolean;
|
|
brandLabel: string;
|
|
onAddToChat: (activity: PrPaneActivity) => void;
|
|
contentStyle?: ViewStyle;
|
|
}) {
|
|
const { actionsVisible, handlePointerEnter, handlePointerLeave, setMenuOpen } =
|
|
useRevealOnHover();
|
|
return (
|
|
<View
|
|
style={threadCommentStyle(contentStyle)}
|
|
onPointerEnter={handlePointerEnter}
|
|
onPointerLeave={handlePointerLeave}
|
|
>
|
|
<View style={styles.threadCommentHeader}>
|
|
<ActivityHeader activity={comment} avatarSize={16}>
|
|
<ActivityKebab
|
|
activity={comment}
|
|
visible={actionsVisible}
|
|
attachEnabled={attachEnabled}
|
|
brandLabel={brandLabel}
|
|
onMenuOpenChange={setMenuOpen}
|
|
onAddToChat={onAddToChat}
|
|
/>
|
|
</ActivityHeader>
|
|
</View>
|
|
{comment.body.trim() !== "" ? (
|
|
<View style={styles.threadCommentBody}>
|
|
<MarkdownRenderer text={comment.body} compact onLinkPress={handleMarkdownLinkPress} />
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
root: {
|
|
flex: 1,
|
|
minHeight: 0,
|
|
backgroundColor: theme.colors.surfaceSidebar,
|
|
},
|
|
scroll: {
|
|
flex: 1,
|
|
minHeight: 0,
|
|
},
|
|
invisible: {
|
|
opacity: 0,
|
|
},
|
|
hoverable: {
|
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
|
},
|
|
header: {
|
|
flexDirection: "column",
|
|
gap: theme.spacing[2],
|
|
paddingHorizontal: theme.spacing[4],
|
|
paddingVertical: theme.spacing[4],
|
|
},
|
|
title: {
|
|
fontSize: theme.fontSize.base,
|
|
fontWeight: theme.fontWeight.normal,
|
|
color: theme.colors.foreground,
|
|
lineHeight: 22,
|
|
},
|
|
titleNumber: {
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
metaLine: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[1],
|
|
minHeight: 16,
|
|
},
|
|
stateLabelOpen: {
|
|
fontSize: theme.fontSize.sm,
|
|
fontWeight: theme.fontWeight.normal,
|
|
color: theme.colors.statusSuccess,
|
|
},
|
|
stateLabelDraft: {
|
|
fontSize: theme.fontSize.sm,
|
|
fontWeight: theme.fontWeight.normal,
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
stateLabelMerged: {
|
|
fontSize: theme.fontSize.sm,
|
|
fontWeight: theme.fontWeight.normal,
|
|
color: theme.colors.statusMerged,
|
|
},
|
|
stateLabelClosed: {
|
|
fontSize: theme.fontSize.sm,
|
|
fontWeight: theme.fontWeight.normal,
|
|
color: theme.colors.statusDanger,
|
|
},
|
|
repoRef: {
|
|
fontSize: theme.fontSize.sm,
|
|
color: theme.colors.foregroundMuted,
|
|
flexShrink: 1,
|
|
marginLeft: theme.spacing[1],
|
|
},
|
|
headerLinkIcon: {
|
|
marginLeft: theme.spacing[1],
|
|
},
|
|
headerLinkIconHidden: {
|
|
marginLeft: theme.spacing[1],
|
|
opacity: 0,
|
|
},
|
|
divider: {
|
|
height: 1,
|
|
backgroundColor: theme.colors.border,
|
|
},
|
|
toolbar: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
paddingRight: theme.spacing[3],
|
|
paddingLeft: theme.spacing[3],
|
|
},
|
|
toolbarActions: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[1],
|
|
},
|
|
viewButton: {
|
|
gap: theme.spacing[1],
|
|
minHeight: 24,
|
|
height: 24,
|
|
paddingVertical: 0,
|
|
paddingHorizontal: theme.spacing[1],
|
|
borderRadius: theme.borderRadius.base,
|
|
},
|
|
refreshIcon: {
|
|
width: ICON_SIZE.md,
|
|
height: ICON_SIZE.md,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
checkAddButton: {
|
|
paddingVertical: 0,
|
|
},
|
|
activityToolbar: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
minHeight: 28,
|
|
paddingRight: theme.spacing[3],
|
|
paddingBottom: theme.spacing[2],
|
|
},
|
|
toolbarTrailing: {
|
|
marginLeft: "auto",
|
|
},
|
|
filterTriggerContent: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[1],
|
|
},
|
|
filterHiddenCount: {
|
|
fontSize: theme.fontSize.sm,
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
eventRow: {
|
|
marginHorizontal: theme.spacing[3],
|
|
marginBottom: theme.spacing[2],
|
|
},
|
|
card: {
|
|
marginHorizontal: theme.spacing[3],
|
|
marginBottom: theme.spacing[3],
|
|
borderWidth: 1,
|
|
borderColor: theme.colors.border,
|
|
borderRadius: theme.borderRadius.lg,
|
|
backgroundColor: theme.colors.surfaceSidebar,
|
|
overflow: "hidden",
|
|
},
|
|
entryHeaderPressable: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
paddingHorizontal: theme.spacing[3],
|
|
paddingVertical: theme.spacing[2],
|
|
minHeight: 36,
|
|
},
|
|
headerTrailing: {
|
|
marginLeft: "auto",
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
},
|
|
authorText: {
|
|
fontSize: theme.fontSize.base,
|
|
fontWeight: theme.fontWeight.normal,
|
|
color: theme.colors.foreground,
|
|
flexShrink: 1,
|
|
},
|
|
verbGroup: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[1],
|
|
},
|
|
verbMuted: {
|
|
fontSize: theme.fontSize.sm,
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
verbSuccess: {
|
|
fontSize: theme.fontSize.sm,
|
|
color: theme.colors.statusSuccess,
|
|
},
|
|
verbDanger: {
|
|
fontSize: theme.fontSize.sm,
|
|
color: theme.colors.statusDanger,
|
|
},
|
|
ageText: {
|
|
fontSize: theme.fontSize.sm,
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
kebabSlot: {
|
|
width: 22,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
kebabSlotHidden: {
|
|
width: 22,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
opacity: 0,
|
|
},
|
|
kebabButton: {
|
|
padding: 2,
|
|
borderRadius: 4,
|
|
},
|
|
kebabButtonHovered: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
cardBody: {
|
|
paddingHorizontal: theme.spacing[3],
|
|
paddingTop: theme.spacing[2],
|
|
paddingBottom: theme.spacing[3],
|
|
},
|
|
cardFooter: {
|
|
flexDirection: "row",
|
|
paddingLeft: 0,
|
|
paddingRight: theme.spacing[2],
|
|
paddingBottom: theme.spacing[2],
|
|
},
|
|
nestedThreadsContainer: {
|
|
paddingTop: theme.spacing[1],
|
|
paddingBottom: theme.spacing[1],
|
|
},
|
|
nestedThread: {
|
|
marginHorizontal: theme.spacing[3],
|
|
marginBottom: theme.spacing[2],
|
|
borderWidth: 1,
|
|
borderColor: theme.colors.border,
|
|
borderRadius: theme.borderRadius.md,
|
|
overflow: "hidden",
|
|
},
|
|
threadHeader: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
paddingHorizontal: theme.spacing[3],
|
|
paddingVertical: theme.spacing[2],
|
|
minHeight: 36,
|
|
backgroundColor: theme.colors.surface1,
|
|
},
|
|
threadPath: {
|
|
fontSize: theme.fontSize.sm,
|
|
fontFamily: theme.fontFamily.mono,
|
|
color: theme.colors.foreground,
|
|
flexShrink: 1,
|
|
},
|
|
threadCount: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 3,
|
|
},
|
|
threadComment: {
|
|
borderTopWidth: 1,
|
|
borderTopColor: theme.colors.border,
|
|
paddingBottom: theme.spacing[2],
|
|
},
|
|
threadCommentHeader: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
paddingHorizontal: theme.spacing[3],
|
|
paddingTop: theme.spacing[2],
|
|
paddingBottom: theme.spacing[1],
|
|
minHeight: 32,
|
|
},
|
|
threadCommentBody: {
|
|
paddingHorizontal: theme.spacing[3],
|
|
paddingTop: theme.spacing[2],
|
|
},
|
|
replyRail: {
|
|
marginHorizontal: theme.spacing[3],
|
|
marginTop: theme.spacing[2],
|
|
},
|
|
replyCard: {
|
|
borderWidth: 1,
|
|
borderColor: theme.colors.border,
|
|
borderRadius: theme.borderRadius.md,
|
|
backgroundColor: theme.colors.surface1,
|
|
marginBottom: theme.spacing[2],
|
|
overflow: "hidden",
|
|
},
|
|
replyThreadComment: {
|
|
borderTopWidth: 0,
|
|
},
|
|
avatar: {
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
overflow: "hidden",
|
|
},
|
|
avatarText: {
|
|
fontSize: 10,
|
|
fontWeight: theme.fontWeight.normal,
|
|
color: "#fff",
|
|
},
|
|
}));
|