Improve GitHub panel toolbar and loading states (#1664)

* feat(app): add toolbar and loading states to the GitHub PR panel

Adds a toolbar under the PR title with a refresh button (refreshes git
and GitHub status + timeline in one tap), an always-visible View Pull
Request button, and a Merge control with a method dropdown that is
disabled-with-reason when the PR can't be merged yet.

The merge control reuses the existing git-actions policy verbatim via a
new buildPrPanelMergeActions selector fed into GitActionsSplitButton, so
no merge readiness/squash/auto-merge logic is duplicated.

Also replaces the empty-state flash with skeletons: a full-panel
skeleton while PR status loads and an activity skeleton while the
timeline loads, with empty states shown only when loaded-and-empty and
an error state with retry.

* refactor(app): tidy PR panel skeleton (i18n, shared pulse, index)

Addresses review feedback on the PR panel work:
- Localize the skeleton's "Checks" label via t() instead of hardcoding.
- Hoist the shared SkeletonPulse + useSkeletonPulse into activity-skeleton
  so pane-skeleton reuses them instead of duplicating the pulse driver.
- Export PullRequestPaneError/Skeleton from the module index and import
  them through it, matching the module's existing public surface.

* test(app): remove mock-based PR pane test

* fix(app): simplify PR panel toolbar

* fix(app): report PR panel retry failures
This commit is contained in:
Mohamed Boudra
2026-06-23 17:50:53 +08:00
committed by GitHub
parent d9cd6ea0fd
commit a397d411dd
16 changed files with 550 additions and 85 deletions
@@ -15,9 +15,13 @@ import { useTranslation } from "react-i18next";
import { import {
formatPrTabLabel, formatPrTabLabel,
PullRequestPane, PullRequestPane,
PullRequestPaneError,
PullRequestPaneSkeleton,
PullRequestTabIcon, PullRequestTabIcon,
usePrPaneData, usePrPaneData,
} from "@/git/pull-request-panel"; } from "@/git/pull-request-panel";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import type { UsePrPaneDataResult } from "@/git/pull-request-panel/use-data";
import { import {
usePanelStore, usePanelStore,
selectIsFileExplorerOpen, selectIsFileExplorerOpen,
@@ -27,6 +31,7 @@ import {
} from "@/stores/panel-store"; } from "@/stores/panel-store";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context"; import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useToast } from "@/contexts/toast-context";
import { canCloseRightSidebarGesture } from "@/utils/sidebar-animation-state"; import { canCloseRightSidebarGesture } from "@/utils/sidebar-animation-state";
import { HEADER_INNER_HEIGHT } from "@/constants/layout"; import { HEADER_INNER_HEIGHT } from "@/constants/layout";
import { GitDiffPane } from "@/git/diff-pane"; import { GitDiffPane } from "@/git/diff-pane";
@@ -452,6 +457,7 @@ function ExplorerSidebarContent({
}: SidebarContentProps) { }: SidebarContentProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation(); const { t } = useTranslation();
const toast = useToast();
const padding = useWindowControlsPadding("explorerSidebar"); const padding = useWindowControlsPadding("explorerSidebar");
const canQueryPullRequest = isGit && Boolean(workspaceRoot); const canQueryPullRequest = isGit && Boolean(workspaceRoot);
const prPane = usePrPaneData({ const prPane = usePrPaneData({
@@ -461,11 +467,17 @@ function ExplorerSidebarContent({
timelineEnabled: activeTab === "pr" && canQueryPullRequest && isOpen, timelineEnabled: activeTab === "pr" && canQueryPullRequest && isOpen,
}); });
const hasPullRequest = prPane.prNumber !== null; const hasPullRequest = prPane.prNumber !== null;
const showPrTab = hasPullRequest || (activeTab === "pr" && prPane.isLoading);
const requestedTab: ExplorerTab = const requestedTab: ExplorerTab =
!isGit && (activeTab === "changes" || activeTab === "pr") ? "files" : activeTab; !isGit && (activeTab === "changes" || activeTab === "pr") ? "files" : activeTab;
const resolvedTab: ExplorerTab = const resolvedTab: ExplorerTab = requestedTab === "pr" && !showPrTab ? "changes" : requestedTab;
requestedTab === "pr" && !hasPullRequest ? "changes" : requestedTab;
const prTabLabel = formatPrTabLabel(prPane.prNumber); const prTabLabel = formatPrTabLabel(prPane.prNumber);
const refreshGitActions = useCheckoutGitActionsStore((s) => s.refresh);
const handlePrRetry = useCallback(() => {
refreshGitActions({ serverId, cwd: workspaceRoot }).catch((error) => {
toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh"));
});
}, [refreshGitActions, serverId, t, toast, workspaceRoot]);
const workspaceAttachmentScopeKey = useMemo( const workspaceAttachmentScopeKey = useMemo(
() => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd: workspaceRoot }), () => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd: workspaceRoot }),
[serverId, workspaceId, workspaceRoot], [serverId, workspaceId, workspaceRoot],
@@ -498,7 +510,7 @@ function ExplorerSidebarContent({
onTabPress={onTabPress} onTabPress={onTabPress}
testID="explorer-tab-files" testID="explorer-tab-files"
/> />
{isGit && hasPullRequest && ( {isGit && showPrTab && (
<ExplorerTabButton <ExplorerTabButton
tab="pr" tab="pr"
active={resolvedTab === "pr"} active={resolvedTab === "pr"}
@@ -542,12 +554,13 @@ function ExplorerSidebarContent({
onOpenFile={onOpenFile} onOpenFile={onOpenFile}
/> />
)} )}
{resolvedTab === "pr" && prPane.data && ( {resolvedTab === "pr" && (
<PullRequestPane <PrTabContent
serverId={serverId} serverId={serverId}
cwd={workspaceRoot} cwd={workspaceRoot}
data={prPane.data} prPane={prPane}
workspaceAttachmentScopeKey={workspaceAttachmentScopeKey} workspaceAttachmentScopeKey={workspaceAttachmentScopeKey}
onRetry={handlePrRetry}
/> />
)} )}
</View> </View>
@@ -555,6 +568,38 @@ function ExplorerSidebarContent({
); );
} }
interface PrTabContentProps {
serverId: string;
cwd: string;
prPane: UsePrPaneDataResult;
workspaceAttachmentScopeKey: string;
onRetry: () => void;
}
function PrTabContent({
serverId,
cwd,
prPane,
workspaceAttachmentScopeKey,
onRetry,
}: PrTabContentProps) {
if (prPane.data) {
return (
<PullRequestPane
serverId={serverId}
cwd={cwd}
data={prPane.data}
activityLoading={prPane.activityLoading}
workspaceAttachmentScopeKey={workspaceAttachmentScopeKey}
/>
);
}
if (prPane.error) {
return <PullRequestPaneError onRetry={onRetry} />;
}
return <PullRequestPaneSkeleton />;
}
// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to // Static styles for Animated.Views — must NOT use Unistyles dynamic theme to
// avoid the "Unable to find node on an unmounted component" crash when Unistyles // avoid the "Unable to find node on an unmounted component" crash when Unistyles
// tries to patch the native node that Reanimated also manages. // tries to patch the native node that Reanimated also manages.
@@ -0,0 +1,87 @@
import { useEffect, useMemo, useRef } from "react";
import { Animated, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
const ROW_KEYS = [0, 1, 2].map((i) => `pr-activity-skeleton-row-${i}`);
export function useSkeletonPulse(): Animated.Value {
const pulse = useRef(new Animated.Value(0)).current;
useEffect(() => {
const animation = Animated.loop(
Animated.sequence([
Animated.timing(pulse, { toValue: 1, duration: 1000, useNativeDriver: true }),
Animated.timing(pulse, { toValue: 0, duration: 1000, useNativeDriver: true }),
]),
);
animation.start();
return () => animation.stop();
}, [pulse]);
return pulse;
}
export function SkeletonPulse({
pulse,
style,
}: {
pulse: Animated.Value;
style: StyleProp<ViewStyle>;
}) {
const opacity = pulse.interpolate({
inputRange: [0, 1],
outputRange: [0.4, 0.8],
});
const pulseStyle = useMemo(() => [style, { opacity }], [style, opacity]);
return <Animated.View style={pulseStyle} />;
}
export function PrActivitySkeleton() {
const pulse = useSkeletonPulse();
return (
<View style={styles.container} testID="pr-pane-activity-skeleton">
{ROW_KEYS.map((key) => (
<View key={key} style={styles.row}>
<SkeletonPulse pulse={pulse} style={styles.avatar} />
<View style={styles.lines}>
<SkeletonPulse pulse={pulse} style={styles.lineWide} />
<SkeletonPulse pulse={pulse} style={styles.lineNarrow} />
</View>
</View>
))}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
paddingHorizontal: theme.spacing[3],
gap: theme.spacing[3],
},
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
avatar: {
width: 20,
height: 20,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface2,
},
lines: {
flex: 1,
gap: theme.spacing[1],
},
lineWide: {
width: "70%",
height: 12,
borderRadius: theme.borderRadius.sm,
backgroundColor: theme.colors.surface2,
},
lineNarrow: {
width: "45%",
height: 10,
borderRadius: theme.borderRadius.sm,
backgroundColor: theme.colors.surface2,
},
}));
@@ -1,4 +1,6 @@
export { PullRequestPane } from "./pane"; export { PullRequestPane } from "./pane";
export { PullRequestPaneError } from "./pane-error";
export { PullRequestPaneSkeleton } from "./pane-skeleton";
export { PullRequestTabIcon } from "./tab-icon"; export { PullRequestTabIcon } from "./tab-icon";
export { formatPrTabLabel } from "./tab-label"; export { formatPrTabLabel } from "./tab-label";
export { prPaneTimelineQueryKind } from "./query-keys"; export { prPaneTimelineQueryKind } from "./query-keys";
@@ -0,0 +1,39 @@
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { RotateCw } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
export function PullRequestPaneError({
onRetry,
message,
}: {
onRetry: () => void;
message?: string;
}) {
const { t } = useTranslation();
return (
<View style={styles.root} testID="pr-pane-error">
<Text style={styles.message}>{message ?? t("workspace.git.diff.failedRefresh")}</Text>
<Button variant="ghost" size="xs" leftIcon={RotateCw} onPress={onRetry}>
{t("common.actions.retry")}
</Button>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
root: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
backgroundColor: theme.colors.surfaceSidebar,
},
message: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
textAlign: "center",
},
}));
@@ -0,0 +1,113 @@
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { PrActivitySkeleton, SkeletonPulse, useSkeletonPulse } from "./activity-skeleton";
const CHECK_ROW_KEYS = [0, 1, 2].map((i) => `pr-pane-skeleton-check-${i}`);
export function PullRequestPaneSkeleton() {
const { t } = useTranslation();
const pulse = useSkeletonPulse();
return (
<View style={styles.root} testID="pr-pane-skeleton">
<View style={styles.header}>
<SkeletonPulse pulse={pulse} style={styles.title} />
<SkeletonPulse pulse={pulse} style={styles.subtitle} />
</View>
<View style={styles.toolbar}>
<SkeletonPulse pulse={pulse} style={styles.toolbarButton} />
<SkeletonPulse pulse={pulse} style={styles.toolbarButton} />
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>{t("workspace.git.pr.sections.checks")}</Text>
<View style={styles.checks}>
{CHECK_ROW_KEYS.map((key) => (
<View key={key} style={styles.checkRow}>
<SkeletonPulse pulse={pulse} style={styles.checkDot} />
<SkeletonPulse pulse={pulse} style={styles.checkName} />
</View>
))}
</View>
</View>
<View style={styles.section}>
<PrActivitySkeleton />
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
root: {
flex: 1,
minHeight: 0,
backgroundColor: theme.colors.surfaceSidebar,
},
header: {
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[4],
},
title: {
width: "75%",
height: 16,
borderRadius: theme.borderRadius.sm,
backgroundColor: theme.colors.surface2,
},
subtitle: {
width: "40%",
height: 12,
borderRadius: theme.borderRadius.sm,
backgroundColor: theme.colors.surface2,
},
toolbar: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
toolbarButton: {
width: 96,
height: 24,
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface2,
},
section: {
paddingVertical: theme.spacing[2],
},
sectionTitle: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
checks: {
gap: theme.spacing[1],
},
checkRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
minHeight: 32,
},
checkDot: {
width: 14,
height: 14,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface2,
},
checkName: {
width: "60%",
height: 12,
borderRadius: theme.borderRadius.sm,
backgroundColor: theme.colors.surface2,
},
}));
+140 -19
View File
@@ -25,10 +25,13 @@ import {
MessageSquare, MessageSquare,
MessageSquarePlus, MessageSquarePlus,
MoreHorizontal, MoreHorizontal,
RotateCw,
} from "lucide-react-native"; } from "lucide-react-native";
import type { PressableStateCallbackType } from "react-native"; import type { PressableStateCallbackType } from "react-native";
import { useTranslation } from "react-i18next";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { StatusBadge } from "@/components/ui/status-badge"; import { StatusBadge } from "@/components/ui/status-badge";
import { import {
DropdownMenu, DropdownMenu,
@@ -42,9 +45,12 @@ import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-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 { isNative } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import type { Theme } from "@/styles/theme"; import { ICON_SIZE, type Theme } from "@/styles/theme";
import { PrActivitySkeleton } from "./activity-skeleton";
import { import {
collapseActivity, collapseActivity,
expandActivity, expandActivity,
@@ -85,6 +91,8 @@ const ThemedGitPullRequestDraft = withUnistyles(GitPullRequestDraft);
const ThemedMessageSquare = withUnistyles(MessageSquare); const ThemedMessageSquare = withUnistyles(MessageSquare);
const ThemedMessageSquarePlus = withUnistyles(MessageSquarePlus); const ThemedMessageSquarePlus = withUnistyles(MessageSquarePlus);
const ThemedMoreHorizontal = withUnistyles(MoreHorizontal); const ThemedMoreHorizontal = withUnistyles(MoreHorizontal);
const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -138,6 +146,13 @@ function kebabTriggerStyle({
return [styles.kebabButton, hovered && styles.kebabButtonHovered]; return [styles.kebabButton, hovered && styles.kebabButtonHovered];
} }
function refreshButtonStyle({
hovered = false,
pressed = false,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.refreshButton, (hovered || pressed) && styles.refreshButtonHovered];
}
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) { function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
return ( return (
<ThemedMoreHorizontal <ThemedMoreHorizontal
@@ -176,13 +191,17 @@ export function PullRequestPane({
serverId, serverId,
cwd, cwd,
data, data,
activityLoading,
workspaceAttachmentScopeKey, workspaceAttachmentScopeKey,
}: { }: {
serverId: string; serverId: string;
cwd: string; cwd: string;
data: PrPaneData; data: PrPaneData;
activityLoading: boolean;
workspaceAttachmentScopeKey?: string; workspaceAttachmentScopeKey?: string;
}) { }) {
const { t } = useTranslation();
const toast = useToast();
const daemonClient = useHostRuntimeClient(serverId); const daemonClient = useHostRuntimeClient(serverId);
const canFetchGitHubCheckDetails = useSessionStore( const canFetchGitHubCheckDetails = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.githubCheckDetails === true, (state) => state.sessions[serverId]?.serverInfo?.features?.githubCheckDetails === true,
@@ -199,6 +218,24 @@ export function PullRequestPane({
void openExternalUrl(data.url); void openExternalUrl(data.url);
}, [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(() => { const handleToggleChecks = useCallback(() => {
setChecksOpen((open) => !open); setChecksOpen((open) => !open);
}, []); }, []);
@@ -395,6 +432,47 @@ export function PullRequestPane({
return ( return (
<View style={styles.root} testID="pr-pane"> <View style={styles.root} testID="pr-pane">
<ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}> <ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}>
<View 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")
}
testID="pr-pane-refresh"
style={refreshButtonStyle}
hitSlop={8}
onPress={handleRefresh}
disabled={isRefreshing}
>
<View style={styles.refreshIcon}>
{isRefreshing ? (
<ThemedLoadingSpinner
size={ICON_SIZE.sm}
uniProps={foregroundMutedColorMapping}
/>
) : (
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
)}
</View>
</Pressable>
) : null}
</View>
<Pressable onPress={handleOpenPrUrl} style={styles.header}> <Pressable onPress={handleOpenPrUrl} style={styles.header}>
{({ hovered }) => ( {({ hovered }) => (
<> <>
@@ -420,8 +498,6 @@ export function PullRequestPane({
)} )}
</Pressable> </Pressable>
<View style={styles.divider} />
<Section <Section
title="Checks" title="Checks"
open={checksOpen} open={checksOpen}
@@ -488,27 +564,30 @@ export function PullRequestPane({
size="xs" size="xs"
leftIcon={MessageSquarePlus} leftIcon={MessageSquarePlus}
onPress={handleAddAllToChat} onPress={handleAddAllToChat}
disabled={activityLoading}
> >
Add all to chat Add all to chat
</Button> </Button>
</View> </View>
) : null} ) : null}
{visibleEntries.length === 0 ? ( {activityLoading ? <PrActivitySkeleton /> : null}
{!activityLoading && visibleEntries.length === 0 ? (
<Text style={styles.emptyText}>No activity yet</Text> <Text style={styles.emptyText}>No activity yet</Text>
) : ( ) : null}
visibleEntries.map(({ entry, collapsed }) => ( {!activityLoading
<TimelineEntryCard ? visibleEntries.map(({ entry, collapsed }) => (
key={entry.id} <TimelineEntryCard
entry={entry} key={entry.id}
collapsed={collapsed} entry={entry}
collapsedEntryIds={collapsedEntryIds} collapsed={collapsed}
attachEnabled={attachEnabled} collapsedEntryIds={collapsedEntryIds}
onAddToChat={handleAddActivityToChat} attachEnabled={attachEnabled}
onAddThreadToChat={handleAddThreadToChat} onAddToChat={handleAddActivityToChat}
onToggleCollapsed={handleToggleEntryCollapsed} onAddThreadToChat={handleAddThreadToChat}
/> onToggleCollapsed={handleToggleEntryCollapsed}
)) />
)} ))
: null}
</Section> </Section>
</ScrollView> </ScrollView>
</View> </View>
@@ -1236,6 +1315,48 @@ const styles = StyleSheet.create((theme) => ({
height: 1, height: 1,
backgroundColor: theme.colors.border, backgroundColor: theme.colors.border,
}, },
toolbar: {
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
paddingTop: theme.spacing[2],
paddingRight: theme.spacing[3],
paddingBottom: theme.spacing[2],
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,
},
refreshButton: {
marginLeft: "auto",
width: 22,
height: 22,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
},
refreshButtonHovered: {
backgroundColor: theme.colors.surface2,
},
refreshIcon: {
width: ICON_SIZE.md,
height: ICON_SIZE.md,
alignItems: "center",
justifyContent: "center",
},
sectionHeader: { sectionHeader: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
@@ -435,6 +435,37 @@ describe("selectPrPaneState", () => {
expect(state.isLoading).toBe(false); expect(state.isLoading).toBe(false);
}); });
it("reports activityLoading while the timeline is pending its first response", () => {
const state = selectPrPaneState({
...baseSelectInput,
status: prStatus(),
shouldFetchTimeline: true,
timelineIsLoading: true,
});
expect(state.activityLoading).toBe(true);
});
it("does not report activityLoading when no timeline fetch was scheduled", () => {
const state = selectPrPaneState({
...baseSelectInput,
status: prStatus(),
shouldFetchTimeline: false,
timelineIsLoading: true,
});
expect(state.activityLoading).toBe(false);
});
it("does not report activityLoading once the timeline payload resolves", () => {
const state = selectPrPaneState({
...baseSelectInput,
status: prStatus(),
shouldFetchTimeline: true,
timelineIsLoading: false,
timelinePayload: timelinePayload(),
});
expect(state.activityLoading).toBe(false);
});
it("reports refreshing during background revalidation of either query", () => { it("reports refreshing during background revalidation of either query", () => {
expect( expect(
selectPrPaneState({ selectPrPaneState({
@@ -27,6 +27,7 @@ export interface UsePrPaneDataResult {
data: PrPaneData | null; data: PrPaneData | null;
prNumber: number | null; prNumber: number | null;
isLoading: boolean; isLoading: boolean;
activityLoading: boolean;
isRefreshing: boolean; isRefreshing: boolean;
error: Error | null; error: Error | null;
githubFeaturesEnabled: boolean; githubFeaturesEnabled: boolean;
@@ -167,13 +168,14 @@ export function selectPrPaneState(input: SelectPrPaneStateInput): UsePrPaneDataR
: mapPrPaneData(input.status, input.timelinePayload); : mapPrPaneData(input.status, input.timelinePayload);
const statusRefreshing = input.statusIsFetching && !input.statusIsLoading; const statusRefreshing = input.statusIsFetching && !input.statusIsLoading;
const timelineRefreshing = input.timelineIsFetching && !input.timelineIsLoading; const timelineRefreshing = input.timelineIsFetching && !input.timelineIsLoading;
const timelinePending =
input.shouldFetchTimeline && input.timelineIsLoading && input.timelinePayload === undefined;
return { return {
data, data,
prNumber: identity.prNumber, prNumber: identity.prNumber,
isLoading: isLoading: input.statusIsLoading || timelinePending,
input.statusIsLoading || activityLoading: timelinePending,
(input.shouldFetchTimeline && input.timelineIsLoading && input.timelinePayload === undefined),
isRefreshing: statusRefreshing || timelineRefreshing, isRefreshing: statusRefreshing || timelineRefreshing,
error: firstNonSuppressedError({ error: firstNonSuppressedError({
statusPayloadError: input.statusPayloadError, statusPayloadError: input.statusPayloadError,
+63 -57
View File
@@ -7,6 +7,7 @@ import { type CheckoutPrStatusPayload, useCheckoutPrStatusQuery } from "@/git/us
import { import {
buildGitActions, buildGitActions,
narrowPullRequestState, narrowPullRequestState,
type BuildGitActionsInput,
type GitAction, type GitAction,
type GitActions, type GitActions,
} from "@/git/policy"; } from "@/git/policy";
@@ -568,8 +569,8 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
}, [prStatus?.url, handleCreatePr]); }, [prStatus?.url, handleCreatePr]);
// Build actions // Build actions
const gitActions: GitActions = useMemo(() => { const gitActionsInput = useMemo<BuildGitActionsInput>(
const actions = buildGitActions({ () => ({
isGit, isGit,
githubFeaturesEnabled, githubFeaturesEnabled,
githubAutoMergeActionsEnabled, githubAutoMergeActionsEnabled,
@@ -684,61 +685,66 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
handler: handleArchiveWorktree, handler: handleArchiveWorktree,
}, },
}, },
}); }),
return translateGitActions(actions, { baseRefLabel, hasPullRequest, t }); [
}, [ isGit,
t, hasRemote,
isGit, hasPullRequest,
hasRemote, prStatus?.url,
hasPullRequest, prStatus?.state,
prStatus?.url, prStatus?.isDraft,
prStatus?.state, prStatus?.isMerged,
prStatus?.isDraft, prStatus?.mergeable,
prStatus?.isMerged, prStatus?.github,
prStatus?.mergeable, aheadCount,
prStatus?.github, behindBaseCount,
aheadCount, isPaseoOwnedWorktree,
behindBaseCount, isOnBaseBranch,
isPaseoOwnedWorktree, githubFeaturesEnabled,
isOnBaseBranch, githubAutoMergeActionsEnabled,
githubFeaturesEnabled, hasUncommittedChanges,
githubAutoMergeActionsEnabled, aheadOfOrigin,
hasUncommittedChanges, behindOfOrigin,
aheadOfOrigin, shipDefault,
behindOfOrigin, baseRefLabel,
shipDefault, shouldPromoteArchive,
baseRefLabel, actionsDisabled,
shouldPromoteArchive, commitStatus,
actionsDisabled, pullStatus,
commitStatus, pushStatus,
pullStatus, pullAndPushStatus,
pushStatus, prCreateStatus,
pullAndPushStatus, mergePrStatuses.squash,
prCreateStatus, mergePrStatuses.merge,
mergePrStatuses.squash, mergePrStatuses.rebase,
mergePrStatuses.merge, enablePrAutoMergeStatuses.squash,
mergePrStatuses.rebase, enablePrAutoMergeStatuses.merge,
enablePrAutoMergeStatuses.squash, enablePrAutoMergeStatuses.rebase,
enablePrAutoMergeStatuses.merge, disablePrAutoMergeStatus,
enablePrAutoMergeStatuses.rebase, mergeStatus,
disablePrAutoMergeStatus, mergeFromBaseStatus,
mergeStatus, archiveStatus,
mergeFromBaseStatus, handleCommit,
archiveStatus, handlePull,
handleCommit, handlePush,
handlePull, handlePullAndPush,
handlePush, handlePrAction,
handlePullAndPush, handleMergePr,
handlePrAction, handleEnablePrAutoMerge,
handleMergePr, handleDisablePrAutoMerge,
handleEnablePrAutoMerge, handleMergeBranch,
handleDisablePrAutoMerge, handleMergeFromBase,
handleMergeBranch, handleArchiveWorktree,
handleMergeFromBase, icons,
handleArchiveWorktree, baseRef,
icons, ],
baseRef, );
]);
const gitActions: GitActions = useMemo(
() =>
translateGitActions(buildGitActions(gitActionsInput), { baseRefLabel, hasPullRequest, t }),
[gitActionsInput, baseRefLabel, hasPullRequest, t],
);
return { gitActions, branchLabel, isGit }; return { gitActions, branchLabel, isGit };
} }
+1
View File
@@ -243,6 +243,7 @@ describe("translation resources", () => {
expect(en.workspace.git.actions.commit.label).toBe("Commit"); expect(en.workspace.git.actions.commit.label).toBe("Commit");
expect(en.workspace.git.diff.binaryFile).toBe("Binary file"); expect(en.workspace.git.diff.binaryFile).toBe("Binary file");
expect(en.workspace.git.pr.sections.checks).toBe("Checks"); expect(en.workspace.git.pr.sections.checks).toBe("Checks");
expect(en.workspace.git.pr.actions.viewPullRequest).toBe("View");
expect(en.review.comment.placeholder).toBe("Leave a comment"); expect(en.review.comment.placeholder).toBe("Leave a comment");
}); });
+3
View File
@@ -717,6 +717,9 @@ export const ar: TranslationResources = {
failedOpen: "فشل في فتح مساحة العمل", failedOpen: "فشل في فتح مساحة العمل",
}, },
pr: { pr: {
actions: {
viewPullRequest: "عرض",
},
sections: { sections: {
checks: "الشيكات", checks: "الشيكات",
reviews: "التعليقات", reviews: "التعليقات",
+3
View File
@@ -724,6 +724,9 @@ export const en = {
failedOpen: "Failed to open workspace", failedOpen: "Failed to open workspace",
}, },
pr: { pr: {
actions: {
viewPullRequest: "View",
},
sections: { sections: {
checks: "Checks", checks: "Checks",
reviews: "Reviews", reviews: "Reviews",
+3
View File
@@ -744,6 +744,9 @@ export const es: TranslationResources = {
failedOpen: "No se pudo abrir el espacio de trabajo", failedOpen: "No se pudo abrir el espacio de trabajo",
}, },
pr: { pr: {
actions: {
viewPullRequest: "Ver",
},
sections: { sections: {
checks: "cheques", checks: "cheques",
reviews: "Reseñas", reviews: "Reseñas",
+3
View File
@@ -743,6 +743,9 @@ export const fr: TranslationResources = {
failedOpen: "Échec de l'ouverture de l'espace de travail", failedOpen: "Échec de l'ouverture de l'espace de travail",
}, },
pr: { pr: {
actions: {
viewPullRequest: "Voir",
},
sections: { sections: {
checks: "Chèques", checks: "Chèques",
reviews: "Avis", reviews: "Avis",
+3
View File
@@ -736,6 +736,9 @@ export const ru: TranslationResources = {
failedOpen: "Не удалось открыть рабочую область", failedOpen: "Не удалось открыть рабочую область",
}, },
pr: { pr: {
actions: {
viewPullRequest: "Открыть",
},
sections: { sections: {
checks: "Чеки", checks: "Чеки",
reviews: "Отзывы", reviews: "Отзывы",
+3
View File
@@ -711,6 +711,9 @@ export const zhCN: TranslationResources = {
failedOpen: "打开 workspace 失败", failedOpen: "打开 workspace 失败",
}, },
pr: { pr: {
actions: {
viewPullRequest: "查看",
},
sections: { sections: {
checks: "Checks", checks: "Checks",
reviews: "Reviews", reviews: "Reviews",