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

View File

@@ -13,9 +13,11 @@ import { useTranslation } from "react-i18next";
import { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
Check,
CircleCheck,
CircleDot,
CircleX,
Copy,
ExternalLink,
Folder,
GitBranch,
@@ -24,12 +26,14 @@ import { GitHubIcon } from "@/components/icons/github-icon";
import type { Theme } from "@/styles/theme";
import { DiffStat } from "@/components/diff-stat";
import { Pressable } from "react-native";
import type { GestureResponderEvent } from "react-native";
import { Portal } from "@gorhom/portal";
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { PrHint } from "@/git/use-pr-status-query";
import { openExternalUrl } from "@/utils/open-external-url";
import { shortenPath } from "@/utils/shorten-path";
import { copyToClipboard } from "@/utils/copy-to-clipboard";
import { PrBadge } from "@/components/sidebar-workspace-list";
import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -276,29 +280,27 @@ function WorkspaceHoverCardContent({
frameStyle={frameStyle}
>
<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}
</Text>
</View>
{workspace.currentBranch ? (
<View style={styles.cardInfoRow}>
<ThemedGitBranch size={12} uniProps={foregroundMutedColorMapping} />
<Text
style={styles.cardInfoText}
numberOfLines={1}
testID="hover-card-workspace-branch"
>
{workspace.currentBranch}
</Text>
</View>
<CopyableInfoRow
icon={ThemedGitBranch}
value={workspace.currentBranch}
copyValue={workspace.currentBranch}
copyLabel={t("workspace.hoverCard.copyBranchName")}
testID="hover-card-workspace-branch"
/>
) : null}
{cwdDisplay ? (
<View style={styles.cardInfoRow}>
<ThemedFolder size={12} uniProps={foregroundMutedColorMapping} />
<Text style={styles.cardInfoText} numberOfLines={1} testID="hover-card-workspace-cwd">
{cwdDisplay}
</Text>
</View>
<CopyableInfoRow
icon={ThemedFolder}
value={cwdDisplay}
copyValue={workspace.workspaceDirectory ?? ""}
copyLabel={t("workspace.hoverCard.copyPath")}
testID="hover-card-workspace-cwd"
/>
) : null}
{prHint || workspace.diffStat ? (
<View style={styles.cardMetaRow}>
@@ -330,6 +332,8 @@ const ThemedGitHubIcon = withUnistyles(GitHubIcon);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedCircleX = withUnistyles(CircleX);
const ThemedCopy = withUnistyles(Copy);
const ThemedCheck = withUnistyles(Check);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
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 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"]>) {
return checks.reduce(
(counts, check) => {
@@ -501,6 +569,9 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
},
cardInfoTextHovered: {
color: theme.colors.foreground,
},
separator: {
height: 1,
backgroundColor: theme.colors.border,
@@ -557,3 +628,5 @@ const checksSummaryLabelHoveredCombined = [
styles.checksSummaryLabel,
styles.checksSummaryLabelHovered,
];
const cardInfoTextHoveredCombined = [styles.cardInfoText, styles.cardInfoTextHovered];

View File

@@ -337,6 +337,9 @@ export const ar: TranslationResources = {
},
hoverCard: {
scriptsAccessibility: "البرامج النصية Workspace",
copyPath: "نسخ المسار",
copyBranchName: "نسخ اسم الفرع",
copied: "تم النسخ",
},
fileExplorer: {
sort: {

View File

@@ -336,6 +336,9 @@ export const en = {
},
hoverCard: {
scriptsAccessibility: "Workspace scripts",
copyPath: "Copy path",
copyBranchName: "Copy branch name",
copied: "Copied",
},
fileExplorer: {
sort: {

View File

@@ -340,6 +340,9 @@ export const es: TranslationResources = {
},
hoverCard: {
scriptsAccessibility: "GuionesWorkspace",
copyPath: "Copiar ruta",
copyBranchName: "Copiar nombre de rama",
copied: "Copiado",
},
fileExplorer: {
sort: {

View File

@@ -340,6 +340,9 @@ export const fr: TranslationResources = {
},
hoverCard: {
scriptsAccessibility: "ScriptsWorkspace",
copyPath: "Copier le chemin",
copyBranchName: "Copier le nom de la branche",
copied: "Copié",
},
fileExplorer: {
sort: {

View File

@@ -339,6 +339,9 @@ export const ru: TranslationResources = {
},
hoverCard: {
scriptsAccessibility: "Скрипты Workspace",
copyPath: "Копировать путь",
copyBranchName: "Копировать имя ветки",
copied: "Скопировано",
},
fileExplorer: {
sort: {

View File

@@ -337,6 +337,9 @@ export const zhCN: TranslationResources = {
},
hoverCard: {
scriptsAccessibility: "Workspace scripts",
copyPath: "复制路径",
copyBranchName: "复制分支名称",
copied: "已复制",
},
fileExplorer: {
sort: {

View File

@@ -0,0 +1,5 @@
import * as Clipboard from "expo-clipboard";
export async function copyToClipboard(text: string): Promise<void> {
await Clipboard.setStringAsync(text);
}