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
// configures name each item the same way twice.
const ROW_ITEM_ICONS: Record<SidebarRowItem, OptionIcon> = {
branch: withUnistyles(GitBranch),
project: withUnistyles(Folder),
host: withUnistyles(Server),
changeRequest: withUnistyles(GitPullRequest),
services: withUnistyles(Globe),
@@ -99,6 +101,8 @@ const TITLE_SOURCE_LABEL_KEYS: Record<WorkspaceTitleSource, string> = {
};
const ROW_ITEM_LABEL_KEYS: Record<SidebarRowItem, string> = {
branch: "sidebar.display.show.branch",
project: "sidebar.display.show.project",
host: "sidebar.display.show.host",
changeRequest: "sidebar.display.show.changeRequest",
services: "sidebar.display.show.services",
@@ -3,14 +3,17 @@ import {
DEFAULT_SIDEBAR_ROW_ITEMS,
isChecksHiddenByLegacyRowItem,
parseSidebarRowItems,
SIDEBAR_ROW_ITEMS,
} from "./row-items";
describe("parseSidebarRowItems", () => {
it("shows everything by default", () => {
for (const item of SIDEBAR_ROW_ITEMS) {
expect(DEFAULT_SIDEBAR_ROW_ITEMS[item]).toBe(true);
}
it("shows operational metadata but hides identity badges by default", () => {
expect(DEFAULT_SIDEBAR_ROW_ITEMS).toEqual({
branch: false,
project: false,
host: true,
changeRequest: true,
services: true,
});
});
it("applies stored overrides", () => {
@@ -21,7 +24,7 @@ describe("parseSidebarRowItems", () => {
});
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({
...DEFAULT_SIDEBAR_ROW_ITEMS,
host: false,
@@ -12,18 +12,22 @@
* 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 SidebarRowItems = Record<SidebarRowItem, boolean>;
/**
* 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".
*/
/** The persisted record is merged over these explicit product defaults. */
export const DEFAULT_SIDEBAR_ROW_ITEMS: SidebarRowItems = {
branch: false,
project: false,
host: true,
changeRequest: true,
services: true,
@@ -196,6 +196,8 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
<View style={sidebarWorkspaceRowStyles.rowRight}>{children}</View>
</View>
<WorkspaceMetaRow
currentBranch={workspace.currentBranch}
projectName={leadingProjectName}
hostBadge={hostBadge ?? null}
prHint={workspace.prHint}
serviceSummary={serviceSummary}
@@ -4,6 +4,8 @@ import { Pressable, Text, View, type GestureResponderEvent } from "react-native"
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
ExternalLink,
Folder,
GitBranch,
GitMerge,
GitPullRequest,
GitPullRequestClosed,
@@ -35,12 +37,15 @@ export {
const META_ICON_SIZE = HOST_BADGE_ICON_SIZE;
const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedFolder = withUnistyles(Folder);
const ThemedGitBranch = withUnistyles(GitBranch);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedGitMerge = withUnistyles(GitMerge);
const ThemedGitPullRequestClosed = withUnistyles(GitPullRequestClosed);
const ThemedGlobe = withUnistyles(Globe);
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 dangerMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
@@ -55,16 +60,22 @@ const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
* leaves color to mean status.
*/
export function WorkspaceMetaRow({
currentBranch,
projectName,
hostBadge,
prHint,
serviceSummary,
}: {
currentBranch: string | null;
projectName: string | null;
hostBadge: HostBadgeModel | null;
prHint: PrHint | null;
serviceSummary: WorkspaceServiceSummary | null;
}) {
const { rowItems, checksDisplay } = useSidebarMetaPreferences();
const items = selectMetaRowItems({
currentBranch,
projectName,
hasHostBadge: hostBadge !== null,
prHint,
serviceSummary,
@@ -93,6 +104,12 @@ function MetaItemNode({
item: MetaRowItem;
hostBadge: HostBadgeModel | null;
}): 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") {
return hostBadge ? <HostBadge badge={hostBadge} /> : null;
}
@@ -105,6 +122,20 @@ function MetaItemNode({
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
* 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,
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: {
opacity: 0.82,
},
@@ -17,6 +17,8 @@ const SERVICE: WorkspaceServiceSummary = { name: "web", health: null };
function select(overrides: Partial<Parameters<typeof selectMetaRowItems>[0]> = {}) {
return selectMetaRowItems({
currentBranch: "feature/sidebar-badges",
projectName: "Paseo",
hasHostBadge: true,
prHint: PR_HINT,
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);
describe("selectMetaRowItems", () => {
it("reads identity, then the change, then its state, then what is running", () => {
expect(kinds(select())).toEqual(["host", "changeRequest", "checks", "services"]);
it("puts the enabled branch and project badges first", () => {
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", () => {
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([
@@ -10,6 +10,8 @@ import type { WorkspaceServiceSummary } from "./service-summary";
* running. Identity first, then the work, then the work's state.
*/
export type MetaRowItem =
| { kind: "branch"; name: string }
| { kind: "project"; name: string }
| { kind: "host" }
| { kind: "changeRequest"; hint: PrHint }
| { 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.
*/
export function selectMetaRowItems(input: {
currentBranch: string | null;
projectName: string | null;
hasHostBadge: boolean;
prHint: PrHint | null;
serviceSummary: WorkspaceServiceSummary | null;
visible: SidebarRowItems;
checksDisplay: SidebarChecksDisplay;
}): MetaRowItem[] {
const { hasHostBadge, prHint, serviceSummary, visible, checksDisplay } = input;
const {
currentBranch,
projectName,
hasHostBadge,
prHint,
serviceSummary,
visible,
checksDisplay,
} = input;
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) {
items.push({ kind: "host" });
}
@@ -78,6 +78,8 @@ export interface Settings extends AppSettings {
}
const SidebarRowItemsSchema = z.strictObject({
branch: z.boolean().optional(),
project: z.boolean().optional(),
host: z.boolean().optional(),
changeRequest: z.boolean().optional(),
services: z.boolean().optional(),
+2
View File
@@ -970,6 +970,8 @@ export const ar: TranslationResources = {
},
show: {
label: "إظهار",
branch: "الفرع",
project: "المشروع",
host: "المضيف",
changeRequest: "طلب السحب",
checks: "الفحوصات",
+2
View File
@@ -979,6 +979,8 @@ export const en = {
},
show: {
label: "Show",
branch: "Branch",
project: "Project",
host: "Host",
changeRequest: "Pull request",
checks: "Checks",
+2
View File
@@ -1001,6 +1001,8 @@ export const es: TranslationResources = {
},
show: {
label: "Mostrar",
branch: "Rama",
project: "Proyecto",
host: "Host",
changeRequest: "Pull request",
checks: "Comprobaciones",
+2
View File
@@ -1000,6 +1000,8 @@ export const fr: TranslationResources = {
},
show: {
label: "Afficher",
branch: "Branche",
project: "Projet",
host: "Hôte",
changeRequest: "Pull request",
checks: "Vérifications",
+2
View File
@@ -981,6 +981,8 @@ export const ja: TranslationResources = {
},
show: {
label: "表示項目",
branch: "ブランチ",
project: "プロジェクト",
host: "ホスト",
changeRequest: "プルリクエスト",
checks: "チェック",
+2
View File
@@ -977,6 +977,8 @@ export const ko: TranslationResources = {
},
show: {
label: "표시 항목",
branch: "브랜치",
project: "프로젝트",
host: "호스트",
changeRequest: "풀 리퀘스트",
checks: "검사",
+2
View File
@@ -992,6 +992,8 @@ export const ptBR: TranslationResources = {
},
show: {
label: "Mostrar",
branch: "Branch",
project: "Projeto",
host: "Host",
changeRequest: "Pull request",
checks: "Verificações",
+2
View File
@@ -992,6 +992,8 @@ export const ru: TranslationResources = {
},
show: {
label: "Показывать",
branch: "Ветка",
project: "Проект",
host: "Хост",
changeRequest: "Pull request",
checks: "Проверки",
+2
View File
@@ -962,6 +962,8 @@ export const zhCN: TranslationResources = {
},
show: {
label: "显示",
branch: "分支",
project: "项目",
host: "主机",
changeRequest: "拉取请求",
checks: "检查",
@@ -241,7 +241,13 @@ function BadgePreview({
<Text style={styles.previewTitle} numberOfLines={1}>
{t("settings.host.appearance.preview.workspaceName")}
</Text>
<WorkspaceMetaRow hostBadge={hostBadge} prHint={null} serviceSummary={null} />
<WorkspaceMetaRow
currentBranch={null}
projectName={null}
hostBadge={hostBadge}
prHint={null}
serviceSummary={null}
/>
</View>
);
}