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:
@@ -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",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user