mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 21:23:04 +00:00
* fix(usage): keep details hoverable and restore compact sidebar sheets Share hover-card mechanics with workspace details so context usage can stay open while users press Refresh. Honor Retry-After for rate-limited usage requests, and let both sidebar entry points share the compact sheet. * fix(usage): restore settings cog with a fitted popover and compact sheet Keep shared Usage preferences in the title controls on every surface. Settings rows use the menu surface directly, avoiding the trailing margin and duplicate card border from the page layout. * fix(app): show account usage before the first agent turn Keep the context meter available for existing agents without context readings so account usage can be opened before a turn. Show an empty ring and an explicit no-data state until readings arrive. * fix(usage): preserve keyboard access and hover card positioning Context details must remain reachable by keyboard after replacing the tooltip. Keep focus within the card, restore side flipping, and dismiss detached cards when the viewport moves. Prune expired cooldowns even after credential rotation. * chore: remove QA screenshots from the source tree * fix(app): let hover cards claim Escape before agent shortcuts Register hover surfaces with the shared overlay keyboard owner without moving or trapping focus on pointer hover. Component-local Escape handling ran after the global interrupt shortcut and cancelled the underlying turn. * fix(app): reopen focused hover cards with button keys Keep focused triggers registered with overlay arbitration after dismissal so activation keys are handled before composer shortcuts. * fix(app): release overlay ownership when hover cards close Closed triggers handle activation locally, so a dismissed card does not block pane Find. Open cards retain shared Escape arbitration to protect running turns. * test(app): pin Linux keyboard identity for composer Find * test(app): synchronize context usage hover journeys Return focus to the composer before asserting pointer dismissal, wait for the card to reach the viewport, and park the pointer before reloading so it cannot consume the next scripted usage response early.
453 lines
12 KiB
TypeScript
453 lines
12 KiB
TypeScript
import {
|
|
useCallback,
|
|
useState,
|
|
type PropsWithChildren,
|
|
type ReactElement,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { Text, View } from "react-native";
|
|
import { useTranslation } from "react-i18next";
|
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
|
import {
|
|
Check,
|
|
Copy,
|
|
ExternalLink,
|
|
FileDiff,
|
|
Folder,
|
|
GitBranch,
|
|
Server,
|
|
} from "lucide-react-native";
|
|
import { getForgePresentation, normalizeForge } from "@/git/forge";
|
|
import { ForgeBrandIcon } from "@/git/forge-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 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 { copyToClipboard } from "@/utils/copy-to-clipboard";
|
|
import { PrBadge } from "@/components/sidebar-workspace-list";
|
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
|
import { useHosts } from "@/runtime/host-runtime";
|
|
import {
|
|
COUNTED_CHECK_PRESENTATIONS,
|
|
countCheckPresentations,
|
|
type CountedCheckPresentation,
|
|
} from "@/git/check-presentation";
|
|
import { formatCheckPresentationCountsLabel } from "@/git/check-presentation-copy";
|
|
import { CheckPresentationIcon, getCheckPresentationTone } from "@/git/check-presentation.view";
|
|
import { buildForgeChecksUrl } from "@/git/forge-url";
|
|
|
|
const HOVER_CARD_WIDTH = 260;
|
|
|
|
interface WorkspaceHoverCardProps {
|
|
workspace: SidebarWorkspaceEntry;
|
|
prHint: PrHint | null;
|
|
isDragging: boolean;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
export function WorkspaceHoverCard({
|
|
workspace,
|
|
prHint,
|
|
isDragging,
|
|
disabled = false,
|
|
children,
|
|
}: PropsWithChildren<WorkspaceHoverCardProps>): ReactNode {
|
|
const { t } = useTranslation();
|
|
const isCompact = useIsCompactFormFactor();
|
|
|
|
if (isCompact) {
|
|
return children;
|
|
}
|
|
|
|
return (
|
|
<HoverCard disabled={isDragging || disabled}>
|
|
<HoverCardTrigger>{children}</HoverCardTrigger>
|
|
<HoverCardContent
|
|
placement="right"
|
|
role="menu"
|
|
accessibilityLabel={t("workspace.hoverCard.scriptsAccessibility")}
|
|
testID="workspace-hover-card"
|
|
style={styles.card}
|
|
>
|
|
<WorkspaceHoverCardContent workspace={workspace} prHint={prHint} />
|
|
</HoverCardContent>
|
|
</HoverCard>
|
|
);
|
|
}
|
|
|
|
function WorkspaceHoverCardContent({
|
|
workspace,
|
|
prHint,
|
|
}: {
|
|
workspace: SidebarWorkspaceEntry;
|
|
prHint: PrHint | null;
|
|
}): ReactElement {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<>
|
|
<View style={styles.cardHeader}>
|
|
<Text style={styles.cardTitle} testID="hover-card-workspace-name">
|
|
{workspace.name}
|
|
</Text>
|
|
</View>
|
|
{prHint ? <PrBadge hint={prHint} style={styles.cardInfoRow} /> : null}
|
|
{workspace.diffStat ? (
|
|
<View style={styles.cardInfoRow}>
|
|
<ThemedFileDiff size={12} uniProps={foregroundMutedColorMapping} />
|
|
<DiffStat
|
|
additions={workspace.diffStat.additions}
|
|
deletions={workspace.diffStat.deletions}
|
|
/>
|
|
</View>
|
|
) : null}
|
|
<HostRow serverId={workspace.serverId} />
|
|
{workspace.currentBranch ? (
|
|
<CopyableInfoRow
|
|
icon={ThemedGitBranch}
|
|
value={workspace.currentBranch}
|
|
copyValue={workspace.currentBranch}
|
|
copyLabel={t("workspace.hoverCard.copyBranchName")}
|
|
testID="hover-card-workspace-branch"
|
|
/>
|
|
) : null}
|
|
{workspace.workspaceDirectoryLabel ? (
|
|
<CopyableInfoRow
|
|
icon={ThemedFolder}
|
|
value={workspace.workspaceDirectoryLabel}
|
|
copyValue={workspace.workspaceDirectory}
|
|
copyLabel={t("workspace.hoverCard.copyPath")}
|
|
testID="hover-card-workspace-cwd"
|
|
/>
|
|
) : null}
|
|
{prHint?.checks && prHint.checks.length > 0 ? (
|
|
<>
|
|
<View style={styles.separator} />
|
|
<ChecksSummaryPressable checks={prHint.checks} url={prHint.url} forge={prHint.forge} />
|
|
</>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|
|
|
|
const ThemedGitBranch = withUnistyles(GitBranch);
|
|
const ThemedFolder = withUnistyles(Folder);
|
|
const ThemedServer = withUnistyles(Server);
|
|
const ThemedFileDiff = withUnistyles(FileDiff);
|
|
|
|
type CardInfoIcon = React.ComponentType<React.ComponentProps<typeof ThemedGitBranch>>;
|
|
|
|
function HostRow({ serverId }: { serverId: string }): ReactElement | null {
|
|
const hosts = useHosts();
|
|
const host = hosts.find((h) => h.serverId === serverId);
|
|
const label = host?.label?.trim() || serverId;
|
|
|
|
return <InfoRow icon={ThemedServer} value={label} testID="hover-card-workspace-host" />;
|
|
}
|
|
|
|
const ThemedExternalLink = withUnistyles(ExternalLink);
|
|
const ThemedCopy = withUnistyles(Copy);
|
|
const ThemedCheck = withUnistyles(Check);
|
|
|
|
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
|
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
|
|
|
function InfoRow({
|
|
icon: Icon,
|
|
value,
|
|
testID,
|
|
}: {
|
|
icon: CardInfoIcon;
|
|
value: string;
|
|
testID: string;
|
|
}) {
|
|
return (
|
|
<View style={styles.cardInfoRow}>
|
|
<Icon size={12} uniProps={foregroundMutedColorMapping} />
|
|
<Text style={styles.cardInfoText} numberOfLines={1} testID={testID}>
|
|
{value}
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function renderChecksSummaryForgeIcon(icon: string, iconUniProps: typeof foregroundColorMapping) {
|
|
return <ForgeBrandIcon iconKind={icon} size={12} uniProps={iconUniProps} />;
|
|
}
|
|
|
|
function CopyableInfoRow({
|
|
icon: Icon,
|
|
value,
|
|
copyValue,
|
|
copyLabel,
|
|
testID,
|
|
}: {
|
|
icon: CardInfoIcon;
|
|
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 ? [styles.cardInfoText, styles.cardInfoTextHovered] : 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 ChecksSummaryPill({
|
|
count,
|
|
presentation,
|
|
}: {
|
|
count: number;
|
|
presentation: CountedCheckPresentation;
|
|
}) {
|
|
if (count === 0) return null;
|
|
return (
|
|
<View style={styles.checksSummaryPill}>
|
|
<CheckPresentationIcon presentation={presentation} size={12} />
|
|
<Text style={checksSummaryTextStyle(presentation)}>{count}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function checksSummaryTextStyle(presentation: CountedCheckPresentation) {
|
|
const tone = getCheckPresentationTone(presentation);
|
|
if (tone === "success") return styles.checksStatusTextPassed;
|
|
if (tone === "danger") return styles.checksStatusTextFailed;
|
|
if (tone === "warning") return styles.checksStatusTextPending;
|
|
return styles.checksStatusTextMuted;
|
|
}
|
|
|
|
function ChecksSummaryContent({
|
|
checks,
|
|
forge,
|
|
hovered,
|
|
}: {
|
|
checks: NonNullable<PrHint["checks"]>;
|
|
forge: PrHint["forge"];
|
|
hovered: boolean;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const counts = countCheckPresentations(checks);
|
|
|
|
const labelStyle = hovered
|
|
? [styles.checksSummaryLabel, styles.checksSummaryLabelHovered]
|
|
: styles.checksSummaryLabel;
|
|
const iconUniProps = hovered ? foregroundColorMapping : foregroundMutedColorMapping;
|
|
const icon = getForgePresentation(normalizeForge(forge)).icon;
|
|
|
|
return (
|
|
<>
|
|
{hovered ? (
|
|
<ThemedExternalLink size={12} uniProps={iconUniProps} />
|
|
) : (
|
|
renderChecksSummaryForgeIcon(icon, iconUniProps)
|
|
)}
|
|
<Text style={labelStyle}>{t("workspace.git.pr.sections.checks")}</Text>
|
|
<View style={styles.checksSummaryCounts}>
|
|
{COUNTED_CHECK_PRESENTATIONS.map((presentation) => (
|
|
<ChecksSummaryPill
|
|
key={presentation}
|
|
count={counts[presentation]}
|
|
presentation={presentation}
|
|
/>
|
|
))}
|
|
</View>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ChecksSummaryPressable({
|
|
checks,
|
|
forge,
|
|
url,
|
|
}: {
|
|
checks: NonNullable<PrHint["checks"]>;
|
|
forge: PrHint["forge"];
|
|
url: string;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const counts = countCheckPresentations(checks);
|
|
const accessibilityLabel = formatCheckPresentationCountsLabel(
|
|
counts,
|
|
t("workspace.git.pr.sections.checks"),
|
|
t,
|
|
);
|
|
const handlePress = useCallback(() => {
|
|
void openExternalUrl(buildForgeChecksUrl(forge, url) ?? url);
|
|
}, [forge, url]);
|
|
|
|
const renderChildren = useCallback(
|
|
({ hovered }: { pressed: boolean; hovered?: boolean }) => (
|
|
<ChecksSummaryContent checks={checks} forge={forge} hovered={Boolean(hovered)} />
|
|
),
|
|
[checks, forge],
|
|
);
|
|
|
|
return (
|
|
<Pressable
|
|
accessibilityLabel={accessibilityLabel}
|
|
accessibilityRole="link"
|
|
style={checksSummaryPressableStyle}
|
|
onPress={handlePress}
|
|
>
|
|
{renderChildren}
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
function checksSummaryPressableStyle({ hovered = false }: { pressed: boolean; hovered?: boolean }) {
|
|
return [styles.checksSummaryRow, hovered && styles.listRowHovered];
|
|
}
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
card: {
|
|
backgroundColor: theme.colors.surface1,
|
|
borderWidth: 1,
|
|
borderColor: theme.colors.borderAccent,
|
|
borderRadius: theme.borderRadius.lg,
|
|
paddingTop: theme.spacing[2],
|
|
width: HOVER_CARD_WIDTH,
|
|
shadowColor: "#000",
|
|
shadowOffset: { width: 0, height: 4 },
|
|
shadowOpacity: 0.2,
|
|
shadowRadius: 8,
|
|
elevation: 8,
|
|
},
|
|
cardHeader: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
paddingHorizontal: theme.spacing[3],
|
|
paddingBottom: theme.spacing[2],
|
|
},
|
|
cardTitle: {
|
|
color: theme.colors.foreground,
|
|
fontSize: theme.fontSize.base,
|
|
fontWeight: theme.fontWeight.normal,
|
|
flex: 1,
|
|
minWidth: 0,
|
|
},
|
|
cardInfoRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[1.5],
|
|
paddingHorizontal: theme.spacing[3],
|
|
paddingBottom: theme.spacing[2],
|
|
},
|
|
cardInfoText: {
|
|
flex: 1,
|
|
minWidth: 0,
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
fontWeight: theme.fontWeight.normal,
|
|
},
|
|
cardInfoTextHovered: {
|
|
color: theme.colors.foreground,
|
|
},
|
|
separator: {
|
|
height: 1,
|
|
backgroundColor: theme.colors.border,
|
|
},
|
|
listRowHovered: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
checksSummaryRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[1.5],
|
|
paddingHorizontal: theme.spacing[3],
|
|
paddingVertical: 6,
|
|
minHeight: 28,
|
|
},
|
|
checksSummaryLabel: {
|
|
fontSize: theme.fontSize.sm,
|
|
fontWeight: theme.fontWeight.normal,
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
checksSummaryLabelHovered: {
|
|
color: theme.colors.foreground,
|
|
},
|
|
checksSummaryCounts: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
flex: 1,
|
|
justifyContent: "flex-end",
|
|
},
|
|
checksSummaryPill: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 3,
|
|
},
|
|
checksStatusTextFailed: {
|
|
fontSize: theme.fontSize.sm,
|
|
fontWeight: theme.fontWeight.normal,
|
|
color: theme.colors.statusDanger,
|
|
},
|
|
checksStatusTextPending: {
|
|
fontSize: theme.fontSize.sm,
|
|
fontWeight: theme.fontWeight.normal,
|
|
color: theme.colors.statusWarning,
|
|
},
|
|
checksStatusTextPassed: {
|
|
fontSize: theme.fontSize.sm,
|
|
fontWeight: theme.fontWeight.normal,
|
|
color: theme.colors.statusSuccess,
|
|
},
|
|
checksStatusTextMuted: {
|
|
fontSize: theme.fontSize.sm,
|
|
fontWeight: theme.fontWeight.normal,
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
}));
|