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], + ); +}