mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-13 11:55:03 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a30b784eb2 |
@@ -624,6 +624,21 @@ one section's order from built-ins, plugin groups, and the section's preference
|
||||
remembers under its own key. It shares header ids and the `plugin:<pluginId>:<id>` settings key
|
||||
with new-API items.
|
||||
|
||||
## Contribute workspace row badge items
|
||||
|
||||
The [public reference](../public-docs/plugins/reference.md#workspace-row-badge-items) owns the author API.
|
||||
The host lives in `packages/app/src/plugins/workspace-row-badge-items/`; `WorkspaceMetaRow` draws a host's
|
||||
items after its own.
|
||||
|
||||
- Plugin items render with no wrapper view. The meta line spaces children with `gap`, so an empty
|
||||
wrapper would leave a gap on rows the plugin has nothing to say about. That is also why
|
||||
the row adds no separator around plugin items; a plugin renders its slot.
|
||||
- A row only renders items from plugins on its own host. The Show menu coalesces items across hosts
|
||||
by `<pluginId>/<itemId>`, the key in the `sidebarPluginRowBadgeItems` setting, and places them in
|
||||
their own group below a separator. A missing key is visible.
|
||||
- Previews (Settings > Host appearance) pass no `workspace` to `WorkspaceMetaRow`, so plugin items
|
||||
stay off them.
|
||||
|
||||
## Contribute file panels
|
||||
|
||||
Client `addFilePanel` registrations become the body of a file tab. The tab stats the file, picks a
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
closeSidebarDisplayPreferences,
|
||||
openMobileAgentSidebar,
|
||||
openSidebarDisplayPage,
|
||||
} from "../support/helpers/sidebar";
|
||||
|
||||
const PLUGIN_ID = "row-badges-e2e";
|
||||
const TITLE = "Badge host workspace";
|
||||
|
||||
const PLUGIN_SOURCE = `import { WorkspaceRowBadge } from "@getpaseo/plugin/client/ui";
|
||||
|
||||
function Review() {
|
||||
return <WorkspaceRowBadge icon="Eye" label="In review" tone="warning" />;
|
||||
}
|
||||
|
||||
function Docs() {
|
||||
return <WorkspaceRowBadge icon="BookOpen" label="Docs" />;
|
||||
}
|
||||
|
||||
function IconOnly() {
|
||||
return <WorkspaceRowBadge icon="Star" />;
|
||||
}
|
||||
|
||||
function EmptyLabel() {
|
||||
return <WorkspaceRowBadge icon="Circle" label="" />;
|
||||
}
|
||||
|
||||
function Quiet() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function Broken() {
|
||||
throw new Error("badge failed");
|
||||
}
|
||||
|
||||
export default function contribute(client) {
|
||||
client.addWorkspaceRowBadgeItem({ id: "review", title: "Review state", icon: "Eye", Component: Review });
|
||||
client.addWorkspaceRowBadgeItem({ id: "docs", title: "Docs state", icon: "BookOpen", Component: Docs });
|
||||
client.addWorkspaceRowBadgeItem({ id: "icon", title: "Icon state", icon: "Star", Component: IconOnly });
|
||||
client.addWorkspaceRowBadgeItem({ id: "empty", title: "Empty label state", icon: "Circle", Component: EmptyLabel });
|
||||
client.addWorkspaceRowBadgeItem({ id: "quiet", title: "Quiet state", icon: "Moon", Component: Quiet });
|
||||
client.addWorkspaceRowBadgeItem({ id: "broken", title: "Broken state", icon: "Bug", Component: Broken });
|
||||
return () => {};
|
||||
}
|
||||
`;
|
||||
|
||||
// The meta line the badges draw on. Each item sits in one wrapper of its own, so the line is two up.
|
||||
const LABELS = { review: "In review", docs: "Docs" } as const;
|
||||
|
||||
function badgeLine(page: Page, itemId: keyof typeof LABELS) {
|
||||
return badge(page, itemId).locator("xpath=../..");
|
||||
}
|
||||
|
||||
function badge(page: Page, itemId: keyof typeof LABELS) {
|
||||
return page.getByLabel(LABELS[itemId], { exact: true }).locator("visible=true");
|
||||
}
|
||||
|
||||
async function toggleShowItem(page: Page, label: string, touch: boolean) {
|
||||
await openSidebarDisplayPage(page, "sidebar-display-show");
|
||||
await page.getByRole("menuitem", { name: label, exact: true }).click();
|
||||
if (touch) {
|
||||
await page
|
||||
.getByRole("button", { name: "Bottom sheet backdrop", exact: true })
|
||||
.click({ position: { x: 5, y: 5 } });
|
||||
} else {
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
}
|
||||
}
|
||||
|
||||
test("a plugin badge shows on workspace rows, hides quietly, and follows its Show toggle", async ({
|
||||
page,
|
||||
}, info) => {
|
||||
test.setTimeout(120_000);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "row-badges-", title: TITLE });
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const config = await client.getDaemonConfig();
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-row-badges-"));
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), PLUGIN_SOURCE);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await gotoAppShell(page);
|
||||
|
||||
await test.step("the badge draws on the row; a null item and a throwing item draw nothing", async () => {
|
||||
await expect(badge(page, "review")).toBeVisible();
|
||||
await expect(badge(page, "review")).toContainText("In review");
|
||||
// The null and throwing items after them draw nothing, and the line adds nothing of its own.
|
||||
await expect(badgeLine(page, "review")).toHaveText("In reviewDocs");
|
||||
// An icon-only badge is named by its item's title.
|
||||
await expect(
|
||||
page.getByLabel("Icon state", { exact: true }).locator("visible=true"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByLabel("Empty label state", { exact: true }).locator("visible=true"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(TITLE).first()).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath("wide.png") });
|
||||
});
|
||||
|
||||
await test.step("every item has a Show toggle, and switching one off removes only that badge", async () => {
|
||||
await openSidebarDisplayPage(page, "sidebar-display-show");
|
||||
for (const label of ["Review state", "Quiet state", "Broken state", "Docs state"]) {
|
||||
await expect(page.getByRole("menuitem", { name: label, exact: true })).toBeVisible();
|
||||
}
|
||||
await expect(page.getByTestId("sidebar-plugin-row-badge-items-separator")).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath("show-menu.png") });
|
||||
await page.getByRole("menuitem", { name: "Review state", exact: true }).click();
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
await expect(badge(page, "review")).toHaveCount(0);
|
||||
// The first badge is gone, so the next one leads the line without a "·".
|
||||
await expect(badgeLine(page, "docs")).toHaveText("Docs");
|
||||
await toggleShowItem(page, "Review state", false);
|
||||
await expect(badge(page, "review")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("the badge draws on a compact layout", async () => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openMobileAgentSidebar(page);
|
||||
await expect(badge(page, "review")).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath("compact.png") });
|
||||
await toggleShowItem(page, "Review state", true);
|
||||
await expect(badge(page, "review")).toHaveCount(0);
|
||||
await toggleShowItem(page, "Review state", true);
|
||||
await expect(badge(page, "review")).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -58,6 +58,8 @@ import { SIDEBAR_ROW_ITEMS, type SidebarRowItem } from "./row-items";
|
||||
import { useWorkspaceLabelProjection } from "@/workspace-labels";
|
||||
import { WorkspaceLabelDot } from "@/workspace-labels/swatch";
|
||||
import { WorkspaceLabelManagerModal } from "@/workspace-labels/manager-modal";
|
||||
import { PluginIconView } from "@/plugins/icons";
|
||||
import type { PluginWorkspaceRowBadgeItemGroup } from "@/plugins/workspace-row-badge-items";
|
||||
|
||||
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
@@ -65,6 +67,7 @@ const ThemedSettings2 = withUnistyles(Settings2);
|
||||
/** CI's mark: the subject of the checks row, and the shape the icon-only option leaves behind. */
|
||||
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||
const ThemedCircle = withUnistyles(Circle);
|
||||
const ThemedPluginIconView = withUnistyles(PluginIconView);
|
||||
|
||||
/** Fits the item's 16pt leading slot with a hair of room, matching the trailing check. */
|
||||
const OPTION_ICON_SIZE = 14;
|
||||
@@ -607,9 +610,9 @@ function OptionList<Value extends string>({
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Three groups: Paseo's own row items, plugin badge items, then the one thing the slot to the
|
||||
* right of the title holds. Picking the trailing choice already showing empties the slot and
|
||||
* gives the width back to the title.
|
||||
*
|
||||
* CI is the one item above the separator with three answers rather than two, so it opens a page
|
||||
* instead of ticking, and it goes last: a row that navigates does not belong in the middle of a
|
||||
@@ -632,6 +635,12 @@ function ShowPage({ preferences }: { preferences: Preferences }): ReactElement {
|
||||
/>
|
||||
))}
|
||||
<ChecksSubTrigger />
|
||||
{preferences.pluginRowBadgeItems.length > 0 ? (
|
||||
<MenuSeparator testID="sidebar-plugin-row-badge-items-separator" />
|
||||
) : null}
|
||||
{preferences.pluginRowBadgeItems.map((item) => (
|
||||
<PluginRowBadgeItemOption key={item.key} item={item} preferences={preferences} />
|
||||
))}
|
||||
<MenuSeparator />
|
||||
{TRAILING_CHOICES.map((choice) => (
|
||||
<OptionItem
|
||||
@@ -649,6 +658,37 @@ function ShowPage({ preferences }: { preferences: Preferences }): ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
/** A plugin's workspace row badge item, toggled like the row's own. */
|
||||
function PluginRowBadgeItemOption({
|
||||
item,
|
||||
preferences,
|
||||
}: {
|
||||
item: PluginWorkspaceRowBadgeItemGroup;
|
||||
preferences: Preferences;
|
||||
}): ReactElement {
|
||||
const handleSelect = useCallback(
|
||||
() => preferences.togglePluginRowBadgeItem(item.key),
|
||||
[item.key, preferences],
|
||||
);
|
||||
const leading = useMemo(
|
||||
() => (
|
||||
<ThemedPluginIconView icon={item.icon} size={OPTION_ICON_SIZE} uniProps={mutedIconMapping} />
|
||||
),
|
||||
[item.icon],
|
||||
);
|
||||
return (
|
||||
<MenuItem
|
||||
selected={preferences.isPluginRowBadgeItemVisible(item.key)}
|
||||
leading={leading}
|
||||
closeOnSelect={false}
|
||||
onSelect={handleSelect}
|
||||
testID={`sidebar-row-badge-item-plugin-${item.pluginId}-${item.itemId}`}
|
||||
>
|
||||
{item.title}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* No value on the row, only the chevron. The three answers are all long enough that the value
|
||||
* fills the row and stops reading as an answer sitting at the right edge — it turns into a second
|
||||
|
||||
@@ -10,6 +10,12 @@ import {
|
||||
type SidebarGroupMode,
|
||||
type SidebarLabelFilter,
|
||||
} from "@/stores/sidebar-view-store";
|
||||
import { useInstalledPlugins } from "@/plugins/registry";
|
||||
import {
|
||||
groupPluginWorkspaceRowBadgeItems,
|
||||
isWorkspaceRowBadgeItemVisible,
|
||||
type PluginWorkspaceRowBadgeItemGroup,
|
||||
} from "@/plugins/workspace-row-badge-items";
|
||||
import { DEFAULT_SIDEBAR_CHECKS_DISPLAY, type SidebarChecksDisplay } from "./checks-display";
|
||||
import { DEFAULT_SIDEBAR_ROW_ITEMS, type SidebarRowItem, type SidebarRowItems } from "./row-items";
|
||||
|
||||
@@ -27,6 +33,10 @@ export interface SidebarDisplayPreferences {
|
||||
setTitleSource: (source: WorkspaceTitleSource) => void;
|
||||
rowItems: SidebarRowItems;
|
||||
toggleRowItem: (item: SidebarRowItem) => void;
|
||||
/** Workspace row badge items contributed by installed plugins, one per plugin item across hosts. */
|
||||
pluginRowBadgeItems: readonly PluginWorkspaceRowBadgeItemGroup[];
|
||||
isPluginRowBadgeItemVisible: (key: string) => boolean;
|
||||
togglePluginRowBadgeItem: (key: string) => void;
|
||||
checksDisplay: SidebarChecksDisplay;
|
||||
setChecksDisplay: (display: SidebarChecksDisplay) => void;
|
||||
trailing: SidebarWorkspaceTrailing;
|
||||
@@ -75,9 +85,30 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
sidebarWorkspaceTrailing,
|
||||
sidebarRowItems,
|
||||
sidebarChecksDisplay,
|
||||
sidebarPluginRowBadgeItems,
|
||||
},
|
||||
updateSettings,
|
||||
} = useAppSettings();
|
||||
const installedPlugins = useInstalledPlugins();
|
||||
const pluginRowBadgeItems = useMemo(
|
||||
() => groupPluginWorkspaceRowBadgeItems(installedPlugins),
|
||||
[installedPlugins],
|
||||
);
|
||||
const isPluginRowBadgeItemVisible = useCallback(
|
||||
(key: string) => isWorkspaceRowBadgeItemVisible(sidebarPluginRowBadgeItems, key),
|
||||
[sidebarPluginRowBadgeItems],
|
||||
);
|
||||
const togglePluginRowBadgeItem = useCallback(
|
||||
(key: string) => {
|
||||
void updateSettings({
|
||||
sidebarPluginRowBadgeItems: {
|
||||
...sidebarPluginRowBadgeItems,
|
||||
[key]: !isWorkspaceRowBadgeItemVisible(sidebarPluginRowBadgeItems, key),
|
||||
},
|
||||
});
|
||||
},
|
||||
[updateSettings, sidebarPluginRowBadgeItems],
|
||||
);
|
||||
|
||||
const setTitleSource = useCallback(
|
||||
(source: WorkspaceTitleSource) => {
|
||||
@@ -123,6 +154,9 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
setTitleSource,
|
||||
rowItems: sidebarRowItems,
|
||||
toggleRowItem,
|
||||
pluginRowBadgeItems,
|
||||
isPluginRowBadgeItemVisible,
|
||||
togglePluginRowBadgeItem,
|
||||
checksDisplay: sidebarChecksDisplay,
|
||||
setChecksDisplay,
|
||||
trailing: sidebarWorkspaceTrailing,
|
||||
@@ -148,6 +182,9 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
setTitleSource,
|
||||
sidebarRowItems,
|
||||
toggleRowItem,
|
||||
pluginRowBadgeItems,
|
||||
isPluginRowBadgeItemVisible,
|
||||
togglePluginRowBadgeItem,
|
||||
sidebarChecksDisplay,
|
||||
setChecksDisplay,
|
||||
sidebarWorkspaceTrailing,
|
||||
|
||||
@@ -186,6 +186,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
||||
prHint={workspace.prHint}
|
||||
serviceSummary={serviceSummary}
|
||||
labels={labels}
|
||||
workspace={workspace}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -16,6 +16,10 @@ import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { useSidebarMetaPreferences } from "@/components/sidebar/display-preferences/model";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { PullRequestStateIcon } from "@/git/pull-request-state-icon";
|
||||
import {
|
||||
PluginWorkspaceRowBadgeItems,
|
||||
usePluginWorkspaceRowBadgeItems,
|
||||
} from "@/plugins/workspace-row-badge-items";
|
||||
import { CheckIndicator } from "./check-indicator";
|
||||
import type { CheckSummary, CheckSummaryState } from "./check-summary";
|
||||
import { selectMetaRowItems, type MetaRowItem } from "./meta-items";
|
||||
@@ -59,6 +63,9 @@ const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
|
||||
* state has its own colors — a label is a name a person chose, so it carries its own identity
|
||||
* color on a tint instead (see `WorkspaceLabelChip`). It sits last so the reported facts are
|
||||
* read first, and it stays the same height as the rest of the line.
|
||||
*
|
||||
* Plugin items follow everything the row knows itself. A plugin renders its slot as it likes:
|
||||
* the row adds no separator, since only the plugin knows whether it has anything to say.
|
||||
*/
|
||||
export function WorkspaceMetaRow({
|
||||
currentBranch,
|
||||
@@ -67,6 +74,7 @@ export function WorkspaceMetaRow({
|
||||
prHint,
|
||||
serviceSummary,
|
||||
labels = EMPTY_LABELS,
|
||||
workspace = null,
|
||||
}: {
|
||||
currentBranch: string | null;
|
||||
projectName: string | null;
|
||||
@@ -74,6 +82,8 @@ export function WorkspaceMetaRow({
|
||||
prHint: PrHint | null;
|
||||
serviceSummary: WorkspaceServiceSummary | null;
|
||||
labels?: readonly WorkspaceLabelDefinition[];
|
||||
/** The real workspace behind the row; previews leave it out and get no plugin items. */
|
||||
workspace?: { serverId: string; workspaceId: string } | null;
|
||||
}) {
|
||||
const { rowItems, checksDisplay } = useSidebarMetaPreferences();
|
||||
const items = selectMetaRowItems({
|
||||
@@ -86,8 +96,9 @@ export function WorkspaceMetaRow({
|
||||
visible: rowItems,
|
||||
checksDisplay,
|
||||
});
|
||||
const pluginItems = usePluginWorkspaceRowBadgeItems(workspace?.serverId ?? null);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
if (items.length === 0 && (!workspace || pluginItems.length === 0)) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
@@ -97,6 +108,9 @@ export function WorkspaceMetaRow({
|
||||
<MetaItemNode item={item} hostBadge={hostBadge} leading={index === 0} />
|
||||
</Fragment>
|
||||
))}
|
||||
{workspace ? (
|
||||
<PluginWorkspaceRowBadgeItems targets={pluginItems} workspaceId={workspace.workspaceId} />
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,6 +96,8 @@ export interface AppSettings {
|
||||
sidebarWorkspaceTrailing: SidebarWorkspaceTrailing;
|
||||
sidebarRowItems: SidebarRowItems;
|
||||
sidebarChecksDisplay: SidebarChecksDisplay;
|
||||
/** Plugin workspace row badge items by `<pluginId>/<itemId>`; a missing key shows. */
|
||||
sidebarPluginRowBadgeItems: Record<string, boolean>;
|
||||
/** Top-level sidebar rows in display order; empty means the default order, all visible. */
|
||||
sidebarNavItems: SidebarNavPreference[];
|
||||
/** Sidebar footer items in display order; empty means the default order, all visible. */
|
||||
@@ -156,6 +158,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
||||
sidebarWorkspaceTrailing: "timestamp",
|
||||
sidebarRowItems: DEFAULT_SIDEBAR_ROW_ITEMS,
|
||||
sidebarChecksDisplay: DEFAULT_SIDEBAR_CHECKS_DISPLAY,
|
||||
sidebarPluginRowBadgeItems: {},
|
||||
sidebarNavItems: [],
|
||||
sidebarFooterItems: [],
|
||||
usage: DEFAULT_USAGE_PREFERENCES,
|
||||
@@ -253,6 +256,7 @@ const StoredAppSettingsSchema = z
|
||||
.enum(["iconAndText", "icon", "none"])
|
||||
.optional()
|
||||
.catch(DEFAULT_SIDEBAR_CHECKS_DISPLAY),
|
||||
sidebarPluginRowBadgeItems: z.record(z.string(), z.boolean()).catch({}),
|
||||
sidebarNavItems: z.array(z.object({ key: z.string(), visible: z.boolean() })).catch([]),
|
||||
sidebarFooterItems: z.array(z.object({ key: z.string(), visible: z.boolean() })).catch([]),
|
||||
usage: UsagePreferencesSchema,
|
||||
|
||||
@@ -26,6 +26,7 @@ function installation(): InstalledPlugin {
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
workspaceRowBadgeItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
|
||||
@@ -105,6 +105,7 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
workspaceRowBadgeItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
|
||||
@@ -37,6 +37,7 @@ function installed(): InstalledPlugin {
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
workspaceRowBadgeItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -47,6 +47,7 @@ function plugin(): InstalledPlugin {
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
workspaceRowBadgeItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -101,6 +101,7 @@ describe("evaluatePluginClientBundle", () => {
|
||||
plugin.addSettingsScreen({ id: "display", title: "Display", icon: "Settings", Component }),
|
||||
plugin.addSidebarHeaderItem({ id: "main", title: "Main", Component }),
|
||||
plugin.addSidebarFooterItem({ id: "status", title: "Status", Component }),
|
||||
plugin.addWorkspaceRowBadgeItem({ id: "status", title: "Status", icon: "Blocks", Component }),
|
||||
plugin.addWorkspacePanel({ id: "panel", title: "Panel", icon: "Blocks", context: "workspace", Component }),
|
||||
plugin.addCommandCenterItem({ id: "command", title: "Command", icon: "Blocks", context: "global", onSelect() {} }),
|
||||
plugin.addSlashCommand({ name: "review", description: "Review", argumentHint: "", context: "workspace", onSubmit() {} }),
|
||||
@@ -132,6 +133,7 @@ describe("evaluatePluginClientBundle", () => {
|
||||
plugin.settingsScreens,
|
||||
plugin.sidebarItems.header,
|
||||
plugin.sidebarItems.footer,
|
||||
plugin.workspaceRowBadgeItems,
|
||||
plugin.workspacePanels,
|
||||
plugin.commandCenterItems,
|
||||
plugin.clientSlashCommands,
|
||||
@@ -152,6 +154,7 @@ describe("evaluatePluginClientBundle", () => {
|
||||
plugin.settingsScreens,
|
||||
plugin.sidebarItems.header,
|
||||
plugin.sidebarItems.footer,
|
||||
plugin.workspaceRowBadgeItems,
|
||||
plugin.workspacePanels,
|
||||
plugin.commandCenterItems,
|
||||
plugin.clientSlashCommands,
|
||||
@@ -282,6 +285,44 @@ describe("evaluatePluginClientBundle", () => {
|
||||
).toThrow("Sidebar item main has no title");
|
||||
});
|
||||
|
||||
it("collects workspace row badge items and rejects duplicate and malformed ones", () => {
|
||||
const plugin = evaluatePluginClientBundle(
|
||||
"example",
|
||||
bundle(`
|
||||
function Item() { return null; }
|
||||
plugin.addWorkspaceRowBadgeItem({ id: "status", title: " Status ", icon: "Blocks", Component: Item });
|
||||
`),
|
||||
);
|
||||
expect(
|
||||
plugin.workspaceRowBadgeItems.map(({ id, title, icon }) => ({ id, title, icon })),
|
||||
).toEqual([{ id: "status", title: "Status", icon: "Blocks" }]);
|
||||
|
||||
const row = (fields: string) =>
|
||||
bundle(`function Item() { return null; } plugin.addWorkspaceRowBadgeItem({ ${fields} });`);
|
||||
expect(() =>
|
||||
evaluatePluginClientBundle(
|
||||
"example",
|
||||
bundle(`
|
||||
function Item() { return null; }
|
||||
plugin.addWorkspaceRowBadgeItem({ id: "status", title: "One", icon: "Blocks", Component: Item });
|
||||
plugin.addWorkspaceRowBadgeItem({ id: "status", title: "Two", icon: "Blocks", Component: Item });
|
||||
`),
|
||||
),
|
||||
).toThrow("Duplicate workspace row badge item: status");
|
||||
expect(() =>
|
||||
evaluatePluginClientBundle(
|
||||
"example",
|
||||
row(`id: "status", title: " ", icon: "Blocks", Component: Item`),
|
||||
),
|
||||
).toThrow("Workspace row badge item status has no title");
|
||||
expect(() =>
|
||||
evaluatePluginClientBundle(
|
||||
"example",
|
||||
row(`id: "status", title: "Status", icon: "Blocks", Component: "Item"`),
|
||||
),
|
||||
).toThrow("Workspace row badge item status is not a component");
|
||||
});
|
||||
|
||||
it("expands the addSurface and addSidebarItem aliases to a screen and a legacy header item", () => {
|
||||
const plugin = evaluatePluginClientBundle(
|
||||
"example",
|
||||
|
||||
@@ -37,6 +37,7 @@ import type {
|
||||
PluginScreenContribution,
|
||||
PluginSidebarItemContribution,
|
||||
PluginWorkspacePanelContribution,
|
||||
PluginWorkspaceRowBadgeItemContribution,
|
||||
} from "./types";
|
||||
import type { ComponentType } from "react";
|
||||
import { parsePluginIcon, parseSerializablePluginIcon } from "./icons";
|
||||
@@ -133,6 +134,7 @@ export function runPluginClientBundle(
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
workspaceRowBadgeItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
@@ -151,6 +153,7 @@ export function runPluginClientBundle(
|
||||
header: new Set(),
|
||||
footer: new Set(),
|
||||
};
|
||||
const workspaceRowItemIds = new Set<string>();
|
||||
const filePanelIds = new Set<string>();
|
||||
const composerItemIds = new Set<string>();
|
||||
const newWorkspaceItemIds = new Set<string>();
|
||||
@@ -323,6 +326,23 @@ export function runPluginClientBundle(
|
||||
addSidebarFooterItem(contribution: PluginSidebarItemContribution) {
|
||||
return addSidebarItem("footer", contribution);
|
||||
},
|
||||
addWorkspaceRowBadgeItem(contribution: PluginWorkspaceRowBadgeItemContribution) {
|
||||
const itemId = requireId(contribution.id, "workspace row badge item id");
|
||||
if (workspaceRowItemIds.has(itemId))
|
||||
throw new Error(`Duplicate workspace row badge item: ${itemId}`);
|
||||
const title = normalizePluginText(contribution.title);
|
||||
if (!title) throw new Error(`Workspace row badge item ${itemId} has no title`);
|
||||
const icon = parsePluginIcon(`Workspace row badge item ${itemId}`, contribution.icon);
|
||||
if (!isComponentType(contribution.Component)) {
|
||||
throw new Error(`Workspace row badge item ${itemId} is not a component`);
|
||||
}
|
||||
workspaceRowItemIds.add(itemId);
|
||||
return register(
|
||||
collector.workspaceRowBadgeItems,
|
||||
{ id: itemId, title, icon, Component: contribution.Component },
|
||||
() => workspaceRowItemIds.delete(itemId),
|
||||
);
|
||||
},
|
||||
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
|
||||
// Titled by its id; a legacy item pointing at it lends the header its own title instead.
|
||||
addSurface(surfaceId: string, Component: ComponentType<PluginSurfaceProps>) {
|
||||
@@ -634,6 +654,7 @@ export function runPluginClientBundle(
|
||||
sidebarViews: collector.sidebarViews,
|
||||
sidebarItems: collector.sidebarItems,
|
||||
legacySidebarItems: collector.legacySidebarItems,
|
||||
workspaceRowBadgeItems: collector.workspaceRowBadgeItems,
|
||||
filePanels: collector.filePanels,
|
||||
composerItems: collector.composerItems,
|
||||
newWorkspaceItems: collector.newWorkspaceItems,
|
||||
|
||||
@@ -46,6 +46,7 @@ function installation(invoked: string[]): InstalledPlugin {
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
workspaceRowBadgeItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { InstalledPlugin } from "./types";
|
||||
/** `withUnistyles` mapping that hands a host the plugin theme. */
|
||||
export const pluginThemeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
|
||||
|
||||
function renderNothing() {
|
||||
export function renderNothing() {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export {
|
||||
export { ExternalLink } from "@/components/ui/external-link";
|
||||
|
||||
export { SidebarRow, SidebarSeparator } from "@/plugins/sidebar-items/kit";
|
||||
export { WorkspaceRowBadge } from "@/plugins/workspace-row-badge-items/kit";
|
||||
|
||||
export { DocumentFrame } from "@/file-panels/document-frame";
|
||||
|
||||
|
||||
@@ -125,6 +125,7 @@ export class PluginRegistry {
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
workspaceRowBadgeItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
|
||||
@@ -37,6 +37,7 @@ function installed(
|
||||
footer: [{ id: "status", title: "Status", Component: () => null }],
|
||||
},
|
||||
legacySidebarItems,
|
||||
workspaceRowBadgeItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
|
||||
@@ -51,6 +51,7 @@ function installation(
|
||||
surfaces: surfaces.map((id) => ({ id, title: id, Component: () => null })),
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
workspaceRowBadgeItems: [],
|
||||
legacySidebarItems: sidebarItems.map((item) => ({
|
||||
...item,
|
||||
title: item.id,
|
||||
|
||||
@@ -64,6 +64,7 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
workspaceRowBadgeItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
|
||||
@@ -27,6 +27,7 @@ function plugin(input: {
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
workspaceRowBadgeItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
|
||||
@@ -34,6 +34,7 @@ vi.mock("../client-runtime", () => ({
|
||||
}));
|
||||
vi.mock("../icons", () => ({
|
||||
Icon: () => null,
|
||||
PluginIconView: () => null,
|
||||
pluginIconComponent: () => () => null,
|
||||
}));
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
PluginSidebarContribution as SDKPluginSidebarContribution,
|
||||
PluginSidebarViewContribution,
|
||||
PluginSidebarItemContribution as SDKPluginSidebarItemContribution,
|
||||
PluginWorkspaceRowBadgeItemContribution as SDKPluginWorkspaceRowBadgeItemContribution,
|
||||
PluginScreenContribution as SDKPluginScreenContribution,
|
||||
PluginSettingsScreenContribution as SDKPluginSettingsScreenContribution,
|
||||
PluginTimelineRendererContribution,
|
||||
@@ -33,6 +34,8 @@ type PluginClientSlashCommandContribution = SDKPluginClientSlashCommandContribut
|
||||
type PluginComposerPillContribution = SDKPluginComposerPillContribution<PluginText>;
|
||||
type PluginSidebarContribution = SDKPluginSidebarContribution<PluginText>;
|
||||
type PluginSidebarItemContribution = SDKPluginSidebarItemContribution<PluginText>;
|
||||
type PluginWorkspaceRowBadgeItemContribution =
|
||||
SDKPluginWorkspaceRowBadgeItemContribution<PluginText>;
|
||||
type PluginScreenContribution = SDKPluginScreenContribution<PluginText>;
|
||||
type PluginSettingsScreenContribution = SDKPluginSettingsScreenContribution<PluginText>;
|
||||
type WorkspacePanelInput = SDKPluginWorkspacePanelContribution<PluginText>;
|
||||
@@ -56,6 +59,7 @@ export interface EvaluatedPlugin {
|
||||
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
|
||||
/** `addSidebarItem` registrations, so `/plugin/<id>/sidebar/<item>` routes keep resolving. */
|
||||
legacySidebarItems: PluginSidebarContribution[];
|
||||
workspaceRowBadgeItems: PluginWorkspaceRowBadgeItemContribution[];
|
||||
filePanels: PluginFilePanelContribution[];
|
||||
composerItems: PluginComposerItemContribution[];
|
||||
newWorkspaceItems: PluginNewWorkspaceItemContribution[];
|
||||
@@ -95,6 +99,7 @@ export type {
|
||||
PluginComposerPillContribution,
|
||||
PluginSidebarContribution,
|
||||
PluginSidebarItemContribution,
|
||||
PluginWorkspaceRowBadgeItemContribution,
|
||||
PluginScreenContribution,
|
||||
PluginSettingsScreenContribution,
|
||||
PluginThemeContribution,
|
||||
|
||||
@@ -24,6 +24,7 @@ function installed(): InstalledPlugin {
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
workspaceRowBadgeItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { PluginTheme } from "@getpaseo/plugin";
|
||||
import type { PluginWorkspaceRowBadgeItemProps } from "@getpaseo/plugin/client";
|
||||
import { useMemo } from "react";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { useInstalledPlugins } from "../registry";
|
||||
import { pluginThemeMapping, renderNothing, usePluginItemHost } from "../item-host";
|
||||
import { PluginEnvironmentProvider } from "../popover";
|
||||
import { SurfaceErrorBoundary } from "../surface-error-boundary";
|
||||
import type { InstalledPlugin, PluginWorkspaceRowBadgeItemContribution } from "../types";
|
||||
import { isWorkspaceRowBadgeItemVisible, workspaceRowBadgeItemKey } from "./model";
|
||||
|
||||
import { WorkspaceRowBadgeTitleContext } from "./kit";
|
||||
|
||||
export { WorkspaceRowBadge } from "./kit";
|
||||
export {
|
||||
groupPluginWorkspaceRowBadgeItems,
|
||||
isWorkspaceRowBadgeItemVisible,
|
||||
type PluginWorkspaceRowBadgeItemGroup,
|
||||
} from "./model";
|
||||
|
||||
interface RowBadgeItemTarget {
|
||||
plugin: InstalledPlugin;
|
||||
item: PluginWorkspaceRowBadgeItemContribution;
|
||||
}
|
||||
|
||||
/** The visible plugin row items installed on this workspace's host. */
|
||||
export function usePluginWorkspaceRowBadgeItems(serverId: string | null): RowBadgeItemTarget[] {
|
||||
const installed = useInstalledPlugins();
|
||||
const {
|
||||
settings: { sidebarPluginRowBadgeItems },
|
||||
} = useAppSettings();
|
||||
return useMemo(
|
||||
() =>
|
||||
installed
|
||||
.filter((plugin) => plugin.serverId === serverId)
|
||||
.flatMap((plugin) => plugin.workspaceRowBadgeItems.map((item) => ({ plugin, item })))
|
||||
.filter(({ plugin, item }) =>
|
||||
isWorkspaceRowBadgeItemVisible(
|
||||
sidebarPluginRowBadgeItems,
|
||||
workspaceRowBadgeItemKey(plugin.id, item.id),
|
||||
),
|
||||
),
|
||||
[installed, serverId, sidebarPluginRowBadgeItems],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin items on a workspace's meta line, after the row's own. No wrapper views: an item that
|
||||
* renders nothing must leave no gap behind it on the line.
|
||||
*/
|
||||
export function PluginWorkspaceRowBadgeItems({
|
||||
targets,
|
||||
workspaceId,
|
||||
}: {
|
||||
targets: readonly RowBadgeItemTarget[];
|
||||
workspaceId: string;
|
||||
}) {
|
||||
return targets.map((target) => (
|
||||
<ThemedRowBadgeItemHost
|
||||
key={workspaceRowBadgeItemKey(target.plugin.id, target.item.id)}
|
||||
target={target}
|
||||
workspaceId={workspaceId}
|
||||
uniProps={pluginThemeMapping}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
function RowBadgeItemHost({
|
||||
target: { plugin, item },
|
||||
workspaceId,
|
||||
theme,
|
||||
}: {
|
||||
target: RowBadgeItemTarget;
|
||||
workspaceId: string;
|
||||
theme: PluginTheme;
|
||||
}) {
|
||||
const { environment, props: hostProps } = usePluginItemHost(plugin, theme);
|
||||
const props = useMemo<PluginWorkspaceRowBadgeItemProps>(
|
||||
() => ({ ...hostProps, workspaceId }),
|
||||
[hostProps, workspaceId],
|
||||
);
|
||||
const title = plugin.i18n.resolve(item.title, plugin.locale);
|
||||
const Item = item.Component;
|
||||
return (
|
||||
<SurfaceErrorBoundary installation={plugin} Surface={Item} renderError={renderNothing}>
|
||||
<PluginEnvironmentProvider environment={environment}>
|
||||
<WorkspaceRowBadgeTitleContext.Provider value={title}>
|
||||
<Item {...props} />
|
||||
</WorkspaceRowBadgeTitleContext.Provider>
|
||||
</PluginEnvironmentProvider>
|
||||
</SurfaceErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedRowBadgeItemHost = withUnistyles(RowBadgeItemHost);
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { WorkspaceRowBadgeProps, WorkspaceRowBadgeTone } from "@getpaseo/plugin/client/ui";
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { HOST_BADGE_ICON_SIZE } from "@/hosts/host-badge";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { parsePluginIcon, PluginIconView } from "../icons";
|
||||
|
||||
/** The item's title, the accessible name of a badge that has neither a label nor its own. */
|
||||
export const WorkspaceRowBadgeTitleContext = createContext<string | undefined>(undefined);
|
||||
|
||||
const ThemedPluginIconView = withUnistyles(PluginIconView);
|
||||
|
||||
function toneColor(theme: Theme, tone: WorkspaceRowBadgeTone): string {
|
||||
if (tone === "success") return theme.colors.statusSuccess;
|
||||
if (tone === "warning") return theme.colors.statusWarning;
|
||||
if (tone === "danger") return theme.colors.statusDanger;
|
||||
if (tone === "info") return theme.colors.statusDotRunning;
|
||||
return theme.colors.foregroundMuted;
|
||||
}
|
||||
|
||||
const TONE_MAPPINGS: Record<WorkspaceRowBadgeTone, (theme: Theme) => { color: string }> = {
|
||||
muted: (theme) => ({ color: toneColor(theme, "muted") }),
|
||||
success: (theme) => ({ color: toneColor(theme, "success") }),
|
||||
warning: (theme) => ({ color: toneColor(theme, "warning") }),
|
||||
danger: (theme) => ({ color: toneColor(theme, "danger") }),
|
||||
info: (theme) => ({ color: toneColor(theme, "info") }),
|
||||
};
|
||||
|
||||
/**
|
||||
* A plugin's badge on the line under a workspace title, drawn like the row's own peers: one glyph
|
||||
* at the line's size and a short word, in the color of the state it reports. The plugin owns the
|
||||
* slot, so the badge draws no separator.
|
||||
*/
|
||||
export function WorkspaceRowBadge({
|
||||
icon,
|
||||
label,
|
||||
tone = "muted",
|
||||
accessibilityLabel,
|
||||
}: WorkspaceRowBadgeProps) {
|
||||
const itemTitle = useContext(WorkspaceRowBadgeTitleContext);
|
||||
// Parsed at render, not registration, so an invalid icon throws to the item's boundary.
|
||||
const parsedIcon = useMemo(
|
||||
() => (icon === undefined ? null : parsePluginIcon("Workspace row badge", icon)),
|
||||
[icon],
|
||||
);
|
||||
if (!parsedIcon && !label) return null;
|
||||
return (
|
||||
<View style={styles.item} accessibilityLabel={accessibilityLabel ?? (label || itemTitle)}>
|
||||
{parsedIcon ? (
|
||||
<ThemedPluginIconView
|
||||
icon={parsedIcon}
|
||||
size={HOST_BADGE_ICON_SIZE}
|
||||
uniProps={TONE_MAPPINGS[tone]}
|
||||
/>
|
||||
) : null}
|
||||
{label ? (
|
||||
<Text style={[styles.text, textToneStyle(tone)]} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Read inside render, never into a module-scope table — see docs/unistyles.md.
|
||||
function textToneStyle(tone: WorkspaceRowBadgeTone) {
|
||||
if (tone === "success") return styles.success;
|
||||
if (tone === "warning") return styles.warning;
|
||||
if (tone === "danger") return styles.danger;
|
||||
if (tone === "info") return styles.info;
|
||||
return styles.muted;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
item: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
text: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: 16,
|
||||
flexShrink: 1,
|
||||
},
|
||||
muted: { color: toneColor(theme, "muted") },
|
||||
success: { color: toneColor(theme, "success") },
|
||||
warning: { color: toneColor(theme, "warning") },
|
||||
danger: { color: toneColor(theme, "danger") },
|
||||
info: { color: toneColor(theme, "info") },
|
||||
}));
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
import { groupPluginWorkspaceRowBadgeItems, isWorkspaceRowBadgeItemVisible } from "./model";
|
||||
|
||||
function Item() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function installed(id: string, serverId: string, itemIds: string[]): InstalledPlugin {
|
||||
return {
|
||||
id,
|
||||
serverId,
|
||||
locale: "en",
|
||||
i18n: { resolve: (text: string) => text },
|
||||
workspaceRowBadgeItems: itemIds.map((itemId) => ({
|
||||
id: itemId,
|
||||
title: `${id} ${itemId}`,
|
||||
icon: "Gauge",
|
||||
Component: Item,
|
||||
})),
|
||||
} as unknown as InstalledPlugin;
|
||||
}
|
||||
|
||||
describe("groupPluginWorkspaceRowBadgeItems", () => {
|
||||
it("lists each plugin item once across hosts, in install order", () => {
|
||||
const groups = groupPluginWorkspaceRowBadgeItems([
|
||||
installed("status", "host-a", ["state"]),
|
||||
installed("asks", "host-a", ["open"]),
|
||||
installed("status", "host-b", ["state"]),
|
||||
]);
|
||||
expect(groups.map(({ key, title }) => ({ key, title }))).toEqual([
|
||||
{ key: "status/state", title: "status state" },
|
||||
{ key: "asks/open", title: "asks open" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isWorkspaceRowBadgeItemVisible", () => {
|
||||
it("shows items until they are switched off", () => {
|
||||
expect(isWorkspaceRowBadgeItemVisible({}, "status/state")).toBe(true);
|
||||
expect(isWorkspaceRowBadgeItemVisible({ "status/state": true }, "status/state")).toBe(true);
|
||||
expect(isWorkspaceRowBadgeItemVisible({ "status/state": false }, "status/state")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { PluginIcon } from "@getpaseo/plugin/client";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
|
||||
/** One workspace row badge item, coalesced across every host that contributes it. */
|
||||
export interface PluginWorkspaceRowBadgeItemGroup {
|
||||
/** `<pluginId>/<itemId>`: the key its Show toggle is stored under. */
|
||||
key: string;
|
||||
pluginId: string;
|
||||
itemId: string;
|
||||
title: string;
|
||||
icon: PluginIcon;
|
||||
}
|
||||
|
||||
export function workspaceRowBadgeItemKey(pluginId: string, itemId: string): string {
|
||||
return `${pluginId}/${itemId}`;
|
||||
}
|
||||
|
||||
/** An item missing from the stored record has never been toggled, so it shows. */
|
||||
export function isWorkspaceRowBadgeItemVisible(
|
||||
visibility: Readonly<Record<string, boolean>>,
|
||||
key: string,
|
||||
): boolean {
|
||||
return visibility[key] !== false;
|
||||
}
|
||||
|
||||
/** Distinct items in install order, for the Show menu. Each host's copy draws on its own rows. */
|
||||
export function groupPluginWorkspaceRowBadgeItems(
|
||||
plugins: readonly InstalledPlugin[],
|
||||
): PluginWorkspaceRowBadgeItemGroup[] {
|
||||
const groups = new Map<string, PluginWorkspaceRowBadgeItemGroup>();
|
||||
for (const plugin of plugins) {
|
||||
for (const item of plugin.workspaceRowBadgeItems) {
|
||||
const key = workspaceRowBadgeItemKey(plugin.id, item.id);
|
||||
if (groups.has(key)) continue;
|
||||
groups.set(key, {
|
||||
key,
|
||||
pluginId: plugin.id,
|
||||
itemId: item.id,
|
||||
title: plugin.i18n.resolve(item.title, plugin.locale),
|
||||
icon: item.icon,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
@@ -121,6 +121,22 @@ export interface PluginSidebarViewContribution<Text extends PluginText = string>
|
||||
Component: ComponentType<PluginSidebarViewProps>;
|
||||
}
|
||||
|
||||
export interface PluginWorkspaceRowBadgeItemProps extends PluginHostProps {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Part of the line under a workspace's title in the sidebar, on every workspace of the plugin's host.
|
||||
* Render `WorkspaceRowBadge`, or nothing when there is nothing to say about this workspace.
|
||||
*/
|
||||
export interface PluginWorkspaceRowBadgeItemContribution<Text extends PluginText = string> {
|
||||
id: string;
|
||||
/** The item's toggle in the sidebar's Show menu. */
|
||||
title: Text;
|
||||
icon: PluginIcon;
|
||||
Component: ComponentType<PluginWorkspaceRowBadgeItemProps>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Lucide icon name, inline SVG markup, or a component. Components receive the surface's size
|
||||
* and color; buttons widen the props with their host and target context.
|
||||
@@ -214,6 +230,9 @@ export interface PluginClientContext extends PluginCommandCapabilities, PluginCl
|
||||
addSidebarHeaderItem(contribution: PluginSidebarItemContribution<PluginText>): PluginCleanup;
|
||||
addSidebarFooterItem(contribution: PluginSidebarItemContribution<PluginText>): PluginCleanup;
|
||||
addAgentPanel(contribution: PluginAgentPanelContribution<PluginText>): PluginCleanup;
|
||||
addWorkspaceRowBadgeItem(
|
||||
contribution: PluginWorkspaceRowBadgeItemContribution<PluginText>,
|
||||
): PluginCleanup;
|
||||
addWorkspacePanel(contribution: PluginWorkspacePanelContribution<PluginText>): PluginCleanup;
|
||||
addCommandCenterItem(
|
||||
contribution: PluginCommandCenterItemContribution<PluginText>,
|
||||
|
||||
@@ -18,6 +18,8 @@ export type {
|
||||
PluginSidebarViewProps,
|
||||
PluginSidebarViewContribution,
|
||||
PluginSidebarItemContribution,
|
||||
PluginWorkspaceRowBadgeItemProps,
|
||||
PluginWorkspaceRowBadgeItemContribution,
|
||||
PluginIcon,
|
||||
PluginPanelLocation,
|
||||
PluginOpenPanelOptions,
|
||||
|
||||
@@ -173,6 +173,19 @@ export declare const SidebarRow: ComponentType<SidebarRowProps>;
|
||||
/** A line between groups of rows. Render it from a sidebar item's `Component`. */
|
||||
export declare const SidebarSeparator: ComponentType;
|
||||
|
||||
export type WorkspaceRowBadgeTone = "muted" | "success" | "warning" | "danger" | "info";
|
||||
export interface WorkspaceRowBadgeProps {
|
||||
/** A Lucide icon name, inline SVG, or a component. */
|
||||
icon?: SidebarIcon;
|
||||
label?: string;
|
||||
/** Default "muted". */
|
||||
tone?: WorkspaceRowBadgeTone;
|
||||
/** Defaults to `label`, then the item's `title`. */
|
||||
accessibilityLabel?: string;
|
||||
}
|
||||
/** One item on a sidebar workspace row's meta line. Render it from a workspace row badge item's `Component`. */
|
||||
export declare const WorkspaceRowBadge: ComponentType<WorkspaceRowBadgeProps>;
|
||||
|
||||
/** Paseo's loading state. Fills the panel; the panel decides when to show it. */
|
||||
export declare const Loading: ComponentType;
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Workspace row badges
|
||||
|
||||
Install this directory as a plugin. Any workspace that needs input or has failed shows a badge on
|
||||
the line under its title in the sidebar. Workspaces in any other state show nothing.
|
||||
|
||||
The component reads its own data with `useWorkspace(workspaceId, selector)` and returns `null` when
|
||||
it has nothing to say. **Show → Workspace attention** in the sidebar's display preferences toggles it.
|
||||
|
||||
See the [reference](../../public-docs/plugins/reference.md#workspace-row-badge-items).
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useWorkspace } from "@getpaseo/plugin/client";
|
||||
import type {
|
||||
PluginClientContext,
|
||||
PluginWorkspaceRowBadgeItemProps,
|
||||
} from "@getpaseo/plugin/client";
|
||||
import { WorkspaceRowBadge } from "@getpaseo/plugin/client/ui";
|
||||
|
||||
function WorkspaceAttention({ workspaceId }: PluginWorkspaceRowBadgeItemProps) {
|
||||
const status = useWorkspace(workspaceId, (workspace) => workspace.status);
|
||||
if (status === "needs_input") {
|
||||
return <WorkspaceRowBadge icon="CircleHelp" label="Needs input" tone="warning" />;
|
||||
}
|
||||
if (status === "failed") {
|
||||
return <WorkspaceRowBadge icon="CircleAlert" label="Failed" tone="danger" />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
// Apps older than this API do not have the method, and requirements.paseo cannot exclude them.
|
||||
if (!client.addWorkspaceRowBadgeItem) return () => {};
|
||||
client.addWorkspaceRowBadgeItem({
|
||||
id: "attention",
|
||||
title: "Workspace attention",
|
||||
icon: "CircleAlert",
|
||||
Component: WorkspaceAttention,
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "id": "workspace-row-badges", "requirements": { "paseo": ">=0.16.0" } }
|
||||
@@ -67,30 +67,31 @@ is a compile error.
|
||||
|
||||
Use this table as the complete registration checklist.
|
||||
|
||||
| Old registration and location | New registration and location |
|
||||
| --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `plugin.handle(contract, handler)` in the old root entry | `server.handle(contract, handler)` in `index.server.ts` |
|
||||
| `plugin.addSurface(id, Component)` in the old root entry | `client.addScreen({ id, title, Component })` in `index.client.tsx` |
|
||||
| `plugin.addSidebarItem({ id, title, icon, surface })` in the old root entry | `client.addSidebarHeaderItem({ id, title, Component })` in `index.client.tsx`; see [sidebar items](reference#sidebar-items) |
|
||||
| New sidebar footer contribution | `client.addSidebarFooterItem({ id, title, Component })` in `index.client.tsx` |
|
||||
| New sidebar replacement view | `client.addSidebarView({ id, title, Component })` in `index.client.tsx`; see [sidebar views](sidebar-views) |
|
||||
| New file panel contribution | `client.addFilePanel(panel)` in `index.client.tsx` |
|
||||
| `plugin.addWorkspacePanel(panel)` in the old root entry | `client.addWorkspacePanel(panel)` in `index.client.tsx` |
|
||||
| Agent panel with `context: "agent"` | `client.addAgentPanel(panel)` in `index.client.tsx`; omit `context` |
|
||||
| `plugin.addCommandCenterItem(item)` in the old root entry | `client.addCommandCenterItem(item)` in `index.client.tsx` |
|
||||
| `plugin.addClientSlashCommand(command)` in the old root entry | `client.addSlashCommand(command)` in `index.client.tsx` |
|
||||
| `plugin.addClientSide(fn)` in the old root entry | Delete the wrapper and move the body of `fn` into the default client entry function |
|
||||
| `client.addComposerPill(pill)` inside the old client callback | `client.addComposerPill(pill)` inside `index.client.tsx` or an imported `client/` function |
|
||||
| New composer item contribution | `client.addComposerItem(item)` in `index.client.tsx`; see [composer items](reference#composer-items) |
|
||||
| New New workspace item contribution | `client.addNewWorkspaceItem(item)` in `index.client.tsx`; see [New workspace items](reference#new-workspace-items) |
|
||||
| New header contribution | `client.addHeaderButton({ id, workspaceId, button })` |
|
||||
| `plugin.addAttachmentSource(source)` in the old root entry | `client.addAttachmentSource(source)` in `index.client.tsx` |
|
||||
| New settings screen contribution | `client.addSettingsScreen(screen)` in `index.client.tsx`; see [settings screens](reference#settings-screens) |
|
||||
| `plugin.addTheme(theme)` in the old root entry | `client.addTheme(theme)` in `index.client.tsx` |
|
||||
| `plugin.addTimelineTransformer(transformer)` in the old root entry | `client.addTimelineTransformer(transformer)` in `index.client.tsx` |
|
||||
| `plugin.addTimelineRenderer(renderer)` in the old root entry | `client.addTimelineRenderer(renderer)` in `index.client.tsx` |
|
||||
| `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin/server"` in shared files | `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin"` |
|
||||
| `ZodOutput<typeof contract.input>` handler parameter types | `RpcInput<typeof contract>` from `@getpaseo/plugin`; `RpcOutput` for return types |
|
||||
| Old registration and location | New registration and location |
|
||||
| --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `plugin.handle(contract, handler)` in the old root entry | `server.handle(contract, handler)` in `index.server.ts` |
|
||||
| `plugin.addSurface(id, Component)` in the old root entry | `client.addScreen({ id, title, Component })` in `index.client.tsx` |
|
||||
| `plugin.addSidebarItem({ id, title, icon, surface })` in the old root entry | `client.addSidebarHeaderItem({ id, title, Component })` in `index.client.tsx`; see [sidebar items](reference#sidebar-items) |
|
||||
| New sidebar footer contribution | `client.addSidebarFooterItem({ id, title, Component })` in `index.client.tsx` |
|
||||
| New sidebar replacement view | `client.addSidebarView({ id, title, Component })` in `index.client.tsx`; see [sidebar views](sidebar-views) |
|
||||
| New badge on sidebar workspace rows | `client.addWorkspaceRowBadgeItem({ id, title, icon, Component })` in `index.client.tsx`; see [workspace row badge items](reference#workspace-row-badge-items) |
|
||||
| New file panel contribution | `client.addFilePanel(panel)` in `index.client.tsx` |
|
||||
| `plugin.addWorkspacePanel(panel)` in the old root entry | `client.addWorkspacePanel(panel)` in `index.client.tsx` |
|
||||
| Agent panel with `context: "agent"` | `client.addAgentPanel(panel)` in `index.client.tsx`; omit `context` |
|
||||
| `plugin.addCommandCenterItem(item)` in the old root entry | `client.addCommandCenterItem(item)` in `index.client.tsx` |
|
||||
| `plugin.addClientSlashCommand(command)` in the old root entry | `client.addSlashCommand(command)` in `index.client.tsx` |
|
||||
| `plugin.addClientSide(fn)` in the old root entry | Delete the wrapper and move the body of `fn` into the default client entry function |
|
||||
| `client.addComposerPill(pill)` inside the old client callback | `client.addComposerPill(pill)` inside `index.client.tsx` or an imported `client/` function |
|
||||
| New composer item contribution | `client.addComposerItem(item)` in `index.client.tsx`; see [composer items](reference#composer-items) |
|
||||
| New New workspace item contribution | `client.addNewWorkspaceItem(item)` in `index.client.tsx`; see [New workspace items](reference#new-workspace-items) |
|
||||
| New header contribution | `client.addHeaderButton({ id, workspaceId, button })` |
|
||||
| `plugin.addAttachmentSource(source)` in the old root entry | `client.addAttachmentSource(source)` in `index.client.tsx` |
|
||||
| New settings screen contribution | `client.addSettingsScreen(screen)` in `index.client.tsx`; see [settings screens](reference#settings-screens) |
|
||||
| `plugin.addTheme(theme)` in the old root entry | `client.addTheme(theme)` in `index.client.tsx` |
|
||||
| `plugin.addTimelineTransformer(transformer)` in the old root entry | `client.addTimelineTransformer(transformer)` in `index.client.tsx` |
|
||||
| `plugin.addTimelineRenderer(renderer)` in the old root entry | `client.addTimelineRenderer(renderer)` in `index.client.tsx` |
|
||||
| `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin/server"` in shared files | `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin"` |
|
||||
| `ZodOutput<typeof contract.input>` handler parameter types | `RpcInput<typeof contract>` from `@getpaseo/plugin`; `RpcOutput` for return types |
|
||||
|
||||
Register the screen that replaces an `addSidebarItem` under that item's `id` to keep saved links
|
||||
working. A `/plugin/<plugin>/sidebar/<id>` link, and the older `/plugin/<plugin>/<id>`, opens the
|
||||
|
||||
@@ -60,29 +60,30 @@ my-plugin/
|
||||
|
||||
把下表作为完整的注册检查清单。
|
||||
|
||||
| 旧的注册及位置 | 新的注册及位置 |
|
||||
| --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| 旧根入口中的 `plugin.handle(contract, handler)` | `index.server.ts` 中的 `server.handle(contract, handler)` |
|
||||
| 旧根入口中的 `plugin.addSurface(id, Component)` | `index.client.tsx` 中的 `client.addScreen({ id, title, Component })` |
|
||||
| 旧根入口中的 `plugin.addSidebarItem({ id, title, icon, surface })` | `index.client.tsx` 中的 `client.addSidebarHeaderItem({ id, title, Component })`;见[侧边栏项](reference#sidebar-items) |
|
||||
| 新的侧边栏底部贡献 | `index.client.tsx` 中的 `client.addSidebarFooterItem({ id, title, Component })` |
|
||||
| 新的文件面板贡献 | `index.client.tsx` 中的 `client.addFilePanel(panel)` |
|
||||
| 旧根入口中的 `plugin.addWorkspacePanel(panel)` | `index.client.tsx` 中的 `client.addWorkspacePanel(panel)` |
|
||||
| 带有 `context: "agent"` 的 Agent 面板 | `index.client.tsx` 中的 `client.addAgentPanel(panel)`;省略 `context` |
|
||||
| 旧根入口中的 `plugin.addCommandCenterItem(item)` | `index.client.tsx` 中的 `client.addCommandCenterItem(item)` |
|
||||
| 旧根入口中的 `plugin.addClientSlashCommand(command)` | `index.client.tsx` 中的 `client.addSlashCommand(command)` |
|
||||
| 旧根入口中的 `plugin.addClientSide(fn)` | 删除这层包装,把 `fn` 的函数体移入默认的客户端入口函数 |
|
||||
| 旧客户端回调中的 `client.addComposerPill(pill)` | `index.client.tsx` 或被导入的 `client/` 函数中的 `client.addComposerPill(pill)` |
|
||||
| 新的编辑器项贡献 | `index.client.tsx` 中的 `client.addComposerItem(item)`;见[编辑器项](reference#composer-items) |
|
||||
| 新的“新建工作区”项贡献 | `index.client.tsx` 中的 `client.addNewWorkspaceItem(item)`;见[新建工作区项](reference#new-workspace-items) |
|
||||
| 新的标题栏贡献 | `client.addHeaderButton({ id, workspaceId, button })` |
|
||||
| 旧根入口中的 `plugin.addAttachmentSource(source)` | `index.client.tsx` 中的 `client.addAttachmentSource(source)` |
|
||||
| 新的设置界面贡献 | `index.client.tsx` 中的 `client.addSettingsScreen(screen)`;见[设置界面](reference#settings-screens) |
|
||||
| 旧根入口中的 `plugin.addTheme(theme)` | `index.client.tsx` 中的 `client.addTheme(theme)` |
|
||||
| 旧根入口中的 `plugin.addTimelineTransformer(transformer)` | `index.client.tsx` 中的 `client.addTimelineTransformer(transformer)` |
|
||||
| 旧根入口中的 `plugin.addTimelineRenderer(renderer)` | `index.client.tsx` 中的 `client.addTimelineRenderer(renderer)` |
|
||||
| shared 文件中的 `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin/server"` | `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin"` |
|
||||
| 处理函数参数类型 `ZodOutput<typeof contract.input>` | 来自 `@getpaseo/plugin` 的 `RpcInput<typeof contract>`;返回类型使用 `RpcOutput` |
|
||||
| 旧的注册及位置 | 新的注册及位置 |
|
||||
| --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 旧根入口中的 `plugin.handle(contract, handler)` | `index.server.ts` 中的 `server.handle(contract, handler)` |
|
||||
| 旧根入口中的 `plugin.addSurface(id, Component)` | `index.client.tsx` 中的 `client.addScreen({ id, title, Component })` |
|
||||
| 旧根入口中的 `plugin.addSidebarItem({ id, title, icon, surface })` | `index.client.tsx` 中的 `client.addSidebarHeaderItem({ id, title, Component })`;见[侧边栏项](reference#sidebar-items) |
|
||||
| 新的侧边栏底部贡献 | `index.client.tsx` 中的 `client.addSidebarFooterItem({ id, title, Component })` |
|
||||
| 新的工作区行徽章贡献 | `index.client.tsx` 中的 `client.addWorkspaceRowBadgeItem({ id, title, icon, Component })`;见[工作区行徽章项](reference#workspace-row-badge-items) |
|
||||
| 新的文件面板贡献 | `index.client.tsx` 中的 `client.addFilePanel(panel)` |
|
||||
| 旧根入口中的 `plugin.addWorkspacePanel(panel)` | `index.client.tsx` 中的 `client.addWorkspacePanel(panel)` |
|
||||
| 带有 `context: "agent"` 的 Agent 面板 | `index.client.tsx` 中的 `client.addAgentPanel(panel)`;省略 `context` |
|
||||
| 旧根入口中的 `plugin.addCommandCenterItem(item)` | `index.client.tsx` 中的 `client.addCommandCenterItem(item)` |
|
||||
| 旧根入口中的 `plugin.addClientSlashCommand(command)` | `index.client.tsx` 中的 `client.addSlashCommand(command)` |
|
||||
| 旧根入口中的 `plugin.addClientSide(fn)` | 删除这层包装,把 `fn` 的函数体移入默认的客户端入口函数 |
|
||||
| 旧客户端回调中的 `client.addComposerPill(pill)` | `index.client.tsx` 或被导入的 `client/` 函数中的 `client.addComposerPill(pill)` |
|
||||
| 新的编辑器项贡献 | `index.client.tsx` 中的 `client.addComposerItem(item)`;见[编辑器项](reference#composer-items) |
|
||||
| 新的“新建工作区”项贡献 | `index.client.tsx` 中的 `client.addNewWorkspaceItem(item)`;见[新建工作区项](reference#new-workspace-items) |
|
||||
| 新的标题栏贡献 | `client.addHeaderButton({ id, workspaceId, button })` |
|
||||
| 旧根入口中的 `plugin.addAttachmentSource(source)` | `index.client.tsx` 中的 `client.addAttachmentSource(source)` |
|
||||
| 新的设置界面贡献 | `index.client.tsx` 中的 `client.addSettingsScreen(screen)`;见[设置界面](reference#settings-screens) |
|
||||
| 旧根入口中的 `plugin.addTheme(theme)` | `index.client.tsx` 中的 `client.addTheme(theme)` |
|
||||
| 旧根入口中的 `plugin.addTimelineTransformer(transformer)` | `index.client.tsx` 中的 `client.addTimelineTransformer(transformer)` |
|
||||
| 旧根入口中的 `plugin.addTimelineRenderer(renderer)` | `index.client.tsx` 中的 `client.addTimelineRenderer(renderer)` |
|
||||
| shared 文件中的 `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin/server"` | `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin"` |
|
||||
| 处理函数参数类型 `ZodOutput<typeof contract.input>` | 来自 `@getpaseo/plugin` 的 `RpcInput<typeof contract>`;返回类型使用 `RpcOutput` |
|
||||
|
||||
用原 `addSidebarItem` 项的 `id` 注册替代它的界面,这样已保存的链接仍然有效。`/plugin/<plugin>/sidebar/<id>` 链接以及更早的 `/plugin/<plugin>/<id>` 链接,会打开具有该 `id` 的 `addSidebarItem`,否则打开具有该 `id` 的界面。
|
||||
|
||||
|
||||
@@ -854,6 +854,60 @@ function hideAlerts() {
|
||||
}
|
||||
```
|
||||
|
||||
### Workspace row badge items
|
||||
|
||||
`addWorkspaceRowBadgeItem({ id, title, icon, Component })` adds an item to the line under each workspace
|
||||
title in the sidebar, after the row's own items (host, pull request, checks, services, labels).
|
||||
`title` and `icon` label the item's toggle in the sidebar's **Show** menu, in a separate plugin group
|
||||
below a separator. Items start visible.
|
||||
|
||||
`Component` renders once per workspace row on the plugin's host and receives
|
||||
`PluginWorkspaceRowBadgeItemProps`: `theme`, `host`, `layout`, and `workspaceId`. Render
|
||||
`WorkspaceRowBadge` from `@getpaseo/plugin/client/ui`, or `null` when there is nothing to say about
|
||||
the workspace:
|
||||
|
||||
| Prop | Meaning |
|
||||
| -------------------- | ------------------------------------------------------------------------------ |
|
||||
| `icon` | Optional. Any [icon](#icons) form, drawn at the line's glyph size. |
|
||||
| `label` | Optional. A short word. A badge with neither `icon` nor `label` draws nothing. |
|
||||
| `tone` | `"muted"` (default), `"success"`, `"warning"`, `"danger"`, or `"info"`. |
|
||||
| `accessibilityLabel` | Defaults to `label`, then the item's `title`. |
|
||||
|
||||
The line adds no separator around your badges, and returning `null` leaves no gap. Render as many
|
||||
badges as you like. Every row
|
||||
renders the component, so share one query across rows instead of fetching per workspace: the
|
||||
plugin's React Query client is shared by all of its components on a host.
|
||||
|
||||
```tsx
|
||||
import type { PluginWorkspaceRowBadgeItemProps } from "@getpaseo/plugin/client";
|
||||
import { useRpc } from "@getpaseo/plugin/client";
|
||||
import { WorkspaceRowBadge } from "@getpaseo/plugin/client/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
function ReviewState({ workspaceId }: PluginWorkspaceRowBadgeItemProps) {
|
||||
const list = useRpc(reviewStates);
|
||||
const { data } = useQuery({
|
||||
queryKey: ["review-states"],
|
||||
queryFn: () => list({}),
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
const state = data?.[workspaceId];
|
||||
if (!state) return null;
|
||||
return <WorkspaceRowBadge icon="Eye" label={state.label} tone="warning" />;
|
||||
}
|
||||
|
||||
client.addWorkspaceRowBadgeItem({
|
||||
id: "review",
|
||||
title: "Review",
|
||||
icon: "Eye",
|
||||
Component: ReviewState,
|
||||
});
|
||||
```
|
||||
|
||||
Apps older than this API have no `addWorkspaceRowBadgeItem`; check for it before calling.
|
||||
|
||||
See the [workspace row badges example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/workspace-row-badges).
|
||||
|
||||
## Host UI
|
||||
|
||||
Import Paseo-owned UI from `@getpaseo/plugin/client/ui` in client code. This example
|
||||
|
||||
@@ -705,6 +705,51 @@ function hideAlerts() {
|
||||
}
|
||||
```
|
||||
|
||||
### 工作区行徽章项 {#workspace-row-badge-items}
|
||||
|
||||
`addWorkspaceRowBadgeItem({ id, title, icon, Component })` 在侧边栏每个工作区标题下方的那一行中添加一项,位于该行自带的各项(主机、拉取请求、检查、服务、标签)之后。`title` 和 `icon` 用于标注该项在侧边栏 **Show** 菜单中的开关,插件开关位于分隔线下方的独立分组中。各项默认可见。
|
||||
|
||||
`Component` 为插件所在主机上的每个工作区行渲染一次,并接收 `PluginWorkspaceRowBadgeItemProps`:`theme`、`host`、`layout` 和 `workspaceId`。渲染 `@getpaseo/plugin/client/ui` 中的 `WorkspaceRowBadge`;工作区没有需要说明的内容时返回 `null`:
|
||||
|
||||
| 属性 | 含义 |
|
||||
| -------------------- | --------------------------------------------------------------------- |
|
||||
| `icon` | 可选。任意[图标](#icons)形式,以该行的图标尺寸绘制。 |
|
||||
| `label` | 可选。一个简短的词。既没有 `icon` 也没有 `label` 的徽章不绘制。 |
|
||||
| `tone` | `"muted"`(默认)、`"success"`、`"warning"`、`"danger"` 或 `"info"`。 |
|
||||
| `accessibilityLabel` | 默认为 `label`,其次是该项的 `title`。 |
|
||||
|
||||
该行不会在你的徽章周围添加分隔符,返回 `null` 不会留下空隙。可以渲染任意数量的徽章。每一行都会渲染该组件,所以请在各行之间共享一次查询,而不是为每个工作区单独获取:插件的 React Query 客户端由它在某台主机上的所有组件共享。
|
||||
|
||||
```tsx
|
||||
import type { PluginWorkspaceRowBadgeItemProps } from "@getpaseo/plugin/client";
|
||||
import { useRpc } from "@getpaseo/plugin/client";
|
||||
import { WorkspaceRowBadge } from "@getpaseo/plugin/client/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
function ReviewState({ workspaceId }: PluginWorkspaceRowBadgeItemProps) {
|
||||
const list = useRpc(reviewStates);
|
||||
const { data } = useQuery({
|
||||
queryKey: ["review-states"],
|
||||
queryFn: () => list({}),
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
const state = data?.[workspaceId];
|
||||
if (!state) return null;
|
||||
return <WorkspaceRowBadge icon="Eye" label={state.label} tone="warning" />;
|
||||
}
|
||||
|
||||
client.addWorkspaceRowBadgeItem({
|
||||
id: "review",
|
||||
title: "Review",
|
||||
icon: "Eye",
|
||||
Component: ReviewState,
|
||||
});
|
||||
```
|
||||
|
||||
早于此 API 的应用没有 `addWorkspaceRowBadgeItem`;调用前请先检查它是否存在。
|
||||
|
||||
参见[工作区行徽章示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/workspace-row-badges)。
|
||||
|
||||
## 宿主 UI {#host-ui}
|
||||
|
||||
在客户端代码中从 `@getpaseo/plugin/client/ui` 导入由 Paseo 提供的 UI。下面的示例会打开一个受控的弹窗、渲染一个宿主图标,并用 toast 确认操作:
|
||||
|
||||
Reference in New Issue
Block a user