Improve review comment UI
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
|
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
|
||||||
import { Text, View } from "react-native";
|
import { Text, View } from "react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import { CircleDot } from "lucide-react-native";
|
import { MessageSquareCode } from "lucide-react-native";
|
||||||
import type {
|
import type {
|
||||||
ComposerAttachment,
|
ComposerAttachment,
|
||||||
UserComposerAttachment,
|
UserComposerAttachment,
|
||||||
@@ -237,7 +237,7 @@ function WorkspaceAttachmentPill({
|
|||||||
>
|
>
|
||||||
<View style={styles.pillBody}>
|
<View style={styles.pillBody}>
|
||||||
<View style={styles.pillIcon}>
|
<View style={styles.pillIcon}>
|
||||||
<ThemedCircleDot size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
<ThemedMessageSquareCode size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.pillText} numberOfLines={1}>
|
<Text style={styles.pillText} numberOfLines={1}>
|
||||||
{label}
|
{label}
|
||||||
@@ -279,5 +279,5 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
|||||||
},
|
},
|
||||||
})) as unknown as Record<string, object>;
|
})) as unknown as Record<string, object>;
|
||||||
|
|
||||||
const ThemedCircleDot = withUnistyles(CircleDot);
|
const ThemedMessageSquareCode = withUnistyles(MessageSquareCode);
|
||||||
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const {
|
|||||||
fontSize: { xs: 11, sm: 13, base: 15, lg: 18 },
|
fontSize: { xs: 11, sm: 13, base: 15, lg: 18 },
|
||||||
fontWeight: { normal: "400", medium: "500" },
|
fontWeight: { normal: "400", medium: "500" },
|
||||||
lineHeight: { diff: 18 },
|
lineHeight: { diff: 18 },
|
||||||
|
opacity: { 50: 0.5 },
|
||||||
shadow: { md: {} },
|
shadow: { md: {} },
|
||||||
colors: {
|
colors: {
|
||||||
surface0: "#000",
|
surface0: "#000",
|
||||||
@@ -226,6 +227,7 @@ vi.mock("lucide-react-native", () => {
|
|||||||
AudioLines: createIcon("AudioLines"),
|
AudioLines: createIcon("AudioLines"),
|
||||||
CircleDot: createIcon("CircleDot"),
|
CircleDot: createIcon("CircleDot"),
|
||||||
GitPullRequest: createIcon("GitPullRequest"),
|
GitPullRequest: createIcon("GitPullRequest"),
|
||||||
|
MessageSquareCode: createIcon("MessageSquareCode"),
|
||||||
X: createIcon("X"),
|
X: createIcon("X"),
|
||||||
Mic: createIcon("Mic"),
|
Mic: createIcon("Mic"),
|
||||||
MicOff: createIcon("MicOff"),
|
MicOff: createIcon("MicOff"),
|
||||||
|
|||||||
@@ -853,9 +853,7 @@ function ComposerCancelButton({
|
|||||||
) : (
|
) : (
|
||||||
<Square size={buttonIconSize} color="white" fill="white" />
|
<Square size={buttonIconSize} color="white" fill="white" />
|
||||||
);
|
);
|
||||||
const shortcutNode = agentInterruptKeys ? (
|
const shortcutNode = agentInterruptKeys ? <Shortcut chord={agentInterruptKeys} /> : null;
|
||||||
<Shortcut chord={agentInterruptKeys} style={styles.tooltipShortcut} />
|
|
||||||
) : null;
|
|
||||||
return (
|
return (
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
@@ -941,9 +939,7 @@ function ComposerVoiceModeButton({
|
|||||||
realtimeVoiceButtonStyle,
|
realtimeVoiceButtonStyle,
|
||||||
voiceToggleKeys,
|
voiceToggleKeys,
|
||||||
}: ComposerVoiceModeButtonProps) {
|
}: ComposerVoiceModeButtonProps) {
|
||||||
const shortcutNode = voiceToggleKeys ? (
|
const shortcutNode = voiceToggleKeys ? <Shortcut chord={voiceToggleKeys} /> : null;
|
||||||
<Shortcut chord={voiceToggleKeys} style={styles.tooltipShortcut} />
|
|
||||||
) : null;
|
|
||||||
const renderTriggerContent = useCallback(
|
const renderTriggerContent = useCallback(
|
||||||
({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => {
|
({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => {
|
||||||
if (isVoiceSwitching) {
|
if (isVoiceSwitching) {
|
||||||
@@ -1874,10 +1870,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
|||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
color: theme.colors.popoverForeground,
|
color: theme.colors.popoverForeground,
|
||||||
},
|
},
|
||||||
tooltipShortcut: {
|
|
||||||
backgroundColor: theme.colors.surface3,
|
|
||||||
borderColor: theme.colors.borderAccent,
|
|
||||||
},
|
|
||||||
buttonDisabled: {
|
buttonDisabled: {
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -138,7 +138,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
tooltipTitle: {
|
tooltipTitle: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontWeight: theme.fontWeight.semibold,
|
|
||||||
},
|
},
|
||||||
tooltipText: {
|
tooltipText: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
|
|||||||
@@ -231,35 +231,59 @@ const EMPTY_COMMENTS: readonly ReviewDraftComment[] = [];
|
|||||||
|
|
||||||
function noopStartComment(): void {}
|
function noopStartComment(): void {}
|
||||||
|
|
||||||
const LONG_PRESS_COMMENT_DELAY_MS = 350;
|
const DIFF_LINE_HOVER_STYLE = isWeb ? ({ cursor: "auto" } as const) : null;
|
||||||
|
|
||||||
function LongPressableLine({
|
function LongPressableLine({
|
||||||
reviewTarget,
|
reviewTarget,
|
||||||
reviewActions,
|
reviewActions,
|
||||||
|
onHoverChange,
|
||||||
|
hoverTargetKey,
|
||||||
|
onHoverTargetChange,
|
||||||
style,
|
style,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
reviewTarget: ReviewableDiffTarget | null | undefined;
|
reviewTarget: ReviewableDiffTarget | null | undefined;
|
||||||
reviewActions: InlineReviewActions | undefined;
|
reviewActions: InlineReviewActions | undefined;
|
||||||
|
onHoverChange?: (hovered: boolean) => void;
|
||||||
|
hoverTargetKey?: string | null;
|
||||||
|
onHoverTargetChange?: (key: string | null) => void;
|
||||||
style: StyleProp<ViewStyle>;
|
style: StyleProp<ViewStyle>;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const onStartComment = reviewActions?.onStartComment;
|
const onStartComment = reviewActions?.onStartComment;
|
||||||
const handleLongPress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
if (reviewTarget && onStartComment) {
|
if (reviewTarget && onStartComment) {
|
||||||
onStartComment(reviewTarget);
|
onStartComment(reviewTarget);
|
||||||
}
|
}
|
||||||
}, [reviewTarget, onStartComment]);
|
}, [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) {
|
if (!isNative || !reviewTarget || !onStartComment) {
|
||||||
return <View style={style}>{children}</View>;
|
return <View style={style}>{children}</View>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable onPress={handlePress} style={style}>
|
||||||
onLongPress={handleLongPress}
|
|
||||||
delayLongPress={LONG_PRESS_COMMENT_DELAY_MS}
|
|
||||||
style={style}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
@@ -279,6 +303,7 @@ function DiffGutterCell({
|
|||||||
gutterWidth,
|
gutterWidth,
|
||||||
reviewTarget,
|
reviewTarget,
|
||||||
reviewActions,
|
reviewActions,
|
||||||
|
isLineHovered,
|
||||||
style,
|
style,
|
||||||
}: {
|
}: {
|
||||||
lineNumber: number | null;
|
lineNumber: number | null;
|
||||||
@@ -286,6 +311,7 @@ function DiffGutterCell({
|
|||||||
gutterWidth: number;
|
gutterWidth: number;
|
||||||
reviewTarget?: ReviewableDiffTarget | null;
|
reviewTarget?: ReviewableDiffTarget | null;
|
||||||
reviewActions?: InlineReviewActions;
|
reviewActions?: InlineReviewActions;
|
||||||
|
isLineHovered?: boolean;
|
||||||
style?: StyleProp<ViewStyle>;
|
style?: StyleProp<ViewStyle>;
|
||||||
}) {
|
}) {
|
||||||
const containerStyle = useMemo(
|
const containerStyle = useMemo(
|
||||||
@@ -315,6 +341,7 @@ function DiffGutterCell({
|
|||||||
reviewTarget={reviewTarget}
|
reviewTarget={reviewTarget}
|
||||||
comments={comments}
|
comments={comments}
|
||||||
isEditorOpen={isEditorOpen}
|
isEditorOpen={isEditorOpen}
|
||||||
|
isLineHovered={isLineHovered}
|
||||||
onStartComment={onStartComment}
|
onStartComment={onStartComment}
|
||||||
style={containerStyle}
|
style={containerStyle}
|
||||||
>
|
>
|
||||||
@@ -330,11 +357,17 @@ function DiffTextLine({
|
|||||||
wrapLines,
|
wrapLines,
|
||||||
reviewTarget,
|
reviewTarget,
|
||||||
reviewActions,
|
reviewActions,
|
||||||
|
onHoverChange,
|
||||||
|
hoverTargetKey,
|
||||||
|
onHoverTargetChange,
|
||||||
}: {
|
}: {
|
||||||
line: DiffLine;
|
line: DiffLine;
|
||||||
wrapLines: boolean;
|
wrapLines: boolean;
|
||||||
reviewTarget?: ReviewableDiffTarget | null;
|
reviewTarget?: ReviewableDiffTarget | null;
|
||||||
reviewActions?: InlineReviewActions;
|
reviewActions?: InlineReviewActions;
|
||||||
|
onHoverChange?: (hovered: boolean) => void;
|
||||||
|
hoverTargetKey?: string | null;
|
||||||
|
onHoverTargetChange?: (key: string | null) => void;
|
||||||
}) {
|
}) {
|
||||||
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
||||||
|
|
||||||
@@ -358,6 +391,9 @@ function DiffTextLine({
|
|||||||
<LongPressableLine
|
<LongPressableLine
|
||||||
reviewTarget={reviewTarget}
|
reviewTarget={reviewTarget}
|
||||||
reviewActions={reviewActions}
|
reviewActions={reviewActions}
|
||||||
|
onHoverChange={onHoverChange}
|
||||||
|
hoverTargetKey={hoverTargetKey}
|
||||||
|
onHoverTargetChange={onHoverTargetChange}
|
||||||
style={containerStyle}
|
style={containerStyle}
|
||||||
>
|
>
|
||||||
{line.type !== "header" && visibleTokens ? (
|
{line.type !== "header" && visibleTokens ? (
|
||||||
@@ -373,10 +409,16 @@ function SplitTextLine({
|
|||||||
line,
|
line,
|
||||||
wrapLines,
|
wrapLines,
|
||||||
reviewActions,
|
reviewActions,
|
||||||
|
onHoverChange,
|
||||||
|
hoverTargetKey,
|
||||||
|
onHoverTargetChange,
|
||||||
}: {
|
}: {
|
||||||
line: SplitDiffDisplayLine | null;
|
line: SplitDiffDisplayLine | null;
|
||||||
wrapLines: boolean;
|
wrapLines: boolean;
|
||||||
reviewActions?: InlineReviewActions;
|
reviewActions?: InlineReviewActions;
|
||||||
|
onHoverChange?: (hovered: boolean) => void;
|
||||||
|
hoverTargetKey?: string | null;
|
||||||
|
onHoverTargetChange?: (key: string | null) => void;
|
||||||
}) {
|
}) {
|
||||||
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
||||||
|
|
||||||
@@ -400,6 +442,9 @@ function SplitTextLine({
|
|||||||
<LongPressableLine
|
<LongPressableLine
|
||||||
reviewTarget={line?.reviewTarget}
|
reviewTarget={line?.reviewTarget}
|
||||||
reviewActions={reviewActions}
|
reviewActions={reviewActions}
|
||||||
|
onHoverChange={onHoverChange}
|
||||||
|
hoverTargetKey={hoverTargetKey}
|
||||||
|
onHoverTargetChange={onHoverTargetChange}
|
||||||
style={containerStyle}
|
style={containerStyle}
|
||||||
>
|
>
|
||||||
{visibleTokens ? (
|
{visibleTokens ? (
|
||||||
@@ -426,6 +471,7 @@ function DiffLineView({
|
|||||||
reviewTarget?: ReviewableDiffTarget | null;
|
reviewTarget?: ReviewableDiffTarget | null;
|
||||||
reviewActions?: InlineReviewActions;
|
reviewActions?: InlineReviewActions;
|
||||||
}) {
|
}) {
|
||||||
|
const [isLineHovered, setIsLineHovered] = useState(false);
|
||||||
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
||||||
|
|
||||||
const containerStyle = useMemo(
|
const containerStyle = useMemo(
|
||||||
@@ -448,6 +494,7 @@ function DiffLineView({
|
|||||||
<LongPressableLine
|
<LongPressableLine
|
||||||
reviewTarget={reviewTarget}
|
reviewTarget={reviewTarget}
|
||||||
reviewActions={reviewActions}
|
reviewActions={reviewActions}
|
||||||
|
onHoverChange={setIsLineHovered}
|
||||||
style={containerStyle}
|
style={containerStyle}
|
||||||
>
|
>
|
||||||
<DiffGutterCell
|
<DiffGutterCell
|
||||||
@@ -456,6 +503,7 @@ function DiffLineView({
|
|||||||
gutterWidth={gutterWidth}
|
gutterWidth={gutterWidth}
|
||||||
reviewTarget={reviewTarget}
|
reviewTarget={reviewTarget}
|
||||||
reviewActions={reviewActions}
|
reviewActions={reviewActions}
|
||||||
|
isLineHovered={isLineHovered}
|
||||||
style={styles.lineNumberGutter}
|
style={styles.lineNumberGutter}
|
||||||
/>
|
/>
|
||||||
{line.type !== "header" && visibleTokens ? (
|
{line.type !== "header" && visibleTokens ? (
|
||||||
@@ -478,6 +526,7 @@ function SplitDiffLine({
|
|||||||
wrapLines: boolean;
|
wrapLines: boolean;
|
||||||
reviewActions?: InlineReviewActions;
|
reviewActions?: InlineReviewActions;
|
||||||
}) {
|
}) {
|
||||||
|
const [isLineHovered, setIsLineHovered] = useState(false);
|
||||||
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
||||||
|
|
||||||
const containerStyle = useMemo(
|
const containerStyle = useMemo(
|
||||||
@@ -500,6 +549,7 @@ function SplitDiffLine({
|
|||||||
<LongPressableLine
|
<LongPressableLine
|
||||||
reviewTarget={line?.reviewTarget}
|
reviewTarget={line?.reviewTarget}
|
||||||
reviewActions={reviewActions}
|
reviewActions={reviewActions}
|
||||||
|
onHoverChange={setIsLineHovered}
|
||||||
style={containerStyle}
|
style={containerStyle}
|
||||||
>
|
>
|
||||||
<DiffGutterCell
|
<DiffGutterCell
|
||||||
@@ -508,6 +558,7 @@ function SplitDiffLine({
|
|||||||
gutterWidth={gutterWidth}
|
gutterWidth={gutterWidth}
|
||||||
reviewTarget={line?.reviewTarget}
|
reviewTarget={line?.reviewTarget}
|
||||||
reviewActions={reviewActions}
|
reviewActions={reviewActions}
|
||||||
|
isLineHovered={isLineHovered}
|
||||||
style={styles.lineNumberGutter}
|
style={styles.lineNumberGutter}
|
||||||
/>
|
/>
|
||||||
{visibleTokens ? (
|
{visibleTokens ? (
|
||||||
@@ -635,6 +686,7 @@ function SplitDiffColumn({
|
|||||||
showDivider?: boolean;
|
showDivider?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [scrollWidth, setScrollWidth] = useState(0);
|
const [scrollWidth, setScrollWidth] = useState(0);
|
||||||
|
const [hoveredReviewTargetKey, setHoveredReviewTargetKey] = useState<string | null>(null);
|
||||||
|
|
||||||
const wrapCellStyle = useMemo(
|
const wrapCellStyle = useMemo(
|
||||||
() => [styles.splitCell, showDivider && styles.splitCellWithDivider],
|
() => [styles.splitCell, showDivider && styles.splitCellWithDivider],
|
||||||
@@ -701,6 +753,7 @@ function SplitDiffColumn({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const line = side === "left" ? row.left : row.right;
|
const line = side === "left" ? row.left : row.right;
|
||||||
|
const reviewTargetKey = line?.reviewTarget?.key ?? null;
|
||||||
const reviewRowState = getSplitInlineReviewThreadState({
|
const reviewRowState = getSplitInlineReviewThreadState({
|
||||||
left: row.left?.reviewTarget,
|
left: row.left?.reviewTarget,
|
||||||
right: row.right?.reviewTarget,
|
right: row.right?.reviewTarget,
|
||||||
@@ -714,6 +767,9 @@ function SplitDiffColumn({
|
|||||||
gutterWidth={gutterWidth}
|
gutterWidth={gutterWidth}
|
||||||
reviewTarget={line?.reviewTarget}
|
reviewTarget={line?.reviewTarget}
|
||||||
reviewActions={reviewActions}
|
reviewActions={reviewActions}
|
||||||
|
isLineHovered={
|
||||||
|
reviewTargetKey !== null && hoveredReviewTargetKey === reviewTargetKey
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<InlineReviewGutterSpacer
|
<InlineReviewGutterSpacer
|
||||||
reviewTarget={line?.reviewTarget}
|
reviewTarget={line?.reviewTarget}
|
||||||
@@ -741,6 +797,7 @@ function SplitDiffColumn({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const line = side === "left" ? row.left : row.right;
|
const line = side === "left" ? row.left : row.right;
|
||||||
|
const reviewTargetKey = line?.reviewTarget?.key ?? null;
|
||||||
const reviewRowState = getSplitInlineReviewThreadState({
|
const reviewRowState = getSplitInlineReviewThreadState({
|
||||||
left: row.left?.reviewTarget,
|
left: row.left?.reviewTarget,
|
||||||
right: row.right?.reviewTarget,
|
right: row.right?.reviewTarget,
|
||||||
@@ -748,7 +805,13 @@ function SplitDiffColumn({
|
|||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<View key={key}>
|
<View key={key}>
|
||||||
<SplitTextLine line={line} wrapLines={false} reviewActions={reviewActions} />
|
<SplitTextLine
|
||||||
|
line={line}
|
||||||
|
wrapLines={false}
|
||||||
|
reviewActions={reviewActions}
|
||||||
|
hoverTargetKey={reviewTargetKey}
|
||||||
|
onHoverTargetChange={setHoveredReviewTargetKey}
|
||||||
|
/>
|
||||||
<InlineReviewThreadContent
|
<InlineReviewThreadContent
|
||||||
reviewTarget={line?.reviewTarget}
|
reviewTarget={line?.reviewTarget}
|
||||||
reviewActions={reviewActions}
|
reviewActions={reviewActions}
|
||||||
@@ -872,6 +935,7 @@ function DiffFileBody({
|
|||||||
}) {
|
}) {
|
||||||
const [scrollViewWidth, setScrollViewWidth] = useState(0);
|
const [scrollViewWidth, setScrollViewWidth] = useState(0);
|
||||||
const [bodyWidth, setBodyWidth] = useState(0);
|
const [bodyWidth, setBodyWidth] = useState(0);
|
||||||
|
const [hoveredReviewTargetKey, setHoveredReviewTargetKey] = useState<string | null>(null);
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
const handleLayout = useCallback(
|
const handleLayout = useCallback(
|
||||||
@@ -975,6 +1039,9 @@ function DiffFileBody({
|
|||||||
gutterWidth={gutterWidth}
|
gutterWidth={gutterWidth}
|
||||||
reviewTarget={reviewTarget}
|
reviewTarget={reviewTarget}
|
||||||
reviewActions={reviewActions}
|
reviewActions={reviewActions}
|
||||||
|
isLineHovered={
|
||||||
|
reviewTarget?.key !== undefined && hoveredReviewTargetKey === reviewTarget.key
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<InlineReviewGutterSpacer
|
<InlineReviewGutterSpacer
|
||||||
reviewTarget={reviewTarget}
|
reviewTarget={reviewTarget}
|
||||||
@@ -998,6 +1065,8 @@ function DiffFileBody({
|
|||||||
wrapLines={false}
|
wrapLines={false}
|
||||||
reviewTarget={reviewTarget}
|
reviewTarget={reviewTarget}
|
||||||
reviewActions={reviewActions}
|
reviewActions={reviewActions}
|
||||||
|
hoverTargetKey={reviewTarget?.key ?? null}
|
||||||
|
onHoverTargetChange={setHoveredReviewTargetKey}
|
||||||
/>
|
/>
|
||||||
<InlineReviewThreadContent
|
<InlineReviewThreadContent
|
||||||
reviewTarget={reviewTarget}
|
reviewTarget={reviewTarget}
|
||||||
@@ -2998,11 +3067,17 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
gutterColumn: {
|
gutterColumn: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface1,
|
||||||
|
zIndex: 4,
|
||||||
|
elevation: 4,
|
||||||
|
overflow: "visible",
|
||||||
},
|
},
|
||||||
gutterCell: {
|
gutterCell: {
|
||||||
borderRightWidth: theme.borderWidth[1],
|
borderRightWidth: theme.borderWidth[1],
|
||||||
borderRightColor: theme.colors.border,
|
borderRightColor: theme.colors.border,
|
||||||
justifyContent: "flex-start",
|
justifyContent: "flex-start",
|
||||||
|
zIndex: 4,
|
||||||
|
elevation: 4,
|
||||||
|
overflow: "visible",
|
||||||
},
|
},
|
||||||
inlineReviewRow: {
|
inlineReviewRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -3050,6 +3125,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
diffLineContainer: {
|
diffLineContainer: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "stretch",
|
alignItems: "stretch",
|
||||||
|
overflow: "visible",
|
||||||
},
|
},
|
||||||
lineNumberGutter: {
|
lineNumberGutter: {
|
||||||
borderRightWidth: theme.borderWidth[1],
|
borderRightWidth: theme.borderWidth[1],
|
||||||
@@ -3057,8 +3133,12 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
marginRight: theme.spacing[2],
|
marginRight: theme.spacing[2],
|
||||||
alignSelf: "stretch",
|
alignSelf: "stretch",
|
||||||
justifyContent: "flex-start",
|
justifyContent: "flex-start",
|
||||||
|
zIndex: 4,
|
||||||
|
elevation: 4,
|
||||||
|
overflow: "visible",
|
||||||
},
|
},
|
||||||
lineNumberText: {
|
lineNumberText: {
|
||||||
|
width: "100%",
|
||||||
textAlign: "right",
|
textAlign: "right",
|
||||||
paddingRight: theme.spacing[2],
|
paddingRight: theme.spacing[2],
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
|
|||||||
@@ -88,8 +88,5 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
color: theme.colors.popoverForeground,
|
color: theme.colors.popoverForeground,
|
||||||
},
|
},
|
||||||
shortcut: {
|
shortcut: {},
|
||||||
backgroundColor: theme.colors.surface3,
|
|
||||||
borderColor: theme.colors.borderAccent,
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -278,7 +278,7 @@ function VoiceTooltipBody({
|
|||||||
return (
|
return (
|
||||||
<View style={styles.tooltipRow}>
|
<View style={styles.tooltipRow}>
|
||||||
<Text style={styles.tooltipText}>{voiceTooltipText}</Text>
|
<Text style={styles.tooltipText}>{voiceTooltipText}</Text>
|
||||||
{shortcut ? <Shortcut chord={shortcut} style={styles.tooltipShortcut} /> : null}
|
{shortcut ? <Shortcut chord={shortcut} /> : null}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -293,7 +293,7 @@ function SendTooltipBody({
|
|||||||
return (
|
return (
|
||||||
<View style={styles.tooltipRow}>
|
<View style={styles.tooltipRow}>
|
||||||
<Text style={styles.tooltipText}>{label}</Text>
|
<Text style={styles.tooltipText}>{label}</Text>
|
||||||
{sendKeys ? <Shortcut chord={sendKeys} style={styles.tooltipShortcut} /> : null}
|
{sendKeys ? <Shortcut chord={sendKeys} /> : null}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1925,10 +1925,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
|||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
color: theme.colors.popoverForeground,
|
color: theme.colors.popoverForeground,
|
||||||
},
|
},
|
||||||
tooltipShortcut: {
|
|
||||||
backgroundColor: theme.colors.surface3,
|
|
||||||
borderColor: theme.colors.borderAccent,
|
|
||||||
},
|
|
||||||
buttonDisabled: {
|
buttonDisabled: {
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2724,10 +2724,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
},
|
},
|
||||||
projectActionTooltipShortcut: {
|
projectActionTooltipShortcut: {},
|
||||||
backgroundColor: theme.colors.surface3,
|
|
||||||
borderColor: theme.colors.borderAccent,
|
|
||||||
},
|
|
||||||
workspaceRow: {
|
workspaceRow: {
|
||||||
minHeight: 36,
|
minHeight: 36,
|
||||||
marginBottom: theme.spacing[1],
|
marginBottom: theme.spacing[1],
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ interface SplitPaneDropData {
|
|||||||
interface SplitNodeViewProps extends Omit<SplitContainerProps, "layout" | "onMoveTabToPane"> {
|
interface SplitNodeViewProps extends Omit<SplitContainerProps, "layout" | "onMoveTabToPane"> {
|
||||||
node: SplitNode;
|
node: SplitNode;
|
||||||
uiTabs: WorkspaceTab[];
|
uiTabs: WorkspaceTab[];
|
||||||
focusedPaneId: string;
|
focusedPaneId: string | null;
|
||||||
activeDragTabId: string | null;
|
activeDragTabId: string | null;
|
||||||
showDropZones: boolean;
|
showDropZones: boolean;
|
||||||
dropPreview: SplitDropZoneHover | null;
|
dropPreview: SplitDropZoneHover | null;
|
||||||
@@ -370,7 +370,7 @@ export function SplitContainer({
|
|||||||
if (!focusModeEnabled) {
|
if (!focusModeEnabled) {
|
||||||
return layout.root;
|
return layout.root;
|
||||||
}
|
}
|
||||||
const focusedPane = panesById.get(layout.focusedPaneId);
|
const focusedPane = layout.focusedPaneId ? panesById.get(layout.focusedPaneId) : null;
|
||||||
if (!focusedPane) {
|
if (!focusedPane) {
|
||||||
return layout.root;
|
return layout.root;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
|
default as React,
|
||||||
useCallback,
|
useCallback,
|
||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
type ComponentType,
|
type ComponentType,
|
||||||
type PropsWithChildren,
|
type PropsWithChildren,
|
||||||
type ReactElement,
|
type ReactElement,
|
||||||
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
||||||
import type {
|
import type {
|
||||||
@@ -17,7 +19,7 @@ import type {
|
|||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
|
||||||
type ButtonVariant = "default" | "secondary" | "outline" | "ghost" | "destructive";
|
type ButtonVariant = "default" | "secondary" | "outline" | "ghost" | "destructive";
|
||||||
type ButtonSize = "sm" | "md" | "lg";
|
type ButtonSize = "xs" | "sm" | "md" | "lg";
|
||||||
|
|
||||||
type LeftIcon =
|
type LeftIcon =
|
||||||
| ReactElement
|
| ReactElement
|
||||||
@@ -25,7 +27,7 @@ type LeftIcon =
|
|||||||
| ((color: string) => ReactElement)
|
| ((color: string) => ReactElement)
|
||||||
| null;
|
| 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) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
base: {
|
base: {
|
||||||
@@ -41,6 +43,12 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
paddingVertical: theme.spacing[3],
|
paddingVertical: theme.spacing[3],
|
||||||
paddingHorizontal: theme.spacing[4],
|
paddingHorizontal: theme.spacing[4],
|
||||||
},
|
},
|
||||||
|
xs: {
|
||||||
|
minHeight: 28,
|
||||||
|
paddingVertical: theme.spacing[1],
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
},
|
||||||
sm: {
|
sm: {
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
@@ -82,6 +90,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
},
|
},
|
||||||
|
textXs: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
},
|
||||||
textDefault: {
|
textDefault: {
|
||||||
color: theme.colors.palette.white,
|
color: theme.colors.palette.white,
|
||||||
},
|
},
|
||||||
@@ -101,6 +112,7 @@ export function Button({
|
|||||||
variant = "secondary",
|
variant = "secondary",
|
||||||
size = "md",
|
size = "md",
|
||||||
leftIcon,
|
leftIcon,
|
||||||
|
trailing,
|
||||||
style,
|
style,
|
||||||
textStyle,
|
textStyle,
|
||||||
disabled,
|
disabled,
|
||||||
@@ -112,6 +124,7 @@ export function Button({
|
|||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
size?: ButtonSize;
|
size?: ButtonSize;
|
||||||
leftIcon?: LeftIcon;
|
leftIcon?: LeftIcon;
|
||||||
|
trailing?: ReactNode;
|
||||||
style?: StyleProp<ViewStyle>;
|
style?: StyleProp<ViewStyle>;
|
||||||
textStyle?: StyleProp<TextStyle>;
|
textStyle?: StyleProp<TextStyle>;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
@@ -135,7 +148,9 @@ export function Button({
|
|||||||
}
|
}
|
||||||
|
|
||||||
let sizeStyle: ViewStyle;
|
let sizeStyle: ViewStyle;
|
||||||
if (size === "sm") {
|
if (size === "xs") {
|
||||||
|
sizeStyle = styles.xs;
|
||||||
|
} else if (size === "sm") {
|
||||||
sizeStyle = styles.sm;
|
sizeStyle = styles.sm;
|
||||||
} else if (size === "lg") {
|
} else if (size === "lg") {
|
||||||
sizeStyle = styles.lg;
|
sizeStyle = styles.lg;
|
||||||
@@ -162,13 +177,14 @@ export function Button({
|
|||||||
const resolvedTextStyle = useMemo(
|
const resolvedTextStyle = useMemo(
|
||||||
() => [
|
() => [
|
||||||
styles.text,
|
styles.text,
|
||||||
|
size === "xs" ? styles.textXs : null,
|
||||||
variant === "default" ? styles.textDefault : null,
|
variant === "default" ? styles.textDefault : null,
|
||||||
variant === "destructive" ? styles.textDestructive : null,
|
variant === "destructive" ? styles.textDestructive : null,
|
||||||
variant === "ghost" ? styles.textGhost : null,
|
variant === "ghost" ? styles.textGhost : null,
|
||||||
textStyle,
|
textStyle,
|
||||||
isGhostHovered ? styles.textGhostHovered : null,
|
isGhostHovered ? styles.textGhostHovered : null,
|
||||||
],
|
],
|
||||||
[variant, textStyle, isGhostHovered],
|
[size, variant, textStyle, isGhostHovered],
|
||||||
);
|
);
|
||||||
|
|
||||||
const accessibilityState = useMemo(
|
const accessibilityState = useMemo(
|
||||||
@@ -235,6 +251,7 @@ export function Button({
|
|||||||
>
|
>
|
||||||
{renderIcon()}
|
{renderIcon()}
|
||||||
{children != null ? <Text style={resolvedTextStyle}>{children}</Text> : null}
|
{children != null ? <Text style={resolvedTextStyle}>{children}</Text> : null}
|
||||||
|
{trailing}
|
||||||
</Pressable>
|
</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 { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut";
|
import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut";
|
||||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
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({
|
export function Shortcut({
|
||||||
keys,
|
keys,
|
||||||
chord,
|
chord,
|
||||||
@@ -53,9 +70,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
paddingHorizontal: theme.spacing[1],
|
paddingHorizontal: theme.spacing[1],
|
||||||
paddingVertical: 2,
|
paddingVertical: 2,
|
||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: theme.borderRadius.md,
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: colorWithOpacity(theme.colors.foreground, 0.05),
|
||||||
borderWidth: theme.borderWidth[1],
|
borderWidth: 0,
|
||||||
borderColor: theme.colors.borderAccent,
|
|
||||||
},
|
},
|
||||||
sequence: {
|
sequence: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -66,6 +82,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
text: {
|
text: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.normal,
|
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: {
|
theme: {
|
||||||
spacing: { 1: 4, 2: 8, 3: 12 },
|
spacing: { 1: 4, 2: 8, 3: 12 },
|
||||||
borderWidth: { 1: 1 },
|
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 },
|
fontSize: { xs: 11, sm: 13 },
|
||||||
fontWeight: { medium: "500" },
|
fontWeight: { normal: "400", medium: "500" },
|
||||||
lineHeight: { diff: 18 },
|
lineHeight: { diff: 18 },
|
||||||
colors: {
|
colors: {
|
||||||
accent: "#0a84ff",
|
accent: "#0a84ff",
|
||||||
@@ -38,6 +39,7 @@ const { theme, pressablePropsByLabel } = vi.hoisted(() => {
|
|||||||
surface1: "#111",
|
surface1: "#111",
|
||||||
surface2: "#222",
|
surface2: "#222",
|
||||||
surface3: "#333",
|
surface3: "#333",
|
||||||
|
palette: { white: "#fff" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
pressablePropsByLabel: new Map<string, Record<string, unknown>>(),
|
pressablePropsByLabel: new Map<string, Record<string, unknown>>(),
|
||||||
@@ -91,6 +93,8 @@ vi.mock("react-native-unistyles", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/constants/platform", () => ({
|
vi.mock("@/constants/platform", () => ({
|
||||||
|
getIsElectron: () => false,
|
||||||
|
getIsElectronMac: () => false,
|
||||||
isNative: false,
|
isNative: false,
|
||||||
isWeb: true,
|
isWeb: true,
|
||||||
}));
|
}));
|
||||||
@@ -101,8 +105,8 @@ vi.mock("lucide-react-native", () => {
|
|||||||
return {
|
return {
|
||||||
Check: createIcon("Check"),
|
Check: createIcon("Check"),
|
||||||
CircleDot: createIcon("CircleDot"),
|
CircleDot: createIcon("CircleDot"),
|
||||||
MessageCircle: createIcon("MessageCircle"),
|
|
||||||
Pencil: createIcon("Pencil"),
|
Pencil: createIcon("Pencil"),
|
||||||
|
Plus: createIcon("Plus"),
|
||||||
Trash2: createIcon("Trash2"),
|
Trash2: createIcon("Trash2"),
|
||||||
X: createIcon("X"),
|
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);
|
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 reviewTarget = target();
|
||||||
const { container, queryByText, rerender } = render(
|
const { container, queryByText, rerender } = render(
|
||||||
<InlineReviewGutterCell
|
<InlineReviewGutterCell
|
||||||
@@ -307,7 +311,7 @@ describe("git diff inline review helpers", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(queryByText("2")).toBeTruthy();
|
expect(queryByText("2")).toBeTruthy();
|
||||||
expect(container.querySelector("[data-icon='MessageCircle']")).toBeNull();
|
expect(container.querySelector("[data-icon='Plus']")).toBeNull();
|
||||||
|
|
||||||
rerender(
|
rerender(
|
||||||
<InlineReviewGutterCell
|
<InlineReviewGutterCell
|
||||||
@@ -320,8 +324,8 @@ describe("git diff inline review helpers", () => {
|
|||||||
</InlineReviewGutterCell>,
|
</InlineReviewGutterCell>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(queryByText("2")).toBeNull();
|
expect(queryByText("2")).toBeTruthy();
|
||||||
expect(container.querySelector("[data-icon='MessageCircle']")).toBeTruthy();
|
expect(container.querySelector("[data-icon='Plus']")).toBeNull();
|
||||||
|
|
||||||
rerender(
|
rerender(
|
||||||
<InlineReviewGutterCell
|
<InlineReviewGutterCell
|
||||||
@@ -334,13 +338,31 @@ describe("git diff inline review helpers", () => {
|
|||||||
</InlineReviewGutterCell>,
|
</InlineReviewGutterCell>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(queryByText("2")).toBeNull();
|
expect(queryByText("2")).toBeTruthy();
|
||||||
expect(container.querySelector("[data-icon='MessageCircle']")).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", () => {
|
describe("InlineReviewEditor", () => {
|
||||||
|
const originalMatchMedia = window.matchMedia;
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
window.matchMedia = originalMatchMedia;
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
@@ -364,6 +386,50 @@ describe("InlineReviewEditor", () => {
|
|||||||
fireEvent.click(getByTestId("editor-cancel"));
|
fireEvent.click(getByTestId("editor-cancel"));
|
||||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
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", () => {
|
describe("InlineReviewThread", () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { MessageCircle, Pencil, Trash2 } from "lucide-react-native";
|
import { Pencil, Plus, Trash2 } from "lucide-react-native";
|
||||||
import {
|
import {
|
||||||
Pressable,
|
Pressable,
|
||||||
type PressableStateCallbackType,
|
type PressableStateCallbackType,
|
||||||
@@ -12,11 +12,19 @@ import {
|
|||||||
type ViewStyle,
|
type ViewStyle,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
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 { isWeb } from "@/constants/platform";
|
||||||
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||||
|
import { useWorkspaceFocusRestoration } from "@/workspace/focus";
|
||||||
import { useReviewDraftComments, useReviewDraftStore, type ReviewDraftComment } from "./store";
|
import { useReviewDraftComments, useReviewDraftStore, type ReviewDraftComment } from "./store";
|
||||||
import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout";
|
import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout";
|
||||||
|
|
||||||
type PressableState = PressableStateCallbackType & { hovered?: boolean };
|
type PressableState = PressableStateCallbackType & { hovered?: boolean };
|
||||||
|
type WebTextInputRef = TextInput & {
|
||||||
|
getNativeElement?: () => unknown;
|
||||||
|
getNativeRef?: () => unknown;
|
||||||
|
};
|
||||||
|
|
||||||
function iconButtonStyle({ hovered, pressed }: PressableState): StyleProp<ViewStyle> {
|
function iconButtonStyle({ hovered, pressed }: PressableState): StyleProp<ViewStyle> {
|
||||||
return [styles.iconButton, (hovered || pressed) && styles.iconButtonHovered];
|
return [styles.iconButton, (hovered || pressed) && styles.iconButtonHovered];
|
||||||
@@ -26,14 +34,48 @@ function iconButtonDestructiveStyle({ hovered, pressed }: PressableState): Style
|
|||||||
return [styles.iconButton, (hovered || pressed) && styles.iconButtonDestructiveHovered];
|
return [styles.iconButton, (hovered || pressed) && styles.iconButtonDestructiveHovered];
|
||||||
}
|
}
|
||||||
|
|
||||||
function ghostButtonStyle({ hovered, pressed }: PressableState): StyleProp<ViewStyle> {
|
function getWebTextInputElement(input: TextInput | null): HTMLElement | null {
|
||||||
return [styles.ghostButton, (hovered || pressed) && styles.ghostButtonHovered];
|
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_COMMENT_HEIGHT = 72;
|
||||||
export const INLINE_REVIEW_EDITOR_HEIGHT = 132;
|
export const INLINE_REVIEW_EDITOR_HEIGHT = 132;
|
||||||
const INLINE_REVIEW_GAP = 6;
|
const INLINE_REVIEW_GAP = 6;
|
||||||
export const SMALL_ACTION_HIT_SLOP = 8;
|
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 {
|
export interface InlineReviewEditorState {
|
||||||
target: ReviewableDiffTarget;
|
target: ReviewableDiffTarget;
|
||||||
@@ -228,7 +270,7 @@ export function InlineReviewGutterCell({
|
|||||||
children,
|
children,
|
||||||
reviewTarget,
|
reviewTarget,
|
||||||
comments,
|
comments,
|
||||||
isEditorOpen,
|
isLineHovered = false,
|
||||||
onStartComment,
|
onStartComment,
|
||||||
style,
|
style,
|
||||||
}: {
|
}: {
|
||||||
@@ -236,26 +278,49 @@ export function InlineReviewGutterCell({
|
|||||||
reviewTarget: ReviewableDiffTarget | null | undefined;
|
reviewTarget: ReviewableDiffTarget | null | undefined;
|
||||||
comments: readonly ReviewDraftComment[];
|
comments: readonly ReviewDraftComment[];
|
||||||
isEditorOpen: boolean;
|
isEditorOpen: boolean;
|
||||||
|
isLineHovered?: boolean;
|
||||||
onStartComment: (target: ReviewableDiffTarget) => void;
|
onStartComment: (target: ReviewableDiffTarget) => void;
|
||||||
style?: StyleProp<ViewStyle>;
|
style?: StyleProp<ViewStyle>;
|
||||||
}) {
|
}) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const canComment = Boolean(reviewTarget);
|
const canComment = Boolean(reviewTarget);
|
||||||
const hasComments = comments.length > 0;
|
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(() => {
|
const handlePress = useCallback(() => {
|
||||||
if (reviewTarget) {
|
if (reviewTarget) {
|
||||||
|
setIsDismissedAfterPress(true);
|
||||||
onStartComment(reviewTarget);
|
onStartComment(reviewTarget);
|
||||||
}
|
}
|
||||||
}, [reviewTarget, onStartComment]);
|
}, [reviewTarget, onStartComment]);
|
||||||
|
|
||||||
const pressableStyle = useCallback(
|
const handleHoverIn = useCallback(() => {
|
||||||
({ hovered, pressed }: PressableState): StyleProp<ViewStyle> => [
|
setIsGutterHovered(true);
|
||||||
style,
|
}, []);
|
||||||
canComment && (hovered || pressed) && styles.gutterHovered,
|
|
||||||
],
|
const handleHoverOut = useCallback(() => {
|
||||||
[style, canComment],
|
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>>(
|
const labelStyle = useMemo<StyleProp<ViewStyle>>(
|
||||||
() => [styles.gutterLabel, hasComments && styles.gutterLabelActive],
|
() => [styles.gutterLabel, hasComments && styles.gutterLabelActive],
|
||||||
@@ -269,26 +334,22 @@ export function InlineReviewGutterCell({
|
|||||||
hitSlop={canComment ? SMALL_ACTION_HIT_SLOP : undefined}
|
hitSlop={canComment ? SMALL_ACTION_HIT_SLOP : undefined}
|
||||||
disabled={!canComment}
|
disabled={!canComment}
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
|
onHoverIn={handleHoverIn}
|
||||||
|
onHoverOut={handleHoverOut}
|
||||||
|
onPressIn={handlePressIn}
|
||||||
|
onPressOut={handlePressOut}
|
||||||
style={pressableStyle}
|
style={pressableStyle}
|
||||||
>
|
>
|
||||||
{({ hovered, pressed }) => {
|
<View style={styles.gutterInner}>
|
||||||
const showAction = canComment && (hovered || pressed || hasComments || isEditorOpen);
|
<View style={labelStyle}>
|
||||||
return (
|
{children}
|
||||||
<View style={styles.gutterInner}>
|
{showAction ? (
|
||||||
<View style={labelStyle}>
|
<View style={styles.gutterActionIcon}>
|
||||||
{showAction ? (
|
<Plus size={16} strokeWidth={2.4} color={theme.colors.accentForeground} />
|
||||||
<MessageCircle
|
|
||||||
size={13}
|
|
||||||
strokeWidth={hasComments ? 2.25 : 1.75}
|
|
||||||
color={hasComments ? theme.colors.accent : theme.colors.foregroundMuted}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
children
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
) : null}
|
||||||
);
|
</View>
|
||||||
}}
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -438,33 +499,75 @@ export function InlineReviewEditor({
|
|||||||
}) {
|
}) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const inputRef = useRef<TextInput | null>(null);
|
const inputRef = useRef<TextInput | null>(null);
|
||||||
|
const focus = useWorkspaceFocusRestoration();
|
||||||
|
const canShowKeyboardHints = useCanShowReviewKeyboardHints();
|
||||||
const [body, setBody] = useState(initialBody);
|
const [body, setBody] = useState(initialBody);
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
const trimmedBody = body.trim();
|
const trimmedBody = body.trim();
|
||||||
const canSave = trimmedBody.length > 0;
|
const canSave = trimmedBody.length > 0;
|
||||||
|
const showKeyboardHints = isFocused && canShowKeyboardHints;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleFocus = useCallback(() => setIsFocused(true), []);
|
const handleFocus = useCallback(() => {
|
||||||
const handleBlur = useCallback(() => setIsFocused(false), []);
|
focus.unfocus();
|
||||||
|
setIsFocused(true);
|
||||||
|
}, [focus]);
|
||||||
|
const handleBlur = useCallback(() => {
|
||||||
|
setIsFocused(false);
|
||||||
|
focus.restore();
|
||||||
|
}, [focus]);
|
||||||
const handleSave = useCallback(() => onSave(trimmedBody), [onSave, trimmedBody]);
|
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>>(
|
const inputStyle = useMemo<StyleProp<TextStyle>>(
|
||||||
() => [styles.editorInput, isFocused && styles.editorInputFocused],
|
() => [styles.editorInput, isFocused && styles.editorInputFocused],
|
||||||
[isFocused],
|
[isFocused],
|
||||||
);
|
);
|
||||||
|
|
||||||
const saveButtonStyle = useCallback(
|
|
||||||
({ hovered, pressed }: PressableState): StyleProp<ViewStyle> => [
|
|
||||||
styles.saveButton,
|
|
||||||
!canSave && styles.saveButtonDisabled,
|
|
||||||
canSave && (hovered || pressed) && styles.saveButtonHovered,
|
|
||||||
],
|
|
||||||
[canSave],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.editorBlock} testID={testID}>
|
<View style={styles.editorBlock} testID={testID}>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -481,40 +584,40 @@ export function InlineReviewEditor({
|
|||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
/>
|
/>
|
||||||
<View style={styles.editorActions}>
|
<View style={styles.editorActions}>
|
||||||
<Pressable
|
<Button
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel="Cancel review comment"
|
accessibilityLabel="Cancel review comment"
|
||||||
testID={testID ? `${testID}-cancel` : undefined}
|
testID={testID ? `${testID}-cancel` : undefined}
|
||||||
hitSlop={SMALL_ACTION_HIT_SLOP}
|
hitSlop={SMALL_ACTION_HIT_SLOP}
|
||||||
onPress={onCancel}
|
onPress={onCancel}
|
||||||
style={ghostButtonStyle}
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
trailing={cancelShortcut}
|
||||||
>
|
>
|
||||||
<Text style={styles.ghostButtonText}>Cancel</Text>
|
Cancel
|
||||||
</Pressable>
|
</Button>
|
||||||
<Pressable
|
<Button
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel="Save review comment"
|
accessibilityLabel="Save review comment"
|
||||||
testID={testID ? `${testID}-save` : undefined}
|
testID={testID ? `${testID}-save` : undefined}
|
||||||
hitSlop={SMALL_ACTION_HIT_SLOP}
|
hitSlop={SMALL_ACTION_HIT_SLOP}
|
||||||
disabled={!canSave}
|
disabled={!canSave}
|
||||||
onPress={handleSave}
|
onPress={handleSave}
|
||||||
style={saveButtonStyle}
|
variant="default"
|
||||||
|
size="xs"
|
||||||
|
trailing={saveShortcut}
|
||||||
>
|
>
|
||||||
<Text style={styles.saveButtonText}>Save</Text>
|
Comment
|
||||||
</Pressable>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
gutterHovered: {
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
gutterInner: {
|
gutterInner: {
|
||||||
minHeight: theme.lineHeight.diff,
|
minHeight: theme.lineHeight.diff,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
overflow: "visible",
|
||||||
},
|
},
|
||||||
gutterLabel: {
|
gutterLabel: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -522,10 +625,25 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
height: theme.lineHeight.diff,
|
height: theme.lineHeight.diff,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
position: "relative",
|
||||||
|
overflow: "visible",
|
||||||
},
|
},
|
||||||
gutterLabelActive: {
|
gutterLabelActive: {
|
||||||
backgroundColor: theme.colors.surface2,
|
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: {
|
threadContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
@@ -616,38 +734,4 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
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",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
newTabTooltipShortcut: {
|
newTabTooltipShortcut: {},
|
||||||
backgroundColor: theme.colors.surface3,
|
|
||||||
borderColor: theme.colors.borderAccent,
|
|
||||||
},
|
|
||||||
tooltipAgentRow: {
|
tooltipAgentRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ import {
|
|||||||
type WorkspacePaneContentModel,
|
type WorkspacePaneContentModel,
|
||||||
} from "@/screens/workspace/workspace-pane-content";
|
} from "@/screens/workspace/workspace-pane-content";
|
||||||
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
|
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
|
||||||
|
import { WorkspaceFocusProvider } from "@/workspace/focus";
|
||||||
import { shouldSeedEmptyWorkspaceDraft } from "@/screens/workspace/workspace-empty-draft-seed";
|
import { shouldSeedEmptyWorkspaceDraft } from "@/screens/workspace/workspace-empty-draft-seed";
|
||||||
import {
|
import {
|
||||||
buildBulkCloseConfirmationMessage,
|
buildBulkCloseConfirmationMessage,
|
||||||
@@ -2897,127 +2898,129 @@ function WorkspaceScreenContent({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={containerStyle}>
|
<WorkspaceFocusProvider workspaceKey={persistenceKey}>
|
||||||
{documentTitleEffectTab ? (
|
<View style={containerStyle}>
|
||||||
<WorkspaceTabPresentationResolver
|
{documentTitleEffectTab ? (
|
||||||
tab={documentTitleEffectTab}
|
<WorkspaceTabPresentationResolver
|
||||||
serverId={normalizedServerId}
|
tab={documentTitleEffectTab}
|
||||||
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
|
|
||||||
serverId={normalizedServerId}
|
serverId={normalizedServerId}
|
||||||
workspaceId={normalizedWorkspaceId}
|
workspaceId={normalizedWorkspaceId}
|
||||||
workspaceRoot={workspaceDirectory}
|
>
|
||||||
isGit={isGitCheckout}
|
{(presentation) => (
|
||||||
onOpenFile={handleOpenFileFromExplorer}
|
<WorkspaceDocumentTitleEffect
|
||||||
/>
|
label={presentation.label}
|
||||||
|
titleState={presentation.titleState}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</WorkspaceTabPresentationResolver>
|
||||||
) : null}
|
) : 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>
|
||||||
</View>
|
</WorkspaceFocusProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3154,10 +3157,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
newTabTooltipShortcut: {
|
newTabTooltipShortcut: {},
|
||||||
backgroundColor: theme.colors.surface3,
|
|
||||||
borderColor: theme.colors.borderAccent,
|
|
||||||
},
|
|
||||||
explorerTooltipRow: {
|
explorerTooltipRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -3167,10 +3167,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
color: theme.colors.popoverForeground,
|
color: theme.colors.popoverForeground,
|
||||||
},
|
},
|
||||||
explorerTooltipShortcut: {
|
explorerTooltipShortcut: {},
|
||||||
backgroundColor: theme.colors.surface3,
|
|
||||||
borderColor: theme.colors.borderAccent,
|
|
||||||
},
|
|
||||||
mobileTabsRow: {
|
mobileTabsRow: {
|
||||||
backgroundColor: theme.colors.surface0,
|
backgroundColor: theme.colors.surface0,
|
||||||
borderBottomWidth: theme.borderWidth[1],
|
borderBottomWidth: theme.borderWidth[1],
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export type SplitNode = { kind: "pane"; pane: SplitPane } | { kind: "group"; gro
|
|||||||
|
|
||||||
export interface WorkspaceLayout {
|
export interface WorkspaceLayout {
|
||||||
root: SplitNode;
|
root: SplitNode;
|
||||||
focusedPaneId: string;
|
focusedPaneId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SplitPaneInternal extends SplitPane {
|
interface SplitPaneInternal extends SplitPane {
|
||||||
@@ -398,9 +398,9 @@ function asInternalNode(node: SplitNode): SplitNodeInternal {
|
|||||||
|
|
||||||
function asInternalLayout(layout: WorkspaceLayout): {
|
function asInternalLayout(layout: WorkspaceLayout): {
|
||||||
root: SplitNodeInternal;
|
root: SplitNodeInternal;
|
||||||
focusedPaneId: string;
|
focusedPaneId: string | null;
|
||||||
} {
|
} {
|
||||||
return layout as { root: SplitNodeInternal; focusedPaneId: string };
|
return layout as { root: SplitNodeInternal; focusedPaneId: string | null };
|
||||||
}
|
}
|
||||||
|
|
||||||
function findPanePathById(
|
function findPanePathById(
|
||||||
@@ -899,11 +899,14 @@ export function normalizeLayout(layout: unknown): WorkspaceLayout {
|
|||||||
|
|
||||||
const rawLayout = layout as WorkspaceLayout;
|
const rawLayout = layout as WorkspaceLayout;
|
||||||
const root = normalizeNode(rawLayout.root) ?? asInternalNode(createDefaultLayout().root);
|
const root = normalizeNode(rawLayout.root) ?? asInternalNode(createDefaultLayout().root);
|
||||||
const focusedPaneId = trimNonEmpty(rawLayout.focusedPaneId);
|
const focusedPaneId =
|
||||||
|
rawLayout.focusedPaneId === null ? null : trimNonEmpty(rawLayout.focusedPaneId);
|
||||||
const resolvedFocusedPaneId =
|
const resolvedFocusedPaneId =
|
||||||
(focusedPaneId && findPaneById(root, focusedPaneId)?.id) ??
|
focusedPaneId === null
|
||||||
collectAllPanes(root)[0]?.id ??
|
? null
|
||||||
DEFAULT_PANE_ID;
|
: ((focusedPaneId && findPaneById(root, focusedPaneId)?.id) ??
|
||||||
|
collectAllPanes(root)[0]?.id ??
|
||||||
|
DEFAULT_PANE_ID);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
root,
|
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);
|
const internalRoot = asInternalNode(root);
|
||||||
if (internalRoot.kind === "pane") {
|
if (internalRoot.kind === "pane") {
|
||||||
return internalRoot.pane.id === paneId ? internalRoot.pane : null;
|
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 fallbackPaneId = findNearestSiblingPaneId(internalLayout.root, pane.id);
|
||||||
const nextRoot = removeTabFromTree(internalLayout.root, input.tabId) as SplitNodeInternal;
|
const nextRoot = removeTabFromTree(internalLayout.root, input.tabId) as SplitNodeInternal;
|
||||||
const nextFocusedPaneId =
|
const nextFocusedPaneId =
|
||||||
findPaneById(nextRoot, internalLayout.focusedPaneId)?.id ??
|
internalLayout.focusedPaneId === null
|
||||||
(fallbackPaneId && findPaneById(nextRoot, fallbackPaneId)?.id) ??
|
? null
|
||||||
collectAllPanes(nextRoot)[0]?.id ??
|
: (findPaneById(nextRoot, internalLayout.focusedPaneId)?.id ??
|
||||||
DEFAULT_PANE_ID;
|
(fallbackPaneId && findPaneById(nextRoot, fallbackPaneId)?.id) ??
|
||||||
|
collectAllPanes(nextRoot)[0]?.id ??
|
||||||
|
DEFAULT_PANE_ID);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
root: nextRoot,
|
root: nextRoot,
|
||||||
@@ -1204,7 +1212,7 @@ export function reorderFocusedPaneTabsInLayout(
|
|||||||
input: ReorderFocusedPaneTabsInLayoutInput,
|
input: ReorderFocusedPaneTabsInLayoutInput,
|
||||||
): WorkspaceLayout | null {
|
): WorkspaceLayout | null {
|
||||||
const layout = asInternalLayout(input.layout);
|
const layout = asInternalLayout(input.layout);
|
||||||
if (!findPaneById(layout.root, layout.focusedPaneId)) {
|
if (!layout.focusedPaneId || !findPaneById(layout.root, layout.focusedPaneId)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ describe("workspace-layout-store actions", () => {
|
|||||||
splitSizesByWorkspace: {},
|
splitSizesByWorkspace: {},
|
||||||
pinnedAgentIdsByWorkspace: {},
|
pinnedAgentIdsByWorkspace: {},
|
||||||
hiddenAgentIdsByWorkspace: {},
|
hiddenAgentIdsByWorkspace: {},
|
||||||
|
focusRestorationByWorkspace: {},
|
||||||
});
|
});
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
@@ -313,6 +314,66 @@ describe("workspace-layout-store actions", () => {
|
|||||||
expect(pane.focusedTabId).toBe(secondTabId);
|
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", () => {
|
it("openTab creates distinct draft tabs for repeated Cmd+T/new-tab opens", () => {
|
||||||
const workspaceKey = createWorkspaceKey();
|
const workspaceKey = createWorkspaceKey();
|
||||||
const store = useWorkspaceLayoutStore.getState();
|
const store = useWorkspaceLayoutStore.getState();
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ interface WorkspaceLayoutStore {
|
|||||||
splitSizesByWorkspace: Record<string, Record<string, number[]>>;
|
splitSizesByWorkspace: Record<string, Record<string, number[]>>;
|
||||||
pinnedAgentIdsByWorkspace: Record<string, Set<string>>;
|
pinnedAgentIdsByWorkspace: Record<string, Set<string>>;
|
||||||
hiddenAgentIdsByWorkspace: Record<string, Set<string>>;
|
hiddenAgentIdsByWorkspace: Record<string, Set<string>>;
|
||||||
|
focusRestorationByWorkspace: Record<string, WorkspaceFocusRestorationState>;
|
||||||
openTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
|
openTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
|
||||||
openTabInBackground: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
|
openTabInBackground: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
|
||||||
closeTab: (workspaceKey: string, tabId: string) => void;
|
closeTab: (workspaceKey: string, tabId: string) => void;
|
||||||
@@ -94,6 +95,8 @@ interface WorkspaceLayoutStore {
|
|||||||
) => string | null;
|
) => string | null;
|
||||||
moveTabToPane: (workspaceKey: string, tabId: string, toPaneId: string) => void;
|
moveTabToPane: (workspaceKey: string, tabId: string, toPaneId: string) => void;
|
||||||
focusPane: (workspaceKey: string, paneId: 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;
|
resizeSplit: (workspaceKey: string, groupId: string, sizes: number[]) => void;
|
||||||
reorderTabsInPane: (workspaceKey: string, paneId: string, tabIds: string[]) => void;
|
reorderTabsInPane: (workspaceKey: string, paneId: string, tabIds: string[]) => void;
|
||||||
pinAgent: (workspaceKey: string, agentId: string) => void;
|
pinAgent: (workspaceKey: string, agentId: string) => void;
|
||||||
@@ -103,6 +106,11 @@ interface WorkspaceLayoutStore {
|
|||||||
purgeWorkspace: (workspaceKey: string) => void;
|
purgeWorkspace: (workspaceKey: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WorkspaceFocusRestorationState {
|
||||||
|
restorePaneId: string | null;
|
||||||
|
tokens: string[];
|
||||||
|
}
|
||||||
|
|
||||||
const MAX_TREE_DEPTH = 4;
|
const MAX_TREE_DEPTH = 4;
|
||||||
|
|
||||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||||
@@ -162,6 +170,26 @@ function getWorkspaceLayout(
|
|||||||
return normalizeLayout(state[workspaceKey] ?? createDefaultLayout());
|
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>()(
|
export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
@@ -169,6 +197,7 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
|||||||
splitSizesByWorkspace: {},
|
splitSizesByWorkspace: {},
|
||||||
pinnedAgentIdsByWorkspace: {},
|
pinnedAgentIdsByWorkspace: {},
|
||||||
hiddenAgentIdsByWorkspace: {},
|
hiddenAgentIdsByWorkspace: {},
|
||||||
|
focusRestorationByWorkspace: {},
|
||||||
openTabFocused: (workspaceKey, target) => {
|
openTabFocused: (workspaceKey, target) => {
|
||||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||||
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
||||||
@@ -183,6 +212,7 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
|||||||
});
|
});
|
||||||
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||||
hiddenAgentIdsByWorkspace:
|
hiddenAgentIdsByWorkspace:
|
||||||
normalizedTarget.kind !== "agent"
|
normalizedTarget.kind !== "agent"
|
||||||
? state.hiddenAgentIdsByWorkspace
|
? state.hiddenAgentIdsByWorkspace
|
||||||
@@ -246,6 +276,7 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||||
layoutByWorkspace: {
|
layoutByWorkspace: {
|
||||||
...state.layoutByWorkspace,
|
...state.layoutByWorkspace,
|
||||||
[normalizedWorkspaceKey]: nextLayout,
|
[normalizedWorkspaceKey]: nextLayout,
|
||||||
@@ -270,6 +301,7 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||||
layoutByWorkspace: {
|
layoutByWorkspace: {
|
||||||
...state.layoutByWorkspace,
|
...state.layoutByWorkspace,
|
||||||
[normalizedWorkspaceKey]: nextLayout,
|
[normalizedWorkspaceKey]: nextLayout,
|
||||||
@@ -295,6 +327,9 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
|
...(result.layout.focusedPaneId !== null
|
||||||
|
? (withoutFocusRestoration(state, normalizedWorkspaceKey) ?? {})
|
||||||
|
: {}),
|
||||||
hiddenAgentIdsByWorkspace:
|
hiddenAgentIdsByWorkspace:
|
||||||
normalizedTarget.kind !== "agent"
|
normalizedTarget.kind !== "agent"
|
||||||
? state.hiddenAgentIdsByWorkspace
|
? state.hiddenAgentIdsByWorkspace
|
||||||
@@ -329,6 +364,9 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
|
...(result.layout.focusedPaneId !== null
|
||||||
|
? (withoutFocusRestoration(state, normalizedWorkspaceKey) ?? {})
|
||||||
|
: {}),
|
||||||
hiddenAgentIdsByWorkspace: removeAgentIdFromWorkspaceSet(
|
hiddenAgentIdsByWorkspace: removeAgentIdFromWorkspaceSet(
|
||||||
state.hiddenAgentIdsByWorkspace,
|
state.hiddenAgentIdsByWorkspace,
|
||||||
normalizedWorkspaceKey,
|
normalizedWorkspaceKey,
|
||||||
@@ -429,6 +467,7 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||||
layoutByWorkspace: {
|
layoutByWorkspace: {
|
||||||
...state.layoutByWorkspace,
|
...state.layoutByWorkspace,
|
||||||
[normalizedWorkspaceKey]: result.layout,
|
[normalizedWorkspaceKey]: result.layout,
|
||||||
@@ -462,6 +501,7 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||||
layoutByWorkspace: {
|
layoutByWorkspace: {
|
||||||
...state.layoutByWorkspace,
|
...state.layoutByWorkspace,
|
||||||
[normalizedWorkspaceKey]: result.layout,
|
[normalizedWorkspaceKey]: result.layout,
|
||||||
@@ -489,6 +529,7 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||||
layoutByWorkspace: {
|
layoutByWorkspace: {
|
||||||
...state.layoutByWorkspace,
|
...state.layoutByWorkspace,
|
||||||
[normalizedWorkspaceKey]: nextLayout,
|
[normalizedWorkspaceKey]: nextLayout,
|
||||||
@@ -513,6 +554,7 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||||
layoutByWorkspace: {
|
layoutByWorkspace: {
|
||||||
...state.layoutByWorkspace,
|
...state.layoutByWorkspace,
|
||||||
[normalizedWorkspaceKey]: nextLayout,
|
[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) => {
|
resizeSplit: (workspaceKey, groupId, sizes) => {
|
||||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||||
const normalizedGroupId = trimNonEmpty(groupId);
|
const normalizedGroupId = trimNonEmpty(groupId);
|
||||||
@@ -555,6 +681,7 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||||
layoutByWorkspace: {
|
layoutByWorkspace: {
|
||||||
...state.layoutByWorkspace,
|
...state.layoutByWorkspace,
|
||||||
[normalizedWorkspaceKey]: nextLayout,
|
[normalizedWorkspaceKey]: nextLayout,
|
||||||
@@ -682,7 +809,8 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
|||||||
normalizedWorkspaceKey in state.layoutByWorkspace ||
|
normalizedWorkspaceKey in state.layoutByWorkspace ||
|
||||||
normalizedWorkspaceKey in state.splitSizesByWorkspace ||
|
normalizedWorkspaceKey in state.splitSizesByWorkspace ||
|
||||||
normalizedWorkspaceKey in state.pinnedAgentIdsByWorkspace ||
|
normalizedWorkspaceKey in state.pinnedAgentIdsByWorkspace ||
|
||||||
normalizedWorkspaceKey in state.hiddenAgentIdsByWorkspace;
|
normalizedWorkspaceKey in state.hiddenAgentIdsByWorkspace ||
|
||||||
|
normalizedWorkspaceKey in state.focusRestorationByWorkspace;
|
||||||
if (!hasAny) {
|
if (!hasAny) {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
@@ -694,11 +822,14 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
|||||||
state.pinnedAgentIdsByWorkspace;
|
state.pinnedAgentIdsByWorkspace;
|
||||||
const { [normalizedWorkspaceKey]: _hidden, ...hiddenAgentIdsByWorkspace } =
|
const { [normalizedWorkspaceKey]: _hidden, ...hiddenAgentIdsByWorkspace } =
|
||||||
state.hiddenAgentIdsByWorkspace;
|
state.hiddenAgentIdsByWorkspace;
|
||||||
|
const { [normalizedWorkspaceKey]: _restoration, ...focusRestorationByWorkspace } =
|
||||||
|
state.focusRestorationByWorkspace;
|
||||||
return {
|
return {
|
||||||
layoutByWorkspace,
|
layoutByWorkspace,
|
||||||
splitSizesByWorkspace,
|
splitSizesByWorkspace,
|
||||||
pinnedAgentIdsByWorkspace,
|
pinnedAgentIdsByWorkspace,
|
||||||
hiddenAgentIdsByWorkspace,
|
hiddenAgentIdsByWorkspace,
|
||||||
|
focusRestorationByWorkspace,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export type ShortcutOs = "mac" | "non-mac";
|
|||||||
|
|
||||||
const KEY_DISPLAY: Record<string, string> = {
|
const KEY_DISPLAY: Record<string, string> = {
|
||||||
Backspace: "⌫",
|
Backspace: "⌫",
|
||||||
Enter: "↩",
|
Enter: "⏎",
|
||||||
Esc: "Esc",
|
Esc: "Esc",
|
||||||
Space: "␣",
|
Space: "␣",
|
||||||
Left: "←",
|
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