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.
This commit is contained in:
@@ -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()]);
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<HostColor, (theme: Theme) => { color: string }> = (() => {
|
||||
const byColor = {} as Record<HostColor, (theme: Theme) => { 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 ? <HostItem hostBadge={hostBadge} /> : null;
|
||||
return hostBadge ? <HostBadge badge={hostBadge} /> : null;
|
||||
}
|
||||
if (item.kind === "changeRequest") {
|
||||
return <PullRequestItem hint={item.hint} />;
|
||||
@@ -114,23 +102,6 @@ function MetaItemNode({
|
||||
return <ScriptItem summary={item.summary} />;
|
||||
}
|
||||
|
||||
function HostItem({ hostBadge }: { hostBadge: HostBadgeModel }) {
|
||||
return (
|
||||
<View
|
||||
style={styles.hostItem}
|
||||
testID={`sidebar-host-badge-${hostBadge.serverId}`}
|
||||
accessibilityLabel={hostBadge.label}
|
||||
>
|
||||
<ThemedServer size={META_ICON_SIZE} uniProps={HOST_ICON_MAPPINGS[hostBadge.color]} />
|
||||
{hostBadge.showLabel ? (
|
||||
<Text style={styles.hostLabel} numberOfLines={1}>
|
||||
{hostBadge.label}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
|
||||
@@ -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<HostColor, (theme: Theme) => { color: string }> = (() => {
|
||||
const byColor = {} as Record<HostColor, (theme: Theme) => { 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 (
|
||||
<View
|
||||
style={styles.badge}
|
||||
testID={`host-badge-${badge.serverId}`}
|
||||
accessibilityLabel={badge.label}
|
||||
>
|
||||
<ThemedServer size={HOST_BADGE_ICON_SIZE} uniProps={HOST_ICON_MAPPINGS[badge.color]} />
|
||||
{badge.showLabel ? (
|
||||
<Text style={styles.label} numberOfLines={1}>
|
||||
{badge.label}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}));
|
||||
@@ -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<string, HostBadgeModel> {
|
||||
const hosts = useHosts();
|
||||
const localServerId = useLocalDaemonServerId();
|
||||
const localDaemon = useLocalDaemonServerIdState();
|
||||
return useMemo(
|
||||
() =>
|
||||
selectHostBadges({
|
||||
hosts,
|
||||
localServerId,
|
||||
localHostResolutionPending: localDaemon.status !== "resolved",
|
||||
enabled,
|
||||
}),
|
||||
[hosts, localDaemon.status, localServerId, enabled],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user