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:
@@ -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(),
|
||||
|
||||
@@ -970,6 +970,8 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
show: {
|
||||
label: "إظهار",
|
||||
branch: "الفرع",
|
||||
project: "المشروع",
|
||||
host: "المضيف",
|
||||
changeRequest: "طلب السحب",
|
||||
checks: "الفحوصات",
|
||||
|
||||
@@ -979,6 +979,8 @@ export const en = {
|
||||
},
|
||||
show: {
|
||||
label: "Show",
|
||||
branch: "Branch",
|
||||
project: "Project",
|
||||
host: "Host",
|
||||
changeRequest: "Pull request",
|
||||
checks: "Checks",
|
||||
|
||||
@@ -1001,6 +1001,8 @@ export const es: TranslationResources = {
|
||||
},
|
||||
show: {
|
||||
label: "Mostrar",
|
||||
branch: "Rama",
|
||||
project: "Proyecto",
|
||||
host: "Host",
|
||||
changeRequest: "Pull request",
|
||||
checks: "Comprobaciones",
|
||||
|
||||
@@ -1000,6 +1000,8 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
show: {
|
||||
label: "Afficher",
|
||||
branch: "Branche",
|
||||
project: "Projet",
|
||||
host: "Hôte",
|
||||
changeRequest: "Pull request",
|
||||
checks: "Vérifications",
|
||||
|
||||
@@ -981,6 +981,8 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
show: {
|
||||
label: "表示項目",
|
||||
branch: "ブランチ",
|
||||
project: "プロジェクト",
|
||||
host: "ホスト",
|
||||
changeRequest: "プルリクエスト",
|
||||
checks: "チェック",
|
||||
|
||||
@@ -977,6 +977,8 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
show: {
|
||||
label: "표시 항목",
|
||||
branch: "브랜치",
|
||||
project: "프로젝트",
|
||||
host: "호스트",
|
||||
changeRequest: "풀 리퀘스트",
|
||||
checks: "검사",
|
||||
|
||||
@@ -992,6 +992,8 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
show: {
|
||||
label: "Mostrar",
|
||||
branch: "Branch",
|
||||
project: "Projeto",
|
||||
host: "Host",
|
||||
changeRequest: "Pull request",
|
||||
checks: "Verificações",
|
||||
|
||||
@@ -992,6 +992,8 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
show: {
|
||||
label: "Показывать",
|
||||
branch: "Ветка",
|
||||
project: "Проект",
|
||||
host: "Хост",
|
||||
changeRequest: "Pull request",
|
||||
checks: "Проверки",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user