feat(app): sidebar display preferences, a shared menu engine, and a colour pass
Sidebar polish round. Menus. `dropdown-menu` and `context-menu` were two copies of one engine that differed only in how the trigger fires — identical from `computePosition` down — and neither could nest. Both are now thin wrappers over `components/ui/menu/`, which owns anchoring, a pure page-stack navigation model, and one presentation fork: flyout on pointer, pushed pages in a sheet on compact. All 31 call sites are unchanged. Items got a rounded inset fill, a visible separator, and `selected` (a check) split from `active` (a fill). Sidebar display preferences. The flat wall of four sections becomes one row per decision with its value, and per-feature toggles over what a workspace row shows: host, pull request, checks, scripts. Each is independent — a ticked toggle that draws nothing is a lie. `display-preferences/` hides that grouping lives in zustand while title source and row items are app settings. Timestamps. "Last activity" now ticks itself. The label rounds to the minute and never shows seconds; past a week it becomes a date. The timer lives in the leaf `<Text>`, at most one per resolution tier shared by every row, and a tick only re-renders when the string actually changed — so a memoized row that legitimately never re-renders still stays current. Colour. Full status tier regenerated at 90% of gamut max (was 85), same rule as the muted tier, lightness and hue untouched. File icons are pulled toward neutral in OKLab behind `getFileIconSvg` — material-icon-theme picks its palette for a tree where the icon is the only colour on the row, and ours is not. New/deleted files use the forge square-plus/square-minus glyphs at muted weight instead of tinted pills. Project icons round a little more; uploaded icons never do. The project status badge is a knockout again: it fills with the colour of the row behind it, so the ring reads as a gap in the icon rather than a white halo. File rows. The always-visible kebab is gone from the file explorer and diff tree in favour of right-click, which also reclaims the reserved action slot.
This commit is contained in:
@@ -0,0 +1,406 @@
|
||||
import { useCallback, useMemo, type ComponentType, type ReactElement, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
CircleCheck,
|
||||
CircleDashed,
|
||||
Clock,
|
||||
Diff,
|
||||
Folder,
|
||||
GitBranch,
|
||||
GitPullRequest,
|
||||
Server,
|
||||
Settings2,
|
||||
SquareTerminal,
|
||||
Type,
|
||||
} from "lucide-react-native";
|
||||
import {
|
||||
MenuItem,
|
||||
MenuRoot,
|
||||
MenuSeparator,
|
||||
MenuSubTrigger,
|
||||
MenuSurface,
|
||||
MenuTrigger,
|
||||
type MenuPageDefinition,
|
||||
} from "@/components/ui/menu";
|
||||
import { HostStatusDot } from "@/components/host-status-dot";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||
import type { WorkspaceTitleSource } from "@/hooks/use-settings";
|
||||
import { useSidebarDisplayPreferences, type SidebarTrailingChoice } from "./model";
|
||||
import { SIDEBAR_ROW_ITEMS, type SidebarRowItem } from "./row-items";
|
||||
|
||||
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
const ThemedSettings2 = withUnistyles(Settings2);
|
||||
|
||||
/** Fits the item's 16pt leading slot with a hair of room, matching the trailing check. */
|
||||
const OPTION_ICON_SIZE = 14;
|
||||
const MENU_WIDTH = 232;
|
||||
|
||||
type OptionIcon = ComponentType<{
|
||||
size: number;
|
||||
uniProps: (theme: Theme) => { color: string };
|
||||
}>;
|
||||
|
||||
// Options carry icons; the root rows deliberately do not. The root is four labels with their
|
||||
// current values, and a column of icons there would be decoration competing with the values.
|
||||
const GROUPING_ICONS: Record<SidebarGroupMode, OptionIcon> = {
|
||||
project: withUnistyles(Folder),
|
||||
status: withUnistyles(CircleDashed),
|
||||
};
|
||||
|
||||
const TITLE_SOURCE_ICONS: Record<WorkspaceTitleSource, OptionIcon> = {
|
||||
title: withUnistyles(Type),
|
||||
branch: withUnistyles(GitBranch),
|
||||
};
|
||||
|
||||
// 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> = {
|
||||
host: withUnistyles(Server),
|
||||
changeRequest: withUnistyles(GitPullRequest),
|
||||
checks: withUnistyles(CircleCheck),
|
||||
scripts: withUnistyles(SquareTerminal),
|
||||
};
|
||||
|
||||
const TRAILING_ICONS: Record<SidebarTrailingChoice, OptionIcon> = {
|
||||
diff: withUnistyles(Diff),
|
||||
timestamp: withUnistyles(Clock),
|
||||
};
|
||||
|
||||
const GROUPING_MODES: readonly SidebarGroupMode[] = ["project", "status"];
|
||||
const TITLE_SOURCES: readonly WorkspaceTitleSource[] = ["title", "branch"];
|
||||
const TRAILING_CHOICES: readonly SidebarTrailingChoice[] = ["diff", "timestamp"];
|
||||
|
||||
const GROUPING_LABEL_KEYS: Record<SidebarGroupMode, string> = {
|
||||
project: "sidebar.display.grouping.project",
|
||||
status: "sidebar.display.grouping.status",
|
||||
};
|
||||
|
||||
const TITLE_SOURCE_LABEL_KEYS: Record<WorkspaceTitleSource, string> = {
|
||||
title: "sidebar.display.titleSource.title",
|
||||
branch: "sidebar.display.titleSource.branch",
|
||||
};
|
||||
|
||||
const ROW_ITEM_LABEL_KEYS: Record<SidebarRowItem, string> = {
|
||||
host: "sidebar.display.show.host",
|
||||
changeRequest: "sidebar.display.show.changeRequest",
|
||||
checks: "sidebar.display.show.checks",
|
||||
scripts: "sidebar.display.show.scripts",
|
||||
};
|
||||
|
||||
const TRAILING_LABEL_KEYS: Record<SidebarTrailingChoice, string> = {
|
||||
diff: "sidebar.display.show.diff",
|
||||
timestamp: "sidebar.display.show.timestamp",
|
||||
};
|
||||
|
||||
/**
|
||||
* What the sidebar shows and how it is arranged.
|
||||
*
|
||||
* The root is one row per decision with its current value; the options live a level down. The
|
||||
* shape is deliberate — every option of every decision on one surface is what this menu used to
|
||||
* be, and it grew a row for each host on top of that.
|
||||
*/
|
||||
export function SidebarDisplayPreferencesMenu(): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const preferences = useSidebarDisplayPreferences();
|
||||
const hosts = useHosts();
|
||||
|
||||
const triggerStyle = useCallback(
|
||||
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.trigger,
|
||||
hovered && styles.triggerHovered,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const showHostFilter = hosts.length > 1;
|
||||
|
||||
const pages = useMemo<MenuPageDefinition[]>(() => {
|
||||
const definitions: MenuPageDefinition[] = [
|
||||
{
|
||||
id: "grouping",
|
||||
title: t("sidebar.display.grouping.label"),
|
||||
content: (
|
||||
<OptionList
|
||||
values={GROUPING_MODES}
|
||||
icons={GROUPING_ICONS}
|
||||
labelKeys={GROUPING_LABEL_KEYS}
|
||||
selectedValue={preferences.grouping}
|
||||
onSelect={preferences.setGrouping}
|
||||
testIDPrefix="sidebar-grouping"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "titleSource",
|
||||
title: t("sidebar.display.titleSource.label"),
|
||||
content: (
|
||||
<OptionList
|
||||
values={TITLE_SOURCES}
|
||||
icons={TITLE_SOURCE_ICONS}
|
||||
labelKeys={TITLE_SOURCE_LABEL_KEYS}
|
||||
selectedValue={preferences.titleSource}
|
||||
onSelect={preferences.setTitleSource}
|
||||
testIDPrefix="sidebar-workspace-title-source"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "show",
|
||||
title: t("sidebar.display.show.label"),
|
||||
content: <ShowPage preferences={preferences} />,
|
||||
},
|
||||
];
|
||||
|
||||
if (showHostFilter) {
|
||||
definitions.push({
|
||||
id: "hostFilter",
|
||||
title: t("sidebar.display.hostFilter.label"),
|
||||
content: <HostFilterPage preferences={preferences} hosts={hosts} />,
|
||||
});
|
||||
}
|
||||
return definitions;
|
||||
}, [t, preferences, hosts, showHostFilter]);
|
||||
|
||||
return (
|
||||
<MenuRoot compactMode="sheet">
|
||||
<MenuTrigger
|
||||
style={triggerStyle}
|
||||
accessibilityRole={isWeb ? undefined : "button"}
|
||||
accessibilityLabel={t("sidebar.display.trigger")}
|
||||
testID="sidebar-display-preferences-menu"
|
||||
>
|
||||
<ThemedSettings2 size={14} uniProps={mutedIconMapping} />
|
||||
</MenuTrigger>
|
||||
<MenuSurface
|
||||
align="end"
|
||||
width={MENU_WIDTH}
|
||||
pages={pages}
|
||||
sheetTitle={t("sidebar.display.heading")}
|
||||
testID="sidebar-display-preferences-content"
|
||||
>
|
||||
<MenuSubTrigger
|
||||
id="grouping"
|
||||
value={t(GROUPING_LABEL_KEYS[preferences.grouping])}
|
||||
testID="sidebar-display-grouping"
|
||||
>
|
||||
{t("sidebar.display.grouping.label")}
|
||||
</MenuSubTrigger>
|
||||
<MenuSubTrigger
|
||||
id="titleSource"
|
||||
value={t(TITLE_SOURCE_LABEL_KEYS[preferences.titleSource])}
|
||||
testID="sidebar-display-title-source"
|
||||
>
|
||||
{t("sidebar.display.titleSource.label")}
|
||||
</MenuSubTrigger>
|
||||
<MenuSubTrigger id="show" testID="sidebar-display-show">
|
||||
{t("sidebar.display.show.label")}
|
||||
</MenuSubTrigger>
|
||||
{showHostFilter ? (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
{/* A filtered sidebar looks like workspaces went missing, so the branch says so
|
||||
from the root rather than making you open it to find out. */}
|
||||
<MenuSubTrigger
|
||||
id="hostFilter"
|
||||
indicator={preferences.hostFilters.length > 0}
|
||||
testID="sidebar-display-host-filter"
|
||||
>
|
||||
{t("sidebar.display.hostFilter.label")}
|
||||
</MenuSubTrigger>
|
||||
</>
|
||||
) : null}
|
||||
</MenuSurface>
|
||||
</MenuRoot>
|
||||
);
|
||||
}
|
||||
|
||||
type Preferences = ReturnType<typeof useSidebarDisplayPreferences>;
|
||||
|
||||
/** One option row: its mark on the left, its label, and a check when it is the chosen one. */
|
||||
function OptionItem<Value extends string>({
|
||||
value,
|
||||
icon: Icon,
|
||||
label,
|
||||
selected,
|
||||
closeOnSelect = true,
|
||||
onSelect,
|
||||
testID,
|
||||
}: {
|
||||
value: Value;
|
||||
icon: OptionIcon;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
closeOnSelect?: boolean;
|
||||
onSelect: (value: Value) => void;
|
||||
testID: string;
|
||||
}): ReactElement {
|
||||
const handleSelect = useCallback(() => onSelect(value), [onSelect, value]);
|
||||
const leading = useMemo(
|
||||
() => <Icon size={OPTION_ICON_SIZE} uniProps={mutedIconMapping} />,
|
||||
[Icon],
|
||||
);
|
||||
return (
|
||||
<MenuItem
|
||||
selected={selected}
|
||||
leading={leading}
|
||||
closeOnSelect={closeOnSelect}
|
||||
onSelect={handleSelect}
|
||||
testID={testID}
|
||||
>
|
||||
{label}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
/** A page of mutually exclusive options — pick one and the menu closes. */
|
||||
function OptionList<Value extends string>({
|
||||
values,
|
||||
icons,
|
||||
labelKeys,
|
||||
selectedValue,
|
||||
onSelect,
|
||||
testIDPrefix,
|
||||
}: {
|
||||
values: readonly Value[];
|
||||
icons: Record<Value, OptionIcon>;
|
||||
labelKeys: Record<Value, string>;
|
||||
selectedValue: Value;
|
||||
onSelect: (value: Value) => void;
|
||||
testIDPrefix: string;
|
||||
}): ReactNode {
|
||||
const { t } = useTranslation();
|
||||
return values.map((value) => (
|
||||
<OptionItem
|
||||
key={value}
|
||||
value={value}
|
||||
icon={icons[value]}
|
||||
label={t(labelKeys[value])}
|
||||
selected={value === selectedValue}
|
||||
onSelect={onSelect}
|
||||
testID={`${testIDPrefix}-${value}`}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Two groups, split by the separator. Above it, what a row may say about a workspace — each one
|
||||
* independent. Below it, the one thing the slot to the right of the title holds, so picking the
|
||||
* one already showing empties the slot and gives the width back to the title.
|
||||
*/
|
||||
function ShowPage({ preferences }: { preferences: Preferences }): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
{SIDEBAR_ROW_ITEMS.map((item) => (
|
||||
<OptionItem
|
||||
key={item}
|
||||
value={item}
|
||||
icon={ROW_ITEM_ICONS[item]}
|
||||
label={t(ROW_ITEM_LABEL_KEYS[item])}
|
||||
selected={preferences.rowItems[item]}
|
||||
closeOnSelect={false}
|
||||
onSelect={preferences.toggleRowItem}
|
||||
testID={`sidebar-row-item-${item}`}
|
||||
/>
|
||||
))}
|
||||
<MenuSeparator />
|
||||
{TRAILING_CHOICES.map((choice) => (
|
||||
<OptionItem
|
||||
key={choice}
|
||||
value={choice}
|
||||
icon={TRAILING_ICONS[choice]}
|
||||
label={t(TRAILING_LABEL_KEYS[choice])}
|
||||
selected={preferences.trailing === choice}
|
||||
closeOnSelect={false}
|
||||
onSelect={preferences.toggleTrailing}
|
||||
testID={`sidebar-workspace-trailing-${choice}`}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HostFilterPage({
|
||||
preferences,
|
||||
hosts,
|
||||
}: {
|
||||
preferences: Preferences;
|
||||
hosts: ReturnType<typeof useHosts>;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<MenuItem
|
||||
selected={preferences.hostFilters.length === 0}
|
||||
closeOnSelect={false}
|
||||
onSelect={preferences.clearHostFilters}
|
||||
testID="sidebar-host-filter-all"
|
||||
>
|
||||
{t("sidebar.display.hostFilter.all")}
|
||||
</MenuItem>
|
||||
{hosts.map((host) => (
|
||||
<HostFilterItem
|
||||
key={host.serverId}
|
||||
serverId={host.serverId}
|
||||
label={host.label?.trim() || host.serverId}
|
||||
selected={preferences.hostFilters.includes(host.serverId)}
|
||||
onToggle={preferences.toggleHostFilter}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The one option row whose mark is live state rather than an icon. */
|
||||
function HostFilterItem({
|
||||
serverId,
|
||||
label,
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
serverId: string;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onToggle: (serverId: string) => void;
|
||||
}): ReactElement {
|
||||
const handleSelect = useCallback(() => onToggle(serverId), [onToggle, serverId]);
|
||||
const leading = useMemo(
|
||||
() => (
|
||||
<View testID={`sidebar-host-filter-status-${serverId}`}>
|
||||
<HostStatusDot serverId={serverId} />
|
||||
</View>
|
||||
),
|
||||
[serverId],
|
||||
);
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
selected={selected}
|
||||
closeOnSelect={false}
|
||||
leading={leading}
|
||||
onSelect={handleSelect}
|
||||
testID={`sidebar-host-filter-${serverId}`}
|
||||
>
|
||||
{label}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
triggerHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import {
|
||||
useAppSettings,
|
||||
type SidebarWorkspaceTrailing,
|
||||
type WorkspaceTitleSource,
|
||||
} from "@/hooks/use-settings";
|
||||
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||
import { DEFAULT_SIDEBAR_ROW_ITEMS, type SidebarRowItem, type SidebarRowItems } from "./row-items";
|
||||
|
||||
/** The trailing slot holds one thing, so these are a choice rather than toggles. */
|
||||
export type SidebarTrailingChoice = Exclude<SidebarWorkspaceTrailing, "none">;
|
||||
|
||||
export interface SidebarDisplayPreferences {
|
||||
grouping: SidebarGroupMode;
|
||||
setGrouping: (mode: SidebarGroupMode) => void;
|
||||
titleSource: WorkspaceTitleSource;
|
||||
setTitleSource: (source: WorkspaceTitleSource) => void;
|
||||
rowItems: SidebarRowItems;
|
||||
toggleRowItem: (item: SidebarRowItem) => void;
|
||||
trailing: SidebarWorkspaceTrailing;
|
||||
/** Picking the choice that is already showing clears the slot. */
|
||||
toggleTrailing: (choice: SidebarTrailingChoice) => void;
|
||||
hostFilters: readonly string[];
|
||||
toggleHostFilter: (serverId: string) => void;
|
||||
clearHostFilters: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every decision the sidebar's display-preferences menu can make, behind one interface.
|
||||
*
|
||||
* Grouping and host filters live in a local zustand store while the title source and row items
|
||||
* are synced app settings — a split that exists for good reasons (a filter is transient view
|
||||
* state; a preference follows you) and that the menu has no business knowing about. Callers ask
|
||||
* this for a value and set it; where it lands is this module's problem.
|
||||
*/
|
||||
export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
const grouping = useSidebarViewStore((state) => state.groupMode);
|
||||
const setGrouping = useSidebarViewStore((state) => state.setGroupMode);
|
||||
const hostFilters = useSidebarViewStore((state) => state.hostFilters);
|
||||
const toggleHostFilter = useSidebarViewStore((state) => state.toggleHostFilter);
|
||||
const clearHostFilters = useSidebarViewStore((state) => state.clearHostFilters);
|
||||
|
||||
const {
|
||||
settings: { workspaceTitleSource, sidebarWorkspaceTrailing, sidebarRowItems },
|
||||
updateSettings,
|
||||
} = useAppSettings();
|
||||
|
||||
const setTitleSource = useCallback(
|
||||
(source: WorkspaceTitleSource) => {
|
||||
void updateSettings({ workspaceTitleSource: source });
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const toggleRowItem = useCallback(
|
||||
(item: SidebarRowItem) => {
|
||||
void updateSettings({
|
||||
sidebarRowItems: { ...sidebarRowItems, [item]: !sidebarRowItems[item] },
|
||||
});
|
||||
},
|
||||
[updateSettings, sidebarRowItems],
|
||||
);
|
||||
|
||||
const toggleTrailing = useCallback(
|
||||
(choice: SidebarTrailingChoice) => {
|
||||
void updateSettings({
|
||||
sidebarWorkspaceTrailing: sidebarWorkspaceTrailing === choice ? "none" : choice,
|
||||
});
|
||||
},
|
||||
[updateSettings, sidebarWorkspaceTrailing],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
grouping,
|
||||
setGrouping,
|
||||
titleSource: workspaceTitleSource,
|
||||
setTitleSource,
|
||||
rowItems: sidebarRowItems,
|
||||
toggleRowItem,
|
||||
trailing: sidebarWorkspaceTrailing,
|
||||
toggleTrailing,
|
||||
hostFilters,
|
||||
toggleHostFilter,
|
||||
clearHostFilters,
|
||||
}),
|
||||
[
|
||||
grouping,
|
||||
setGrouping,
|
||||
workspaceTitleSource,
|
||||
setTitleSource,
|
||||
sidebarRowItems,
|
||||
toggleRowItem,
|
||||
sidebarWorkspaceTrailing,
|
||||
toggleTrailing,
|
||||
hostFilters,
|
||||
toggleHostFilter,
|
||||
clearHostFilters,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Just the row items, for the row renderers. They re-render per workspace, so they subscribe to
|
||||
* the one field they use rather than to every preference in the menu.
|
||||
*/
|
||||
export function useSidebarRowItems(): SidebarRowItems {
|
||||
const {
|
||||
settings: { sidebarRowItems },
|
||||
} = useAppSettings();
|
||||
return sidebarRowItems ?? DEFAULT_SIDEBAR_ROW_ITEMS;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_SIDEBAR_ROW_ITEMS, 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("applies stored overrides", () => {
|
||||
expect(parseSidebarRowItems({ checks: false })).toEqual({
|
||||
...DEFAULT_SIDEBAR_ROW_ITEMS,
|
||||
checks: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves items absent from storage at their default", () => {
|
||||
// A newly added item must ship visible rather than inheriting "missing means off".
|
||||
expect(parseSidebarRowItems({ host: false })).toEqual({
|
||||
...DEFAULT_SIDEBAR_ROW_ITEMS,
|
||||
host: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores unknown keys and non-boolean values", () => {
|
||||
expect(
|
||||
parseSidebarRowItems({ checks: "yes", nonsense: false, scripts: null, host: false }),
|
||||
).toEqual({ ...DEFAULT_SIDEBAR_ROW_ITEMS, host: false });
|
||||
});
|
||||
|
||||
it.each([[null], [undefined], ["diff"], [42], [["checks"]]])(
|
||||
"falls back to defaults for %s",
|
||||
(value) => {
|
||||
expect(parseSidebarRowItems(value)).toEqual(DEFAULT_SIDEBAR_ROW_ITEMS);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not hand back the shared default object", () => {
|
||||
const parsed = parseSidebarRowItems({ checks: false });
|
||||
expect(parsed).not.toBe(DEFAULT_SIDEBAR_ROW_ITEMS);
|
||||
expect(DEFAULT_SIDEBAR_ROW_ITEMS.checks).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Which facts a sidebar workspace row is allowed to show about a workspace.
|
||||
*
|
||||
* Each item is independent — this is not a mode. Turning one off removes it from every row and
|
||||
* gives the space back to the title.
|
||||
*
|
||||
* Pure on purpose: `hooks/use-settings/storage.ts` validates the persisted value through
|
||||
* `parseSidebarRowItems` rather than growing its own checks, so this module stays the one place
|
||||
* that knows what an item is.
|
||||
*/
|
||||
|
||||
export const SIDEBAR_ROW_ITEMS = ["host", "changeRequest", "checks", "scripts"] 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".
|
||||
*/
|
||||
export const DEFAULT_SIDEBAR_ROW_ITEMS: SidebarRowItems = {
|
||||
host: true,
|
||||
changeRequest: true,
|
||||
checks: true,
|
||||
scripts: true,
|
||||
};
|
||||
|
||||
function isSidebarRowItem(value: string): value is SidebarRowItem {
|
||||
return (SIDEBAR_ROW_ITEMS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function parseSidebarRowItems(value: unknown): SidebarRowItems {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return DEFAULT_SIDEBAR_ROW_ITEMS;
|
||||
}
|
||||
|
||||
const stored = value as Record<string, unknown>;
|
||||
const result = { ...DEFAULT_SIDEBAR_ROW_ITEMS };
|
||||
for (const [key, entry] of Object.entries(stored)) {
|
||||
if (isSidebarRowItem(key) && typeof entry === "boolean") {
|
||||
result[key] = entry;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import { Text, View, type TextStyle, type ViewStyle } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Server } from "lucide-react-native";
|
||||
import { HOST_COLORS, type HostColor } from "@/hosts/appearance";
|
||||
import { identityColor, identityTint } from "@/styles/identity-colors";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
const ThemedServer = withUnistyles(Server);
|
||||
const MAX_NAMED_BADGE_WIDTH = 96;
|
||||
|
||||
interface HostBadgeColorOverride {
|
||||
badge: ViewStyle | null;
|
||||
text: TextStyle | null;
|
||||
}
|
||||
|
||||
function buildColorOverrides(): Record<HostColor, HostBadgeColorOverride> {
|
||||
const byColor = {} as Record<HostColor, HostBadgeColorOverride>;
|
||||
for (const color of HOST_COLORS) {
|
||||
byColor[color] =
|
||||
color === "none"
|
||||
? { badge: null, text: null }
|
||||
: {
|
||||
badge: { backgroundColor: identityTint(color) },
|
||||
text: { color: identityColor(color) },
|
||||
};
|
||||
}
|
||||
return byColor;
|
||||
}
|
||||
|
||||
// The identity table is theme-independent, so its icon mapping is fixed for the life of the
|
||||
// module. It must not be rebuilt per render: a fresh `uniProps` identity makes withUnistyles
|
||||
// re-subscribe every pass.
|
||||
function buildIconMappings(): 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"
|
||||
? (theme: Theme) => ({ color: theme.colors.foregroundMuted })
|
||||
: () => ({ color: identityColor(color) });
|
||||
}
|
||||
return byColor;
|
||||
}
|
||||
|
||||
const COLOR_OVERRIDES = buildColorOverrides();
|
||||
const ICON_MAPPINGS = buildIconMappings();
|
||||
|
||||
/**
|
||||
* Which host a workspace lives on, as a pill after the title. Only rendered in multi-host
|
||||
* setups — see selectHostBadges. It's a badge rather than a second line of text because the
|
||||
* row has to stay scannable at one line per workspace.
|
||||
*
|
||||
*/
|
||||
export function HostBadge({
|
||||
serverId,
|
||||
label,
|
||||
color,
|
||||
showLabel,
|
||||
}: {
|
||||
serverId: string;
|
||||
label: string;
|
||||
color: HostColor;
|
||||
showLabel: boolean;
|
||||
}) {
|
||||
const override = COLOR_OVERRIDES[color];
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
hostBadgeStyles.badge,
|
||||
showLabel ? null : hostBadgeStyles.badgeIconOnly,
|
||||
override.badge,
|
||||
]}
|
||||
testID={`sidebar-host-badge-${serverId}`}
|
||||
>
|
||||
<ThemedServer size={9} uniProps={ICON_MAPPINGS[color]} />
|
||||
{showLabel ? (
|
||||
<Text style={[hostBadgeStyles.text, override.text]} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const hostBadgeStyles = StyleSheet.create((theme) => ({
|
||||
// Sized off the title's 20pt line box so the pill never grows the row. A named badge must
|
||||
// leave room for the workspace title and trailing actions, even when a host has an arbitrary
|
||||
// custom label. The full host name remains in the row's accessible label and hover card.
|
||||
badge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
height: 16,
|
||||
paddingLeft: 4,
|
||||
paddingRight: 6,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
maxWidth: MAX_NAMED_BADGE_WIDTH,
|
||||
minWidth: 0,
|
||||
flexShrink: 0,
|
||||
},
|
||||
// Icon-only drops the label, so the asymmetric padding that made room for text leaves a
|
||||
// lopsided pill. Symmetric padding recenters the icon.
|
||||
badgeIconOnly: {
|
||||
paddingLeft: 4,
|
||||
paddingRight: 4,
|
||||
flexShrink: 0,
|
||||
},
|
||||
text: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
lineHeight: 14,
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
}));
|
||||
@@ -4,7 +4,7 @@ import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native";
|
||||
import { ProjectIconView } from "@/components/project-icon-view";
|
||||
import { PulsingStatusDot } from "@/components/sidebar/pulsing-status-dot";
|
||||
import { STATUS_BUCKET_LABELS } from "@/hooks/sidebar-status-view-model";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import {
|
||||
getProjectStatusBadgeContent,
|
||||
@@ -13,25 +13,25 @@ import {
|
||||
} from "@/utils/project-status-badge-content";
|
||||
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
|
||||
// Every surfaced status shares one badge shell, so the badge never changes size or position
|
||||
// between states. Only the thing inside it changes.
|
||||
const STATUS_BADGE_SIZE = 14;
|
||||
const STATUS_BADGE_BORDER_WIDTH = 1;
|
||||
const STATUS_BADGE_SIZE = 12;
|
||||
// How far the badge overhangs the icon's bottom-right corner. Well short of half the badge:
|
||||
// centered on the corner it reads as hanging off the icon rather than sitting on it, and the
|
||||
// sidebar row has no padding there to absorb the overhang.
|
||||
const STATUS_BADGE_OFFSET = -5;
|
||||
// Both glyphs must be EVEN. The shell's interior is 12pt (14 minus the 1pt ring on each side),
|
||||
// so a centered glyph of size N sits at a (12 - N) / 2 offset — fractional for odd N, which the
|
||||
// browser snaps to a device-pixel boundary and renders visibly off-center (an odd size measured
|
||||
// 1.5 device px right and down at 3x, ~3px of asymmetry between opposite gaps). Even sizes
|
||||
// divide the interior into whole pixels and land dead center with no correction.
|
||||
const STATUS_BADGE_OFFSET = -4;
|
||||
// Both glyphs must be EVEN. A centered glyph of size N sits at a (12 - N) / 2 offset — fractional
|
||||
// for odd N, which the browser snaps to a device-pixel boundary and renders visibly off-center (an
|
||||
// odd size measured 1.5 device px right and down at 3x, ~3px of asymmetry between opposite gaps).
|
||||
// Even sizes divide the shell into whole pixels and land dead center with no correction.
|
||||
//
|
||||
// Lucide's circle-alert paints ~83% of its nominal size, so an alert of 10 draws a ~8.3pt circle
|
||||
// against the 8pt dot — the two states read as the same-diameter disc.
|
||||
const STATUS_BADGE_DOT_SIZE = 8;
|
||||
const STATUS_BADGE_ALERT_SIZE = 10;
|
||||
// Lucide's circle-alert paints ~83% of its nominal size, so an alert of 8 draws a ~6.6pt circle
|
||||
// against the 6pt dot — the two states read as the same-diameter disc. 8 is the closest even
|
||||
// size to the exact match (6 / 0.83 = 7.2); 6 would undershoot the dot rather than meet it.
|
||||
const STATUS_BADGE_DOT_SIZE = 6;
|
||||
const STATUS_BADGE_ALERT_SIZE = 8;
|
||||
// Matches the workspace title's lineHeight (sidebar-workspace-row-content's
|
||||
// workspaceBranchText) so the icon centers on the title rather than floating above it.
|
||||
const LEADING_SLOT_HEIGHT = 20;
|
||||
@@ -55,6 +55,7 @@ export function ProjectLeadingVisual({
|
||||
iconDataUri,
|
||||
statusBucket,
|
||||
projectViewKey,
|
||||
backdrop,
|
||||
chevron = null,
|
||||
showChevron = false,
|
||||
isArchiving = false,
|
||||
@@ -64,6 +65,8 @@ export function ProjectLeadingVisual({
|
||||
/** Aggregate status of the project's workspaces; null when it shouldn't be surfaced. */
|
||||
statusBucket: SidebarStateBucket | null;
|
||||
projectViewKey: string;
|
||||
/** The row's current background, so the status badge can knock out of it. */
|
||||
backdrop: SurfaceBackdrop;
|
||||
chevron?: "expand" | "collapse" | null;
|
||||
showChevron?: boolean;
|
||||
isArchiving?: boolean;
|
||||
@@ -90,6 +93,7 @@ export function ProjectLeadingVisual({
|
||||
displayName={displayName}
|
||||
projectViewKey={projectViewKey}
|
||||
statusBucket={statusBucket}
|
||||
backdrop={backdrop}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -104,6 +108,7 @@ export function ProjectStatusIndicator({
|
||||
displayName,
|
||||
projectViewKey,
|
||||
statusBucket,
|
||||
backdrop,
|
||||
loading = false,
|
||||
testID,
|
||||
}: {
|
||||
@@ -111,13 +116,15 @@ export function ProjectStatusIndicator({
|
||||
displayName: string;
|
||||
projectViewKey: string;
|
||||
statusBucket: SidebarStateBucket | null;
|
||||
/** The row's current background, so the status badge can knock out of it. */
|
||||
backdrop: SurfaceBackdrop;
|
||||
loading?: boolean;
|
||||
testID?: string;
|
||||
}) {
|
||||
const placeholderInitial = projectIconPlaceholderLabelFromDisplayName(displayName)
|
||||
.charAt(0)
|
||||
.toUpperCase();
|
||||
// A row that's still starting up and a row that's working are both "busy", and at 8pt
|
||||
// A row that's still starting up and a row that's working are both "busy", and at this size
|
||||
// there's no room to draw the difference — so `loading` just resolves to the running bucket
|
||||
// and they share one badge.
|
||||
const badgeBucket = loading ? "running" : statusBucket;
|
||||
@@ -140,7 +147,11 @@ export function ProjectStatusIndicator({
|
||||
projectViewKey={projectViewKey}
|
||||
/>
|
||||
{badgeContent === null || badgeBucket === null ? null : (
|
||||
<ProjectStatusBadge content={badgeContent} statusBucket={badgeBucket} />
|
||||
<ProjectStatusBadge
|
||||
content={badgeContent}
|
||||
statusBucket={badgeBucket}
|
||||
backdrop={backdrop}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
@@ -150,15 +161,17 @@ export function ProjectStatusIndicator({
|
||||
function ProjectStatusBadge({
|
||||
content,
|
||||
statusBucket,
|
||||
backdrop,
|
||||
}: {
|
||||
content: ProjectStatusBadgeContent;
|
||||
statusBucket: SidebarStateBucket;
|
||||
backdrop: SurfaceBackdrop;
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
role="status"
|
||||
accessibilityLabel={STATUS_BUCKET_LABELS[statusBucket]}
|
||||
style={styles.statusBadge}
|
||||
style={[styles.statusBadge, getStatusBadgeBackdropStyle(backdrop)]}
|
||||
testID="project-status-badge"
|
||||
>
|
||||
{content.kind === "alert" ? (
|
||||
@@ -170,6 +183,17 @@ function ProjectStatusBadge({
|
||||
);
|
||||
}
|
||||
|
||||
function getStatusBadgeBackdropStyle(backdrop: SurfaceBackdrop): ViewStyle {
|
||||
switch (backdrop) {
|
||||
case "surfaceSidebarHover":
|
||||
return styles.statusBadgeOnSidebarHover;
|
||||
case "surface2":
|
||||
return styles.statusBadgeOnSurface2;
|
||||
default:
|
||||
return styles.statusBadgeOnSidebar;
|
||||
}
|
||||
}
|
||||
|
||||
function ProjectStatusDot({ bucket }: { bucket: ProjectStatusBadgeDotBucket }) {
|
||||
if (bucket !== "running") {
|
||||
return <View testID="project-status-dot" style={getStatusDotColorStyle(bucket)} />;
|
||||
@@ -191,8 +215,7 @@ function ProjectIcon({
|
||||
iconDataUri={iconDataUri}
|
||||
initial={placeholderInitial}
|
||||
projectViewKey={projectViewKey}
|
||||
imageStyle={styles.projectIcon}
|
||||
fallbackStyle={styles.projectIconFallback}
|
||||
size={ICON_SIZE.md}
|
||||
textStyle={styles.projectIconFallbackText}
|
||||
/>
|
||||
);
|
||||
@@ -245,24 +268,13 @@ const styles = StyleSheet.create((theme) => {
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
},
|
||||
projectIcon: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
},
|
||||
projectIconFallback: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
projectIconFallbackText: {
|
||||
fontSize: 9,
|
||||
},
|
||||
// The shell the alert and dot statuses share. It straddles the icon's bottom-right corner
|
||||
// (half in, half out) so the lettered project box stays readable, and its ring is
|
||||
// sidebar-colored so the badge separates from the icon underneath rather than blending in.
|
||||
// (half in, half out) so the lettered project box stays readable. The shell is a knockout:
|
||||
// it is filled with the colour of the row behind it, which is what makes the ring around the
|
||||
// dot read as a gap in the icon rather than as a white halo drawn on top of it.
|
||||
statusBadge: {
|
||||
position: "absolute",
|
||||
right: STATUS_BADGE_OFFSET,
|
||||
@@ -270,13 +282,13 @@ const styles = StyleSheet.create((theme) => {
|
||||
width: STATUS_BADGE_SIZE,
|
||||
height: STATUS_BADGE_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderWidth: STATUS_BADGE_BORDER_WIDTH,
|
||||
borderColor: theme.colors.surfaceSidebar,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "hidden",
|
||||
},
|
||||
statusBadgeOnSidebar: { backgroundColor: theme.colors.surfaceSidebar },
|
||||
statusBadgeOnSidebarHover: { backgroundColor: theme.colors.surfaceSidebarHover },
|
||||
statusBadgeOnSurface2: { backgroundColor: theme.colors.surface2 },
|
||||
statusDotRunning: statusDot("running"),
|
||||
statusDotFailed: statusDot("failed"),
|
||||
statusDotAttention: statusDot("attention"),
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Settings2 } from "lucide-react-native";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { HostStatusDot } from "@/components/host-status-dot";
|
||||
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||
import { useAppSettings, type WorkspaceTitleSource } from "@/hooks/use-settings";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||
|
||||
const ThemedSettings2 = withUnistyles(Settings2);
|
||||
const filterColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
const GROUP_MODE_ITEMS: Array<{ value: SidebarGroupMode; label: string }> = [
|
||||
{ value: "project", label: "Project" },
|
||||
{ value: "status", label: "Status" },
|
||||
];
|
||||
|
||||
const WORKSPACE_TITLE_SOURCE_ITEMS: Array<{ value: WorkspaceTitleSource; label: string }> = [
|
||||
{ value: "title", label: "Title" },
|
||||
{ value: "branch", label: "Branch name" },
|
||||
];
|
||||
|
||||
interface DisplayPreferenceOption<Value extends string> {
|
||||
value: Value;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function SidebarDisplayPreferencesMenu() {
|
||||
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
||||
const hostFilters = useSidebarViewStore((state) => state.hostFilters);
|
||||
const setGroupMode = useSidebarViewStore((state) => state.setGroupMode);
|
||||
const toggleHostFilter = useSidebarViewStore((state) => state.toggleHostFilter);
|
||||
const clearHostFilters = useSidebarViewStore((state) => state.clearHostFilters);
|
||||
const hosts = useHosts();
|
||||
const {
|
||||
settings: { workspaceTitleSource },
|
||||
updateSettings,
|
||||
} = useAppSettings();
|
||||
|
||||
const handleSelectMode = useCallback(
|
||||
(mode: SidebarGroupMode) => {
|
||||
setGroupMode(mode);
|
||||
},
|
||||
[setGroupMode],
|
||||
);
|
||||
|
||||
const handleWorkspaceTitleSourceSelect = useCallback(
|
||||
(source: WorkspaceTitleSource) => {
|
||||
void updateSettings({ workspaceTitleSource: source });
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const triggerStyle = useCallback(
|
||||
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.trigger,
|
||||
hovered && styles.triggerHovered,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const showHostFilter = hosts.length > 1;
|
||||
const allHostsSelected = hostFilters.length === 0;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
style={triggerStyle}
|
||||
accessibilityRole={platformIsWeb ? undefined : "button"}
|
||||
accessibilityLabel="Display preferences"
|
||||
testID="sidebar-display-preferences-menu"
|
||||
>
|
||||
<ThemedSettings2 size={14} uniProps={filterColorMapping} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={220} testID="sidebar-display-preferences-content">
|
||||
<View style={styles.menuHeader}>
|
||||
<Text style={styles.menuHeaderLabel}>Group by</Text>
|
||||
</View>
|
||||
{GROUP_MODE_ITEMS.map((item) => (
|
||||
<DisplayPreferenceMenuItem
|
||||
key={item.value}
|
||||
item={item}
|
||||
isSelected={groupMode === item.value}
|
||||
testIDPrefix="sidebar-grouping"
|
||||
onSelect={handleSelectMode}
|
||||
/>
|
||||
))}
|
||||
{showHostFilter ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<View style={styles.menuHeader}>
|
||||
<Text style={styles.menuHeaderLabel}>Filter</Text>
|
||||
</View>
|
||||
<DropdownMenuItem
|
||||
testID="sidebar-host-filter-all"
|
||||
selected={allHostsSelected}
|
||||
closeOnSelect={false}
|
||||
onSelect={clearHostFilters}
|
||||
>
|
||||
All hosts
|
||||
</DropdownMenuItem>
|
||||
{hosts.map((host) => (
|
||||
<HostFilterItem
|
||||
key={host.serverId}
|
||||
label={host.label?.trim() || host.serverId}
|
||||
serverId={host.serverId}
|
||||
selected={hostFilters.includes(host.serverId)}
|
||||
onToggle={toggleHostFilter}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
<DropdownMenuSeparator />
|
||||
<View style={styles.menuHeader}>
|
||||
<Text style={styles.menuHeaderLabel}>Workspace title</Text>
|
||||
</View>
|
||||
{WORKSPACE_TITLE_SOURCE_ITEMS.map((item) => (
|
||||
<DisplayPreferenceMenuItem
|
||||
key={item.value}
|
||||
item={item}
|
||||
isSelected={workspaceTitleSource === item.value}
|
||||
testIDPrefix="sidebar-workspace-title-source"
|
||||
onSelect={handleWorkspaceTitleSourceSelect}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function DisplayPreferenceMenuItem<Value extends string>({
|
||||
item,
|
||||
isSelected,
|
||||
testIDPrefix,
|
||||
onSelect,
|
||||
}: {
|
||||
item: DisplayPreferenceOption<Value>;
|
||||
isSelected: boolean;
|
||||
testIDPrefix: string;
|
||||
onSelect: (value: Value) => void;
|
||||
}) {
|
||||
const handleSelect = useCallback(() => onSelect(item.value), [item.value, onSelect]);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
testID={`${testIDPrefix}-${item.value}`}
|
||||
selected={isSelected}
|
||||
onSelect={handleSelect}
|
||||
>
|
||||
<Text style={styles.optionLabel}>{item.label}</Text>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function HostFilterItem({
|
||||
label,
|
||||
serverId,
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
label: string;
|
||||
serverId: string;
|
||||
selected: boolean;
|
||||
onToggle: (serverId: string) => void;
|
||||
}) {
|
||||
const handleSelect = useCallback(() => onToggle(serverId), [serverId, onToggle]);
|
||||
const leading = useMemo(
|
||||
() => (
|
||||
<View testID={`sidebar-host-filter-status-${serverId}`}>
|
||||
<HostStatusDot serverId={serverId} />
|
||||
</View>
|
||||
),
|
||||
[serverId],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-host-filter-${serverId}`}
|
||||
selected={selected}
|
||||
closeOnSelect={false}
|
||||
leading={leading}
|
||||
onSelect={handleSelect}
|
||||
>
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
triggerHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
menuHeader: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
menuHeaderLabel: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
optionLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
|
||||
/**
|
||||
* Which surface a sidebar row is currently painting, so anything knocking out of it — the status
|
||||
* badge on a project icon — can match.
|
||||
*
|
||||
* This restates the precedence of the row style arrays, which live in three separate renderers
|
||||
* (`sidebar-workspace-list`, `sidebar-status-list`, `sidebar-workspace-row`). One copy of the rule
|
||||
* here rather than three ternaries beside three stylesheets: drifting from them is how the badge
|
||||
* ends up with a halo, and a halo in one grouping mode but not the other is worse than either.
|
||||
*
|
||||
* The pressed background is deliberately absent. It lives in a Pressable style callback and lasts
|
||||
* as long as a finger is down, which is not long enough to see a mismatched ring.
|
||||
*/
|
||||
export function getSidebarRowBackdrop({
|
||||
isDragging = false,
|
||||
selected = false,
|
||||
isHovered = false,
|
||||
}: {
|
||||
isDragging?: boolean;
|
||||
selected?: boolean;
|
||||
isHovered?: boolean;
|
||||
}): SurfaceBackdrop {
|
||||
if (isDragging) return "surface2";
|
||||
if (selected || isHovered) return "surfaceSidebarHover";
|
||||
return "surfaceSidebar";
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
CircleDot,
|
||||
CircleX,
|
||||
} from "lucide-react-native";
|
||||
import { DiffStat } from "@/components/diff-stat";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
@@ -40,7 +39,15 @@ import {
|
||||
SidebarWorkspaceTrailingActionBase,
|
||||
SidebarWorkspaceTrailingActionOverlay,
|
||||
SidebarWorkspaceTrailingActionSlot,
|
||||
sidebarWorkspaceRowStyles,
|
||||
} from "@/components/sidebar/sidebar-workspace-row-content";
|
||||
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
|
||||
import { selectWorkspaceScriptSummary } from "@/components/sidebar/workspace-meta-row";
|
||||
import {
|
||||
SidebarWorkspaceTrailingContent,
|
||||
useSidebarWorkspaceTrailing,
|
||||
type SidebarWorkspaceTrailing,
|
||||
} from "@/components/sidebar/workspace-trailing";
|
||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||
import {
|
||||
SidebarWorkspaceContextMenu,
|
||||
@@ -121,6 +128,7 @@ export function SidebarStatusWorkspaceList({
|
||||
projectIconByProjectViewKey,
|
||||
hostBadgeByServerId,
|
||||
})}
|
||||
inStatusGroup={false}
|
||||
shortcutNumber={statusShortcutIndex.get(workspace.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
||||
@@ -399,6 +407,7 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
canPin,
|
||||
onToggleWorkspacePin,
|
||||
reserveIdleStatusIndicatorSpace = true,
|
||||
inStatusGroup = true,
|
||||
onWorkspacePress,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
@@ -410,6 +419,11 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
canPin: boolean;
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
reserveIdleStatusIndicatorSpace?: boolean;
|
||||
/**
|
||||
* Whether the row sits under a status header, which is what it indents from. Pinned rows
|
||||
* are a flat list under their own header and sit flush.
|
||||
*/
|
||||
inStatusGroup?: boolean;
|
||||
onWorkspacePress?: () => void;
|
||||
}) {
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
@@ -435,6 +449,7 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
canPin={canPin}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
inStatusGroup={inStatusGroup}
|
||||
onPress={handlePress}
|
||||
/>
|
||||
);
|
||||
@@ -451,6 +466,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
canPin,
|
||||
onToggleWorkspacePin,
|
||||
reserveIdleStatusIndicatorSpace = true,
|
||||
inStatusGroup = true,
|
||||
onPress,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
@@ -463,6 +479,11 @@ function StatusWorkspaceRowWithMenu({
|
||||
canPin: boolean;
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
reserveIdleStatusIndicatorSpace?: boolean;
|
||||
/**
|
||||
* Whether the row sits under a status header, which is what it indents from. Pinned rows
|
||||
* are a flat list under their own header and sit flush.
|
||||
*/
|
||||
inStatusGroup?: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -584,6 +605,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
inStatusGroup={inStatusGroup}
|
||||
/>
|
||||
<AdaptiveRenameModal
|
||||
visible={isRenameOpen}
|
||||
@@ -621,6 +643,7 @@ function StatusWorkspaceRowInner({
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
reserveIdleStatusIndicatorSpace = true,
|
||||
inStatusGroup = true,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
hostBadge: HostBadgeModel | null;
|
||||
@@ -643,18 +666,17 @@ function StatusWorkspaceRowInner({
|
||||
isPinned?: boolean;
|
||||
onTogglePin?: () => void;
|
||||
reserveIdleStatusIndicatorSpace?: boolean;
|
||||
/**
|
||||
* Whether the row sits under a status header, which is what it indents from. Pinned rows
|
||||
* are a flat list under their own header and sit flush.
|
||||
*/
|
||||
inStatusGroup?: boolean;
|
||||
}) {
|
||||
const isTouchPlatform = platformIsNative;
|
||||
const trailing = useSidebarWorkspaceTrailing();
|
||||
|
||||
const isDesktop = !isTouchPlatform;
|
||||
const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
|
||||
const hasRunningService = workspace.scripts.some(
|
||||
(s) => s.lifecycle === "running" && (s.type ?? "service") === "service",
|
||||
);
|
||||
let scriptIconKind: "service" | "command" | null = null;
|
||||
if (showScriptsIcon) {
|
||||
scriptIconKind = hasRunningService ? "service" : "command";
|
||||
}
|
||||
const scriptSummary = isDesktop ? selectWorkspaceScriptSummary(workspace.scripts) : null;
|
||||
|
||||
const accessibilityState = useMemo(() => ({ selected }), [selected]);
|
||||
|
||||
@@ -663,18 +685,24 @@ function StatusWorkspaceRowInner({
|
||||
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
|
||||
const showShortcut = showShortcutBadge && shortcutNumber !== null;
|
||||
const {
|
||||
showDiffStat,
|
||||
showTrailing,
|
||||
showKebab: showKebabInSlot,
|
||||
showScrim,
|
||||
renderSlot,
|
||||
reserveSlotWidth,
|
||||
} = resolveTrailingActionVisibility({
|
||||
hasDiffStat: Boolean(workspace.diffStat),
|
||||
workspace,
|
||||
trailing,
|
||||
hasArchiveAction: Boolean(onArchive),
|
||||
isHovered,
|
||||
isTouchPlatform,
|
||||
showShortcut,
|
||||
});
|
||||
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
|
||||
const workspaceRowStyle = getStatusWorkspaceRowStyle({ selected, isHovered });
|
||||
const workspaceRowStyle = getStatusWorkspaceRowStyle({
|
||||
selected,
|
||||
isHovered,
|
||||
inStatusGroup,
|
||||
});
|
||||
return (
|
||||
<View style={styles.workspaceRowContainer} {...hoverHandlers}>
|
||||
<SidebarWorkspaceContextMenu
|
||||
@@ -683,7 +711,7 @@ function StatusWorkspaceRowInner({
|
||||
workspace={workspace}
|
||||
leadingProjectName={projectName}
|
||||
hostBadgeLabel={hostBadge?.label}
|
||||
scriptIconKind={scriptIconKind}
|
||||
scriptSummary={scriptSummary}
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
@@ -709,19 +737,22 @@ function StatusWorkspaceRowInner({
|
||||
hostBadge={hostBadge}
|
||||
leadingProjectName={projectName}
|
||||
leadingProjectIconDataUri={projectIconDataUri}
|
||||
scriptIconKind={scriptIconKind}
|
||||
scriptSummary={scriptSummary}
|
||||
backdrop={getSidebarRowBackdrop({ selected, isHovered })}
|
||||
isHovered={isHovered}
|
||||
isLoading={isArchiving}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
>
|
||||
{shouldRenderActionSlot ? (
|
||||
{renderSlot ? (
|
||||
<StatusWorkspaceActionSlot
|
||||
workspace={workspace}
|
||||
showBase={showDiffStat}
|
||||
trailing={trailing}
|
||||
showBase={showTrailing}
|
||||
showKebab={showKebabInSlot}
|
||||
showScrim={showScrim}
|
||||
reserveSlotWidth={reserveSlotWidth}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
onCopyPath={onCopyPath}
|
||||
@@ -746,9 +777,11 @@ function StatusWorkspaceRowInner({
|
||||
|
||||
function StatusWorkspaceActionSlot({
|
||||
workspace,
|
||||
trailing,
|
||||
showBase,
|
||||
showKebab,
|
||||
showScrim,
|
||||
reserveSlotWidth,
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
onCopyPath,
|
||||
@@ -762,9 +795,11 @@ function StatusWorkspaceActionSlot({
|
||||
archiveShortcutKeys,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
trailing: SidebarWorkspaceTrailing;
|
||||
showBase: boolean;
|
||||
showKebab: boolean;
|
||||
showScrim: boolean;
|
||||
reserveSlotWidth: boolean;
|
||||
isPinned?: boolean;
|
||||
onTogglePin?: () => void;
|
||||
onCopyPath?: () => void;
|
||||
@@ -778,14 +813,9 @@ function StatusWorkspaceActionSlot({
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
}) {
|
||||
return (
|
||||
<SidebarWorkspaceTrailingActionSlot>
|
||||
<SidebarWorkspaceTrailingActionSlot reserveWidth={reserveSlotWidth}>
|
||||
<SidebarWorkspaceTrailingActionBase visible={showBase}>
|
||||
{workspace.diffStat ? (
|
||||
<DiffStat
|
||||
additions={workspace.diffStat.additions}
|
||||
deletions={workspace.diffStat.deletions}
|
||||
/>
|
||||
) : null}
|
||||
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
|
||||
</SidebarWorkspaceTrailingActionBase>
|
||||
<SidebarWorkspaceTrailingActionOverlay visible={showKebab} scrim={showScrim}>
|
||||
{showKebab && onArchive ? (
|
||||
@@ -812,12 +842,15 @@ function StatusWorkspaceActionSlot({
|
||||
function getStatusWorkspaceRowStyle({
|
||||
selected,
|
||||
isHovered,
|
||||
inStatusGroup,
|
||||
}: {
|
||||
selected: boolean;
|
||||
isHovered: boolean;
|
||||
inStatusGroup: boolean;
|
||||
}) {
|
||||
return [
|
||||
styles.workspaceRow,
|
||||
inStatusGroup && sidebarWorkspaceRowStyles.rowIndented,
|
||||
selected && styles.sidebarRowSelected,
|
||||
isHovered && styles.workspaceRowHovered,
|
||||
];
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item";
|
||||
import { resolveSidebarWorkspaceAccessibilityLabel } from "@/components/sidebar/sidebar-workspace-title";
|
||||
import type { WorkspaceScriptSummary } from "@/components/sidebar/workspace-meta-row";
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
@@ -248,7 +249,7 @@ export function SidebarWorkspaceContextMenu({
|
||||
workspace,
|
||||
leadingProjectName,
|
||||
hostBadgeLabel,
|
||||
scriptIconKind,
|
||||
scriptSummary,
|
||||
workspaceKey,
|
||||
onCopyPath,
|
||||
onCopyBranchName,
|
||||
@@ -272,7 +273,7 @@ export function SidebarWorkspaceContextMenu({
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
leadingProjectName?: string | null;
|
||||
hostBadgeLabel?: string | null;
|
||||
scriptIconKind?: "service" | "command" | null;
|
||||
scriptSummary?: WorkspaceScriptSummary | null;
|
||||
}
|
||||
>) {
|
||||
const {
|
||||
@@ -291,7 +292,7 @@ export function SidebarWorkspaceContextMenu({
|
||||
leadingProjectName,
|
||||
hostBadgeLabel,
|
||||
pullRequestLabel,
|
||||
scriptLabel: scriptIconKind ? t("workspace.status.scriptsAvailable") : null,
|
||||
scriptLabel: scriptSummary ? t("workspace.status.scriptsAvailable") : null,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,38 +1,27 @@
|
||||
import { memo, useCallback, useId, useMemo, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
type GestureResponderEvent,
|
||||
type PressableStateCallbackType,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { memo, useId, useMemo, useCallback, useState, type ReactNode } from "react";
|
||||
import { Text, View, type ViewStyle } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
|
||||
import {
|
||||
CircleAlert,
|
||||
Folder,
|
||||
FolderGit2,
|
||||
GitPullRequest,
|
||||
Globe,
|
||||
Monitor,
|
||||
SquareTerminal,
|
||||
} from "lucide-react-native";
|
||||
import { HostBadge } from "@/components/sidebar/host-badge";
|
||||
import { CircleAlert, Folder, FolderGit2, Monitor } from "lucide-react-native";
|
||||
import { ProjectStatusIndicator } from "@/components/sidebar/project-leading-visual";
|
||||
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import { PulsingStatusDot } from "@/components/sidebar/pulsing-status-dot";
|
||||
import {
|
||||
WorkspaceMetaRow,
|
||||
type WorkspaceScriptSummary,
|
||||
} from "@/components/sidebar/workspace-meta-row";
|
||||
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
|
||||
import type { HostBadgeModel } from "@/hosts/appearance";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import {
|
||||
hasSidebarWorkspaceTrailing,
|
||||
type SidebarWorkspaceTrailing,
|
||||
} from "@/components/sidebar/workspace-trailing";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { PrHint } from "@/git/use-pr-status-query";
|
||||
import { getForgePresentation, normalizeForge } from "@/git/forge";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { getStatusDotColor, isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
|
||||
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title";
|
||||
|
||||
// The scrim spans more than the kebab so the fade starts left of the diff stat. Solid from
|
||||
@@ -40,6 +29,17 @@ import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sideba
|
||||
const SCRIM_WIDTH = 48;
|
||||
const SCRIM_SOLID_OFFSET = "55%";
|
||||
|
||||
/**
|
||||
* How far a workspace row sits inside the status header above it. Only status grouping
|
||||
* indents: a project row carries an icon and its workspaces line up under it already, but a
|
||||
* status header is just a label, so its rows need the offset to read as belonging to it.
|
||||
*
|
||||
* It is row padding rather than a margin on the list, because the row's hover and selected
|
||||
* backgrounds have to keep spanning the group's full width. Indenting the container instead
|
||||
* pulls the highlight in with the content and the row stops lining up with its header.
|
||||
*/
|
||||
const WORKSPACE_ROW_INDENT = 12;
|
||||
|
||||
const DEFAULT_STATUS_DOT_SIZE = 7;
|
||||
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
||||
const DEFAULT_STATUS_DOT_OFFSET = 0;
|
||||
@@ -47,18 +47,11 @@ const EMPHASIZED_STATUS_DOT_OFFSET = -1;
|
||||
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const amberColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] });
|
||||
const blueColorMapping = (theme: Theme) => ({ color: theme.colors.palette.blue[500] });
|
||||
const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] });
|
||||
const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] });
|
||||
const purpleColorMapping = (theme: Theme) => ({ color: theme.colors.palette.purple[500] });
|
||||
|
||||
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
||||
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
||||
const ThemedMonitor = withUnistyles(Monitor);
|
||||
const ThemedFolder = withUnistyles(Folder);
|
||||
const ThemedFolderGit2 = withUnistyles(FolderGit2);
|
||||
const ThemedGlobe = withUnistyles(Globe);
|
||||
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
||||
|
||||
/**
|
||||
* react-native-svg's extractGradient reads stopColor off the child elements structurally,
|
||||
@@ -87,18 +80,6 @@ const ThemedTrailingActionScrimSvg = withUnistyles(TrailingActionScrimSvg);
|
||||
|
||||
const scrimColorMapping = (theme: Theme) => ({ color: theme.colors.surfaceSidebarHover });
|
||||
|
||||
function pullRequestIconStyle({
|
||||
hovered,
|
||||
pressed,
|
||||
}: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [
|
||||
pullRequestIconStyles.pressable,
|
||||
(Boolean(hovered) || pressed) && pullRequestIconStyles.pressableActive,
|
||||
];
|
||||
}
|
||||
|
||||
type SidebarWorkspaceScriptIconKind = "service" | "command";
|
||||
|
||||
export function SidebarWorkspaceRowFrame({
|
||||
workspace,
|
||||
isDragging = false,
|
||||
@@ -150,7 +131,8 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
||||
hostBadge,
|
||||
leadingProjectName = null,
|
||||
leadingProjectIconDataUri = null,
|
||||
scriptIconKind = null,
|
||||
scriptSummary = null,
|
||||
backdrop,
|
||||
isHovered,
|
||||
isLoading,
|
||||
isCreating = false,
|
||||
@@ -164,7 +146,9 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
||||
/** Hoisted rows use their project icon as the leading visual because no project row contains them. */
|
||||
leadingProjectName?: string | null;
|
||||
leadingProjectIconDataUri?: string | null;
|
||||
scriptIconKind?: SidebarWorkspaceScriptIconKind | null;
|
||||
scriptSummary?: WorkspaceScriptSummary | null;
|
||||
/** The row's current background, so the project status badge can knock out of it. */
|
||||
backdrop: SurfaceBackdrop;
|
||||
isHovered: boolean;
|
||||
isLoading: boolean;
|
||||
isCreating?: boolean;
|
||||
@@ -178,17 +162,13 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
||||
settings: { workspaceTitleSource },
|
||||
} = useAppSettings();
|
||||
const workspaceLabel = resolveSidebarWorkspacePrimaryLabel({ workspace, workspaceTitleSource });
|
||||
const hasTitleAccessory = Boolean(scriptIconKind || workspace.prHint || hostBadge);
|
||||
const workspaceBranchTextStyle = useMemo(
|
||||
() => [
|
||||
styles.workspaceBranchText,
|
||||
hasTitleAccessory
|
||||
? styles.workspaceBranchTextWithAccessory
|
||||
: styles.workspaceBranchTextFlexible,
|
||||
isHovered && styles.workspaceBranchTextHovered,
|
||||
isCreating && styles.workspaceBranchTextCreating,
|
||||
],
|
||||
[hasTitleAccessory, isHovered, isCreating],
|
||||
[isHovered, isCreating],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -200,6 +180,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
||||
displayName={leadingProjectName}
|
||||
projectViewKey={workspace.projectViewKey}
|
||||
statusBucket={workspace.statusBucket}
|
||||
backdrop={backdrop}
|
||||
loading={isLoading}
|
||||
testID={`sidebar-row-project-icon-${workspace.workspaceKey}`}
|
||||
/>
|
||||
@@ -213,23 +194,16 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
||||
)}
|
||||
<View style={styles.workspaceContentColumn}>
|
||||
<View style={styles.workspaceTitleRow}>
|
||||
<View style={styles.workspaceTitleLeft}>
|
||||
{workspace.prHint ? <PullRequestIcon hint={workspace.prHint} /> : null}
|
||||
{hostBadge ? (
|
||||
<HostBadge
|
||||
serverId={hostBadge.serverId}
|
||||
label={hostBadge.label}
|
||||
color={hostBadge.color}
|
||||
showLabel={hostBadge.showLabel}
|
||||
/>
|
||||
) : null}
|
||||
<Text style={workspaceBranchTextStyle} numberOfLines={1}>
|
||||
{workspaceLabel}
|
||||
</Text>
|
||||
{scriptIconKind ? <WorkspaceScriptIcon kind={scriptIconKind} /> : null}
|
||||
</View>
|
||||
<Text style={workspaceBranchTextStyle} numberOfLines={1}>
|
||||
{workspaceLabel}
|
||||
</Text>
|
||||
<View style={sidebarWorkspaceRowStyles.rowRight}>{children}</View>
|
||||
</View>
|
||||
<WorkspaceMetaRow
|
||||
hostBadge={hostBadge ?? null}
|
||||
prHint={workspace.prHint}
|
||||
scriptSummary={scriptSummary}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
{showShortcutBadge && shortcutNumber !== null ? (
|
||||
@@ -241,22 +215,6 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
||||
);
|
||||
});
|
||||
|
||||
function WorkspaceScriptIcon({ kind }: { kind: SidebarWorkspaceScriptIconKind }) {
|
||||
return (
|
||||
<View
|
||||
style={styles.workspaceTitleAccessory}
|
||||
accessibilityLabel="Scripts available"
|
||||
testID={kind === "service" ? "workspace-globe-icon" : "workspace-terminal-icon"}
|
||||
>
|
||||
{kind === "service" ? (
|
||||
<ThemedGlobe size={12} uniProps={blueColorMapping} />
|
||||
) : (
|
||||
<ThemedSquareTerminal size={12} uniProps={blueColorMapping} />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceStatusIndicator({
|
||||
bucket,
|
||||
workspaceKind,
|
||||
@@ -368,47 +326,6 @@ function StatusDotOverlay({
|
||||
return <View style={overlayStyle} />;
|
||||
}
|
||||
|
||||
function PullRequestIcon({ hint }: { hint: PrHint }) {
|
||||
const { t } = useTranslation();
|
||||
const handlePress = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
event.stopPropagation();
|
||||
void openExternalUrl(hint.url);
|
||||
},
|
||||
[hint.url],
|
||||
);
|
||||
const presentation = getForgePresentation(normalizeForge(hint.forge));
|
||||
|
||||
const handlePressIn = useCallback((event: GestureResponderEvent) => event.stopPropagation(), []);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={t("workspace.git.pr.accessibility.pullRequest", {
|
||||
number: hint.number,
|
||||
context: presentation.changeRequestContext,
|
||||
})}
|
||||
hitSlop={4}
|
||||
onPressIn={handlePressIn}
|
||||
onPress={handlePress}
|
||||
style={pullRequestIconStyle}
|
||||
>
|
||||
<ThemedGitPullRequest size={12} uniProps={getPrIconUniMapping(hint.state)} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function getPrIconUniMapping(state: PrHint["state"]) {
|
||||
switch (state) {
|
||||
case "merged":
|
||||
return purpleColorMapping;
|
||||
case "open":
|
||||
return greenColorMapping;
|
||||
case "closed":
|
||||
return redColorMapping;
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusDotColorStyle(bucket: SidebarStateBucket) {
|
||||
switch (bucket) {
|
||||
case "needs_input":
|
||||
@@ -424,19 +341,12 @@ function getStatusDotColorStyle(bucket: SidebarStateBucket) {
|
||||
}
|
||||
}
|
||||
|
||||
const pullRequestIconStyles = StyleSheet.create(() => ({
|
||||
pressable: {
|
||||
height: 20,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
pressableActive: {
|
||||
opacity: 0.82,
|
||||
},
|
||||
}));
|
||||
|
||||
export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({
|
||||
// Layered over the row's own padding rather than replacing it, so the indent stays one
|
||||
// number here instead of being baked into a row style.
|
||||
rowIndented: {
|
||||
paddingLeft: theme.spacing[2] + WORKSPACE_ROW_INDENT,
|
||||
},
|
||||
rowRight: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
@@ -462,7 +372,16 @@ export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({
|
||||
lineHeight: 14,
|
||||
},
|
||||
hidden: { opacity: 0 },
|
||||
// Stays position:relative at zero width so the absolutely-positioned kebab keeps
|
||||
// anchoring to the same right edge whether or not the slot holds anything.
|
||||
trailingActionSlot: {
|
||||
position: "relative",
|
||||
minHeight: 20,
|
||||
flexShrink: 0,
|
||||
alignItems: "flex-end",
|
||||
justifyContent: "flex-start",
|
||||
},
|
||||
trailingActionSlotReserved: {
|
||||
position: "relative",
|
||||
minWidth: 18,
|
||||
minHeight: 20,
|
||||
@@ -498,30 +417,68 @@ export function SidebarWorkspaceShortcutBadge({ number }: { number: number }) {
|
||||
* into each of them and immediately drifted — one call site kept hiding the diff after the
|
||||
* others stopped.
|
||||
*
|
||||
* The diff survives the kebab on hover and fades under the scrim instead of blinking out.
|
||||
* Touch has no hover, so its permanent kebab still hides the diff outright rather than
|
||||
* scrimming an unhovered row whose background doesn't match the gradient.
|
||||
* The trailing content survives the kebab on hover and fades under the scrim instead of
|
||||
* blinking out. Touch has no hover, so its permanent kebab still hides the content outright
|
||||
* rather than scrimming an unhovered row whose background doesn't match the gradient.
|
||||
*/
|
||||
export function resolveTrailingActionVisibility({
|
||||
hasDiffStat,
|
||||
workspace,
|
||||
trailing,
|
||||
hasArchiveAction,
|
||||
isHovered,
|
||||
isTouchPlatform,
|
||||
showShortcut,
|
||||
}: {
|
||||
hasDiffStat: boolean;
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
trailing: SidebarWorkspaceTrailing;
|
||||
hasArchiveAction: boolean;
|
||||
isHovered: boolean;
|
||||
isTouchPlatform: boolean;
|
||||
showShortcut: boolean;
|
||||
}): { showDiffStat: boolean; showKebab: boolean; showScrim: boolean } {
|
||||
}): {
|
||||
showTrailing: boolean;
|
||||
showKebab: boolean;
|
||||
showScrim: boolean;
|
||||
renderSlot: boolean;
|
||||
reserveSlotWidth: boolean;
|
||||
} {
|
||||
const hasTrailing = hasSidebarWorkspaceTrailing({ workspace, trailing });
|
||||
const showKebab = Boolean(hasArchiveAction && (isHovered || isTouchPlatform)) && !showShortcut;
|
||||
const showDiffStat = hasDiffStat && !showShortcut && (isHovered || !showKebab);
|
||||
return { showDiffStat, showKebab, showScrim: showDiffStat && showKebab };
|
||||
const showTrailing = hasTrailing && !showShortcut && (isHovered || !showKebab);
|
||||
return {
|
||||
showTrailing,
|
||||
showKebab,
|
||||
// The scrim paints the row's own hover background, so it can only be drawn on a hovered
|
||||
// row — over an unhovered one the gradient fades to the wrong color. That is also why
|
||||
// touch, which shows the kebab without ever hovering, never gets one.
|
||||
showScrim: showKebab && isHovered,
|
||||
renderSlot: hasArchiveAction || hasTrailing,
|
||||
// The slot only holds width for something that permanently sits in it. Trailing content
|
||||
// does; the kebab only does on touch, where there is no hover for it to appear on and so
|
||||
// no scrim to let it overlay the title. Everywhere else the width goes back to the title
|
||||
// and the kebab fades in over its tail.
|
||||
reserveSlotWidth: hasTrailing || (hasArchiveAction && isTouchPlatform),
|
||||
};
|
||||
}
|
||||
|
||||
export function SidebarWorkspaceTrailingActionSlot({ children }: { children: ReactNode }) {
|
||||
return <View style={sidebarWorkspaceRowStyles.trailingActionSlot}>{children}</View>;
|
||||
export function SidebarWorkspaceTrailingActionSlot({
|
||||
reserveWidth,
|
||||
children,
|
||||
}: {
|
||||
reserveWidth: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
style={
|
||||
reserveWidth
|
||||
? sidebarWorkspaceRowStyles.trailingActionSlotReserved
|
||||
: sidebarWorkspaceRowStyles.trailingActionSlot
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarWorkspaceTrailingActionBase({
|
||||
@@ -595,13 +552,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
workspaceTitleLeft: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
shortcutBadgeOverlay: {
|
||||
position: "absolute",
|
||||
top: 1,
|
||||
@@ -640,25 +590,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.foregroundExtraMuted,
|
||||
opacity: 0.3,
|
||||
},
|
||||
// The title owns the first line outright now that the host, change request and CI moved
|
||||
// to the meta row, so it takes the full width the trailing slot leaves behind.
|
||||
workspaceBranchText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: "400",
|
||||
lineHeight: 20,
|
||||
opacity: 0.76,
|
||||
minWidth: 0,
|
||||
},
|
||||
workspaceBranchTextFlexible: {
|
||||
flex: 1,
|
||||
},
|
||||
workspaceBranchTextWithAccessory: {
|
||||
flexShrink: 1,
|
||||
},
|
||||
workspaceTitleAccessory: {
|
||||
height: 20,
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minWidth: 0,
|
||||
},
|
||||
workspaceBranchTextCreating: {
|
||||
opacity: 0.92,
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { HostBadgeModel } from "@/hosts/appearance";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { DiffStat } from "@/components/diff-stat";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
@@ -33,6 +32,13 @@ import {
|
||||
SidebarWorkspaceTrailingActionOverlay,
|
||||
SidebarWorkspaceTrailingActionSlot,
|
||||
} from "@/components/sidebar/sidebar-workspace-row-content";
|
||||
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
|
||||
import { selectWorkspaceScriptSummary } from "@/components/sidebar/workspace-meta-row";
|
||||
import {
|
||||
SidebarWorkspaceTrailingContent,
|
||||
useSidebarWorkspaceTrailing,
|
||||
type SidebarWorkspaceTrailing,
|
||||
} from "@/components/sidebar/workspace-trailing";
|
||||
|
||||
function noop() {}
|
||||
|
||||
@@ -257,6 +263,7 @@ function WorkspaceRowBody({
|
||||
archiveShortcutKeys,
|
||||
}: WorkspaceRowBodyProps) {
|
||||
const isTouchPlatform = platformIsNative;
|
||||
const trailing = useSidebarWorkspaceTrailing();
|
||||
const draggable = Boolean(drag);
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag: drag ?? noop,
|
||||
@@ -283,14 +290,7 @@ function WorkspaceRowBody({
|
||||
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
|
||||
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
|
||||
const isDesktop = !isTouchPlatform;
|
||||
const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
|
||||
const hasRunningService = workspace.scripts.some(
|
||||
(s) => s.lifecycle === "running" && (s.type ?? "service") === "service",
|
||||
);
|
||||
let scriptIconKind: "service" | "command" | null = null;
|
||||
if (showScriptsIcon) {
|
||||
scriptIconKind = hasRunningService ? "service" : "command";
|
||||
}
|
||||
const scriptSummary = isDesktop ? selectWorkspaceScriptSummary(workspace.scripts) : null;
|
||||
const workspaceRowStyle = getWorkspaceRowStyle({ isDragging, selected, isHovered });
|
||||
return (
|
||||
<View
|
||||
@@ -307,7 +307,7 @@ function WorkspaceRowBody({
|
||||
onContextMenuOpenChange={onContextMenuOpenChange}
|
||||
workspace={workspace}
|
||||
hostBadgeLabel={hostBadge?.label}
|
||||
scriptIconKind={scriptIconKind}
|
||||
scriptSummary={scriptSummary}
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
@@ -333,7 +333,8 @@ function WorkspaceRowBody({
|
||||
<SidebarWorkspaceRowContent
|
||||
workspace={workspace}
|
||||
hostBadge={hostBadge}
|
||||
scriptIconKind={scriptIconKind}
|
||||
scriptSummary={scriptSummary}
|
||||
backdrop={getSidebarRowBackdrop({ isDragging, selected, isHovered })}
|
||||
isHovered={isHovered}
|
||||
isLoading={isArchiving || isCreating}
|
||||
isCreating={isCreating}
|
||||
@@ -342,6 +343,7 @@ function WorkspaceRowBody({
|
||||
>
|
||||
<WorkspaceRowTrailingActions
|
||||
workspace={workspace}
|
||||
trailing={trailing}
|
||||
isHovered={isHovered}
|
||||
isTouchPlatform={isTouchPlatform}
|
||||
isCreating={isCreating}
|
||||
@@ -368,6 +370,7 @@ function WorkspaceRowBody({
|
||||
|
||||
function WorkspaceRowTrailingActions({
|
||||
workspace,
|
||||
trailing,
|
||||
isHovered,
|
||||
isTouchPlatform,
|
||||
isCreating,
|
||||
@@ -384,6 +387,7 @@ function WorkspaceRowTrailingActions({
|
||||
onRename,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
trailing: SidebarWorkspaceTrailing;
|
||||
isHovered: boolean;
|
||||
isTouchPlatform: boolean;
|
||||
isCreating: boolean;
|
||||
@@ -402,32 +406,29 @@ function WorkspaceRowTrailingActions({
|
||||
const { t } = useTranslation();
|
||||
const showShortcut = showShortcutBadge && shortcutNumber !== null;
|
||||
const {
|
||||
showDiffStat,
|
||||
showTrailing,
|
||||
showKebab: showKebabInSlot,
|
||||
showScrim,
|
||||
renderSlot,
|
||||
reserveSlotWidth,
|
||||
} = resolveTrailingActionVisibility({
|
||||
hasDiffStat: Boolean(workspace.diffStat),
|
||||
workspace,
|
||||
trailing,
|
||||
hasArchiveAction: Boolean(onArchive),
|
||||
isHovered,
|
||||
isTouchPlatform,
|
||||
showShortcut,
|
||||
});
|
||||
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isCreating ? (
|
||||
<Text style={styles.workspaceCreatingText}>{t("sidebar.workspace.status.creating")}</Text>
|
||||
) : null}
|
||||
{shouldRenderActionSlot ? (
|
||||
<SidebarWorkspaceTrailingActionSlot>
|
||||
<SidebarWorkspaceTrailingActionBase visible={showDiffStat}>
|
||||
{workspace.diffStat ? (
|
||||
<DiffStat
|
||||
additions={workspace.diffStat.additions}
|
||||
deletions={workspace.diffStat.deletions}
|
||||
/>
|
||||
) : null}
|
||||
{renderSlot ? (
|
||||
<SidebarWorkspaceTrailingActionSlot reserveWidth={reserveSlotWidth}>
|
||||
<SidebarWorkspaceTrailingActionBase visible={showTrailing}>
|
||||
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
|
||||
</SidebarWorkspaceTrailingActionBase>
|
||||
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot} scrim={showScrim}>
|
||||
{onArchive ? (
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import Svg, { Circle, Path } from "react-native-svg";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { toFilledQuarters, type CheckSummary } from "./check-summary";
|
||||
|
||||
/**
|
||||
* Geometry in a 24-unit box scaled to the icon size, so the stroke weight matches the
|
||||
* lucide icons sitting next to it in the row. The outline radius leaves half the stroke
|
||||
* inside the box on each side; the pie is inset far enough to read as a separate shape
|
||||
* rather than a thick ring.
|
||||
*/
|
||||
const VIEW_BOX = 24;
|
||||
const STROKE_WIDTH = 2;
|
||||
const OUTLINE_RADIUS = (VIEW_BOX - STROKE_WIDTH) / 2;
|
||||
const CENTER = VIEW_BOX / 2;
|
||||
const PIE_RADIUS = 7;
|
||||
|
||||
const CHECK_PATH = "M8 12.5 L11 15.5 L16.5 9";
|
||||
const CROSS_PATH = "M8.5 8.5 L15.5 15.5 M15.5 8.5 L8.5 15.5";
|
||||
|
||||
/**
|
||||
* A change request's CI as one glyph: a check inside a circle when everything passed, a
|
||||
* cross when anything failed, and a pie filling clockwise from twelve while the run is
|
||||
* still going. The three share the circle so a row's CI always occupies the same shape and
|
||||
* only its interior changes.
|
||||
*/
|
||||
function CheckIndicatorSvg({
|
||||
summary,
|
||||
size,
|
||||
color,
|
||||
}: {
|
||||
summary: CheckSummary;
|
||||
size: number;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox={`0 0 ${VIEW_BOX} ${VIEW_BOX}`}>
|
||||
<Circle
|
||||
cx={CENTER}
|
||||
cy={CENTER}
|
||||
r={OUTLINE_RADIUS}
|
||||
stroke={color}
|
||||
strokeWidth={STROKE_WIDTH}
|
||||
fill="none"
|
||||
/>
|
||||
{summary.state === "running" ? (
|
||||
<Pie fraction={toFilledQuarters(summary)} color={color} />
|
||||
) : (
|
||||
<Path
|
||||
d={summary.state === "passed" ? CHECK_PATH : CROSS_PATH}
|
||||
stroke={color}
|
||||
strokeWidth={STROKE_WIDTH}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
)}
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A filled wedge from the centre, starting at twelve o'clock and sweeping clockwise. A full
|
||||
* circle is drawn as a circle rather than an arc: an arc whose start and end points
|
||||
* coincide has nothing to sweep between and paints nothing at all.
|
||||
*/
|
||||
function Pie({ fraction, color }: { fraction: number; color: string }) {
|
||||
if (fraction <= 0) return null;
|
||||
if (fraction >= 1) {
|
||||
return <Circle cx={CENTER} cy={CENTER} r={PIE_RADIUS} fill={color} />;
|
||||
}
|
||||
|
||||
const angle = fraction * 2 * Math.PI;
|
||||
const endX = CENTER + PIE_RADIUS * Math.sin(angle);
|
||||
const endY = CENTER - PIE_RADIUS * Math.cos(angle);
|
||||
const largeArc = fraction > 0.5 ? 1 : 0;
|
||||
const d = [
|
||||
`M ${CENTER} ${CENTER}`,
|
||||
`L ${CENTER} ${CENTER - PIE_RADIUS}`,
|
||||
`A ${PIE_RADIUS} ${PIE_RADIUS} 0 ${largeArc} 1 ${endX} ${endY}`,
|
||||
"Z",
|
||||
].join(" ");
|
||||
return <Path d={d} fill={color} />;
|
||||
}
|
||||
|
||||
const ThemedCheckIndicatorSvg = withUnistyles(CheckIndicatorSvg);
|
||||
|
||||
/**
|
||||
* Passing CI is the uneventful case and asks nothing of the reader, so it gets the quiet
|
||||
* tier — green enough to name the state, close enough to the subtitle's weight that a
|
||||
* sidebar full of them doesn't bury the one red row. Failure and a run still deciding keep
|
||||
* real colour, because those are the two that want something from you.
|
||||
*/
|
||||
const COLOR_MAPPINGS: Record<CheckSummary["state"], (theme: Theme) => { color: string }> = {
|
||||
passed: (theme) => ({ color: theme.colors.statusMutedSuccess }),
|
||||
failed: (theme) => ({ color: theme.colors.statusMutedDanger }),
|
||||
running: (theme) => ({ color: theme.colors.statusMutedWarning }),
|
||||
};
|
||||
|
||||
export function CheckIndicator({ summary, size }: { summary: CheckSummary; size: number }) {
|
||||
return (
|
||||
<ThemedCheckIndicatorSvg
|
||||
summary={summary}
|
||||
size={size}
|
||||
uniProps={COLOR_MAPPINGS[summary.state]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PrHint } from "@/git/pr-hint";
|
||||
import { selectCheckSummary, toFilledQuarters } from "./check-summary";
|
||||
|
||||
function hint(overrides: Partial<PrHint>): PrHint {
|
||||
return {
|
||||
url: "https://github.com/o/r/pull/1",
|
||||
number: 1,
|
||||
state: "open",
|
||||
forge: "github",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function checks(...statuses: string[]): PrHint["checks"] {
|
||||
return statuses.map((status, index) => ({ name: `check-${index}`, status, url: null }));
|
||||
}
|
||||
|
||||
describe("selectCheckSummary", () => {
|
||||
it("returns nothing without a pull request", () => {
|
||||
expect(selectCheckSummary(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns nothing when the pull request reports no checks", () => {
|
||||
expect(selectCheckSummary(hint({ checksStatus: "none" }))).toBeNull();
|
||||
expect(selectCheckSummary(hint({ checks: [] }))).toBeNull();
|
||||
});
|
||||
|
||||
it("passes once every check is terminal and none failed", () => {
|
||||
expect(selectCheckSummary(hint({ checks: checks("success", "success", "skipped") }))).toEqual({
|
||||
state: "passed",
|
||||
completed: 3,
|
||||
total: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("counts terminal checks while others are still pending", () => {
|
||||
expect(
|
||||
selectCheckSummary(hint({ checks: checks("success", "skipped", "pending", "pending") })),
|
||||
).toEqual({ state: "running", completed: 2, total: 4 });
|
||||
});
|
||||
|
||||
it("fails as soon as one check fails, even mid-run", () => {
|
||||
expect(
|
||||
selectCheckSummary(hint({ checks: checks("success", "failure", "pending", "pending") })),
|
||||
).toEqual({ state: "failed", completed: 4, total: 4 });
|
||||
});
|
||||
|
||||
it("treats cancelled checks as finished rather than failed", () => {
|
||||
expect(selectCheckSummary(hint({ checks: checks("success", "cancelled") }))).toEqual({
|
||||
state: "passed",
|
||||
completed: 2,
|
||||
total: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats an unrecognized status as still running", () => {
|
||||
expect(selectCheckSummary(hint({ checks: checks("success", "queued") }))).toEqual({
|
||||
state: "running",
|
||||
completed: 1,
|
||||
total: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the summary status when individual checks are absent", () => {
|
||||
expect(selectCheckSummary(hint({ checksStatus: "pending" }))).toEqual({
|
||||
state: "running",
|
||||
completed: 0,
|
||||
total: 1,
|
||||
});
|
||||
expect(selectCheckSummary(hint({ checksStatus: "failure" }))).toEqual({
|
||||
state: "failed",
|
||||
completed: 1,
|
||||
total: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers individual checks over the summary status", () => {
|
||||
expect(
|
||||
selectCheckSummary(hint({ checks: checks("failure"), checksStatus: "success" })),
|
||||
).toEqual({ state: "failed", completed: 1, total: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("toFilledQuarters", () => {
|
||||
it("fills nothing before the first quarter completes", () => {
|
||||
expect(toFilledQuarters({ state: "running", completed: 0, total: 8 })).toBe(0);
|
||||
expect(toFilledQuarters({ state: "running", completed: 1, total: 8 })).toBe(0);
|
||||
});
|
||||
|
||||
it("fills in quarter steps", () => {
|
||||
expect(toFilledQuarters({ state: "running", completed: 2, total: 8 })).toBe(0.25);
|
||||
expect(toFilledQuarters({ state: "running", completed: 5, total: 10 })).toBe(0.5);
|
||||
expect(toFilledQuarters({ state: "running", completed: 6, total: 8 })).toBe(0.75);
|
||||
});
|
||||
|
||||
it("rounds down so an unfinished run never reads as complete", () => {
|
||||
expect(toFilledQuarters({ state: "running", completed: 9, total: 10 })).toBe(0.75);
|
||||
});
|
||||
|
||||
it("closes the pie only when every check is in", () => {
|
||||
expect(toFilledQuarters({ state: "passed", completed: 10, total: 10 })).toBe(1);
|
||||
});
|
||||
|
||||
it("treats an empty run as empty rather than dividing by zero", () => {
|
||||
expect(toFilledQuarters({ state: "running", completed: 0, total: 0 })).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { mapCheckStatus } from "@/git/pull-request-panel/check-status";
|
||||
import type { PrHint } from "@/git/pr-hint";
|
||||
|
||||
/**
|
||||
* What the sidebar reports about a change request's CI, reduced to the three things a row
|
||||
* can show. "running" carries a fraction so the indicator can fill as checks land.
|
||||
*/
|
||||
export type CheckSummaryState = "passed" | "failed" | "running";
|
||||
|
||||
export interface CheckSummary {
|
||||
state: CheckSummaryState;
|
||||
/** Checks that have reached a terminal status. Equals `total` when state is not "running". */
|
||||
completed: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Failure wins over progress. A run that is half done with one failure already needs the
|
||||
* user to open the hover card, and a ring still filling says "wait" — so the moment any
|
||||
* check fails the summary is "failed" and the fraction stops mattering.
|
||||
*
|
||||
* `checks` is the richer source and the only one that can produce a fraction. Forges that
|
||||
* report a summary without the individual checks fall back to `checksStatus`, which
|
||||
* degrades the ring to empty-or-full rather than dropping the indicator.
|
||||
*/
|
||||
export function selectCheckSummary(hint: PrHint | null): CheckSummary | null {
|
||||
if (!hint) return null;
|
||||
return summarizeChecks(hint.checks) ?? summarizeChecksStatus(hint.checksStatus);
|
||||
}
|
||||
|
||||
/** The pie has four pieces, so a run reports progress in quarters. */
|
||||
const PIE_PIECES = 4;
|
||||
|
||||
/**
|
||||
* How much of the pie is filled, as a fraction, snapped down to a quarter. Rounding down
|
||||
* rather than to nearest means the indicator never claims a check finished that hasn't —
|
||||
* nine of ten reads as three quarters, and only the tenth closes the circle.
|
||||
*/
|
||||
export function toFilledQuarters(summary: CheckSummary): number {
|
||||
if (summary.total === 0) return 0;
|
||||
return Math.floor((summary.completed / summary.total) * PIE_PIECES) / PIE_PIECES;
|
||||
}
|
||||
|
||||
function summarizeChecks(checks: PrHint["checks"]): CheckSummary | null {
|
||||
if (!checks || checks.length === 0) return null;
|
||||
|
||||
let completed = 0;
|
||||
let failed = false;
|
||||
for (const check of checks) {
|
||||
const status = mapCheckStatus(check.status);
|
||||
if (status === "failure") failed = true;
|
||||
if (status !== "pending") completed += 1;
|
||||
}
|
||||
|
||||
const total = checks.length;
|
||||
if (failed) return { state: "failed", completed: total, total };
|
||||
if (completed === total) return { state: "passed", completed: total, total };
|
||||
return { state: "running", completed, total };
|
||||
}
|
||||
|
||||
function summarizeChecksStatus(checksStatus: PrHint["checksStatus"]): CheckSummary | null {
|
||||
switch (checksStatus) {
|
||||
case "success":
|
||||
return { state: "passed", completed: 1, total: 1 };
|
||||
case "failure":
|
||||
return { state: "failed", completed: 1, total: 1 };
|
||||
case "pending":
|
||||
return { state: "running", completed: 0, total: 1 };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import { Fragment, useCallback, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View, type GestureResponderEvent } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
ExternalLink,
|
||||
GitMerge,
|
||||
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 { PrHint } from "@/git/pr-hint";
|
||||
import { getForgePresentation, normalizeForge } from "@/git/forge";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { useSidebarRowItems } from "@/components/sidebar/display-preferences/model";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { CheckIndicator } from "./check-indicator";
|
||||
import type { CheckSummary, CheckSummaryState } from "./check-summary";
|
||||
import { selectMetaRowItems, type MetaRowItem } from "./meta-items";
|
||||
import type { WorkspaceScriptSummary } from "./script-summary";
|
||||
|
||||
export { selectWorkspaceScriptSummary, type WorkspaceScriptSummary } from "./script-summary";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const META_ICON_SIZE = 12;
|
||||
|
||||
/** Carried over from the host pill: a host can carry an arbitrary custom label. */
|
||||
const MAX_HOST_LABEL_WIDTH = 96;
|
||||
|
||||
const ThemedServer = withUnistyles(Server);
|
||||
const ThemedExternalLink = withUnistyles(ExternalLink);
|
||||
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
||||
const ThemedGitMerge = withUnistyles(GitMerge);
|
||||
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
|
||||
* that isn't its name.
|
||||
*
|
||||
* Items are peers separated by a dot rather than ranked by chrome. The host used to be a
|
||||
* tinted pill on the title line, which made it the loudest thing in a row whose subject is
|
||||
* the title; flattening it lets the whole line read as one piece of secondary text and
|
||||
* leaves color to mean status.
|
||||
*/
|
||||
export function WorkspaceMetaRow({
|
||||
hostBadge,
|
||||
prHint,
|
||||
scriptSummary,
|
||||
}: {
|
||||
hostBadge: HostBadgeModel | null;
|
||||
prHint: PrHint | null;
|
||||
scriptSummary: WorkspaceScriptSummary | null;
|
||||
}) {
|
||||
const visible = useSidebarRowItems();
|
||||
const items = selectMetaRowItems({
|
||||
hasHostBadge: hostBadge !== null,
|
||||
prHint,
|
||||
scriptSummary,
|
||||
visible,
|
||||
});
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
{items.map((item, index) => (
|
||||
<Fragment key={item.kind}>
|
||||
{index > 0 ? <Text style={styles.separator}>·</Text> : null}
|
||||
<MetaItemNode item={item} hostBadge={hostBadge} />
|
||||
</Fragment>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaItemNode({
|
||||
item,
|
||||
hostBadge,
|
||||
}: {
|
||||
item: MetaRowItem;
|
||||
hostBadge: HostBadgeModel | null;
|
||||
}): ReactNode {
|
||||
if (item.kind === "host") {
|
||||
return hostBadge ? <HostItem hostBadge={hostBadge} /> : null;
|
||||
}
|
||||
if (item.kind === "changeRequest") {
|
||||
return <PullRequestItem hint={item.hint} />;
|
||||
}
|
||||
if (item.kind === "checks") {
|
||||
return <ChecksItem summary={item.summary} />;
|
||||
}
|
||||
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,
|
||||
* saying where the click goes before you spend it.
|
||||
*
|
||||
* `onHoverIn`/`onHoverOut` with local state is safe here despite the usual rule in
|
||||
* docs/hover.md — the state never leaves this Pressable, and nothing pressable is nested
|
||||
* inside it, so there is no second hover state machine to fight. Both icons are the same
|
||||
* size, so the swap can't move the target out from under the cursor.
|
||||
*/
|
||||
function PullRequestItem({ hint }: { hint: PrHint }) {
|
||||
const { t } = useTranslation();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const presentation = getForgePresentation(normalizeForge(hint.forge));
|
||||
|
||||
const handlePress = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
event.stopPropagation();
|
||||
void openExternalUrl(hint.url);
|
||||
},
|
||||
[hint.url],
|
||||
);
|
||||
const handlePressIn = useCallback((event: GestureResponderEvent) => event.stopPropagation(), []);
|
||||
const handleHoverIn = useCallback(() => setIsHovered(true), []);
|
||||
const handleHoverOut = useCallback(() => setIsHovered(false), []);
|
||||
|
||||
const Icon = isHovered ? ThemedExternalLink : PR_ICONS[hint.state];
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={t("workspace.git.pr.accessibility.pullRequest", {
|
||||
number: hint.number,
|
||||
context: presentation.changeRequestContext,
|
||||
})}
|
||||
hitSlop={4}
|
||||
onPressIn={handlePressIn}
|
||||
onPress={handlePress}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
style={pressableItemStyle}
|
||||
>
|
||||
<Icon
|
||||
size={META_ICON_SIZE}
|
||||
uniProps={isHovered ? foregroundMapping : PR_COLOR_MAPPINGS[hint.state]}
|
||||
/>
|
||||
<Text style={isHovered ? styles.prTextHovered : styles.prText} numberOfLines={1}>
|
||||
{hint.number}
|
||||
{/* An open change request is the unremarkable case and says nothing extra; a merged
|
||||
or closed one is why the row still looks like it has work in it. */}
|
||||
{hint.state === "open" ? "" : ` ${t(PR_STATE_LABEL_KEYS[hint.state])}`}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every state says its own name. A tick on its own has no subject — the reader has to already
|
||||
* know the glyph is about CI to read it at all, and sitting after a host and a change request it
|
||||
* reads as something left over. One short word costs less than that ambiguity.
|
||||
*
|
||||
* The words are the outcome, not the noun: "passed" against "failed", both naming how a finished
|
||||
* run ended, "running" for one still deciding. Repeating the subject on every healthy row —
|
||||
* "checks passed" — is the noise worth avoiding; naming the outcome is not.
|
||||
*/
|
||||
function ChecksItem({ summary }: { summary: CheckSummary }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View
|
||||
style={styles.item}
|
||||
accessibilityLabel={t(CHECK_STATE_ACCESSIBLE_KEYS[summary.state])}
|
||||
testID={`sidebar-workspace-checks-${summary.state}`}
|
||||
>
|
||||
<CheckIndicator summary={summary} size={META_ICON_SIZE} />
|
||||
<Text style={checksTextStyle(summary.state)} numberOfLines={1}>
|
||||
{t(CHECK_STATE_LABEL_KEYS[summary.state])}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const CHECK_STATE_LABEL_KEYS = {
|
||||
passed: "workspace.git.pr.checksSummary.passedLabel",
|
||||
failed: "workspace.git.pr.checksSummary.failedLabel",
|
||||
running: "workspace.git.pr.checksSummary.runningLabel",
|
||||
} as const;
|
||||
|
||||
const CHECK_STATE_ACCESSIBLE_KEYS = {
|
||||
passed: "workspace.git.pr.checksSummary.passedAccessible",
|
||||
failed: "workspace.git.pr.checksSummary.failedAccessible",
|
||||
running: "workspace.git.pr.checksSummary.runningAccessible",
|
||||
} as const;
|
||||
|
||||
function ScriptItem({ summary }: { summary: WorkspaceScriptSummary }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View
|
||||
style={styles.item}
|
||||
accessibilityLabel={t("workspace.status.scriptsAvailable")}
|
||||
testID={summary.kind === "service" ? "workspace-globe-icon" : "workspace-terminal-icon"}
|
||||
>
|
||||
{summary.kind === "service" ? (
|
||||
<ThemedGlobe size={META_ICON_SIZE} uniProps={successMapping} />
|
||||
) : (
|
||||
<ThemedSquareTerminal size={META_ICON_SIZE} uniProps={successMapping} />
|
||||
)}
|
||||
{summary.port !== null ? <Text style={styles.scriptPort}>:{summary.port}</Text> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const successMapping = (theme: Theme) => ({ color: theme.colors.statusMutedSuccess });
|
||||
|
||||
const PR_ICONS = {
|
||||
open: ThemedGitPullRequest,
|
||||
merged: ThemedGitMerge,
|
||||
closed: ThemedGitPullRequestClosed,
|
||||
} as const;
|
||||
|
||||
// Same three colours the hover card gives the same three states — a change request has one
|
||||
// identity, and it shouldn't shift when you hover the row that names it.
|
||||
const PR_COLOR_MAPPINGS = {
|
||||
open: successMapping,
|
||||
merged: mergedMapping,
|
||||
closed: dangerMapping,
|
||||
} as const;
|
||||
|
||||
const PR_STATE_LABEL_KEYS = {
|
||||
merged: "workspace.git.pr.states.merged",
|
||||
closed: "workspace.git.pr.states.closed",
|
||||
} as const;
|
||||
|
||||
function pressableItemStyle({ pressed }: { pressed: boolean }) {
|
||||
return [styles.item, pressed && styles.itemPressed];
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1.5],
|
||||
minWidth: 0,
|
||||
},
|
||||
item: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
minWidth: 0,
|
||||
flexShrink: 0,
|
||||
},
|
||||
// A host label is arbitrary user text and the only item on the line that can be long, so
|
||||
// it is the one that gets capped and truncated. The change request, CI and service items
|
||||
// are bounded by their own content and must never be the thing that gives way.
|
||||
hostItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
minWidth: 0,
|
||||
flexShrink: 0,
|
||||
maxWidth: MAX_HOST_LABEL_WIDTH,
|
||||
},
|
||||
itemPressed: {
|
||||
opacity: 0.82,
|
||||
},
|
||||
separator: {
|
||||
color: theme.colors.foregroundExtraMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
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,
|
||||
lineHeight: 16,
|
||||
flexShrink: 0,
|
||||
},
|
||||
prText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: 16,
|
||||
flexShrink: 0,
|
||||
},
|
||||
prTextHovered: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: 16,
|
||||
flexShrink: 0,
|
||||
},
|
||||
// Matches the indicator — see COLOR_MAPPINGS in check-indicator.tsx.
|
||||
checksTextPassed: {
|
||||
color: theme.colors.statusMutedSuccess,
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: 16,
|
||||
flexShrink: 0,
|
||||
},
|
||||
checksTextFailed: {
|
||||
color: theme.colors.statusMutedDanger,
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: 16,
|
||||
flexShrink: 0,
|
||||
},
|
||||
checksTextRunning: {
|
||||
color: theme.colors.statusMutedWarning,
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: 16,
|
||||
flexShrink: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
// Read inside render, never into a module-scope table: touching `styles.x` at module load
|
||||
// materializes the Unistyles proxy before the persisted theme has resolved, and the style
|
||||
// freezes on whatever theme happened to be active first. See docs/unistyles.md.
|
||||
function checksTextStyle(state: CheckSummaryState) {
|
||||
if (state === "failed") return styles.checksTextFailed;
|
||||
if (state === "running") return styles.checksTextRunning;
|
||||
return styles.checksTextPassed;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PrHint } from "@/git/pr-hint";
|
||||
import { DEFAULT_SIDEBAR_ROW_ITEMS } from "@/components/sidebar/display-preferences/row-items";
|
||||
import { selectMetaRowItems } from "./meta-items";
|
||||
import type { WorkspaceScriptSummary } from "./script-summary";
|
||||
|
||||
const PR_HINT: PrHint = {
|
||||
url: "https://github.com/acme/app/pull/7",
|
||||
number: 7,
|
||||
state: "open",
|
||||
forge: "github",
|
||||
checksStatus: "success",
|
||||
};
|
||||
|
||||
const SCRIPT: WorkspaceScriptSummary = { kind: "service", port: 3000 };
|
||||
|
||||
function select(overrides: Partial<Parameters<typeof selectMetaRowItems>[0]> = {}) {
|
||||
return selectMetaRowItems({
|
||||
hasHostBadge: true,
|
||||
prHint: PR_HINT,
|
||||
scriptSummary: SCRIPT,
|
||||
visible: DEFAULT_SIDEBAR_ROW_ITEMS,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
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", "scripts"]);
|
||||
});
|
||||
|
||||
it("omits what the workspace does not have", () => {
|
||||
expect(kinds(select({ hasHostBadge: false, prHint: null, scriptSummary: null }))).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["changeRequest", ["host", "checks", "scripts"]],
|
||||
["checks", ["host", "changeRequest", "scripts"]],
|
||||
["scripts", ["host", "changeRequest", "checks"]],
|
||||
] as const)("drops %s and only %s when it is switched off", (item, expected) => {
|
||||
expect(kinds(select({ visible: { ...DEFAULT_SIDEBAR_ROW_ITEMS, [item]: false } }))).toEqual(
|
||||
expected,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps checks when the change request is hidden", () => {
|
||||
// Each toggle answers for itself. A ticked "checks" that drew nothing because a different
|
||||
// switch was off would be lying about its own state.
|
||||
const items = select({
|
||||
visible: { ...DEFAULT_SIDEBAR_ROW_ITEMS, changeRequest: false, checks: true },
|
||||
});
|
||||
expect(kinds(items)).toEqual(["host", "checks", "scripts"]);
|
||||
});
|
||||
|
||||
it("draws nothing for checks when both are off", () => {
|
||||
const items = select({
|
||||
visible: { ...DEFAULT_SIDEBAR_ROW_ITEMS, changeRequest: false, checks: false },
|
||||
});
|
||||
expect(kinds(items)).toEqual(["host", "scripts"]);
|
||||
});
|
||||
|
||||
it("carries the resolved check summary rather than the raw hint", () => {
|
||||
const checks = select().find((item) => item.kind === "checks");
|
||||
expect(checks).toEqual({
|
||||
kind: "checks",
|
||||
summary: { state: "passed", completed: 1, total: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a change request whose forge reports no checks", () => {
|
||||
const items = select({ prHint: { ...PR_HINT, checksStatus: undefined } });
|
||||
expect(kinds(items)).toEqual(["host", "changeRequest", "scripts"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { PrHint } from "@/git/pr-hint";
|
||||
import type { SidebarRowItems } from "@/components/sidebar/display-preferences/row-items";
|
||||
import { selectCheckSummary, type CheckSummary } from "./check-summary";
|
||||
import type { WorkspaceScriptSummary } from "./script-summary";
|
||||
|
||||
/**
|
||||
* What ends up on the line under a workspace title, in the order it is read: where the
|
||||
* workspace lives, what change it belongs to, whether that change is passing, and what it is
|
||||
* running. Identity first, then the work, then the work's state.
|
||||
*/
|
||||
export type MetaRowItem =
|
||||
| { kind: "host" }
|
||||
| { kind: "changeRequest"; hint: PrHint }
|
||||
| { kind: "checks"; summary: CheckSummary }
|
||||
| { kind: "scripts"; summary: WorkspaceScriptSummary };
|
||||
|
||||
/**
|
||||
* Which peers a row should draw, given what it knows and what the user left switched on.
|
||||
*
|
||||
* Kept out of the component because this — not the markup — is the part with rules in it: every
|
||||
* toggle answers for itself, so a row can end up showing checks with no change request beside
|
||||
* them, and CI resolves from the hint even when the hint itself is not drawn.
|
||||
*
|
||||
* The host is filtered upstream, where the badge map is built: a host that should show nothing
|
||||
* has no badge to hand down, so by the time a row sees one it is meant to be drawn.
|
||||
*/
|
||||
export function selectMetaRowItems(input: {
|
||||
hasHostBadge: boolean;
|
||||
prHint: PrHint | null;
|
||||
scriptSummary: WorkspaceScriptSummary | null;
|
||||
visible: SidebarRowItems;
|
||||
}): MetaRowItem[] {
|
||||
const { hasHostBadge, prHint, scriptSummary, visible } = input;
|
||||
const items: MetaRowItem[] = [];
|
||||
|
||||
if (hasHostBadge) {
|
||||
items.push({ kind: "host" });
|
||||
}
|
||||
if (prHint && visible.changeRequest) {
|
||||
items.push({ kind: "changeRequest", hint: prHint });
|
||||
}
|
||||
|
||||
// Independent of the change request, even though checks are read off one. Tying them together
|
||||
// meant the checks toggle could sit ticked while nothing was drawn, which is a switch that lies
|
||||
// about its own state. Showing checks without the change request beside them is the stranger
|
||||
// combination, but it is the one you asked for and it is what you get.
|
||||
if (visible.checks) {
|
||||
const summary = selectCheckSummary(prHint);
|
||||
if (summary) {
|
||||
items.push({ kind: "checks", summary });
|
||||
}
|
||||
}
|
||||
|
||||
if (scriptSummary && visible.scripts) {
|
||||
items.push({ kind: "scripts", summary: scriptSummary });
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { selectWorkspaceScriptSummary } from "./script-summary";
|
||||
|
||||
type Script = SidebarWorkspaceEntry["scripts"][number];
|
||||
|
||||
function script(overrides: Partial<Script>): Script {
|
||||
return {
|
||||
scriptName: "dev",
|
||||
type: "service",
|
||||
hostname: "localhost",
|
||||
port: null,
|
||||
proxyUrl: null,
|
||||
lifecycle: "running",
|
||||
health: null,
|
||||
exitCode: null,
|
||||
terminalId: null,
|
||||
...overrides,
|
||||
} as Script;
|
||||
}
|
||||
|
||||
describe("selectWorkspaceScriptSummary", () => {
|
||||
it("returns nothing when no script is running", () => {
|
||||
expect(selectWorkspaceScriptSummary([])).toBeNull();
|
||||
expect(selectWorkspaceScriptSummary([script({ lifecycle: "stopped", port: 3000 })])).toBeNull();
|
||||
});
|
||||
|
||||
it("reports a running service with its port", () => {
|
||||
expect(selectWorkspaceScriptSummary([script({ port: 3000 })])).toEqual({
|
||||
kind: "service",
|
||||
port: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a service that has not bound a port yet", () => {
|
||||
expect(selectWorkspaceScriptSummary([script({ port: null })])).toEqual({
|
||||
kind: "service",
|
||||
port: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to a running command when no service runs", () => {
|
||||
expect(selectWorkspaceScriptSummary([script({ type: "script", port: null })])).toEqual({
|
||||
kind: "command",
|
||||
port: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers a running service over a running command regardless of order", () => {
|
||||
expect(
|
||||
selectWorkspaceScriptSummary([
|
||||
script({ scriptName: "test", type: "script" }),
|
||||
script({ scriptName: "dev", type: "service", port: 8080 }),
|
||||
]),
|
||||
).toEqual({ kind: "service", port: 8080 });
|
||||
});
|
||||
|
||||
it("ignores stopped services when a command is running", () => {
|
||||
expect(
|
||||
selectWorkspaceScriptSummary([
|
||||
script({ scriptName: "dev", type: "service", port: 3000, lifecycle: "stopped" }),
|
||||
script({ scriptName: "test", type: "script" }),
|
||||
]),
|
||||
).toEqual({ kind: "command", port: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
|
||||
/**
|
||||
* The one running script a row reports. A workspace can run several; the row has space for
|
||||
* one item, and a service (something reachable on a port) is the one worth surfacing, so a
|
||||
* running service outranks a running command.
|
||||
*/
|
||||
export interface WorkspaceScriptSummary {
|
||||
kind: "service" | "command";
|
||||
/** Only a service binds a port, and only then when the daemon has observed it. */
|
||||
port: number | null;
|
||||
}
|
||||
|
||||
export function selectWorkspaceScriptSummary(
|
||||
scripts: SidebarWorkspaceEntry["scripts"],
|
||||
): WorkspaceScriptSummary | null {
|
||||
let command: WorkspaceScriptSummary | null = null;
|
||||
for (const script of scripts) {
|
||||
if (script.lifecycle !== "running") continue;
|
||||
if ((script.type ?? "service") === "service") {
|
||||
return { kind: "service", port: script.port };
|
||||
}
|
||||
command ??= { kind: "command", port: null };
|
||||
}
|
||||
return command;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Text } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { DiffStat } from "@/components/diff-stat";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import type { SidebarWorkspaceTrailing } from "@/hooks/use-settings";
|
||||
import { useCompactTimeAgo } from "@/hooks/use-compact-time-ago";
|
||||
|
||||
export type { SidebarWorkspaceTrailing };
|
||||
|
||||
/**
|
||||
* The slot to the right of a workspace title. Three renderers behind one preference, so
|
||||
* every row renderer asks the same question and the kebab overlay geometry stays identical
|
||||
* no matter which one is showing.
|
||||
*
|
||||
* "none" exists because the slot is the only thing competing with the title for width, and
|
||||
* a user who never reads the diff would rather have the characters.
|
||||
*/
|
||||
export function useSidebarWorkspaceTrailing(): SidebarWorkspaceTrailing {
|
||||
const {
|
||||
settings: { sidebarWorkspaceTrailing },
|
||||
} = useAppSettings();
|
||||
return sidebarWorkspaceTrailing;
|
||||
}
|
||||
|
||||
/** Whether the slot has anything to draw for this workspace under the current preference. */
|
||||
export function hasSidebarWorkspaceTrailing({
|
||||
workspace,
|
||||
trailing,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
trailing: SidebarWorkspaceTrailing;
|
||||
}): boolean {
|
||||
if (trailing === "diff") return workspace.diffStat !== null;
|
||||
if (trailing === "timestamp") return workspace.statusEnteredAt !== null;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function SidebarWorkspaceTrailingContent({
|
||||
workspace,
|
||||
trailing,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
trailing: SidebarWorkspaceTrailing;
|
||||
}) {
|
||||
if (trailing === "diff" && workspace.diffStat) {
|
||||
return (
|
||||
<DiffStat additions={workspace.diffStat.additions} deletions={workspace.diffStat.deletions} />
|
||||
);
|
||||
}
|
||||
if (trailing === "timestamp" && workspace.statusEnteredAt) {
|
||||
return <WorkspaceTimestamp enteredAt={workspace.statusEnteredAt} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Its own component so the clock stops here. `useCompactTimeAgo` holds state, and state
|
||||
* re-renders the component that owns it — keeping that component down to a single `<Text>` is
|
||||
* what stops a minute tick from reaching the row, the list, or the diff stat next door.
|
||||
*/
|
||||
function WorkspaceTimestamp({ enteredAt }: { enteredAt: Date }) {
|
||||
const label = useCompactTimeAgo(enteredAt);
|
||||
return (
|
||||
<Text style={styles.timestamp} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
timestamp: {
|
||||
height: 20,
|
||||
lineHeight: 20,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
flexShrink: 0,
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user