From 22cb7f9996739f831f8f1f7be1d8de7e7b90a21a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 4 Aug 2026 12:04:32 +0200 Subject: [PATCH] refactor(app): give the host badge one home The server glyph, its identity-colour table and the muted label lived inside the sidebar meta row, and the three stores that decide whether a host has a badge at all were assembled at one call site. Both are about to have a second caller in the workspace header. Move the badge to hosts/host-badge and the resolution to useHostBadges. The badge now owns its glyph size, and the meta row matches it, since it is the one item on that line that also appears off it. --- .../app/e2e/browser/host-appearance.spec.ts | 2 +- packages/app/e2e/support/helpers/hosts.ts | 4 +- .../src/components/sidebar-workspace-list.tsx | 20 ++---- .../sidebar/workspace-meta-row/index.tsx | 57 ++------------- packages/app/src/hosts/host-badge.tsx | 72 +++++++++++++++++++ packages/app/src/hosts/use-host-badges.ts | 30 ++++++++ 6 files changed, 117 insertions(+), 68 deletions(-) create mode 100644 packages/app/src/hosts/host-badge.tsx create mode 100644 packages/app/src/hosts/use-host-badges.ts diff --git a/packages/app/e2e/browser/host-appearance.spec.ts b/packages/app/e2e/browser/host-appearance.spec.ts index a3677b358..b8d869b31 100644 --- a/packages/app/e2e/browser/host-appearance.spec.ts +++ b/packages/app/e2e/browser/host-appearance.spec.ts @@ -108,7 +108,7 @@ test.describe("Host appearance", () => { await leaveHostAppearanceSettings(page); const row = page.getByTestId(`sidebar-workspace-row-${workspaceKey}`); - const badge = row.getByTestId(`sidebar-host-badge-${twoHostSidebar.secondaryServerId}`); + const badge = row.getByTestId(`host-badge-${twoHostSidebar.secondaryServerId}`); await expect(badge).toHaveText(hostName); const [badgeBox, rowBox] = await Promise.all([badge.boundingBox(), row.boundingBox()]); diff --git a/packages/app/e2e/support/helpers/hosts.ts b/packages/app/e2e/support/helpers/hosts.ts index 8536eef5b..e3d988047 100644 --- a/packages/app/e2e/support/helpers/hosts.ts +++ b/packages/app/e2e/support/helpers/hosts.ts @@ -214,7 +214,7 @@ export async function chooseHostBadgeDisplay( function hostBadge(page: Page, input: HostBadgeTarget) { return page .getByTestId(`sidebar-workspace-row-${input.serverId}:${input.workspaceId}`) - .getByTestId(`sidebar-host-badge-${input.serverId}`); + .getByTestId(`host-badge-${input.serverId}`); } interface HostBadgeTarget { @@ -262,7 +262,7 @@ export async function expectHostAppearancePreview( ): Promise { const preview = page.getByTestId("host-appearance-preview"); await expect(preview).toBeVisible(); - const badge = preview.getByTestId(`sidebar-host-badge-${input.serverId}`); + const badge = preview.getByTestId(`host-badge-${input.serverId}`); await expect(badge).toHaveText(input.hostName); await expect(badge.getByText(input.hostName)).toHaveCSS( "color", diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index aa2f157a9..26f153552 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -146,8 +146,9 @@ import { } from "@/constants/platform"; import { getDesktopHost } from "@/desktop/host"; import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item"; -import { useLocalDaemonServerId, useLocalDaemonServerIdState } from "@/hooks/use-is-local-daemon"; -import { selectHostBadges, type HostBadgeModel } from "@/hosts/appearance"; +import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon"; +import type { HostBadgeModel } from "@/hosts/appearance"; +import { useHostBadges } from "@/hosts/use-host-badges"; import { useSidebarRowItems } from "@/components/sidebar/display-preferences/model"; const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey; @@ -1892,23 +1893,14 @@ export function SidebarWorkspaceList({ }: SidebarWorkspaceListProps) { const pathname = usePathname(); const hosts = useHosts(); - const localServerId = useLocalDaemonServerId(); - const localDaemon = useLocalDaemonServerIdState(); const rowItems = useSidebarRowItems(); // Host badge visibility is a lattice, not three competing switches: this gate is the global // "off", `shouldShowSidebarHostLabels` is the automatic "there is only one host so it says // nothing", and each host's own `badgeDisplay` decides name vs icon vs hidden. Turning the // item off here removes the badge everywhere; leaving it on defers to the per-host setting. - const hostBadgeByServerId = useMemo( - () => - selectHostBadges({ - hosts, - localServerId, - localHostResolutionPending: localDaemon.status !== "resolved", - enabled: rowItems.host && shouldShowSidebarHostLabels(projects), - }), - [hosts, localDaemon.status, localServerId, projects, rowItems.host], - ); + const hostBadgeByServerId = useHostBadges({ + enabled: rowItems.host && shouldShowSidebarHostLabels(projects), + }); const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]); const supportsMultiplicityByServerId = useHostFeatureMap(serverIds, "workspaceMultiplicity"); const supportsPinningByServerId = useHostFeatureMap(serverIds, "workspacePinning"); diff --git a/packages/app/src/components/sidebar/workspace-meta-row/index.tsx b/packages/app/src/components/sidebar/workspace-meta-row/index.tsx index cf62ff8cd..5a8a2e36a 100644 --- a/packages/app/src/components/sidebar/workspace-meta-row/index.tsx +++ b/packages/app/src/components/sidebar/workspace-meta-row/index.tsx @@ -8,11 +8,10 @@ import { GitPullRequest, GitPullRequestClosed, Globe, - Server, SquareTerminal, } from "lucide-react-native"; -import { HOST_COLORS, type HostBadgeModel, type HostColor } from "@/hosts/appearance"; -import { identityColor } from "@/styles/identity-colors"; +import type { HostBadgeModel } from "@/hosts/appearance"; +import { HostBadge, HOST_BADGE_ICON_SIZE } from "@/hosts/host-badge"; import type { PrHint } from "@/git/pr-hint"; import { getForgePresentation, normalizeForge } from "@/git/forge"; import { openExternalUrl } from "@/utils/open-external-url"; @@ -27,11 +26,11 @@ export { selectWorkspaceScriptSummary, type WorkspaceScriptSummary } from "./scr /** * One size for every glyph on the line. The items are peers — host, change request, CI, - * running service — so a glyph that differs in size reads as a different rank. + * running service — so a glyph that differs in size reads as a different rank. The host badge + * owns the size because it is the one item that also appears off this line. */ -const META_ICON_SIZE = 12; +const META_ICON_SIZE = HOST_BADGE_ICON_SIZE; -const ThemedServer = withUnistyles(Server); const ThemedExternalLink = withUnistyles(ExternalLink); const ThemedGitPullRequest = withUnistyles(GitPullRequest); const ThemedGitMerge = withUnistyles(GitMerge); @@ -39,21 +38,10 @@ const ThemedGitPullRequestClosed = withUnistyles(GitPullRequestClosed); const ThemedGlobe = withUnistyles(Globe); const ThemedSquareTerminal = withUnistyles(SquareTerminal); -const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const foregroundMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const mergedMapping = (theme: Theme) => ({ color: theme.colors.statusMutedMerged }); const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusMutedDanger }); -// Theme-independent, so the mapping per host color is fixed for the life of the module. A -// fresh `uniProps` identity on every render makes withUnistyles re-subscribe each pass. -const HOST_ICON_MAPPINGS: Record { color: string }> = (() => { - const byColor = {} as Record { color: string }>; - for (const color of HOST_COLORS) { - byColor[color] = color === "none" ? mutedMapping : () => ({ color: identityColor(color) }); - } - return byColor; -})(); - /** * The subtitle under a workspace title: which host it lives on, its change request, that * change request's CI, and any running service. Everything the row knows about a workspace @@ -103,7 +91,7 @@ function MetaItemNode({ hostBadge: HostBadgeModel | null; }): ReactNode { if (item.kind === "host") { - return hostBadge ? : null; + return hostBadge ? : null; } if (item.kind === "changeRequest") { return ; @@ -114,23 +102,6 @@ function MetaItemNode({ return ; } -function HostItem({ hostBadge }: { hostBadge: HostBadgeModel }) { - return ( - - - {hostBadge.showLabel ? ( - - {hostBadge.label} - - ) : null} - - ); -} - /** * The only thing on the line that navigates, so it is the only thing that answers the * pointer: it brightens to full foreground and its icon becomes an external-link arrow, @@ -280,15 +251,6 @@ const styles = StyleSheet.create((theme) => ({ minWidth: 0, flexShrink: 0, }, - // The host is the only unbounded item, so it consumes the remaining width and gives way - // before the bounded change request, CI, and service items. - hostItem: { - flexDirection: "row", - alignItems: "center", - gap: 3, - minWidth: 0, - flexShrink: 1, - }, itemPressed: { opacity: 0.82, }, @@ -298,13 +260,6 @@ const styles = StyleSheet.create((theme) => ({ lineHeight: 16, flexShrink: 0, }, - hostLabel: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - lineHeight: 16, - flexShrink: 1, - minWidth: 0, - }, scriptPort: { color: theme.colors.statusMutedSuccess, fontSize: theme.fontSize.xs, diff --git a/packages/app/src/hosts/host-badge.tsx b/packages/app/src/hosts/host-badge.tsx new file mode 100644 index 000000000..135130b21 --- /dev/null +++ b/packages/app/src/hosts/host-badge.tsx @@ -0,0 +1,72 @@ +import { Text, View } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { Server } from "lucide-react-native"; +import { HOST_COLORS, type HostBadgeModel, type HostColor } from "@/hosts/appearance"; +import { identityColor } from "@/styles/identity-colors"; +import type { Theme } from "@/styles/theme"; + +/** + * The glyph size the badge draws at. Rows that place the badge alongside their own glyphs + * match this so the line reads as one rank of peers. + */ +export const HOST_BADGE_ICON_SIZE = 12; + +const ThemedServer = withUnistyles(Server); + +const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +// Theme-independent, so the mapping per host color is fixed for the life of the module. A +// fresh `uniProps` identity on every render makes withUnistyles re-subscribe each pass. +const HOST_ICON_MAPPINGS: Record { color: string }> = (() => { + const byColor = {} as Record { color: string }>; + for (const color of HOST_COLORS) { + byColor[color] = color === "none" ? mutedMapping : () => ({ color: identityColor(color) }); + } + return byColor; +})(); + +/** + * Which machine something lives on, drawn the same way everywhere it appears: a server glyph + * in the host's identity color, and the host's name when the host is configured to show one. + * + * A hostname is the least interesting thing on any line that carries it and the only one whose + * length nobody chose, so the badge yields space before its neighbours rather than alongside + * them — see `flexShrink` below. + */ +export function HostBadge({ badge }: { badge: HostBadgeModel }) { + return ( + + + {badge.showLabel ? ( + + {badge.label} + + ) : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + badge: { + flexDirection: "row", + alignItems: "center", + gap: 3, + minWidth: 0, + // Outweighs a sibling's `flexShrink: 1` by enough that the badge is effectively fully + // squeezed before that sibling gives up its first pixel. An equal factor would split the + // loss in proportion to length, which hands the most space to the longest hostname — + // exactly backwards. The icon has a fixed width, so the badge never disappears outright. + flexShrink: 100, + }, + label: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + lineHeight: 16, + flexShrink: 1, + minWidth: 0, + }, +})); diff --git a/packages/app/src/hosts/use-host-badges.ts b/packages/app/src/hosts/use-host-badges.ts new file mode 100644 index 000000000..d401d3eef --- /dev/null +++ b/packages/app/src/hosts/use-host-badges.ts @@ -0,0 +1,30 @@ +import { useMemo } from "react"; +import { useHosts } from "@/runtime/host-runtime"; +import { useLocalDaemonServerId, useLocalDaemonServerIdState } from "@/hooks/use-is-local-daemon"; +import { selectHostBadges, type HostBadgeModel } from "@/hosts/appearance"; + +/** + * Every host's badge, resolved from the three things that decide one: the host registry, which + * host is local, and each host's own appearance. `enabled` is the caller's own "off" — a surface + * that has its own reason to hide badges passes false rather than filtering the result, so the + * per-host setting stays the only thing that decides name vs icon vs hidden. + */ +export function useHostBadges({ + enabled, +}: { + enabled: boolean; +}): ReadonlyMap { + const hosts = useHosts(); + const localServerId = useLocalDaemonServerId(); + const localDaemon = useLocalDaemonServerIdState(); + return useMemo( + () => + selectHostBadges({ + hosts, + localServerId, + localHostResolutionPending: localDaemon.status !== "resolved", + enabled, + }), + [hosts, localDaemon.status, localServerId, enabled], + ); +}