Add copy-to-clipboard hover action for branch and path in workspace hover card
This commit is contained in:
@@ -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];
|
||||||
|
|||||||
@@ -337,6 +337,9 @@ export const ar: TranslationResources = {
|
|||||||
},
|
},
|
||||||
hoverCard: {
|
hoverCard: {
|
||||||
scriptsAccessibility: "البرامج النصية Workspace",
|
scriptsAccessibility: "البرامج النصية Workspace",
|
||||||
|
copyPath: "نسخ المسار",
|
||||||
|
copyBranchName: "نسخ اسم الفرع",
|
||||||
|
copied: "تم النسخ",
|
||||||
},
|
},
|
||||||
fileExplorer: {
|
fileExplorer: {
|
||||||
sort: {
|
sort: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -339,6 +339,9 @@ export const ru: TranslationResources = {
|
|||||||
},
|
},
|
||||||
hoverCard: {
|
hoverCard: {
|
||||||
scriptsAccessibility: "Скрипты Workspace",
|
scriptsAccessibility: "Скрипты Workspace",
|
||||||
|
copyPath: "Копировать путь",
|
||||||
|
copyBranchName: "Копировать имя ветки",
|
||||||
|
copied: "Скопировано",
|
||||||
},
|
},
|
||||||
fileExplorer: {
|
fileExplorer: {
|
||||||
sort: {
|
sort: {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user