Fix sidebar shortcut hints shifting rows

This commit is contained in:
Mohamed Boudra
2026-06-04 00:48:53 +07:00
parent ffe4d4046c
commit f6c0b60b7c
6 changed files with 233 additions and 67 deletions

View File

@@ -101,7 +101,10 @@ import { SidebarStatusWorkspaceList } from "@/components/sidebar/sidebar-status-
import {
SidebarWorkspaceRowFrame,
SidebarWorkspaceRowContent,
sidebarWorkspaceRowStyles,
SidebarWorkspaceShortcutBadge,
SidebarWorkspaceTrailingActionBase,
SidebarWorkspaceTrailingActionOverlay,
SidebarWorkspaceTrailingActionSlot,
} from "@/components/sidebar/sidebar-workspace-row-content";
import {
useHydratedWorkspaceEntries,
@@ -652,7 +655,10 @@ function WorkspaceRowRightGroup({
onCopyPath?: () => void;
onRename?: () => void;
}) {
const showShortcut = showShortcutBadge && shortcutNumber !== null;
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
const showKebabInSlot = showKebab && !showShortcut;
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
return (
<>
{showScriptsIcon ? (
@@ -665,30 +671,35 @@ function WorkspaceRowRightGroup({
</View>
) : null}
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
{showKebab && onArchive ? (
<WorkspaceKebabMenu
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
/>
) : null}
{!showKebab && workspace.diffStat ? (
<DiffStat
additions={workspace.diffStat.additions}
deletions={workspace.diffStat.deletions}
/>
) : null}
{showShortcutBadge && shortcutNumber !== null ? (
<View style={sidebarWorkspaceRowStyles.shortcutBadge}>
<Text style={sidebarWorkspaceRowStyles.shortcutBadgeText}>{shortcutNumber}</Text>
</View>
{shouldRenderActionSlot ? (
<SidebarWorkspaceTrailingActionSlot>
<SidebarWorkspaceTrailingActionBase
visible={Boolean(workspace.diffStat && !showKebabInSlot && !showShortcut)}
>
{workspace.diffStat ? (
<DiffStat
additions={workspace.diffStat.additions}
deletions={workspace.diffStat.deletions}
/>
) : null}
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot}>
{onArchive ? (
<WorkspaceKebabMenu
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
/>
) : null}
</SidebarWorkspaceTrailingActionOverlay>
</SidebarWorkspaceTrailingActionSlot>
) : null}
</>
);
@@ -1275,8 +1286,8 @@ function ProjectHeaderRow({
removeProjectStatus={removeProjectStatus}
/>
{showShortcutBadge && shortcutNumber !== null ? (
<View style={styles.shortcutBadge}>
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
<View style={styles.projectShortcutBadgeOverlay} pointerEvents="none">
<SidebarWorkspaceShortcutBadge number={shortcutNumber} />
</View>
) : null}
</>
@@ -1412,6 +1423,8 @@ function WorkspaceRowInner({
isHovered={isHovered}
isLoading={isArchiving || isCreating}
isCreating={isCreating}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
>
<WorkspaceRowRightGroup
workspace={workspace}
@@ -2789,6 +2802,7 @@ const styles = StyleSheet.create((theme) => ({
textAlign: "center",
},
projectRow: {
position: "relative",
minHeight: 36,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
@@ -2925,6 +2939,11 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
},
projectActionTooltipShortcut: {},
projectShortcutBadgeOverlay: {
position: "absolute",
top: theme.spacing[2] + 1,
right: theme.spacing[2],
},
workspaceRow: {
minHeight: 36,
marginBottom: theme.spacing[1],
@@ -3045,24 +3064,6 @@ const styles = StyleSheet.create((theme) => ({
kebabButtonHovered: {
backgroundColor: theme.colors.surface2,
},
shortcutBadge: {
minWidth: 18,
height: 18,
paddingHorizontal: theme.spacing[1],
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.sm,
borderWidth: 1,
borderColor: theme.colors.surface2,
backgroundColor: theme.colors.surface0,
flexShrink: 0,
},
shortcutBadgeText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
lineHeight: 14,
},
statusDotNeedsInput: {
backgroundColor: theme.colors.palette.amber[500],
borderColor: theme.colors.surface0,

View File

@@ -59,7 +59,9 @@ import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attentio
import {
SidebarWorkspaceRowFrame,
SidebarWorkspaceRowContent,
sidebarWorkspaceRowStyles,
SidebarWorkspaceTrailingActionBase,
SidebarWorkspaceTrailingActionOverlay,
SidebarWorkspaceTrailingActionSlot,
} from "@/components/sidebar/sidebar-workspace-row-content";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
@@ -611,7 +613,10 @@ function StatusWorkspaceRowInner({
return (
<SidebarWorkspaceRowFrame workspace={workspace}>
{({ isHovered, hoverHandlers }) => {
const showShortcut = showShortcutBadge && shortcutNumber !== null;
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
const showKebabInSlot = showKebab && !showShortcut;
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
const workspaceRowStyle = getStatusWorkspaceRowStyle({ selected, isHovered });
return (
<View style={styles.workspaceRowContainer} {...hoverHandlers}>
@@ -628,6 +633,8 @@ function StatusWorkspaceRowInner({
subtitle={projectName}
isHovered={isHovered}
isLoading={isArchiving}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
>
<>
{showScriptsIcon ? (
@@ -639,9 +646,11 @@ function StatusWorkspaceRowInner({
)}
</View>
) : null}
{showKebab && onArchive ? (
<StatusKebabMenu
workspaceKey={workspace.workspaceKey}
{shouldRenderActionSlot ? (
<StatusWorkspaceActionSlot
workspace={workspace}
showBase={Boolean(workspace.diffStat && !showKebabInSlot && !showShortcut)}
showOverlay={showKebabInSlot}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
@@ -653,19 +662,6 @@ function StatusWorkspaceRowInner({
archiveShortcutKeys={archiveShortcutKeys}
/>
) : null}
{!showKebab && workspace.diffStat ? (
<DiffStat
additions={workspace.diffStat.additions}
deletions={workspace.diffStat.deletions}
/>
) : null}
{showShortcutBadge && shortcutNumber !== null ? (
<View style={sidebarWorkspaceRowStyles.shortcutBadge}>
<Text style={sidebarWorkspaceRowStyles.shortcutBadgeText}>
{shortcutNumber}
</Text>
</View>
) : null}
</>
</SidebarWorkspaceRowContent>
</Pressable>
@@ -676,6 +672,63 @@ function StatusWorkspaceRowInner({
);
}
function StatusWorkspaceActionSlot({
workspace,
showBase,
showOverlay,
onCopyPath,
onCopyBranchName,
onRename,
onMarkAsRead,
onArchive,
archiveLabel,
archiveStatus,
archivePendingLabel,
archiveShortcutKeys,
}: {
workspace: SidebarWorkspaceEntry;
showBase: boolean;
showOverlay: boolean;
onCopyPath?: () => void;
onCopyBranchName?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
onArchive?: () => void;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
archiveShortcutKeys?: ShortcutKey[][] | null;
}) {
return (
<SidebarWorkspaceTrailingActionSlot>
<SidebarWorkspaceTrailingActionBase visible={showBase}>
{workspace.diffStat ? (
<DiffStat
additions={workspace.diffStat.additions}
deletions={workspace.diffStat.deletions}
/>
) : null}
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={showOverlay}>
{onArchive ? (
<StatusKebabMenu
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
/>
) : null}
</SidebarWorkspaceTrailingActionOverlay>
</SidebarWorkspaceTrailingActionSlot>
);
}
function StatusKebabMenu({
workspaceKey,
onCopyPath,

View File

@@ -89,6 +89,8 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
isHovered,
isLoading,
isCreating = false,
shortcutNumber = null,
showShortcutBadge = false,
children,
}: {
workspace: SidebarWorkspaceEntry;
@@ -96,6 +98,8 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
isHovered: boolean;
isLoading: boolean;
isCreating?: boolean;
shortcutNumber?: number | null;
showShortcutBadge?: boolean;
children?: ReactNode;
}) {
const workspaceBranchTextStyle = useMemo(
@@ -108,7 +112,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
);
return (
<>
<View style={styles.workspaceRowContent}>
<View style={styles.workspaceRowMain}>
<View style={styles.workspaceRowLeft}>
<WorkspaceStatusIndicator
@@ -122,6 +126,11 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
</View>
<View style={styles.workspaceRowRight}>{children}</View>
</View>
{showShortcutBadge && shortcutNumber !== null ? (
<View style={styles.shortcutBadgeOverlay} pointerEvents="none">
<SidebarWorkspaceShortcutBadge number={shortcutNumber} />
</View>
) : null}
{subtitle ? (
<Text style={styles.workspaceSubtitle} numberOfLines={1}>
{subtitle}
@@ -133,7 +142,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
<ChecksBadge checks={workspace.prHint.checks} />
</View>
) : null}
</>
</View>
);
});
@@ -346,7 +355,7 @@ const checksBadgeStyles = StyleSheet.create((theme) => ({
export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({
rowRight: {
flexDirection: "row",
alignItems: "center",
alignItems: "flex-start",
gap: theme.spacing[2],
flexShrink: 0,
},
@@ -368,12 +377,63 @@ export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.medium,
lineHeight: 14,
},
hidden: { opacity: 0 },
trailingActionSlot: {
position: "relative",
minWidth: 18,
minHeight: 20,
flexShrink: 0,
alignItems: "flex-end",
justifyContent: "flex-start",
},
trailingActionOverlay: {
position: "absolute",
top: 0,
right: 0,
},
}));
export function SidebarWorkspaceShortcutBadge({ number }: { number: number }) {
return (
<View style={sidebarWorkspaceRowStyles.shortcutBadge}>
<Text style={sidebarWorkspaceRowStyles.shortcutBadgeText}>{number}</Text>
</View>
);
}
export function SidebarWorkspaceTrailingActionSlot({ children }: { children: ReactNode }) {
return <View style={sidebarWorkspaceRowStyles.trailingActionSlot}>{children}</View>;
}
export function SidebarWorkspaceTrailingActionBase({
visible,
children,
}: {
visible: boolean;
children: ReactNode;
}) {
if (!children) return null;
return <View style={visible ? undefined : sidebarWorkspaceRowStyles.hidden}>{children}</View>;
}
export function SidebarWorkspaceTrailingActionOverlay({
visible,
children,
}: {
visible: boolean;
children: ReactNode;
}) {
if (!visible || !children) return null;
return <View style={sidebarWorkspaceRowStyles.trailingActionOverlay}>{children}</View>;
}
const styles = StyleSheet.create((theme) => ({
workspaceRowContent: {
position: "relative",
},
workspaceRowMain: {
flexDirection: "row",
alignItems: "center",
alignItems: "flex-start",
justifyContent: "space-between",
gap: theme.spacing[2],
width: "100%",
@@ -386,6 +446,11 @@ const styles = StyleSheet.create((theme) => ({
minWidth: 0,
},
workspaceRowRight: sidebarWorkspaceRowStyles.rowRight,
shortcutBadgeOverlay: {
position: "absolute",
top: 1,
right: 0,
},
workspaceStatusDot: {
position: "relative",
width: WORKSPACE_STATUS_DOT_WIDTH,

View File

@@ -28,6 +28,9 @@ export function WorkspaceShortcutTargetsSubscriber({
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedProjectKeys,
);
const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedStatusGroupKeys,
);
const setSidebarShortcutWorkspaceTargets = useKeyboardShortcutsStore(
(state) => state.setSidebarShortcutWorkspaceTargets,
);
@@ -37,6 +40,7 @@ export function WorkspaceShortcutTargetsSubscriber({
return buildStatusSidebarShortcutModel({
workspaces: statusWorkspaces,
projectNamesByKey,
collapsedStatusGroupKeys,
});
}
@@ -44,7 +48,14 @@ export function WorkspaceShortcutTargetsSubscriber({
projects,
collapsedProjectKeys,
});
}, [collapsedProjectKeys, groupMode, projectNamesByKey, projects, statusWorkspaces]);
}, [
collapsedProjectKeys,
collapsedStatusGroupKeys,
groupMode,
projectNamesByKey,
projects,
statusWorkspaces,
]);
useEffect(() => {
if (!enabled || !serverId) {

View File

@@ -199,6 +199,37 @@ describe("buildStatusSidebarShortcutModel", () => {
expect(model.shortcutIndexByWorkspaceKey.get("s1:running-old")).toBe(3);
expect(model.shortcutIndexByWorkspaceKey.get("s1:done-old")).toBe(4);
});
it("excludes collapsed status groups from shortcut targets", () => {
const workspaces = [
workspace({
serverId: "s1",
workspaceId: "needs-input",
workspaceDirectory: "/repo/needs-input",
name: "needs input",
projectKey: "p1",
statusBucket: "needs_input",
}),
workspace({
serverId: "s1",
workspaceId: "running",
workspaceDirectory: "/repo/running",
name: "running",
projectKey: "p1",
statusBucket: "running",
}),
];
const model = buildStatusSidebarShortcutModel({
workspaces,
projectNamesByKey: new Map([["p1", "Project 1"]]),
collapsedStatusGroupKeys: new Set(["needs_input"]),
});
expect(model.shortcutTargets).toEqual([{ serverId: "s1", workspaceId: "running" }]);
expect(model.shortcutIndexByWorkspaceKey.get("s1:needs-input")).toBeUndefined();
expect(model.shortcutIndexByWorkspaceKey.get("s1:running")).toBe(1);
});
});
describe("getRelativeSidebarShortcutTarget", () => {

View File

@@ -53,6 +53,7 @@ export function buildSidebarShortcutModel(input: {
export function buildStatusSidebarShortcutModel(input: {
workspaces: SidebarWorkspaceEntry[];
projectNamesByKey: Map<string, string>;
collapsedStatusGroupKeys?: ReadonlySet<string>;
shortcutLimit?: number;
}): SidebarShortcutModel {
const maxShortcuts = Math.max(0, Math.floor(input.shortcutLimit ?? 9));
@@ -61,6 +62,10 @@ export function buildStatusSidebarShortcutModel(input: {
const shortcutIndexByWorkspaceKey = new Map<string, number>();
for (const group of groups) {
if (input.collapsedStatusGroupKeys?.has(group.bucket)) {
continue;
}
for (const workspace of group.rows) {
if (shortcutTargets.length >= maxShortcuts) {
break;