Add copy-to-clipboard hover action for branch and path in workspace hover card

This commit is contained in:
Mohamed Boudra
2026-06-16 17:20:11 +07:00
parent be3a1ea6c3
commit 10664487fb
8 changed files with 113 additions and 17 deletions
@@ -13,9 +13,11 @@ import { useTranslation } from "react-i18next";
import { FadeIn, FadeOut } from "react-native-reanimated"; import { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { import {
Check,
CircleCheck, CircleCheck,
CircleDot, CircleDot,
CircleX, CircleX,
Copy,
ExternalLink, ExternalLink,
Folder, Folder,
GitBranch, GitBranch,
@@ -24,12 +26,14 @@ import { GitHubIcon } from "@/components/icons/github-icon";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import { DiffStat } from "@/components/diff-stat"; import { DiffStat } from "@/components/diff-stat";
import { Pressable } from "react-native"; import { Pressable } from "react-native";
import type { GestureResponderEvent } from "react-native";
import { Portal } from "@gorhom/portal"; import { Portal } from "@gorhom/portal";
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet"; import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { PrHint } from "@/git/use-pr-status-query"; import type { PrHint } from "@/git/use-pr-status-query";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { shortenPath } from "@/utils/shorten-path"; import { shortenPath } from "@/utils/shorten-path";
import { copyToClipboard } from "@/utils/copy-to-clipboard";
import { PrBadge } from "@/components/sidebar-workspace-list"; import { PrBadge } from "@/components/sidebar-workspace-list";
import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone"; import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
@@ -276,29 +280,27 @@ function WorkspaceHoverCardContent({
frameStyle={frameStyle} frameStyle={frameStyle}
> >
<View style={styles.cardHeader}> <View style={styles.cardHeader}>
<Text style={styles.cardTitle} numberOfLines={1} testID="hover-card-workspace-name"> <Text style={styles.cardTitle} testID="hover-card-workspace-name">
{workspace.name} {workspace.name}
</Text> </Text>
</View> </View>
{workspace.currentBranch ? ( {workspace.currentBranch ? (
<View style={styles.cardInfoRow}> <CopyableInfoRow
<ThemedGitBranch size={12} uniProps={foregroundMutedColorMapping} /> icon={ThemedGitBranch}
<Text value={workspace.currentBranch}
style={styles.cardInfoText} copyValue={workspace.currentBranch}
numberOfLines={1} copyLabel={t("workspace.hoverCard.copyBranchName")}
testID="hover-card-workspace-branch" testID="hover-card-workspace-branch"
> />
{workspace.currentBranch}
</Text>
</View>
) : null} ) : null}
{cwdDisplay ? ( {cwdDisplay ? (
<View style={styles.cardInfoRow}> <CopyableInfoRow
<ThemedFolder size={12} uniProps={foregroundMutedColorMapping} /> icon={ThemedFolder}
<Text style={styles.cardInfoText} numberOfLines={1} testID="hover-card-workspace-cwd"> value={cwdDisplay}
{cwdDisplay} copyValue={workspace.workspaceDirectory ?? ""}
</Text> copyLabel={t("workspace.hoverCard.copyPath")}
</View> testID="hover-card-workspace-cwd"
/>
) : null} ) : null}
{prHint || workspace.diffStat ? ( {prHint || workspace.diffStat ? (
<View style={styles.cardMetaRow}> <View style={styles.cardMetaRow}>
@@ -330,6 +332,8 @@ const ThemedGitHubIcon = withUnistyles(GitHubIcon);
const ThemedCircleCheck = withUnistyles(CircleCheck); const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedCircleDot = withUnistyles(CircleDot); const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedCircleX = withUnistyles(CircleX); const ThemedCircleX = withUnistyles(CircleX);
const ThemedCopy = withUnistyles(Copy);
const ThemedCheck = withUnistyles(Check);
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 });
@@ -337,6 +341,70 @@ const successColorMapping = (theme: Theme) => ({ color: theme.colors.statusSucce
const warningColorMapping = (theme: Theme) => ({ color: theme.colors.statusWarning }); const warningColorMapping = (theme: Theme) => ({ color: theme.colors.statusWarning });
const dangerColorMapping = (theme: Theme) => ({ color: theme.colors.statusDanger }); const dangerColorMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
function CopyableInfoRow({
icon: Icon,
value,
copyValue,
copyLabel,
testID,
}: {
icon: React.ComponentType<React.ComponentProps<typeof ThemedGitBranch>>;
value: string;
copyValue: string;
copyLabel: string;
testID: string;
}) {
const [isHovered, setIsHovered] = useState(false);
const [copied, setCopied] = useState(false);
const handlePressIn = useCallback((event: GestureResponderEvent) => {
event.stopPropagation();
}, []);
const handlePress = useCallback(() => {
void copyToClipboard(copyValue);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
}, [copyValue]);
const handleHoverIn = useCallback(() => setIsHovered(true), []);
const handleHoverOut = useCallback(() => setIsHovered(false), []);
let iconUniProps = foregroundMutedColorMapping;
if (copied || isHovered) {
iconUniProps = foregroundColorMapping;
}
const textStyle = copied || isHovered ? cardInfoTextHoveredCombined : styles.cardInfoText;
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={copyLabel}
style={styles.cardInfoRow}
hitSlop={4}
onPressIn={handlePressIn}
onPress={handlePress}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
>
{(() => {
if (copied) {
return <ThemedCheck size={12} uniProps={iconUniProps} />;
}
if (isHovered) {
return <ThemedCopy size={12} uniProps={iconUniProps} />;
}
return <Icon size={12} uniProps={iconUniProps} />;
})()}
<Text style={textStyle} numberOfLines={1} testID={testID}>
{value}
</Text>
</Pressable>
);
}
function getChecksSummaryCounts(checks: NonNullable<PrHint["checks"]>) { function getChecksSummaryCounts(checks: NonNullable<PrHint["checks"]>) {
return checks.reduce( return checks.reduce(
(counts, check) => { (counts, check) => {
@@ -501,6 +569,9 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
}, },
cardInfoTextHovered: {
color: theme.colors.foreground,
},
separator: { separator: {
height: 1, height: 1,
backgroundColor: theme.colors.border, backgroundColor: theme.colors.border,
@@ -557,3 +628,5 @@ const checksSummaryLabelHoveredCombined = [
styles.checksSummaryLabel, styles.checksSummaryLabel,
styles.checksSummaryLabelHovered, styles.checksSummaryLabelHovered,
]; ];
const cardInfoTextHoveredCombined = [styles.cardInfoText, styles.cardInfoTextHovered];
+3
View File
@@ -337,6 +337,9 @@ export const ar: TranslationResources = {
}, },
hoverCard: { hoverCard: {
scriptsAccessibility: "البرامج النصية Workspace", scriptsAccessibility: "البرامج النصية Workspace",
copyPath: "نسخ المسار",
copyBranchName: "نسخ اسم الفرع",
copied: "تم النسخ",
}, },
fileExplorer: { fileExplorer: {
sort: { sort: {
+3
View File
@@ -336,6 +336,9 @@ export const en = {
}, },
hoverCard: { hoverCard: {
scriptsAccessibility: "Workspace scripts", scriptsAccessibility: "Workspace scripts",
copyPath: "Copy path",
copyBranchName: "Copy branch name",
copied: "Copied",
}, },
fileExplorer: { fileExplorer: {
sort: { sort: {
+3
View File
@@ -340,6 +340,9 @@ export const es: TranslationResources = {
}, },
hoverCard: { hoverCard: {
scriptsAccessibility: "GuionesWorkspace", scriptsAccessibility: "GuionesWorkspace",
copyPath: "Copiar ruta",
copyBranchName: "Copiar nombre de rama",
copied: "Copiado",
}, },
fileExplorer: { fileExplorer: {
sort: { sort: {
+3
View File
@@ -340,6 +340,9 @@ export const fr: TranslationResources = {
}, },
hoverCard: { hoverCard: {
scriptsAccessibility: "ScriptsWorkspace", scriptsAccessibility: "ScriptsWorkspace",
copyPath: "Copier le chemin",
copyBranchName: "Copier le nom de la branche",
copied: "Copié",
}, },
fileExplorer: { fileExplorer: {
sort: { sort: {
+3
View File
@@ -339,6 +339,9 @@ export const ru: TranslationResources = {
}, },
hoverCard: { hoverCard: {
scriptsAccessibility: "Скрипты Workspace", scriptsAccessibility: "Скрипты Workspace",
copyPath: "Копировать путь",
copyBranchName: "Копировать имя ветки",
copied: "Скопировано",
}, },
fileExplorer: { fileExplorer: {
sort: { sort: {
+3
View File
@@ -337,6 +337,9 @@ export const zhCN: TranslationResources = {
}, },
hoverCard: { hoverCard: {
scriptsAccessibility: "Workspace scripts", scriptsAccessibility: "Workspace scripts",
copyPath: "复制路径",
copyBranchName: "复制分支名称",
copied: "已复制",
}, },
fileExplorer: { fileExplorer: {
sort: { sort: {
@@ -0,0 +1,5 @@
import * as Clipboard from "expo-clipboard";
export async function copyToClipboard(text: string): Promise<void> {
await Clipboard.setStringAsync(text);
}