Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 5bc78b951b fix(diff): align untracked patches and comparison modes
Read untracked bodies with --no-textconv so they follow the same patch
rules as tracked bodies. Include the comparison-side mode in worktree
identity so a base file type change cannot keep stale hunks. Use object
parameters for the identity inputs.
2026-11-13 11:12:24 +01:00
Mohamed Boudra e2f31e5db2 fix(diff): identify worktree files by content
Stat-only identity changes re-sent unchanged diff bodies after a touch or
git add. Worktree tracked and untracked files now share one content identity
built from the comparison-side blob, working-tree content, and Git
interpretation. Stat metadata no longer forces body delivery, while real
content changes still change the identity.

Closes #9563
2026-11-13 11:00:23 +01:00
41 changed files with 396 additions and 884 deletions
-15
View File
@@ -624,21 +624,6 @@ 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
@@ -1,148 +0,0 @@
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,8 +58,6 @@ 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 });
@@ -67,7 +65,6 @@ 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;
@@ -610,9 +607,9 @@ function OptionList<Value extends string>({
}
/**
* 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.
* 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.
*
* 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
@@ -635,12 +632,6 @@ 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
@@ -658,37 +649,6 @@ 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,12 +10,6 @@ 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";
@@ -33,10 +27,6 @@ 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;
@@ -85,30 +75,9 @@ 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) => {
@@ -154,9 +123,6 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
setTitleSource,
rowItems: sidebarRowItems,
toggleRowItem,
pluginRowBadgeItems,
isPluginRowBadgeItemVisible,
togglePluginRowBadgeItem,
checksDisplay: sidebarChecksDisplay,
setChecksDisplay,
trailing: sidebarWorkspaceTrailing,
@@ -182,9 +148,6 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
setTitleSource,
sidebarRowItems,
toggleRowItem,
pluginRowBadgeItems,
isPluginRowBadgeItemVisible,
togglePluginRowBadgeItem,
sidebarChecksDisplay,
setChecksDisplay,
sidebarWorkspaceTrailing,
@@ -186,7 +186,6 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
prHint={workspace.prHint}
serviceSummary={serviceSummary}
labels={labels}
workspace={workspace}
/>
</View>
</View>
@@ -16,10 +16,6 @@ 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";
@@ -63,9 +59,6 @@ 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,
@@ -74,7 +67,6 @@ export function WorkspaceMetaRow({
prHint,
serviceSummary,
labels = EMPTY_LABELS,
workspace = null,
}: {
currentBranch: string | null;
projectName: string | null;
@@ -82,8 +74,6 @@ 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({
@@ -96,9 +86,8 @@ export function WorkspaceMetaRow({
visible: rowItems,
checksDisplay,
});
const pluginItems = usePluginWorkspaceRowBadgeItems(workspace?.serverId ?? null);
if (items.length === 0 && (!workspace || pluginItems.length === 0)) return null;
if (items.length === 0) return null;
return (
<View style={styles.row}>
@@ -108,9 +97,6 @@ export function WorkspaceMetaRow({
<MetaItemNode item={item} hostBadge={hostBadge} leading={index === 0} />
</Fragment>
))}
{workspace ? (
<PluginWorkspaceRowBadgeItems targets={pluginItems} workspaceId={workspace.workspaceId} />
) : null}
</View>
);
}
@@ -96,8 +96,6 @@ 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. */
@@ -158,7 +156,6 @@ 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,
@@ -256,7 +253,6 @@ 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,7 +26,6 @@ function installation(): InstalledPlugin {
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -105,7 +105,6 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -37,7 +37,6 @@ function installed(): InstalledPlugin {
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
workspaceRowBadgeItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
@@ -47,7 +47,6 @@ function plugin(): InstalledPlugin {
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
workspaceRowBadgeItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
-41
View File
@@ -101,7 +101,6 @@ 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() {} }),
@@ -133,7 +132,6 @@ describe("evaluatePluginClientBundle", () => {
plugin.settingsScreens,
plugin.sidebarItems.header,
plugin.sidebarItems.footer,
plugin.workspaceRowBadgeItems,
plugin.workspacePanels,
plugin.commandCenterItems,
plugin.clientSlashCommands,
@@ -154,7 +152,6 @@ describe("evaluatePluginClientBundle", () => {
plugin.settingsScreens,
plugin.sidebarItems.header,
plugin.sidebarItems.footer,
plugin.workspaceRowBadgeItems,
plugin.workspacePanels,
plugin.commandCenterItems,
plugin.clientSlashCommands,
@@ -285,44 +282,6 @@ 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",
-21
View File
@@ -37,7 +37,6 @@ import type {
PluginScreenContribution,
PluginSidebarItemContribution,
PluginWorkspacePanelContribution,
PluginWorkspaceRowBadgeItemContribution,
} from "./types";
import type { ComponentType } from "react";
import { parsePluginIcon, parseSerializablePluginIcon } from "./icons";
@@ -134,7 +133,6 @@ export function runPluginClientBundle(
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -153,7 +151,6 @@ 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>();
@@ -326,23 +323,6 @@ 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>) {
@@ -654,7 +634,6 @@ 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,7 +46,6 @@ function installation(invoked: string[]): InstalledPlugin {
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
+1 -1
View File
@@ -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) });
export function renderNothing() {
function renderNothing() {
return null;
}
@@ -11,7 +11,6 @@ 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";
-1
View File
@@ -125,7 +125,6 @@ export class PluginRegistry {
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -37,7 +37,6 @@ function installed(
footer: [{ id: "status", title: "Status", Component: () => null }],
},
legacySidebarItems,
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -51,7 +51,6 @@ function installation(
surfaces: surfaces.map((id) => ({ id, title: id, Component: () => null })),
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
workspaceRowBadgeItems: [],
legacySidebarItems: sidebarItems.map((item) => ({
...item,
title: item.id,
-1
View File
@@ -64,7 +64,6 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -27,7 +27,6 @@ function plugin(input: {
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -34,7 +34,6 @@ vi.mock("../client-runtime", () => ({
}));
vi.mock("../icons", () => ({
Icon: () => null,
PluginIconView: () => null,
pluginIconComponent: () => () => null,
}));
-5
View File
@@ -17,7 +17,6 @@ import type {
PluginSidebarContribution as SDKPluginSidebarContribution,
PluginSidebarViewContribution,
PluginSidebarItemContribution as SDKPluginSidebarItemContribution,
PluginWorkspaceRowBadgeItemContribution as SDKPluginWorkspaceRowBadgeItemContribution,
PluginScreenContribution as SDKPluginScreenContribution,
PluginSettingsScreenContribution as SDKPluginSettingsScreenContribution,
PluginTimelineRendererContribution,
@@ -34,8 +33,6 @@ 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>;
@@ -59,7 +56,6 @@ 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[];
@@ -99,7 +95,6 @@ export type {
PluginComposerPillContribution,
PluginSidebarContribution,
PluginSidebarItemContribution,
PluginWorkspaceRowBadgeItemContribution,
PluginScreenContribution,
PluginSettingsScreenContribution,
PluginThemeContribution,
@@ -24,7 +24,6 @@ function installed(): InstalledPlugin {
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -1,96 +0,0 @@
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);
@@ -1,93 +0,0 @@
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") },
}));
@@ -1,44 +0,0 @@
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);
});
});
@@ -1,45 +0,0 @@
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()];
}
-19
View File
@@ -121,22 +121,6 @@ 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.
@@ -230,9 +214,6 @@ 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>,
-2
View File
@@ -18,8 +18,6 @@ export type {
PluginSidebarViewProps,
PluginSidebarViewContribution,
PluginSidebarItemContribution,
PluginWorkspaceRowBadgeItemProps,
PluginWorkspaceRowBadgeItemContribution,
PluginIcon,
PluginPanelLocation,
PluginOpenPanelOptions,
-13
View File
@@ -173,19 +173,6 @@ 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;
+113 -34
View File
@@ -12,7 +12,6 @@ import {
type CheckoutDiffCompare,
} from "../../utils/checkout-git.js";
import { lstat, readFile, readlink } from "node:fs/promises";
import type { BigIntStats } from "node:fs";
import { resolve } from "node:path";
import type { DiffEndpoint, DiffSubscribeRequest } from "@getpaseo/protocol/messages";
import type { DiffEntry, DiffBody } from "@getpaseo/protocol/binary-frames/index";
@@ -27,6 +26,16 @@ export interface ListedFile {
identity: string;
source: { kind: "tracked" } | { kind: "untracked"; size: number };
}
interface WorktreeInput {
/** Comparison-side mode, or null when the file is added/untracked on this side. */
oldMode: string | null;
/** Comparison-side blob, or null when the file is added/untracked on this side. */
oldBlob: string | null;
/** A regular file whose bytes readGitInputs blob-hashes. */
hashed: boolean;
/** Content Git cannot read as a regular file, such as a symlink target or submodule state. */
literal: string | null;
}
interface ResolvedComparison {
baseSha: string;
headSha: string;
@@ -195,20 +204,47 @@ async function readComparisonList(
...(privateIndex ? { GIT_INDEX_FILE: privateIndex.indexFile } : {}),
},
};
const { files, worktreeFiles, interpretationPaths } = await readTrackedFiles(
compare,
args,
options,
);
if (compare.head.kind === "worktree") files.push(...(await listUntrackedFiles(options)));
const tracked = await readTrackedFiles(compare, args, options);
const files = tracked.files;
const worktreeInputs = tracked.worktreeInputs;
if (compare.head.kind === "worktree") {
const untracked = await listUntrackedFiles(options);
files.push(...untracked.files);
for (const [path, input] of untracked.worktreeInputs) worktreeInputs.set(path, input);
}
files.sort((a, b) => a.entry.path.localeCompare(b.entry.path));
for (const file of files) interpretationPaths.add(file.entry.path);
const gitInputs = await readGitInputs([...interpretationPaths].sort(), worktreeFiles, options);
for (const file of files)
for (const file of files) tracked.interpretationPaths.add(file.entry.path);
const hashedPaths = new Set(
[...worktreeInputs].filter(([, input]) => input.hashed).map(([path]) => path),
);
const literalContent = new Map(
[...worktreeInputs].flatMap(([path, input]) =>
input.literal === null ? [] : [[path, input.literal] as const],
),
);
const gitInputs = await readGitInputs({
paths: [...tracked.interpretationPaths].sort(),
hashedPaths,
options,
literalContent,
});
for (const file of files) {
if (compare.head.kind === "worktree") {
const input = worktreeInputs.get(file.entry.path);
if (!input) throw new Error(`Missing worktree identity for ${file.entry.path}`);
file.identity = JSON.stringify([
input.oldMode,
input.oldBlob,
gitInputs.identities.get(file.entry.path) ?? null,
gitInputs.identities.get(file.entry.oldPath ?? file.entry.path) ?? null,
]);
continue;
}
file.identity += JSON.stringify([
gitInputs.identities.get(file.entry.path),
gitInputs.identities.get(file.entry.oldPath ?? file.entry.path),
]);
}
const metrics = { highlightCalls: 0 };
const patches = new Patches(args, options, files);
const sources = new HighlightSources({
@@ -237,7 +273,15 @@ async function readComparisonList(
const result =
file.source.kind === "untracked"
? await runGitCommand(
["diff", "--no-index", ...GIT_PATCH_FORMAT_ARGS, "--", "/dev/null", file.entry.path],
[
"diff",
"--no-index",
"--no-textconv",
...GIT_PATCH_FORMAT_ARGS,
"--",
"/dev/null",
file.entry.path,
],
{
...options,
acceptExitCodes: [0, 1],
@@ -293,7 +337,11 @@ async function readTrackedFiles(
compare: Compare,
args: string[],
options: GitCommandOptions,
): Promise<{ files: ListedFile[]; worktreeFiles: Set<string>; interpretationPaths: Set<string> }> {
): Promise<{
files: ListedFile[];
worktreeInputs: Map<string, WorktreeInput>;
interpretationPaths: Set<string>;
}> {
const [raw, stat] = await Promise.all([
runGitCommand([...args.filter((arg) => arg !== "-w"), "--raw", "--no-abbrev", "-z"], options),
runGitCommand([...args, "--numstat", "-z"], options),
@@ -303,7 +351,7 @@ async function readTrackedFiles(
const stats = parseStats(stat.stdout);
const fields = raw.stdout.split("\0");
const files: ListedFile[] = [];
const worktreeFiles = new Set<string>();
const worktreeInputs = new Map<string, WorktreeInput>();
const interpretationPaths = new Set<string>();
for (let index = 0; index < fields.length - 1; index++) {
const header = fields[index].split(" ");
@@ -325,10 +373,25 @@ async function readTrackedFiles(
else if (code === "D") change = "deleted";
else if (oldPath !== null) change = "renamed";
let identity = header.join(" ");
if (compare.head.kind === "worktree" && code !== "D") {
const worktree = await readWorktreeIdentity(path, header, options);
identity += `:${worktree.identity}`;
if (worktree.regular && !counts.binary) worktreeFiles.add(path);
if (compare.head.kind === "worktree") {
const input: WorktreeInput = {
oldMode: code === "A" ? null : header[0],
oldBlob: code === "A" ? null : absentBlob(header[2]),
hashed: false,
literal: null,
};
if (code !== "D") {
const worktree = await readWorktreeContent({
path,
header,
text: !counts.binary,
options,
});
input.hashed = worktree.hashed;
input.literal = worktree.literal;
}
worktreeInputs.set(path, input);
identity = "";
}
files.push({
@@ -345,24 +408,32 @@ async function readTrackedFiles(
source: { kind: "tracked" },
});
}
return { files, worktreeFiles, interpretationPaths };
return { files, worktreeInputs, interpretationPaths };
}
async function readWorktreeIdentity(
path: string,
header: string[],
options: GitCommandOptions,
): Promise<{ identity: string; regular: boolean }> {
const value = await lstat(resolve(options.cwd, path), { bigint: true });
let identity = fileStatIdentity(value);
async function readWorktreeContent({
path,
header,
text,
options,
}: {
path: string;
header: string[];
text: boolean;
options: GitCommandOptions;
}): Promise<{ hashed: boolean; literal: string | null }> {
const absolutePath = resolve(options.cwd, path);
const value = await lstat(absolutePath, { bigint: true });
if (value.isSymbolicLink())
return { hashed: false, literal: `symlink:${await readlink(absolutePath)}` };
if (value.isDirectory() && (header[0] === ":160000" || header[1] === "160000")) {
const submodule = await runGitCommand(
["status", "--porcelain=v2", "--branch", "--untracked-files=normal"],
{ cwd: resolve(options.cwd, path), envOverlay: { GIT_OPTIONAL_LOCKS: "0" } },
{ cwd: absolutePath, envOverlay: { GIT_OPTIONAL_LOCKS: "0" } },
);
identity += `:${submodule.stdout}`;
return { hashed: false, literal: `submodule:${submodule.stdout}` };
}
return { identity, regular: value.isFile() };
return { hashed: value.isFile() && text, literal: null };
}
function diffArguments(compare: Compare, baseSha: string, headSha: string): string[] {
@@ -385,9 +456,12 @@ async function readUntrackedText(
return readFile(path);
}
async function listUntrackedFiles(options: GitCommandOptions): Promise<ListedFile[]> {
async function listUntrackedFiles(
options: GitCommandOptions,
): Promise<{ files: ListedFile[]; worktreeInputs: Map<string, WorktreeInput> }> {
const cwd = options.cwd;
const files: ListedFile[] = [];
const worktreeInputs = new Map<string, WorktreeInput>();
const untracked = await runGitCommand(
["ls-files", "--others", "--exclude-standard", "-z"],
options,
@@ -408,6 +482,12 @@ async function listUntrackedFiles(options: GitCommandOptions): Promise<ListedFil
binary || content.length === 0
? 0
: content.toString("utf8").split("\n").length - Number(content.at(-1) === 10);
worktreeInputs.set(path, {
oldMode: null,
oldBlob: null,
hashed: !symlink && !binary && !omitted,
literal: symlink ? `symlink:${content.toString("utf8")}` : null,
});
files.push({
entry: {
path,
@@ -418,14 +498,13 @@ async function listUntrackedFiles(options: GitCommandOptions): Promise<ListedFil
deletions: 0,
body: !binary,
},
identity: fileStatIdentity(value),
identity: "",
source: { kind: "untracked", size: Number(value.size) },
});
}
return files;
return { files, worktreeInputs };
}
function fileStatIdentity(value: BigIntStats): string {
// Timestamp-preserving copies still change file status or replace the inode.
return `${value.dev}:${value.ino}:${value.size}:${value.mtimeNs}:${value.ctimeNs}`;
function absentBlob(value: string): string | null {
return /^0+$/.test(value) ? null : value;
}
+15 -7
View File
@@ -9,12 +9,20 @@ interface Interpretation {
// Reuse depends on Git's interpretation, not just filesystem metadata. Ask Git
// for effective attributes/config and filtered blob identities without writing objects.
export async function readGitInputs(
paths: string[],
worktreeFiles: Set<string>,
options: GitCommandOptions,
) {
const regular = paths.filter((path) => worktreeFiles.has(path));
interface ReadGitInputsOptions {
paths: string[];
hashedPaths: ReadonlySet<string>;
options: GitCommandOptions;
literalContent?: ReadonlyMap<string, string>;
}
export async function readGitInputs({
paths,
hashedPaths,
options,
literalContent = new Map(),
}: ReadGitInputsOptions) {
const regular = paths.filter((path) => hashedPaths.has(path));
const [interpretation, blobs] = await Promise.all([
readInterpretation(paths, options),
regular.length
@@ -34,7 +42,7 @@ export async function readGitInputs(
fingerprint([
interpretation.config,
interpretation.attributes.get(path) ?? [],
blobByPath.get(path) ?? null,
blobByPath.get(path) ?? literalContent.get(path) ?? null,
]),
]),
);
@@ -530,6 +530,222 @@ test("submodule commits invalidate cached worktree gitlink bodies", async () =>
}
});
test("stat-only changes reuse cached bodies and send no body frames", async () => {
const { utimes } = await import("node:fs/promises");
const cwd = await mkdtemp(join(tmpdir(), "diff-stat-only-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const streams = new DiffSubscriptions({
async requestWorkingTreeWatch(root) {
return { repoRoot: root, unsubscribe: async () => {} };
},
});
const frames: DiffFrame[] = [];
const path = join(cwd, "file.txt");
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await writeFile(join(cwd, "README"), "base\n");
await writeFile(path, "base\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await writeFile(path, "changed\n");
const subscription = await streams.subscribe(
{ cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } },
async (frame) => {
frames.push(frame);
},
);
await subscription.start();
expect(
frames.flatMap((frame) => (frame.opcode === DiffOpcode.Bodies ? frame.files : [])),
).toContainEqual(expect.objectContaining({ path: "file.txt" }));
frames.length = 0;
for (let index = 0; index < 3; index++) {
const ended = frames.filter((frame) => frame.opcode === DiffOpcode.End).length;
const timestamp = new Date(Date.now() + 1000 * (index + 1));
await utimes(path, timestamp, timestamp);
await streams.invalidateForCwd(cwd);
await expect
.poll(() => frames.filter((frame) => frame.opcode === DiffOpcode.End).length, {
timeout: 5000,
})
.toBeGreaterThanOrEqual(ended + 1);
await new Promise((resolve) => setTimeout(resolve, 300));
}
expect(frames.filter((frame) => frame.opcode === DiffOpcode.Bodies)).toHaveLength(0);
const beginnings = frames.filter((frame) => frame.opcode === DiffOpcode.Begin);
expect(beginnings.length).toBeGreaterThanOrEqual(3);
for (const begin of beginnings) {
expect(begin.entries.find((entry) => entry.path === "file.txt")?.body).toBe(false);
}
await subscription.release();
} finally {
await streams.close();
await rm(cwd, { recursive: true, force: true });
}
});
test("staging unchanged worktree content reuses its cached body", async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-staging-only-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const streams = new DiffSubscriptions({
async requestWorkingTreeWatch(root) {
return { repoRoot: root, unsubscribe: async () => {} };
},
});
const frames: DiffFrame[] = [];
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await writeFile(join(cwd, "README"), "base\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await writeFile(join(cwd, "staged.ts"), "export const staged = true;\n");
const subscription = await streams.subscribe(
{ cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } },
async (frame) => {
frames.push(frame);
},
);
await subscription.start();
expect(
frames.flatMap((frame) => (frame.opcode === DiffOpcode.Bodies ? frame.files : [])),
).toContainEqual(expect.objectContaining({ path: "staged.ts" }));
frames.length = 0;
git("add", "-A");
await streams.invalidateForCwd(cwd);
await expect
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 5000 })
.toBe(true);
expect(frames.filter((frame) => frame.opcode === DiffOpcode.Bodies)).toHaveLength(0);
expect(frames.find((frame) => frame.opcode === DiffOpcode.Begin)).toMatchObject({
entries: [{ path: "staged.ts", change: "added", body: false }],
});
await subscription.release();
} finally {
await streams.close();
await rm(cwd, { recursive: true, force: true });
}
});
test.skipIf(process.platform === "win32")(
"untracked bodies use the same patch conversion as tracked files",
async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-textconv-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const streams = new DiffSubscriptions({
async requestWorkingTreeWatch(root) {
return { repoRoot: root, unsubscribe: async () => {} };
},
});
const frames: DiffFrame[] = [];
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await writeFile(join(cwd, ".gitattributes"), "*.dat diff=upper\n");
git("config", "diff.upper.textconv", "sed s/hello/HELLO/");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await writeFile(join(cwd, "x.dat"), "hello\n");
const subscription = await streams.subscribe(
{ cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } },
async (frame) => {
frames.push(frame);
},
);
await subscription.start();
const initial = frames
.flatMap((frame) => (frame.opcode === DiffOpcode.Bodies ? frame.files : []))
.find((file) => file.path === "x.dat");
expect(initial?.hunks.flatMap((hunk) => hunk.lines)).toContainEqual({
type: "add",
content: "hello",
});
frames.length = 0;
git("add", "x.dat");
await streams.invalidateForCwd(cwd);
await expect
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 5000 })
.toBe(true);
expect(frames.filter((frame) => frame.opcode === DiffOpcode.Bodies)).toHaveLength(0);
await subscription.release();
} finally {
await streams.close();
await rm(cwd, { recursive: true, force: true });
}
},
);
test.skipIf(process.platform === "win32")(
"comparison-side file type changes refresh cached bodies",
async () => {
const { symlink } = await import("node:fs/promises");
const cwd = await mkdtemp(join(tmpdir(), "diff-base-mode-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const streams = new DiffSubscriptions({
async requestWorkingTreeWatch(root) {
return { repoRoot: root, unsubscribe: async () => {} };
},
});
const frames: DiffFrame[] = [];
const path = join(cwd, "x");
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await writeFile(path, "target");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "regular");
git("branch", "compare");
await rm(path);
await symlink("target", path);
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "symlink");
const moved = git("rev-parse", "HEAD");
await rm(path);
await writeFile(path, "target\n");
const compare = {
cwd,
base: { kind: "rev" as const, rev: "refs/heads/compare" },
head: { kind: "worktree" as const },
};
const subscription = await streams.subscribe(compare, async (frame) => {
frames.push(frame);
});
await subscription.start();
expect(
frames.flatMap((frame) => (frame.opcode === DiffOpcode.Bodies ? frame.files : [])),
).toContainEqual(expect.objectContaining({ path: "x" }));
frames.length = 0;
git("update-ref", "refs/heads/compare", moved);
await streams.invalidateForCwd(cwd);
await expect
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 5000 })
.toBe(true);
const bodies = frames.flatMap((frame) =>
frame.opcode === DiffOpcode.Bodies ? frame.files : [],
);
expect(bodies).toContainEqual(expect.objectContaining({ path: "x" }));
const { listComparison } = await import("./git.js");
const fresh = await listComparison(compare);
try {
const record = await fresh.read(fresh.files[0]);
if (record.kind !== "ready") throw new Error("Expected readable type change");
expect(bodies).toContainEqual(record.body);
} finally {
await fresh.close();
}
await subscription.release();
} finally {
await streams.close();
await rm(cwd, { recursive: true, force: true });
}
},
);
test.each([true, false])(
"tracked=%s timestamp-preserving replacements refresh cached bodies",
async (tracked) => {
@@ -1,9 +0,0 @@
# 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).
@@ -1,29 +0,0 @@
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 () => {};
}
@@ -1 +0,0 @@
{ "id": "workspace-row-badges", "requirements": { "paseo": ">=0.16.0" } }
+24 -25
View File
@@ -67,31 +67,30 @@ 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 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 |
| 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 |
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
+23 -24
View File
@@ -60,30 +60,29 @@ 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.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` |
| 旧的注册及位置 | 新的注册及位置 |
| --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| 旧根入口中的 `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` |
用原 `addSidebarItem` 项的 `id` 注册替代它的界面,这样已保存的链接仍然有效。`/plugin/<plugin>/sidebar/<id>` 链接以及更早的 `/plugin/<plugin>/<id>` 链接,会打开具有该 `id` 的 `addSidebarItem`,否则打开具有该 `id` 的界面。
-54
View File
@@ -854,60 +854,6 @@ 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
-45
View File
@@ -705,51 +705,6 @@ 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 确认操作: