Improve review comment UI
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { CircleDot } from "lucide-react-native";
|
||||
import { MessageSquareCode } from "lucide-react-native";
|
||||
import type {
|
||||
ComposerAttachment,
|
||||
UserComposerAttachment,
|
||||
@@ -237,7 +237,7 @@ function WorkspaceAttachmentPill({
|
||||
>
|
||||
<View style={styles.pillBody}>
|
||||
<View style={styles.pillIcon}>
|
||||
<ThemedCircleDot size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
<ThemedMessageSquareCode size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
</View>
|
||||
<Text style={styles.pillText} numberOfLines={1}>
|
||||
{label}
|
||||
@@ -279,5 +279,5 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
},
|
||||
})) as unknown as Record<string, object>;
|
||||
|
||||
const ThemedCircleDot = withUnistyles(CircleDot);
|
||||
const ThemedMessageSquareCode = withUnistyles(MessageSquareCode);
|
||||
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
@@ -43,6 +43,7 @@ const {
|
||||
fontSize: { xs: 11, sm: 13, base: 15, lg: 18 },
|
||||
fontWeight: { normal: "400", medium: "500" },
|
||||
lineHeight: { diff: 18 },
|
||||
opacity: { 50: 0.5 },
|
||||
shadow: { md: {} },
|
||||
colors: {
|
||||
surface0: "#000",
|
||||
@@ -226,6 +227,7 @@ vi.mock("lucide-react-native", () => {
|
||||
AudioLines: createIcon("AudioLines"),
|
||||
CircleDot: createIcon("CircleDot"),
|
||||
GitPullRequest: createIcon("GitPullRequest"),
|
||||
MessageSquareCode: createIcon("MessageSquareCode"),
|
||||
X: createIcon("X"),
|
||||
Mic: createIcon("Mic"),
|
||||
MicOff: createIcon("MicOff"),
|
||||
|
||||
@@ -853,9 +853,7 @@ function ComposerCancelButton({
|
||||
) : (
|
||||
<Square size={buttonIconSize} color="white" fill="white" />
|
||||
);
|
||||
const shortcutNode = agentInterruptKeys ? (
|
||||
<Shortcut chord={agentInterruptKeys} style={styles.tooltipShortcut} />
|
||||
) : null;
|
||||
const shortcutNode = agentInterruptKeys ? <Shortcut chord={agentInterruptKeys} /> : null;
|
||||
return (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
@@ -941,9 +939,7 @@ function ComposerVoiceModeButton({
|
||||
realtimeVoiceButtonStyle,
|
||||
voiceToggleKeys,
|
||||
}: ComposerVoiceModeButtonProps) {
|
||||
const shortcutNode = voiceToggleKeys ? (
|
||||
<Shortcut chord={voiceToggleKeys} style={styles.tooltipShortcut} />
|
||||
) : null;
|
||||
const shortcutNode = voiceToggleKeys ? <Shortcut chord={voiceToggleKeys} /> : null;
|
||||
const renderTriggerContent = useCallback(
|
||||
({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => {
|
||||
if (isVoiceSwitching) {
|
||||
@@ -1874,10 +1870,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.popoverForeground,
|
||||
},
|
||||
tooltipShortcut: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
|
||||
@@ -138,7 +138,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
tooltipTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
tooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
|
||||
@@ -231,35 +231,59 @@ const EMPTY_COMMENTS: readonly ReviewDraftComment[] = [];
|
||||
|
||||
function noopStartComment(): void {}
|
||||
|
||||
const LONG_PRESS_COMMENT_DELAY_MS = 350;
|
||||
const DIFF_LINE_HOVER_STYLE = isWeb ? ({ cursor: "auto" } as const) : null;
|
||||
|
||||
function LongPressableLine({
|
||||
reviewTarget,
|
||||
reviewActions,
|
||||
onHoverChange,
|
||||
hoverTargetKey,
|
||||
onHoverTargetChange,
|
||||
style,
|
||||
children,
|
||||
}: {
|
||||
reviewTarget: ReviewableDiffTarget | null | undefined;
|
||||
reviewActions: InlineReviewActions | undefined;
|
||||
onHoverChange?: (hovered: boolean) => void;
|
||||
hoverTargetKey?: string | null;
|
||||
onHoverTargetChange?: (key: string | null) => void;
|
||||
style: StyleProp<ViewStyle>;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const onStartComment = reviewActions?.onStartComment;
|
||||
const handleLongPress = useCallback(() => {
|
||||
const handlePress = useCallback(() => {
|
||||
if (reviewTarget && onStartComment) {
|
||||
onStartComment(reviewTarget);
|
||||
}
|
||||
}, [reviewTarget, onStartComment]);
|
||||
|
||||
const handleHoverIn = useCallback(() => {
|
||||
onHoverChange?.(true);
|
||||
if (hoverTargetKey) {
|
||||
onHoverTargetChange?.(hoverTargetKey);
|
||||
}
|
||||
}, [hoverTargetKey, onHoverChange, onHoverTargetChange]);
|
||||
const handleHoverOut = useCallback(() => {
|
||||
onHoverChange?.(false);
|
||||
if (hoverTargetKey) {
|
||||
onHoverTargetChange?.(null);
|
||||
}
|
||||
}, [hoverTargetKey, onHoverChange, onHoverTargetChange]);
|
||||
const hoverStyle = useMemo(() => [style, DIFF_LINE_HOVER_STYLE], [style]);
|
||||
|
||||
if (isWeb && (onHoverChange || onHoverTargetChange)) {
|
||||
return (
|
||||
<Pressable onHoverIn={handleHoverIn} onHoverOut={handleHoverOut} style={hoverStyle}>
|
||||
{children}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isNative || !reviewTarget || !onStartComment) {
|
||||
return <View style={style}>{children}</View>;
|
||||
}
|
||||
return (
|
||||
<Pressable
|
||||
onLongPress={handleLongPress}
|
||||
delayLongPress={LONG_PRESS_COMMENT_DELAY_MS}
|
||||
style={style}
|
||||
>
|
||||
<Pressable onPress={handlePress} style={style}>
|
||||
{children}
|
||||
</Pressable>
|
||||
);
|
||||
@@ -279,6 +303,7 @@ function DiffGutterCell({
|
||||
gutterWidth,
|
||||
reviewTarget,
|
||||
reviewActions,
|
||||
isLineHovered,
|
||||
style,
|
||||
}: {
|
||||
lineNumber: number | null;
|
||||
@@ -286,6 +311,7 @@ function DiffGutterCell({
|
||||
gutterWidth: number;
|
||||
reviewTarget?: ReviewableDiffTarget | null;
|
||||
reviewActions?: InlineReviewActions;
|
||||
isLineHovered?: boolean;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}) {
|
||||
const containerStyle = useMemo(
|
||||
@@ -315,6 +341,7 @@ function DiffGutterCell({
|
||||
reviewTarget={reviewTarget}
|
||||
comments={comments}
|
||||
isEditorOpen={isEditorOpen}
|
||||
isLineHovered={isLineHovered}
|
||||
onStartComment={onStartComment}
|
||||
style={containerStyle}
|
||||
>
|
||||
@@ -330,11 +357,17 @@ function DiffTextLine({
|
||||
wrapLines,
|
||||
reviewTarget,
|
||||
reviewActions,
|
||||
onHoverChange,
|
||||
hoverTargetKey,
|
||||
onHoverTargetChange,
|
||||
}: {
|
||||
line: DiffLine;
|
||||
wrapLines: boolean;
|
||||
reviewTarget?: ReviewableDiffTarget | null;
|
||||
reviewActions?: InlineReviewActions;
|
||||
onHoverChange?: (hovered: boolean) => void;
|
||||
hoverTargetKey?: string | null;
|
||||
onHoverTargetChange?: (key: string | null) => void;
|
||||
}) {
|
||||
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
||||
|
||||
@@ -358,6 +391,9 @@ function DiffTextLine({
|
||||
<LongPressableLine
|
||||
reviewTarget={reviewTarget}
|
||||
reviewActions={reviewActions}
|
||||
onHoverChange={onHoverChange}
|
||||
hoverTargetKey={hoverTargetKey}
|
||||
onHoverTargetChange={onHoverTargetChange}
|
||||
style={containerStyle}
|
||||
>
|
||||
{line.type !== "header" && visibleTokens ? (
|
||||
@@ -373,10 +409,16 @@ function SplitTextLine({
|
||||
line,
|
||||
wrapLines,
|
||||
reviewActions,
|
||||
onHoverChange,
|
||||
hoverTargetKey,
|
||||
onHoverTargetChange,
|
||||
}: {
|
||||
line: SplitDiffDisplayLine | null;
|
||||
wrapLines: boolean;
|
||||
reviewActions?: InlineReviewActions;
|
||||
onHoverChange?: (hovered: boolean) => void;
|
||||
hoverTargetKey?: string | null;
|
||||
onHoverTargetChange?: (key: string | null) => void;
|
||||
}) {
|
||||
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
||||
|
||||
@@ -400,6 +442,9 @@ function SplitTextLine({
|
||||
<LongPressableLine
|
||||
reviewTarget={line?.reviewTarget}
|
||||
reviewActions={reviewActions}
|
||||
onHoverChange={onHoverChange}
|
||||
hoverTargetKey={hoverTargetKey}
|
||||
onHoverTargetChange={onHoverTargetChange}
|
||||
style={containerStyle}
|
||||
>
|
||||
{visibleTokens ? (
|
||||
@@ -426,6 +471,7 @@ function DiffLineView({
|
||||
reviewTarget?: ReviewableDiffTarget | null;
|
||||
reviewActions?: InlineReviewActions;
|
||||
}) {
|
||||
const [isLineHovered, setIsLineHovered] = useState(false);
|
||||
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
||||
|
||||
const containerStyle = useMemo(
|
||||
@@ -448,6 +494,7 @@ function DiffLineView({
|
||||
<LongPressableLine
|
||||
reviewTarget={reviewTarget}
|
||||
reviewActions={reviewActions}
|
||||
onHoverChange={setIsLineHovered}
|
||||
style={containerStyle}
|
||||
>
|
||||
<DiffGutterCell
|
||||
@@ -456,6 +503,7 @@ function DiffLineView({
|
||||
gutterWidth={gutterWidth}
|
||||
reviewTarget={reviewTarget}
|
||||
reviewActions={reviewActions}
|
||||
isLineHovered={isLineHovered}
|
||||
style={styles.lineNumberGutter}
|
||||
/>
|
||||
{line.type !== "header" && visibleTokens ? (
|
||||
@@ -478,6 +526,7 @@ function SplitDiffLine({
|
||||
wrapLines: boolean;
|
||||
reviewActions?: InlineReviewActions;
|
||||
}) {
|
||||
const [isLineHovered, setIsLineHovered] = useState(false);
|
||||
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
||||
|
||||
const containerStyle = useMemo(
|
||||
@@ -500,6 +549,7 @@ function SplitDiffLine({
|
||||
<LongPressableLine
|
||||
reviewTarget={line?.reviewTarget}
|
||||
reviewActions={reviewActions}
|
||||
onHoverChange={setIsLineHovered}
|
||||
style={containerStyle}
|
||||
>
|
||||
<DiffGutterCell
|
||||
@@ -508,6 +558,7 @@ function SplitDiffLine({
|
||||
gutterWidth={gutterWidth}
|
||||
reviewTarget={line?.reviewTarget}
|
||||
reviewActions={reviewActions}
|
||||
isLineHovered={isLineHovered}
|
||||
style={styles.lineNumberGutter}
|
||||
/>
|
||||
{visibleTokens ? (
|
||||
@@ -635,6 +686,7 @@ function SplitDiffColumn({
|
||||
showDivider?: boolean;
|
||||
}) {
|
||||
const [scrollWidth, setScrollWidth] = useState(0);
|
||||
const [hoveredReviewTargetKey, setHoveredReviewTargetKey] = useState<string | null>(null);
|
||||
|
||||
const wrapCellStyle = useMemo(
|
||||
() => [styles.splitCell, showDivider && styles.splitCellWithDivider],
|
||||
@@ -701,6 +753,7 @@ function SplitDiffColumn({
|
||||
);
|
||||
}
|
||||
const line = side === "left" ? row.left : row.right;
|
||||
const reviewTargetKey = line?.reviewTarget?.key ?? null;
|
||||
const reviewRowState = getSplitInlineReviewThreadState({
|
||||
left: row.left?.reviewTarget,
|
||||
right: row.right?.reviewTarget,
|
||||
@@ -714,6 +767,9 @@ function SplitDiffColumn({
|
||||
gutterWidth={gutterWidth}
|
||||
reviewTarget={line?.reviewTarget}
|
||||
reviewActions={reviewActions}
|
||||
isLineHovered={
|
||||
reviewTargetKey !== null && hoveredReviewTargetKey === reviewTargetKey
|
||||
}
|
||||
/>
|
||||
<InlineReviewGutterSpacer
|
||||
reviewTarget={line?.reviewTarget}
|
||||
@@ -741,6 +797,7 @@ function SplitDiffColumn({
|
||||
);
|
||||
}
|
||||
const line = side === "left" ? row.left : row.right;
|
||||
const reviewTargetKey = line?.reviewTarget?.key ?? null;
|
||||
const reviewRowState = getSplitInlineReviewThreadState({
|
||||
left: row.left?.reviewTarget,
|
||||
right: row.right?.reviewTarget,
|
||||
@@ -748,7 +805,13 @@ function SplitDiffColumn({
|
||||
});
|
||||
return (
|
||||
<View key={key}>
|
||||
<SplitTextLine line={line} wrapLines={false} reviewActions={reviewActions} />
|
||||
<SplitTextLine
|
||||
line={line}
|
||||
wrapLines={false}
|
||||
reviewActions={reviewActions}
|
||||
hoverTargetKey={reviewTargetKey}
|
||||
onHoverTargetChange={setHoveredReviewTargetKey}
|
||||
/>
|
||||
<InlineReviewThreadContent
|
||||
reviewTarget={line?.reviewTarget}
|
||||
reviewActions={reviewActions}
|
||||
@@ -872,6 +935,7 @@ function DiffFileBody({
|
||||
}) {
|
||||
const [scrollViewWidth, setScrollViewWidth] = useState(0);
|
||||
const [bodyWidth, setBodyWidth] = useState(0);
|
||||
const [hoveredReviewTargetKey, setHoveredReviewTargetKey] = useState<string | null>(null);
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const handleLayout = useCallback(
|
||||
@@ -975,6 +1039,9 @@ function DiffFileBody({
|
||||
gutterWidth={gutterWidth}
|
||||
reviewTarget={reviewTarget}
|
||||
reviewActions={reviewActions}
|
||||
isLineHovered={
|
||||
reviewTarget?.key !== undefined && hoveredReviewTargetKey === reviewTarget.key
|
||||
}
|
||||
/>
|
||||
<InlineReviewGutterSpacer
|
||||
reviewTarget={reviewTarget}
|
||||
@@ -998,6 +1065,8 @@ function DiffFileBody({
|
||||
wrapLines={false}
|
||||
reviewTarget={reviewTarget}
|
||||
reviewActions={reviewActions}
|
||||
hoverTargetKey={reviewTarget?.key ?? null}
|
||||
onHoverTargetChange={setHoveredReviewTargetKey}
|
||||
/>
|
||||
<InlineReviewThreadContent
|
||||
reviewTarget={reviewTarget}
|
||||
@@ -2998,11 +3067,17 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
gutterColumn: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
zIndex: 4,
|
||||
elevation: 4,
|
||||
overflow: "visible",
|
||||
},
|
||||
gutterCell: {
|
||||
borderRightWidth: theme.borderWidth[1],
|
||||
borderRightColor: theme.colors.border,
|
||||
justifyContent: "flex-start",
|
||||
zIndex: 4,
|
||||
elevation: 4,
|
||||
overflow: "visible",
|
||||
},
|
||||
inlineReviewRow: {
|
||||
flexDirection: "row",
|
||||
@@ -3050,6 +3125,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
diffLineContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "stretch",
|
||||
overflow: "visible",
|
||||
},
|
||||
lineNumberGutter: {
|
||||
borderRightWidth: theme.borderWidth[1],
|
||||
@@ -3057,8 +3133,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
marginRight: theme.spacing[2],
|
||||
alignSelf: "stretch",
|
||||
justifyContent: "flex-start",
|
||||
zIndex: 4,
|
||||
elevation: 4,
|
||||
overflow: "visible",
|
||||
},
|
||||
lineNumberText: {
|
||||
width: "100%",
|
||||
textAlign: "right",
|
||||
paddingRight: theme.spacing[2],
|
||||
fontSize: theme.fontSize.xs,
|
||||
|
||||
@@ -88,8 +88,5 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.popoverForeground,
|
||||
},
|
||||
shortcut: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
shortcut: {},
|
||||
}));
|
||||
|
||||
@@ -278,7 +278,7 @@ function VoiceTooltipBody({
|
||||
return (
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>{voiceTooltipText}</Text>
|
||||
{shortcut ? <Shortcut chord={shortcut} style={styles.tooltipShortcut} /> : null}
|
||||
{shortcut ? <Shortcut chord={shortcut} /> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -293,7 +293,7 @@ function SendTooltipBody({
|
||||
return (
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>{label}</Text>
|
||||
{sendKeys ? <Shortcut chord={sendKeys} style={styles.tooltipShortcut} /> : null}
|
||||
{sendKeys ? <Shortcut chord={sendKeys} /> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1925,10 +1925,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.popoverForeground,
|
||||
},
|
||||
tooltipShortcut: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
|
||||
@@ -2724,10 +2724,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
projectActionTooltipShortcut: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
projectActionTooltipShortcut: {},
|
||||
workspaceRow: {
|
||||
minHeight: 36,
|
||||
marginBottom: theme.spacing[1],
|
||||
|
||||
@@ -126,7 +126,7 @@ interface SplitPaneDropData {
|
||||
interface SplitNodeViewProps extends Omit<SplitContainerProps, "layout" | "onMoveTabToPane"> {
|
||||
node: SplitNode;
|
||||
uiTabs: WorkspaceTab[];
|
||||
focusedPaneId: string;
|
||||
focusedPaneId: string | null;
|
||||
activeDragTabId: string | null;
|
||||
showDropZones: boolean;
|
||||
dropPreview: SplitDropZoneHover | null;
|
||||
@@ -370,7 +370,7 @@ export function SplitContainer({
|
||||
if (!focusModeEnabled) {
|
||||
return layout.root;
|
||||
}
|
||||
const focusedPane = panesById.get(layout.focusedPaneId);
|
||||
const focusedPane = layout.focusedPaneId ? panesById.get(layout.focusedPaneId) : null;
|
||||
if (!focusedPane) {
|
||||
return layout.root;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import {
|
||||
default as React,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
type ComponentType,
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
||||
import type {
|
||||
@@ -17,7 +19,7 @@ import type {
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
|
||||
type ButtonVariant = "default" | "secondary" | "outline" | "ghost" | "destructive";
|
||||
type ButtonSize = "sm" | "md" | "lg";
|
||||
type ButtonSize = "xs" | "sm" | "md" | "lg";
|
||||
|
||||
type LeftIcon =
|
||||
| ReactElement
|
||||
@@ -25,7 +27,7 @@ type LeftIcon =
|
||||
| ((color: string) => ReactElement)
|
||||
| null;
|
||||
|
||||
const ICON_SIZE: Record<ButtonSize, number> = { sm: 14, md: 16, lg: 20 };
|
||||
const ICON_SIZE: Record<ButtonSize, number> = { xs: 12, sm: 14, md: 16, lg: 20 };
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
base: {
|
||||
@@ -41,6 +43,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
},
|
||||
xs: {
|
||||
minHeight: 28,
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
sm: {
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
@@ -82,6 +90,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
textXs: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
textDefault: {
|
||||
color: theme.colors.palette.white,
|
||||
},
|
||||
@@ -101,6 +112,7 @@ export function Button({
|
||||
variant = "secondary",
|
||||
size = "md",
|
||||
leftIcon,
|
||||
trailing,
|
||||
style,
|
||||
textStyle,
|
||||
disabled,
|
||||
@@ -112,6 +124,7 @@ export function Button({
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
leftIcon?: LeftIcon;
|
||||
trailing?: ReactNode;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
textStyle?: StyleProp<TextStyle>;
|
||||
loading?: boolean;
|
||||
@@ -135,7 +148,9 @@ export function Button({
|
||||
}
|
||||
|
||||
let sizeStyle: ViewStyle;
|
||||
if (size === "sm") {
|
||||
if (size === "xs") {
|
||||
sizeStyle = styles.xs;
|
||||
} else if (size === "sm") {
|
||||
sizeStyle = styles.sm;
|
||||
} else if (size === "lg") {
|
||||
sizeStyle = styles.lg;
|
||||
@@ -162,13 +177,14 @@ export function Button({
|
||||
const resolvedTextStyle = useMemo(
|
||||
() => [
|
||||
styles.text,
|
||||
size === "xs" ? styles.textXs : null,
|
||||
variant === "default" ? styles.textDefault : null,
|
||||
variant === "destructive" ? styles.textDestructive : null,
|
||||
variant === "ghost" ? styles.textGhost : null,
|
||||
textStyle,
|
||||
isGhostHovered ? styles.textGhostHovered : null,
|
||||
],
|
||||
[variant, textStyle, isGhostHovered],
|
||||
[size, variant, textStyle, isGhostHovered],
|
||||
);
|
||||
|
||||
const accessibilityState = useMemo(
|
||||
@@ -235,6 +251,7 @@ export function Button({
|
||||
>
|
||||
{renderIcon()}
|
||||
{children != null ? <Text style={resolvedTextStyle}>{children}</Text> : null}
|
||||
{trailing}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
import { useMemo, type ReactElement } from "react";
|
||||
import React, { useMemo, type ReactElement } from "react";
|
||||
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
|
||||
function colorWithOpacity(color: string, opacity: number): string {
|
||||
const hex = color.trim();
|
||||
if (/^#[0-9a-fA-F]{3}$/.test(hex)) {
|
||||
const r = Number.parseInt(hex[1] + hex[1], 16);
|
||||
const g = Number.parseInt(hex[2] + hex[2], 16);
|
||||
const b = Number.parseInt(hex[3] + hex[3], 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
}
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(hex)) {
|
||||
const r = Number.parseInt(hex.slice(1, 3), 16);
|
||||
const g = Number.parseInt(hex.slice(3, 5), 16);
|
||||
const b = Number.parseInt(hex.slice(5, 7), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
}
|
||||
return `rgba(255, 255, 255, ${opacity})`;
|
||||
}
|
||||
|
||||
export function Shortcut({
|
||||
keys,
|
||||
chord,
|
||||
@@ -53,9 +70,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
paddingVertical: 2,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
backgroundColor: colorWithOpacity(theme.colors.foreground, 0.05),
|
||||
borderWidth: 0,
|
||||
},
|
||||
sequence: {
|
||||
flexDirection: "row",
|
||||
@@ -66,6 +82,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
text: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
color: colorWithOpacity(theme.colors.foreground, 0.6),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -24,9 +24,10 @@ const { theme, pressablePropsByLabel } = vi.hoisted(() => {
|
||||
theme: {
|
||||
spacing: { 1: 4, 2: 8, 3: 12 },
|
||||
borderWidth: { 1: 1 },
|
||||
borderRadius: { base: 4 },
|
||||
borderRadius: { base: 4, md: 6, lg: 8, xl: 12, full: 999 },
|
||||
opacity: { 50: 0.5 },
|
||||
fontSize: { xs: 11, sm: 13 },
|
||||
fontWeight: { medium: "500" },
|
||||
fontWeight: { normal: "400", medium: "500" },
|
||||
lineHeight: { diff: 18 },
|
||||
colors: {
|
||||
accent: "#0a84ff",
|
||||
@@ -38,6 +39,7 @@ const { theme, pressablePropsByLabel } = vi.hoisted(() => {
|
||||
surface1: "#111",
|
||||
surface2: "#222",
|
||||
surface3: "#333",
|
||||
palette: { white: "#fff" },
|
||||
},
|
||||
},
|
||||
pressablePropsByLabel: new Map<string, Record<string, unknown>>(),
|
||||
@@ -91,6 +93,8 @@ vi.mock("react-native-unistyles", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/constants/platform", () => ({
|
||||
getIsElectron: () => false,
|
||||
getIsElectronMac: () => false,
|
||||
isNative: false,
|
||||
isWeb: true,
|
||||
}));
|
||||
@@ -101,8 +105,8 @@ vi.mock("lucide-react-native", () => {
|
||||
return {
|
||||
Check: createIcon("Check"),
|
||||
CircleDot: createIcon("CircleDot"),
|
||||
MessageCircle: createIcon("MessageCircle"),
|
||||
Pencil: createIcon("Pencil"),
|
||||
Plus: createIcon("Plus"),
|
||||
Trash2: createIcon("Trash2"),
|
||||
X: createIcon("X"),
|
||||
};
|
||||
@@ -293,7 +297,7 @@ describe("git diff inline review helpers", () => {
|
||||
expect(pressablePropsByLabel.get("Add review comment")?.hitSlop).toBe(SMALL_ACTION_HIT_SLOP);
|
||||
});
|
||||
|
||||
it("shows the line number by default and replaces it with the icon when comments exist or the editor is open", () => {
|
||||
it("keeps the line number visible and only floats the plus for line hover", () => {
|
||||
const reviewTarget = target();
|
||||
const { container, queryByText, rerender } = render(
|
||||
<InlineReviewGutterCell
|
||||
@@ -307,7 +311,7 @@ describe("git diff inline review helpers", () => {
|
||||
);
|
||||
|
||||
expect(queryByText("2")).toBeTruthy();
|
||||
expect(container.querySelector("[data-icon='MessageCircle']")).toBeNull();
|
||||
expect(container.querySelector("[data-icon='Plus']")).toBeNull();
|
||||
|
||||
rerender(
|
||||
<InlineReviewGutterCell
|
||||
@@ -320,8 +324,8 @@ describe("git diff inline review helpers", () => {
|
||||
</InlineReviewGutterCell>,
|
||||
);
|
||||
|
||||
expect(queryByText("2")).toBeNull();
|
||||
expect(container.querySelector("[data-icon='MessageCircle']")).toBeTruthy();
|
||||
expect(queryByText("2")).toBeTruthy();
|
||||
expect(container.querySelector("[data-icon='Plus']")).toBeNull();
|
||||
|
||||
rerender(
|
||||
<InlineReviewGutterCell
|
||||
@@ -334,13 +338,31 @@ describe("git diff inline review helpers", () => {
|
||||
</InlineReviewGutterCell>,
|
||||
);
|
||||
|
||||
expect(queryByText("2")).toBeNull();
|
||||
expect(container.querySelector("[data-icon='MessageCircle']")).toBeTruthy();
|
||||
expect(queryByText("2")).toBeTruthy();
|
||||
expect(container.querySelector("[data-icon='Plus']")).toBeNull();
|
||||
|
||||
rerender(
|
||||
<InlineReviewGutterCell
|
||||
reviewTarget={reviewTarget}
|
||||
comments={COMMENT_LIST}
|
||||
isEditorOpen={false}
|
||||
isLineHovered
|
||||
onStartComment={vi.fn()}
|
||||
>
|
||||
<span>2</span>
|
||||
</InlineReviewGutterCell>,
|
||||
);
|
||||
|
||||
expect(queryByText("2")).toBeTruthy();
|
||||
expect(container.querySelector("[data-icon='Plus']")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("InlineReviewEditor", () => {
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
|
||||
afterEach(() => {
|
||||
window.matchMedia = originalMatchMedia;
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -364,6 +386,50 @@ describe("InlineReviewEditor", () => {
|
||||
fireEvent.click(getByTestId("editor-cancel"));
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("handles Escape cancel and Mod+Enter save from the focused textarea", () => {
|
||||
const onCancel = vi.fn();
|
||||
const onSave = vi.fn();
|
||||
const { getByTestId } = render(
|
||||
<InlineReviewEditor
|
||||
initialBody="ready"
|
||||
onCancel={onCancel}
|
||||
onSave={onSave}
|
||||
testID="editor"
|
||||
/>,
|
||||
);
|
||||
const input = getByTestId("editor-input");
|
||||
|
||||
fireEvent.focus(input);
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.keyDown(input, { key: "Enter", metaKey: true });
|
||||
expect(onSave).toHaveBeenCalledWith("ready");
|
||||
});
|
||||
|
||||
it("shows shared shortcut hints while focused on a fine-pointer screen", () => {
|
||||
window.matchMedia = vi.fn().mockReturnValue({
|
||||
matches: true,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
const { getByTestId, getByText, queryByText } = render(
|
||||
<InlineReviewEditor
|
||||
initialBody="ready"
|
||||
onCancel={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
testID="editor"
|
||||
/>,
|
||||
);
|
||||
const input = getByTestId("editor-input");
|
||||
|
||||
expect(getByText("Esc")).toBeTruthy();
|
||||
expect(getByText(/(?:⌘⏎|Ctrl\+⏎)/)).toBeTruthy();
|
||||
|
||||
fireEvent.blur(input);
|
||||
expect(queryByText("Esc")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("InlineReviewThread", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { MessageCircle, Pencil, Trash2 } from "lucide-react-native";
|
||||
import { Pencil, Plus, Trash2 } from "lucide-react-native";
|
||||
import {
|
||||
Pressable,
|
||||
type PressableStateCallbackType,
|
||||
@@ -12,11 +12,19 @@ import {
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { useWorkspaceFocusRestoration } from "@/workspace/focus";
|
||||
import { useReviewDraftComments, useReviewDraftStore, type ReviewDraftComment } from "./store";
|
||||
import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout";
|
||||
|
||||
type PressableState = PressableStateCallbackType & { hovered?: boolean };
|
||||
type WebTextInputRef = TextInput & {
|
||||
getNativeElement?: () => unknown;
|
||||
getNativeRef?: () => unknown;
|
||||
};
|
||||
|
||||
function iconButtonStyle({ hovered, pressed }: PressableState): StyleProp<ViewStyle> {
|
||||
return [styles.iconButton, (hovered || pressed) && styles.iconButtonHovered];
|
||||
@@ -26,14 +34,48 @@ function iconButtonDestructiveStyle({ hovered, pressed }: PressableState): Style
|
||||
return [styles.iconButton, (hovered || pressed) && styles.iconButtonDestructiveHovered];
|
||||
}
|
||||
|
||||
function ghostButtonStyle({ hovered, pressed }: PressableState): StyleProp<ViewStyle> {
|
||||
return [styles.ghostButton, (hovered || pressed) && styles.ghostButtonHovered];
|
||||
function getWebTextInputElement(input: TextInput | null): HTMLElement | null {
|
||||
if (!isWeb || typeof HTMLElement === "undefined" || !input) {
|
||||
return null;
|
||||
}
|
||||
const webInput = input as WebTextInputRef;
|
||||
const element = webInput.getNativeElement?.() ?? webInput.getNativeRef?.() ?? input;
|
||||
return element instanceof HTMLElement ? element : null;
|
||||
}
|
||||
|
||||
function getCanShowReviewKeyboardHints(): boolean {
|
||||
if (!isWeb || typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
||||
return false;
|
||||
}
|
||||
return window.matchMedia("(hover: hover) and (pointer: fine)").matches;
|
||||
}
|
||||
|
||||
function useCanShowReviewKeyboardHints(): boolean {
|
||||
const [canShowHints, setCanShowHints] = useState(getCanShowReviewKeyboardHints);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWeb || typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia("(hover: hover) and (pointer: fine)");
|
||||
const handleChange = () => setCanShowHints(mediaQuery.matches);
|
||||
handleChange();
|
||||
mediaQuery.addEventListener?.("change", handleChange);
|
||||
return () => {
|
||||
mediaQuery.removeEventListener?.("change", handleChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return canShowHints;
|
||||
}
|
||||
|
||||
export const INLINE_REVIEW_COMMENT_HEIGHT = 72;
|
||||
export const INLINE_REVIEW_EDITOR_HEIGHT = 132;
|
||||
const INLINE_REVIEW_GAP = 6;
|
||||
export const SMALL_ACTION_HIT_SLOP = 8;
|
||||
const REVIEW_CANCEL_SHORTCUT_KEYS: ShortcutKey[] = ["Esc"];
|
||||
const REVIEW_SAVE_SHORTCUT_KEYS: ShortcutKey[] = ["mod", "Enter"];
|
||||
|
||||
export interface InlineReviewEditorState {
|
||||
target: ReviewableDiffTarget;
|
||||
@@ -228,7 +270,7 @@ export function InlineReviewGutterCell({
|
||||
children,
|
||||
reviewTarget,
|
||||
comments,
|
||||
isEditorOpen,
|
||||
isLineHovered = false,
|
||||
onStartComment,
|
||||
style,
|
||||
}: {
|
||||
@@ -236,26 +278,49 @@ export function InlineReviewGutterCell({
|
||||
reviewTarget: ReviewableDiffTarget | null | undefined;
|
||||
comments: readonly ReviewDraftComment[];
|
||||
isEditorOpen: boolean;
|
||||
isLineHovered?: boolean;
|
||||
onStartComment: (target: ReviewableDiffTarget) => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const canComment = Boolean(reviewTarget);
|
||||
const hasComments = comments.length > 0;
|
||||
const [isGutterHovered, setIsGutterHovered] = useState(false);
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const [isDismissedAfterPress, setIsDismissedAfterPress] = useState(false);
|
||||
const isInteractionActive = isGutterHovered || isLineHovered || isPressed;
|
||||
const showAction = canComment && isInteractionActive && !isDismissedAfterPress;
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (reviewTarget) {
|
||||
setIsDismissedAfterPress(true);
|
||||
onStartComment(reviewTarget);
|
||||
}
|
||||
}, [reviewTarget, onStartComment]);
|
||||
|
||||
const pressableStyle = useCallback(
|
||||
({ hovered, pressed }: PressableState): StyleProp<ViewStyle> => [
|
||||
style,
|
||||
canComment && (hovered || pressed) && styles.gutterHovered,
|
||||
],
|
||||
[style, canComment],
|
||||
);
|
||||
const handleHoverIn = useCallback(() => {
|
||||
setIsGutterHovered(true);
|
||||
}, []);
|
||||
|
||||
const handleHoverOut = useCallback(() => {
|
||||
setIsGutterHovered(false);
|
||||
}, []);
|
||||
|
||||
const handlePressIn = useCallback(() => {
|
||||
setIsPressed(true);
|
||||
}, []);
|
||||
|
||||
const handlePressOut = useCallback(() => {
|
||||
setIsPressed(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInteractionActive) {
|
||||
setIsDismissedAfterPress(false);
|
||||
}
|
||||
}, [isInteractionActive]);
|
||||
|
||||
const pressableStyle = useCallback((): StyleProp<ViewStyle> => style, [style]);
|
||||
|
||||
const labelStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.gutterLabel, hasComments && styles.gutterLabelActive],
|
||||
@@ -269,26 +334,22 @@ export function InlineReviewGutterCell({
|
||||
hitSlop={canComment ? SMALL_ACTION_HIT_SLOP : undefined}
|
||||
disabled={!canComment}
|
||||
onPress={handlePress}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
style={pressableStyle}
|
||||
>
|
||||
{({ hovered, pressed }) => {
|
||||
const showAction = canComment && (hovered || pressed || hasComments || isEditorOpen);
|
||||
return (
|
||||
<View style={styles.gutterInner}>
|
||||
<View style={labelStyle}>
|
||||
{showAction ? (
|
||||
<MessageCircle
|
||||
size={13}
|
||||
strokeWidth={hasComments ? 2.25 : 1.75}
|
||||
color={hasComments ? theme.colors.accent : theme.colors.foregroundMuted}
|
||||
/>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
<View style={styles.gutterInner}>
|
||||
<View style={labelStyle}>
|
||||
{children}
|
||||
{showAction ? (
|
||||
<View style={styles.gutterActionIcon}>
|
||||
<Plus size={16} strokeWidth={2.4} color={theme.colors.accentForeground} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -438,33 +499,75 @@ export function InlineReviewEditor({
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const inputRef = useRef<TextInput | null>(null);
|
||||
const focus = useWorkspaceFocusRestoration();
|
||||
const canShowKeyboardHints = useCanShowReviewKeyboardHints();
|
||||
const [body, setBody] = useState(initialBody);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const trimmedBody = body.trim();
|
||||
const canSave = trimmedBody.length > 0;
|
||||
const showKeyboardHints = isFocused && canShowKeyboardHints;
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleFocus = useCallback(() => setIsFocused(true), []);
|
||||
const handleBlur = useCallback(() => setIsFocused(false), []);
|
||||
const handleFocus = useCallback(() => {
|
||||
focus.unfocus();
|
||||
setIsFocused(true);
|
||||
}, [focus]);
|
||||
const handleBlur = useCallback(() => {
|
||||
setIsFocused(false);
|
||||
focus.restore();
|
||||
}, [focus]);
|
||||
const handleSave = useCallback(() => onSave(trimmedBody), [onSave, trimmedBody]);
|
||||
const cancelShortcut = useMemo(
|
||||
() => (showKeyboardHints ? <Shortcut keys={REVIEW_CANCEL_SHORTCUT_KEYS} /> : null),
|
||||
[showKeyboardHints],
|
||||
);
|
||||
const saveShortcut = useMemo(
|
||||
() => (showKeyboardHints ? <Shortcut keys={REVIEW_SAVE_SHORTCUT_KEYS} /> : null),
|
||||
[showKeyboardHints],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const element = getWebTextInputElement(inputRef.current);
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== "Enter" || event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
if (!event.metaKey && !event.ctrlKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!canSave) {
|
||||
return;
|
||||
}
|
||||
handleSave();
|
||||
};
|
||||
|
||||
element.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
element.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [canSave, handleSave, onCancel]);
|
||||
|
||||
const inputStyle = useMemo<StyleProp<TextStyle>>(
|
||||
() => [styles.editorInput, isFocused && styles.editorInputFocused],
|
||||
[isFocused],
|
||||
);
|
||||
|
||||
const saveButtonStyle = useCallback(
|
||||
({ hovered, pressed }: PressableState): StyleProp<ViewStyle> => [
|
||||
styles.saveButton,
|
||||
!canSave && styles.saveButtonDisabled,
|
||||
canSave && (hovered || pressed) && styles.saveButtonHovered,
|
||||
],
|
||||
[canSave],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.editorBlock} testID={testID}>
|
||||
<TextInput
|
||||
@@ -481,40 +584,40 @@ export function InlineReviewEditor({
|
||||
style={inputStyle}
|
||||
/>
|
||||
<View style={styles.editorActions}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
<Button
|
||||
accessibilityLabel="Cancel review comment"
|
||||
testID={testID ? `${testID}-cancel` : undefined}
|
||||
hitSlop={SMALL_ACTION_HIT_SLOP}
|
||||
onPress={onCancel}
|
||||
style={ghostButtonStyle}
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
trailing={cancelShortcut}
|
||||
>
|
||||
<Text style={styles.ghostButtonText}>Cancel</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
accessibilityLabel="Save review comment"
|
||||
testID={testID ? `${testID}-save` : undefined}
|
||||
hitSlop={SMALL_ACTION_HIT_SLOP}
|
||||
disabled={!canSave}
|
||||
onPress={handleSave}
|
||||
style={saveButtonStyle}
|
||||
variant="default"
|
||||
size="xs"
|
||||
trailing={saveShortcut}
|
||||
>
|
||||
<Text style={styles.saveButtonText}>Save</Text>
|
||||
</Pressable>
|
||||
Comment
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
gutterHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
gutterInner: {
|
||||
minHeight: theme.lineHeight.diff,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "visible",
|
||||
},
|
||||
gutterLabel: {
|
||||
width: "100%",
|
||||
@@ -522,10 +625,25 @@ const styles = StyleSheet.create((theme) => ({
|
||||
height: theme.lineHeight.diff,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
overflow: "visible",
|
||||
},
|
||||
gutterLabelActive: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
gutterActionIcon: {
|
||||
position: "absolute",
|
||||
right: -10,
|
||||
top: Math.floor((theme.lineHeight.diff - 22) / 2),
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: theme.colors.accent,
|
||||
zIndex: 10,
|
||||
elevation: 10,
|
||||
},
|
||||
threadContainer: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
@@ -616,38 +734,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
ghostButton: {
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
ghostButtonHovered: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
ghostButtonText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
saveButton: {
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.accent,
|
||||
},
|
||||
saveButtonHovered: {
|
||||
opacity: 0.9,
|
||||
},
|
||||
saveButtonDisabled: {
|
||||
opacity: 0.4,
|
||||
},
|
||||
saveButtonText: {
|
||||
color: theme.colors.accentForeground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1013,10 +1013,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
newTabTooltipShortcut: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
newTabTooltipShortcut: {},
|
||||
tooltipAgentRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -118,6 +118,7 @@ import {
|
||||
type WorkspacePaneContentModel,
|
||||
} from "@/screens/workspace/workspace-pane-content";
|
||||
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
|
||||
import { WorkspaceFocusProvider } from "@/workspace/focus";
|
||||
import { shouldSeedEmptyWorkspaceDraft } from "@/screens/workspace/workspace-empty-draft-seed";
|
||||
import {
|
||||
buildBulkCloseConfirmationMessage,
|
||||
@@ -2897,127 +2898,129 @@ function WorkspaceScreenContent({
|
||||
]);
|
||||
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
{documentTitleEffectTab ? (
|
||||
<WorkspaceTabPresentationResolver
|
||||
tab={documentTitleEffectTab}
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
>
|
||||
{(presentation) => (
|
||||
<WorkspaceDocumentTitleEffect
|
||||
label={presentation.label}
|
||||
titleState={presentation.titleState}
|
||||
/>
|
||||
)}
|
||||
</WorkspaceTabPresentationResolver>
|
||||
) : null}
|
||||
<View style={styles.threePaneRow}>
|
||||
<View style={styles.centerColumn}>
|
||||
{showScreenHeader && (
|
||||
<ScreenHeader
|
||||
onRowLayout={onHeaderLayout}
|
||||
left={
|
||||
<>
|
||||
<SidebarMenuToggle />
|
||||
<WorkspaceHeaderTitleBar
|
||||
isLoading={isWorkspaceHeaderLoading}
|
||||
title={workspaceHeaderTitle}
|
||||
subtitle={workspaceHeaderSubtitle}
|
||||
showSubtitle={shouldShowWorkspaceHeaderSubtitle}
|
||||
currentBranchName={currentBranchName}
|
||||
isGitCheckout={isGitCheckout}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
showWorkspaceSetup={showWorkspaceSetup}
|
||||
isMobile={isMobile}
|
||||
createTerminalDisabled={createTerminalDisabled}
|
||||
menuNewAgentIcon={menuNewAgentIcon}
|
||||
menuNewTerminalIcon={menuNewTerminalIcon}
|
||||
menuCopyIcon={menuCopyIcon}
|
||||
menuSettingsIcon={menuSettingsIcon}
|
||||
onCreateDraftTab={handleCreateDraftTab}
|
||||
onCreateTerminal={handleCreateTerminal}
|
||||
onCopyWorkspacePath={handleCopyWorkspacePath}
|
||||
onCopyBranchName={handleCopyBranchName}
|
||||
onOpenSetupTab={handleOpenSetupTab}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
right={headerRight}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isMobile ? (
|
||||
<MobileWorkspaceTabSwitcher
|
||||
tabs={tabs}
|
||||
activeTabKey={activeTabKey}
|
||||
activeTab={activeTabDescriptor}
|
||||
tabSwitcherOptions={tabSwitcherOptions}
|
||||
tabByKey={tabByKey}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
onSelectSwitcherTab={handleSelectSwitcherTab}
|
||||
onCopyResumeCommand={handleCopyResumeCommand}
|
||||
onCopyAgentId={handleCopyAgentId}
|
||||
onReloadAgent={handleReloadAgent}
|
||||
onCloseTab={handleCloseTabById}
|
||||
onCloseTabsAbove={handleCloseTabsToLeft}
|
||||
onCloseTabsBelow={handleCloseTabsToRight}
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{shouldRenderDesktopPaneFallback ? (
|
||||
<WorkspaceDesktopTabsRow
|
||||
paneId={focusedPaneIdOrUndefined}
|
||||
isFocused={isRouteFocused}
|
||||
tabs={desktopTabRowItems}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
setHoveredTabKey={setHoveredTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
onNavigateTab={navigateToTabId}
|
||||
onCloseTab={handleCloseTabById}
|
||||
onCopyResumeCommand={handleCopyResumeCommand}
|
||||
onCopyAgentId={handleCopyAgentId}
|
||||
onReloadAgent={handleReloadAgent}
|
||||
onCloseTabsToLeft={handleCloseTabsToLeft}
|
||||
onCloseTabsToRight={handleCloseTabsToRight}
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
onCreateDraftTab={handleCreateDraftTab}
|
||||
onCreateTerminalTab={handleCreateTerminal}
|
||||
disableCreateTerminal={createTerminalMutation.isPending}
|
||||
isWaitingOnTerminalReadiness={pendingTerminalCreateInput !== null}
|
||||
onReorderTabs={handleReorderTabsInFocusedPane}
|
||||
onSplitRight={noop}
|
||||
onSplitDown={noop}
|
||||
showPaneSplitActions={false}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<View style={styles.centerContent}>
|
||||
{isMobile ? (
|
||||
<GestureDetector gesture={explorerOpenGesture} touchAction="pan-y">
|
||||
<View style={styles.content}>{content}</View>
|
||||
</GestureDetector>
|
||||
) : (
|
||||
<View style={styles.content}>{desktopContent}</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{showExplorerSidebar && workspaceDirectory ? (
|
||||
<ExplorerSidebar
|
||||
<WorkspaceFocusProvider workspaceKey={persistenceKey}>
|
||||
<View style={containerStyle}>
|
||||
{documentTitleEffectTab ? (
|
||||
<WorkspaceTabPresentationResolver
|
||||
tab={documentTitleEffectTab}
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
workspaceRoot={workspaceDirectory}
|
||||
isGit={isGitCheckout}
|
||||
onOpenFile={handleOpenFileFromExplorer}
|
||||
/>
|
||||
>
|
||||
{(presentation) => (
|
||||
<WorkspaceDocumentTitleEffect
|
||||
label={presentation.label}
|
||||
titleState={presentation.titleState}
|
||||
/>
|
||||
)}
|
||||
</WorkspaceTabPresentationResolver>
|
||||
) : null}
|
||||
<View style={styles.threePaneRow}>
|
||||
<View style={styles.centerColumn}>
|
||||
{showScreenHeader && (
|
||||
<ScreenHeader
|
||||
onRowLayout={onHeaderLayout}
|
||||
left={
|
||||
<>
|
||||
<SidebarMenuToggle />
|
||||
<WorkspaceHeaderTitleBar
|
||||
isLoading={isWorkspaceHeaderLoading}
|
||||
title={workspaceHeaderTitle}
|
||||
subtitle={workspaceHeaderSubtitle}
|
||||
showSubtitle={shouldShowWorkspaceHeaderSubtitle}
|
||||
currentBranchName={currentBranchName}
|
||||
isGitCheckout={isGitCheckout}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
showWorkspaceSetup={showWorkspaceSetup}
|
||||
isMobile={isMobile}
|
||||
createTerminalDisabled={createTerminalDisabled}
|
||||
menuNewAgentIcon={menuNewAgentIcon}
|
||||
menuNewTerminalIcon={menuNewTerminalIcon}
|
||||
menuCopyIcon={menuCopyIcon}
|
||||
menuSettingsIcon={menuSettingsIcon}
|
||||
onCreateDraftTab={handleCreateDraftTab}
|
||||
onCreateTerminal={handleCreateTerminal}
|
||||
onCopyWorkspacePath={handleCopyWorkspacePath}
|
||||
onCopyBranchName={handleCopyBranchName}
|
||||
onOpenSetupTab={handleOpenSetupTab}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
right={headerRight}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isMobile ? (
|
||||
<MobileWorkspaceTabSwitcher
|
||||
tabs={tabs}
|
||||
activeTabKey={activeTabKey}
|
||||
activeTab={activeTabDescriptor}
|
||||
tabSwitcherOptions={tabSwitcherOptions}
|
||||
tabByKey={tabByKey}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
onSelectSwitcherTab={handleSelectSwitcherTab}
|
||||
onCopyResumeCommand={handleCopyResumeCommand}
|
||||
onCopyAgentId={handleCopyAgentId}
|
||||
onReloadAgent={handleReloadAgent}
|
||||
onCloseTab={handleCloseTabById}
|
||||
onCloseTabsAbove={handleCloseTabsToLeft}
|
||||
onCloseTabsBelow={handleCloseTabsToRight}
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{shouldRenderDesktopPaneFallback ? (
|
||||
<WorkspaceDesktopTabsRow
|
||||
paneId={focusedPaneIdOrUndefined}
|
||||
isFocused={isRouteFocused}
|
||||
tabs={desktopTabRowItems}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
setHoveredTabKey={setHoveredTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
onNavigateTab={navigateToTabId}
|
||||
onCloseTab={handleCloseTabById}
|
||||
onCopyResumeCommand={handleCopyResumeCommand}
|
||||
onCopyAgentId={handleCopyAgentId}
|
||||
onReloadAgent={handleReloadAgent}
|
||||
onCloseTabsToLeft={handleCloseTabsToLeft}
|
||||
onCloseTabsToRight={handleCloseTabsToRight}
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
onCreateDraftTab={handleCreateDraftTab}
|
||||
onCreateTerminalTab={handleCreateTerminal}
|
||||
disableCreateTerminal={createTerminalMutation.isPending}
|
||||
isWaitingOnTerminalReadiness={pendingTerminalCreateInput !== null}
|
||||
onReorderTabs={handleReorderTabsInFocusedPane}
|
||||
onSplitRight={noop}
|
||||
onSplitDown={noop}
|
||||
showPaneSplitActions={false}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<View style={styles.centerContent}>
|
||||
{isMobile ? (
|
||||
<GestureDetector gesture={explorerOpenGesture} touchAction="pan-y">
|
||||
<View style={styles.content}>{content}</View>
|
||||
</GestureDetector>
|
||||
) : (
|
||||
<View style={styles.content}>{desktopContent}</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{showExplorerSidebar && workspaceDirectory ? (
|
||||
<ExplorerSidebar
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
workspaceRoot={workspaceDirectory}
|
||||
isGit={isGitCheckout}
|
||||
onOpenFile={handleOpenFileFromExplorer}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</WorkspaceFocusProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3154,10 +3157,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
newTabTooltipShortcut: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
newTabTooltipShortcut: {},
|
||||
explorerTooltipRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -3167,10 +3167,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.popoverForeground,
|
||||
},
|
||||
explorerTooltipShortcut: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
explorerTooltipShortcut: {},
|
||||
mobileTabsRow: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderBottomWidth: theme.borderWidth[1],
|
||||
|
||||
@@ -23,7 +23,7 @@ export type SplitNode = { kind: "pane"; pane: SplitPane } | { kind: "group"; gro
|
||||
|
||||
export interface WorkspaceLayout {
|
||||
root: SplitNode;
|
||||
focusedPaneId: string;
|
||||
focusedPaneId: string | null;
|
||||
}
|
||||
|
||||
interface SplitPaneInternal extends SplitPane {
|
||||
@@ -398,9 +398,9 @@ function asInternalNode(node: SplitNode): SplitNodeInternal {
|
||||
|
||||
function asInternalLayout(layout: WorkspaceLayout): {
|
||||
root: SplitNodeInternal;
|
||||
focusedPaneId: string;
|
||||
focusedPaneId: string | null;
|
||||
} {
|
||||
return layout as { root: SplitNodeInternal; focusedPaneId: string };
|
||||
return layout as { root: SplitNodeInternal; focusedPaneId: string | null };
|
||||
}
|
||||
|
||||
function findPanePathById(
|
||||
@@ -899,11 +899,14 @@ export function normalizeLayout(layout: unknown): WorkspaceLayout {
|
||||
|
||||
const rawLayout = layout as WorkspaceLayout;
|
||||
const root = normalizeNode(rawLayout.root) ?? asInternalNode(createDefaultLayout().root);
|
||||
const focusedPaneId = trimNonEmpty(rawLayout.focusedPaneId);
|
||||
const focusedPaneId =
|
||||
rawLayout.focusedPaneId === null ? null : trimNonEmpty(rawLayout.focusedPaneId);
|
||||
const resolvedFocusedPaneId =
|
||||
(focusedPaneId && findPaneById(root, focusedPaneId)?.id) ??
|
||||
collectAllPanes(root)[0]?.id ??
|
||||
DEFAULT_PANE_ID;
|
||||
focusedPaneId === null
|
||||
? null
|
||||
: ((focusedPaneId && findPaneById(root, focusedPaneId)?.id) ??
|
||||
collectAllPanes(root)[0]?.id ??
|
||||
DEFAULT_PANE_ID);
|
||||
|
||||
return {
|
||||
root,
|
||||
@@ -911,7 +914,10 @@ export function normalizeLayout(layout: unknown): WorkspaceLayout {
|
||||
};
|
||||
}
|
||||
|
||||
export function findPaneById(root: SplitNode, paneId: string): SplitPane | null {
|
||||
export function findPaneById(root: SplitNode, paneId: string | null | undefined): SplitPane | null {
|
||||
if (!paneId) {
|
||||
return null;
|
||||
}
|
||||
const internalRoot = asInternalNode(root);
|
||||
if (internalRoot.kind === "pane") {
|
||||
return internalRoot.pane.id === paneId ? internalRoot.pane : null;
|
||||
@@ -1074,10 +1080,12 @@ export function closeTabInLayout(input: CloseTabInLayoutInput): WorkspaceLayout
|
||||
const fallbackPaneId = findNearestSiblingPaneId(internalLayout.root, pane.id);
|
||||
const nextRoot = removeTabFromTree(internalLayout.root, input.tabId) as SplitNodeInternal;
|
||||
const nextFocusedPaneId =
|
||||
findPaneById(nextRoot, internalLayout.focusedPaneId)?.id ??
|
||||
(fallbackPaneId && findPaneById(nextRoot, fallbackPaneId)?.id) ??
|
||||
collectAllPanes(nextRoot)[0]?.id ??
|
||||
DEFAULT_PANE_ID;
|
||||
internalLayout.focusedPaneId === null
|
||||
? null
|
||||
: (findPaneById(nextRoot, internalLayout.focusedPaneId)?.id ??
|
||||
(fallbackPaneId && findPaneById(nextRoot, fallbackPaneId)?.id) ??
|
||||
collectAllPanes(nextRoot)[0]?.id ??
|
||||
DEFAULT_PANE_ID);
|
||||
|
||||
return {
|
||||
root: nextRoot,
|
||||
@@ -1204,7 +1212,7 @@ export function reorderFocusedPaneTabsInLayout(
|
||||
input: ReorderFocusedPaneTabsInLayoutInput,
|
||||
): WorkspaceLayout | null {
|
||||
const layout = asInternalLayout(input.layout);
|
||||
if (!findPaneById(layout.root, layout.focusedPaneId)) {
|
||||
if (!layout.focusedPaneId || !findPaneById(layout.root, layout.focusedPaneId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -226,6 +226,7 @@ describe("workspace-layout-store actions", () => {
|
||||
splitSizesByWorkspace: {},
|
||||
pinnedAgentIdsByWorkspace: {},
|
||||
hiddenAgentIdsByWorkspace: {},
|
||||
focusRestorationByWorkspace: {},
|
||||
});
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -313,6 +314,66 @@ describe("workspace-layout-store actions", () => {
|
||||
expect(pane.focusedTabId).toBe(secondTabId);
|
||||
});
|
||||
|
||||
it("unfocuses and restores the previous focused pane", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
|
||||
const token = store.unfocusPane(workspaceKey);
|
||||
expect(token).toBeTruthy();
|
||||
expect(
|
||||
useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId,
|
||||
).toBeNull();
|
||||
|
||||
store.restorePaneFocus(workspaceKey, token!);
|
||||
expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
|
||||
"main",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not restore stale focus after another pane is focused", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||
);
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
const firstTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" });
|
||||
store.splitPane(workspaceKey, {
|
||||
tabId: firstTabId!,
|
||||
targetPaneId: "main",
|
||||
position: "right",
|
||||
});
|
||||
store.focusPane(workspaceKey, "main");
|
||||
|
||||
const token = store.unfocusPane(workspaceKey);
|
||||
store.focusPane(workspaceKey, "pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||
store.restorePaneFocus(workspaceKey, token!);
|
||||
|
||||
expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
|
||||
"pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||
);
|
||||
});
|
||||
|
||||
it("waits for nested focus restorations before restoring", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
|
||||
const outerToken = store.unfocusPane(workspaceKey);
|
||||
const innerToken = store.unfocusPane(workspaceKey);
|
||||
|
||||
store.restorePaneFocus(workspaceKey, outerToken!);
|
||||
expect(
|
||||
useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId,
|
||||
).toBeNull();
|
||||
|
||||
store.restorePaneFocus(workspaceKey, innerToken!);
|
||||
expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
|
||||
"main",
|
||||
);
|
||||
});
|
||||
|
||||
it("openTab creates distinct draft tabs for repeated Cmd+T/new-tab opens", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
@@ -68,6 +68,7 @@ interface WorkspaceLayoutStore {
|
||||
splitSizesByWorkspace: Record<string, Record<string, number[]>>;
|
||||
pinnedAgentIdsByWorkspace: Record<string, Set<string>>;
|
||||
hiddenAgentIdsByWorkspace: Record<string, Set<string>>;
|
||||
focusRestorationByWorkspace: Record<string, WorkspaceFocusRestorationState>;
|
||||
openTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
|
||||
openTabInBackground: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
|
||||
closeTab: (workspaceKey: string, tabId: string) => void;
|
||||
@@ -94,6 +95,8 @@ interface WorkspaceLayoutStore {
|
||||
) => string | null;
|
||||
moveTabToPane: (workspaceKey: string, tabId: string, toPaneId: string) => void;
|
||||
focusPane: (workspaceKey: string, paneId: string) => void;
|
||||
unfocusPane: (workspaceKey: string) => string | null;
|
||||
restorePaneFocus: (workspaceKey: string, token: string) => void;
|
||||
resizeSplit: (workspaceKey: string, groupId: string, sizes: number[]) => void;
|
||||
reorderTabsInPane: (workspaceKey: string, paneId: string, tabIds: string[]) => void;
|
||||
pinAgent: (workspaceKey: string, agentId: string) => void;
|
||||
@@ -103,6 +106,11 @@ interface WorkspaceLayoutStore {
|
||||
purgeWorkspace: (workspaceKey: string) => void;
|
||||
}
|
||||
|
||||
interface WorkspaceFocusRestorationState {
|
||||
restorePaneId: string | null;
|
||||
tokens: string[];
|
||||
}
|
||||
|
||||
const MAX_TREE_DEPTH = 4;
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
@@ -162,6 +170,26 @@ function getWorkspaceLayout(
|
||||
return normalizeLayout(state[workspaceKey] ?? createDefaultLayout());
|
||||
}
|
||||
|
||||
function createFocusRestorationToken(): string {
|
||||
const randomValue =
|
||||
typeof globalThis.crypto?.randomUUID === "function"
|
||||
? globalThis.crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
return `workspace-focus-${randomValue}`;
|
||||
}
|
||||
|
||||
function withoutFocusRestoration(
|
||||
state: WorkspaceLayoutStore,
|
||||
workspaceKey: string,
|
||||
): Pick<WorkspaceLayoutStore, "focusRestorationByWorkspace"> | null {
|
||||
if (!(workspaceKey in state.focusRestorationByWorkspace)) {
|
||||
return null;
|
||||
}
|
||||
const { [workspaceKey]: _removed, ...focusRestorationByWorkspace } =
|
||||
state.focusRestorationByWorkspace;
|
||||
return { focusRestorationByWorkspace };
|
||||
}
|
||||
|
||||
export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
@@ -169,6 +197,7 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||
splitSizesByWorkspace: {},
|
||||
pinnedAgentIdsByWorkspace: {},
|
||||
hiddenAgentIdsByWorkspace: {},
|
||||
focusRestorationByWorkspace: {},
|
||||
openTabFocused: (workspaceKey, target) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
||||
@@ -183,6 +212,7 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||
});
|
||||
|
||||
set((state) => ({
|
||||
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||
hiddenAgentIdsByWorkspace:
|
||||
normalizedTarget.kind !== "agent"
|
||||
? state.hiddenAgentIdsByWorkspace
|
||||
@@ -246,6 +276,7 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||
}
|
||||
|
||||
return {
|
||||
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: nextLayout,
|
||||
@@ -270,6 +301,7 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||
}
|
||||
|
||||
return {
|
||||
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: nextLayout,
|
||||
@@ -295,6 +327,9 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
...(result.layout.focusedPaneId !== null
|
||||
? (withoutFocusRestoration(state, normalizedWorkspaceKey) ?? {})
|
||||
: {}),
|
||||
hiddenAgentIdsByWorkspace:
|
||||
normalizedTarget.kind !== "agent"
|
||||
? state.hiddenAgentIdsByWorkspace
|
||||
@@ -329,6 +364,9 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
...(result.layout.focusedPaneId !== null
|
||||
? (withoutFocusRestoration(state, normalizedWorkspaceKey) ?? {})
|
||||
: {}),
|
||||
hiddenAgentIdsByWorkspace: removeAgentIdFromWorkspaceSet(
|
||||
state.hiddenAgentIdsByWorkspace,
|
||||
normalizedWorkspaceKey,
|
||||
@@ -429,6 +467,7 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: result.layout,
|
||||
@@ -462,6 +501,7 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: result.layout,
|
||||
@@ -489,6 +529,7 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||
}
|
||||
|
||||
return {
|
||||
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: nextLayout,
|
||||
@@ -513,6 +554,7 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||
}
|
||||
|
||||
return {
|
||||
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: nextLayout,
|
||||
@@ -520,6 +562,90 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||
};
|
||||
});
|
||||
},
|
||||
unfocusPane: (workspaceKey) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
if (!normalizedWorkspaceKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = createFocusRestorationToken();
|
||||
set((state) => {
|
||||
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
||||
const currentRestoration = state.focusRestorationByWorkspace[normalizedWorkspaceKey];
|
||||
const restorePaneId = currentRestoration?.restorePaneId ?? layout.focusedPaneId;
|
||||
|
||||
return {
|
||||
focusRestorationByWorkspace: {
|
||||
...state.focusRestorationByWorkspace,
|
||||
[normalizedWorkspaceKey]: {
|
||||
restorePaneId,
|
||||
tokens: [...(currentRestoration?.tokens ?? []), token],
|
||||
},
|
||||
},
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]:
|
||||
layout.focusedPaneId === null ? layout : { ...layout, focusedPaneId: null },
|
||||
},
|
||||
};
|
||||
});
|
||||
return token;
|
||||
},
|
||||
restorePaneFocus: (workspaceKey, token) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedToken = trimNonEmpty(token);
|
||||
if (!normalizedWorkspaceKey || !normalizedToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const restoration = state.focusRestorationByWorkspace[normalizedWorkspaceKey];
|
||||
if (!restoration?.tokens.includes(normalizedToken)) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const nextTokens = restoration.tokens.filter((entry) => entry !== normalizedToken);
|
||||
const { [normalizedWorkspaceKey]: _removed, ...remainingRestorations } =
|
||||
state.focusRestorationByWorkspace;
|
||||
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
||||
|
||||
if (layout.focusedPaneId !== null) {
|
||||
return {
|
||||
focusRestorationByWorkspace: remainingRestorations,
|
||||
};
|
||||
}
|
||||
|
||||
if (nextTokens.length > 0) {
|
||||
return {
|
||||
focusRestorationByWorkspace: {
|
||||
...remainingRestorations,
|
||||
[normalizedWorkspaceKey]: {
|
||||
restorePaneId: restoration.restorePaneId,
|
||||
tokens: nextTokens,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const restorePaneId = findPaneById(layout.root, restoration.restorePaneId)?.id ?? null;
|
||||
if (!restorePaneId) {
|
||||
return {
|
||||
focusRestorationByWorkspace: remainingRestorations,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
focusRestorationByWorkspace: remainingRestorations,
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: {
|
||||
...layout,
|
||||
focusedPaneId: restorePaneId,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
resizeSplit: (workspaceKey, groupId, sizes) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedGroupId = trimNonEmpty(groupId);
|
||||
@@ -555,6 +681,7 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||
}
|
||||
|
||||
return {
|
||||
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: nextLayout,
|
||||
@@ -682,7 +809,8 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||
normalizedWorkspaceKey in state.layoutByWorkspace ||
|
||||
normalizedWorkspaceKey in state.splitSizesByWorkspace ||
|
||||
normalizedWorkspaceKey in state.pinnedAgentIdsByWorkspace ||
|
||||
normalizedWorkspaceKey in state.hiddenAgentIdsByWorkspace;
|
||||
normalizedWorkspaceKey in state.hiddenAgentIdsByWorkspace ||
|
||||
normalizedWorkspaceKey in state.focusRestorationByWorkspace;
|
||||
if (!hasAny) {
|
||||
return state;
|
||||
}
|
||||
@@ -694,11 +822,14 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||
state.pinnedAgentIdsByWorkspace;
|
||||
const { [normalizedWorkspaceKey]: _hidden, ...hiddenAgentIdsByWorkspace } =
|
||||
state.hiddenAgentIdsByWorkspace;
|
||||
const { [normalizedWorkspaceKey]: _restoration, ...focusRestorationByWorkspace } =
|
||||
state.focusRestorationByWorkspace;
|
||||
return {
|
||||
layoutByWorkspace,
|
||||
splitSizesByWorkspace,
|
||||
pinnedAgentIdsByWorkspace,
|
||||
hiddenAgentIdsByWorkspace,
|
||||
focusRestorationByWorkspace,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ export type ShortcutOs = "mac" | "non-mac";
|
||||
|
||||
const KEY_DISPLAY: Record<string, string> = {
|
||||
Backspace: "⌫",
|
||||
Enter: "↩",
|
||||
Enter: "⏎",
|
||||
Esc: "Esc",
|
||||
Space: "␣",
|
||||
Left: "←",
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
|
||||
interface WorkspaceFocusContextValue {
|
||||
workspaceKey: string | null;
|
||||
unfocusPane: (workspaceKey: string) => string | null;
|
||||
restorePaneFocus: (workspaceKey: string, token: string) => void;
|
||||
}
|
||||
|
||||
export interface WorkspaceFocusRestoration {
|
||||
unfocus: () => void;
|
||||
restore: () => void;
|
||||
}
|
||||
|
||||
const WorkspaceFocusContext = createContext<WorkspaceFocusContextValue | null>(null);
|
||||
|
||||
const noopFocusRestoration: WorkspaceFocusRestoration = {
|
||||
unfocus: () => {},
|
||||
restore: () => {},
|
||||
};
|
||||
|
||||
export function WorkspaceFocusProvider({
|
||||
workspaceKey,
|
||||
children,
|
||||
}: {
|
||||
workspaceKey: string | null;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const unfocusPane = useWorkspaceLayoutStore((state) => state.unfocusPane);
|
||||
const restorePaneFocus = useWorkspaceLayoutStore((state) => state.restorePaneFocus);
|
||||
const value = useMemo<WorkspaceFocusContextValue>(
|
||||
() => ({
|
||||
workspaceKey,
|
||||
unfocusPane,
|
||||
restorePaneFocus,
|
||||
}),
|
||||
[restorePaneFocus, unfocusPane, workspaceKey],
|
||||
);
|
||||
|
||||
return <WorkspaceFocusContext.Provider value={value}>{children}</WorkspaceFocusContext.Provider>;
|
||||
}
|
||||
|
||||
export function useWorkspaceFocusRestoration(): WorkspaceFocusRestoration {
|
||||
const context = useContext(WorkspaceFocusContext);
|
||||
const tokenRef = useRef<string | null>(null);
|
||||
|
||||
const restore = useCallback(() => {
|
||||
const token = tokenRef.current;
|
||||
if (!context?.workspaceKey || !token) {
|
||||
tokenRef.current = null;
|
||||
return;
|
||||
}
|
||||
tokenRef.current = null;
|
||||
context.restorePaneFocus(context.workspaceKey, token);
|
||||
}, [context]);
|
||||
|
||||
const unfocus = useCallback(() => {
|
||||
if (!context?.workspaceKey || tokenRef.current) {
|
||||
return;
|
||||
}
|
||||
tokenRef.current = context.unfocusPane(context.workspaceKey);
|
||||
}, [context]);
|
||||
|
||||
useEffect(() => restore, [restore]);
|
||||
|
||||
return context ? { unfocus, restore } : noopFocusRestoration;
|
||||
}
|
||||
Reference in New Issue
Block a user