refactor(sidebar): use status dot overlay on icons instead of replacing them

Sidebar workspace and project rows now keep their icon (Monitor/FolderGit2
for workspaces, project icon/initial for projects) and overlay a small
colored status dot on the bottom-right, matching the tab icon pattern.
Loading and syncing states still replace the icon entirely.
This commit is contained in:
Mohamed Boudra
2026-03-22 19:45:26 +07:00
parent d79ea8b15c
commit 1e5b809b70
2 changed files with 104 additions and 61 deletions
@@ -27,10 +27,8 @@ import { type GestureType } from "react-native-gesture-handler";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import { import {
Archive, Archive,
Check,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
CircleHelp,
Copy, Copy,
FolderGit2, FolderGit2,
GitPullRequest, GitPullRequest,
@@ -49,7 +47,6 @@ import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { import {
type SidebarProjectEntry, type SidebarProjectEntry,
type SidebarWorkspaceEntry, type SidebarWorkspaceEntry,
type SidebarStateBucket,
} from "@/hooks/use-sidebar-workspaces-list"; } from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
@@ -74,6 +71,7 @@ import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
import { confirmDialog } from "@/utils/confirm-dialog"; import { confirmDialog } from "@/utils/confirm-dialog";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader"; import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { getStatusDotColor } from "@/utils/status-dot-color";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut"; import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut";
@@ -214,22 +212,6 @@ function WorkspacePrBadge({ hint }: { hint: PrHint }) {
); );
} }
function resolveStatusDotColor(input: {
theme: ReturnType<typeof useUnistyles>["theme"];
bucket: SidebarStateBucket;
}) {
const { theme, bucket } = input;
return bucket === "needs_input"
? theme.colors.palette.amber[500]
: bucket === "failed"
? theme.colors.palette.red[500]
: bucket === "running"
? theme.colors.palette.blue[500]
: bucket === "attention"
? theme.colors.palette.green[500]
: theme.colors.border;
}
function WorkspaceStatusIndicator({ function WorkspaceStatusIndicator({
bucket, bucket,
workspaceKind, workspaceKind,
@@ -240,38 +222,48 @@ function WorkspaceStatusIndicator({
loading?: boolean; loading?: boolean;
}) { }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const color = resolveStatusDotColor({ theme, bucket });
const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket }); const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket });
const isIdle = !loading && !shouldShowSyncedLoader && bucket === "done";
if (isIdle) { if (loading) {
const KindIcon =
workspaceKind === "local_checkout"
? Monitor
: workspaceKind === "worktree"
? FolderGit2
: null;
if (!KindIcon) return null;
return ( return (
<View style={styles.workspaceStatusDot}> <View style={styles.workspaceStatusDot}>
<KindIcon size={14} color={theme.colors.foregroundMuted} /> <ActivityIndicator size={8} color={theme.colors.foregroundMuted} />
</View> </View>
); );
} }
if (shouldShowSyncedLoader) {
return (
<View style={styles.workspaceStatusDot}>
<SyncedLoader size={11} color={theme.colors.palette.amber[500]} />
</View>
);
}
const KindIcon =
workspaceKind === "local_checkout"
? Monitor
: workspaceKind === "worktree"
? FolderGit2
: null;
if (!KindIcon) return null;
const dotColor = getStatusDotColor({ theme, bucket, showDoneAsInactive: false });
return ( return (
<View style={styles.workspaceStatusDot}> <View style={styles.workspaceStatusDot}>
{loading ? ( <KindIcon size={14} color={theme.colors.foregroundMuted} />
<ActivityIndicator size={8} color={theme.colors.foregroundMuted} /> {dotColor ? (
) : shouldShowSyncedLoader ? ( <View
<SyncedLoader size={11} color={theme.colors.palette.amber[500]} /> style={[
) : bucket === "needs_input" ? ( styles.statusDotOverlay,
<CircleHelp size={14} color={theme.colors.palette.amber[500]} /> {
) : bucket === "attention" ? ( backgroundColor: dotColor,
<Check size={14} color={theme.colors.palette.green[500]} /> borderColor: theme.colors.surface0,
) : ( },
<View style={[styles.workspaceStatusDotFill, { backgroundColor: color }]} /> ]}
)} />
) : null}
</View> </View>
); );
} }
@@ -291,29 +283,72 @@ function ProjectLeadingVisual({
showChevron?: boolean; showChevron?: boolean;
isArchiving?: boolean; isArchiving?: boolean;
}) { }) {
const { theme } = useUnistyles();
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(displayName); const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(displayName);
const placeholderInitial = placeholderLabel.charAt(0).toUpperCase(); const placeholderInitial = placeholderLabel.charAt(0).toUpperCase();
const activeWorkspace = workspace; const activeWorkspace = workspace;
const shouldShowWorkspaceStatus = const shouldShowWorkspaceStatus =
activeWorkspace !== null && (isArchiving || activeWorkspace.statusBucket !== "done"); activeWorkspace !== null && (isArchiving || activeWorkspace.statusBucket !== "done");
const shouldShowSyncedLoader = activeWorkspace
? shouldRenderSyncedStatusLoader({ bucket: activeWorkspace.statusBucket })
: false;
if (showChevron && chevron !== null) {
return (
<View style={styles.projectLeadingVisualSlot}>
<ProjectInlineChevron chevron={chevron} />
</View>
);
}
const projectIcon = iconDataUri ? (
<Image source={{ uri: iconDataUri }} style={styles.projectIcon} />
) : (
<View style={styles.projectIconFallback}>
<Text style={styles.projectIconFallbackText}>{placeholderInitial}</Text>
</View>
);
if (!shouldShowWorkspaceStatus || !activeWorkspace) {
return <View style={styles.projectLeadingVisualSlot}>{projectIcon}</View>;
}
if (isArchiving) {
return (
<View style={styles.projectLeadingVisualSlot}>
<ActivityIndicator size={8} color={theme.colors.foregroundMuted} />
</View>
);
}
if (shouldShowSyncedLoader) {
return (
<View style={styles.projectLeadingVisualSlot}>
<SyncedLoader size={11} color={theme.colors.palette.amber[500]} />
</View>
);
}
const dotColor = getStatusDotColor({
theme,
bucket: activeWorkspace.statusBucket,
showDoneAsInactive: false,
});
return ( return (
<View style={styles.projectLeadingVisualSlot}> <View style={styles.projectLeadingVisualSlot}>
{showChevron && chevron !== null ? ( {projectIcon}
<ProjectInlineChevron chevron={chevron} /> {dotColor ? (
) : shouldShowWorkspaceStatus && activeWorkspace ? ( <View
<WorkspaceStatusIndicator style={[
bucket={activeWorkspace.statusBucket} styles.statusDotOverlay,
workspaceKind={activeWorkspace.workspaceKind} {
loading={isArchiving} backgroundColor: dotColor,
borderColor: theme.colors.surface0,
},
]}
/> />
) : iconDataUri ? ( ) : null}
<Image source={{ uri: iconDataUri }} style={styles.projectIcon} />
) : (
<View style={styles.projectIconFallback}>
<Text style={styles.projectIconFallbackText}>{placeholderInitial}</Text>
</View>
)}
</View> </View>
); );
} }
@@ -1993,6 +2028,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.sm, borderRadius: theme.borderRadius.sm,
}, },
projectLeadingVisualSlot: { projectLeadingVisualSlot: {
position: "relative",
width: theme.iconSize.md, width: theme.iconSize.md,
height: theme.iconSize.md, height: theme.iconSize.md,
flexShrink: 0, flexShrink: 0,
@@ -2124,17 +2160,22 @@ const styles = StyleSheet.create((theme) => ({
position: "relative", position: "relative",
}, },
workspaceStatusDot: { workspaceStatusDot: {
position: "relative",
width: WORKSPACE_STATUS_DOT_WIDTH, width: WORKSPACE_STATUS_DOT_WIDTH,
height: 16, height: 16,
borderRadius: theme.borderRadius.full, borderRadius: theme.borderRadius.full,
flexShrink: 0, flexShrink: 0,
alignItems: "flex-start", alignItems: "center",
justifyContent: "center", justifyContent: "center",
}, },
workspaceStatusDotFill: { statusDotOverlay: {
position: "absolute",
right: 0,
bottom: 0,
width: 7, width: 7,
height: 7, height: 7,
borderRadius: theme.borderRadius.full, borderRadius: theme.borderRadius.full,
borderWidth: 1,
}, },
workspaceArchivingOverlay: { workspaceArchivingOverlay: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
@@ -2179,7 +2220,7 @@ const styles = StyleSheet.create((theme) => ({
}, },
workspacePrBadgeText: { workspacePrBadgeText: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium, fontWeight: theme.fontWeight.normal,
lineHeight: 14, lineHeight: 14,
}, },
workspacePrBadgeTextHovered: { workspacePrBadgeTextHovered: {
@@ -13,7 +13,7 @@ export interface SidebarProjectWorkspaceLinkRowModel {
export interface SidebarProjectSectionRowModel { export interface SidebarProjectSectionRowModel {
kind: "project_section"; kind: "project_section";
chevron: "expand" | "collapse"; chevron: "expand" | "collapse" | null;
trailingAction: "new_worktree" | "none"; trailingAction: "new_worktree" | "none";
} }
@@ -22,7 +22,7 @@ export type SidebarProjectRowModel =
| SidebarProjectSectionRowModel; | SidebarProjectSectionRowModel;
export function isSidebarProjectFlattened(project: SidebarProjectEntry): boolean { export function isSidebarProjectFlattened(project: SidebarProjectEntry): boolean {
return project.workspaces.length === 1; return project.workspaces.length === 1 && project.projectKind !== "git";
} }
export function buildSidebarProjectRowModel(input: { export function buildSidebarProjectRowModel(input: {
@@ -50,9 +50,11 @@ export function buildSidebarProjectRowModel(input: {
}; };
} }
const collapsible = input.project.workspaces.length > 1;
return { return {
kind: "project_section", kind: "project_section",
chevron: input.collapsed ? "expand" : "collapse", chevron: collapsible ? (input.collapsed ? "expand" : "collapse") : null,
trailingAction: input.project.projectKind === "git" ? "new_worktree" : "none", trailingAction: input.project.projectKind === "git" ? "new_worktree" : "none",
}; };
} }