mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 00:44:49 +00:00
* fix(app): show PR panel state, activity verbs, and thread badges in the app language The PR panel's state label, the verb after each activity author, and the Resolved and Outdated thread badges were English literals, so they stayed English in every other language. The state and verb translations already existed and were unused; the badges get new keys in every locale. Closes #6214 * fix(app): translate every PR panel verb and state, and phrase them after the author The zh-CN state and review labels still held English ("Open", "Draft", "已 review"), and the Spanish and Portuguese verbs were adjectives that do not read after an author's name. The translation test now checks each verb and state on its own, so one English label fails it. Co-authored-by: Dillon <dillonzq@outlook.com> --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: Dillon <dillonzq@outlook.com>
1475 lines
46 KiB
TypeScript
1475 lines
46 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 { getActivityVerbKey, getStateLabelKey } 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">
|
|
{t(getStateLabelKey(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={t("workspace.git.pr.sections.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}
|
|
>
|
|
{t("workspace.git.pr.actions.addAllToChat")}
|
|
</Button>
|
|
</View>
|
|
) : null}
|
|
{activityLoading ? <PrActivitySkeleton /> : null}
|
|
{!activityLoading && visibleEntries.length === 0 ? (
|
|
<Text style={sectionKitStyles.emptyText}>{t("workspace.git.pr.empty.noActivity")}</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={t("workspace.git.pr.accessibility.commentActions")}
|
|
>
|
|
{renderKebabTriggerIcon}
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" width={200}>
|
|
{attachEnabled && canAddPullRequestActivityToChat(activity) ? (
|
|
<DropdownMenuItem leading={ADD_TO_CHAT_MENU_ICON} onSelect={handleAddToChat}>
|
|
{t("workspace.git.pr.actions.addToChat")}
|
|
</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 { t } = useTranslation();
|
|
const verb = t(getActivityVerbKey(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 { t } = useTranslation();
|
|
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}
|
|
>
|
|
{t("workspace.git.pr.actions.addToChat")}
|
|
</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 { t } = useTranslation();
|
|
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}
|
|
>
|
|
{t("workspace.git.pr.actions.addToChat")}
|
|
</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}
|
|
>
|
|
{t("workspace.git.pr.actions.addToChat")}
|
|
</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={t("workspace.git.pr.thread.resolved")} variant="success" />
|
|
) : null}
|
|
{thread.location?.isOutdated ? (
|
|
<StatusBadge label={t("workspace.git.pr.thread.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={t("workspace.git.pr.accessibility.threadActions")}
|
|
>
|
|
{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}
|
|
>
|
|
{t("workspace.git.pr.actions.addToChat")}
|
|
</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",
|
|
},
|
|
}));
|