feat(app): show workspace identity badges (#3445)

Let users opt into branch and project context on workspace rows while keeping the default sidebar quiet. Project names follow the existing project-icon visibility rule so grouped rows do not repeat their parent.
This commit is contained in:
Mohamed Boudra
2026-08-16 11:46:15 +02:00
committed by GitHub
parent 9a0c53d790
commit d60d92e909
18 changed files with 151 additions and 17 deletions
@@ -66,6 +66,8 @@ const TITLE_SOURCE_ICONS: Record<WorkspaceTitleSource, OptionIcon> = {
// The same marks these things carry on the workspace row itself, so the menu and the row it // The same marks these things carry on the workspace row itself, so the menu and the row it
// configures name each item the same way twice. // configures name each item the same way twice.
const ROW_ITEM_ICONS: Record<SidebarRowItem, OptionIcon> = { const ROW_ITEM_ICONS: Record<SidebarRowItem, OptionIcon> = {
branch: withUnistyles(GitBranch),
project: withUnistyles(Folder),
host: withUnistyles(Server), host: withUnistyles(Server),
changeRequest: withUnistyles(GitPullRequest), changeRequest: withUnistyles(GitPullRequest),
services: withUnistyles(Globe), services: withUnistyles(Globe),
@@ -99,6 +101,8 @@ const TITLE_SOURCE_LABEL_KEYS: Record<WorkspaceTitleSource, string> = {
}; };
const ROW_ITEM_LABEL_KEYS: Record<SidebarRowItem, string> = { const ROW_ITEM_LABEL_KEYS: Record<SidebarRowItem, string> = {
branch: "sidebar.display.show.branch",
project: "sidebar.display.show.project",
host: "sidebar.display.show.host", host: "sidebar.display.show.host",
changeRequest: "sidebar.display.show.changeRequest", changeRequest: "sidebar.display.show.changeRequest",
services: "sidebar.display.show.services", services: "sidebar.display.show.services",
@@ -3,14 +3,17 @@ import {
DEFAULT_SIDEBAR_ROW_ITEMS, DEFAULT_SIDEBAR_ROW_ITEMS,
isChecksHiddenByLegacyRowItem, isChecksHiddenByLegacyRowItem,
parseSidebarRowItems, parseSidebarRowItems,
SIDEBAR_ROW_ITEMS,
} from "./row-items"; } from "./row-items";
describe("parseSidebarRowItems", () => { describe("parseSidebarRowItems", () => {
it("shows everything by default", () => { it("shows operational metadata but hides identity badges by default", () => {
for (const item of SIDEBAR_ROW_ITEMS) { expect(DEFAULT_SIDEBAR_ROW_ITEMS).toEqual({
expect(DEFAULT_SIDEBAR_ROW_ITEMS[item]).toBe(true); branch: false,
} project: false,
host: true,
changeRequest: true,
services: true,
});
}); });
it("applies stored overrides", () => { it("applies stored overrides", () => {
@@ -21,7 +24,7 @@ describe("parseSidebarRowItems", () => {
}); });
it("leaves items absent from storage at their default", () => { it("leaves items absent from storage at their default", () => {
// A newly added item must ship visible rather than inheriting "missing means off". // An absent key takes the item's explicit product default.
expect(parseSidebarRowItems({ host: false })).toEqual({ expect(parseSidebarRowItems({ host: false })).toEqual({
...DEFAULT_SIDEBAR_ROW_ITEMS, ...DEFAULT_SIDEBAR_ROW_ITEMS,
host: false, host: false,
@@ -12,18 +12,22 @@
* that knows what an item is. * that knows what an item is.
*/ */
export const SIDEBAR_ROW_ITEMS = ["host", "changeRequest", "services"] as const; export const SIDEBAR_ROW_ITEMS = [
"branch",
"project",
"host",
"changeRequest",
"services",
] as const;
export type SidebarRowItem = (typeof SIDEBAR_ROW_ITEMS)[number]; export type SidebarRowItem = (typeof SIDEBAR_ROW_ITEMS)[number];
export type SidebarRowItems = Record<SidebarRowItem, boolean>; export type SidebarRowItems = Record<SidebarRowItem, boolean>;
/** /** The persisted record is merged over these explicit product defaults. */
* Everything on by default. A new item ships visible and users turn it off, which is also why
* the persisted shape is a record of overrides rather than a list of enabled items: an absent
* key has to mean "default", not "off".
*/
export const DEFAULT_SIDEBAR_ROW_ITEMS: SidebarRowItems = { export const DEFAULT_SIDEBAR_ROW_ITEMS: SidebarRowItems = {
branch: false,
project: false,
host: true, host: true,
changeRequest: true, changeRequest: true,
services: true, services: true,
@@ -196,6 +196,8 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
<View style={sidebarWorkspaceRowStyles.rowRight}>{children}</View> <View style={sidebarWorkspaceRowStyles.rowRight}>{children}</View>
</View> </View>
<WorkspaceMetaRow <WorkspaceMetaRow
currentBranch={workspace.currentBranch}
projectName={leadingProjectName}
hostBadge={hostBadge ?? null} hostBadge={hostBadge ?? null}
prHint={workspace.prHint} prHint={workspace.prHint}
serviceSummary={serviceSummary} serviceSummary={serviceSummary}
@@ -4,6 +4,8 @@ import { Pressable, Text, View, type GestureResponderEvent } from "react-native"
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { import {
ExternalLink, ExternalLink,
Folder,
GitBranch,
GitMerge, GitMerge,
GitPullRequest, GitPullRequest,
GitPullRequestClosed, GitPullRequestClosed,
@@ -35,12 +37,15 @@ export {
const META_ICON_SIZE = HOST_BADGE_ICON_SIZE; const META_ICON_SIZE = HOST_BADGE_ICON_SIZE;
const ThemedExternalLink = withUnistyles(ExternalLink); const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedFolder = withUnistyles(Folder);
const ThemedGitBranch = withUnistyles(GitBranch);
const ThemedGitPullRequest = withUnistyles(GitPullRequest); const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedGitMerge = withUnistyles(GitMerge); const ThemedGitMerge = withUnistyles(GitMerge);
const ThemedGitPullRequestClosed = withUnistyles(GitPullRequestClosed); const ThemedGitPullRequestClosed = withUnistyles(GitPullRequestClosed);
const ThemedGlobe = withUnistyles(Globe); const ThemedGlobe = withUnistyles(Globe);
const foregroundMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const mergedMapping = (theme: Theme) => ({ color: theme.colors.statusMerged }); const mergedMapping = (theme: Theme) => ({ color: theme.colors.statusMerged });
const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusDanger }); const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
@@ -55,16 +60,22 @@ const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
* leaves color to mean status. * leaves color to mean status.
*/ */
export function WorkspaceMetaRow({ export function WorkspaceMetaRow({
currentBranch,
projectName,
hostBadge, hostBadge,
prHint, prHint,
serviceSummary, serviceSummary,
}: { }: {
currentBranch: string | null;
projectName: string | null;
hostBadge: HostBadgeModel | null; hostBadge: HostBadgeModel | null;
prHint: PrHint | null; prHint: PrHint | null;
serviceSummary: WorkspaceServiceSummary | null; serviceSummary: WorkspaceServiceSummary | null;
}) { }) {
const { rowItems, checksDisplay } = useSidebarMetaPreferences(); const { rowItems, checksDisplay } = useSidebarMetaPreferences();
const items = selectMetaRowItems({ const items = selectMetaRowItems({
currentBranch,
projectName,
hasHostBadge: hostBadge !== null, hasHostBadge: hostBadge !== null,
prHint, prHint,
serviceSummary, serviceSummary,
@@ -93,6 +104,12 @@ function MetaItemNode({
item: MetaRowItem; item: MetaRowItem;
hostBadge: HostBadgeModel | null; hostBadge: HostBadgeModel | null;
}): ReactNode { }): ReactNode {
if (item.kind === "branch") {
return <IdentityItem kind="branch" name={item.name} />;
}
if (item.kind === "project") {
return <IdentityItem kind="project" name={item.name} />;
}
if (item.kind === "host") { if (item.kind === "host") {
return hostBadge ? <HostBadge badge={hostBadge} /> : null; return hostBadge ? <HostBadge badge={hostBadge} /> : null;
} }
@@ -105,6 +122,20 @@ function MetaItemNode({
return <ServiceItem summary={item.summary} />; return <ServiceItem summary={item.summary} />;
} }
function IdentityItem({ kind, name }: { kind: "branch" | "project"; name: string }) {
const Icon = kind === "branch" ? ThemedGitBranch : ThemedFolder;
return (
<View style={styles.identityItem} testID={`sidebar-workspace-${kind}`}>
<View style={styles.identityIcon}>
<Icon size={META_ICON_SIZE} uniProps={mutedMapping} />
</View>
<Text style={styles.identityText} numberOfLines={1}>
{name}
</Text>
</View>
);
}
/** /**
* The only thing on the line that navigates, so it is the only thing that answers the * 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, * pointer: it brightens to full foreground and its icon becomes an external-link arrow,
@@ -267,6 +298,22 @@ const styles = StyleSheet.create((theme) => ({
minWidth: 0, minWidth: 0,
flexShrink: 0, flexShrink: 0,
}, },
identityItem: {
flexDirection: "row",
alignItems: "center",
gap: 3,
minWidth: 0,
flexShrink: 1,
},
identityIcon: {
flexShrink: 0,
},
identityText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
lineHeight: 16,
flexShrink: 1,
},
itemPressed: { itemPressed: {
opacity: 0.82, opacity: 0.82,
}, },
@@ -17,6 +17,8 @@ const SERVICE: WorkspaceServiceSummary = { name: "web", health: null };
function select(overrides: Partial<Parameters<typeof selectMetaRowItems>[0]> = {}) { function select(overrides: Partial<Parameters<typeof selectMetaRowItems>[0]> = {}) {
return selectMetaRowItems({ return selectMetaRowItems({
currentBranch: "feature/sidebar-badges",
projectName: "Paseo",
hasHostBadge: true, hasHostBadge: true,
prHint: PR_HINT, prHint: PR_HINT,
serviceSummary: SERVICE, serviceSummary: SERVICE,
@@ -29,12 +31,40 @@ function select(overrides: Partial<Parameters<typeof selectMetaRowItems>[0]> = {
const kinds = (items: ReturnType<typeof selectMetaRowItems>) => items.map((item) => item.kind); const kinds = (items: ReturnType<typeof selectMetaRowItems>) => items.map((item) => item.kind);
describe("selectMetaRowItems", () => { describe("selectMetaRowItems", () => {
it("reads identity, then the change, then its state, then what is running", () => { it("puts the enabled branch and project badges first", () => {
expect(kinds(select())).toEqual(["host", "changeRequest", "checks", "services"]); const visible = { ...DEFAULT_SIDEBAR_ROW_ITEMS, branch: true, project: true };
expect(kinds(select({ visible }))).toEqual([
"branch",
"project",
"host",
"changeRequest",
"checks",
"services",
]);
}); });
it("omits what the workspace does not have", () => { it("omits what the workspace does not have", () => {
expect(kinds(select({ hasHostBadge: false, prHint: null, serviceSummary: null }))).toEqual([]); expect(
kinds(
select({
currentBranch: null,
projectName: null,
hasHostBadge: false,
prHint: null,
serviceSummary: null,
}),
),
).toEqual([]);
});
it("only draws identity badges when enabled and available", () => {
const visible = { ...DEFAULT_SIDEBAR_ROW_ITEMS, branch: true, project: true };
expect(kinds(select({ currentBranch: null, projectName: null, visible }))).toEqual([
"host",
"changeRequest",
"checks",
"services",
]);
}); });
it.each([ it.each([
@@ -10,6 +10,8 @@ import type { WorkspaceServiceSummary } from "./service-summary";
* running. Identity first, then the work, then the work's state. * running. Identity first, then the work, then the work's state.
*/ */
export type MetaRowItem = export type MetaRowItem =
| { kind: "branch"; name: string }
| { kind: "project"; name: string }
| { kind: "host" } | { kind: "host" }
| { kind: "changeRequest"; hint: PrHint } | { kind: "changeRequest"; hint: PrHint }
| { kind: "checks"; summary: CheckSummary; label: boolean } | { kind: "checks"; summary: CheckSummary; label: boolean }
@@ -26,15 +28,31 @@ export type MetaRowItem =
* has no badge to hand down, so by the time a row sees one it is meant to be drawn. * has no badge to hand down, so by the time a row sees one it is meant to be drawn.
*/ */
export function selectMetaRowItems(input: { export function selectMetaRowItems(input: {
currentBranch: string | null;
projectName: string | null;
hasHostBadge: boolean; hasHostBadge: boolean;
prHint: PrHint | null; prHint: PrHint | null;
serviceSummary: WorkspaceServiceSummary | null; serviceSummary: WorkspaceServiceSummary | null;
visible: SidebarRowItems; visible: SidebarRowItems;
checksDisplay: SidebarChecksDisplay; checksDisplay: SidebarChecksDisplay;
}): MetaRowItem[] { }): MetaRowItem[] {
const { hasHostBadge, prHint, serviceSummary, visible, checksDisplay } = input; const {
currentBranch,
projectName,
hasHostBadge,
prHint,
serviceSummary,
visible,
checksDisplay,
} = input;
const items: MetaRowItem[] = []; const items: MetaRowItem[] = [];
if (currentBranch && visible.branch) {
items.push({ kind: "branch", name: currentBranch });
}
if (projectName && visible.project) {
items.push({ kind: "project", name: projectName });
}
if (hasHostBadge) { if (hasHostBadge) {
items.push({ kind: "host" }); items.push({ kind: "host" });
} }
@@ -78,6 +78,8 @@ export interface Settings extends AppSettings {
} }
const SidebarRowItemsSchema = z.strictObject({ const SidebarRowItemsSchema = z.strictObject({
branch: z.boolean().optional(),
project: z.boolean().optional(),
host: z.boolean().optional(), host: z.boolean().optional(),
changeRequest: z.boolean().optional(), changeRequest: z.boolean().optional(),
services: z.boolean().optional(), services: z.boolean().optional(),
+2
View File
@@ -970,6 +970,8 @@ export const ar: TranslationResources = {
}, },
show: { show: {
label: "إظهار", label: "إظهار",
branch: "الفرع",
project: "المشروع",
host: "المضيف", host: "المضيف",
changeRequest: "طلب السحب", changeRequest: "طلب السحب",
checks: "الفحوصات", checks: "الفحوصات",
+2
View File
@@ -979,6 +979,8 @@ export const en = {
}, },
show: { show: {
label: "Show", label: "Show",
branch: "Branch",
project: "Project",
host: "Host", host: "Host",
changeRequest: "Pull request", changeRequest: "Pull request",
checks: "Checks", checks: "Checks",
+2
View File
@@ -1001,6 +1001,8 @@ export const es: TranslationResources = {
}, },
show: { show: {
label: "Mostrar", label: "Mostrar",
branch: "Rama",
project: "Proyecto",
host: "Host", host: "Host",
changeRequest: "Pull request", changeRequest: "Pull request",
checks: "Comprobaciones", checks: "Comprobaciones",
+2
View File
@@ -1000,6 +1000,8 @@ export const fr: TranslationResources = {
}, },
show: { show: {
label: "Afficher", label: "Afficher",
branch: "Branche",
project: "Projet",
host: "Hôte", host: "Hôte",
changeRequest: "Pull request", changeRequest: "Pull request",
checks: "Vérifications", checks: "Vérifications",
+2
View File
@@ -981,6 +981,8 @@ export const ja: TranslationResources = {
}, },
show: { show: {
label: "表示項目", label: "表示項目",
branch: "ブランチ",
project: "プロジェクト",
host: "ホスト", host: "ホスト",
changeRequest: "プルリクエスト", changeRequest: "プルリクエスト",
checks: "チェック", checks: "チェック",
+2
View File
@@ -977,6 +977,8 @@ export const ko: TranslationResources = {
}, },
show: { show: {
label: "표시 항목", label: "표시 항목",
branch: "브랜치",
project: "프로젝트",
host: "호스트", host: "호스트",
changeRequest: "풀 리퀘스트", changeRequest: "풀 리퀘스트",
checks: "검사", checks: "검사",
+2
View File
@@ -992,6 +992,8 @@ export const ptBR: TranslationResources = {
}, },
show: { show: {
label: "Mostrar", label: "Mostrar",
branch: "Branch",
project: "Projeto",
host: "Host", host: "Host",
changeRequest: "Pull request", changeRequest: "Pull request",
checks: "Verificações", checks: "Verificações",
+2
View File
@@ -992,6 +992,8 @@ export const ru: TranslationResources = {
}, },
show: { show: {
label: "Показывать", label: "Показывать",
branch: "Ветка",
project: "Проект",
host: "Хост", host: "Хост",
changeRequest: "Pull request", changeRequest: "Pull request",
checks: "Проверки", checks: "Проверки",
+2
View File
@@ -962,6 +962,8 @@ export const zhCN: TranslationResources = {
}, },
show: { show: {
label: "显示", label: "显示",
branch: "分支",
project: "项目",
host: "主机", host: "主机",
changeRequest: "拉取请求", changeRequest: "拉取请求",
checks: "检查", checks: "检查",
@@ -241,7 +241,13 @@ function BadgePreview({
<Text style={styles.previewTitle} numberOfLines={1}> <Text style={styles.previewTitle} numberOfLines={1}>
{t("settings.host.appearance.preview.workspaceName")} {t("settings.host.appearance.preview.workspaceName")}
</Text> </Text>
<WorkspaceMetaRow hostBadge={hostBadge} prHint={null} serviceSummary={null} /> <WorkspaceMetaRow
currentBranch={null}
projectName={null}
hostBadge={hostBadge}
prHint={null}
serviceSummary={null}
/>
</View> </View>
); );
} }