mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-15 21:04:54 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03b7bf9a92 | ||
|
|
a01098ed31 | ||
|
|
4421f0473a | ||
|
|
2a432b1e0a | ||
|
|
1c175b36b3 | ||
|
|
aa9984b899 | ||
|
|
c4ff5599c7 | ||
|
|
ef146bc62d | ||
|
|
2e4dab0556 | ||
|
|
aa988dd8a8 | ||
|
|
781cb8d5b0 | ||
|
|
116b0a9190 |
@@ -156,8 +156,10 @@ keeps following.
|
||||
|
||||
Following resumes only for a gesture that ends within 1 px of the end, either when the finger lifts
|
||||
or when momentum stops. A release past the end counts: iOS bounces back to the end, and lines that
|
||||
land during the bounce must keep scrolling in. A release anywhere short of the end stays detached
|
||||
with the scroll-to-bottom pill, so the rows the user let go of never move.
|
||||
land during the bounce must keep scrolling in. A release short of the end stays detached with the scroll-to-bottom pill unless you submitted
|
||||
another prompt during that gesture. That submit requests the end after the gesture finishes;
|
||||
a new drag after submitting takes ownership again. Rewind and tail-fetch completions do not
|
||||
override a scroll away because they can arrive after the action that requested them.
|
||||
|
||||
### Draw distance
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ Every block is separated by one gap, `density.blockGap`: 12 on compact layouts,
|
||||
|
||||
Workspace file links use the shared file-link resolver. The workspace supplies its client, root and open-file action once; chat can supply the agent's more specific context. URLs use the same link press path. Recognized inline-code paths stay tappable. The library callback carries the tapped occurrence’s syntax context. Preserve it when opening files: explicit Markdown links decode URL escapes, while recognized inline-code paths preserve literal percent characters and use suffix lookup. Identical URL strings in different occurrences must stay distinct. Explicit absolute workspace links receive a relative hover title; existing authored titles remain intact. Inside a workspace, forge references such as `#12` and `owner/repo#12` are recognized in prose (not code) and open the workspace forge; the reference pattern comes before path recognition so `group/project#12` is not read as a path. See [forge providers](forge-providers.md#gotchas).
|
||||
|
||||
Pass `external` for author-controlled Markdown from outside the host, such as a plugin README. It follows the registry's overview contract in `packages/protocol/src/plugin-overview.ts`, which the website also uses: only absolute https links and images work, and any other destination renders as its label or alt text. The parser still autolinks bare `http://`, `www.` and email text, so a link press checks the URL again and opens only https. None of the workspace recognition above applies, so `Node.js` is prose, not a file. The lines of a paragraph join, as they do on the README's forge.
|
||||
|
||||
Resolve workspace image paths over the existing session RPC, then give the library a cached native file URL or web blob URL. HTTP(S) images pass through unchanged. This preserves relay, SSH and local socket connections, which do not expose a daemon HTTP endpoint. Each mounted renderer owns its image acquisition identity, so a later reply reads a rewritten path again while the earlier mounted reply retains its snapshot. Image taps open the attachment lightbox. File previews resolve relative images against the Markdown file's directory. An image alone on its line becomes its own paragraph before the library sees it, so it renders as a block on every platform; the library only treats sole-child paragraph images as blocks, and with hard soft breaks every newline is a break node, which left own-line images inline on iOS and web. Images that share a line with text stay inline at line height and keep their aspect ratio. A linked image counts as an image, so review-bot badges such as `[](url) Confidence` stay inline on web; the library's web renderer only checked direct paragraph children, so it rendered them as full-width block images (local patch in `patches/react-native-enriched-markdown+*.patch`).
|
||||
|
||||
## Engine constraints
|
||||
|
||||
+4
-3
@@ -873,9 +873,10 @@ await paseo.agents.ref(agentId).timeline.append({
|
||||
The daemon stamps the runtime `pluginId`; plugin code never supplies it. Reusing the same `id`
|
||||
replaces the previous row from that plugin on live clients and fresh timeline fetches. Rows live in
|
||||
the daemon's in-memory timeline and survive scroll, refetch, and reconnect, but not a daemon
|
||||
restart. A row without an installed renderer shows an unavailable placeholder. Serialized `data`
|
||||
must be at most 64 KiB; the daemon rejects a larger append instead of storing a payload that cannot
|
||||
be rendered intact. The daemon advertises this RPC through
|
||||
restart. A row without an installed renderer shows an unavailable placeholder. `kind` must match
|
||||
`/^[a-z][a-z0-9-]*$/`, the rule `addTimelineRenderer` enforces; the daemon rejects any other kind.
|
||||
Serialized `data` must be at most 64 KiB; the daemon rejects a larger append instead of storing a
|
||||
payload that cannot be rendered intact. The daemon advertises this RPC through
|
||||
`server_info.features.pluginTimelineItems`.
|
||||
|
||||
## Contribute client slash commands
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import { openWorkspaceFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { expectWorkspaceAbsentFromSidebar } from "../support/helpers/sidebar";
|
||||
|
||||
async function isWorkspaceListed(seeded: SeededWorkspace): Promise<boolean> {
|
||||
const result = await seeded.client.fetchWorkspaces();
|
||||
return result.entries.some((entry) => entry.id === seeded.workspaceId);
|
||||
}
|
||||
|
||||
async function expectWorkspaceArchivedOnDaemon(seeded: SeededWorkspace): Promise<void> {
|
||||
await expect.poll(() => isWorkspaceListed(seeded), { timeout: 30_000 }).toBe(false);
|
||||
}
|
||||
|
||||
test.describe("Command center archive for a non-Git workspace", () => {
|
||||
test("archives a plain directory workspace from the command center", async ({ page }) => {
|
||||
const title = "Command Center Directory Archive";
|
||||
const seeded = await seedWorkspace({ repoPrefix: "cc-archive-dir-", title, git: false });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspaceFromCommandCenter(page, seeded, title);
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("archive");
|
||||
const archive = panel.getByRole("button", { name: /^Archive(?: workspace)?(?:\s|$)/ });
|
||||
await expect(archive).toBeVisible({ timeout: 30_000 });
|
||||
await archive.click();
|
||||
|
||||
await expectWorkspaceAbsentFromSidebar(page, seeded.workspaceId);
|
||||
expect(existsSync(seeded.repoPath)).toBe(true);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("archives a plain directory workspace from the command center with the sidebar closed", async ({
|
||||
page,
|
||||
}) => {
|
||||
const title = "Command Center Directory Archive Closed Sidebar";
|
||||
const seeded = await seedWorkspace({ repoPrefix: "cc-archive-dir-closed-", title, git: false });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspaceFromCommandCenter(page, seeded, title);
|
||||
|
||||
await page.getByTestId("menu-button").click();
|
||||
await expect(page.getByTestId("sidebar-search")).toBeHidden({ timeout: 10_000 });
|
||||
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+k`);
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
await panel.getByTestId("command-center-input").fill("archive");
|
||||
const archive = panel.getByRole("button", { name: /^Archive(?: workspace)?(?:\s|$)/ });
|
||||
await expect(archive).toBeVisible({ timeout: 30_000 });
|
||||
await archive.click();
|
||||
|
||||
await expectWorkspaceArchivedOnDaemon(seeded);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("archives a plain directory workspace from the command center with its project collapsed", async ({
|
||||
page,
|
||||
}) => {
|
||||
const title = "Command Center Directory Archive Collapsed Project";
|
||||
const seeded = await seedWorkspace({
|
||||
repoPrefix: "cc-archive-dir-collapsed-",
|
||||
title,
|
||||
git: false,
|
||||
});
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspaceFromCommandCenter(page, seeded, title);
|
||||
|
||||
const workspaceRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${getServerId()}:${seeded.workspaceId}`,
|
||||
);
|
||||
await expect(workspaceRow).toBeVisible({ timeout: 30_000 });
|
||||
await page
|
||||
.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(seeded.projectKey)}`)
|
||||
.first()
|
||||
.click();
|
||||
await expect(workspaceRow).toHaveCount(0, { timeout: 10_000 });
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("archive");
|
||||
const archive = panel.getByRole("button", { name: /^Archive(?: workspace)?(?:\s|$)/ });
|
||||
await expect(archive).toBeVisible({ timeout: 30_000 });
|
||||
await archive.click();
|
||||
|
||||
await expectWorkspaceArchivedOnDaemon(seeded);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -788,11 +788,20 @@ test.describe("Composer autocomplete", () => {
|
||||
await input.press("ArrowDown");
|
||||
await input.press("h");
|
||||
// On web the search reads the cursor after paint, so the input's value runs ahead of
|
||||
// the list. Wait on the list itself: /tdd is hidden only while the search is "h", and
|
||||
// back only once the search is "" again.
|
||||
await expect(popover.getByText("/tdd", { exact: true })).toHaveCount(0);
|
||||
// the list. Wait on the list itself. The list mounts only the rows near its scroll
|
||||
// position, so one missing row proves nothing: right after the arrows wrap to the top,
|
||||
// /tdd at the bottom is unmounted while the search is still "". With the search at "",
|
||||
// the list always mounts the bottom row (/tdd) or the chosen one (/exit); only the
|
||||
// search "h" leaves both out. Check them in one query so no frame falls between them.
|
||||
await expect(
|
||||
popover.filter({
|
||||
has: page.getByText("/help", { exact: true }),
|
||||
hasNot: page.getByText(/^\/(tdd|exit)$/),
|
||||
}),
|
||||
).toBeVisible();
|
||||
await input.press("Backspace");
|
||||
await expect(input).toHaveValue("/");
|
||||
// /tdd is back only once the search is "" again.
|
||||
await expect(popover.getByText("/tdd", { exact: true }).first()).toBeVisible();
|
||||
await input.press("Enter");
|
||||
|
||||
|
||||
@@ -20,6 +20,21 @@ function explorerSidebar(page: Parameters<typeof ensureExplorerSidebar>[0]) {
|
||||
return page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
|
||||
}
|
||||
|
||||
const LAYOUT_VIEWPORTS = {
|
||||
compact: { width: 390, height: 844 },
|
||||
wide: { width: 1280, height: 900 },
|
||||
};
|
||||
|
||||
// The previous layout stays on screen for a few renders after the resize. Wait for the shell to
|
||||
// switch so the next step reads the layout it resized to.
|
||||
async function resizeToLayout(
|
||||
page: Parameters<typeof ensureExplorerSidebar>[0],
|
||||
layout: keyof typeof LAYOUT_VIEWPORTS,
|
||||
) {
|
||||
await page.setViewportSize(LAYOUT_VIEWPORTS[layout]);
|
||||
await expect(page.getByTestId(`${layout}-shell`)).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectExplorerActiveTabForeground(
|
||||
page: Parameters<typeof ensureExplorerSidebar>[0],
|
||||
) {
|
||||
@@ -244,7 +259,7 @@ test("tablet prototype keeps Explorer tabs when resizing down to a phone", async
|
||||
await page.screenshot({ path: testInfo.outputPath("tablet-951.png") });
|
||||
await openExplorerAgentDraft(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("tablet-agent-draft.png") });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await resizeToLayout(page, "compact");
|
||||
await revealPhoneExplorer(page);
|
||||
await expect(
|
||||
explorerSidebar(page).getByRole("button", { name: "New tab", exact: true }),
|
||||
@@ -289,10 +304,10 @@ test("retains the Explorer host through appearance changes and folding", async (
|
||||
.poll(() => explorer.evaluate((element) => getComputedStyle(element).backgroundColor))
|
||||
.not.toBe(previousColor);
|
||||
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await resizeToLayout(page, "compact");
|
||||
await ensureExplorerSidebar(page);
|
||||
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await resizeToLayout(page, "wide");
|
||||
await expect(explorer).toBeVisible();
|
||||
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
|
||||
} finally {
|
||||
@@ -327,7 +342,7 @@ test("reorders compact Explorer tabs with a pointer without changing selection",
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-compact-drag-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await resizeToLayout(page, "compact");
|
||||
await openFilesPanel(page);
|
||||
await reorderCompactExplorerTabs(page);
|
||||
await expect(
|
||||
@@ -394,9 +409,9 @@ test("updates populated code and diff appearance without losing the editor insta
|
||||
.getByTestId("file-source-editor")
|
||||
.elementHandle();
|
||||
await text.fill(`${source}\n// retained Explorer edit`);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await resizeToLayout(page, "compact");
|
||||
await ensureExplorerSidebar(page);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await resizeToLayout(page, "wide");
|
||||
expect(await explorerEditor!.evaluate((node) => node.isConnected)).toBe(true);
|
||||
await expect(text).toContainText("retained Explorer edit");
|
||||
} finally {
|
||||
@@ -461,9 +476,9 @@ test("retains populated Explorer chat and terminal attachments through appearanc
|
||||
const terminalNode = await terminal.elementHandle();
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
await ensureExplorerSidebar(page);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await resizeToLayout(page, "compact");
|
||||
await ensureExplorerSidebar(page);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await resizeToLayout(page, "wide");
|
||||
await cycleWorkspaceTheme(page);
|
||||
expect(await terminalNode!.evaluate((node) => node.isConnected)).toBe(true);
|
||||
expect(attaches).toHaveLength(1);
|
||||
@@ -483,7 +498,7 @@ test("compact file sheets cover Explorer and return to its retained file list",
|
||||
try {
|
||||
await writeFile(`${workspace.repoPath}/sheet-marker.txt`, "Explorer sheet marker\n");
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await resizeToLayout(page, "compact");
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
await openFilesPanel(page);
|
||||
const host = await explorer.elementHandle();
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
const HEADER_ICON_BUTTONS = ["workspace-header-menu-trigger", "workspace-explorer-toggle"];
|
||||
|
||||
function visible(page: Page, testId: string): Locator {
|
||||
return page.getByTestId(testId).filter({ visible: true }).first();
|
||||
}
|
||||
|
||||
function matchesFocusVisible(target: Locator): Promise<boolean> {
|
||||
return target.evaluate((el) => el.matches(":focus-visible"));
|
||||
}
|
||||
|
||||
async function expectKeyboardFocusRing(page: Page, testId: string): Promise<void> {
|
||||
const button = visible(page, testId);
|
||||
// Chromium matches :focus-visible on a programmatic focus that follows keyboard input.
|
||||
await page.keyboard.press("Shift");
|
||||
await button.focus();
|
||||
await expect(button).toBeFocused();
|
||||
expect(await matchesFocusVisible(button)).toBe(true);
|
||||
await expect(button).toHaveCSS("outline-style", "solid");
|
||||
await expect(button).toHaveCSS("outline-width", "2px");
|
||||
// Header rows clip overflow; an outset ring would lose its top and bottom edges.
|
||||
await expect(button).toHaveCSS("outline-offset", "-2px");
|
||||
}
|
||||
|
||||
async function expectNoPointerFocusRing(page: Page, testId: string): Promise<void> {
|
||||
const button = visible(page, testId);
|
||||
await button.blur();
|
||||
// Toggle twice so the layout ends where it started.
|
||||
await button.click();
|
||||
await button.click();
|
||||
await expect(button).toBeFocused();
|
||||
expect(await matchesFocusVisible(button)).toBe(false);
|
||||
await expect(button).toHaveCSS("outline-style", "none");
|
||||
}
|
||||
|
||||
test.describe("Icon button focus ring", () => {
|
||||
test("header icon buttons show the focus ring for keyboard focus only", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
const workspace = await seedWorkspace({ repoPrefix: "icon-button-focus-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
for (const testId of HEADER_ICON_BUTTONS) {
|
||||
await expectKeyboardFocusRing(page, testId);
|
||||
}
|
||||
await expectNoPointerFocusRing(page, "workspace-explorer-toggle");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
test,
|
||||
expectStillBrowsing,
|
||||
installFromPage,
|
||||
installListingFromRow,
|
||||
openDirectory,
|
||||
openFromInstalledTab,
|
||||
openListingPage,
|
||||
uninstallFromPage,
|
||||
} from "../support/helpers/plugin-directory";
|
||||
|
||||
test("installs a listed plugin from Browse and uninstalls it from either of its pages", async ({
|
||||
page,
|
||||
directoryHost,
|
||||
}) => {
|
||||
await openDirectory(page, directoryHost);
|
||||
await installListingFromRow(page);
|
||||
await expectStillBrowsing(page);
|
||||
await openListingPage(page);
|
||||
await uninstallFromPage(page);
|
||||
await installFromPage(page);
|
||||
await openFromInstalledTab(page);
|
||||
await uninstallFromPage(page);
|
||||
});
|
||||
@@ -14,6 +14,44 @@ import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { waitForSettledPosition } from "../support/helpers/sheet-layout";
|
||||
import {
|
||||
backToPlugins,
|
||||
clearSearch,
|
||||
closePluginLogs,
|
||||
expectGalleryMenuWithoutSourceInstall,
|
||||
expectPluginActionPending,
|
||||
expectPluginFailed,
|
||||
expectPluginHealthy,
|
||||
expectPluginOff,
|
||||
expectPluginOn,
|
||||
expectPluginRemoved,
|
||||
expectPluginsOff,
|
||||
expectPluginsOn,
|
||||
expectPluginsRoute,
|
||||
expectSourceInstallFailed,
|
||||
expectSourceInstallTurnsPluginsOn,
|
||||
expectSourceInstalled,
|
||||
expectToast,
|
||||
expectUpdateAvailable,
|
||||
installFromSource,
|
||||
openPluginLogs,
|
||||
openPluginPage,
|
||||
openPluginPageActions,
|
||||
openSourceInstall,
|
||||
pluginRow,
|
||||
reloadPluginFromPage,
|
||||
removePluginFromPage,
|
||||
searchInstalled,
|
||||
selectInstalledTab,
|
||||
selectPluginPageAction,
|
||||
submitSourceInstall,
|
||||
turnOffPluginsFromMenu,
|
||||
turnOnPluginsFromAlert,
|
||||
turnOnPluginsFromMenu,
|
||||
turnPluginOff,
|
||||
turnPluginOn,
|
||||
updatePlugin,
|
||||
} from "../support/helpers/plugin-gallery";
|
||||
import {
|
||||
expectSettingsHeader,
|
||||
openCompactSettings,
|
||||
@@ -170,22 +208,12 @@ async function openCompactPluginSettings(page: Page): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
await openCompactSettings(page, buildOpenProjectRoute());
|
||||
await openHostSection(page, serverId, "plugins");
|
||||
await expect(page.getByRole("textbox", { name: "Plugin source", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /^Installed/ })).toBeVisible();
|
||||
}
|
||||
|
||||
async function reloadPlugin(page: Page): Promise<void> {
|
||||
await selectPluginAction(page, "e2e-plugin", "Reload");
|
||||
await expect(page.getByText("Reloaded e2e-plugin", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function openPluginActions(page: Page, pluginId: string): Promise<void> {
|
||||
await page.getByRole("button", { name: `Actions for ${pluginId}`, exact: true }).click();
|
||||
await expect(page.getByRole("menuitem").first()).toBeVisible();
|
||||
}
|
||||
|
||||
async function selectPluginAction(page: Page, pluginId: string, action: string): Promise<void> {
|
||||
await openPluginActions(page, pluginId);
|
||||
await page.getByRole("menuitem", { name: action, exact: true }).click();
|
||||
async function openPluginPageFromSettings(page: Page, name: string): Promise<void> {
|
||||
await openPluginSettings(page);
|
||||
await openPluginPage(page, name);
|
||||
}
|
||||
|
||||
async function expectContributionBody(page: Page, expectedBody: string): Promise<void> {
|
||||
@@ -238,11 +266,6 @@ async function capturePluginInstallForm(
|
||||
});
|
||||
}
|
||||
|
||||
async function installPlugin(page: Page, source: string): Promise<void> {
|
||||
await page.getByLabel("Plugin source").fill(source);
|
||||
await page.getByRole("button", { name: "Install plugin" }).click();
|
||||
}
|
||||
|
||||
async function expectPluginSourceDocsOpen(page: Page): Promise<void> {
|
||||
const requestedPage = page
|
||||
.context()
|
||||
@@ -266,6 +289,17 @@ async function expectPluginSourceDocsOpen(page: Page): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function commitAll(repository: string, message: string): void {
|
||||
execFileSync("git", ["add", "-A"], { cwd: repository, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", message], { cwd: repository, stdio: "ignore" });
|
||||
}
|
||||
|
||||
/** A new commit on the remote is an update for a plugin installed from that Git repository. */
|
||||
async function publishGitPluginChange(repository: string, title: string): Promise<void> {
|
||||
await writeFile(path.join(repository, "index.client.tsx"), pluginSource(title));
|
||||
commitAll(repository, title);
|
||||
}
|
||||
|
||||
async function createGitPluginRepository(root: string): Promise<string> {
|
||||
const repository = path.join(root, "repository");
|
||||
await mkdir(repository);
|
||||
@@ -359,14 +393,12 @@ async function createGatedBuildRepository(
|
||||
}
|
||||
|
||||
async function installFailedPlugin(
|
||||
page: Page,
|
||||
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
|
||||
directory: string,
|
||||
): Promise<void> {
|
||||
await client.installPluginSource({ source: directory });
|
||||
await writeFile(path.join(directory, "index.client.tsx"), "export default broken !!!");
|
||||
await client.reloadPlugin("failed-preview-plugin");
|
||||
await expect(page.getByLabel("failed-preview-plugin failed")).toBeVisible();
|
||||
}
|
||||
|
||||
test("installs, reloads, recovers, disables, and removes a trusted local plugin", async ({
|
||||
@@ -378,12 +410,15 @@ test("installs, reloads, recovers, disables, and removes a trusted local plugin"
|
||||
await toggleRemoveAndReinstallLocalPlugin(page, directory);
|
||||
});
|
||||
|
||||
test("installs a Git source after a failed source remains editable", async ({
|
||||
test("installs a Git source after a failed source remains editable, then updates it", async ({
|
||||
page,
|
||||
pluginEnvironment,
|
||||
}, testInfo) => {
|
||||
const source = await prepareCompactGitInstall(page, pluginEnvironment);
|
||||
await retryGitInstallAndInspectRows(page, source, testInfo);
|
||||
await updateGitPlugin(page, source.repository);
|
||||
await expectCompactPluginMenuFits(page, testInfo);
|
||||
await failUpdateFromPluginPage(page, source.repository);
|
||||
});
|
||||
|
||||
for (const sourceSupport of [undefined, false]) {
|
||||
@@ -400,7 +435,7 @@ for (const viewport of [
|
||||
{ width: 1280, height: 900 },
|
||||
{ width: 390, height: 844 },
|
||||
]) {
|
||||
test(`installs an npm source and manages its row at ${viewport.width}px`, async ({
|
||||
test(`installs an npm source and manages it at ${viewport.width}px`, async ({
|
||||
page,
|
||||
pluginEnvironment,
|
||||
}, testInfo) => {
|
||||
@@ -415,18 +450,19 @@ async function openNpmPluginSettings(page: Page, width: number) {
|
||||
else await openPluginSettings(page);
|
||||
}
|
||||
|
||||
async function expectSourceHierarchy(page: Page, description: string, source: string) {
|
||||
const descriptionText = page.getByText(description, { exact: false });
|
||||
const sourceText = page.getByText(source, { exact: false });
|
||||
const descriptionSize = await descriptionText.evaluate((element) =>
|
||||
Number.parseFloat(getComputedStyle(element).fontSize),
|
||||
/** The row's description reads before where the plugin was installed from. */
|
||||
async function expectSourceHierarchy(
|
||||
page: Page,
|
||||
pluginId: string,
|
||||
description: string,
|
||||
source: string,
|
||||
) {
|
||||
const row = pluginRow(page, pluginId);
|
||||
await expect(row.getByText(description, { exact: true })).toHaveCSS(
|
||||
"color",
|
||||
"rgb(113, 113, 122)",
|
||||
);
|
||||
const sourceSize = await sourceText.evaluate((element) =>
|
||||
Number.parseFloat(getComputedStyle(element).fontSize),
|
||||
);
|
||||
expect(sourceSize).toBeLessThan(descriptionSize);
|
||||
await expect(sourceText).toHaveCSS("color", "rgb(161, 161, 170)");
|
||||
await expect(descriptionText).toHaveCSS("color", "rgb(113, 113, 122)");
|
||||
await expect(row.getByText(source, { exact: true })).toHaveCSS("color", "rgb(161, 161, 170)");
|
||||
}
|
||||
|
||||
async function installLocalPluginWithStatusExamples(
|
||||
@@ -457,36 +493,51 @@ async function installLocalPluginWithStatusExamples(
|
||||
}),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v1"));
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installPluginSource({ source: disabledDirectory });
|
||||
await client.disablePlugin("disabled-preview-plugin-with-a-long-name");
|
||||
await installFailedPlugin(client, failedDirectory);
|
||||
|
||||
const catalog = observePluginCatalog(page);
|
||||
await gotoAppShell(page);
|
||||
await openPluginSettings(page);
|
||||
await catalog.waitForInitialFetch();
|
||||
await expect(page.getByRole("textbox", { name: "Plugin installation ID" })).toHaveCount(0);
|
||||
await expectPluginSourceDocsOpen(page);
|
||||
await expectPluginFailed(page, "failed-preview-plugin");
|
||||
|
||||
// Plugins off on the host: the Installed tab says so and turns them back on.
|
||||
await client.patchDaemonConfig({ pluginsEnabled: false });
|
||||
await page.getByRole("switch", { name: "Enable plugins" }).click();
|
||||
await expect(page.getByText("Plugins enabled", { exact: true })).toBeVisible();
|
||||
await client.installPluginSource({ source: disabledDirectory });
|
||||
await client.disablePlugin("disabled-preview-plugin-with-a-long-name");
|
||||
await installFailedPlugin(page, client, failedDirectory);
|
||||
await page.getByLabel("Plugin source").fill(directory);
|
||||
await page.getByRole("button", { name: "Install plugin" }).click();
|
||||
await expect(page.getByText("Installed e2e-plugin", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
||||
await expectPluginsOff(page);
|
||||
await turnOnPluginsFromAlert(page);
|
||||
await turnOffPluginsFromMenu(page);
|
||||
await turnOnPluginsFromMenu(page);
|
||||
|
||||
await openSourceInstall(page);
|
||||
await expectPluginSourceDocsOpen(page);
|
||||
await submitSourceInstall(page, directory);
|
||||
await expectSourceInstalled(page, "e2e-plugin");
|
||||
await expectPluginOn(page, "e2e-plugin");
|
||||
await expectPluginHealthy(page, "e2e-plugin");
|
||||
await expect(page.getByText("Exercises the complete local plugin lifecycle")).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("Keeps optional previews off until this plugin is enabled"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("Demonstrates a plugin that needs attention")).toBeVisible();
|
||||
await expect(page.getByLabel("disabled-preview-plugin-with-a-long-name disabled")).toBeVisible();
|
||||
await expect(page.getByLabel("failed-preview-plugin failed")).toBeVisible();
|
||||
await expectPluginOff(page, "disabled-preview-plugin-with-a-long-name");
|
||||
await expectPluginFailed(page, "failed-preview-plugin");
|
||||
|
||||
await searchInstalled(page, "needs attention");
|
||||
await expect(pluginRow(page, "failed-preview-plugin")).toBeVisible();
|
||||
await expect(pluginRow(page, "e2e-plugin")).toHaveCount(0);
|
||||
await clearSearch(page);
|
||||
await expect(pluginRow(page, "e2e-plugin")).toBeVisible();
|
||||
await capturePluginInstallForm(page, testInfo, "wide");
|
||||
await openPluginActions(page, "e2e-plugin");
|
||||
|
||||
await openPluginPage(page, "e2e-plugin");
|
||||
await openPluginPageActions(page, "e2e-plugin");
|
||||
await capturePluginInstallForm(page, testInfo, "wide-menu");
|
||||
await page.getByRole("menuitem", { name: "Logs", exact: true }).click();
|
||||
await expect(page.getByRole("dialog")).toContainText("Logs: e2e-plugin");
|
||||
await page.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await page.keyboard.press("Escape");
|
||||
await openPluginLogs(page, "e2e-plugin");
|
||||
await closePluginLogs(page);
|
||||
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 0");
|
||||
|
||||
return directory;
|
||||
@@ -497,13 +548,13 @@ async function reloadAndRecoverLocalPlugin(
|
||||
client: PluginEnvironment["client"],
|
||||
directory: string,
|
||||
) {
|
||||
await openPluginSettings(page);
|
||||
await reloadPlugin(page);
|
||||
await openPluginPageFromSettings(page, "e2e-plugin");
|
||||
await reloadPluginFromPage(page, "e2e-plugin");
|
||||
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 1");
|
||||
|
||||
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v2"));
|
||||
await openPluginSettings(page);
|
||||
await reloadPlugin(page);
|
||||
await openPluginPageFromSettings(page, "e2e-plugin");
|
||||
await reloadPluginFromPage(page, "e2e-plugin");
|
||||
await openContributionFromSettings(page, "Plugin v2", "Plugin v2 cleanup 2");
|
||||
await expect(page.getByRole("button", { name: "Plugin v1", exact: true })).not.toBeVisible();
|
||||
|
||||
@@ -511,8 +562,8 @@ async function reloadAndRecoverLocalPlugin(
|
||||
path.join(directory, "index.client.tsx"),
|
||||
pluginSource("Broken surface", "render exploded"),
|
||||
);
|
||||
await openPluginSettings(page);
|
||||
await reloadPlugin(page);
|
||||
await openPluginPageFromSettings(page, "e2e-plugin");
|
||||
await reloadPluginFromPage(page, "e2e-plugin");
|
||||
await openContributionFromSettings(page, "Broken surface", "Plugin failed: render exploded");
|
||||
await openContribution(page, "Healthy surface", "Healthy contribution");
|
||||
await openContribution(page, "Broken surface", "Plugin failed: render exploded");
|
||||
@@ -522,39 +573,39 @@ async function reloadAndRecoverLocalPlugin(
|
||||
await expect(page.getByRole("button", { name: "Broken surface", exact: true })).not.toBeVisible();
|
||||
|
||||
await writeFile(path.join(directory, "index.client.tsx"), "export default broken syntax !!!");
|
||||
await openPluginSettings(page);
|
||||
await selectPluginAction(page, "e2e-plugin", "Reload");
|
||||
await expect(page.getByTestId("plugin-management-feedback")).toContainText("Request failed");
|
||||
await expect(page.getByLabel("e2e-plugin failed")).toBeVisible();
|
||||
await openPluginPageFromSettings(page, "e2e-plugin");
|
||||
await selectPluginPageAction(page, "e2e-plugin", "Reload");
|
||||
await expectToast(page, "Request failed");
|
||||
await backToPlugins(page, "e2e-plugin");
|
||||
await expectPluginFailed(page, "e2e-plugin");
|
||||
await leavePluginSettings(page);
|
||||
await expectContributionRemoved(page, "Recovered surface");
|
||||
|
||||
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v3"));
|
||||
await openPluginSettings(page);
|
||||
await selectPluginAction(page, "e2e-plugin", "Reload");
|
||||
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
||||
await openPluginPageFromSettings(page, "e2e-plugin");
|
||||
await reloadPluginFromPage(page, "e2e-plugin");
|
||||
await backToPlugins(page, "e2e-plugin");
|
||||
await expectPluginHealthy(page, "e2e-plugin");
|
||||
await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 5");
|
||||
}
|
||||
|
||||
async function toggleRemoveAndReinstallLocalPlugin(page: Page, directory: string) {
|
||||
await openPluginSettings(page);
|
||||
await page.getByRole("switch", { name: "e2e-plugin: Disable", exact: true }).click();
|
||||
await expect(page.getByLabel("e2e-plugin disabled")).toBeVisible();
|
||||
await turnPluginOff(page, "e2e-plugin");
|
||||
await leavePluginSettings(page);
|
||||
await expectContributionRemoved(page, "Plugin v3");
|
||||
|
||||
await openPluginSettings(page);
|
||||
await page.getByRole("switch", { name: "e2e-plugin: Enable", exact: true }).click();
|
||||
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
||||
await turnPluginOn(page, "e2e-plugin");
|
||||
await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 6");
|
||||
|
||||
await openPluginSettings(page);
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await selectPluginAction(page, "e2e-plugin", "Remove");
|
||||
await expect(page.getByTestId("plugin-row-e2e-plugin")).toHaveCount(0);
|
||||
await openPluginPageFromSettings(page, "e2e-plugin");
|
||||
await removePluginFromPage(page, "e2e-plugin");
|
||||
await expectPluginsRoute(page);
|
||||
await expectPluginRemoved(page, "e2e-plugin");
|
||||
|
||||
await installPlugin(page, directory);
|
||||
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
||||
await installFromSource(page, directory, "e2e-plugin");
|
||||
await expectPluginOn(page, "e2e-plugin");
|
||||
await gotoAppShell(page);
|
||||
await openContribution(page, "Plugin v3", "Plugin v3 cleanup 7");
|
||||
}
|
||||
@@ -583,10 +634,8 @@ async function prepareCompactGitInstall(page: Page, pluginEnvironment: PluginEnv
|
||||
await client.disablePlugin("compact-disabled-plugin");
|
||||
await gotoAppShell(page);
|
||||
await openCompactPluginSettings(page);
|
||||
await expect(page.getByRole("textbox", { name: "Plugin installation ID" })).toHaveCount(0);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: false });
|
||||
await page.getByRole("switch", { name: "Enable plugins" }).click();
|
||||
await expect(page.getByText("Plugins enabled", { exact: true })).toBeVisible();
|
||||
await expectPluginsOff(page);
|
||||
|
||||
return { repository, missingSource };
|
||||
}
|
||||
@@ -598,24 +647,58 @@ async function retryGitInstallAndInspectRows(
|
||||
) {
|
||||
const { repository, missingSource } = source;
|
||||
|
||||
await installPlugin(page, missingSource);
|
||||
await expect(page.getByTestId("plugin-management-feedback")).toContainText(
|
||||
await openSourceInstall(page);
|
||||
// Plugins are off, so the confirmation says installing turns them on.
|
||||
await expectSourceInstallTurnsPluginsOn(page);
|
||||
await submitSourceInstall(page, missingSource);
|
||||
await expectSourceInstallFailed(
|
||||
page,
|
||||
missingSource,
|
||||
`Plugin directory does not exist: ${missingSource}`,
|
||||
);
|
||||
await expect(page.getByLabel("Plugin source")).toHaveValue(missingSource);
|
||||
await capturePluginInstallForm(page, testInfo, "compact-error");
|
||||
|
||||
await installPlugin(page, pathToFileURL(repository).href);
|
||||
await expect(page.getByText("Installed git-e2e-plugin", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("git-e2e-plugin running")).toBeVisible();
|
||||
await submitSourceInstall(page, pathToFileURL(repository).href);
|
||||
await expectSourceInstalled(page, "git-e2e-plugin");
|
||||
await expectPluginsOn(page);
|
||||
await expectPluginOn(page, "git-e2e-plugin");
|
||||
await expect(
|
||||
page.getByText("Shows how a realistically long plugin description wraps on a compact screen"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByLabel("compact-disabled-plugin disabled")).toBeVisible();
|
||||
await expect(page.getByLabel("Plugin source")).toHaveValue("");
|
||||
await expectPluginOff(page, "compact-disabled-plugin");
|
||||
await capturePluginInstallForm(page, testInfo, "compact-success");
|
||||
await openPluginActions(page, "git-e2e-plugin");
|
||||
const removeAction = page.getByRole("menuitem", { name: "Remove", exact: true });
|
||||
}
|
||||
|
||||
async function updateGitPlugin(page: Page, repository: string) {
|
||||
await publishGitPluginChange(repository, "Git plugin v2");
|
||||
// Updates are checked when Plugins opens.
|
||||
await page.reload();
|
||||
await expectUpdateAvailable(page, "git-e2e-plugin");
|
||||
await updatePlugin(page, "git-e2e-plugin");
|
||||
await expectPluginOn(page, "git-e2e-plugin");
|
||||
}
|
||||
|
||||
/** An update that fails on the plugin's page says why there, and offers Retry. */
|
||||
async function failUpdateFromPluginPage(page: Page, repository: string) {
|
||||
await publishGitPluginChange(repository, "Git plugin v3");
|
||||
await page.reload();
|
||||
const update = page.getByTestId("plugin-update").filter({ visible: true });
|
||||
await expect(update).toHaveText("Update");
|
||||
// Rewriting the commit the page checked leaves its update pointing at a commit the remote no
|
||||
// longer serves. The next check offers the rewritten commit.
|
||||
execFileSync("git", ["commit", "--amend", "-m", "Git plugin v3, amended"], {
|
||||
cwd: repository,
|
||||
stdio: "ignore",
|
||||
});
|
||||
await update.click();
|
||||
await expect(update).toHaveText("Retry");
|
||||
await expect(page.getByText(/Git command failed/)).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectCompactPluginMenuFits(page: Page, testInfo: TestInfo) {
|
||||
await openPluginPage(page, "git-e2e-plugin");
|
||||
await openPluginPageActions(page, "git-e2e-plugin");
|
||||
const removeAction = page.getByRole("menuitem", { name: "Uninstall", exact: true });
|
||||
await expect(removeAction).toBeInViewport({ ratio: 1 });
|
||||
await waitForSettledPosition(removeAction);
|
||||
await capturePluginInstallForm(page, testInfo, "compact-menu");
|
||||
@@ -648,12 +731,11 @@ async function prepareLegacyPluginHost(
|
||||
|
||||
async function verifyLegacyPluginManagement(page: Page, directory: string) {
|
||||
await expect(
|
||||
page.getByText("Update this host to install plugins", { exact: true }),
|
||||
pluginRow(page, "legacy-source-plugin").getByText(directory, { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("textbox", { name: "Plugin source", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText(directory, { exact: true })).toBeVisible();
|
||||
await selectPluginAction(page, "legacy-source-plugin", "Reload");
|
||||
await expect(page.getByText("Reloaded legacy-source-plugin", { exact: true })).toBeVisible();
|
||||
await expectGalleryMenuWithoutSourceInstall(page);
|
||||
await openPluginPage(page, "legacy-source-plugin");
|
||||
await reloadPluginFromPage(page, "legacy-source-plugin");
|
||||
}
|
||||
|
||||
async function prepareNpmPluginHost(
|
||||
@@ -667,23 +749,22 @@ async function prepareNpmPluginHost(
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await gotoAppShell(page);
|
||||
await openNpmPluginSettings(page, viewport.width);
|
||||
await selectInstalledTab(page);
|
||||
}
|
||||
|
||||
async function retryNpmInstallAndInspectRow(page: Page, width: number, testInfo: TestInfo) {
|
||||
await installPlugin(page, "npm:missing-plugin");
|
||||
await expect(page.getByTestId("plugin-management-feedback")).toContainText("404");
|
||||
await expect(page.getByLabel("Plugin source")).toHaveValue("npm:missing-plugin");
|
||||
await installPlugin(page, "npm:@paseo-fixture/review@^2.0.0");
|
||||
await expect(page.getByText("Installed npm-review", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("npm-review running")).toBeVisible();
|
||||
await openSourceInstall(page);
|
||||
await submitSourceInstall(page, "npm:missing-plugin");
|
||||
await expectSourceInstallFailed(page, "npm:missing-plugin", "404");
|
||||
await submitSourceInstall(page, "npm:@paseo-fixture/review@^2.0.0");
|
||||
await expectSourceInstalled(page, "npm-review");
|
||||
await expectPluginOn(page, "npm-review");
|
||||
await expectSourceHierarchy(
|
||||
page,
|
||||
"npm-review",
|
||||
"Installed from the npm fixture registry",
|
||||
"npm:@paseo-fixture/review · 2.0.0",
|
||||
);
|
||||
await expect(
|
||||
page.getByText("Installed from the npm fixture registry", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath(`npm-plugin-${width}.png`),
|
||||
animations: "disabled",
|
||||
@@ -691,12 +772,11 @@ async function retryNpmInstallAndInspectRow(page: Page, width: number, testInfo:
|
||||
}
|
||||
|
||||
async function reloadAndRemoveNpmPlugin(page: Page) {
|
||||
await selectPluginAction(page, "npm-review", "Reload");
|
||||
await expect(page.getByText("Reloaded npm-review", { exact: true })).toBeVisible();
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await selectPluginAction(page, "npm-review", "Remove");
|
||||
await expect(page.getByText("Removed npm-review", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("npm-review running")).toHaveCount(0);
|
||||
await openPluginPage(page, "npm-review");
|
||||
await reloadPluginFromPage(page, "npm-review");
|
||||
await removePluginFromPage(page, "npm-review");
|
||||
await expectPluginsRoute(page);
|
||||
await expectPluginRemoved(page, "npm-review");
|
||||
}
|
||||
|
||||
test("renders installed-plugin descriptions as plain text", async ({ page, pluginEnvironment }) => {
|
||||
@@ -793,19 +873,6 @@ async function holdPluginLifecycleQueue(
|
||||
};
|
||||
}
|
||||
|
||||
function pluginRowActions(page: Page, pluginId: string) {
|
||||
return page.getByRole("button", { name: `Actions for ${pluginId}`, exact: true });
|
||||
}
|
||||
|
||||
async function expectPluginRowPending(page: Page, pluginId: string): Promise<void> {
|
||||
await expect(pluginRowActions(page, pluginId)).toBeDisabled();
|
||||
}
|
||||
|
||||
async function expectPluginRowSettled(page: Page, pluginId: string): Promise<void> {
|
||||
await expect(pluginRowActions(page, pluginId)).toBeEnabled();
|
||||
await expect(page.getByLabel(`${pluginId} running`)).toBeVisible();
|
||||
}
|
||||
|
||||
async function capturePluginSurface(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath(name),
|
||||
@@ -836,39 +903,36 @@ test("keeps Reload pending past a minute while the plugin queue is busy", async
|
||||
// Observe before the app loads: its daemon socket comes up with the page.
|
||||
const reload = observePluginReload(page);
|
||||
await gotoAppShell(page);
|
||||
await openPluginSettings(page);
|
||||
// Plugins on is a precondition here, not the subject: set it through the daemon instead of the
|
||||
// switch, whose transient "Plugins enabled" confirmation this scenario must not depend on.
|
||||
// Plugins on is a precondition here, not the subject: set it through the daemon.
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installPluginSource({ source: pluginDirectory });
|
||||
await expectPluginRowSettled(page, "e2e-plugin");
|
||||
// Installed before Plugins opens, so it opens on the Installed tab.
|
||||
await openPluginSettings(page);
|
||||
await expectPluginOn(page, "e2e-plugin");
|
||||
await openPluginPage(page, "e2e-plugin");
|
||||
|
||||
const queue = await holdPluginLifecycleQueue(client, directory);
|
||||
try {
|
||||
await selectPluginAction(page, "e2e-plugin", "Reload");
|
||||
await selectPluginPageAction(page, "e2e-plugin", "Reload");
|
||||
await reload.waitForRequest();
|
||||
// Pending: the row's actions stay disabled, so a second reload cannot be submitted.
|
||||
await expectPluginRowPending(page, "e2e-plugin");
|
||||
// Pending: the plugin's controls stay disabled, so a second action cannot be submitted.
|
||||
await expectPluginActionPending(page, "e2e-plugin");
|
||||
await capturePluginSurface(page, testInfo, "plugin-reload-pending.png");
|
||||
|
||||
await waitPastFormerReloadDeadline(page, reload.requestedAt());
|
||||
|
||||
// Past the former deadline the reload is still pending, the row shows its previous state, and
|
||||
// no timeout error reached the surface.
|
||||
await expectPluginRowPending(page, "e2e-plugin");
|
||||
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
||||
await expect(page.getByTestId("plugin-management-feedback")).toHaveCount(0);
|
||||
// Past the former deadline the reload is still pending; a request timeout would have settled
|
||||
// it and released the switch.
|
||||
await expectPluginActionPending(page, "e2e-plugin");
|
||||
await expect(page.getByTestId("app-toast-message")).toHaveCount(0);
|
||||
await capturePluginSurface(page, testInfo, "plugin-reload-past-deadline.png");
|
||||
|
||||
await queue.release();
|
||||
// The daemon's answer only arrives once the plugin really started again; a request timeout
|
||||
// would have shown up as feedback during the wait above.
|
||||
// The daemon's answer only arrives once the plugin really started again.
|
||||
await reload.waitForResponse();
|
||||
expect(reload.requestCount()).toBe(1);
|
||||
await expect(page.getByTestId("plugin-management-feedback")).toContainText(
|
||||
"Reloaded e2e-plugin",
|
||||
);
|
||||
await expectPluginRowSettled(page, "e2e-plugin");
|
||||
await expectToast(page, "Reloaded e2e-plugin");
|
||||
await expectPluginOn(page, "e2e-plugin");
|
||||
await capturePluginSurface(page, testInfo, "plugin-reload-completed.png");
|
||||
} finally {
|
||||
await queue.settle();
|
||||
|
||||
@@ -18,11 +18,12 @@ import {
|
||||
expectSettingsUnavailable,
|
||||
openPluginsPage,
|
||||
expectActionsMenuWithoutSettings,
|
||||
expectNoSettingsCogWithoutScreens,
|
||||
expectSettingsFollowPluginSwitch,
|
||||
expectSettingsHidden,
|
||||
expectSettingsTab,
|
||||
openTabbedSettings,
|
||||
selectSettingsTab,
|
||||
openTabbedSettingsLink,
|
||||
expectSettingsTooltip,
|
||||
expectNamedPluginRow,
|
||||
} from "../support/helpers/plugin-settings";
|
||||
|
||||
@@ -52,35 +53,41 @@ test("another client's save preserves a conflicting draft", async ({ page }) =>
|
||||
await discardTitle(page);
|
||||
});
|
||||
|
||||
test("Back to plugins returns from a plugin settings screen", async ({ page }) => {
|
||||
test("Back to plugins returns from a plugin page", async ({ page }) => {
|
||||
await openDisplaySettings(page);
|
||||
await returnToPluginsPage(page);
|
||||
});
|
||||
|
||||
test("plugin settings open from the cog and show a tab per screen", async ({ page }) => {
|
||||
test("plugin pages open from their row and show a tab per settings screen", async ({ page }) => {
|
||||
await openPluginsPage(page);
|
||||
await test.step("plugin rows show the display name", async () => {
|
||||
await expectNamedPluginRow(page);
|
||||
});
|
||||
await test.step("the cog explains itself", async () => {
|
||||
await expectSettingsTooltip(page);
|
||||
await test.step("the row opens the plugin's page", async () => {
|
||||
await openTabbedSettings(page);
|
||||
});
|
||||
await test.step("the actions menu keeps only plugin actions", async () => {
|
||||
await test.step("the page's menu keeps only plugin actions", async () => {
|
||||
await expectActionsMenuWithoutSettings(page);
|
||||
});
|
||||
await test.step("tabs switch between the plugin's settings screens", async () => {
|
||||
await openTabbedSettings(page);
|
||||
await selectSettingsTab(page, "Advanced");
|
||||
await selectSettingsTab(page, "General");
|
||||
await returnToPluginsPage(page);
|
||||
});
|
||||
await test.step("a plugin without settings screens shows no cog", async () => {
|
||||
await expectNoSettingsCogWithoutScreens(page);
|
||||
await test.step("turning a plugin off removes its settings from its page", async () => {
|
||||
await expectSettingsFollowPluginSwitch(page);
|
||||
});
|
||||
});
|
||||
|
||||
test("a settings screen link opens its tab", async ({ page }) => {
|
||||
test("a settings screen link opens its tab, unavailable while the plugin is off", async ({
|
||||
page,
|
||||
settingsPlugin,
|
||||
}) => {
|
||||
await openTabbedSettingsLink(page, "Advanced");
|
||||
await settingsPlugin.disablePlugin("settings-tabs");
|
||||
await expectSettingsUnavailable(page);
|
||||
await settingsPlugin.enablePlugin("settings-tabs");
|
||||
await expectSettingsTab(page, "Advanced");
|
||||
});
|
||||
|
||||
test.describe("compact plugin settings", () => {
|
||||
@@ -95,7 +102,7 @@ test.describe("compact plugin settings", () => {
|
||||
await hideMetadata(page);
|
||||
await returnAndReopenSettings(page);
|
||||
await settingsPlugin.disablePlugin("settings-example");
|
||||
await expectSettingsUnavailable(page);
|
||||
await expectSettingsHidden(page);
|
||||
await settingsPlugin.enablePlugin("settings-example");
|
||||
await reloadSavedDisplaySettings(page);
|
||||
});
|
||||
|
||||
@@ -153,29 +153,21 @@ async function holdStreamAfterFirstCompletedToolCall(page: Page, agentId: string
|
||||
type OverviewChatDisplay = "collapseToolCalls" | "collapseTurns";
|
||||
|
||||
// Expand thinking keeps reasoning out of tool call runs, so the first tool call starts alone.
|
||||
async function configureOverviewToolCalls(
|
||||
page: Page,
|
||||
chatDisplay: OverviewChatDisplay,
|
||||
autoExpandReasoning: boolean,
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ display, expandReasoning }) => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({ chatDisplay: display, autoExpandReasoning: expandReasoning }),
|
||||
);
|
||||
},
|
||||
{ display: chatDisplay, expandReasoning: autoExpandReasoning },
|
||||
);
|
||||
async function configureOverviewToolCalls(page: Page, chatDisplay: OverviewChatDisplay) {
|
||||
await page.addInitScript((display) => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({ chatDisplay: display, autoExpandReasoning: true }),
|
||||
);
|
||||
}, chatDisplay);
|
||||
}
|
||||
|
||||
async function createOverviewAgent(
|
||||
page: Page,
|
||||
title: string,
|
||||
chatDisplay: OverviewChatDisplay = "collapseToolCalls",
|
||||
autoExpandReasoning = true,
|
||||
) {
|
||||
await configureOverviewToolCalls(page, chatDisplay, autoExpandReasoning);
|
||||
await configureOverviewToolCalls(page, chatDisplay);
|
||||
return seedMockAgentWorkspace({
|
||||
repoPrefix: "tool-call-overview-sheet-",
|
||||
title,
|
||||
@@ -409,211 +401,3 @@ test("forms a closed group when the lone tool call was left closed", async ({ pa
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
const longGroupedDetail = {
|
||||
type: "unknown",
|
||||
input: Array.from({ length: 80 }, (_, i) => `Long input ${i}`),
|
||||
output: Array.from({ length: 80 }, (_, i) => `Long output ${i}`),
|
||||
};
|
||||
|
||||
function provideLongReadDetail(item: Record<string, unknown> | undefined) {
|
||||
if (item?.type === "tool_call" && item.name === "read") item.detail = longGroupedDetail;
|
||||
}
|
||||
|
||||
function provideLongReasoningDetail(item: Record<string, unknown> | undefined) {
|
||||
if (item?.type === "reasoning") {
|
||||
item.text = Array.from({ length: 80 }, (_, i) => `Long reasoning ${i}`).join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
async function provideLongToolDetails(page: Page, transformItem = provideLongReadDetail) {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => server.send(message));
|
||||
server.onMessage((message) => {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
const envelope = JSON.parse(raw);
|
||||
const item = envelope.message?.payload?.event?.item;
|
||||
transformItem(item);
|
||||
if (envelope.message?.type === "fetch_agent_timeline_response") {
|
||||
for (const entry of envelope.message.payload.entries) transformItem(entry.item);
|
||||
}
|
||||
ws.send(JSON.stringify(envelope));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleAndMeasureTool(badge: Locator) {
|
||||
await badge.scrollIntoViewIfNeeded();
|
||||
await badge.evaluate((node) => {
|
||||
// Inline groups own a bounded viewport; opened turns use the chat viewport.
|
||||
const container =
|
||||
node.closest('[data-testid="tool-call-group"]') ??
|
||||
node.closest('[data-testid="agent-chat-scroll"]');
|
||||
if (!container) throw new Error("Expected a tool inside a group or chat viewport");
|
||||
const frames: { top: number; height: number; containerHeight: number }[] = [];
|
||||
(node as HTMLElement & { disclosureFrames: typeof frames }).disclosureFrames = frames;
|
||||
node.querySelector("button")!.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
const measure = () => {
|
||||
const bounds = node.getBoundingClientRect();
|
||||
frames.push({
|
||||
top: bounds.top,
|
||||
height: bounds.height,
|
||||
containerHeight: container.getBoundingClientRect().height,
|
||||
});
|
||||
if (frames.length < 20) requestAnimationFrame(measure);
|
||||
};
|
||||
requestAnimationFrame(measure);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
await openToolDetails(badge);
|
||||
await expect
|
||||
.poll(() =>
|
||||
badge.evaluate(
|
||||
(node) => (node as HTMLElement & { disclosureFrames: unknown[] }).disclosureFrames.length,
|
||||
),
|
||||
)
|
||||
.toBe(20);
|
||||
return badge.evaluate(
|
||||
(node) =>
|
||||
(
|
||||
node as HTMLElement & {
|
||||
disclosureFrames: { top: number; height: number; containerHeight: number }[];
|
||||
}
|
||||
).disclosureFrames,
|
||||
);
|
||||
}
|
||||
|
||||
for (const display of ["collapseToolCalls", "collapseTurns"] as const) {
|
||||
test(`keeps multi-section tool details within the group (${display})`, async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await createOverviewAgent(page, "Bounded grouped tool details", display);
|
||||
try {
|
||||
await provideLongToolDetails(page);
|
||||
await openOverviewAgent(page, agent);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Exercise bounded grouped details.");
|
||||
await agent.client.waitForAgentUpsert(
|
||||
agent.agentId,
|
||||
(snapshot) => snapshot.status === "idle",
|
||||
30_000,
|
||||
);
|
||||
// A fresh mount of a finished conversation starts with its disclosures closed.
|
||||
await page.reload();
|
||||
await expectComposerVisible(page);
|
||||
if (display === "collapseToolCalls") {
|
||||
await scrollChatAwayFromBottom(page, { deltaY: -10_000, minDistanceFromBottom: 1 });
|
||||
}
|
||||
const group = page.getByTestId("tool-call-group").first();
|
||||
if (display === "collapseTurns") {
|
||||
await expect(page.getByTestId("tool-call-badge")).toHaveCount(0);
|
||||
await openToolDetails(page.getByTestId("turn-summary").first());
|
||||
} else {
|
||||
await expect(group).toBeVisible();
|
||||
await expect(group.getByTestId("tool-call-badge")).toHaveCount(0);
|
||||
await openToolDetails(group);
|
||||
}
|
||||
const scope = display === "collapseTurns" ? page : group;
|
||||
const badge = scope.getByTestId("tool-call-badge").filter({ hasText: "Read" }).first();
|
||||
await expect(badge).toBeVisible();
|
||||
const expandedFrames = await toggleAndMeasureTool(badge);
|
||||
for (const frame of expandedFrames)
|
||||
expect(frame.height).toBeLessThanOrEqual(frame.containerHeight);
|
||||
expect(
|
||||
Math.max(...expandedFrames.map((frame) => frame.top)) -
|
||||
Math.min(...expandedFrames.map((frame) => frame.top)),
|
||||
).toBeLessThanOrEqual(1);
|
||||
await expect(badge).toContainText("Long output");
|
||||
const collapsedFrames = await toggleAndMeasureTool(badge);
|
||||
expect(
|
||||
Math.max(...collapsedFrames.map((frame) => frame.top)) -
|
||||
Math.min(...collapsedFrames.map((frame) => frame.top)),
|
||||
).toBeLessThanOrEqual(1);
|
||||
await expect(badge).not.toContainText("Long output");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("keeps tool headers reachable in an overflowing group", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await createOverviewAgent(page, "Overflowing overview tool group");
|
||||
try {
|
||||
const gate = await holdStreamAfterFirstCompletedToolCall(page, agent.agentId);
|
||||
await openOverviewAgent(page, agent);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Inspect a large tool group.");
|
||||
await gate.waitForFirstCompleted();
|
||||
gate.appendCompletedCalls(15);
|
||||
const group = page.getByTestId("tool-call-group").first();
|
||||
await openToolDetails(group);
|
||||
const badges = group.getByTestId("tool-call-badge");
|
||||
await expect(badges).toHaveCount(16);
|
||||
await expect.poll(() => group.evaluate(hasScrolledToLatest)).toBe(true);
|
||||
const first = badges.first();
|
||||
for (const action of ["expand", "collapse"]) {
|
||||
await test.step(`${action} the first tool without moving its header`, async () => {
|
||||
await first.scrollIntoViewIfNeeded();
|
||||
const headerTop = await topOf(first);
|
||||
const frames = await toggleAndMeasureTool(first);
|
||||
for (const frame of frames) expect(Math.abs(frame.top - headerTop)).toBeLessThanOrEqual(1);
|
||||
});
|
||||
}
|
||||
await test.step("follow the next appended tool", async () => {
|
||||
gate.appendCompletedCalls(1);
|
||||
await expect(badges).toHaveCount(17);
|
||||
await expect.poll(() => group.evaluate(hasScrolledToLatest)).toBe(true);
|
||||
});
|
||||
await test.step("reopen the group at its latest tool", async () => {
|
||||
await openToolDetails(group);
|
||||
await expect(badges).toHaveCount(0);
|
||||
await openToolDetails(group);
|
||||
await expect(badges).toHaveCount(17);
|
||||
await expect.poll(() => group.evaluate(hasScrolledToLatest)).toBe(true);
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps expanded reasoning within the group viewport", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await createOverviewAgent(
|
||||
page,
|
||||
"Bounded grouped reasoning",
|
||||
"collapseToolCalls",
|
||||
false,
|
||||
);
|
||||
try {
|
||||
// Seed before opening so the fixture transforms complete snapshots, not token deltas.
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Inspect grouped reasoning.");
|
||||
await agent.client.waitForAgentUpsert(
|
||||
agent.agentId,
|
||||
(snapshot) => snapshot.status === "idle",
|
||||
30_000,
|
||||
);
|
||||
await provideLongToolDetails(page, provideLongReasoningDetail);
|
||||
await openOverviewAgent(page, agent);
|
||||
await scrollChatAwayFromBottom(page, { deltaY: -10_000, minDistanceFromBottom: 1 });
|
||||
const group = page.getByTestId("tool-call-group").first();
|
||||
await openToolDetails(group);
|
||||
const thought = group.getByTestId("tool-call-badge").filter({ hasText: "Thinking" }).first();
|
||||
const frames = await toggleAndMeasureTool(thought);
|
||||
const viewportHeight = await group.evaluate((node) => {
|
||||
const scroll = Array.from(node.querySelectorAll<HTMLElement>("*")).find((child) =>
|
||||
["auto", "scroll"].includes(getComputedStyle(child).overflowY),
|
||||
);
|
||||
if (!scroll) throw new Error("Expected the group's scroll viewport");
|
||||
return scroll.getBoundingClientRect().height;
|
||||
});
|
||||
for (const frame of frames) expect(frame.height).toBeLessThanOrEqual(viewportHeight);
|
||||
await expect(thought).toContainText("Long reasoning 79");
|
||||
await openToolDetails(thought);
|
||||
await expect(thought).not.toContainText("Long reasoning 79");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
pickUsageHost,
|
||||
usageModalHost,
|
||||
dragUsageCardAbove,
|
||||
recordUsageCardPositionsAfterRelease,
|
||||
expectUsageCardOrder,
|
||||
expectUsageOptionsFitContent,
|
||||
setUsageCardOn,
|
||||
@@ -254,9 +255,15 @@ test.describe("usage modal", () => {
|
||||
await expect(work.getByText("work", { exact: true })).toBeVisible();
|
||||
await expectPinnedUsage(page, ["31% Weekly", "20% Weekly"]);
|
||||
|
||||
const stopRecording = await recordUsageCardPositionsAfterRelease(page);
|
||||
await dragUsageCardAbove(page, "beta:home", "alpha:a");
|
||||
await expectUsageCardOrder(page, ["beta:home", "alpha:a", "beta:work"]);
|
||||
await expectPinnedUsage(page, ["20% Weekly", "31% Weekly"]);
|
||||
// The cards are in their new slots from the first frame after the drop.
|
||||
const framesAfterDrop = await stopRecording();
|
||||
const settled = framesAfterDrop.at(-1);
|
||||
expect(settled).toBeDefined();
|
||||
expect(framesAfterDrop.filter((frame) => frame !== settled)).toEqual([]);
|
||||
|
||||
await page.reload();
|
||||
await expectPinnedUsage(page, ["20% Weekly", "31% Weekly"]);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
context platform=ios timeout=60000 retries=0
|
||||
# Start in an empty, connected composer with the Claude provider selected.
|
||||
# COMMAND_ROW must name an inserting command, not an immediate client action.
|
||||
env COMMAND_ROW="/advisor, Let Claude consult a stronger model at key moments"
|
||||
env COMMAND_TEXT="/advisor "
|
||||
open sh.paseo.debug
|
||||
wait "editable=true" 60000
|
||||
fill "editable=true" "/"
|
||||
wait "label=\"${COMMAND_ROW}\"" 15000
|
||||
press "label=\"${COMMAND_ROW}\""
|
||||
# Do not type or move the caret: replacement itself must close autocomplete.
|
||||
wait absent "label=\"${COMMAND_ROW}\"" 3000
|
||||
wait "editable=true value=\"${COMMAND_TEXT}\"" 3000
|
||||
@@ -1,28 +0,0 @@
|
||||
# Inline tool detail scrolling on iPad
|
||||
|
||||
Use a task-owned iPad Pro 11-inch (M5) simulator in portrait (834×1210 points),
|
||||
with the development bundle already loaded and a connected isolated mock host.
|
||||
Set Chat display to Collapse turns. Prepare a finished turn containing a tool
|
||||
named `scroll_probe`, with unknown detail: `input` is 80 newline-separated
|
||||
`input-line-N` lines (N=0…79), and `output` is `SCROLL_REACHED_OUTPUT`.
|
||||
|
||||
Expand the turn, scroll chat to its start, and expand Scroll probe. The tool's
|
||||
header must be at y=613…644 and its 400-point body below it. The fixed pan path
|
||||
starts inside that body and stays inside it. Output starts below the cap.
|
||||
These are replay preconditions, not asserted pixel sizes. Close other expanded
|
||||
tools so the Scroll probe selector identifies this disclosure.
|
||||
|
||||
Replay `native-inline-tool-scroll.ios.yaml --maestro` on that explicit simulator
|
||||
through the existing mobile runner or agent-device. In Expo's development menu,
|
||||
turn off Tools button, then dismiss the known notification entitlement toast
|
||||
with its bottom-right close button. Their transparent accessibility windows
|
||||
otherwise mark app text covered. Another overlay must fail the visibility
|
||||
assertions. Pass `--platform ios` and the prepared session's `--state-dir` when
|
||||
replaying in that session.
|
||||
|
||||
The flow first asserts Input is visible and the output marker is not. After
|
||||
panning it requires Output and its marker to be visible, then requires closing
|
||||
the tool to hide its contents and reopening it to show Input again. Presence in
|
||||
the accessibility tree is insufficient. A negative control with the passive
|
||||
wrapper retained and only the detail's scrolling disabled must fail at Output
|
||||
visibility. The browser disclosure suite checks tab navigation.
|
||||
@@ -1,28 +0,0 @@
|
||||
appId: sh.paseo.debug
|
||||
---
|
||||
- assertVisible:
|
||||
id: workspace-tabs-scroll
|
||||
- assertVisible: Scroll probe
|
||||
- assertVisible: Input
|
||||
- assertNotVisible: SCROLL_REACHED_OUTPUT
|
||||
- repeat:
|
||||
times: 7
|
||||
commands:
|
||||
- swipe:
|
||||
start: "600,850"
|
||||
end: "600,670"
|
||||
duration: 600
|
||||
- swipe:
|
||||
start: "600,850"
|
||||
end: "600,700"
|
||||
duration: 600
|
||||
- assertVisible: Output
|
||||
- assertVisible: SCROLL_REACHED_OUTPUT
|
||||
- tapOn:
|
||||
point: "600,628"
|
||||
- assertNotVisible: Output
|
||||
- assertNotVisible: Input
|
||||
- tapOn:
|
||||
point: "600,628"
|
||||
- assertVisible: Input
|
||||
- stopApp
|
||||
@@ -111,11 +111,13 @@ try {
|
||||
"android",
|
||||
"--no-test-ime",
|
||||
);
|
||||
ad("wait", "text", "Plugin source", "5000");
|
||||
const plugins = JSON.parse(ad("snapshot", "-i", "--json"));
|
||||
const source = plugins.data.nodes.find((node) => node.type === "android.widget.EditText");
|
||||
assert(source, "The connected host must support installing plugins from source");
|
||||
ad("press", `@${source.ref}`);
|
||||
// The source field lives in the Install from source sheet, behind the page's menu.
|
||||
ad("wait", 'label="More plugin actions"', "5000");
|
||||
ad("press", 'label="More plugin actions"');
|
||||
ad("wait", 'text="Install from source"', "5000");
|
||||
ad("press", 'text="Install from source"');
|
||||
ad("wait", 'id="plugin-source-input"', "5000");
|
||||
ad("press", 'id="plugin-source-input"');
|
||||
ad("type", "qa-source");
|
||||
const pluginMeasurement = await waitForFocusedInput();
|
||||
// A FormTextInput draws its border and padding on the native input's parent.
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Android Usage drop regressions
|
||||
|
||||
Use a device you own and fixed reports from an isolated daemon. Keep recordings in `.artifacts/`. Do not upload recordings containing account details.
|
||||
|
||||
## Same-slot drop
|
||||
|
||||
Open Usage with two cards that fit without scrolling. Run:
|
||||
|
||||
```sh
|
||||
ANDROID_SERIAL=emulator-5580 node packages/app/e2e/mobile/usage-drag/android.mjs
|
||||
```
|
||||
|
||||
Set `PASEO_DEVICE_SESSION` to reuse your Agent Device session. The test holds the first handle, moves within its slot, drops it, then swipes the sheet body. It asserts sheet movement while the finger is still down: after release, the sheet can snap back to its starting position.
|
||||
|
||||
## Old-order frame
|
||||
|
||||
Record a drop with Android `screenrecord`. Include the original order, the held arrangement, the release, and at least five seconds after release. Take a reference screenshot before recording. Use fixed labels that stay visible throughout the drop.
|
||||
|
||||
Create a JSON map of label names to screenshot rectangles `[x, y, width, height]`, in physical pixels. Crop each label tightly, without changing text or the pointer. Install `opencv-python-headless` in a local virtual environment under `.artifacts/`, then run:
|
||||
|
||||
```sh
|
||||
.artifacts/frames-venv/bin/python packages/app/e2e/mobile/usage-drag/frames.py \
|
||||
--reference .artifacts/before.png --labels .artifacts/labels.json \
|
||||
.artifacts/drop-up.mp4 .artifacts/drop-down.mp4
|
||||
```
|
||||
|
||||
The check requires recognized labels in the first and last frames and a changed final order. After every label first reaches its final slot, it fails on any frame showing all original slots again. It prints the offending frame numbers for each recording. This exercises the Android compositor; a JavaScript render test cannot establish which intermediate commit reached the screen.
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
|
||||
// Open Usage with a fixture whose cards fit without scrolling before running.
|
||||
// A same-slot drop must release the list's recognizer so a body swipe moves the sheet.
|
||||
const serial = process.env.ANDROID_SERIAL;
|
||||
assert.ok(serial, "Set ANDROID_SERIAL to the device owned by this test");
|
||||
const session = process.env.PASEO_DEVICE_SESSION ?? "usage-drag-regression";
|
||||
const artifacts = resolve(".artifacts/usage-drag-regression");
|
||||
mkdirSync(artifacts, { recursive: true });
|
||||
function ad(...args) {
|
||||
return execFileSync(
|
||||
"agent-device",
|
||||
[...args, "--platform", "android", "--serial", serial, "--session", session],
|
||||
{ encoding: "utf8", timeout: 30_000 },
|
||||
);
|
||||
}
|
||||
function adb(...args) {
|
||||
return execFileSync("adb", ["-s", serial, ...args], { timeout: 30_000 });
|
||||
}
|
||||
function snapshot() {
|
||||
return JSON.parse(ad("snapshot", "--json")).data.nodes;
|
||||
}
|
||||
function touch(kind, x, y) {
|
||||
adb("shell", "input", "motionevent", kind, String(Math.round(x)), String(Math.round(y)));
|
||||
}
|
||||
try {
|
||||
ad("open", process.env.PASEO_MOBILE_APP_ID ?? "sh.paseo.debug");
|
||||
const nodes = snapshot();
|
||||
writeFileSync(resolve(artifacts, "before.json"), JSON.stringify(nodes));
|
||||
const handle = nodes.find((node) => node.identifier === "usage-card-drag-handle");
|
||||
const list = nodes.find((node) => node.identifier === "usage-list");
|
||||
assert.ok(handle?.rect && list?.rect, "Open Usage with a visible card handle");
|
||||
const x = handle.rect.x + handle.rect.width / 2;
|
||||
const y = handle.rect.y + handle.rect.height / 2;
|
||||
touch("DOWN", x, y);
|
||||
await setTimeout(600);
|
||||
touch("MOVE", x, y + 60);
|
||||
await setTimeout(200);
|
||||
touch("MOVE", x, y + 90);
|
||||
touch("MOVE", x, y + 60);
|
||||
ad("screenshot", resolve(artifacts, "dragging.png"));
|
||||
touch("UP", x, y + 60);
|
||||
await setTimeout(2000);
|
||||
ad("screenshot", resolve(artifacts, "dropped.png"));
|
||||
const swipeX = list.rect.x + list.rect.width / 2;
|
||||
touch("DOWN", swipeX, y + 80);
|
||||
for (let offset = 90; offset <= 500; offset += 10) {
|
||||
touch("MOVE", swipeX, y + offset);
|
||||
}
|
||||
const during = snapshot();
|
||||
writeFileSync(resolve(artifacts, "during.json"), JSON.stringify(during));
|
||||
ad("screenshot", resolve(artifacts, "during.png"));
|
||||
touch("UP", swipeX, y + 500);
|
||||
await setTimeout(1500);
|
||||
const after = snapshot();
|
||||
writeFileSync(resolve(artifacts, "after.json"), JSON.stringify(after));
|
||||
ad("screenshot", resolve(artifacts, "after.png"));
|
||||
const beforeSheet = nodes.find((node) => node.label === "Bottom sheet handle");
|
||||
const afterSheet = during.find((node) => node.label === "Bottom sheet handle");
|
||||
assert.ok(beforeSheet?.rect, "Usage must start as a sheet");
|
||||
assert.ok(
|
||||
!afterSheet || afterSheet.rect.y > beforeSheet.rect.y + 50,
|
||||
"A body swipe after a same-slot drop must move the sheet",
|
||||
);
|
||||
} finally {
|
||||
ad("close");
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Check native drop recordings against visible row labels (requires opencv-python-headless).
|
||||
|
||||
Supply a before screenshot and a JSON map of label names to [x, y, width, height].
|
||||
A recording must start in the original order and end in a different order. Once
|
||||
all labels reach their final slots, any frame returning to the original slots fails.
|
||||
Keep the pointer clear of the labels and use fixed fixture data.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--reference", required=True)
|
||||
parser.add_argument("--labels", required=True)
|
||||
parser.add_argument("--top", type=int, default=0)
|
||||
parser.add_argument("--bottom", type=int)
|
||||
parser.add_argument("videos", nargs="+")
|
||||
args = parser.parse_args()
|
||||
reference = cv2.imread(args.reference, cv2.IMREAD_GRAYSCALE)
|
||||
assert reference is not None, "Cannot read reference image"
|
||||
labels = json.loads(Path(args.labels).read_text())
|
||||
results = []
|
||||
for video in args.videos:
|
||||
capture = cv2.VideoCapture(video)
|
||||
frames = []
|
||||
while True:
|
||||
ok, image = capture.read()
|
||||
if not ok:
|
||||
break
|
||||
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
||||
positions = {}
|
||||
for label, (x, y, width, height) in labels.items():
|
||||
template = reference[y:y + height, x:x + width]
|
||||
matches = cv2.matchTemplate(gray[args.top:args.bottom, x:x + width], template, cv2.TM_CCOEFF_NORMED)
|
||||
_, confidence, _, point = cv2.minMaxLoc(matches)
|
||||
positions[label] = (point[1] + args.top, confidence)
|
||||
frames.append(positions)
|
||||
capture.release()
|
||||
assert frames, f"No frames in {video}"
|
||||
initial, final = frames[0], frames[-1]
|
||||
assert all(confidence >= 0.95 for _, confidence in initial.values()), f"Unrecognized initial labels: {video}"
|
||||
assert all(confidence >= 0.95 for _, confidence in final.values()), f"Unrecognized final labels: {video}"
|
||||
assert sorted(initial, key=lambda key: initial[key][0]) != sorted(final, key=lambda key: final[key][0]), f"Drop did not reorder: {video}"
|
||||
|
||||
def matches_slots(frame, target):
|
||||
return all(confidence >= 0.95 and abs(y - target[label][0]) <= 2
|
||||
for label, (y, confidence) in frame.items())
|
||||
|
||||
reached_final = False
|
||||
wrong = []
|
||||
for number, frame in enumerate(frames, start=1):
|
||||
reached_final = reached_final or matches_slots(frame, final)
|
||||
if reached_final and matches_slots(frame, initial):
|
||||
wrong.append(number)
|
||||
results.append({"recording": video, "oldOrderFrames": wrong, "count": len(wrong)})
|
||||
print(json.dumps(results, indent=2))
|
||||
raise SystemExit(1 if any(result["count"] for result in results) else 0)
|
||||
@@ -0,0 +1,205 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { expect, test as base, type Page } from "../fixtures";
|
||||
import appPackage from "../../../package.json";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { addConnectedHostAndReload } from "./hosts";
|
||||
import { pluginRequirements } from "./plugin-fixture";
|
||||
import {
|
||||
expectToast,
|
||||
openPluginPage,
|
||||
pluginPageName,
|
||||
selectInstalledTab,
|
||||
selectPluginPageAction,
|
||||
} from "./plugin-gallery";
|
||||
import { openHostSection, selectSettingsHost } from "./settings";
|
||||
import { startHostDaemon } from "./versioned-host-daemon";
|
||||
|
||||
/**
|
||||
* Settings → Plugins → Browse against a host whose registry is a local server. The one listing
|
||||
* installs from a local Git repository, so installing needs no network.
|
||||
*/
|
||||
|
||||
export const LISTING = {
|
||||
id: "acme/directory-example",
|
||||
name: "Directory example",
|
||||
description: "Adds a review checklist to every workspace",
|
||||
overview: "Directory example keeps a checklist beside your agents.",
|
||||
};
|
||||
|
||||
export const test = base.extend<{ directoryHost: { serverId: string; port: number } }>({
|
||||
directoryHost: async ({ e2eWorker }, provide) => {
|
||||
void e2eWorker;
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-plugin-directory-"));
|
||||
const registry = await startRegistry(await createListedPlugin(root));
|
||||
try {
|
||||
const host = await startHostDaemon({
|
||||
version: appPackage.version,
|
||||
pluginRegistryUrl: registry.url,
|
||||
});
|
||||
try {
|
||||
await provide({ serverId: host.serverId, port: host.port });
|
||||
} finally {
|
||||
await host.dispose();
|
||||
}
|
||||
} finally {
|
||||
await registry.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function createListedPlugin(root: string) {
|
||||
const repository = path.join(root, "repository");
|
||||
execFileSync("git", ["init", "-b", "main", repository], { stdio: "ignore" });
|
||||
await writeFile(
|
||||
path.join(repository, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "directory-example",
|
||||
name: LISTING.name,
|
||||
requirements: pluginRequirements,
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(repository, "index.client.ts"),
|
||||
"export default function () { return () => {}; }",
|
||||
);
|
||||
const git = (...args: string[]) =>
|
||||
execFileSync(
|
||||
"git",
|
||||
["-c", "user.name=Paseo Tests", "-c", "user.email=e2e@paseo.test", ...args],
|
||||
{
|
||||
cwd: repository,
|
||||
encoding: "utf8",
|
||||
},
|
||||
).trim();
|
||||
git("add", "-A");
|
||||
git("commit", "-m", "initial");
|
||||
const date = "2026-10-03T00:00:00.000Z";
|
||||
return {
|
||||
id: LISTING.id,
|
||||
name: LISTING.name,
|
||||
description: LISTING.description,
|
||||
categories: ["tools"],
|
||||
author: { github: "acme", name: "Acme" },
|
||||
repository: { url: "https://github.com/acme/directory-example" },
|
||||
artifact: {
|
||||
kind: "git",
|
||||
remote: pathToFileURL(repository).href,
|
||||
commit: git("rev-parse", "HEAD"),
|
||||
},
|
||||
media: [],
|
||||
submittedAt: date,
|
||||
reviewedAt: date,
|
||||
updatedAt: date,
|
||||
publishedAt: date,
|
||||
installs: 1200,
|
||||
};
|
||||
}
|
||||
|
||||
async function startRegistry(listing: Record<string, unknown>) {
|
||||
const documents: Record<string, unknown> = {
|
||||
"/index.json": {
|
||||
schemaVersion: 1,
|
||||
registry: { name: "Local", url: "http://127.0.0.1" },
|
||||
categories: [{ slug: "tools", label: "Tools" }],
|
||||
plugins: [listing],
|
||||
featured: [],
|
||||
generatedAt: "2026-10-03T00:00:00.000Z",
|
||||
},
|
||||
[`/plugins/${LISTING.id}.json`]: {
|
||||
...listing,
|
||||
readme: `# ${LISTING.name}\n\n${LISTING.overview}`,
|
||||
},
|
||||
};
|
||||
const server = createServer((request, response) => {
|
||||
const document = request.url ? documents[request.url] : undefined;
|
||||
if (!document) {
|
||||
response.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
response.end(JSON.stringify(document));
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Missing registry address");
|
||||
return {
|
||||
url: `http://127.0.0.1:${address.port}`,
|
||||
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
|
||||
};
|
||||
}
|
||||
|
||||
function listingRow(page: Page) {
|
||||
return page.getByTestId(`plugin-listing-${LISTING.id}`).filter({ visible: true });
|
||||
}
|
||||
|
||||
function browseTab(page: Page) {
|
||||
return page.getByRole("button", { name: "Browse", exact: true });
|
||||
}
|
||||
|
||||
/** A host with no plugins opens Plugins on Browse. */
|
||||
export async function openDirectory(page: Page, host: { serverId: string; port: number }) {
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostAndReload(page, { ...host, label: "Plugin directory" });
|
||||
await openSettings(page);
|
||||
await selectSettingsHost(page, host.serverId);
|
||||
await openHostSection(page, host.serverId, "plugins");
|
||||
await expect(browseTab(page)).toHaveAttribute("aria-selected", "true");
|
||||
await expect(listingRow(page)).toContainText(LISTING.description);
|
||||
}
|
||||
|
||||
/** Install asks first, naming the author and version, then the row offers Uninstall. */
|
||||
export async function installListingFromRow(page: Page) {
|
||||
await listingRow(page).getByTestId(`plugin-install-${LISTING.id}`).click();
|
||||
const sheet = page.getByTestId("plugin-install-sheet");
|
||||
await expect(sheet).toContainText("Acme");
|
||||
await sheet.getByTestId("plugin-install-confirm").click();
|
||||
await expect(listingRow(page).getByTestId(`plugin-uninstall-${LISTING.id}`)).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Installing the first plugin keeps the user on Browse. */
|
||||
export async function expectStillBrowsing(page: Page) {
|
||||
await expect(browseTab(page)).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.getByRole("button", { name: /^Installed · 1/ })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function openListingPage(page: Page) {
|
||||
await listingRow(page).getByText(LISTING.name, { exact: true }).click();
|
||||
await expect(pluginPageName(page)).toHaveText(LISTING.name);
|
||||
await expect(page.getByText(LISTING.overview, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstalling keeps the plugin's page open on its listing, back on Install, whether the page was
|
||||
* opened from Browse or from the Installed tab.
|
||||
*/
|
||||
export async function uninstallFromPage(page: Page) {
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await selectPluginPageAction(page, LISTING.name, "Uninstall");
|
||||
await expectToast(page, `Uninstalled ${LISTING.name}`);
|
||||
await expect(pluginPageName(page)).toHaveText(LISTING.name);
|
||||
await expect(
|
||||
page.getByTestId(`plugin-install-${LISTING.id}`).filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function installFromPage(page: Page) {
|
||||
await page.getByTestId(`plugin-install-${LISTING.id}`).filter({ visible: true }).click();
|
||||
await page.getByTestId("plugin-install-confirm").click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: `Actions for ${LISTING.name}`, exact: true }),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
}
|
||||
|
||||
export async function openFromInstalledTab(page: Page) {
|
||||
await page.getByRole("button", { name: "Back to plugins", exact: true }).click();
|
||||
await selectInstalledTab(page);
|
||||
await openPluginPage(page, LISTING.name, "directory-example");
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
|
||||
import { expect, type Page } from "../fixtures";
|
||||
import { getServerId } from "./server-id";
|
||||
import { expectAppRoute } from "./route-assertions";
|
||||
|
||||
/**
|
||||
* Settings → Host → Plugins: the Installed and Browse tabs, the page menu, the install sheet and a
|
||||
* plugin's own page. Helpers that take a plugin name expect the manifest name, which is the plugin
|
||||
* ID when the manifest has none.
|
||||
*/
|
||||
|
||||
export function pluginsRoute(serverId = getServerId()): string {
|
||||
return buildSettingsHostSectionRoute(serverId, "plugins");
|
||||
}
|
||||
|
||||
export function pluginPageRoute(pluginId: string, serverId = getServerId()): string {
|
||||
return `${pluginsRoute(serverId)}/${encodeURIComponent(pluginId)}`;
|
||||
}
|
||||
|
||||
/** Settings keeps the Plugins page mounted beneath a plugin's page, so read visible rows only. */
|
||||
export function pluginRow(page: Page, pluginId: string) {
|
||||
return page.getByTestId(`plugin-row-${pluginId}`).filter({ visible: true });
|
||||
}
|
||||
|
||||
function pluginSwitch(page: Page, name: string, action: "Disable" | "Enable") {
|
||||
return page.getByRole("switch", { name: `${name}: ${action}`, exact: true }).filter({
|
||||
visible: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectToast(page: Page, message: string): Promise<void> {
|
||||
await expect(page.getByTestId("app-toast-message")).toContainText(message);
|
||||
}
|
||||
|
||||
// Tabs and search
|
||||
|
||||
export async function selectInstalledTab(page: Page): Promise<void> {
|
||||
const tab = page.getByRole("button", { name: /^Installed/ });
|
||||
await tab.click();
|
||||
await expect(tab).toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
|
||||
export async function searchInstalled(page: Page, query: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Search installed", exact: true }).fill(query);
|
||||
}
|
||||
|
||||
export async function clearSearch(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Clear search", exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "Search installed", exact: true })).toHaveValue(
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
// Installed rows
|
||||
|
||||
export async function expectPluginOn(page: Page, name: string): Promise<void> {
|
||||
await expect(pluginSwitch(page, name, "Disable")).toBeVisible();
|
||||
await expect(pluginSwitch(page, name, "Disable")).toBeEnabled();
|
||||
}
|
||||
|
||||
export async function expectPluginOff(page: Page, name: string): Promise<void> {
|
||||
await expect(pluginSwitch(page, name, "Enable")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function turnPluginOff(page: Page, name: string): Promise<void> {
|
||||
await pluginSwitch(page, name, "Disable").click();
|
||||
await expectPluginOff(page, name);
|
||||
}
|
||||
|
||||
export async function turnPluginOn(page: Page, name: string): Promise<void> {
|
||||
await pluginSwitch(page, name, "Enable").click();
|
||||
await expectPluginOn(page, name);
|
||||
}
|
||||
|
||||
/** The switch stays disabled while an action on the plugin runs, so a second one cannot start. */
|
||||
export async function expectPluginActionPending(page: Page, name: string): Promise<void> {
|
||||
await expect(pluginSwitch(page, name, "Disable")).toBeDisabled();
|
||||
}
|
||||
|
||||
export async function expectPluginFailed(page: Page, pluginId: string): Promise<void> {
|
||||
await expect(pluginRow(page, pluginId).getByText("failed", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectPluginHealthy(page: Page, pluginId: string): Promise<void> {
|
||||
await expect(pluginRow(page, pluginId)).toBeVisible();
|
||||
await expect(pluginRow(page, pluginId).getByText("failed", { exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectPluginRemoved(page: Page, pluginId: string): Promise<void> {
|
||||
await expect(page.getByTestId(`plugin-row-${pluginId}`)).toHaveCount(0);
|
||||
}
|
||||
|
||||
// Plugins on the host
|
||||
|
||||
function pluginsOffAlert(page: Page) {
|
||||
return page.getByRole("alert").filter({ hasText: "Plugins are off on" });
|
||||
}
|
||||
|
||||
export async function expectPluginsOff(page: Page): Promise<void> {
|
||||
await expect(pluginsOffAlert(page)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectPluginsOn(page: Page): Promise<void> {
|
||||
await expect(pluginsOffAlert(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function turnOnPluginsFromAlert(page: Page): Promise<void> {
|
||||
await pluginsOffAlert(page).getByRole("button", { name: "Turn on", exact: true }).click();
|
||||
await expectPluginsOn(page);
|
||||
}
|
||||
|
||||
// The page menu
|
||||
|
||||
function galleryMenu(page: Page) {
|
||||
return page.getByRole("button", { name: "More plugin actions", exact: true }).filter({
|
||||
visible: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function openGalleryMenu(page: Page): Promise<void> {
|
||||
await galleryMenu(page).click();
|
||||
await expect(page.getByRole("menuitem").first()).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectGalleryMenuWithoutSourceInstall(page: Page): Promise<void> {
|
||||
await openGalleryMenu(page);
|
||||
await expect(page.getByRole("menuitem", { name: "Turn off plugins", exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Install from source", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
export async function turnOffPluginsFromMenu(page: Page): Promise<void> {
|
||||
await openGalleryMenu(page);
|
||||
await page.getByRole("menuitem", { name: "Turn off plugins", exact: true }).click();
|
||||
await expectToast(page, "Plugins disabled");
|
||||
await expectPluginsOff(page);
|
||||
}
|
||||
|
||||
export async function turnOnPluginsFromMenu(page: Page): Promise<void> {
|
||||
await openGalleryMenu(page);
|
||||
await page.getByRole("menuitem", { name: "Turn on plugins", exact: true }).click();
|
||||
await expectToast(page, "Plugins enabled");
|
||||
await expectPluginsOn(page);
|
||||
}
|
||||
|
||||
// Install from source
|
||||
|
||||
function sourceField(page: Page) {
|
||||
return page.getByRole("textbox", { name: "Plugin source", exact: true });
|
||||
}
|
||||
|
||||
export async function openSourceInstall(page: Page): Promise<void> {
|
||||
await openGalleryMenu(page);
|
||||
await page.getByRole("menuitem", { name: "Install from source", exact: true }).click();
|
||||
await expect(sourceField(page)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectSourceInstallTurnsPluginsOn(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByText("Plugins are not sandboxed. Installing also turns plugins on.", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function submitSourceInstall(page: Page, source: string): Promise<void> {
|
||||
await sourceField(page).fill(source);
|
||||
await page.getByRole("button", { name: "Install", exact: true }).click();
|
||||
}
|
||||
|
||||
/** A failed install keeps the sheet open with the source, so the user can correct it. */
|
||||
export async function expectSourceInstallFailed(
|
||||
page: Page,
|
||||
source: string,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
await expect(page.getByRole("alert").filter({ hasText: message })).toBeVisible();
|
||||
await expect(sourceField(page)).toHaveValue(source);
|
||||
}
|
||||
|
||||
export async function expectSourceInstalled(page: Page, name: string): Promise<void> {
|
||||
await expectToast(page, `Installed ${name}`);
|
||||
await expect(sourceField(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function installFromSource(page: Page, source: string, name: string): Promise<void> {
|
||||
await openSourceInstall(page);
|
||||
await submitSourceInstall(page, source);
|
||||
await expectSourceInstalled(page, name);
|
||||
}
|
||||
|
||||
// Updates
|
||||
|
||||
function updateRow(page: Page, pluginId: string) {
|
||||
return page.getByTestId(`plugin-update-${pluginId}`).filter({ visible: true });
|
||||
}
|
||||
|
||||
export async function updatePlugin(page: Page, pluginId: string): Promise<void> {
|
||||
await updateRow(page, pluginId).getByRole("button", { name: "Update", exact: true }).click();
|
||||
await expectToast(page, `${pluginId} updated`);
|
||||
await expect(page.getByTestId(`plugin-update-${pluginId}`)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectUpdateAvailable(page: Page, pluginId: string): Promise<void> {
|
||||
await expect(updateRow(page, pluginId)).toBeVisible();
|
||||
}
|
||||
|
||||
// A plugin's page
|
||||
|
||||
export function pluginPageName(page: Page) {
|
||||
return page.getByTestId("plugin-page-name").filter({ visible: true });
|
||||
}
|
||||
|
||||
/** Opens the plugin's page from its row on the Installed tab. */
|
||||
export async function openPluginPage(page: Page, name: string, pluginId = name): Promise<void> {
|
||||
await pluginRow(page, pluginId).getByText(name, { exact: true }).click();
|
||||
await expect(pluginPageName(page)).toHaveText(name);
|
||||
}
|
||||
|
||||
export async function backToPlugins(page: Page, pluginId: string): Promise<void> {
|
||||
await page.getByRole("button", { name: "Back to plugins", exact: true }).click();
|
||||
await expect(pluginRow(page, pluginId)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function openPluginPageActions(page: Page, name: string): Promise<void> {
|
||||
await page
|
||||
.getByRole("button", { name: `Actions for ${name}`, exact: true })
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await expect(page.getByRole("menuitem").first()).toBeVisible();
|
||||
}
|
||||
|
||||
export async function selectPluginPageAction(
|
||||
page: Page,
|
||||
name: string,
|
||||
action: "Reload" | "Logs" | "Uninstall",
|
||||
): Promise<void> {
|
||||
await openPluginPageActions(page, name);
|
||||
await page.getByRole("menuitem", { name: action, exact: true }).click();
|
||||
}
|
||||
|
||||
export async function reloadPluginFromPage(page: Page, name: string): Promise<void> {
|
||||
await selectPluginPageAction(page, name, "Reload");
|
||||
await expectToast(page, `Reloaded ${name}`);
|
||||
}
|
||||
|
||||
export async function openPluginLogs(page: Page, name: string): Promise<void> {
|
||||
await selectPluginPageAction(page, name, "Logs");
|
||||
await expect(page.getByRole("dialog")).toContainText(`Logs: ${name}`);
|
||||
}
|
||||
|
||||
export async function closePluginLogs(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Uninstall confirms first; uninstalling a plugin that is not in the directory returns to Plugins. */
|
||||
export async function removePluginFromPage(page: Page, name: string): Promise<void> {
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await selectPluginPageAction(page, name, "Uninstall");
|
||||
await expectToast(page, `Uninstalled ${name}`);
|
||||
await expect(pluginPageName(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectPluginsRoute(page: Page): Promise<void> {
|
||||
await expectAppRoute(page, pluginsRoute());
|
||||
}
|
||||
@@ -9,6 +9,15 @@ import { addConnectedHostAndReload } from "./hosts";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { openHostSection, selectSettingsHost } from "./settings";
|
||||
import { pluginRequirements } from "./plugin-fixture";
|
||||
import {
|
||||
backToPlugins,
|
||||
expectPluginFailed,
|
||||
expectPluginHealthy,
|
||||
openPluginPage,
|
||||
selectPluginPageAction,
|
||||
} from "./plugin-gallery";
|
||||
|
||||
const PLUGIN_ID = "requirements-example";
|
||||
|
||||
export const test = base.extend<{
|
||||
requirementHost: { serverId: string; port: number; directory: string; client: DaemonClient };
|
||||
@@ -80,7 +89,7 @@ export const test = base.extend<{
|
||||
async function writeRequirements(directory: string, paseo: string) {
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "requirements-example", requirements: { paseo } }),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: { paseo } }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,7 +102,7 @@ export async function openRequirementHost(page: Page, host: { serverId: string;
|
||||
}
|
||||
|
||||
export async function expectAppMismatch(page: Page) {
|
||||
await expect(page.getByLabel("requirements-example failed", { exact: true })).toBeVisible();
|
||||
await expectPluginFailed(page, PLUGIN_ID);
|
||||
await expect(
|
||||
page.getByText(/Plugin "requirements-example" requires Paseo \^99.0.0. Your app is/),
|
||||
).toBeVisible();
|
||||
@@ -102,18 +111,21 @@ export async function expectAppMismatch(page: Page) {
|
||||
export async function correctRequirementAndReload(page: Page, directory: string) {
|
||||
await writeRequirements(directory, pluginRequirements.paseo);
|
||||
await reloadRequirementsPlugin(page);
|
||||
await expect(page.getByLabel("requirements-example running", { exact: true })).toBeVisible();
|
||||
await backToPlugins(page, PLUGIN_ID);
|
||||
await expectPluginHealthy(page, PLUGIN_ID);
|
||||
await expect(page.getByText(/Your app is/)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function rejectDaemonMismatch(page: Page, directory: string) {
|
||||
await writeRequirements(directory, ">=100.0.0");
|
||||
await reloadRequirementsPlugin(page);
|
||||
await expect(page.getByLabel("requirements-example failed", { exact: true })).toBeVisible();
|
||||
await backToPlugins(page, PLUGIN_ID);
|
||||
await expectPluginFailed(page, PLUGIN_ID);
|
||||
await expect(page.getByText(/Your daemon is 99.0.0/).first()).toBeVisible();
|
||||
}
|
||||
|
||||
/** Reload lives on the plugin's page. */
|
||||
async function reloadRequirementsPlugin(page: Page) {
|
||||
await page.getByRole("button", { name: "Actions for requirements-example", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Reload", exact: true }).click();
|
||||
await openPluginPage(page, PLUGIN_ID);
|
||||
await selectPluginPageAction(page, PLUGIN_ID, "Reload");
|
||||
}
|
||||
|
||||
@@ -7,8 +7,18 @@ import { gotoAppShell, openSettings } from "./app";
|
||||
import { goBackInSettings, openCompactSettings, openHostSection } from "./settings";
|
||||
import { getServerId } from "./server-id";
|
||||
import { expectAppRoute } from "./route-assertions";
|
||||
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import {
|
||||
backToPlugins,
|
||||
openPluginPage,
|
||||
openPluginPageActions,
|
||||
pluginPageName,
|
||||
pluginPageRoute,
|
||||
pluginRow,
|
||||
pluginsRoute,
|
||||
turnPluginOff,
|
||||
turnPluginOn,
|
||||
} from "./plugin-gallery";
|
||||
|
||||
type SettingsClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
|
||||
@@ -90,68 +100,64 @@ export const test = base.extend<{ settingsPlugin: SettingsClient }>({
|
||||
export async function openDisplaySettings(page: Page) {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openPluginScreen(page);
|
||||
await openSettingsExamplePage(page);
|
||||
}
|
||||
export async function openCompactDisplaySettings(page: Page) {
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page, new URL(page.url()).pathname);
|
||||
await openPluginScreen(page);
|
||||
await openSettingsExamplePage(page);
|
||||
}
|
||||
async function openPluginScreen(page: Page) {
|
||||
async function openSettingsExamplePage(page: Page) {
|
||||
await openHostSection(page, getServerId(), "plugins");
|
||||
await openSettingsFromCog(page);
|
||||
await openPluginPage(page, "settings-example");
|
||||
await expectSettingsExampleScreen(page);
|
||||
}
|
||||
/** The cog on a plugin's row. Its label is "Settings"; the row tells plugins apart. */
|
||||
function settingsButton(page: Page, pluginId: string) {
|
||||
return page
|
||||
.getByTestId(`plugin-row-${pluginId}`)
|
||||
.filter({ visible: true })
|
||||
.getByRole("button", { name: "Settings", exact: true });
|
||||
}
|
||||
async function openSettingsFromCog(page: Page) {
|
||||
await settingsButton(page, "settings-example").click();
|
||||
/** A plugin's page shows its settings inline. With one screen there are no tabs. */
|
||||
async function expectSettingsExampleScreen(page: Page) {
|
||||
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
|
||||
// A plugin with one settings screen shows no tabs, and the header names only the plugin.
|
||||
await expect(page.getByTestId("plugin-settings-tabs")).toHaveCount(0);
|
||||
await expectSettingsHeader(page, "settings-example");
|
||||
await expectAppRoute(page, pluginPageRoute("settings-example"));
|
||||
}
|
||||
export async function openPluginsPage(page: Page) {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openHostSection(page, getServerId(), "plugins");
|
||||
}
|
||||
/** The page's menu holds plugin actions only; its settings screens are on the page itself. */
|
||||
export async function expectActionsMenuWithoutSettings(page: Page) {
|
||||
await page.getByRole("button", { name: "Actions for settings-example", exact: true }).click();
|
||||
await openPluginPageActions(page, TABBED_PLUGIN_NAME);
|
||||
await expect(page.getByRole("menuitem", { name: "Reload", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: "Display", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("menuitem", { name: "General", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("menuitem", { name: "Advanced", exact: true })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
export async function expectNoSettingsCogWithoutScreens(page: Page) {
|
||||
await page.getByRole("switch", { name: "settings-example: Disable" }).click();
|
||||
await expect(page.getByRole("switch", { name: "settings-example: Enable" })).toBeVisible();
|
||||
await expect(settingsButton(page, "settings-example")).toHaveCount(0);
|
||||
await page.getByRole("switch", { name: "settings-example: Enable" }).click();
|
||||
await expect(settingsButton(page, "settings-example")).toBeVisible();
|
||||
/** Turning a plugin off unloads its settings screens, so its page no longer shows them. */
|
||||
export async function expectSettingsFollowPluginSwitch(page: Page) {
|
||||
await openPluginPage(page, "settings-example");
|
||||
await turnPluginOff(page, "settings-example");
|
||||
await expectSettingsHidden(page);
|
||||
await turnPluginOn(page, "settings-example");
|
||||
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
|
||||
}
|
||||
export async function expectSettingsTooltip(page: Page) {
|
||||
await settingsButton(page, "settings-example").hover();
|
||||
await expect(page.getByRole("tooltip")).toHaveText("Settings");
|
||||
await page.mouse.move(0, 0);
|
||||
export async function expectSettingsHidden(page: Page) {
|
||||
await expect(page.getByRole("switch", { name: "Show metadata" })).toHaveCount(0);
|
||||
}
|
||||
/** A manifest `name` titles the row and its controls; the id stays visible beneath it. */
|
||||
/** A manifest `name` titles the row and its switch, and opens the plugin's page. */
|
||||
export async function expectNamedPluginRow(page: Page) {
|
||||
const row = page.getByTestId(`plugin-row-${TABBED_PLUGIN_ID}`).filter({ visible: true });
|
||||
const row = pluginRow(page, TABBED_PLUGIN_ID);
|
||||
await expect(row.getByText(TABBED_PLUGIN_NAME, { exact: true })).toBeVisible();
|
||||
await expect(row.getByText(TABBED_PLUGIN_ID, { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("switch", { name: `${TABBED_PLUGIN_NAME}: Disable`, exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: `Actions for ${TABBED_PLUGIN_NAME}`, exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(row).toHaveAccessibleName(TABBED_PLUGIN_NAME);
|
||||
}
|
||||
/** The page names the plugin by its manifest name, with the ID beneath it. */
|
||||
export async function openTabbedSettings(page: Page) {
|
||||
await settingsButton(page, TABBED_PLUGIN_ID).click();
|
||||
await openPluginPage(page, TABBED_PLUGIN_NAME, TABBED_PLUGIN_ID);
|
||||
await expect(
|
||||
page.getByText(TABBED_PLUGIN_ID, { exact: true }).filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
await expectAppRoute(page, pluginPageRoute(TABBED_PLUGIN_ID));
|
||||
await expectSettingsTab(page, "General");
|
||||
// The Advanced icon reads the plugin's translations; the General icon throws and is dropped.
|
||||
await expect(page.getByTestId("advanced-tab-icon")).toBeVisible();
|
||||
@@ -159,27 +165,25 @@ export async function openTabbedSettings(page: Page) {
|
||||
function settingsTab(page: Page, title: string) {
|
||||
return page.getByTestId("plugin-settings-tabs").getByRole("button", { name: title, exact: true });
|
||||
}
|
||||
/** Tabs swap the screen in place on the plugin's page, so Back still returns to Plugins. */
|
||||
export async function selectSettingsTab(page: Page, title: "General" | "Advanced") {
|
||||
await settingsTab(page, title).click();
|
||||
await expectSettingsTab(page, title);
|
||||
await expectAppRoute(
|
||||
page,
|
||||
`${pluginPageRoute(TABBED_PLUGIN_ID)}?screenId=${title.toLowerCase()}`,
|
||||
);
|
||||
}
|
||||
export async function expectSettingsTab(page: Page, title: "General" | "Advanced") {
|
||||
const other = title === "General" ? "Advanced" : "General";
|
||||
await expect(settingsTab(page, title)).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.getByText(`${title} settings body`, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(`${other} settings body`, { exact: true })).toHaveCount(0);
|
||||
// The header names the plugin by its manifest name; the tab names the screen.
|
||||
await expectSettingsHeader(page, TABBED_PLUGIN_NAME);
|
||||
await expectAppRoute(
|
||||
page,
|
||||
`${buildSettingsHostSectionRoute(getServerId(), "plugins")}/${TABBED_PLUGIN_ID}/${title.toLowerCase()}`,
|
||||
);
|
||||
await expect(pluginPageName(page)).toHaveText(TABBED_PLUGIN_NAME);
|
||||
}
|
||||
export async function openTabbedSettingsLink(page: Page, title: "General" | "Advanced") {
|
||||
await gotoAppShell(page);
|
||||
await page.goto(
|
||||
`${buildSettingsHostSectionRoute(getServerId(), "plugins")}/${TABBED_PLUGIN_ID}/${title.toLowerCase()}`,
|
||||
);
|
||||
await page.goto(`${pluginPageRoute(TABBED_PLUGIN_ID)}/${title.toLowerCase()}`);
|
||||
await expectSettingsTab(page, title);
|
||||
}
|
||||
export async function groupByWorkspace(page: Page) {
|
||||
@@ -235,18 +239,13 @@ export async function discardTitle(page: Page) {
|
||||
}
|
||||
export async function returnAndReopenSettings(page: Page) {
|
||||
await goBackInSettings(page);
|
||||
await openSettingsFromCog(page);
|
||||
await expectAppRoute(page, pluginsRoute());
|
||||
await openPluginPage(page, "settings-example");
|
||||
await expectSettingsExampleScreen(page);
|
||||
}
|
||||
export async function returnToPluginsPage(page: Page) {
|
||||
await page.getByRole("button", { name: "Back to plugins", exact: true }).click();
|
||||
await expectAppRoute(page, buildSettingsHostSectionRoute(getServerId(), "plugins"));
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Actions for settings-example", exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
async function expectSettingsHeader(page: Page, title: string) {
|
||||
// Settings keeps the Plugins page mounted beneath, so read the visible title only.
|
||||
await expect(page.getByText(title, { exact: true }).filter({ visible: true })).toHaveCount(1);
|
||||
await backToPlugins(page, "settings-example");
|
||||
await expectAppRoute(page, pluginsRoute());
|
||||
}
|
||||
export async function expectSettingsUnavailable(page: Page) {
|
||||
await expect(
|
||||
|
||||
@@ -187,6 +187,44 @@ export async function dragUsageCardAbove(
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts recording where the cards are painted on every frame after the next mouse release.
|
||||
* Call the returned function to stop and read the frames, each as "id@top" for every card.
|
||||
*/
|
||||
export async function recordUsageCardPositionsAfterRelease(
|
||||
page: Page,
|
||||
): Promise<() => Promise<string[]>> {
|
||||
const recording = await usageModal(page).evaluateHandle((modal) => {
|
||||
const state = { released: false, stopped: false, frames: [] as string[] };
|
||||
const markReleased = () => {
|
||||
state.released = true;
|
||||
};
|
||||
window.addEventListener("mouseup", markReleased, { capture: true, once: true });
|
||||
const recordFrame = () => {
|
||||
if (state.stopped) return;
|
||||
if (state.released) {
|
||||
const cards = Array.from(modal.querySelectorAll('[data-testid^="usage-report-"]'));
|
||||
state.frames.push(
|
||||
cards
|
||||
.map((card) => {
|
||||
const id = card.getAttribute("data-testid")?.slice("usage-report-".length);
|
||||
return `${id}@${Math.round(card.getBoundingClientRect().top)}`;
|
||||
})
|
||||
.join(" "),
|
||||
);
|
||||
}
|
||||
requestAnimationFrame(recordFrame);
|
||||
};
|
||||
requestAnimationFrame(recordFrame);
|
||||
return state;
|
||||
});
|
||||
return () =>
|
||||
recording.evaluate((state) => {
|
||||
state.stopped = true;
|
||||
return state.frames;
|
||||
});
|
||||
}
|
||||
|
||||
/** Each pinned window as it reads: its percent and short label, "31% 5h". */
|
||||
export async function expectPinnedUsage(page: Page, windows: string[]): Promise<void> {
|
||||
const pinned = usageItem(page).getByTestId("sidebar-usage-pinned-window");
|
||||
|
||||
@@ -62,6 +62,38 @@ export async function startRestartableHostDaemon(version: string): Promise<Resta
|
||||
};
|
||||
}
|
||||
|
||||
export interface HostDaemon {
|
||||
serverId: string;
|
||||
port: number;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
/** A host daemon of its own, for tests that need host configuration the worker daemon lacks. */
|
||||
export async function startHostDaemon(options: {
|
||||
version: string;
|
||||
pluginRegistryUrl?: string;
|
||||
}): Promise<HostDaemon> {
|
||||
const paseoHomeRoot = await mkdtemp(path.join(tmpdir(), "paseo-e2e-host-"));
|
||||
try {
|
||||
const daemon = await startVersionedHostDaemon({
|
||||
...options,
|
||||
port: await getAvailableHostDaemonPort(),
|
||||
paseoHomeRoot,
|
||||
});
|
||||
return {
|
||||
serverId: daemon.serverId,
|
||||
port: daemon.port,
|
||||
async dispose() {
|
||||
await daemon.stop();
|
||||
await rm(paseoHomeRoot, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await rm(paseoHomeRoot, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
interface VersionedHostDaemon {
|
||||
serverId: string;
|
||||
port: number;
|
||||
@@ -73,6 +105,7 @@ interface VersionedHostDaemonOptions {
|
||||
version: string;
|
||||
port: number;
|
||||
paseoHomeRoot: string;
|
||||
pluginRegistryUrl?: string;
|
||||
}
|
||||
|
||||
// Starts the versioned test daemon on an explicit port with a caller-owned PASEO_HOME. The caller
|
||||
@@ -83,7 +116,12 @@ async function startVersionedHostDaemon(
|
||||
const child = spawnTsx(
|
||||
VERSIONED_DAEMON_ENTRYPOINT,
|
||||
[options.version, String(options.port), options.paseoHomeRoot],
|
||||
{ stdio: ["ignore", "pipe", "pipe", "ipc"] },
|
||||
{
|
||||
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
||||
env: options.pluginRegistryUrl
|
||||
? { ...process.env, PASEO_PLUGIN_REGISTRY: options.pluginRegistryUrl }
|
||||
: process.env,
|
||||
},
|
||||
);
|
||||
const ready = Promise.withResolvers<{ port: number; serverId: string }>();
|
||||
let output = "";
|
||||
|
||||
@@ -91,10 +91,13 @@ async function selectWorkspaceTab(tab: Locator): Promise<void> {
|
||||
export async function ensureExplorerSidebar(page: Page): Promise<Locator> {
|
||||
const toggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
await expect(toggle).toBeVisible({ timeout: 30_000 });
|
||||
const explorer = explorerSidebar(page);
|
||||
if ((await explorer.count()) === 0) {
|
||||
// Decide from the toggle, which reports the open state. The sidebar's own visibility lags it:
|
||||
// a compact layout draws its closed Explorer for a frame before hiding it.
|
||||
if ((await toggle.getAttribute("aria-expanded")) !== "true") {
|
||||
await toggle.click();
|
||||
}
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
const explorer = explorerSidebar(page);
|
||||
await expect(explorer).toBeVisible({ timeout: 30_000 });
|
||||
return explorer;
|
||||
}
|
||||
|
||||
@@ -394,6 +394,56 @@ describe("bottom anchor controller driver", () => {
|
||||
expect(harness.scrollAttempts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("honors a submit made during a gesture when that gesture ends away", () => {
|
||||
const harness = createDriverHarness({ isNearBottom: false });
|
||||
harness.driver.beginUserScroll();
|
||||
harness.driver.requestLocalAnchor({ agentId: "agent-1", reason: "message-sent" });
|
||||
harness.scheduler.flushAll();
|
||||
expect(harness.scrollAttempts).toEqual([]);
|
||||
|
||||
harness.driver.endUserScroll({ isAtEnd: false });
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.context.measurementState.offsetY).toBe(
|
||||
harness.context.measurementState.contentHeight -
|
||||
harness.context.measurementState.viewportHeight,
|
||||
);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "sticky-bottom",
|
||||
pendingRequest: null,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["rewind", "jump-to-bottom"] as const)(
|
||||
"lets scrolling away supersede a late %s request",
|
||||
(reason) => {
|
||||
const harness = createDriverHarness({ isNearBottom: false });
|
||||
harness.driver.beginUserScroll();
|
||||
harness.driver.requestLocalAnchor({ agentId: "agent-1", reason });
|
||||
harness.driver.endUserScroll({ isAtEnd: false });
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollAttempts).toEqual([]);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "detached",
|
||||
pendingRequest: null,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("lets a new gesture supersede a submit requested during the previous gesture", () => {
|
||||
const harness = createDriverHarness({ isNearBottom: false });
|
||||
harness.driver.beginUserScroll();
|
||||
harness.driver.requestLocalAnchor({ agentId: "agent-1", reason: "message-sent" });
|
||||
harness.driver.endUserScroll({ isAtEnd: false });
|
||||
harness.driver.beginUserScroll();
|
||||
harness.driver.endUserScroll({ isAtEnd: false });
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollAttempts).toEqual([]);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({ mode: "detached", pendingRequest: null });
|
||||
});
|
||||
|
||||
it("resumes following when a user scroll ends at the end", () => {
|
||||
const harness = createDriverHarness({
|
||||
transportBehavior: {
|
||||
|
||||
@@ -499,11 +499,16 @@ function createBottomAnchorControllerDriver(
|
||||
cancelPendingAttempt();
|
||||
setModeInternal("detached");
|
||||
},
|
||||
// Following resumes only for a gesture that ends at the end. Resuming from short of it would
|
||||
// scroll the rows the finger just let go of, so a release above the end, or with output that
|
||||
// arrived below it during the gesture, stays detached with the pill.
|
||||
// A gesture ending short stays detached unless a newer prompt submission requested the end.
|
||||
// Passive output must not move the rows the finger just let go of.
|
||||
endUserScroll(params) {
|
||||
isUserScrollActive = false;
|
||||
// Submit arrives with the user's action; rewind and tail jumps can arrive after a fetch.
|
||||
// A newer gesture would have detached again, so only a still-sticky submit owns release.
|
||||
if (pendingRequest?.reason === "message-sent" && mode === "sticky-bottom") {
|
||||
evaluate(false, "user_scroll_end");
|
||||
return;
|
||||
}
|
||||
if (params.isAtEnd) {
|
||||
if (mode === "detached") {
|
||||
setModeInternal("sticky-bottom");
|
||||
|
||||
@@ -4,7 +4,6 @@ import { within } from "@testing-library/dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { i18n as testI18n } from "@/i18n/i18next";
|
||||
import { ToolCallDetailsContent } from "@/components/tool-call-details";
|
||||
import { ExpandableBadge } from "@/components/message";
|
||||
import { page, userEvent } from "vitest/browser";
|
||||
import { PlanCard } from "@/components/plan-card";
|
||||
@@ -203,87 +202,3 @@ describe("tool badge file action", () => {
|
||||
expect(view().queryByText("File contents")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const longDetail = {
|
||||
type: "unknown" as const,
|
||||
input: { text: "input\n".repeat(80) },
|
||||
output: { text: "output\n".repeat(80) },
|
||||
};
|
||||
|
||||
describe("inline tool detail height", () => {
|
||||
it("caps the complete detail, including input, output and error, to its allowance", () => {
|
||||
act(() =>
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToolCallDetailsContent
|
||||
detail={longDetail}
|
||||
errorText={"error\n".repeat(80)}
|
||||
maxHeight={200}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
);
|
||||
expect(container.getBoundingClientRect().height).toBeLessThanOrEqual(200);
|
||||
expect(container.textContent).toContain("output");
|
||||
expect(container.textContent).toContain("error");
|
||||
});
|
||||
});
|
||||
|
||||
const longShellDetail = {
|
||||
type: "shell" as const,
|
||||
command: "echo command\n".repeat(80),
|
||||
output: "output\n".repeat(80),
|
||||
};
|
||||
|
||||
const longEditDetail = {
|
||||
type: "edit" as const,
|
||||
filePath: "example.txt",
|
||||
oldString: "old line\n".repeat(80),
|
||||
newString: "new line\n".repeat(80),
|
||||
};
|
||||
|
||||
describe("capped tool scrolling", () => {
|
||||
it.each([longShellDetail, longEditDetail])(
|
||||
"gives a capped $type detail one vertical scroll owner",
|
||||
(detail) => {
|
||||
act(() =>
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToolCallDetailsContent detail={detail} maxHeight={200} />
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
);
|
||||
const verticalScrollers = Array.from(container.querySelectorAll("*")).filter((node) =>
|
||||
["auto", "scroll"].includes(getComputedStyle(node).overflowY),
|
||||
);
|
||||
expect(verticalScrollers).toHaveLength(1);
|
||||
expect(container.getBoundingClientRect().height).toBeLessThanOrEqual(200);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
function renderCopyOutput() {
|
||||
return <button type="button">Copy output</button>;
|
||||
}
|
||||
|
||||
describe("tool detail interaction", () => {
|
||||
it("keeps detail controls in the tab order", async () => {
|
||||
function Badge() {
|
||||
const [expanded, setExpanded] = React.useState(true);
|
||||
const toggle = React.useCallback(() => setExpanded(!expanded), [expanded]);
|
||||
return (
|
||||
<ExpandableBadge
|
||||
label="Probe"
|
||||
isExpanded={expanded}
|
||||
onToggle={toggle}
|
||||
renderDetails={renderCopyOutput}
|
||||
/>
|
||||
);
|
||||
}
|
||||
act(() => root.render(<Badge />));
|
||||
const header = view().getByRole("button", { name: "Probe" });
|
||||
act(() => header.focus());
|
||||
await userEvent.keyboard("{Tab}");
|
||||
expect(document.activeElement).toBe(view().getByRole("button", { name: "Copy output" }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -883,7 +883,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
status={call.status}
|
||||
isLastInSequence={index === group.run.calls.length - 1}
|
||||
defaultExpanded={false}
|
||||
maxDetailHeight={GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT}
|
||||
standalone={false}
|
||||
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
|
||||
/>
|
||||
@@ -1376,7 +1375,6 @@ interface ThoughtSlotProps {
|
||||
isLastInSequence: boolean;
|
||||
defaultExpanded: boolean;
|
||||
standalone?: boolean;
|
||||
maxDetailHeight?: number;
|
||||
}
|
||||
|
||||
// Reasoning text is paced the same way assistant text is; see @/hooks/use-revealed-text.
|
||||
@@ -1388,7 +1386,6 @@ function ThoughtSlot({
|
||||
isLastInSequence,
|
||||
defaultExpanded,
|
||||
standalone,
|
||||
maxDetailHeight,
|
||||
}: ThoughtSlotProps) {
|
||||
const revealedText = useRevealedText(text, status === "ready" ? "complete" : "streaming");
|
||||
return (
|
||||
@@ -1401,7 +1398,6 @@ function ThoughtSlot({
|
||||
isLastInSequence={isLastInSequence}
|
||||
defaultExpanded={defaultExpanded}
|
||||
standalone={standalone}
|
||||
maxDetailHeight={maxDetailHeight}
|
||||
forceInline={defaultExpanded}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -931,7 +931,9 @@ function RootStack() {
|
||||
<Stack.Screen name="h/[serverId]" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/index" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/[hostSection]" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/plugins/[pluginId]/index" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/plugins/[pluginId]/[screenId]" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/plugin-directory/[owner]/[slug]" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/projects/index" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/projects/[projectId]" />
|
||||
</ThemedStack>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import SettingsScreen from "@/screens/settings-screen";
|
||||
|
||||
/** A plugin directory listing's page, by its `owner/slug` ID. */
|
||||
export default function PluginListingRoute() {
|
||||
const { serverId, owner, slug } = useLocalSearchParams<{
|
||||
serverId: string;
|
||||
owner: string;
|
||||
slug: string;
|
||||
}>();
|
||||
const view = useMemo(
|
||||
() => ({ kind: "pluginListing" as const, serverId, listingId: `${owner}/${slug}` }),
|
||||
[serverId, owner, slug],
|
||||
);
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<SettingsScreen view={view} />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import SettingsScreen from "@/screens/settings-screen";
|
||||
|
||||
/** An installed plugin's page. A settings tab chosen on it arrives as `screenId`. */
|
||||
export default function PluginPageRoute() {
|
||||
const { serverId, pluginId, screenId } = useLocalSearchParams<{
|
||||
serverId: string;
|
||||
pluginId: string;
|
||||
screenId?: string;
|
||||
}>();
|
||||
const view = useMemo(
|
||||
() => ({ kind: "plugin" as const, serverId, pluginId, screenId }),
|
||||
[serverId, pluginId, screenId],
|
||||
);
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<SettingsScreen view={view} />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
@@ -25,12 +25,13 @@ function source(gitActions: GitActions): {
|
||||
dispatched: KeyboardActionDefinition[];
|
||||
copiedPaths: number;
|
||||
copiedBranchNames: number;
|
||||
archivedWorkspaces: number;
|
||||
toggledLabels: Array<{ name: string; assigned: boolean }>;
|
||||
} {
|
||||
const runGitActions: GitAction[] = [];
|
||||
const dispatched: KeyboardActionDefinition[] = [];
|
||||
const toggledLabels: Array<{ name: string; assigned: boolean }> = [];
|
||||
const counters = { copiedPaths: 0, copiedBranchNames: 0 };
|
||||
const counters = { copiedPaths: 0, copiedBranchNames: 0, archivedWorkspaces: 0 };
|
||||
return {
|
||||
value: {
|
||||
gitActions,
|
||||
@@ -68,6 +69,7 @@ function source(gitActions: GitActions): {
|
||||
closePane: "Close pane",
|
||||
toggleFocusMode: "Toggle focus mode",
|
||||
toggleExplorerSidebar: "Toggle Explorer sidebar",
|
||||
archiveWorkspace: "Archive workspace",
|
||||
rename: "Rename workspace",
|
||||
copyPath: "Copy workspace path",
|
||||
copyBranchName: "Copy branch name",
|
||||
@@ -93,6 +95,9 @@ function source(gitActions: GitActions): {
|
||||
labelCatalog: null,
|
||||
dispatch: (action) => dispatched.push(action),
|
||||
runGitAction: (action) => runGitActions.push(action),
|
||||
archiveWorkspace: () => {
|
||||
counters.archivedWorkspaces += 1;
|
||||
},
|
||||
copyPath: () => {
|
||||
counters.copiedPaths += 1;
|
||||
},
|
||||
@@ -112,10 +117,48 @@ function source(gitActions: GitActions): {
|
||||
get copiedBranchNames() {
|
||||
return counters.copiedBranchNames;
|
||||
},
|
||||
get archivedWorkspaces() {
|
||||
return counters.archivedWorkspaces;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("workspace command center contributions", () => {
|
||||
it("offers archive in a non-Git workspace and runs the workspace's archive", () => {
|
||||
const fixture = source({ primary: null, secondary: [], menu: [] });
|
||||
|
||||
const archive = buildWorkspaceCommandCenterContributions(fixture.value).find(
|
||||
(item) => item.id === "workspace:archive",
|
||||
);
|
||||
|
||||
expect(archive?.visibility).toBe("query");
|
||||
expect(archive?.presentation).toMatchObject({ title: "Archive workspace" });
|
||||
archive?.run();
|
||||
expect(fixture.archivedWorkspaces).toBe(1);
|
||||
expect(fixture.dispatched).toEqual([]);
|
||||
});
|
||||
|
||||
it("omits archive while the workspace cannot be archived", () => {
|
||||
const fixture = source({ primary: null, secondary: [], menu: [] });
|
||||
fixture.value.archiveWorkspace = null;
|
||||
|
||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||
|
||||
expect(contributions.some((item) => item.id === "workspace:archive")).toBe(false);
|
||||
});
|
||||
|
||||
it("lists archive once in a Git workspace, through its Git action", () => {
|
||||
const archive = gitAction("archive-workspace", "Archive workspace");
|
||||
const fixture = source({ primary: null, secondary: [archive], menu: [] });
|
||||
fixture.value.capabilities.isGit = true;
|
||||
|
||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||
|
||||
expect(
|
||||
contributions.filter((item) => item.id.includes("archive")).map((item) => item.id),
|
||||
).toEqual(["git:archive-workspace"]);
|
||||
});
|
||||
|
||||
it("makes only the policy-selected primary Git action default-visible and runs it", () => {
|
||||
const primary = gitAction("commit", "Commit");
|
||||
const fixture = source({
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface WorkspaceCommandCenterLabels {
|
||||
toggleFocusMode: string;
|
||||
toggleExplorerSidebar: string;
|
||||
// Workspace management actions
|
||||
archiveWorkspace: string;
|
||||
rename: string;
|
||||
copyPath: string;
|
||||
copyBranchName: string;
|
||||
@@ -79,6 +80,7 @@ export interface WorkspaceCommandCenterIcons {
|
||||
focusMode?: CommandCenterIcon;
|
||||
explorerSidebar?: CommandCenterIcon;
|
||||
// Workspace management action icons
|
||||
archiveWorkspace?: CommandCenterIcon;
|
||||
copyPath?: CommandCenterIcon;
|
||||
copyBranchName?: CommandCenterIcon;
|
||||
pin?: CommandCenterIcon;
|
||||
@@ -131,6 +133,8 @@ export interface WorkspaceCommandCenterSource {
|
||||
labelCatalog: readonly WorkspaceCommandCenterLabelChoice[] | null;
|
||||
dispatch(action: KeyboardActionDefinition): void;
|
||||
runGitAction(action: GitAction): void;
|
||||
/** Null while the workspace cannot be archived. A Git workspace archives through `gitActions`. */
|
||||
archiveWorkspace: (() => void) | null;
|
||||
copyPath(): void;
|
||||
copyBranchName(): void;
|
||||
toggleLabel(name: string, assigned: boolean): void | Promise<void>;
|
||||
@@ -585,6 +589,23 @@ export function buildWorkspaceCommandCenterContributions(
|
||||
contributions.push(buildGitContribution(source, action, 100 + index, "query"));
|
||||
}
|
||||
|
||||
// A Git workspace lists archive among its Git actions; any other workspace archives here.
|
||||
if (!source.capabilities.isGit && source.archiveWorkspace) {
|
||||
contributions.push(
|
||||
buildWorkspaceCallback({
|
||||
source,
|
||||
id: "workspace:archive",
|
||||
rank: 100,
|
||||
title: source.labels.archiveWorkspace,
|
||||
keywords: ["archive", "workspace"],
|
||||
icon: source.icons.archiveWorkspace,
|
||||
shortcutKeys: source.shortcuts.archiveWorkspace,
|
||||
run: source.archiveWorkspace,
|
||||
visibility: "query",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (source.capabilities.canPin) {
|
||||
contributions.push(
|
||||
buildWorkspaceAction({
|
||||
|
||||
@@ -203,7 +203,7 @@ export function useWorkspaceCommandCenterActions(): void {
|
||||
supportsSetupObservation,
|
||||
);
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
const { gitActions, isGit } = useGitActions({
|
||||
const { gitActions, isGit, archiveWorkspace } = useGitActions({
|
||||
serverId: serverId ?? "",
|
||||
cwd: cwd ?? "",
|
||||
icons: GIT_ACTION_ICONS,
|
||||
@@ -270,6 +270,7 @@ export function useWorkspaceCommandCenterActions(): void {
|
||||
toggleFocusMode: t("settings.shortcuts.help.toggleFocusMode"),
|
||||
toggleExplorerSidebar: t("workspace.tabs.explorerSidebar.toggle"),
|
||||
// Workspace management labels
|
||||
archiveWorkspace: t("workspace.git.actions.archive.label"),
|
||||
rename: t("sidebar.workspace.actions.rename"),
|
||||
copyPath: t("workspace.header.actions.copyPath"),
|
||||
copyBranchName: t("workspace.header.actions.copyBranchName"),
|
||||
@@ -280,6 +281,7 @@ export function useWorkspaceCommandCenterActions(): void {
|
||||
},
|
||||
icons: {
|
||||
...WORKSPACE_COMMAND_CENTER_ICONS,
|
||||
archiveWorkspace: staticIcon(GIT_ACTION_ICONS.archive),
|
||||
git: (action) => staticIcon(action.icon),
|
||||
},
|
||||
shortcuts: resolveWorkspaceShortcuts(overrides),
|
||||
@@ -301,12 +303,14 @@ export function useWorkspaceCommandCenterActions(): void {
|
||||
keyboardActionDispatcher.dispatch(action);
|
||||
},
|
||||
runGitAction,
|
||||
archiveWorkspace,
|
||||
copyPath,
|
||||
copyBranchName,
|
||||
toggleLabel,
|
||||
}),
|
||||
[
|
||||
activeTabIndex,
|
||||
archiveWorkspace,
|
||||
activeTabKind,
|
||||
canPin,
|
||||
canShowSetup,
|
||||
|
||||
@@ -15,6 +15,7 @@ const ScrollView = isWeb ? RNScrollView : GHScrollView;
|
||||
|
||||
interface DiffViewerProps {
|
||||
diffLines: DiffLine[];
|
||||
maxHeight?: number;
|
||||
emptyLabel?: string;
|
||||
fillAvailableHeight?: boolean;
|
||||
}
|
||||
@@ -118,6 +119,7 @@ function DiffSegment({
|
||||
|
||||
export function DiffViewer({
|
||||
diffLines,
|
||||
maxHeight,
|
||||
emptyLabel,
|
||||
fillAvailableHeight = false,
|
||||
}: DiffViewerProps) {
|
||||
@@ -131,8 +133,12 @@ export function DiffViewer({
|
||||
);
|
||||
|
||||
const outerScrollStyle = React.useMemo(
|
||||
() => [styles.verticalScroll, fillAvailableHeight && styles.fillHeight],
|
||||
[fillAvailableHeight],
|
||||
() => [
|
||||
styles.verticalScroll,
|
||||
maxHeight !== undefined && inlineUnistylesStyle({ maxHeight }),
|
||||
fillAvailableHeight && styles.fillHeight,
|
||||
],
|
||||
[maxHeight, fillAvailableHeight],
|
||||
);
|
||||
const linesContainerStyle = React.useMemo(
|
||||
() => [
|
||||
@@ -178,9 +184,6 @@ export function DiffViewer({
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
// Capped tool details own vertical scrolling outside the diff.
|
||||
if (!fillAvailableHeight) return horizontalScroll;
|
||||
|
||||
const content = (
|
||||
<ScrollView
|
||||
style={outerScrollStyle}
|
||||
|
||||
@@ -2,31 +2,41 @@ import { describe, expect, it } from "vitest";
|
||||
import { dragStateInitial, dragStateReducer } from "./drag-reducer";
|
||||
|
||||
describe("dragStateReducer", () => {
|
||||
it("starts tracking the active item with a snapshot of the data", () => {
|
||||
it("starts tracking the active item with the rows on screen", () => {
|
||||
const next = dragStateReducer(dragStateInitial<string>(), {
|
||||
type: "start",
|
||||
id: "alpha",
|
||||
data: ["alpha", "beta"],
|
||||
items: ["alpha", "beta"],
|
||||
});
|
||||
|
||||
expect(next).toEqual({ activeId: "alpha", dragItems: ["alpha", "beta"] });
|
||||
expect(next).toEqual({ activeId: "alpha", dragItems: ["alpha", "beta"], dropped: null });
|
||||
});
|
||||
|
||||
it("clears the active item and the snapshot", () => {
|
||||
it("clears the active item and the rows", () => {
|
||||
const next = dragStateReducer(
|
||||
{ activeId: "alpha", dragItems: ["alpha", "beta"] },
|
||||
{ activeId: "alpha", dragItems: ["alpha", "beta"], dropped: null },
|
||||
{ type: "clear" },
|
||||
);
|
||||
|
||||
expect(next).toEqual({ activeId: null, dragItems: null });
|
||||
expect(next).toEqual({ activeId: null, dragItems: null, dropped: null });
|
||||
});
|
||||
|
||||
it("holds the dropped order in place of the order it replaces", () => {
|
||||
const dropped = { order: ["beta", "alpha"], replaces: ["alpha", "beta"] };
|
||||
const next = dragStateReducer(
|
||||
{ activeId: "alpha", dragItems: ["alpha", "beta"], dropped: null },
|
||||
{ type: "drop", dropped },
|
||||
);
|
||||
|
||||
expect(next).toEqual({ activeId: null, dragItems: null, dropped });
|
||||
});
|
||||
|
||||
it("replaces an in-flight drag when a new one starts", () => {
|
||||
const next = dragStateReducer(
|
||||
{ activeId: "alpha", dragItems: ["alpha", "beta"] },
|
||||
{ type: "start", id: "beta", data: ["beta", "gamma"] },
|
||||
{ activeId: "alpha", dragItems: ["alpha", "beta"], dropped: null },
|
||||
{ type: "start", id: "beta", items: ["beta", "gamma"] },
|
||||
);
|
||||
|
||||
expect(next).toEqual({ activeId: "beta", dragItems: ["beta", "gamma"] });
|
||||
expect(next).toEqual({ activeId: "beta", dragItems: ["beta", "gamma"], dropped: null });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,33 @@
|
||||
export interface DragState<T> {
|
||||
activeId: string | null;
|
||||
dragItems: T[] | null;
|
||||
/** A dropped key order, shown in place of the owner's until the owner's order changes. */
|
||||
export interface DroppedOrder {
|
||||
order: string[];
|
||||
/** The owner's key order at the drop. */
|
||||
replaces: string[];
|
||||
}
|
||||
|
||||
export type DragAction<T> = { type: "start"; id: string; data: T[] } | { type: "clear" };
|
||||
export interface DragState<T> {
|
||||
activeId: string | null;
|
||||
/** The rows on screen while a drag is in flight. */
|
||||
dragItems: T[] | null;
|
||||
dropped: DroppedOrder | null;
|
||||
}
|
||||
|
||||
export type DragAction<T> =
|
||||
| { type: "start"; id: string; items: T[] }
|
||||
| { type: "drop"; dropped: DroppedOrder }
|
||||
| { type: "clear" };
|
||||
|
||||
export function dragStateInitial<T>(): DragState<T> {
|
||||
return { activeId: null, dragItems: null };
|
||||
return { activeId: null, dragItems: null, dropped: null };
|
||||
}
|
||||
|
||||
export function dragStateReducer<T>(state: DragState<T>, action: DragAction<T>): DragState<T> {
|
||||
switch (action.type) {
|
||||
case "start":
|
||||
return { activeId: action.id, dragItems: action.data };
|
||||
return { activeId: action.id, dragItems: action.items, dropped: null };
|
||||
case "drop":
|
||||
return { activeId: null, dragItems: null, dropped: action.dropped };
|
||||
case "clear":
|
||||
return { activeId: null, dragItems: null };
|
||||
return dragStateInitial();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useReducer } from "react";
|
||||
import { useCallback, useMemo, useReducer } from "react";
|
||||
import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
|
||||
import { dragStateInitial, dragStateReducer } from "./drag-reducer";
|
||||
import { dragStateInitial, dragStateReducer, type DroppedOrder } from "./drag-reducer";
|
||||
import { reorderItemsOnDragEnd } from "./reorder-items";
|
||||
|
||||
export interface DragReorderHandlers {
|
||||
@@ -15,6 +15,28 @@ export interface DragReorderState<T> {
|
||||
handlers: DragReorderHandlers;
|
||||
}
|
||||
|
||||
/**
|
||||
* The owner's rows in the dropped order, or null once the owner's own order has changed since
|
||||
* the drop.
|
||||
*/
|
||||
function arrangeAsDropped<T>(
|
||||
data: T[],
|
||||
keys: string[],
|
||||
{ order, replaces }: DroppedOrder,
|
||||
): T[] | null {
|
||||
const ownerOrderUnchanged =
|
||||
keys.length === replaces.length && keys.every((key, index) => key === replaces[index]);
|
||||
if (!ownerOrderUnchanged) return null;
|
||||
|
||||
const indexByKey = new Map(keys.map((key, index) => [key, index]));
|
||||
const arranged: T[] = [];
|
||||
for (const key of order) {
|
||||
const index = indexByKey.get(key);
|
||||
if (index !== undefined) arranged.push(data[index]);
|
||||
}
|
||||
return arranged.length === data.length ? arranged : null;
|
||||
}
|
||||
|
||||
export function useDragReorderState<T>({
|
||||
data,
|
||||
keyExtractor,
|
||||
@@ -30,13 +52,22 @@ export function useDragReorderState<T>({
|
||||
}): DragReorderState<T> {
|
||||
const [state, dispatch] = useReducer(dragStateReducer<T>, undefined, dragStateInitial<T>);
|
||||
|
||||
const keys = useMemo(() => data.map(keyExtractor), [data, keyExtractor]);
|
||||
const held = useMemo(
|
||||
() => (state.dropped ? arrangeAsDropped(data, keys, state.dropped) : null),
|
||||
[data, keys, state.dropped],
|
||||
);
|
||||
// The owner's order changed, so the dropped order has nothing left to stand in for.
|
||||
if (state.dropped && !held) dispatch({ type: "clear" });
|
||||
const items = state.dragItems ?? held ?? data;
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
if (disabled) return;
|
||||
dispatch({ type: "start", id: String(event.active.id), data });
|
||||
dispatch({ type: "start", id: String(event.active.id), items });
|
||||
onDragBegin?.();
|
||||
},
|
||||
[data, disabled, onDragBegin],
|
||||
[disabled, items, onDragBegin],
|
||||
);
|
||||
|
||||
const clearDragState = useCallback(() => {
|
||||
@@ -46,25 +77,29 @@ export function useDragReorderState<T>({
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
const items = state.dragItems ?? data;
|
||||
dispatch({ type: "clear" });
|
||||
if (disabled) return;
|
||||
|
||||
const activeId = String(active.id);
|
||||
const reordered = reorderItemsOnDragEnd({
|
||||
items,
|
||||
activeId,
|
||||
overId: over ? String(over.id) : null,
|
||||
keyExtractor,
|
||||
});
|
||||
if (reordered) onDragEnd?.(reordered, activeId);
|
||||
const reordered = disabled
|
||||
? null
|
||||
: reorderItemsOnDragEnd({
|
||||
items,
|
||||
activeId,
|
||||
overId: over ? String(over.id) : null,
|
||||
keyExtractor,
|
||||
});
|
||||
if (!reordered) {
|
||||
dispatch({ type: "clear" });
|
||||
return;
|
||||
}
|
||||
// The owner's new order can arrive in a later render. Hold the dropped order until it does.
|
||||
dispatch({ type: "drop", dropped: { order: reordered.map(keyExtractor), replaces: keys } });
|
||||
onDragEnd?.(reordered, activeId);
|
||||
},
|
||||
[data, disabled, keyExtractor, onDragEnd, state.dragItems],
|
||||
[disabled, items, keyExtractor, keys, onDragEnd],
|
||||
);
|
||||
|
||||
return {
|
||||
activeId: state.activeId,
|
||||
items: state.dragItems ?? data,
|
||||
items,
|
||||
handlers: {
|
||||
onDragStart: handleDragStart,
|
||||
onDragCancel: clearDragState,
|
||||
|
||||
@@ -36,7 +36,10 @@ export interface DraggableListProps<T> {
|
||||
data: T[];
|
||||
keyExtractor: (item: T, index: number) => string;
|
||||
renderItem: (info: DraggableRenderItemInfo<T>) => ReactElement;
|
||||
/** The new order, and the key of the item the user moved to reach it. */
|
||||
/**
|
||||
* The new order, and the key of the item the user moved to reach it. The list keeps showing
|
||||
* this order until `data` arrives in a different order, so the owner must apply it.
|
||||
*/
|
||||
onDragEnd: (data: T[], movedKey: string) => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
/** Outer container style (useful for nested, non-scrolling lists). */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { act } from "react";
|
||||
import React, { act, useCallback, useState } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
@@ -11,6 +11,7 @@ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
interface Item {
|
||||
id: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
function keyExtractor(item: Item): string {
|
||||
@@ -41,7 +42,7 @@ function renderRowWithHandle(info: DraggableRenderItemInfo<Item>) {
|
||||
<DraggableListHandle accessibilityLabel={`Move ${info.item.id}`} testID="row-handle">
|
||||
<span>grip</span>
|
||||
</DraggableListHandle>
|
||||
<span data-testid="row-body">{info.item.id}</span>
|
||||
<span data-testid="row-body">{info.item.label ?? info.item.id}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -87,6 +88,87 @@ function pressAndMove(element: HTMLElement, distance: number) {
|
||||
});
|
||||
}
|
||||
|
||||
const OWNER_DELAY_MS = 50;
|
||||
|
||||
function wait(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* An owner that relabels its rows as soon as one is dropped, and whose new order reaches the list
|
||||
* in a later render, like one that saves to a store.
|
||||
*/
|
||||
function ListWithLateOwner() {
|
||||
const [items, setItems] = useState<Item[]>([{ id: "one" }, { id: "two" }]);
|
||||
const relabelThenReorderLater = useCallback((next: Item[]) => {
|
||||
const relabel = (item: Item): Item => ({ id: item.id, label: `${item.id} dropped` });
|
||||
setItems((current) => current.map(relabel));
|
||||
setTimeout(() => setItems(next.map(relabel)), OWNER_DELAY_MS);
|
||||
}, []);
|
||||
return (
|
||||
<DraggableList
|
||||
data={items}
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={renderRowWithHandle}
|
||||
onDragEnd={relabelThenReorderLater}
|
||||
useDragHandle
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function rowOrder(): (string | null)[] {
|
||||
return Array.from(container?.querySelectorAll('[data-testid="row-body"]') ?? []).map(
|
||||
(row) => row.textContent,
|
||||
);
|
||||
}
|
||||
|
||||
/** Drag the element down by `distance` in two mouse moves, letting dnd-kit measure in between. */
|
||||
async function dragDown(element: HTMLElement, distance: number) {
|
||||
const { left, top, width, height } = element.getBoundingClientRect();
|
||||
const x = left + width / 2;
|
||||
const y = top + height / 2;
|
||||
await act(async () => {
|
||||
element.dispatchEvent(
|
||||
new MouseEvent("mousedown", { bubbles: true, button: 0, clientX: x, clientY: y }),
|
||||
);
|
||||
});
|
||||
for (const moved of [distance / 2, distance]) {
|
||||
await act(async () => {
|
||||
document.dispatchEvent(
|
||||
new MouseEvent("mousemove", { bubbles: true, clientX: x, clientY: y + moved }),
|
||||
);
|
||||
await wait(20);
|
||||
});
|
||||
}
|
||||
act(() => {
|
||||
document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
describe("DraggableList on web", () => {
|
||||
it("shows the owner's current rows in the dropped order until its new order arrives", async () => {
|
||||
act(() => {
|
||||
root?.render(<ListWithLateOwner />);
|
||||
});
|
||||
const [firstHandle] = Array.from(
|
||||
container?.querySelectorAll<HTMLElement>('[data-testid="row-handle"]') ?? [],
|
||||
);
|
||||
const [firstRow, secondRow] = Array.from(
|
||||
container?.querySelectorAll<HTMLElement>('[data-testid="row-body"]') ?? [],
|
||||
);
|
||||
const rowDistance =
|
||||
secondRow.getBoundingClientRect().top - firstRow.getBoundingClientRect().top;
|
||||
|
||||
await dragDown(firstHandle, rowDistance);
|
||||
expect(rowOrder()).toEqual(["two dropped", "one dropped"]);
|
||||
|
||||
await act(() => wait(OWNER_DELAY_MS * 2));
|
||||
expect(rowOrder()).toEqual(["two dropped", "one dropped"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DraggableListHandle on web", () => {
|
||||
it("starts the row's drag from the handle, and only from the handle", () => {
|
||||
const onDragBegin = vi.fn();
|
||||
|
||||
@@ -38,18 +38,23 @@ export interface MarkdownImageContext {
|
||||
export function useMarkdownSource(
|
||||
text: string,
|
||||
context: MarkdownImageContext,
|
||||
links: { commandNames?: ReadonlySet<string>; forgeReferencePattern?: string } = {},
|
||||
links: {
|
||||
commandNames?: ReadonlySet<string>;
|
||||
forgeReferencePattern?: string;
|
||||
external?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const occurrenceKey = useId();
|
||||
const { commandNames, forgeReferencePattern } = links;
|
||||
const { commandNames, forgeReferencePattern, external } = links;
|
||||
const prepared = useMemo(
|
||||
() =>
|
||||
prepareMarkdownSource(text, context.workspaceRoot, {
|
||||
supportsSvg: isWeb,
|
||||
commandNames,
|
||||
forgeReferencePattern,
|
||||
external,
|
||||
}),
|
||||
[text, context.workspaceRoot, commandNames, forgeReferencePattern],
|
||||
[text, context.workspaceRoot, commandNames, forgeReferencePattern, external],
|
||||
);
|
||||
const sources = [...new Set(prepared.images.map((image) => image.source))];
|
||||
const acquisitions = sources.map((source) => {
|
||||
|
||||
@@ -1096,3 +1096,30 @@ it.each([false, true])(
|
||||
expect(markdownStyle.codeBlock?.fontSize).toBe(theme.fontSize.code);
|
||||
},
|
||||
);
|
||||
|
||||
describe("external Markdown", () => {
|
||||
const external = (source: string) => prepareMarkdownSource(source, undefined, { external: true });
|
||||
|
||||
it("recognizes no files, forge references or commands, only https URLs", () => {
|
||||
const { linkRecognition } = external("Needs Node.js 22 and `~/.dsh/.env`, see #12");
|
||||
expect("Needs Node.js 22, see #12".match(linkRecognition.text)).toBeNull();
|
||||
expect(linkRecognition.inlineCode.test("~/.dsh/.env")).toBe(false);
|
||||
expect("Docs at https://example.com/a.".match(linkRecognition.text)).toEqual([
|
||||
"https://example.com/a",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps https links and leaves any other destination as its label", () => {
|
||||
expect(
|
||||
external(
|
||||
"[Guide](docs/guide.md), [site](https://example.com), [mail](mailto:a@b.c), [ref][r]\n\n[r]: ./x",
|
||||
).render(),
|
||||
).toBe("Guide, [site](https://example.com), mail, ref\n\n[r]: ./x");
|
||||
});
|
||||
|
||||
it("shows a relative image as its alt text", () => {
|
||||
expect(external(" ").render()).toBe(
|
||||
"Demo ",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { matchCommandLink, useAgentCommandHints, type CommandHints } from "@/agent-commands";
|
||||
import { useForgeShorthand } from "@/git/forge-shorthand";
|
||||
import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
@@ -24,6 +25,8 @@ import { MermaidFence, MermaidStreamingContext, splitMermaidFences } from "./fen
|
||||
|
||||
const ThemedMarkdown = withUnistyles(EnrichedMarkdownText);
|
||||
const MD4C_FLAGS = { latexMath: false, hardSoftBreaks: true, admonitions: true };
|
||||
// A README is written for its forge, where the lines of a paragraph join into one.
|
||||
const EXTERNAL_MD4C_FLAGS = { ...MD4C_FLAGS, hardSoftBreaks: false };
|
||||
|
||||
// Reading the breakpoint here makes Unistyles restyle the text when the layout crosses it.
|
||||
const markdownStyle =
|
||||
@@ -52,6 +55,12 @@ export interface MarkdownRendererProps extends MarkdownImageContext {
|
||||
streaming?: boolean;
|
||||
onLinkPress?: (url: string) => unknown;
|
||||
agentCommands?: boolean;
|
||||
/**
|
||||
* Author-controlled Markdown from outside this host, such as a plugin README: only its https
|
||||
* links and images work, nothing is read as a file, forge reference or command, and the lines of
|
||||
* a paragraph join as they do on its forge.
|
||||
*/
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
/** All Markdown surfaces share the same engine and source adaptation. */
|
||||
@@ -86,12 +95,14 @@ const MarkdownPart = memo(function MarkdownPart({
|
||||
streaming = false,
|
||||
onLinkPress,
|
||||
agentCommands: _agentCommands,
|
||||
external = false,
|
||||
commandHints = null,
|
||||
...imageContext
|
||||
}: MarkdownRendererProps & { commandHints?: CommandHints | null }) {
|
||||
const environment = useAssistantFileLinkEnvironment();
|
||||
const actions = useAssistantFileLinkActions();
|
||||
const forgeShorthand = useForgeShorthand();
|
||||
const workspaceForgeShorthand = useForgeShorthand();
|
||||
const forgeShorthand = external ? null : workspaceForgeShorthand;
|
||||
const client =
|
||||
imageContext.client ??
|
||||
(environment.client?.readFile
|
||||
@@ -105,10 +116,20 @@ const MarkdownPart = memo(function MarkdownPart({
|
||||
...imageContext,
|
||||
client,
|
||||
},
|
||||
{ commandNames: commandHints?.names, forgeReferencePattern: forgeShorthand?.pattern },
|
||||
{
|
||||
commandNames: commandHints?.names,
|
||||
forgeReferencePattern: forgeShorthand?.pattern,
|
||||
external,
|
||||
},
|
||||
);
|
||||
const [image, setImage] = useState<ImageLightboxSource | null>(null);
|
||||
const pressLink = useStableEvent(({ url, sourceType }: LinkPressEvent) => {
|
||||
if (external) {
|
||||
// The parser still autolinks bare http, www and email text; only https links open.
|
||||
const safe = pluginOverviewUrl(url);
|
||||
if (safe) void openExternalUrl(safe);
|
||||
return;
|
||||
}
|
||||
const command = commandHints && matchCommandLink(url, sourceType, commandHints.names);
|
||||
if (command) {
|
||||
commandHints.insert(command);
|
||||
@@ -145,7 +166,7 @@ const MarkdownPart = memo(function MarkdownPart({
|
||||
uniProps={pickMarkdownStyle(compact, muted)}
|
||||
flavor="github"
|
||||
selectable={selectable}
|
||||
md4cFlags={MD4C_FLAGS}
|
||||
md4cFlags={external ? EXTERNAL_MD4C_FLAGS : MD4C_FLAGS}
|
||||
linkRecognition={linkRecognition}
|
||||
enableTaskListItemToggle={false}
|
||||
enableLinkPreview={false}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
|
||||
import { classifyAssistantFileLink } from "@/assistant-file-links";
|
||||
import { ASSISTANT_FILE_EXTENSIONS } from "@/assistant-file-links/extensions";
|
||||
import { normalizeMarkdownHtml } from "./html";
|
||||
@@ -53,6 +54,12 @@ function textPattern(forgeReferencePattern?: string): RegExp {
|
||||
return pattern;
|
||||
}
|
||||
|
||||
// External Markdown links only what its author wrote as an https URL.
|
||||
const EXTERNAL_LINKS = {
|
||||
text: new RegExp(BARE_URL.replace("https?", "https"), "g"),
|
||||
inlineCode: /^https:\/\/[^\s<>]+$/,
|
||||
};
|
||||
|
||||
function linkMetadata(inlineCodePaths: ReadonlySet<string>, forgeReferencePattern?: string) {
|
||||
// The engine accepts regexes on both platforms. Classify complete code spans
|
||||
// first, then pass literal matches rather than maintaining a second path grammar.
|
||||
@@ -139,6 +146,12 @@ export function prepareMarkdownSource(
|
||||
commandNames?: ReadonlySet<string>;
|
||||
/** Regex source for forge references such as `#12`; see `@/git/forge-shorthand`. */
|
||||
forgeReferencePattern?: string;
|
||||
/**
|
||||
* Author-controlled Markdown from outside this host, such as a plugin README. It follows the
|
||||
* registry's overview contract: only absolute https links and images, and nothing is
|
||||
* recognized as a file, forge reference or command.
|
||||
*/
|
||||
external?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
source = normalizeMarkdownHtml(source);
|
||||
@@ -149,10 +162,13 @@ export function prepareMarkdownSource(
|
||||
extensions: [gfmTable()],
|
||||
mdastExtensions: [gfmTableFromMarkdown()],
|
||||
}));
|
||||
const metadata = linkMetadata(
|
||||
collectInlineCodePaths(source, workspaceRoot, options.commandNames),
|
||||
options.forgeReferencePattern,
|
||||
);
|
||||
const external = options.external === true;
|
||||
const metadata = external
|
||||
? { linkRecognition: EXTERNAL_LINKS, linkSource: markdownLinkSource }
|
||||
: linkMetadata(
|
||||
collectInlineCodePaths(source, workspaceRoot, options.commandNames),
|
||||
options.forgeReferencePattern,
|
||||
);
|
||||
const edits: { start: number; end: number; text: string }[] = [];
|
||||
if (
|
||||
!source.includes("![") &&
|
||||
@@ -162,7 +178,8 @@ export function prepareMarkdownSource(
|
||||
!/^[\t >]*[^\t >\r\n][^\r\n]*(?:\r\n|\r|\n)[^\r\n]+(?:\r\n|\r|\n)(?=[\t >:|-]*\|)[\t >|]*:?-[-\t :|]*$/m.test(
|
||||
source,
|
||||
) &&
|
||||
!(workspaceRoot && source.includes("]("))
|
||||
!(workspaceRoot && source.includes("](")) &&
|
||||
!(external && /\]|<[a-z]+:/i.test(source))
|
||||
) {
|
||||
return { images, ...metadata, render: () => source };
|
||||
}
|
||||
@@ -181,9 +198,10 @@ export function prepareMarkdownSource(
|
||||
) {
|
||||
const target = node.type === "image" ? node : definitions.get(node.identifier);
|
||||
if (
|
||||
target &&
|
||||
options.supportsSvg === false &&
|
||||
/(?:\.svg(?:[?#]|$)|^data:image\/svg\+xml)/i.test(target.url)
|
||||
(external && !pluginOverviewUrl(target?.url)) ||
|
||||
(target &&
|
||||
options.supportsSvg === false &&
|
||||
/(?:\.svg(?:[?#]|$)|^data:image\/svg\+xml)/i.test(target.url))
|
||||
) {
|
||||
edits.push({ start, end, text: escapeMarkdownText(node.alt ?? "") });
|
||||
return;
|
||||
@@ -236,6 +254,21 @@ export function prepareMarkdownSource(
|
||||
if (lineEnd < end) split(lineEnd);
|
||||
}
|
||||
}
|
||||
// An external link to anything but an https URL keeps its label and loses the brackets and
|
||||
// destination around it, so edits inside the label still apply.
|
||||
function dropUnsafeDestination(node: Nodes, start: number, end: number) {
|
||||
if (node.type !== "link" && node.type !== "linkReference") return;
|
||||
const target = node.type === "link" ? node : definitions.get(node.identifier);
|
||||
if (pluginOverviewUrl(target?.url)) return;
|
||||
const labelStart = node.children[0]?.position?.start.offset;
|
||||
const labelEnd = node.children.at(-1)?.position?.end.offset;
|
||||
if (labelStart === undefined || labelEnd === undefined) {
|
||||
edits.push({ start, end, text: "" });
|
||||
return;
|
||||
}
|
||||
edits.push({ start, end: labelStart, text: "" });
|
||||
edits.push({ start: labelEnd, end, text: "" });
|
||||
}
|
||||
function visit(node: Nodes, previous?: Nodes) {
|
||||
const position = node.position;
|
||||
if (position?.start.offset !== undefined && position.end.offset !== undefined) {
|
||||
@@ -255,6 +288,7 @@ export function prepareMarkdownSource(
|
||||
const boundary = lineStart - newline.length;
|
||||
edits.push({ start: boundary, end: lineStart, text: `${newline}${blank}${newline}` });
|
||||
}
|
||||
if (external) dropUnsafeDestination(node, start, end);
|
||||
if (node.type === "link") {
|
||||
const title = workspaceHoverTitle(node, source.slice(start, end), workspaceRoot);
|
||||
if (title !== null)
|
||||
|
||||
@@ -1562,6 +1562,7 @@ interface ExpandableBadgeProps {
|
||||
style?: StyleProp<ViewStyle>;
|
||||
onToggle?: () => void;
|
||||
onOpenFile?: () => void;
|
||||
onDetailHoverChange?: (hovered: boolean) => void;
|
||||
renderDetails?: () => ReactNode;
|
||||
isLoading?: boolean;
|
||||
isError?: boolean;
|
||||
@@ -1886,6 +1887,7 @@ export const ExpandableBadge = memo(function ExpandableBadge({
|
||||
isExpanded,
|
||||
onToggle,
|
||||
onOpenFile,
|
||||
onDetailHoverChange,
|
||||
renderDetails,
|
||||
isLoading = false,
|
||||
isError = false,
|
||||
@@ -1911,6 +1913,11 @@ export const ExpandableBadge = memo(function ExpandableBadge({
|
||||
}, []);
|
||||
const handlePressIn = useCallback(() => setIsPressed(true), []);
|
||||
const handlePressOut = useCallback(() => setIsPressed(false), []);
|
||||
const handleDetailHoverIn = useCallback(() => onDetailHoverChange?.(true), [onDetailHoverChange]);
|
||||
const handleDetailHoverOut = useCallback(
|
||||
() => onDetailHoverChange?.(false),
|
||||
[onDetailHoverChange],
|
||||
);
|
||||
const handleOpenFilePress = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
event.stopPropagation?.();
|
||||
@@ -2200,9 +2207,14 @@ export const ExpandableBadge = memo(function ExpandableBadge({
|
||||
) : null}
|
||||
</View>
|
||||
{detailContent ? (
|
||||
<View ref={detailWrapperRef} style={detailWrapperStyle}>
|
||||
<Pressable
|
||||
ref={detailWrapperRef}
|
||||
style={detailWrapperStyle}
|
||||
onHoverIn={handleDetailHoverIn}
|
||||
onHoverOut={handleDetailHoverOut}
|
||||
>
|
||||
{detailContent}
|
||||
</View>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
@@ -2222,6 +2234,7 @@ function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: Expa
|
||||
if (previous.testID !== next.testID) return false;
|
||||
if (previous.onToggle !== next.onToggle) return false;
|
||||
if (previous.onOpenFile !== next.onOpenFile) return false;
|
||||
if (previous.onDetailHoverChange !== next.onDetailHoverChange) return false;
|
||||
if (previous.renderDetails !== next.renderDetails) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -2239,6 +2252,7 @@ interface ToolCallProps {
|
||||
metadata?: Record<string, unknown>;
|
||||
isLastInSequence?: boolean;
|
||||
disableOuterSpacing?: boolean;
|
||||
onInlineDetailsHoverChange?: (hovered: boolean) => void;
|
||||
onInlineDetailsExpandedChange?: (expanded: boolean) => void;
|
||||
onOpenFilePath?: (filePath: string) => void;
|
||||
defaultExpanded?: boolean;
|
||||
@@ -2262,6 +2276,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
metadata,
|
||||
isLastInSequence = false,
|
||||
disableOuterSpacing,
|
||||
onInlineDetailsHoverChange,
|
||||
onInlineDetailsExpandedChange,
|
||||
onOpenFilePath,
|
||||
defaultExpanded,
|
||||
@@ -2347,6 +2362,13 @@ export const ToolCall = memo(function ToolCall({
|
||||
presentation.isLoadingDetails,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onInlineDetailsHoverChange || !shouldRenderInline || isExpanded) {
|
||||
return;
|
||||
}
|
||||
onInlineDetailsHoverChange(false);
|
||||
}, [isExpanded, shouldRenderInline, onInlineDetailsHoverChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onInlineDetailsExpandedChange) {
|
||||
return;
|
||||
@@ -2413,6 +2435,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
isError={status === "failed"}
|
||||
isLastInSequence={isLastInSequence}
|
||||
disableOuterSpacing={disableOuterSpacing}
|
||||
onDetailHoverChange={onInlineDetailsHoverChange}
|
||||
/>
|
||||
);
|
||||
}, areToolCallPropsEqual);
|
||||
|
||||
@@ -19,6 +19,8 @@ interface PageLayoutProps {
|
||||
titleTestID?: string;
|
||||
/** Controls for the whole page: right of the title on desktop, in the back header on compact. */
|
||||
actions?: ReactNode;
|
||||
/** `wide` for pages that lay content out in a grid, such as a gallery. Defaults to a reading column. */
|
||||
width?: "default" | "wide";
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -33,6 +35,7 @@ export function PageLayout({
|
||||
testID,
|
||||
titleTestID,
|
||||
actions,
|
||||
width = "default",
|
||||
children,
|
||||
}: PageLayoutProps) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
@@ -54,7 +57,7 @@ export function PageLayout({
|
||||
contentContainerStyle={scrollContentStyle}
|
||||
testID={testID}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<View style={width === "wide" ? styles.contentWide : styles.content}>
|
||||
{showTitle ? (
|
||||
<View style={styles.titleRow} testID={titleTestID}>
|
||||
<Text style={styles.title} testID="page-title">
|
||||
@@ -85,6 +88,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
maxWidth: 720,
|
||||
alignSelf: "center",
|
||||
},
|
||||
contentWide: {
|
||||
padding: theme.spacing[4],
|
||||
paddingTop: theme.spacing[6],
|
||||
width: "100%",
|
||||
maxWidth: 1080,
|
||||
alignSelf: "center",
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
ScrollView as RNScrollView,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
type ScrollViewProps,
|
||||
} from "react-native";
|
||||
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
@@ -29,25 +28,6 @@ import { isWeb } from "@/constants/platform";
|
||||
|
||||
const ScrollView = isWeb ? RNScrollView : GHScrollView;
|
||||
|
||||
// A capped detail scrolls as a whole. Only full-height sheets need section scrollers.
|
||||
function DetailSectionBody({
|
||||
fillAvailableHeight,
|
||||
style,
|
||||
contentContainerStyle,
|
||||
children,
|
||||
...scrollProps
|
||||
}: ScrollViewProps & { fillAvailableHeight: boolean }) {
|
||||
const bodyStyle = useMemo(() => [style, contentContainerStyle], [style, contentContainerStyle]);
|
||||
if (fillAvailableHeight) {
|
||||
return (
|
||||
<ScrollView {...scrollProps} style={style} contentContainerStyle={contentContainerStyle}>
|
||||
{children}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
return <View style={bodyStyle}>{children}</View>;
|
||||
}
|
||||
|
||||
// ---- Content Component ----
|
||||
|
||||
interface ToolCallDetailsContentProps {
|
||||
@@ -60,7 +40,6 @@ interface ToolCallDetailsContentProps {
|
||||
}
|
||||
|
||||
interface DetailStyles {
|
||||
fillAvailableHeight: boolean;
|
||||
sectionFillStyle: StyleProp<ViewStyle>;
|
||||
codeBlockFillStyle: StyleProp<ViewStyle>;
|
||||
codeVerticalScrollStyle: StyleProp<ViewStyle>;
|
||||
@@ -70,6 +49,7 @@ interface DetailStyles {
|
||||
jsonScrollErrorCombined: StyleProp<ViewStyle>;
|
||||
fullBleedContainerStyle: StyleProp<ViewStyle>;
|
||||
loadingContainerStyle: StyleProp<ViewStyle>;
|
||||
resolvedMaxHeight: number | undefined;
|
||||
shouldFill: boolean;
|
||||
isFullBleed: boolean;
|
||||
}
|
||||
@@ -89,6 +69,7 @@ function resolveShouldFill(
|
||||
|
||||
function useDetailStyles(
|
||||
detail: ToolCallDetail | undefined,
|
||||
resolvedMaxHeight: number | undefined,
|
||||
fillAvailableHeight: boolean,
|
||||
): DetailStyles {
|
||||
const isFullBleed = resolveIsFullBleed(detail);
|
||||
@@ -104,14 +85,28 @@ function useDetailStyles(
|
||||
[codeBlockStyle, shouldFill],
|
||||
);
|
||||
const codeVerticalScrollStyle = useMemo(
|
||||
() => [styles.codeVerticalScroll, shouldFill && styles.fillHeight],
|
||||
[shouldFill],
|
||||
() => [
|
||||
styles.codeVerticalScroll,
|
||||
resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }),
|
||||
shouldFill && styles.fillHeight,
|
||||
],
|
||||
[resolvedMaxHeight, shouldFill],
|
||||
);
|
||||
const scrollAreaFillStyle = useMemo(
|
||||
() => [styles.scrollArea, shouldFill && styles.fillHeight],
|
||||
[shouldFill],
|
||||
() => [
|
||||
styles.scrollArea,
|
||||
resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }),
|
||||
shouldFill && styles.fillHeight,
|
||||
],
|
||||
[resolvedMaxHeight, shouldFill],
|
||||
);
|
||||
const scrollAreaStyle = useMemo(
|
||||
() => [
|
||||
styles.scrollArea,
|
||||
resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }),
|
||||
],
|
||||
[resolvedMaxHeight],
|
||||
);
|
||||
const scrollAreaStyle = useMemo(() => [styles.scrollArea], []);
|
||||
const jsonScrollCombined = styles.jsonScroll;
|
||||
const jsonScrollErrorCombined = [styles.jsonScroll, styles.jsonScrollError];
|
||||
const fullBleedContainerStyle = useMemo(
|
||||
@@ -127,7 +122,6 @@ function useDetailStyles(
|
||||
);
|
||||
|
||||
return {
|
||||
fillAvailableHeight,
|
||||
sectionFillStyle,
|
||||
codeBlockFillStyle,
|
||||
codeVerticalScrollStyle,
|
||||
@@ -137,6 +131,7 @@ function useDetailStyles(
|
||||
jsonScrollErrorCombined,
|
||||
fullBleedContainerStyle,
|
||||
loadingContainerStyle,
|
||||
resolvedMaxHeight,
|
||||
shouldFill,
|
||||
isFullBleed,
|
||||
};
|
||||
@@ -165,8 +160,7 @@ function ShellDetailSection({ command, output, ds }: ShellDetailProps) {
|
||||
return (
|
||||
<View style={ds.sectionFillStyle}>
|
||||
<View style={ds.codeBlockFillStyle}>
|
||||
<DetailSectionBody
|
||||
fillAvailableHeight={ds.fillAvailableHeight}
|
||||
<ScrollView
|
||||
style={ds.codeVerticalScrollStyle}
|
||||
contentContainerStyle={styles.codeVerticalContent}
|
||||
nestedScrollEnabled
|
||||
@@ -186,7 +180,7 @@ function ShellDetailSection({ command, output, ds }: ShellDetailProps) {
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</DetailSectionBody>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -202,7 +196,11 @@ function EditDetailSection({ diffLines, ds }: EditDetailProps) {
|
||||
<View style={ds.sectionFillStyle}>
|
||||
{diffLines ? (
|
||||
<View style={ds.codeBlockFillStyle}>
|
||||
<DiffViewer diffLines={diffLines} fillAvailableHeight={ds.shouldFill} />
|
||||
<DiffViewer
|
||||
diffLines={diffLines}
|
||||
maxHeight={ds.resolvedMaxHeight}
|
||||
fillAvailableHeight={ds.shouldFill}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -230,8 +228,7 @@ function ScrollableTextSection({
|
||||
[content, filePath],
|
||||
);
|
||||
const body = (
|
||||
<DetailSectionBody
|
||||
fillAvailableHeight={ds.fillAvailableHeight}
|
||||
<ScrollView
|
||||
style={ds.scrollAreaFillStyle}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
nestedScrollEnabled
|
||||
@@ -246,7 +243,7 @@ function ScrollableTextSection({
|
||||
</Text>
|
||||
)}
|
||||
</ScrollView>
|
||||
</DetailSectionBody>
|
||||
</ScrollView>
|
||||
);
|
||||
if (!wrapInSectionFill) return body;
|
||||
return <View style={ds.sectionFillStyle}>{body}</View>;
|
||||
@@ -261,8 +258,7 @@ interface FetchDetailProps {
|
||||
function FetchDetailSection({ url, result, ds }: FetchDetailProps) {
|
||||
return (
|
||||
<View style={ds.sectionFillStyle}>
|
||||
<DetailSectionBody
|
||||
fillAvailableHeight={ds.fillAvailableHeight}
|
||||
<ScrollView
|
||||
style={ds.scrollAreaFillStyle}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
nestedScrollEnabled
|
||||
@@ -273,7 +269,7 @@ function FetchDetailSection({ url, result, ds }: FetchDetailProps) {
|
||||
{result ? `${url}\n\n${result}` : url}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</DetailSectionBody>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -281,8 +277,7 @@ function FetchDetailSection({ url, result, ds }: FetchDetailProps) {
|
||||
function ScrollablePlainTextSection({ text, ds }: { text: string; ds: DetailStyles }) {
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<DetailSectionBody
|
||||
fillAvailableHeight={ds.fillAvailableHeight}
|
||||
<ScrollView
|
||||
style={ds.scrollAreaStyle}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
nestedScrollEnabled
|
||||
@@ -291,7 +286,7 @@ function ScrollablePlainTextSection({ text, ds }: { text: string; ds: DetailStyl
|
||||
<Text selectable style={styles.plainText}>
|
||||
{text}
|
||||
</Text>
|
||||
</DetailSectionBody>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -309,8 +304,7 @@ function buildSearchSections(detail: SearchDetail, ds: DetailStyles): ReactNode[
|
||||
if (detail.content) {
|
||||
out.push(
|
||||
<View key="search-content" style={styles.section}>
|
||||
<DetailSectionBody
|
||||
fillAvailableHeight={ds.fillAvailableHeight}
|
||||
<ScrollView
|
||||
style={ds.scrollAreaStyle}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
nestedScrollEnabled
|
||||
@@ -321,7 +315,7 @@ function buildSearchSections(detail: SearchDetail, ds: DetailStyles): ReactNode[
|
||||
{detail.content}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</DetailSectionBody>
|
||||
</ScrollView>
|
||||
</View>,
|
||||
);
|
||||
}
|
||||
@@ -554,8 +548,7 @@ export function ToolCallDetailsContent({
|
||||
}: ToolCallDetailsContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const resolvedMaxHeight = fillAvailableHeight ? undefined : (maxHeight ?? 300);
|
||||
// The allowance belongs to the whole disclosure, rather than each section.
|
||||
const ds = useDetailStyles(detail, fillAvailableHeight);
|
||||
const ds = useDetailStyles(detail, resolvedMaxHeight, fillAvailableHeight);
|
||||
const diffLines = useDiffLines(detail);
|
||||
|
||||
const sections: ReactNode[] = buildDetailSections(toolName, detail, diffLines, ds, t);
|
||||
@@ -571,17 +564,6 @@ export function ToolCallDetailsContent({
|
||||
return <Text style={styles.emptyStateText}>{t("toolCallDetails.empty")}</Text>;
|
||||
}
|
||||
|
||||
if (resolvedMaxHeight !== undefined) {
|
||||
return (
|
||||
<ScrollView
|
||||
style={inlineUnistylesStyle({ maxHeight: resolvedMaxHeight })}
|
||||
contentContainerStyle={ds.fullBleedContainerStyle}
|
||||
nestedScrollEnabled
|
||||
>
|
||||
{sections}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
return <View style={ds.fullBleedContainerStyle}>{sections}</View>;
|
||||
}
|
||||
|
||||
|
||||
@@ -126,6 +126,10 @@ export function smallIconButtonChromeFrameSize(compact = false): number {
|
||||
return ICON_BUTTON_SIZES.small[controlSizingForLayout(compact)];
|
||||
}
|
||||
|
||||
// Icon buttons sit in header and toolbar rows that clip overflow, so the keyboard focus ring
|
||||
// from public/index.html is drawn inside the frame instead of around it.
|
||||
const FOCUS_RING_INSET = -2;
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
large: {
|
||||
width: bySizing(ICON_BUTTON_SIZES.large),
|
||||
@@ -135,8 +139,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
outlineWidth: 0,
|
||||
outlineColor: "transparent",
|
||||
outlineOffset: FOCUS_RING_INSET,
|
||||
},
|
||||
small: {
|
||||
width: ICON_BUTTON_SIZES.small.pointer,
|
||||
@@ -146,8 +149,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
outlineWidth: 0,
|
||||
outlineColor: "transparent",
|
||||
outlineOffset: FOCUS_RING_INSET,
|
||||
},
|
||||
smallTouch: {
|
||||
width: ICON_BUTTON_SIZES.small.touch,
|
||||
@@ -157,8 +159,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
outlineWidth: 0,
|
||||
outlineColor: "transparent",
|
||||
outlineOffset: FOCUS_RING_INSET,
|
||||
},
|
||||
round: {
|
||||
borderRadius: theme.borderRadius.full,
|
||||
|
||||
@@ -43,6 +43,7 @@ vi.mock("@gorhom/portal", () => ({
|
||||
|
||||
/** Every `waitFor` Gorhom received, in render order. Gorhom rebuilds its content pan per value. */
|
||||
const sheetWaitFor = vi.hoisted(() => [] as unknown[]);
|
||||
const sheetSimultaneousHandlers = vi.hoisted(() => [] as unknown[]);
|
||||
|
||||
vi.mock("@gorhom/bottom-sheet", async () => {
|
||||
const { Portal } = await import("@gorhom/portal");
|
||||
@@ -55,10 +56,17 @@ vi.mock("@gorhom/bottom-sheet", async () => {
|
||||
children,
|
||||
stackBehavior,
|
||||
waitFor,
|
||||
}: { children?: React.ReactNode; stackBehavior?: string; waitFor?: unknown },
|
||||
simultaneousHandlers,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
stackBehavior?: string;
|
||||
waitFor?: unknown;
|
||||
simultaneousHandlers?: unknown;
|
||||
},
|
||||
_ref,
|
||||
) => {
|
||||
sheetWaitFor.push(waitFor);
|
||||
sheetSimultaneousHandlers.push(simultaneousHandlers);
|
||||
return React.createElement(
|
||||
Portal,
|
||||
null,
|
||||
@@ -259,6 +267,7 @@ describe("IsolatedBottomSheetModal content drag", () => {
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
sheetWaitFor.length = 0;
|
||||
sheetSimultaneousHandlers.length = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -275,11 +284,19 @@ describe("IsolatedBottomSheetModal content drag", () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderSheet(content: React.ReactNode) {
|
||||
function renderSheet(
|
||||
content: React.ReactNode,
|
||||
handlers: Pick<
|
||||
React.ComponentProps<typeof IsolatedBottomSheetModal>,
|
||||
"waitFor" | "simultaneousHandlers"
|
||||
> = {},
|
||||
) {
|
||||
act(() => {
|
||||
root.render(
|
||||
<>
|
||||
<IsolatedBottomSheetModal contextBridge={null}>{content}</IsolatedBottomSheetModal>
|
||||
<IsolatedBottomSheetModal contextBridge={null} {...handlers}>
|
||||
{content}
|
||||
</IsolatedBottomSheetModal>
|
||||
<PortalHostProbe />
|
||||
</>,
|
||||
);
|
||||
@@ -291,9 +308,106 @@ describe("IsolatedBottomSheetModal content drag", () => {
|
||||
expect(sheetWaitFor.at(-1)).toBeUndefined();
|
||||
|
||||
renderSheet(<ContentDrag />);
|
||||
expect(sheetWaitFor.at(-1)).toBe(dragGesture);
|
||||
expect(sheetSimultaneousHandlers.at(-1)).toEqual([dragGesture]);
|
||||
expect(sheetWaitFor.at(-1)).toEqual([dragGesture]);
|
||||
|
||||
renderSheet(null);
|
||||
expect(sheetWaitFor.at(-1)).toBeUndefined();
|
||||
expect(sheetSimultaneousHandlers.at(-1)).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"preserves supplied relations when a content drag mounts and unmounts (array=%s)",
|
||||
(array) => {
|
||||
const waiting = React.createRef<React.ComponentType>();
|
||||
const simultaneous = React.createRef<React.ComponentType>();
|
||||
const handlers = {
|
||||
waitFor: array ? [waiting] : waiting,
|
||||
simultaneousHandlers: array ? [simultaneous] : simultaneous,
|
||||
};
|
||||
renderSheet(<ContentDrag />, handlers);
|
||||
expect(sheetWaitFor.at(-1)).toEqual([waiting, dragGesture]);
|
||||
expect(sheetSimultaneousHandlers.at(-1)).toEqual([simultaneous, dragGesture]);
|
||||
renderSheet(null, handlers);
|
||||
expect(sheetWaitFor.at(-1)).toBe(handlers.waitFor);
|
||||
expect(sheetSimultaneousHandlers.at(-1)).toBe(handlers.simultaneousHandlers);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Load the real vendored implementations at runtime without typechecking Gorhom's entire source
|
||||
// tree (its hooks barrel reaches upstream files that don't compile under the app's TS settings).
|
||||
const gorhomSource = "@gorhom/bottom-sheet/src";
|
||||
const { default: BottomSheetDraggableView } = await import(
|
||||
`${gorhomSource}/components/bottomSheetDraggableView/BottomSheetDraggableView`
|
||||
);
|
||||
const { BottomSheetInternalContext } = await import(`${gorhomSource}/contexts/internal`);
|
||||
const { BottomSheetDraggableContext, BottomSheetGestureHandlersContext } = await import(
|
||||
`${gorhomSource}/contexts/gesture`
|
||||
);
|
||||
const rnghSource = "react-native-gesture-handler/src";
|
||||
const { extractGestureRelations } = await import(
|
||||
`${rnghSource}/handlers/gestures/GestureDetector/utils`
|
||||
);
|
||||
import { Gesture, type GestureType } from "react-native-gesture-handler";
|
||||
|
||||
const noGestureWork = () => {};
|
||||
const gestureHandlerContext = {
|
||||
contentPanGestureHandler: {
|
||||
handleOnStart: noGestureWork,
|
||||
handleOnChange: noGestureWork,
|
||||
handleOnEnd: noGestureWork,
|
||||
handleOnFinalize: noGestureWork,
|
||||
},
|
||||
handlePanGestureHandler: {
|
||||
handleOnStart: noGestureWork,
|
||||
handleOnChange: noGestureWork,
|
||||
handleOnEnd: noGestureWork,
|
||||
handleOnFinalize: noGestureWork,
|
||||
},
|
||||
};
|
||||
function dragInternalContext(relations: SheetContentDragGesture | SheetContentDragGesture[]) {
|
||||
return {
|
||||
enableContentPanningGesture: true,
|
||||
waitFor: relations,
|
||||
simultaneousHandlers: relations,
|
||||
};
|
||||
}
|
||||
|
||||
describe("bottom sheet native drag relations", () => {
|
||||
it.each([false, true])(
|
||||
"resolves both relations through the real content pan (array=%s)",
|
||||
(array) => {
|
||||
const container = document.createElement("div");
|
||||
const root = createRoot(container);
|
||||
const child = Gesture.Pan();
|
||||
child.handlerTag = 42;
|
||||
const ref = { current: child };
|
||||
const relations = array ? [ref] : ref;
|
||||
let resolved: ReturnType<typeof extractGestureRelations> | undefined;
|
||||
const internal = dragInternalContext(relations);
|
||||
function Probe() {
|
||||
const pan = useContext(BottomSheetDraggableContext) as GestureType | null;
|
||||
if (pan) resolved = extractGestureRelations(pan);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
act(() =>
|
||||
root.render(
|
||||
<BottomSheetInternalContext.Provider value={internal}>
|
||||
<BottomSheetGestureHandlersContext.Provider value={gestureHandlerContext}>
|
||||
<BottomSheetDraggableView>
|
||||
<Probe />
|
||||
</BottomSheetDraggableView>
|
||||
</BottomSheetGestureHandlersContext.Provider>
|
||||
</BottomSheetInternalContext.Provider>,
|
||||
),
|
||||
);
|
||||
expect(resolved?.waitFor).toEqual([42]);
|
||||
expect(resolved?.simultaneousHandlers).toEqual([42]);
|
||||
} finally {
|
||||
act(() => root.unmount());
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -105,6 +105,8 @@ export const IsolatedBottomSheetModal = forwardRef<
|
||||
contextBridge,
|
||||
backdropOpacity,
|
||||
enableDismissOnClose = true,
|
||||
waitFor,
|
||||
simultaneousHandlers,
|
||||
...bottomSheetProps
|
||||
} = props;
|
||||
// State, not a ref the child fills in: Gorhom builds its pan once per `waitFor`, and RNGH reads
|
||||
@@ -115,6 +117,19 @@ export const IsolatedBottomSheetModal = forwardRef<
|
||||
setContentDragGesture(gesture);
|
||||
return () => setContentDragGesture((current) => (current === gesture ? undefined : current));
|
||||
}, []);
|
||||
// RNGH Android's shouldBeCancelledByActiveHandler treats a waiting ancestor's raw ACTIVE
|
||||
// state as ownership and cancels its child at activation. Simultaneity lets the child activate;
|
||||
// waitFor still keeps the sheet stationary until the child fails.
|
||||
const contentDragHandlers = useMemo(() => {
|
||||
if (!contentDragGesture) return { waitFor, simultaneousHandlers };
|
||||
return {
|
||||
waitFor: [...(waitFor ? [waitFor].flat() : []), contentDragGesture],
|
||||
simultaneousHandlers: [
|
||||
...(simultaneousHandlers ? [simultaneousHandlers].flat() : []),
|
||||
contentDragGesture,
|
||||
],
|
||||
};
|
||||
}, [contentDragGesture, waitFor, simultaneousHandlers]);
|
||||
const renderBackdrop = useCallback(
|
||||
(backdropProps: BottomSheetBackdropProps) => (
|
||||
<SheetBackdrop
|
||||
@@ -132,7 +147,8 @@ export const IsolatedBottomSheetModal = forwardRef<
|
||||
enableDismissOnClose={enableDismissOnClose}
|
||||
stackBehavior={presentation}
|
||||
backdropComponent={backdropOpacity === undefined ? undefined : renderBackdrop}
|
||||
waitFor={contentDragGesture}
|
||||
waitFor={contentDragHandlers.waitFor}
|
||||
simultaneousHandlers={contentDragHandlers.simultaneousHandlers}
|
||||
>
|
||||
<BottomSheetScope attachContentDrag={attachContentDrag}>
|
||||
{contextBridge ? contextBridge(children) : children}
|
||||
|
||||
@@ -204,7 +204,7 @@ export interface MessageInputProps {
|
||||
onSubmitLoadingPress?: () => void;
|
||||
/** Intercept key press events before default handling. Return true to prevent default. */
|
||||
onKeyPress?: (event: ComposerKeyPressEvent) => boolean;
|
||||
/** Reports cursor selection updates from the underlying input. */
|
||||
/** Reports selection updates from user input and programmatic replacements. */
|
||||
onSelectionChange?: (selection: { start: number; end: number }) => void;
|
||||
onFocusChange?: (focused: boolean) => void;
|
||||
onHeightChange?: (height: number) => void;
|
||||
@@ -1468,11 +1468,18 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
if (nextText === "") {
|
||||
textInputRef.current?.reset();
|
||||
} else {
|
||||
textInputRef.current?.replaceText(nextText, selection);
|
||||
textInputRef.current?.replaceText(nextText, selectionRef.current);
|
||||
}
|
||||
onChangeText(nextText);
|
||||
// iOS does not emit a selection event for a programmatic text replacement.
|
||||
onSelectionChangeCallback?.(selectionRef.current);
|
||||
},
|
||||
[onChangeText, updateComposerHeightForText, updateLiveTextPresence],
|
||||
[
|
||||
onChangeText,
|
||||
onSelectionChangeCallback,
|
||||
updateComposerHeightForText,
|
||||
updateLiveTextPresence,
|
||||
],
|
||||
);
|
||||
|
||||
useLayoutEffect(
|
||||
|
||||
@@ -209,6 +209,8 @@ interface UseGitActionsResult {
|
||||
gitActions: GitActions;
|
||||
branchLabel: string;
|
||||
isGit: boolean;
|
||||
/** Archives this workspace, Git or not. Null while it cannot be archived. */
|
||||
archiveWorkspace: (() => void) | null;
|
||||
}
|
||||
|
||||
interface UseWorkspaceScreenArchiveControllerInput {
|
||||
@@ -266,6 +268,13 @@ function canArchiveWorkspace(
|
||||
);
|
||||
}
|
||||
|
||||
function availableArchive(
|
||||
controller: { canArchive: boolean; isArchiving: boolean },
|
||||
archive: () => void,
|
||||
): (() => void) | null {
|
||||
return controller.canArchive && !controller.isArchiving ? archive : null;
|
||||
}
|
||||
|
||||
function useWorkspaceScreenArchiveController({
|
||||
serverId,
|
||||
activeWorkspaceSelection,
|
||||
@@ -864,7 +873,12 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
[gitActionsInput, baseRefLabel, hasPullRequest, forge, t],
|
||||
);
|
||||
|
||||
return { gitActions, branchLabel, isGit };
|
||||
return {
|
||||
gitActions,
|
||||
branchLabel,
|
||||
isGit,
|
||||
archiveWorkspace: availableArchive(archiveController, handleArchiveWorkspace),
|
||||
};
|
||||
}
|
||||
|
||||
function translateGitActions(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { pluginGallery } from "./plugin-gallery";
|
||||
|
||||
export const en = {
|
||||
pluginCombobox: {
|
||||
searching: "Searching…",
|
||||
@@ -2421,6 +2423,7 @@ export const en = {
|
||||
host: "Overview",
|
||||
},
|
||||
plugins: {
|
||||
gallery: pluginGallery.en,
|
||||
title: "Plugins",
|
||||
screens: {
|
||||
open: "Open",
|
||||
@@ -2458,21 +2461,22 @@ export const en = {
|
||||
enabling: "Enabling…",
|
||||
disable: "Disable",
|
||||
disabling: "Disabling…",
|
||||
remove: "Remove",
|
||||
removing: "Removing…",
|
||||
remove: "Uninstall",
|
||||
removing: "Uninstalling…",
|
||||
},
|
||||
feedback: {
|
||||
installed: "Installed {{name}}",
|
||||
reload: "Reloaded {{name}}",
|
||||
enable: "Enabled {{name}}",
|
||||
disable: "Disabled {{name}}",
|
||||
remove: "Removed {{name}}",
|
||||
remove: "Uninstalled {{name}}",
|
||||
kept: "Kept {{name}}",
|
||||
globalEnabled: "Plugins enabled",
|
||||
globalDisabled: "Plugins disabled",
|
||||
},
|
||||
removeConfirmTitle: "Remove {{name}}?",
|
||||
removeConfirmMessage: "This removes the configuration. The source directory is not deleted.",
|
||||
removeConfirmTitle: "Uninstall {{name}}?",
|
||||
removeConfirmMessage: "Its download and settings on this host are deleted.",
|
||||
removeConfirmLocalMessage: "Its settings are deleted. Its folder stays on disk.",
|
||||
states: {
|
||||
offlineTitle: "Plugin host is offline",
|
||||
offlineDescription: "Reconnect to this host to manage its plugins.",
|
||||
|
||||
@@ -0,0 +1,559 @@
|
||||
/** Settings → Plugins: the installed list, the directory and a plugin's page. */
|
||||
export const pluginGallery = {
|
||||
en: {
|
||||
tabs: { installed: "Installed", installedCount: "Installed · {{count}}", browse: "Browse" },
|
||||
search: { installed: "Search installed", browse: "Search plugins", clear: "Clear search" },
|
||||
filters: {
|
||||
category: "Category",
|
||||
allCategories: "All categories",
|
||||
sort: "Sort",
|
||||
popular: "Most installed",
|
||||
recent: "Recently updated",
|
||||
name: "Name",
|
||||
},
|
||||
sections: {
|
||||
updates: "Updates",
|
||||
installed: "Installed",
|
||||
featured: "Featured",
|
||||
all: "All plugins",
|
||||
overview: "Overview",
|
||||
details: "Details",
|
||||
},
|
||||
installs: "{{installs}} installs",
|
||||
installs_one: "{{installs}} install",
|
||||
menu: "More plugin actions",
|
||||
actions: {
|
||||
install: "Install",
|
||||
installing: "Installing…",
|
||||
installed: "Installed",
|
||||
retry: "Retry",
|
||||
update: "Update",
|
||||
updating: "Updating…",
|
||||
updateAll: "Update all",
|
||||
turnOn: "Turn on",
|
||||
turnOnAll: "Turn on plugins",
|
||||
turnOff: "Turn off plugins",
|
||||
installFromSource: "Install from source",
|
||||
submit: "Submit a plugin",
|
||||
repository: "Repository",
|
||||
showMore: "Show more",
|
||||
browse: "Browse plugins",
|
||||
},
|
||||
off: { title: "Plugins are off on {{host}}" },
|
||||
empty: { noMatches: "No plugins match" },
|
||||
directory: {
|
||||
errorTitle: "Unable to load the plugin directory",
|
||||
update: "Update this host to browse plugins",
|
||||
},
|
||||
install: {
|
||||
title: "Install {{name}}?",
|
||||
sourceTitle: "Install from source",
|
||||
author: "Author",
|
||||
source: "Source",
|
||||
version: "Version",
|
||||
warningTitle: "Runs with your access on {{host}}",
|
||||
warningDescription: "Plugins are not sandboxed. Install only code you trust.",
|
||||
turnsOn: "Plugins are not sandboxed. Installing also turns plugins on.",
|
||||
},
|
||||
update: { updated: "{{name}} updated", updatedMany: "{{count}} plugins updated" },
|
||||
details: { license: "License", updated: "Updated", openImage: "Open image" },
|
||||
missing: "This plugin is not installed or listed",
|
||||
},
|
||||
ar: {
|
||||
tabs: { installed: "المثبّتة", installedCount: "المثبّتة · {{count}}", browse: "تصفّح" },
|
||||
search: { installed: "ابحث في المثبّتة", browse: "ابحث عن إضافات", clear: "مسح البحث" },
|
||||
filters: {
|
||||
category: "الفئة",
|
||||
allCategories: "كل الفئات",
|
||||
sort: "الترتيب",
|
||||
popular: "الأكثر تثبيتًا",
|
||||
recent: "المحدّثة مؤخرًا",
|
||||
name: "الاسم",
|
||||
},
|
||||
sections: {
|
||||
updates: "التحديثات",
|
||||
installed: "المثبّتة",
|
||||
featured: "مميّزة",
|
||||
all: "كل الإضافات",
|
||||
overview: "نظرة عامة",
|
||||
details: "التفاصيل",
|
||||
},
|
||||
installs: "{{installs}} تثبيت",
|
||||
installs_one: "{{installs}} تثبيت",
|
||||
menu: "إجراءات أخرى للإضافات",
|
||||
actions: {
|
||||
install: "تثبيت",
|
||||
installing: "جارٍ التثبيت…",
|
||||
installed: "مثبّتة",
|
||||
retry: "إعادة المحاولة",
|
||||
update: "تحديث",
|
||||
updating: "جارٍ التحديث…",
|
||||
updateAll: "تحديث الكل",
|
||||
turnOn: "تشغيل",
|
||||
turnOnAll: "تشغيل الإضافات",
|
||||
turnOff: "إيقاف الإضافات",
|
||||
installFromSource: "التثبيت من مصدر",
|
||||
submit: "إرسال إضافة",
|
||||
repository: "المستودع",
|
||||
showMore: "عرض المزيد",
|
||||
browse: "تصفّح الإضافات",
|
||||
},
|
||||
off: { title: "الإضافات متوقفة على {{host}}" },
|
||||
empty: { noMatches: "لا توجد إضافات مطابقة" },
|
||||
directory: {
|
||||
errorTitle: "تعذّر تحميل دليل الإضافات",
|
||||
update: "حدّث هذا المضيف لتصفّح الإضافات",
|
||||
},
|
||||
install: {
|
||||
title: "تثبيت {{name}}؟",
|
||||
sourceTitle: "التثبيت من مصدر",
|
||||
author: "المؤلف",
|
||||
source: "المصدر",
|
||||
version: "الإصدار",
|
||||
warningTitle: "تعمل بصلاحياتك على {{host}}",
|
||||
warningDescription: "الإضافات غير معزولة. ثبّت فقط التعليمات البرمجية التي تثق بها.",
|
||||
turnsOn: "الإضافات غير معزولة. يؤدي التثبيت أيضًا إلى تشغيل الإضافات.",
|
||||
},
|
||||
update: { updated: "تم تحديث {{name}}", updatedMany: "تم تحديث {{count}} إضافات" },
|
||||
details: { license: "الترخيص", updated: "آخر تحديث", openImage: "فتح الصورة" },
|
||||
missing: "هذه الإضافة غير مثبّتة وغير مدرجة",
|
||||
},
|
||||
es: {
|
||||
tabs: { installed: "Instalados", installedCount: "Instalados · {{count}}", browse: "Explorar" },
|
||||
search: { installed: "Buscar instalados", browse: "Buscar plugins", clear: "Borrar búsqueda" },
|
||||
filters: {
|
||||
category: "Categoría",
|
||||
allCategories: "Todas las categorías",
|
||||
sort: "Orden",
|
||||
popular: "Más instalados",
|
||||
recent: "Actualizados recientemente",
|
||||
name: "Nombre",
|
||||
},
|
||||
sections: {
|
||||
updates: "Actualizaciones",
|
||||
installed: "Instalados",
|
||||
featured: "Destacados",
|
||||
all: "Todos los plugins",
|
||||
overview: "Descripción general",
|
||||
details: "Detalles",
|
||||
},
|
||||
installs: "{{installs}} instalaciones",
|
||||
installs_one: "{{installs}} instalación",
|
||||
menu: "Más acciones de plugins",
|
||||
actions: {
|
||||
install: "Instalar",
|
||||
installing: "Instalando…",
|
||||
installed: "Instalado",
|
||||
retry: "Reintentar",
|
||||
update: "Actualizar",
|
||||
updating: "Actualizando…",
|
||||
updateAll: "Actualizar todo",
|
||||
turnOn: "Activar",
|
||||
turnOnAll: "Activar plugins",
|
||||
turnOff: "Desactivar plugins",
|
||||
installFromSource: "Instalar desde una fuente",
|
||||
submit: "Enviar un plugin",
|
||||
repository: "Repositorio",
|
||||
showMore: "Mostrar más",
|
||||
browse: "Explorar plugins",
|
||||
},
|
||||
off: { title: "Los plugins están desactivados en {{host}}" },
|
||||
empty: { noMatches: "Ningún plugin coincide" },
|
||||
directory: {
|
||||
errorTitle: "No se pudo cargar el directorio de plugins",
|
||||
update: "Actualiza este host para explorar plugins",
|
||||
},
|
||||
install: {
|
||||
title: "¿Instalar {{name}}?",
|
||||
sourceTitle: "Instalar desde una fuente",
|
||||
author: "Autor",
|
||||
source: "Fuente",
|
||||
version: "Versión",
|
||||
warningTitle: "Se ejecuta con tu acceso en {{host}}",
|
||||
warningDescription: "Los plugins no están aislados. Instala solo código de confianza.",
|
||||
turnsOn: "Los plugins no están aislados. Instalar también activa los plugins.",
|
||||
},
|
||||
update: { updated: "{{name}} actualizado", updatedMany: "{{count}} plugins actualizados" },
|
||||
details: { license: "Licencia", updated: "Actualizado", openImage: "Abrir imagen" },
|
||||
missing: "Este plugin no está instalado ni publicado",
|
||||
},
|
||||
fr: {
|
||||
tabs: { installed: "Installés", installedCount: "Installés · {{count}}", browse: "Parcourir" },
|
||||
search: {
|
||||
installed: "Rechercher dans les installés",
|
||||
browse: "Rechercher des plugins",
|
||||
clear: "Effacer la recherche",
|
||||
},
|
||||
filters: {
|
||||
category: "Catégorie",
|
||||
allCategories: "Toutes les catégories",
|
||||
sort: "Tri",
|
||||
popular: "Les plus installés",
|
||||
recent: "Mis à jour récemment",
|
||||
name: "Nom",
|
||||
},
|
||||
sections: {
|
||||
updates: "Mises à jour",
|
||||
installed: "Installés",
|
||||
featured: "À la une",
|
||||
all: "Tous les plugins",
|
||||
overview: "Présentation",
|
||||
details: "Détails",
|
||||
},
|
||||
installs: "{{installs}} installations",
|
||||
installs_one: "{{installs}} installation",
|
||||
menu: "Autres actions sur les plugins",
|
||||
actions: {
|
||||
install: "Installer",
|
||||
installing: "Installation…",
|
||||
installed: "Installé",
|
||||
retry: "Réessayer",
|
||||
update: "Mettre à jour",
|
||||
updating: "Mise à jour…",
|
||||
updateAll: "Tout mettre à jour",
|
||||
turnOn: "Activer",
|
||||
turnOnAll: "Activer les plugins",
|
||||
turnOff: "Désactiver les plugins",
|
||||
installFromSource: "Installer depuis une source",
|
||||
submit: "Proposer un plugin",
|
||||
repository: "Dépôt",
|
||||
showMore: "Afficher plus",
|
||||
browse: "Parcourir les plugins",
|
||||
},
|
||||
off: { title: "Les plugins sont désactivés sur {{host}}" },
|
||||
empty: { noMatches: "Aucun plugin ne correspond" },
|
||||
directory: {
|
||||
errorTitle: "Impossible de charger l’annuaire des plugins",
|
||||
update: "Mettez à jour cet hôte pour parcourir les plugins",
|
||||
},
|
||||
install: {
|
||||
title: "Installer {{name}} ?",
|
||||
sourceTitle: "Installer depuis une source",
|
||||
author: "Auteur",
|
||||
source: "Source",
|
||||
version: "Version",
|
||||
warningTitle: "S’exécute avec vos accès sur {{host}}",
|
||||
warningDescription: "Les plugins ne sont pas isolés. N’installez que du code de confiance.",
|
||||
turnsOn: "Les plugins ne sont pas isolés. L’installation active aussi les plugins.",
|
||||
},
|
||||
update: { updated: "{{name}} mis à jour", updatedMany: "{{count}} plugins mis à jour" },
|
||||
details: { license: "Licence", updated: "Mis à jour", openImage: "Ouvrir l’image" },
|
||||
missing: "Ce plugin n’est ni installé ni publié",
|
||||
},
|
||||
ja: {
|
||||
tabs: {
|
||||
installed: "インストール済み",
|
||||
installedCount: "インストール済み · {{count}}",
|
||||
browse: "見つける",
|
||||
},
|
||||
search: {
|
||||
installed: "インストール済みを検索",
|
||||
browse: "プラグインを検索",
|
||||
clear: "検索をクリア",
|
||||
},
|
||||
filters: {
|
||||
category: "カテゴリ",
|
||||
allCategories: "すべてのカテゴリ",
|
||||
sort: "並び順",
|
||||
popular: "インストール数順",
|
||||
recent: "最近の更新順",
|
||||
name: "名前順",
|
||||
},
|
||||
sections: {
|
||||
updates: "アップデート",
|
||||
installed: "インストール済み",
|
||||
featured: "おすすめ",
|
||||
all: "すべてのプラグイン",
|
||||
overview: "概要",
|
||||
details: "詳細",
|
||||
},
|
||||
installs: "{{installs}} 件のインストール",
|
||||
installs_one: "{{installs}} 件のインストール",
|
||||
menu: "その他のプラグイン操作",
|
||||
actions: {
|
||||
install: "インストール",
|
||||
installing: "インストール中…",
|
||||
installed: "インストール済み",
|
||||
retry: "再試行",
|
||||
update: "アップデート",
|
||||
updating: "アップデート中…",
|
||||
updateAll: "すべてアップデート",
|
||||
turnOn: "オンにする",
|
||||
turnOnAll: "プラグインをオンにする",
|
||||
turnOff: "プラグインをオフにする",
|
||||
installFromSource: "ソースからインストール",
|
||||
submit: "プラグインを申請",
|
||||
repository: "リポジトリ",
|
||||
showMore: "さらに表示",
|
||||
browse: "プラグインを見つける",
|
||||
},
|
||||
off: { title: "{{host}} ではプラグインがオフです" },
|
||||
empty: { noMatches: "一致するプラグインはありません" },
|
||||
directory: {
|
||||
errorTitle: "プラグインディレクトリを読み込めません",
|
||||
update: "プラグインを探すにはこのホストをアップデートしてください",
|
||||
},
|
||||
install: {
|
||||
title: "{{name}} をインストールしますか?",
|
||||
sourceTitle: "ソースからインストール",
|
||||
author: "作者",
|
||||
source: "ソース",
|
||||
version: "バージョン",
|
||||
warningTitle: "{{host}} であなたの権限で動作します",
|
||||
warningDescription:
|
||||
"プラグインはサンドボックス化されていません。信頼できるコードのみインストールしてください。",
|
||||
turnsOn:
|
||||
"プラグインはサンドボックス化されていません。インストールするとプラグインもオンになります。",
|
||||
},
|
||||
update: {
|
||||
updated: "{{name}} をアップデートしました",
|
||||
updatedMany: "{{count}} 個のプラグインをアップデートしました",
|
||||
},
|
||||
details: { license: "ライセンス", updated: "更新日", openImage: "画像を開く" },
|
||||
missing: "このプラグインはインストールも公開もされていません",
|
||||
},
|
||||
ko: {
|
||||
tabs: { installed: "설치됨", installedCount: "설치됨 · {{count}}", browse: "찾아보기" },
|
||||
search: { installed: "설치된 항목 검색", browse: "플러그인 검색", clear: "검색 지우기" },
|
||||
filters: {
|
||||
category: "카테고리",
|
||||
allCategories: "모든 카테고리",
|
||||
sort: "정렬",
|
||||
popular: "설치 많은 순",
|
||||
recent: "최근 업데이트 순",
|
||||
name: "이름순",
|
||||
},
|
||||
sections: {
|
||||
updates: "업데이트",
|
||||
installed: "설치됨",
|
||||
featured: "추천",
|
||||
all: "모든 플러그인",
|
||||
overview: "개요",
|
||||
details: "세부 정보",
|
||||
},
|
||||
installs: "{{installs}}회 설치",
|
||||
installs_one: "{{installs}}회 설치",
|
||||
menu: "기타 플러그인 작업",
|
||||
actions: {
|
||||
install: "설치",
|
||||
installing: "설치 중…",
|
||||
installed: "설치됨",
|
||||
retry: "다시 시도",
|
||||
update: "업데이트",
|
||||
updating: "업데이트 중…",
|
||||
updateAll: "모두 업데이트",
|
||||
turnOn: "켜기",
|
||||
turnOnAll: "플러그인 켜기",
|
||||
turnOff: "플러그인 끄기",
|
||||
installFromSource: "소스에서 설치",
|
||||
submit: "플러그인 제출",
|
||||
repository: "저장소",
|
||||
showMore: "더 보기",
|
||||
browse: "플러그인 찾아보기",
|
||||
},
|
||||
off: { title: "{{host}}에서 플러그인이 꺼져 있습니다" },
|
||||
empty: { noMatches: "일치하는 플러그인이 없습니다" },
|
||||
directory: {
|
||||
errorTitle: "플러그인 디렉터리를 로드할 수 없습니다",
|
||||
update: "플러그인을 찾아보려면 이 호스트를 업데이트하세요",
|
||||
},
|
||||
install: {
|
||||
title: "{{name}}을(를) 설치할까요?",
|
||||
sourceTitle: "소스에서 설치",
|
||||
author: "작성자",
|
||||
source: "소스",
|
||||
version: "버전",
|
||||
warningTitle: "{{host}}에서 사용자 권한으로 실행됩니다",
|
||||
warningDescription: "플러그인은 격리되지 않습니다. 신뢰하는 코드만 설치하세요.",
|
||||
turnsOn: "플러그인은 격리되지 않습니다. 설치하면 플러그인도 켜집니다.",
|
||||
},
|
||||
update: { updated: "{{name}} 업데이트됨", updatedMany: "플러그인 {{count}}개 업데이트됨" },
|
||||
details: { license: "라이선스", updated: "업데이트", openImage: "이미지 열기" },
|
||||
missing: "이 플러그인은 설치되지 않았고 등록되어 있지도 않습니다",
|
||||
},
|
||||
"pt-BR": {
|
||||
tabs: { installed: "Instalados", installedCount: "Instalados · {{count}}", browse: "Explorar" },
|
||||
search: { installed: "Buscar instalados", browse: "Buscar plugins", clear: "Limpar busca" },
|
||||
filters: {
|
||||
category: "Categoria",
|
||||
allCategories: "Todas as categorias",
|
||||
sort: "Ordem",
|
||||
popular: "Mais instalados",
|
||||
recent: "Atualizados recentemente",
|
||||
name: "Nome",
|
||||
},
|
||||
sections: {
|
||||
updates: "Atualizações",
|
||||
installed: "Instalados",
|
||||
featured: "Destaques",
|
||||
all: "Todos os plugins",
|
||||
overview: "Visão geral",
|
||||
details: "Detalhes",
|
||||
},
|
||||
installs: "{{installs}} instalações",
|
||||
installs_one: "{{installs}} instalação",
|
||||
menu: "Mais ações de plugins",
|
||||
actions: {
|
||||
install: "Instalar",
|
||||
installing: "Instalando…",
|
||||
installed: "Instalado",
|
||||
retry: "Tentar novamente",
|
||||
update: "Atualizar",
|
||||
updating: "Atualizando…",
|
||||
updateAll: "Atualizar tudo",
|
||||
turnOn: "Ativar",
|
||||
turnOnAll: "Ativar plugins",
|
||||
turnOff: "Desativar plugins",
|
||||
installFromSource: "Instalar de uma fonte",
|
||||
submit: "Enviar um plugin",
|
||||
repository: "Repositório",
|
||||
showMore: "Mostrar mais",
|
||||
browse: "Explorar plugins",
|
||||
},
|
||||
off: { title: "Os plugins estão desativados em {{host}}" },
|
||||
empty: { noMatches: "Nenhum plugin corresponde" },
|
||||
directory: {
|
||||
errorTitle: "Não foi possível carregar o diretório de plugins",
|
||||
update: "Atualize este host para explorar plugins",
|
||||
},
|
||||
install: {
|
||||
title: "Instalar {{name}}?",
|
||||
sourceTitle: "Instalar de uma fonte",
|
||||
author: "Autor",
|
||||
source: "Fonte",
|
||||
version: "Versão",
|
||||
warningTitle: "Roda com o seu acesso em {{host}}",
|
||||
warningDescription: "Plugins não são isolados. Instale apenas código confiável.",
|
||||
turnsOn: "Plugins não são isolados. Instalar também ativa os plugins.",
|
||||
},
|
||||
update: { updated: "{{name}} atualizado", updatedMany: "{{count}} plugins atualizados" },
|
||||
details: { license: "Licença", updated: "Atualizado", openImage: "Abrir imagem" },
|
||||
missing: "Este plugin não está instalado nem publicado",
|
||||
},
|
||||
ru: {
|
||||
tabs: {
|
||||
installed: "Установленные",
|
||||
installedCount: "Установленные · {{count}}",
|
||||
browse: "Обзор",
|
||||
},
|
||||
search: {
|
||||
installed: "Поиск среди установленных",
|
||||
browse: "Поиск плагинов",
|
||||
clear: "Очистить поиск",
|
||||
},
|
||||
filters: {
|
||||
category: "Категория",
|
||||
allCategories: "Все категории",
|
||||
sort: "Сортировка",
|
||||
popular: "По установкам",
|
||||
recent: "Недавно обновлённые",
|
||||
name: "По названию",
|
||||
},
|
||||
sections: {
|
||||
updates: "Обновления",
|
||||
installed: "Установленные",
|
||||
featured: "Рекомендуемые",
|
||||
all: "Все плагины",
|
||||
overview: "Обзор",
|
||||
details: "Подробности",
|
||||
},
|
||||
installs: "Установок: {{installs}}",
|
||||
installs_one: "Установок: {{installs}}",
|
||||
menu: "Другие действия с плагинами",
|
||||
actions: {
|
||||
install: "Установить",
|
||||
installing: "Установка…",
|
||||
installed: "Установлен",
|
||||
retry: "Повторить",
|
||||
update: "Обновить",
|
||||
updating: "Обновление…",
|
||||
updateAll: "Обновить все",
|
||||
turnOn: "Включить",
|
||||
turnOnAll: "Включить плагины",
|
||||
turnOff: "Выключить плагины",
|
||||
installFromSource: "Установить из источника",
|
||||
submit: "Предложить плагин",
|
||||
repository: "Репозиторий",
|
||||
showMore: "Показать ещё",
|
||||
browse: "Обзор плагинов",
|
||||
},
|
||||
off: { title: "Плагины выключены на {{host}}" },
|
||||
empty: { noMatches: "Нет подходящих плагинов" },
|
||||
directory: {
|
||||
errorTitle: "Не удалось загрузить каталог плагинов",
|
||||
update: "Обновите этот хост, чтобы просматривать плагины",
|
||||
},
|
||||
install: {
|
||||
title: "Установить {{name}}?",
|
||||
sourceTitle: "Установить из источника",
|
||||
author: "Автор",
|
||||
source: "Источник",
|
||||
version: "Версия",
|
||||
warningTitle: "Работает с вашим доступом на {{host}}",
|
||||
warningDescription: "Плагины не изолированы. Устанавливайте только код, которому доверяете.",
|
||||
turnsOn: "Плагины не изолированы. Установка также включит плагины.",
|
||||
},
|
||||
update: { updated: "{{name}} обновлён", updatedMany: "Обновлено плагинов: {{count}}" },
|
||||
details: { license: "Лицензия", updated: "Обновлён", openImage: "Открыть изображение" },
|
||||
missing: "Этот плагин не установлен и не опубликован",
|
||||
},
|
||||
"zh-CN": {
|
||||
tabs: { installed: "已安装", installedCount: "已安装 · {{count}}", browse: "浏览" },
|
||||
search: { installed: "搜索已安装", browse: "搜索插件", clear: "清除搜索" },
|
||||
filters: {
|
||||
category: "分类",
|
||||
allCategories: "全部分类",
|
||||
sort: "排序",
|
||||
popular: "安装最多",
|
||||
recent: "最近更新",
|
||||
name: "名称",
|
||||
},
|
||||
sections: {
|
||||
updates: "更新",
|
||||
installed: "已安装",
|
||||
featured: "精选",
|
||||
all: "全部插件",
|
||||
overview: "概览",
|
||||
details: "详情",
|
||||
},
|
||||
installs: "{{installs}} 次安装",
|
||||
installs_one: "{{installs}} 次安装",
|
||||
menu: "更多插件操作",
|
||||
actions: {
|
||||
install: "安装",
|
||||
installing: "正在安装…",
|
||||
installed: "已安装",
|
||||
retry: "重试",
|
||||
update: "更新",
|
||||
updating: "正在更新…",
|
||||
updateAll: "全部更新",
|
||||
turnOn: "开启",
|
||||
turnOnAll: "开启插件",
|
||||
turnOff: "关闭插件",
|
||||
installFromSource: "从来源安装",
|
||||
submit: "提交插件",
|
||||
repository: "仓库",
|
||||
showMore: "显示更多",
|
||||
browse: "浏览插件",
|
||||
},
|
||||
off: { title: "{{host}} 上的插件已关闭" },
|
||||
empty: { noMatches: "没有匹配的插件" },
|
||||
directory: {
|
||||
errorTitle: "无法加载插件目录",
|
||||
update: "请更新此主机以浏览插件",
|
||||
},
|
||||
install: {
|
||||
title: "安装 {{name}}?",
|
||||
sourceTitle: "从来源安装",
|
||||
author: "作者",
|
||||
source: "来源",
|
||||
version: "版本",
|
||||
warningTitle: "以你在 {{host}} 上的权限运行",
|
||||
warningDescription: "插件没有沙箱隔离。只安装你信任的代码。",
|
||||
turnsOn: "插件没有沙箱隔离。安装后也会开启插件。",
|
||||
},
|
||||
update: { updated: "已更新 {{name}}", updatedMany: "已更新 {{count}} 个插件" },
|
||||
details: { license: "许可证", updated: "更新于", openImage: "打开图片" },
|
||||
missing: "此插件既未安装也未上架",
|
||||
},
|
||||
} as const;
|
||||
@@ -1,3 +1,5 @@
|
||||
import { pluginGallery } from "./plugin-gallery";
|
||||
|
||||
const shared = {
|
||||
logs: {
|
||||
action: "Logs",
|
||||
@@ -13,6 +15,7 @@ const shared = {
|
||||
export const pluginSettings = {
|
||||
ar: {
|
||||
...shared,
|
||||
gallery: pluginGallery.ar,
|
||||
screens: {
|
||||
open: "فتح",
|
||||
offline: "اتصل بهذا المضيف لفتح إعدادات الإضافة.",
|
||||
@@ -41,21 +44,22 @@ export const pluginSettings = {
|
||||
enabling: "جارٍ التفعيل…",
|
||||
disable: "تعطيل",
|
||||
disabling: "جارٍ التعطيل…",
|
||||
remove: "إزالة",
|
||||
removing: "جارٍ الإزالة…",
|
||||
remove: "إلغاء التثبيت",
|
||||
removing: "جارٍ إلغاء التثبيت…",
|
||||
},
|
||||
feedback: {
|
||||
installed: "تم تثبيت {{name}}",
|
||||
reload: "تمت إعادة تحميل {{name}}",
|
||||
enable: "تم تفعيل {{name}}",
|
||||
disable: "تم تعطيل {{name}}",
|
||||
remove: "تمت إزالة {{name}}",
|
||||
remove: "تم إلغاء تثبيت {{name}}",
|
||||
kept: "تم الإبقاء على {{name}}",
|
||||
globalEnabled: "تم تفعيل الإضافات",
|
||||
globalDisabled: "تم تعطيل الإضافات",
|
||||
},
|
||||
removeConfirmTitle: "إزالة {{name}}؟",
|
||||
removeConfirmMessage: "يؤدي هذا إلى إزالة الإعداد فقط. لن يُحذف مجلد المصدر.",
|
||||
removeConfirmTitle: "إلغاء تثبيت {{name}}؟",
|
||||
removeConfirmMessage: "تُحذف نسختها المنزّلة وإعداداتها على هذا المضيف.",
|
||||
removeConfirmLocalMessage: "تُحذف إعداداتها. يبقى مجلدها على القرص.",
|
||||
states: {
|
||||
offlineTitle: "مضيف الإضافات غير متصل",
|
||||
offlineDescription: "أعد الاتصال بهذا المضيف لإدارة إضافاته.",
|
||||
@@ -69,6 +73,7 @@ export const pluginSettings = {
|
||||
},
|
||||
es: {
|
||||
...shared,
|
||||
gallery: pluginGallery.es,
|
||||
screens: {
|
||||
open: "Abrir",
|
||||
offline: "Conéctate a este host para abrir los ajustes del plugin.",
|
||||
@@ -97,21 +102,22 @@ export const pluginSettings = {
|
||||
enabling: "Activando…",
|
||||
disable: "Desactivar",
|
||||
disabling: "Desactivando…",
|
||||
remove: "Eliminar",
|
||||
removing: "Eliminando…",
|
||||
remove: "Desinstalar",
|
||||
removing: "Desinstalando…",
|
||||
},
|
||||
feedback: {
|
||||
installed: "{{name}} instalado",
|
||||
reload: "{{name}} recargado",
|
||||
enable: "{{name}} activado",
|
||||
disable: "{{name}} desactivado",
|
||||
remove: "{{name}} eliminado",
|
||||
remove: "{{name}} desinstalado",
|
||||
kept: "Se conservó {{name}}",
|
||||
globalEnabled: "Plugins activados",
|
||||
globalDisabled: "Plugins desactivados",
|
||||
},
|
||||
removeConfirmTitle: "¿Eliminar {{name}}?",
|
||||
removeConfirmMessage: "Esto elimina la configuración. El directorio de origen no se borra.",
|
||||
removeConfirmTitle: "¿Desinstalar {{name}}?",
|
||||
removeConfirmMessage: "Se borran su descarga y sus ajustes en este host.",
|
||||
removeConfirmLocalMessage: "Se borran sus ajustes. Su carpeta se queda en el disco.",
|
||||
states: {
|
||||
offlineTitle: "El host de plugins está sin conexión",
|
||||
offlineDescription: "Vuelve a conectar este host para gestionar sus plugins.",
|
||||
@@ -125,6 +131,7 @@ export const pluginSettings = {
|
||||
},
|
||||
fr: {
|
||||
...shared,
|
||||
gallery: pluginGallery.fr,
|
||||
screens: {
|
||||
open: "Ouvrir",
|
||||
offline: "Connectez-vous à cet hôte pour ouvrir les paramètres du plugin.",
|
||||
@@ -153,21 +160,22 @@ export const pluginSettings = {
|
||||
enabling: "Activation…",
|
||||
disable: "Désactiver",
|
||||
disabling: "Désactivation…",
|
||||
remove: "Supprimer",
|
||||
removing: "Suppression…",
|
||||
remove: "Désinstaller",
|
||||
removing: "Désinstallation…",
|
||||
},
|
||||
feedback: {
|
||||
installed: "{{name}} installé",
|
||||
reload: "{{name}} rechargé",
|
||||
enable: "{{name}} activé",
|
||||
disable: "{{name}} désactivé",
|
||||
remove: "{{name}} supprimé",
|
||||
remove: "{{name}} désinstallé",
|
||||
kept: "{{name}} conservé",
|
||||
globalEnabled: "Plugins activés",
|
||||
globalDisabled: "Plugins désactivés",
|
||||
},
|
||||
removeConfirmTitle: "Supprimer {{name}} ?",
|
||||
removeConfirmMessage: "La configuration sera supprimée. Le dossier source ne sera pas effacé.",
|
||||
removeConfirmTitle: "Désinstaller {{name}} ?",
|
||||
removeConfirmMessage: "Son téléchargement et ses réglages sur cet hôte sont supprimés.",
|
||||
removeConfirmLocalMessage: "Ses réglages sont supprimés. Son dossier reste sur le disque.",
|
||||
states: {
|
||||
offlineTitle: "L’hôte des plugins est hors ligne",
|
||||
offlineDescription: "Reconnectez cet hôte pour gérer ses plugins.",
|
||||
@@ -181,6 +189,7 @@ export const pluginSettings = {
|
||||
},
|
||||
ja: {
|
||||
...shared,
|
||||
gallery: pluginGallery.ja,
|
||||
screens: {
|
||||
open: "開く",
|
||||
offline: "このホストに接続してプラグイン設定を開いてください。",
|
||||
@@ -209,21 +218,22 @@ export const pluginSettings = {
|
||||
enabling: "有効化中…",
|
||||
disable: "無効化",
|
||||
disabling: "無効化中…",
|
||||
remove: "削除",
|
||||
removing: "削除中…",
|
||||
remove: "アンインストール",
|
||||
removing: "アンインストール中…",
|
||||
},
|
||||
feedback: {
|
||||
installed: "{{name}} をインストールしました",
|
||||
reload: "{{name}} を再読み込みしました",
|
||||
enable: "{{name}} を有効にしました",
|
||||
disable: "{{name}} を無効にしました",
|
||||
remove: "{{name}} を削除しました",
|
||||
remove: "{{name}} をアンインストールしました",
|
||||
kept: "{{name}} を保持しました",
|
||||
globalEnabled: "プラグインを有効にしました",
|
||||
globalDisabled: "プラグインを無効にしました",
|
||||
},
|
||||
removeConfirmTitle: "{{name}} を削除しますか?",
|
||||
removeConfirmMessage: "設定のみを削除します。ソースディレクトリは削除されません。",
|
||||
removeConfirmTitle: "{{name}} をアンインストールしますか?",
|
||||
removeConfirmMessage: "このホスト上のダウンロードと設定が削除されます。",
|
||||
removeConfirmLocalMessage: "設定が削除されます。フォルダはディスクに残ります。",
|
||||
states: {
|
||||
offlineTitle: "プラグインホストはオフラインです",
|
||||
offlineDescription: "プラグインを管理するにはホストに再接続してください。",
|
||||
@@ -237,6 +247,7 @@ export const pluginSettings = {
|
||||
},
|
||||
ko: {
|
||||
...shared,
|
||||
gallery: pluginGallery.ko,
|
||||
screens: {
|
||||
open: "열기",
|
||||
offline: "플러그인 설정을 열려면 이 호스트에 연결하세요.",
|
||||
@@ -278,7 +289,8 @@ export const pluginSettings = {
|
||||
globalDisabled: "플러그인 비활성화됨",
|
||||
},
|
||||
removeConfirmTitle: "{{name}}을(를) 제거할까요?",
|
||||
removeConfirmMessage: "설정만 제거합니다. 소스 디렉터리는 삭제되지 않습니다.",
|
||||
removeConfirmMessage: "이 호스트의 다운로드와 설정이 삭제됩니다.",
|
||||
removeConfirmLocalMessage: "설정이 삭제됩니다. 폴더는 디스크에 남습니다.",
|
||||
states: {
|
||||
offlineTitle: "플러그인 호스트가 오프라인입니다",
|
||||
offlineDescription: "플러그인을 관리하려면 호스트에 다시 연결하세요.",
|
||||
@@ -292,6 +304,7 @@ export const pluginSettings = {
|
||||
},
|
||||
"pt-BR": {
|
||||
...shared,
|
||||
gallery: pluginGallery["pt-BR"],
|
||||
screens: {
|
||||
open: "Abrir",
|
||||
offline: "Conecte-se a este host para abrir as configurações do plugin.",
|
||||
@@ -320,21 +333,22 @@ export const pluginSettings = {
|
||||
enabling: "Ativando…",
|
||||
disable: "Desativar",
|
||||
disabling: "Desativando…",
|
||||
remove: "Remover",
|
||||
removing: "Removendo…",
|
||||
remove: "Desinstalar",
|
||||
removing: "Desinstalando…",
|
||||
},
|
||||
feedback: {
|
||||
installed: "{{name}} instalado",
|
||||
reload: "{{name}} recarregado",
|
||||
enable: "{{name}} ativado",
|
||||
disable: "{{name}} desativado",
|
||||
remove: "{{name}} removido",
|
||||
remove: "{{name}} desinstalado",
|
||||
kept: "{{name}} mantido",
|
||||
globalEnabled: "Plugins ativados",
|
||||
globalDisabled: "Plugins desativados",
|
||||
},
|
||||
removeConfirmTitle: "Remover {{name}}?",
|
||||
removeConfirmMessage: "Isso remove a configuração. O diretório de origem não será excluído.",
|
||||
removeConfirmTitle: "Desinstalar {{name}}?",
|
||||
removeConfirmMessage: "O download e as configurações neste host são apagados.",
|
||||
removeConfirmLocalMessage: "As configurações são apagadas. A pasta continua no disco.",
|
||||
states: {
|
||||
offlineTitle: "O host de plugins está offline",
|
||||
offlineDescription: "Reconecte este host para gerenciar seus plugins.",
|
||||
@@ -348,6 +362,7 @@ export const pluginSettings = {
|
||||
},
|
||||
ru: {
|
||||
...shared,
|
||||
gallery: pluginGallery.ru,
|
||||
screens: {
|
||||
open: "Открыть",
|
||||
offline: "Подключитесь к этому хосту, чтобы открыть настройки плагина.",
|
||||
@@ -390,7 +405,8 @@ export const pluginSettings = {
|
||||
globalDisabled: "Плагины отключены",
|
||||
},
|
||||
removeConfirmTitle: "Удалить {{name}}?",
|
||||
removeConfirmMessage: "Конфигурация будет удалена. Исходный каталог останется на месте.",
|
||||
removeConfirmMessage: "Загруженные файлы и настройки на этом хосте будут удалены.",
|
||||
removeConfirmLocalMessage: "Настройки будут удалены. Папка останется на диске.",
|
||||
states: {
|
||||
offlineTitle: "Хост плагинов не в сети",
|
||||
offlineDescription: "Подключите хост снова, чтобы управлять плагинами.",
|
||||
@@ -404,6 +420,7 @@ export const pluginSettings = {
|
||||
},
|
||||
"zh-CN": {
|
||||
...shared,
|
||||
gallery: pluginGallery["zh-CN"],
|
||||
screens: {
|
||||
open: "打开",
|
||||
offline: "连接到此主机以打开插件设置。",
|
||||
@@ -431,21 +448,22 @@ export const pluginSettings = {
|
||||
enabling: "正在启用…",
|
||||
disable: "禁用",
|
||||
disabling: "正在禁用…",
|
||||
remove: "移除",
|
||||
removing: "正在移除…",
|
||||
remove: "卸载",
|
||||
removing: "正在卸载…",
|
||||
},
|
||||
feedback: {
|
||||
installed: "已安装 {{name}}",
|
||||
reload: "已重新加载 {{name}}",
|
||||
enable: "已启用 {{name}}",
|
||||
disable: "已禁用 {{name}}",
|
||||
remove: "已移除 {{name}}",
|
||||
remove: "已卸载 {{name}}",
|
||||
kept: "已保留 {{name}}",
|
||||
globalEnabled: "插件已启用",
|
||||
globalDisabled: "插件已禁用",
|
||||
},
|
||||
removeConfirmTitle: "移除 {{name}}?",
|
||||
removeConfirmMessage: "这会移除配置,但不会删除源目录。",
|
||||
removeConfirmTitle: "卸载 {{name}}?",
|
||||
removeConfirmMessage: "将删除此主机上的下载内容和设置。",
|
||||
removeConfirmLocalMessage: "将删除其设置。文件夹保留在磁盘上。",
|
||||
states: {
|
||||
offlineTitle: "插件主机离线",
|
||||
offlineDescription: "重新连接此主机以管理其插件。",
|
||||
|
||||
@@ -35,7 +35,8 @@ export function openSettingsFromSidebar(input: {
|
||||
}
|
||||
|
||||
export type SettingsView =
|
||||
| { kind: "plugin"; serverId: string; pluginId: string; screenId: string }
|
||||
| { kind: "plugin"; serverId: string; pluginId: string; screenId?: string }
|
||||
| { kind: "pluginListing"; serverId: string; listingId: string }
|
||||
| { kind: "root" }
|
||||
| { kind: "section"; section: SettingsSectionSlug }
|
||||
| { kind: "host"; serverId: string; section: HostSectionSlug }
|
||||
@@ -61,13 +62,17 @@ export function returnFromSettings(view: SettingsView): void {
|
||||
return;
|
||||
}
|
||||
if (!navigateToLastWorkspace()) {
|
||||
// Detail pages such as a plugin's page are pushed onto Settings; replacing only the top
|
||||
// entry would leave the rest of Settings mounted underneath.
|
||||
if (router.canDismiss()) router.dismissAll();
|
||||
router.replace(buildOpenProjectRoute());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let parent: Href = buildSettingsRoute();
|
||||
if (view.kind === "plugin") parent = buildSettingsHostSectionRoute(view.serverId, "plugins");
|
||||
if (view.kind === "plugin" || view.kind === "pluginListing")
|
||||
parent = buildSettingsHostSectionRoute(view.serverId, "plugins");
|
||||
if (view.kind === "project") parent = buildProjectsSettingsRoute(view.serverId);
|
||||
router.dismissTo(parent as Href);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { PluginListItem } from "@getpaseo/protocol/messages";
|
||||
import { useToast } from "@/contexts/toast-api-context";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { pluginDisplayName, pluginListQueryKey } from "@/plugins/plugin-list";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { pluginErrorText } from "./error-text";
|
||||
import { dismissFailedOperation } from "./operations";
|
||||
|
||||
export type PluginAction = "enable" | "disable" | "reload" | "remove";
|
||||
|
||||
/**
|
||||
* Turning a plugin on or off, reloading and removing it. Each action writes its result into the
|
||||
* plugin list, so every row and page showing the plugin agrees. Toggling says nothing; the switch
|
||||
* already shows the result.
|
||||
*/
|
||||
export function usePluginActions(serverId: string) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const [pending, setPending] = useState<{ pluginId: string; action: PluginAction } | null>(null);
|
||||
|
||||
const replace = useCallback(
|
||||
(plugin: PluginListItem) =>
|
||||
queryClient.setQueryData<PluginListItem[]>(pluginListQueryKey(serverId), (plugins) =>
|
||||
plugins?.map((candidate) => (candidate.id === plugin.id ? plugin : candidate)),
|
||||
),
|
||||
[queryClient, serverId],
|
||||
);
|
||||
|
||||
const run = useCallback(
|
||||
/** Resolves true once the action is done; false when it was cancelled or failed. */
|
||||
async (action: PluginAction, plugin: PluginListItem): Promise<boolean> => {
|
||||
if (!client) return false;
|
||||
const name = pluginDisplayName(plugin);
|
||||
if (action === "remove") {
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("settings.plugins.removeConfirmTitle", { name }),
|
||||
// A plugin installed from a source has its download deleted; a local folder is kept.
|
||||
message: plugin.installation
|
||||
? t("settings.plugins.removeConfirmMessage")
|
||||
: t("settings.plugins.removeConfirmLocalMessage"),
|
||||
confirmLabel: t("settings.plugins.actions.remove"),
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) return false;
|
||||
}
|
||||
setPending({ pluginId: plugin.id, action });
|
||||
try {
|
||||
if (action === "remove") {
|
||||
await client.removePlugin(plugin.id);
|
||||
// A failed update belonged to this installation, not to a later one with the same ID.
|
||||
dismissFailedOperation(serverId, plugin.id);
|
||||
queryClient.setQueryData<PluginListItem[]>(pluginListQueryKey(serverId), (plugins) =>
|
||||
plugins?.filter((candidate) => candidate.id !== plugin.id),
|
||||
);
|
||||
toast.show(t("settings.plugins.feedback.remove", { name }), { variant: "success" });
|
||||
} else {
|
||||
replace(await client[`${action}Plugin`](plugin.id));
|
||||
if (action === "reload")
|
||||
toast.show(t("settings.plugins.feedback.reload", { name }), { variant: "success" });
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.error(pluginErrorText(error));
|
||||
return false;
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
},
|
||||
[client, queryClient, replace, serverId, t, toast],
|
||||
);
|
||||
|
||||
return useMemo(() => ({ run, pending }), [pending, run]);
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
import { router } from "expo-router";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Image, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { PluginListItem } from "@getpaseo/protocol/messages";
|
||||
import type { PluginListingIndex, PluginListing } from "@getpaseo/protocol/plugin-registry";
|
||||
import { SettingsCard, SettingsSection } from "@/components/settings";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useContainerWidthBelow } from "@/hooks/use-container-width";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { usePluginActions } from "./actions";
|
||||
import { ListingInstallControl } from "./install-control";
|
||||
import {
|
||||
ALL_CATEGORIES,
|
||||
categoryLabel,
|
||||
featuredListings,
|
||||
filterDirectory,
|
||||
formatInstallCount,
|
||||
listingAuthor,
|
||||
listingAuthorAvatar,
|
||||
listingImages,
|
||||
listingText,
|
||||
type DirectorySort,
|
||||
} from "./listing";
|
||||
import { usePluginOperation } from "./operations";
|
||||
import { PluginMeta, PluginRow, PluginTile } from "./plugin-row";
|
||||
import { usePluginDirectory } from "./queries";
|
||||
import { buildPluginListingRoute } from "./routes";
|
||||
import { PluginRowsSkeleton } from "./skeletons";
|
||||
import { pluginErrorText } from "./error-text";
|
||||
|
||||
const SORTS: DirectorySort[] = ["popular", "recent", "name"];
|
||||
// Below this width the featured grid has two columns instead of four.
|
||||
const FEATURED_FOUR_COLUMNS = 760;
|
||||
const LISTING_PAGE = 50;
|
||||
|
||||
/** "author · 1.1k installs". */
|
||||
export function useListingMeta() {
|
||||
const { t } = useTranslation();
|
||||
return useCallback(
|
||||
(listing: PluginListing) =>
|
||||
listing.installs === undefined
|
||||
? listingAuthor(listing)
|
||||
: `${listingAuthor(listing)} · ${t("settings.plugins.gallery.installs", {
|
||||
count: listing.installs,
|
||||
installs: formatInstallCount(listing.installs),
|
||||
})}`,
|
||||
[t],
|
||||
);
|
||||
}
|
||||
|
||||
function ListingRow({
|
||||
serverId,
|
||||
listing,
|
||||
installedPlugin,
|
||||
uninstalling,
|
||||
onInstall,
|
||||
onUninstall,
|
||||
}: {
|
||||
serverId: string;
|
||||
listing: PluginListing;
|
||||
installedPlugin?: PluginListItem;
|
||||
uninstalling: boolean;
|
||||
onInstall(listing: PluginListing): void;
|
||||
onUninstall(plugin: PluginListItem): void;
|
||||
}) {
|
||||
const { i18n } = useTranslation();
|
||||
const meta = useListingMeta();
|
||||
const operation = usePluginOperation(serverId, listing.id);
|
||||
const { name, description } = listingText(listing, i18n.language);
|
||||
const open = useCallback(
|
||||
() => router.push(buildPluginListingRoute(serverId, listing.id)),
|
||||
[listing.id, serverId],
|
||||
);
|
||||
return (
|
||||
<PluginRow
|
||||
name={name}
|
||||
icon={listing.icon}
|
||||
description={description}
|
||||
meta={meta(listing)}
|
||||
authorAvatar={listingAuthorAvatar(listing)}
|
||||
error={!installedPlugin && operation?.status === "failed" ? operation.error : undefined}
|
||||
onPress={open}
|
||||
accessibilityLabel={name}
|
||||
testID={`plugin-listing-${listing.id}`}
|
||||
>
|
||||
<ListingInstallControl
|
||||
listing={listing}
|
||||
installedPlugin={installedPlugin}
|
||||
operation={operation}
|
||||
uninstalling={uninstalling}
|
||||
onInstall={onInstall}
|
||||
onUninstall={onUninstall}
|
||||
/>
|
||||
</PluginRow>
|
||||
);
|
||||
}
|
||||
|
||||
type HoverState = PressableStateCallbackType & { hovered?: boolean };
|
||||
|
||||
function FeaturedCard({ serverId, listing }: { serverId: string; listing: PluginListing }) {
|
||||
const { i18n } = useTranslation();
|
||||
const meta = useListingMeta();
|
||||
const { name, description } = listingText(listing, i18n.language);
|
||||
const image = listingImages(listing)[0];
|
||||
const source = useMemo(() => (image ? { uri: image } : null), [image]);
|
||||
const open = useCallback(
|
||||
() => router.push(buildPluginListingRoute(serverId, listing.id)),
|
||||
[listing.id, serverId],
|
||||
);
|
||||
const cardStyle = useCallback(
|
||||
({ hovered }: HoverState) => [styles.featured, hovered && styles.featuredHovered],
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<PressHighlight
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={name}
|
||||
onPress={open}
|
||||
style={cardStyle}
|
||||
highlightStyle={styles.featuredPressed}
|
||||
testID={`plugin-featured-${listing.id}`}
|
||||
>
|
||||
<View style={styles.media}>
|
||||
{source ? <Image source={source} style={styles.mediaImage} resizeMode="cover" /> : null}
|
||||
</View>
|
||||
<View style={styles.featuredBody}>
|
||||
<View style={styles.featuredTitle}>
|
||||
<PluginTile name={name} icon={listing.icon} />
|
||||
<Text style={styles.featuredName} numberOfLines={1}>
|
||||
{name}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.featuredDescription} numberOfLines={2}>
|
||||
{description}
|
||||
</Text>
|
||||
<PluginMeta text={meta(listing)} authorAvatar={listingAuthorAvatar(listing)} />
|
||||
</View>
|
||||
</PressHighlight>
|
||||
);
|
||||
}
|
||||
|
||||
/** Every featured listing: a row to swipe on a phone, a grid of four or two columns otherwise. */
|
||||
function Featured({ serverId, listings }: { serverId: string; listings: PluginListing[] }) {
|
||||
const { t } = useTranslation();
|
||||
const compact = useIsCompactFormFactor();
|
||||
const grid = useContainerWidthBelow(FEATURED_FOUR_COLUMNS, { initialIsBelow: false });
|
||||
const cellStyle = grid.isBelow ? styles.cellHalf : styles.cellQuarter;
|
||||
return (
|
||||
<SettingsSection title={t("settings.plugins.gallery.sections.featured")}>
|
||||
{compact ? (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.featuredRow}
|
||||
>
|
||||
{listings.map((listing) => (
|
||||
<View key={listing.id} style={styles.cellSwipe}>
|
||||
<FeaturedCard serverId={serverId} listing={listing} />
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
) : (
|
||||
<View style={styles.featuredGrid} onLayout={grid.onLayout}>
|
||||
{listings.map((listing) => (
|
||||
<View key={listing.id} style={cellStyle}>
|
||||
<FeaturedCard serverId={serverId} listing={listing} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterOption<Value extends string>({
|
||||
option,
|
||||
selected,
|
||||
onChange,
|
||||
}: {
|
||||
option: { value: Value; label: string };
|
||||
selected: boolean;
|
||||
onChange(value: Value): void;
|
||||
}) {
|
||||
const select = useCallback(() => onChange(option.value), [onChange, option.value]);
|
||||
return (
|
||||
<DropdownMenuItem selected={selected} onSelect={select}>
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterMenu<Value extends string>({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
testID,
|
||||
}: {
|
||||
label: string;
|
||||
value: Value;
|
||||
options: { value: Value; label: string }[];
|
||||
onChange(value: Value): void;
|
||||
testID: string;
|
||||
}) {
|
||||
const selected = options.find((option) => option.value === value)?.label ?? value;
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownTrigger
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`${label}: ${selected}`}
|
||||
testID={testID}
|
||||
>
|
||||
{selected}
|
||||
</DropdownTrigger>
|
||||
<DropdownMenuContent side="bottom" align="start" width={220} sheetTitle={label}>
|
||||
{options.map((option) => (
|
||||
<FilterOption
|
||||
key={option.value}
|
||||
option={option}
|
||||
selected={option.value === value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The listings in pages: the directory has hundreds, and rendering them all makes switching to
|
||||
* Browse and typing in search lag. A new query, category or sort starts again at the first page.
|
||||
*/
|
||||
function ListingList({
|
||||
serverId,
|
||||
listings,
|
||||
installed,
|
||||
onInstall,
|
||||
}: {
|
||||
serverId: string;
|
||||
listings: PluginListing[];
|
||||
installed: ReadonlyMap<string, PluginListItem>;
|
||||
onInstall(listing: PluginListing): void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const actions = usePluginActions(serverId);
|
||||
const uninstall = useCallback(
|
||||
(plugin: PluginListItem) => void actions.run("remove", plugin),
|
||||
[actions],
|
||||
);
|
||||
const [limit, setLimit] = useState(LISTING_PAGE);
|
||||
const showMore = useCallback(() => setLimit((value) => value + LISTING_PAGE), []);
|
||||
if (!listings.length)
|
||||
return (
|
||||
<SettingsCard testID="plugin-directory-list">
|
||||
<View style={styles.empty}>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{t("settings.plugins.gallery.empty.noMatches")}
|
||||
</Text>
|
||||
</View>
|
||||
</SettingsCard>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<SettingsCard testID="plugin-directory-list">
|
||||
{listings.slice(0, limit).map((listing) => (
|
||||
<ListingRow
|
||||
key={listing.id}
|
||||
serverId={serverId}
|
||||
listing={listing}
|
||||
installedPlugin={installed.get(listing.id)}
|
||||
uninstalling={
|
||||
actions.pending?.action === "remove" &&
|
||||
actions.pending.pluginId === installed.get(listing.id)?.id
|
||||
}
|
||||
onInstall={onInstall}
|
||||
onUninstall={uninstall}
|
||||
/>
|
||||
))}
|
||||
</SettingsCard>
|
||||
{listings.length > limit ? (
|
||||
<View style={styles.more}>
|
||||
<Button variant="outline" onPress={showMore} testID="plugin-directory-more">
|
||||
{t("settings.plugins.gallery.actions.showMore")}
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Directory({
|
||||
serverId,
|
||||
index,
|
||||
installed,
|
||||
query,
|
||||
onInstall,
|
||||
}: {
|
||||
serverId: string;
|
||||
index: PluginListingIndex;
|
||||
installed: ReadonlyMap<string, PluginListItem>;
|
||||
query: string;
|
||||
onInstall(listing: PluginListing): void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [category, setCategory] = useState<string>(ALL_CATEGORIES);
|
||||
const [sort, setSort] = useState<DirectorySort>("popular");
|
||||
const listings = filterDirectory(index, { query, category, sort }, i18n.language);
|
||||
const browsing = !query.trim() && category === ALL_CATEGORIES;
|
||||
const featured = browsing ? featuredListings(index) : [];
|
||||
const categoryOptions = useMemo(
|
||||
() => [
|
||||
{ value: ALL_CATEGORIES, label: t("settings.plugins.gallery.filters.allCategories") },
|
||||
...index.categories.map((entry) => ({ value: entry.slug, label: entry.label })),
|
||||
],
|
||||
[index.categories, t],
|
||||
);
|
||||
const sortOptions = useMemo(
|
||||
(): { value: DirectorySort; label: string }[] =>
|
||||
SORTS.map((value) => ({ value, label: t(`settings.plugins.gallery.filters.${value}`) })),
|
||||
[t],
|
||||
);
|
||||
const listTitle =
|
||||
category === ALL_CATEGORIES
|
||||
? t("settings.plugins.gallery.sections.all")
|
||||
: categoryLabel(index, category);
|
||||
return (
|
||||
<>
|
||||
<View style={styles.filters}>
|
||||
<FilterMenu
|
||||
label={t("settings.plugins.gallery.filters.category")}
|
||||
value={category}
|
||||
options={categoryOptions}
|
||||
onChange={setCategory}
|
||||
testID="plugin-category-filter"
|
||||
/>
|
||||
<FilterMenu<DirectorySort>
|
||||
label={t("settings.plugins.gallery.filters.sort")}
|
||||
value={sort}
|
||||
options={sortOptions}
|
||||
onChange={setSort}
|
||||
testID="plugin-sort"
|
||||
/>
|
||||
</View>
|
||||
{featured.length ? <Featured serverId={serverId} listings={featured} /> : null}
|
||||
<SettingsSection title={listTitle}>
|
||||
<ListingList
|
||||
key={`${query}\n${category}\n${sort}`}
|
||||
serverId={serverId}
|
||||
listings={listings}
|
||||
installed={installed}
|
||||
onInstall={onInstall}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function BrowseTab({
|
||||
serverId,
|
||||
installed,
|
||||
query,
|
||||
onInstall,
|
||||
}: {
|
||||
serverId: string;
|
||||
/** Installed plugins by the listing they came from. */
|
||||
installed: ReadonlyMap<string, PluginListItem>;
|
||||
query: string;
|
||||
onInstall(listing: PluginListing): void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const directory = usePluginDirectory(serverId);
|
||||
const retry = useCallback(() => void directory.query.refetch(), [directory.query]);
|
||||
if (!directory.supported)
|
||||
return (
|
||||
<Alert size="sm" variant="warning" title={t("settings.plugins.gallery.directory.update")} />
|
||||
);
|
||||
if (directory.query.isError)
|
||||
return (
|
||||
<Alert
|
||||
size="sm"
|
||||
variant="error"
|
||||
title={t("settings.plugins.gallery.directory.errorTitle")}
|
||||
description={pluginErrorText(directory.query.error)}
|
||||
>
|
||||
<Button variant="outline" onPress={retry}>
|
||||
{t("settings.plugins.states.retry")}
|
||||
</Button>
|
||||
</Alert>
|
||||
);
|
||||
if (!directory.query.data) return <PluginRowsSkeleton testID="plugin-directory-loading" />;
|
||||
return (
|
||||
<Directory
|
||||
serverId={serverId}
|
||||
index={directory.query.data}
|
||||
installed={installed}
|
||||
query={query}
|
||||
onInstall={onInstall}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
filters: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing[2],
|
||||
marginBottom: theme.spacing[6],
|
||||
},
|
||||
featuredRow: { flexDirection: "row", gap: theme.spacing[3] },
|
||||
// Percent widths and a gap would overflow the row, so each cell carries half the gap on each side
|
||||
// and the grid pulls out by the same amount to keep the cards on the section's edges.
|
||||
featuredGrid: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
marginHorizontal: -theme.spacing[3] / 2,
|
||||
marginBottom: -theme.spacing[3],
|
||||
},
|
||||
cellQuarter: {
|
||||
width: "25%",
|
||||
paddingHorizontal: theme.spacing[3] / 2,
|
||||
marginBottom: theme.spacing[3],
|
||||
},
|
||||
cellHalf: {
|
||||
width: "50%",
|
||||
paddingHorizontal: theme.spacing[3] / 2,
|
||||
marginBottom: theme.spacing[3],
|
||||
},
|
||||
cellSwipe: { width: 260 },
|
||||
featured: {
|
||||
flexGrow: 1,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
overflow: "hidden",
|
||||
},
|
||||
featuredHovered: { backgroundColor: theme.colors.surface2 },
|
||||
featuredPressed: { backgroundColor: theme.colors.surface3 },
|
||||
media: {
|
||||
aspectRatio: 16 / 10,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
mediaImage: { width: "100%", height: "100%" },
|
||||
featuredBody: { padding: theme.spacing[3], gap: theme.spacing[1] },
|
||||
featuredTitle: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
|
||||
featuredName: { flex: 1, color: theme.colors.foreground, fontSize: theme.fontSize.base },
|
||||
featuredDescription: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
|
||||
empty: { padding: theme.spacing[6], alignItems: "center" },
|
||||
more: { alignItems: "center", marginTop: theme.spacing[3] },
|
||||
}));
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pluginErrorText } from "./error-text";
|
||||
|
||||
describe("pluginErrorText", () => {
|
||||
it("shows the daemon's text without the request details an RPC error adds", () => {
|
||||
const rpcError = Object.assign(
|
||||
new Error(
|
||||
"Registry credentials require HTTPS requestType=plugin.registry.list.request code=handler_error",
|
||||
),
|
||||
{ userMessage: "Registry credentials require HTTPS" },
|
||||
);
|
||||
expect(pluginErrorText(rpcError)).toBe("Registry credentials require HTTPS");
|
||||
});
|
||||
|
||||
it("shows any other error's message", () => {
|
||||
expect(pluginErrorText(new Error("Socket closed"))).toBe("Socket closed");
|
||||
expect(pluginErrorText("offline")).toBe("offline");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* What a failed plugin request tells the user. A daemon RPC error keeps the daemon's text in
|
||||
* `userMessage`; its `message` adds the request type and code for logs.
|
||||
*/
|
||||
export function pluginErrorText(error: unknown): string {
|
||||
if (!(error instanceof Error)) return String(error);
|
||||
const userMessage: unknown = Reflect.get(error, "userMessage");
|
||||
return typeof userMessage === "string" ? userMessage : error.message;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { PluginListing } from "@getpaseo/protocol/plugin-registry";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SearchField } from "@/components/ui/search-field";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useInstalledPlugins } from "@/plugins/registry";
|
||||
import { BrowseTab } from "./browse-tab";
|
||||
import { InstallPluginSheet, type InstallTarget } from "./install-sheet";
|
||||
import { InstalledTab } from "./installed-tab";
|
||||
import { installedByListing } from "./listing";
|
||||
import { PluginsOffAlert } from "./plugins-off-alert";
|
||||
import { useLivePluginList, usePluginDirectory } from "./queries";
|
||||
import { PluginRowsSkeleton } from "./skeletons";
|
||||
import { pluginErrorText } from "./error-text";
|
||||
|
||||
type GalleryTab = "installed" | "browse";
|
||||
|
||||
/**
|
||||
* Settings → Plugins: the plugins on this host and the directory to add more, behind one switch.
|
||||
* Opens on Installed when something is installed, otherwise on Browse.
|
||||
*/
|
||||
export function PluginGalleryPage({ serverId }: { serverId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const connected = useHostRuntimeIsConnected(serverId);
|
||||
const supported = useHostFeature(serverId, "pluginManagement");
|
||||
useInstalledPlugins();
|
||||
const plugins = useLivePluginList(serverId);
|
||||
const directory = usePluginDirectory(serverId);
|
||||
const [chosenTab, setChosenTab] = useState<GalleryTab | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [installTarget, setInstallTarget] = useState<InstallTarget | null>(null);
|
||||
const installed = useMemo(() => plugins.data ?? [], [plugins.data]);
|
||||
// The opening tab is chosen once, so installing the first plugin does not move the user off Browse.
|
||||
if (chosenTab === null && plugins.data)
|
||||
setChosenTab(plugins.data.length === 0 ? "browse" : "installed");
|
||||
const tab: GalleryTab = chosenTab ?? "installed";
|
||||
const byListing = useMemo(() => installedByListing(installed), [installed]);
|
||||
const listings = useMemo(
|
||||
() => new Map((directory.query.data?.plugins ?? []).map((listing) => [listing.id, listing])),
|
||||
[directory.query.data],
|
||||
);
|
||||
const tabs = useMemo(
|
||||
() => [
|
||||
{
|
||||
value: "installed" as const,
|
||||
label: plugins.data
|
||||
? t("settings.plugins.gallery.tabs.installedCount", { count: plugins.data.length })
|
||||
: t("settings.plugins.gallery.tabs.installed"),
|
||||
testID: "plugin-tab-installed",
|
||||
},
|
||||
{
|
||||
value: "browse" as const,
|
||||
label: t("settings.plugins.gallery.tabs.browse"),
|
||||
testID: "plugin-tab-browse",
|
||||
},
|
||||
],
|
||||
[plugins.data, t],
|
||||
);
|
||||
const browse = useCallback(() => setChosenTab("browse"), []);
|
||||
const install = useCallback(
|
||||
(listing: PluginListing) => setInstallTarget({ kind: "listing", listing }),
|
||||
[],
|
||||
);
|
||||
const closeInstall = useCallback(() => setInstallTarget(null), []);
|
||||
const retry = useCallback(() => void plugins.refetch(), [plugins]);
|
||||
|
||||
if (!connected)
|
||||
return (
|
||||
<Alert
|
||||
size="md"
|
||||
variant="warning"
|
||||
title={t("settings.plugins.states.offlineTitle")}
|
||||
description={t("settings.plugins.states.offlineDescription")}
|
||||
/>
|
||||
);
|
||||
if (!supported)
|
||||
return <Alert size="md" variant="warning" title={t("settings.plugins.states.updateTitle")} />;
|
||||
|
||||
let content = <PluginRowsSkeleton testID="plugin-list-loading" />;
|
||||
if (plugins.isError) {
|
||||
content = (
|
||||
<Alert
|
||||
size="sm"
|
||||
variant="error"
|
||||
title={t("settings.plugins.states.errorTitle")}
|
||||
description={pluginErrorText(plugins.error)}
|
||||
>
|
||||
<Button variant="outline" onPress={retry}>
|
||||
{t("settings.plugins.states.retry")}
|
||||
</Button>
|
||||
</Alert>
|
||||
);
|
||||
} else if (plugins.data) {
|
||||
content =
|
||||
tab === "installed" ? (
|
||||
<InstalledTab
|
||||
serverId={serverId}
|
||||
plugins={installed}
|
||||
listings={listings}
|
||||
query={query}
|
||||
onBrowse={browse}
|
||||
/>
|
||||
) : (
|
||||
<BrowseTab serverId={serverId} installed={byListing} query={query} onInstall={install} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View style={styles.toolbar}>
|
||||
<SegmentedControl
|
||||
options={tabs}
|
||||
value={tab}
|
||||
onValueChange={setChosenTab}
|
||||
testID="plugin-tabs"
|
||||
/>
|
||||
<View style={styles.search}>
|
||||
<SearchField
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder={
|
||||
tab === "installed"
|
||||
? t("settings.plugins.gallery.search.installed")
|
||||
: t("settings.plugins.gallery.search.browse")
|
||||
}
|
||||
clearAccessibilityLabel={t("settings.plugins.gallery.search.clear")}
|
||||
testID="plugin-search"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
{tab === "installed" ? <PluginsOffAlert serverId={serverId} /> : null}
|
||||
{content}
|
||||
{installTarget ? (
|
||||
<InstallPluginSheet serverId={serverId} target={installTarget} onClose={closeInstall} />
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
toolbar: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
marginBottom: theme.spacing[6],
|
||||
},
|
||||
// The search box sits at the end of the row, as on the website, and takes the whole row when the
|
||||
// tabs leave it too little room.
|
||||
search: { flexGrow: 1, flexBasis: 220, flexDirection: "row", justifyContent: "flex-end" },
|
||||
}));
|
||||
@@ -0,0 +1,4 @@
|
||||
export { PluginGalleryPage } from "./gallery-page";
|
||||
export { PluginGalleryMenu } from "./menus";
|
||||
export { PluginPage, type PluginPageSubject } from "./plugin-page";
|
||||
export { buildPluginListingRoute, buildPluginPageRoute } from "./routes";
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { PluginListItem } from "@getpaseo/protocol/messages";
|
||||
import type { PluginListing } from "@getpaseo/protocol/plugin-registry";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { PluginOperation } from "./operations";
|
||||
|
||||
function useInstallLabel(operation: PluginOperation | undefined): string {
|
||||
const { t } = useTranslation();
|
||||
if (operation?.status === "running") return t("settings.plugins.gallery.actions.installing");
|
||||
if (operation?.status === "failed") return t("settings.plugins.gallery.actions.retry");
|
||||
return t("settings.plugins.gallery.actions.install");
|
||||
}
|
||||
|
||||
/**
|
||||
* A listing's install state where the user is looking: Install, Installing… while it runs, Retry
|
||||
* after a failure (the row or page shows why), and Uninstall once it is on the host. Both ask
|
||||
* before they act.
|
||||
*/
|
||||
export function ListingInstallControl({
|
||||
listing,
|
||||
installedPlugin,
|
||||
operation,
|
||||
uninstalling = false,
|
||||
onInstall,
|
||||
onUninstall,
|
||||
primary = false,
|
||||
}: {
|
||||
listing: PluginListing;
|
||||
/** The plugin this listing installed on the host, when it is installed. */
|
||||
installedPlugin?: PluginListItem;
|
||||
operation: PluginOperation | undefined;
|
||||
uninstalling?: boolean;
|
||||
onInstall(listing: PluginListing): void;
|
||||
onUninstall?(plugin: PluginListItem): void;
|
||||
/** The page's one accent action, on the plugin's own page. */
|
||||
primary?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const installLabel = useInstallLabel(operation);
|
||||
const install = useCallback(() => onInstall(listing), [listing, onInstall]);
|
||||
const uninstall = useCallback(() => {
|
||||
if (installedPlugin) onUninstall?.(installedPlugin);
|
||||
}, [installedPlugin, onUninstall]);
|
||||
if (installedPlugin) {
|
||||
const label = uninstalling
|
||||
? t("settings.plugins.actions.removing")
|
||||
: t("settings.plugins.actions.remove");
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
onPress={uninstall}
|
||||
loading={uninstalling}
|
||||
accessibilityLabel={`${label} ${listing.name}`}
|
||||
testID={`plugin-uninstall-${listing.id}`}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
variant={primary ? "default" : "outline"}
|
||||
onPress={install}
|
||||
loading={operation?.status === "running"}
|
||||
accessibilityLabel={`${installLabel} ${listing.name}`}
|
||||
testID={`plugin-install-${listing.id}`}
|
||||
>
|
||||
{installLabel}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { PluginListItem } from "@getpaseo/protocol/messages";
|
||||
import type { PluginListing } from "@getpaseo/protocol/plugin-registry";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { SettingsCard, SettingsRow } from "@/components/settings";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ExternalLink } from "@/components/ui/external-link";
|
||||
import { Field, FormTextInput } from "@/components/ui/form-field";
|
||||
import { useToast } from "@/contexts/toast-api-context";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
|
||||
import { pluginDisplayName } from "@/plugins/plugin-list";
|
||||
import { listingAuthor, listingSource, listingText, listingVersion } from "./listing";
|
||||
import { dismissFailedOperation, installPlugin, usePluginOperation } from "./operations";
|
||||
|
||||
const PLUGIN_SOURCE_DOCS_URL = "https://paseo.sh/docs/plugins/reference#plugin-sources";
|
||||
const SOURCE_SUBJECT = "source";
|
||||
|
||||
/** What the sheet installs: a directory listing, or a source the user types. */
|
||||
export type InstallTarget = { kind: "listing"; listing: PluginListing } | { kind: "source" };
|
||||
|
||||
export function useHostLabel(serverId: string): string {
|
||||
const hosts = useHosts();
|
||||
return hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every install is confirmed here: who made the plugin, where its code comes from, and that it runs
|
||||
* unsandboxed on the host. A listing install closes the sheet and reports on the listing's row; an
|
||||
* install from source has no row, so the sheet stays open until it finishes.
|
||||
*/
|
||||
export function InstallPluginSheet({
|
||||
serverId,
|
||||
target,
|
||||
onClose,
|
||||
}: {
|
||||
serverId: string;
|
||||
target: InstallTarget;
|
||||
onClose(): void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const toast = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const hostLabel = useHostLabel(serverId);
|
||||
const { config } = useDaemonConfig(serverId);
|
||||
const pluginsOff = config?.pluginsEnabled !== true;
|
||||
const [source, setSource] = useState("");
|
||||
const sourceRun = usePluginOperation(serverId, target.kind === "source" ? SOURCE_SUBJECT : "");
|
||||
const sourceRunning = target.kind === "source" && sourceRun?.status === "running";
|
||||
const close = useCallback(() => {
|
||||
if (target.kind === "source") dismissFailedOperation(serverId, SOURCE_SUBJECT);
|
||||
onClose();
|
||||
}, [onClose, serverId, target.kind]);
|
||||
const name =
|
||||
target.kind === "listing"
|
||||
? listingText(target.listing, i18n.language).name
|
||||
: t("settings.plugins.gallery.install.sourceTitle");
|
||||
|
||||
const onInstalled = useCallback(
|
||||
(plugin: PluginListItem) => {
|
||||
toast.show(t("settings.plugins.feedback.installed", { name: pluginDisplayName(plugin) }), {
|
||||
variant: "success",
|
||||
});
|
||||
},
|
||||
[t, toast],
|
||||
);
|
||||
const confirm = useCallback(() => {
|
||||
if (!client) return;
|
||||
const request = { client, queryClient, serverId, enablePlugins: pluginsOff, onInstalled };
|
||||
if (target.kind === "listing") {
|
||||
void installPlugin({ ...request, source: target.listing.id, subject: target.listing.id });
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
void installPlugin({
|
||||
...request,
|
||||
source: source.trim(),
|
||||
subject: SOURCE_SUBJECT,
|
||||
onInstalled: (plugin) => {
|
||||
onInstalled(plugin);
|
||||
close();
|
||||
},
|
||||
});
|
||||
}, [client, close, onClose, onInstalled, pluginsOff, queryClient, serverId, source, target]);
|
||||
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
title:
|
||||
target.kind === "listing"
|
||||
? t("settings.plugins.gallery.install.title", { name })
|
||||
: t("settings.plugins.gallery.install.sourceTitle"),
|
||||
}),
|
||||
[name, t, target.kind],
|
||||
);
|
||||
// Until the config loads, the sheet cannot say whether installing also turns plugins on.
|
||||
const canInstall =
|
||||
Boolean(client && config) && (target.kind === "listing" || source.trim().length > 0);
|
||||
const footer = useMemo(
|
||||
() => (
|
||||
<View style={styles.actions}>
|
||||
<Button variant="secondary" onPress={close}>
|
||||
{t("common.actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onPress={confirm}
|
||||
disabled={!canInstall}
|
||||
loading={sourceRunning}
|
||||
testID="plugin-install-confirm"
|
||||
>
|
||||
{sourceRunning
|
||||
? t("settings.plugins.gallery.actions.installing")
|
||||
: t("settings.plugins.gallery.actions.install")}
|
||||
</Button>
|
||||
</View>
|
||||
),
|
||||
[canInstall, close, confirm, sourceRunning, t],
|
||||
);
|
||||
const docsLink = useMemo(
|
||||
() => <ExternalLink href={PLUGIN_SOURCE_DOCS_URL} label={t("settings.plugins.docs")} />,
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
visible
|
||||
header={header}
|
||||
onClose={close}
|
||||
footer={footer}
|
||||
desktopMaxWidth={480}
|
||||
testID="plugin-install-sheet"
|
||||
>
|
||||
<View style={styles.body}>
|
||||
{target.kind === "listing" ? (
|
||||
<SettingsCard>
|
||||
<SettingsRow label={t("settings.plugins.gallery.install.author")}>
|
||||
<Text style={styles.value}>{listingAuthor(target.listing)}</Text>
|
||||
</SettingsRow>
|
||||
<SettingsRow label={t("settings.plugins.gallery.install.source")}>
|
||||
<Text style={styles.value} numberOfLines={1}>
|
||||
{listingSource(target.listing)}
|
||||
</Text>
|
||||
</SettingsRow>
|
||||
<SettingsRow label={t("settings.plugins.gallery.install.version")}>
|
||||
<Text style={styles.value}>{listingVersion(target.listing)}</Text>
|
||||
</SettingsRow>
|
||||
</SettingsCard>
|
||||
) : (
|
||||
<Field label={t("settings.plugins.sourceLabel")} trailing={docsLink}>
|
||||
<FormTextInput
|
||||
initialValue=""
|
||||
onChangeText={setSource}
|
||||
placeholder={t("settings.plugins.sourcePlaceholder")}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
editable={!sourceRunning}
|
||||
accessibilityLabel={t("settings.plugins.sourceLabel")}
|
||||
testID="plugin-source-input"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Alert
|
||||
size="sm"
|
||||
variant="warning"
|
||||
title={t("settings.plugins.gallery.install.warningTitle", { host: hostLabel })}
|
||||
description={
|
||||
pluginsOff
|
||||
? t("settings.plugins.gallery.install.turnsOn")
|
||||
: t("settings.plugins.gallery.install.warningDescription")
|
||||
}
|
||||
/>
|
||||
{target.kind === "source" && sourceRun?.status === "failed" ? (
|
||||
<Alert size="sm" variant="error" title={sourceRun.error} />
|
||||
) : null}
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
body: { gap: theme.spacing[4] },
|
||||
value: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, maxWidth: 260 },
|
||||
actions: { flex: 1, flexDirection: "row", justifyContent: "flex-end", gap: theme.spacing[2] },
|
||||
}));
|
||||
@@ -0,0 +1,276 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { PluginListItem, PluginUpdatePreview } from "@getpaseo/protocol/messages";
|
||||
import { formatPluginInstallation } from "@getpaseo/protocol/plugin-source-reference";
|
||||
import type { PluginListing } from "@getpaseo/protocol/plugin-registry";
|
||||
import { SettingsCard, SettingsSection } from "@/components/settings";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useToast } from "@/contexts/toast-api-context";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { usePluginEvaluationError } from "@/plugins/registry";
|
||||
import { pluginDisplayName } from "@/plugins/plugin-list";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { usePluginActions } from "./actions";
|
||||
import { listingAuthor, listingAuthorAvatar, installedListingId } from "./listing";
|
||||
import { updatePlugins, usePluginOperation } from "./operations";
|
||||
import { PluginRow } from "./plugin-row";
|
||||
import { usePluginUpdates } from "./queries";
|
||||
import { buildPluginPageRoute } from "./routes";
|
||||
|
||||
function shortRevision(revision: string | undefined): string | undefined {
|
||||
return revision && /^[0-9a-f]{40,64}$/.test(revision) ? revision.slice(0, 7) : revision;
|
||||
}
|
||||
|
||||
function matchesQuery(plugin: PluginListItem, query: string): boolean {
|
||||
if (!query) return true;
|
||||
return [plugin.id, plugin.name ?? "", plugin.description ?? ""]
|
||||
.join("\n")
|
||||
.toLowerCase()
|
||||
.includes(query);
|
||||
}
|
||||
|
||||
/** "author · version" for a listed plugin; where it was installed from otherwise. */
|
||||
function installedMeta(plugin: PluginListItem, listing: PluginListing | undefined) {
|
||||
if (!plugin.installation) return plugin.path;
|
||||
if (!listing) return formatPluginInstallation(plugin.installation);
|
||||
const version = shortRevision(plugin.installation.currentRevision);
|
||||
return version ? `${listingAuthor(listing)} · ${version}` : listingAuthor(listing);
|
||||
}
|
||||
|
||||
function useUpdate(serverId: string) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
return useCallback(
|
||||
(previews: PluginUpdatePreview[]) => {
|
||||
if (!client) return;
|
||||
void updatePlugins({
|
||||
client,
|
||||
queryClient,
|
||||
serverId,
|
||||
proposals: previews.flatMap((preview) => preview.proposal ?? []),
|
||||
onUpdated: (plugins) =>
|
||||
toast.show(
|
||||
plugins.length === 1
|
||||
? t("settings.plugins.gallery.update.updated", {
|
||||
name: pluginDisplayName(plugins[0]!),
|
||||
})
|
||||
: t("settings.plugins.gallery.update.updatedMany", { count: plugins.length }),
|
||||
{ variant: "success" },
|
||||
),
|
||||
});
|
||||
},
|
||||
[client, queryClient, serverId, t, toast],
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateRow({
|
||||
serverId,
|
||||
plugin,
|
||||
preview,
|
||||
listing,
|
||||
onUpdate,
|
||||
}: {
|
||||
serverId: string;
|
||||
plugin: PluginListItem;
|
||||
preview: PluginUpdatePreview;
|
||||
listing?: PluginListing;
|
||||
onUpdate(previews: PluginUpdatePreview[]): void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const operation = usePluginOperation(serverId, plugin.id);
|
||||
const running = operation?.status === "running";
|
||||
const name = pluginDisplayName(plugin);
|
||||
const target =
|
||||
preview.target?.kind === "npm" ? preview.target.version : preview.target?.commit.slice(0, 7);
|
||||
const current = shortRevision(preview.current?.currentRevision);
|
||||
const open = useCallback(
|
||||
() => router.push(buildPluginPageRoute(serverId, plugin.id)),
|
||||
[plugin.id, serverId],
|
||||
);
|
||||
const update = useCallback(() => onUpdate([preview]), [onUpdate, preview]);
|
||||
let label = t("settings.plugins.gallery.actions.update");
|
||||
if (running) label = t("settings.plugins.gallery.actions.updating");
|
||||
else if (operation?.status === "failed") label = t("settings.plugins.gallery.actions.retry");
|
||||
return (
|
||||
<PluginRow
|
||||
name={name}
|
||||
icon={listing?.icon}
|
||||
description={plugin.description ?? listing?.description}
|
||||
meta={current && target ? `${current} → ${target}` : target}
|
||||
error={operation?.status === "failed" ? operation.error : undefined}
|
||||
onPress={open}
|
||||
accessibilityLabel={name}
|
||||
testID={`plugin-update-${plugin.id}`}
|
||||
>
|
||||
<Button variant="outline" onPress={update} loading={running}>
|
||||
{label}
|
||||
</Button>
|
||||
</PluginRow>
|
||||
);
|
||||
}
|
||||
|
||||
function InstalledRow({
|
||||
serverId,
|
||||
plugin,
|
||||
listing,
|
||||
pending,
|
||||
onToggle,
|
||||
}: {
|
||||
serverId: string;
|
||||
plugin: PluginListItem;
|
||||
listing?: PluginListing;
|
||||
pending: boolean;
|
||||
onToggle(plugin: PluginListItem, enabled: boolean): void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const name = pluginDisplayName(plugin);
|
||||
const error = usePluginEvaluationError(serverId, plugin.id) ?? plugin.error;
|
||||
const failed = error !== undefined || plugin.status === "failed";
|
||||
const open = useCallback(
|
||||
() => router.push(buildPluginPageRoute(serverId, plugin.id)),
|
||||
[plugin.id, serverId],
|
||||
);
|
||||
const toggle = useCallback((enabled: boolean) => onToggle(plugin, enabled), [onToggle, plugin]);
|
||||
const badge = useMemo(
|
||||
() =>
|
||||
failed ? (
|
||||
<StatusBadge label={t("settings.plugins.status.failed")} variant="error" size="xs" />
|
||||
) : null,
|
||||
[failed, t],
|
||||
);
|
||||
return (
|
||||
<PluginRow
|
||||
name={name}
|
||||
icon={listing?.icon}
|
||||
badge={badge}
|
||||
description={plugin.description ?? listing?.description}
|
||||
meta={installedMeta(plugin, listing)}
|
||||
authorAvatar={listing && listingAuthorAvatar(listing)}
|
||||
error={error}
|
||||
onPress={open}
|
||||
accessibilityLabel={name}
|
||||
testID={`plugin-row-${plugin.id}`}
|
||||
>
|
||||
<Switch
|
||||
value={plugin.enabled}
|
||||
onValueChange={toggle}
|
||||
disabled={pending}
|
||||
accessibilityLabel={`${name}: ${t(
|
||||
plugin.enabled ? "settings.plugins.actions.disable" : "settings.plugins.actions.enable",
|
||||
)}`}
|
||||
/>
|
||||
</PluginRow>
|
||||
);
|
||||
}
|
||||
|
||||
export function InstalledTab({
|
||||
serverId,
|
||||
plugins,
|
||||
listings,
|
||||
query,
|
||||
onBrowse,
|
||||
}: {
|
||||
serverId: string;
|
||||
plugins: readonly PluginListItem[];
|
||||
/** Directory listings by ID, for icons and authors. Empty until the directory loads. */
|
||||
listings: ReadonlyMap<string, PluginListing>;
|
||||
query: string;
|
||||
onBrowse(): void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const actions = usePluginActions(serverId);
|
||||
const updates = usePluginUpdates(serverId);
|
||||
const update = useUpdate(serverId);
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const shown = plugins.filter((plugin) => matchesQuery(plugin, normalizedQuery));
|
||||
const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
|
||||
const pendingUpdates = (updates.data ?? []).filter((preview) => byId.has(preview.id));
|
||||
const listingOf = (plugin: PluginListItem) => {
|
||||
const id = installedListingId(plugin);
|
||||
return id ? listings.get(id) : undefined;
|
||||
};
|
||||
const toggle = useCallback(
|
||||
(plugin: PluginListItem, enabled: boolean) =>
|
||||
void actions.run(enabled ? "enable" : "disable", plugin),
|
||||
[actions],
|
||||
);
|
||||
const updateAll = useCallback(() => update(pendingUpdates), [pendingUpdates, update]);
|
||||
const updatesTrailing = useMemo(
|
||||
() =>
|
||||
pendingUpdates.length > 1 ? (
|
||||
<Button variant="ghost" onPress={updateAll}>
|
||||
{t("settings.plugins.gallery.actions.updateAll")}
|
||||
</Button>
|
||||
) : undefined,
|
||||
[pendingUpdates.length, t, updateAll],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{pendingUpdates.length > 0 && !normalizedQuery ? (
|
||||
<SettingsSection
|
||||
title={t("settings.plugins.gallery.sections.updates")}
|
||||
trailing={updatesTrailing}
|
||||
testID="plugin-updates"
|
||||
>
|
||||
<SettingsCard>
|
||||
{pendingUpdates.map((preview) => {
|
||||
const plugin = byId.get(preview.id)!;
|
||||
return (
|
||||
<UpdateRow
|
||||
key={preview.id}
|
||||
serverId={serverId}
|
||||
plugin={plugin}
|
||||
preview={preview}
|
||||
listing={listingOf(plugin)}
|
||||
onUpdate={update}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
<SettingsSection title={t("settings.plugins.gallery.sections.installed")}>
|
||||
<SettingsCard testID="plugin-installed-list">
|
||||
{shown.length ? (
|
||||
shown.map((plugin) => (
|
||||
<InstalledRow
|
||||
key={plugin.id}
|
||||
serverId={serverId}
|
||||
plugin={plugin}
|
||||
listing={listingOf(plugin)}
|
||||
pending={actions.pending?.pluginId === plugin.id}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<View style={styles.empty}>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{normalizedQuery
|
||||
? t("settings.plugins.gallery.empty.noMatches")
|
||||
: t("settings.plugins.states.empty")}
|
||||
</Text>
|
||||
{normalizedQuery ? null : (
|
||||
<Button variant="outline" onPress={onBrowse}>
|
||||
{t("settings.plugins.gallery.actions.browse")}
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
empty: { padding: theme.spacing[6], alignItems: "center", gap: theme.spacing[3] },
|
||||
}));
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PluginListingIndex, PluginListing } from "@getpaseo/protocol/plugin-registry";
|
||||
import {
|
||||
ALL_CATEGORIES,
|
||||
featuredListings,
|
||||
filterDirectory,
|
||||
formatInstallCount,
|
||||
listingImages,
|
||||
listingText,
|
||||
} from "./listing";
|
||||
|
||||
function listing(id: string, overrides: Partial<PluginListing> = {}): PluginListing {
|
||||
return {
|
||||
id,
|
||||
name: id.split("/")[1] ?? id,
|
||||
description: `${id} description`,
|
||||
categories: [],
|
||||
author: { github: id.split("/")[0] ?? id },
|
||||
repository: { url: `https://github.com/${id}` },
|
||||
artifact: { kind: "git", remote: `https://github.com/${id}.git`, commit: "a".repeat(40) },
|
||||
media: [],
|
||||
submittedAt: "2026-01-01T00:00:00Z",
|
||||
reviewedAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
publishedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
} as PluginListing;
|
||||
}
|
||||
|
||||
function directory(plugins: PluginListing[], featured: string[] = []): PluginListingIndex {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
registry: { name: "Paseo", url: "https://plugins.paseo.sh" },
|
||||
categories: [{ slug: "git", label: "Git" }],
|
||||
plugins,
|
||||
featured,
|
||||
generatedAt: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
const ids = (listings: PluginListing[]) => listings.map((entry) => entry.id);
|
||||
|
||||
describe("filterDirectory", () => {
|
||||
const index = directory([
|
||||
listing("acme/zeta", { installs: 5, updatedAt: "2026-03-01T00:00:00Z", categories: ["git"] }),
|
||||
listing("acme/alpha", { installs: 50, updatedAt: "2026-01-01T00:00:00Z" }),
|
||||
listing("other/beta", {
|
||||
updatedAt: "2026-02-01T00:00:00Z",
|
||||
translations: { fr: { name: "Bêta", description: "Revue de code" } },
|
||||
}),
|
||||
]);
|
||||
|
||||
it("orders by installs, recency or name", () => {
|
||||
const all = { query: "", category: ALL_CATEGORIES };
|
||||
expect(ids(filterDirectory(index, { ...all, sort: "popular" }, "en"))).toEqual([
|
||||
"acme/alpha",
|
||||
"acme/zeta",
|
||||
"other/beta",
|
||||
]);
|
||||
expect(ids(filterDirectory(index, { ...all, sort: "recent" }, "en"))).toEqual([
|
||||
"acme/zeta",
|
||||
"other/beta",
|
||||
"acme/alpha",
|
||||
]);
|
||||
expect(ids(filterDirectory(index, { ...all, sort: "name" }, "en"))).toEqual([
|
||||
"acme/alpha",
|
||||
"other/beta",
|
||||
"acme/zeta",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps only the chosen category", () => {
|
||||
expect(
|
||||
ids(filterDirectory(index, { query: "", category: "git", sort: "popular" }, "en")),
|
||||
).toEqual(["acme/zeta"]);
|
||||
});
|
||||
|
||||
it("matches the query against the author and the reader's translation", () => {
|
||||
const search = (query: string, language: string) =>
|
||||
ids(filterDirectory(index, { query, category: ALL_CATEGORIES, sort: "name" }, language));
|
||||
expect(search(" ACME ", "en")).toEqual(["acme/alpha", "acme/zeta"]);
|
||||
expect(search("revue", "fr-CA")).toEqual(["other/beta"]);
|
||||
expect(search("revue", "en")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listing presentation", () => {
|
||||
it("falls back from a regional language to its base translation, then English", () => {
|
||||
const entry = listing("other/beta", { translations: { fr: { name: "Bêta" } } });
|
||||
expect(listingText(entry, "fr-CA")).toEqual({
|
||||
name: "Bêta",
|
||||
description: "other/beta description",
|
||||
});
|
||||
expect(listingText(entry, "ja").name).toBe("beta");
|
||||
});
|
||||
|
||||
it("formats install counts compactly", () => {
|
||||
expect([999, 1000, 1149, 9950, 12_400].map(formatInstallCount)).toEqual([
|
||||
"999",
|
||||
"1k",
|
||||
"1.1k",
|
||||
"9.9k",
|
||||
"12k",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows images and leaves videos out", () => {
|
||||
const entry = listing("acme/alpha", {
|
||||
media: ["https://cdn.test/a.png", "https://cdn.test/demo.mp4", "https://cdn.test/b.webp"],
|
||||
});
|
||||
expect(listingImages(entry)).toEqual(["https://cdn.test/a.png", "https://cdn.test/b.webp"]);
|
||||
});
|
||||
|
||||
it("features listed plugins in the maintainer's order and skips unlisted ones", () => {
|
||||
const index = directory(
|
||||
[listing("acme/alpha"), listing("acme/zeta")],
|
||||
["acme/zeta", "gone/plugin", "acme/alpha"],
|
||||
);
|
||||
expect(ids(featuredListings(index))).toEqual(["acme/zeta", "acme/alpha"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { PluginListItem } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
pluginMediaKind,
|
||||
type PluginListingIndex,
|
||||
type PluginListing,
|
||||
} from "@getpaseo/protocol/plugin-registry";
|
||||
|
||||
export type DirectorySort = "popular" | "recent" | "name";
|
||||
export const ALL_CATEGORIES = "all";
|
||||
|
||||
export interface DirectoryFilter {
|
||||
query: string;
|
||||
category: string;
|
||||
sort: DirectorySort;
|
||||
}
|
||||
|
||||
/** The listing's name and description in the reader's language, falling back to English. */
|
||||
export function listingText(listing: PluginListing, language: string) {
|
||||
const translation = listing.translations?.[language] ?? listing.translations?.[baseTag(language)];
|
||||
return {
|
||||
name: translation?.name ?? listing.name,
|
||||
description: translation?.description ?? listing.description,
|
||||
};
|
||||
}
|
||||
|
||||
function baseTag(language: string): string {
|
||||
return language.split("-")[0] ?? language;
|
||||
}
|
||||
|
||||
export function listingAuthor(listing: PluginListing): string {
|
||||
return listing.author.name ?? listing.author.github;
|
||||
}
|
||||
|
||||
/** The author's GitHub avatar, the same picture the website shows. */
|
||||
export function listingAuthorAvatar(listing: PluginListing): string {
|
||||
return `https://github.com/${encodeURIComponent(listing.author.github)}.png?size=40`;
|
||||
}
|
||||
|
||||
/** The version a listing pins: the npm version, or the short Git commit. */
|
||||
export function listingVersion(listing: PluginListing): string | undefined {
|
||||
return listing.artifact.version ?? listing.artifact.commit?.slice(0, 7);
|
||||
}
|
||||
|
||||
/** Where a listing's code comes from: the npm package, or the repository. */
|
||||
export function listingSource(listing: PluginListing): string {
|
||||
return listing.artifact.package ?? listing.repository.url;
|
||||
}
|
||||
|
||||
/** Image media in display order. Videos are left to the website. */
|
||||
export function listingImages(listing: PluginListing): string[] {
|
||||
return listing.media.filter((url) => pluginMediaKind(url) === "image");
|
||||
}
|
||||
|
||||
/** "1.1k" style counts: one decimal below ten thousand, whole thousands above. */
|
||||
export function formatInstallCount(count: number): string {
|
||||
if (count < 1000) return String(count);
|
||||
if (count < 10_000) return `${(count / 1000).toFixed(1).replace(/\.0$/, "")}k`;
|
||||
return `${Math.round(count / 1000)}k`;
|
||||
}
|
||||
|
||||
/** The registry listing an installed plugin came from, if it came from one. */
|
||||
export function installedListingId(plugin: PluginListItem): string | undefined {
|
||||
const identity = plugin.installation?.identity;
|
||||
if (!identity || identity.kind === "directory") return undefined;
|
||||
return identity.registry?.id;
|
||||
}
|
||||
|
||||
/** Installed plugins by the listing they came from. */
|
||||
export function installedByListing(plugins: readonly PluginListItem[]) {
|
||||
const byListing = new Map<string, PluginListItem>();
|
||||
for (const plugin of plugins) {
|
||||
const listingId = installedListingId(plugin);
|
||||
if (listingId) byListing.set(listingId, plugin);
|
||||
}
|
||||
return byListing;
|
||||
}
|
||||
|
||||
function matches(listing: PluginListing, language: string, query: string): boolean {
|
||||
if (!query) return true;
|
||||
const { name, description } = listingText(listing, language);
|
||||
const haystack = [name, description, listing.id, listingAuthor(listing)].join("\n").toLowerCase();
|
||||
return haystack.includes(query);
|
||||
}
|
||||
|
||||
const SORTS: Record<DirectorySort, (left: PluginListing, right: PluginListing) => number> = {
|
||||
popular: (left, right) => (right.installs ?? 0) - (left.installs ?? 0),
|
||||
recent: (left, right) => right.updatedAt.localeCompare(left.updatedAt),
|
||||
name: (left, right) => left.name.localeCompare(right.name),
|
||||
};
|
||||
|
||||
/** The directory's listings that pass the filter, in the chosen order. */
|
||||
export function filterDirectory(
|
||||
index: PluginListingIndex,
|
||||
filter: DirectoryFilter,
|
||||
language: string,
|
||||
): PluginListing[] {
|
||||
const query = filter.query.trim().toLowerCase();
|
||||
return index.plugins
|
||||
.filter(
|
||||
(listing) =>
|
||||
(filter.category === ALL_CATEGORIES || listing.categories.includes(filter.category)) &&
|
||||
matches(listing, language, query),
|
||||
)
|
||||
.sort(SORTS[filter.sort]);
|
||||
}
|
||||
|
||||
/** Featured listings that exist, in the maintainer's order. */
|
||||
export function featuredListings(index: PluginListingIndex): PluginListing[] {
|
||||
const byId = new Map(index.plugins.map((listing) => [listing.id, listing]));
|
||||
return index.featured.flatMap((id) => byId.get(id) ?? []);
|
||||
}
|
||||
|
||||
export function categoryLabel(index: PluginListingIndex, slug: string): string {
|
||||
return index.categories.find((category) => category.slug === slug)?.label ?? slug;
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
FileText,
|
||||
MoreHorizontal,
|
||||
PackagePlus,
|
||||
Power,
|
||||
RotateCw,
|
||||
Trash2,
|
||||
} from "lucide-react-native";
|
||||
import type { PluginListItem, PluginLogEntry } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
AdaptiveModalSheet,
|
||||
SheetHeaderIconButton,
|
||||
type SheetHeader,
|
||||
} from "@/components/adaptive-modal-sheet";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
iconButtonChromeHang,
|
||||
useIconButtonChromeGlyphSize,
|
||||
useIconButtonChromeStyle,
|
||||
} from "@/components/ui/icon-button-chrome";
|
||||
import { foregroundIconColorMapping, mutedIconColorMapping } from "@/components/ui/icon-color";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useToast } from "@/contexts/toast-api-context";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { pluginDisplayName } from "@/plugins/plugin-list";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import type { PluginAction } from "./actions";
|
||||
import { InstallPluginSheet } from "./install-sheet";
|
||||
import { pluginErrorText } from "./error-text";
|
||||
|
||||
const SUBMIT_PLUGIN_URL =
|
||||
"https://github.com/getpaseo/plugins/issues/new?template=submit-plugin.yml";
|
||||
const ThemedMore = withUnistyles(MoreHorizontal);
|
||||
const dangerIconMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
|
||||
const ThemedPackagePlus = withUnistyles(PackagePlus);
|
||||
const ThemedPower = withUnistyles(Power);
|
||||
const ThemedArrow = withUnistyles(ArrowUpRight);
|
||||
const ThemedRotate = withUnistyles(RotateCw);
|
||||
const ThemedLogs = withUnistyles(FileText);
|
||||
const ThemedTrash = withUnistyles(Trash2);
|
||||
const installIcon = <ThemedPackagePlus size={14} uniProps={mutedIconColorMapping} />;
|
||||
const powerIcon = <ThemedPower size={14} uniProps={mutedIconColorMapping} />;
|
||||
const linkIcon = <ThemedArrow size={14} uniProps={mutedIconColorMapping} />;
|
||||
const reloadIcon = <ThemedRotate size={14} uniProps={mutedIconColorMapping} />;
|
||||
const logsIcon = <ThemedLogs size={14} uniProps={mutedIconColorMapping} />;
|
||||
const removeIcon = <ThemedTrash size={14} uniProps={dangerIconMapping} />;
|
||||
|
||||
type TriggerState = PressableStateCallbackType & { hovered?: boolean; open?: boolean };
|
||||
// The trigger hangs by how far its frame reaches past the glyph, so the glyph sits on the trailing
|
||||
// content rail and the frame does not grow the row.
|
||||
const TRIGGER_HANG = iconButtonChromeHang("small");
|
||||
|
||||
function MenuTrigger({ label, testID }: { label: string; testID: string }) {
|
||||
const compact = useIsCompactFormFactor();
|
||||
const style = useIconButtonChromeStyle({ size: "small", compact, style: styles.trigger });
|
||||
const glyphSize = useIconButtonChromeGlyphSize("small", compact);
|
||||
return (
|
||||
<DropdownMenuTrigger
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
style={style}
|
||||
testID={testID}
|
||||
>
|
||||
{({ hovered, open }: TriggerState) => (
|
||||
<ThemedMore
|
||||
size={glyphSize}
|
||||
uniProps={hovered || open ? foregroundIconColorMapping : mutedIconColorMapping}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
/** The page's occasional actions: install from a source, turn plugins off, submit a plugin. */
|
||||
export function PluginGalleryMenu({ serverId }: { serverId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
// COMPAT(pluginSourceInstallation): added in v0.8.0; remove gate after 2027-03-16.
|
||||
const sourceInstallSupported = useHostFeature(serverId, "pluginSourceInstallation");
|
||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const pluginsOn = config?.pluginsEnabled === true;
|
||||
const openInstall = useCallback(() => setInstalling(true), []);
|
||||
const closeInstall = useCallback(() => setInstalling(false), []);
|
||||
const toggle = useCallback(async () => {
|
||||
try {
|
||||
await patchConfig({ pluginsEnabled: !pluginsOn });
|
||||
toast.show(
|
||||
pluginsOn
|
||||
? t("settings.plugins.feedback.globalDisabled")
|
||||
: t("settings.plugins.feedback.globalEnabled"),
|
||||
{ variant: "success" },
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(pluginErrorText(error));
|
||||
}
|
||||
}, [patchConfig, pluginsOn, t, toast]);
|
||||
const submit = useCallback(() => void openExternalUrl(SUBMIT_PLUGIN_URL), []);
|
||||
const label = t("settings.plugins.gallery.menu");
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<MenuTrigger label={label} testID="plugin-gallery-menu" />
|
||||
<DropdownMenuContent align="end" width={220} sheetTitle={label}>
|
||||
{sourceInstallSupported ? (
|
||||
<DropdownMenuItem leading={installIcon} onSelect={openInstall}>
|
||||
{t("settings.plugins.gallery.actions.installFromSource")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem leading={powerIcon} onSelect={toggle} disabled={!config}>
|
||||
{pluginsOn
|
||||
? t("settings.plugins.gallery.actions.turnOff")
|
||||
: t("settings.plugins.gallery.actions.turnOnAll")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem leading={linkIcon} onSelect={submit}>
|
||||
{t("settings.plugins.gallery.actions.submit")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{installing ? (
|
||||
<InstallPluginSheet serverId={serverId} target={SOURCE_TARGET} onClose={closeInstall} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
const SOURCE_TARGET = { kind: "source" } as const;
|
||||
|
||||
/** One installed plugin's occasional actions, on its page. */
|
||||
export function PluginActionsMenu({
|
||||
serverId,
|
||||
plugin,
|
||||
repositoryUrl,
|
||||
pendingAction,
|
||||
onAction,
|
||||
}: {
|
||||
serverId: string;
|
||||
plugin: PluginListItem;
|
||||
repositoryUrl?: string;
|
||||
pendingAction?: PluginAction;
|
||||
onAction(action: PluginAction): void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// COMPAT(pluginLogs): added in v0.4.0, remove gate after 2027-08-16.
|
||||
const logsSupported = useHostFeature(serverId, "pluginLogs");
|
||||
const [logsOpen, setLogsOpen] = useState(false);
|
||||
const reload = useCallback(() => onAction("reload"), [onAction]);
|
||||
const remove = useCallback(() => onAction("remove"), [onAction]);
|
||||
const openLogs = useCallback(() => setLogsOpen(true), []);
|
||||
const closeLogs = useCallback(() => setLogsOpen(false), []);
|
||||
const openRepository = useCallback(() => {
|
||||
if (repositoryUrl) void openExternalUrl(repositoryUrl);
|
||||
}, [repositoryUrl]);
|
||||
const label = t("settings.plugins.actions.menu", { name: pluginDisplayName(plugin) });
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<MenuTrigger label={label} testID={`plugin-actions-${plugin.id}`} />
|
||||
<DropdownMenuContent align="end" width={220} sheetTitle={label}>
|
||||
<DropdownMenuItem
|
||||
leading={reloadIcon}
|
||||
onSelect={reload}
|
||||
disabled={!plugin.enabled || pendingAction !== undefined}
|
||||
status={pendingAction === "reload" ? "pending" : "idle"}
|
||||
pendingLabel={t("settings.plugins.actions.reloading")}
|
||||
>
|
||||
{t("settings.plugins.actions.reload")}
|
||||
</DropdownMenuItem>
|
||||
{logsSupported ? (
|
||||
<DropdownMenuItem leading={logsIcon} onSelect={openLogs}>
|
||||
{t("settings.plugins.logs.action")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{repositoryUrl ? (
|
||||
<DropdownMenuItem leading={linkIcon} onSelect={openRepository}>
|
||||
{t("settings.plugins.gallery.actions.repository")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
destructive
|
||||
leading={removeIcon}
|
||||
onSelect={remove}
|
||||
disabled={pendingAction !== undefined}
|
||||
status={pendingAction === "remove" ? "pending" : "idle"}
|
||||
pendingLabel={t("settings.plugins.actions.removing")}
|
||||
>
|
||||
{t("settings.plugins.actions.remove")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{logsOpen ? (
|
||||
<PluginLogsSheet serverId={serverId} plugin={plugin} onClose={closeLogs} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginLogsSheet({
|
||||
serverId,
|
||||
plugin,
|
||||
onClose,
|
||||
}: {
|
||||
serverId: string;
|
||||
plugin: PluginListItem;
|
||||
onClose(): void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const pluginId = plugin.id;
|
||||
const logs = useFetchQuery({
|
||||
queryKey: ["plugin-logs", serverId, pluginId],
|
||||
queryFn: () => {
|
||||
if (!client) throw new Error("Plugin host is offline");
|
||||
return client.getPluginLogs(pluginId);
|
||||
},
|
||||
enabled: Boolean(client),
|
||||
dataShape: "list",
|
||||
staleTimeMs: 0,
|
||||
});
|
||||
const refresh = useCallback(() => {
|
||||
void logs.refetch();
|
||||
}, [logs]);
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
title: t("settings.plugins.logs.title", { name: pluginDisplayName(plugin) }),
|
||||
actions: (
|
||||
<SheetHeaderIconButton
|
||||
icon={RotateCw}
|
||||
label={
|
||||
logs.isFetching
|
||||
? t("settings.plugins.logs.refreshing")
|
||||
: t("settings.plugins.logs.refresh")
|
||||
}
|
||||
onPress={refresh}
|
||||
busy={logs.isFetching}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[logs.isFetching, plugin, refresh, t],
|
||||
);
|
||||
|
||||
let content = <Text style={styles.logsState}>{t("settings.plugins.logs.loading")}</Text>;
|
||||
if (logs.isError) {
|
||||
content = (
|
||||
<Alert
|
||||
size="sm"
|
||||
variant="error"
|
||||
title={t("settings.plugins.logs.errorTitle")}
|
||||
description={pluginErrorText(logs.error)}
|
||||
>
|
||||
<Button variant="outline" onPress={refresh}>
|
||||
{t("settings.plugins.logs.refresh")}
|
||||
</Button>
|
||||
</Alert>
|
||||
);
|
||||
} else if (!logs.isLoading) {
|
||||
content = logs.data?.length ? (
|
||||
<View style={styles.logList}>
|
||||
{logs.data.map((entry: PluginLogEntry) => (
|
||||
<View key={entry.sequence} style={styles.logEntry}>
|
||||
<Text style={entry.stream === "stderr" ? styles.logStderr : styles.logStdout}>
|
||||
{entry.timestamp} {entry.stream}
|
||||
</Text>
|
||||
<Text style={styles.logMessage}>{entry.message}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.logsState}>{t("settings.plugins.logs.empty")}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet header={header} visible onClose={onClose} testID="plugin-logs-sheet">
|
||||
{content}
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: { marginVertical: TRIGGER_HANG, marginRight: TRIGGER_HANG },
|
||||
logsState: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
textAlign: "center",
|
||||
paddingVertical: theme.spacing[6],
|
||||
},
|
||||
logList: { gap: theme.spacing[2] },
|
||||
logEntry: {
|
||||
padding: theme.spacing[3],
|
||||
gap: theme.spacing[1],
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
logStdout: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
logStderr: {
|
||||
color: theme.colors.statusDanger,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
logMessage: {
|
||||
color: theme.colors.foreground,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type {
|
||||
PluginListItem,
|
||||
PluginUpdatePreview,
|
||||
PluginUpdateProposal,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { daemonConfigQueryKey } from "@/data/daemon-config";
|
||||
import { pluginListQueryKey } from "@/plugins/plugin-list";
|
||||
import { pluginErrorText } from "./error-text";
|
||||
|
||||
/**
|
||||
* Installs and updates outlive the screen that started them: leaving Plugins and coming back
|
||||
* mid-run still shows the run on its row. A finished run leaves no entry; the plugin list says
|
||||
* what changed.
|
||||
*/
|
||||
export type PluginOperation =
|
||||
| { kind: "install" | "update"; status: "running" }
|
||||
| { kind: "install" | "update"; status: "failed"; error: string };
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
const operations = new Map<string, PluginOperation>();
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
function operationKey(serverId: string, subject: string): string {
|
||||
return `${serverId}\n${subject}`;
|
||||
}
|
||||
|
||||
function setOperation(serverId: string, subject: string, operation: PluginOperation | null) {
|
||||
const key = operationKey(serverId, subject);
|
||||
if (operation) operations.set(key, operation);
|
||||
else operations.delete(key);
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function subscribe(listener: Listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/** The install or update running or failed for a plugin, keyed by listing ID or plugin ID. */
|
||||
export function usePluginOperation(serverId: string, subject: string | undefined) {
|
||||
const read = useCallback(
|
||||
() => (subject ? operations.get(operationKey(serverId, subject)) : undefined),
|
||||
[serverId, subject],
|
||||
);
|
||||
return useSyncExternalStore(subscribe, read, read);
|
||||
}
|
||||
|
||||
/** Forgets a failed run once its error has been read. A running one keeps going. */
|
||||
export function dismissFailedOperation(serverId: string, subject: string) {
|
||||
if (operations.get(operationKey(serverId, subject))?.status === "failed")
|
||||
setOperation(serverId, subject, null);
|
||||
}
|
||||
|
||||
export function pluginUpdatesQueryKey(serverId: string) {
|
||||
return ["plugin-updates", serverId] as const;
|
||||
}
|
||||
|
||||
function upsertPlugin(queryClient: QueryClient, serverId: string, plugin: PluginListItem) {
|
||||
queryClient.setQueryData<PluginListItem[]>(pluginListQueryKey(serverId), (plugins = []) => {
|
||||
const others = plugins.filter((candidate) => candidate.id !== plugin.id);
|
||||
return [...others, plugin].sort((left, right) => left.id.localeCompare(right.id));
|
||||
});
|
||||
}
|
||||
|
||||
interface HostContext {
|
||||
client: DaemonClient;
|
||||
queryClient: QueryClient;
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export interface InstallRequest extends HostContext {
|
||||
/** A registry ID such as `acme/example`, or any source the CLI accepts. */
|
||||
source: string;
|
||||
/** Where the run shows: the listing ID, or the source for an install from source. */
|
||||
subject: string;
|
||||
/** Plugins are off on the host; the confirmation said installing turns them on. */
|
||||
enablePlugins: boolean;
|
||||
onInstalled(plugin: PluginListItem): void;
|
||||
}
|
||||
|
||||
export async function installPlugin(request: InstallRequest): Promise<void> {
|
||||
const { client, queryClient, serverId, subject } = request;
|
||||
if (operations.get(operationKey(serverId, subject))?.status === "running") return;
|
||||
setOperation(serverId, subject, { kind: "install", status: "running" });
|
||||
try {
|
||||
if (request.enablePlugins) {
|
||||
const result = await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
queryClient.setQueryData(daemonConfigQueryKey(serverId), result.config);
|
||||
}
|
||||
const plugin = await client.installPluginSource({ source: request.source });
|
||||
// Write the result before clearing the run, so the row goes straight to Installed.
|
||||
upsertPlugin(queryClient, serverId, plugin);
|
||||
setOperation(serverId, subject, null);
|
||||
request.onInstalled(plugin);
|
||||
} catch (error) {
|
||||
setOperation(serverId, subject, {
|
||||
kind: "install",
|
||||
status: "failed",
|
||||
error: pluginErrorText(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface UpdateRequest extends HostContext {
|
||||
proposals: PluginUpdateProposal[];
|
||||
onUpdated(plugins: PluginListItem[]): void;
|
||||
}
|
||||
|
||||
export async function updatePlugins(request: UpdateRequest): Promise<void> {
|
||||
const { client, queryClient, serverId } = request;
|
||||
const proposals = request.proposals.filter(
|
||||
(proposal) => operations.get(operationKey(serverId, proposal.id))?.status !== "running",
|
||||
);
|
||||
if (proposals.length === 0) return;
|
||||
for (const proposal of proposals)
|
||||
setOperation(serverId, proposal.id, { kind: "update", status: "running" });
|
||||
try {
|
||||
const results = await client.applyPluginUpdates(proposals);
|
||||
const updated: PluginListItem[] = [];
|
||||
for (const result of results) {
|
||||
if (result.outcome === "updated" && result.plugin) {
|
||||
upsertPlugin(queryClient, serverId, result.plugin);
|
||||
updated.push(result.plugin);
|
||||
setOperation(serverId, result.id, null);
|
||||
} else {
|
||||
setOperation(serverId, result.id, {
|
||||
kind: "update",
|
||||
status: "failed",
|
||||
error: result.error ?? "Update failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
const updatedIds = new Set(updated.map((plugin) => plugin.id));
|
||||
queryClient.setQueryData<PluginUpdatePreview[]>(pluginUpdatesQueryKey(serverId), (previews) =>
|
||||
previews?.filter((preview) => !updatedIds.has(preview.id)),
|
||||
);
|
||||
if (updated.length) request.onUpdated(updated);
|
||||
if (updated.length < results.length) recheckUpdates(request);
|
||||
} catch (error) {
|
||||
for (const proposal of proposals)
|
||||
setOperation(serverId, proposal.id, {
|
||||
kind: "update",
|
||||
status: "failed",
|
||||
error: pluginErrorText(error),
|
||||
});
|
||||
recheckUpdates(request);
|
||||
}
|
||||
}
|
||||
|
||||
// A proposal fails when the plugin changed since it was reviewed; Retry needs a fresh one.
|
||||
function recheckUpdates({ queryClient, serverId }: HostContext): void {
|
||||
void queryClient.invalidateQueries({ queryKey: pluginUpdatesQueryKey(serverId) });
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
import { ArrowLeft } from "lucide-react-native";
|
||||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Image, ScrollView, Text, View } from "react-native";
|
||||
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { PluginListItem, PluginUpdatePreview } from "@getpaseo/protocol/messages";
|
||||
import { formatPluginInstallation } from "@getpaseo/protocol/plugin-source-reference";
|
||||
import type {
|
||||
PluginListingIndex,
|
||||
PluginListing,
|
||||
PluginListingDetail,
|
||||
} from "@getpaseo/protocol/plugin-registry";
|
||||
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
||||
import { SettingsCard, SettingsRow, SettingsSection } from "@/components/settings";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ExternalLink } from "@/components/ui/external-link";
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useToast } from "@/contexts/toast-api-context";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { useInstalledPlugin, usePluginEvaluationError } from "@/plugins/registry";
|
||||
import { pluginDisplayName } from "@/plugins/plugin-list";
|
||||
import { PluginSettingsContent } from "@/plugins/settings";
|
||||
import { usePluginActions, type PluginAction } from "./actions";
|
||||
import { useListingMeta } from "./browse-tab";
|
||||
import { ListingInstallControl } from "./install-control";
|
||||
import { InstallPluginSheet, type InstallTarget } from "./install-sheet";
|
||||
import {
|
||||
categoryLabel,
|
||||
installedByListing,
|
||||
installedListingId,
|
||||
listingAuthorAvatar,
|
||||
listingImages,
|
||||
listingText,
|
||||
listingSource,
|
||||
listingVersion,
|
||||
} from "./listing";
|
||||
import { PluginActionsMenu } from "./menus";
|
||||
import { updatePlugins, usePluginOperation, type PluginOperation } from "./operations";
|
||||
import { PluginMeta, PluginTile } from "./plugin-row";
|
||||
import { buildPluginListingRoute } from "./routes";
|
||||
import { PluginsOffAlert } from "./plugins-off-alert";
|
||||
import { OverviewSkeleton, PluginPageSkeleton } from "./skeletons";
|
||||
import {
|
||||
useLivePluginList,
|
||||
usePluginDirectory,
|
||||
usePluginListing,
|
||||
usePluginUpdates,
|
||||
} from "./queries";
|
||||
import { pluginErrorText } from "./error-text";
|
||||
|
||||
/** Which plugin the page is for: one installed on the host, or one listed in the directory. */
|
||||
export type PluginPageSubject =
|
||||
| { kind: "installed"; pluginId: string; screenId?: string }
|
||||
| { kind: "listing"; listingId: string };
|
||||
|
||||
function useResolvedPlugin(serverId: string, subject: PluginPageSubject) {
|
||||
const plugins = useLivePluginList(serverId);
|
||||
const plugin = useMemo(() => {
|
||||
const list = plugins.data ?? [];
|
||||
if (subject.kind === "installed") return list.find((item) => item.id === subject.pluginId);
|
||||
return installedByListing(list).get(subject.listingId);
|
||||
}, [plugins.data, subject]);
|
||||
const listingId =
|
||||
subject.kind === "listing" ? subject.listingId : plugin && installedListingId(plugin);
|
||||
const detail = usePluginListing(serverId, listingId);
|
||||
const directory = usePluginDirectory(serverId);
|
||||
// The directory the user came from already has the listing, so the page shows it at once and
|
||||
// only the overview waits for the listing's own request.
|
||||
const listing =
|
||||
detail.data ?? directory.query.data?.plugins.find((entry) => entry.id === listingId);
|
||||
const listingLoading = detail.isLoading && !listing;
|
||||
return {
|
||||
plugin,
|
||||
listingId,
|
||||
listing,
|
||||
detail,
|
||||
loading: plugins.isLoading || (subject.kind === "listing" && listingLoading),
|
||||
};
|
||||
}
|
||||
|
||||
function UpdateButton({ serverId, plugin }: { serverId: string; plugin: PluginListItem }) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const updates = usePluginUpdates(serverId);
|
||||
const operation = usePluginOperation(serverId, plugin.id);
|
||||
const preview = updates.data?.find((candidate) => candidate.id === plugin.id);
|
||||
const update = useCallback(() => {
|
||||
if (!client || !preview?.proposal) return;
|
||||
void updatePlugins({
|
||||
client,
|
||||
queryClient,
|
||||
serverId,
|
||||
proposals: [preview.proposal],
|
||||
onUpdated: ([updated]) =>
|
||||
toast.show(
|
||||
t("settings.plugins.gallery.update.updated", { name: pluginDisplayName(updated!) }),
|
||||
{ variant: "success" },
|
||||
),
|
||||
});
|
||||
}, [client, preview, queryClient, serverId, t, toast]);
|
||||
if (!preview) return null;
|
||||
const running = operation?.status === "running";
|
||||
let label = t("settings.plugins.gallery.actions.update");
|
||||
if (running) label = t("settings.plugins.gallery.actions.updating");
|
||||
else if (operation?.status === "failed") label = t("settings.plugins.gallery.actions.retry");
|
||||
return (
|
||||
<Button variant="outline" onPress={update} loading={running} testID="plugin-update">
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function InstalledControls({
|
||||
serverId,
|
||||
plugin,
|
||||
repositoryUrl,
|
||||
onRemoved,
|
||||
}: {
|
||||
serverId: string;
|
||||
plugin: PluginListItem;
|
||||
repositoryUrl?: string;
|
||||
onRemoved(): void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const actions = usePluginActions(serverId);
|
||||
const name = pluginDisplayName(plugin);
|
||||
const pendingAction =
|
||||
actions.pending?.pluginId === plugin.id ? actions.pending.action : undefined;
|
||||
const toggle = useCallback(
|
||||
(enabled: boolean) => void actions.run(enabled ? "enable" : "disable", plugin),
|
||||
[actions, plugin],
|
||||
);
|
||||
const act = useCallback(
|
||||
async (action: PluginAction) => {
|
||||
if ((await actions.run(action, plugin)) && action === "remove") onRemoved();
|
||||
},
|
||||
[actions, onRemoved, plugin],
|
||||
);
|
||||
const onAction = useCallback((action: PluginAction) => void act(action), [act]);
|
||||
return (
|
||||
<View style={styles.controls}>
|
||||
<UpdateButton serverId={serverId} plugin={plugin} />
|
||||
<Switch
|
||||
value={plugin.enabled}
|
||||
onValueChange={toggle}
|
||||
disabled={pendingAction !== undefined}
|
||||
accessibilityLabel={`${name}: ${t(
|
||||
plugin.enabled ? "settings.plugins.actions.disable" : "settings.plugins.actions.enable",
|
||||
)}`}
|
||||
/>
|
||||
<PluginActionsMenu
|
||||
serverId={serverId}
|
||||
plugin={plugin}
|
||||
repositoryUrl={repositoryUrl}
|
||||
pendingAction={pendingAction}
|
||||
onAction={onAction}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaImage({ url, onOpen }: { url: string; onOpen(url: string): void }) {
|
||||
const { t } = useTranslation();
|
||||
const source = useMemo(() => ({ uri: url }), [url]);
|
||||
const open = useCallback(() => onOpen(url), [onOpen, url]);
|
||||
return (
|
||||
<PressHighlight
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("settings.plugins.gallery.details.openImage")}
|
||||
onPress={open}
|
||||
style={styles.media}
|
||||
>
|
||||
<Image source={source} style={styles.mediaImage} resizeMode="cover" />
|
||||
</PressHighlight>
|
||||
);
|
||||
}
|
||||
|
||||
/** The listing's screenshots in a row; one opens full size in the lightbox. */
|
||||
function MediaStrip({ urls }: { urls: string[] }) {
|
||||
const [image, setImage] = useState<ImageLightboxSource | null>(null);
|
||||
const open = useCallback((uri: string) => setImage({ type: "uri", uri }), []);
|
||||
const close = useCallback(() => setImage(null), []);
|
||||
if (urls.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.mediaStrip}
|
||||
contentContainerStyle={styles.mediaRow}
|
||||
>
|
||||
{urls.map((url) => (
|
||||
<MediaImage key={url} url={url} onOpen={open} />
|
||||
))}
|
||||
</ScrollView>
|
||||
<AttachmentLightbox source={image} onClose={close} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailValue({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Text style={styles.detailValue} numberOfLines={1}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function detailSource(plugin: PluginListItem | undefined, listing: PluginListing | undefined) {
|
||||
if (plugin?.installation) return formatPluginInstallation(plugin.installation);
|
||||
if (plugin) return plugin.path;
|
||||
if (!listing) return undefined;
|
||||
return listingSource(listing);
|
||||
}
|
||||
|
||||
function Details({ plugin, listing }: { plugin?: PluginListItem; listing?: PluginListing }) {
|
||||
const { t } = useTranslation();
|
||||
const version = plugin?.installation?.currentRevision ?? (listing && listingVersion(listing));
|
||||
const source = detailSource(plugin, listing);
|
||||
const repository = listing?.repository.url;
|
||||
return (
|
||||
<SettingsSection title={t("settings.plugins.gallery.sections.details")}>
|
||||
<SettingsCard>
|
||||
{version ? (
|
||||
<SettingsRow label={t("settings.plugins.gallery.install.version")}>
|
||||
<DetailValue>{version}</DetailValue>
|
||||
</SettingsRow>
|
||||
) : null}
|
||||
{source ? (
|
||||
<SettingsRow label={t("settings.plugins.gallery.install.source")}>
|
||||
<DetailValue>{source}</DetailValue>
|
||||
</SettingsRow>
|
||||
) : null}
|
||||
{listing?.license ? (
|
||||
<SettingsRow label={t("settings.plugins.gallery.details.license")}>
|
||||
<DetailValue>{listing.license}</DetailValue>
|
||||
</SettingsRow>
|
||||
) : null}
|
||||
{listing ? (
|
||||
<SettingsRow label={t("settings.plugins.gallery.details.updated")}>
|
||||
<DetailValue>{listing.updatedAt.slice(0, 10)}</DetailValue>
|
||||
</SettingsRow>
|
||||
) : null}
|
||||
{repository ? (
|
||||
<SettingsRow label={t("settings.plugins.gallery.actions.repository")}>
|
||||
<ExternalLink href={repository} label={repository.replace(/^https:\/\//, "")} />
|
||||
</SettingsRow>
|
||||
) : null}
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
/** What the page shows about the plugin, from whichever of the installed plugin and listing exist. */
|
||||
interface PluginPageModel {
|
||||
name: string;
|
||||
description?: string;
|
||||
/** "author · installs · category" for a listing; the ID when the name hides it. */
|
||||
metaLine?: string;
|
||||
/** The listing author's picture, before the meta line. */
|
||||
authorAvatar?: string;
|
||||
/** Why the plugin failed to load, or why its install failed. */
|
||||
error?: string;
|
||||
/** Why the last update failed, while the update is still offered. The installed version keeps running. */
|
||||
updateError?: string;
|
||||
/** The plugin's own settings screen, shown inline. */
|
||||
screenId?: string;
|
||||
overview?: string;
|
||||
}
|
||||
|
||||
function usePluginPageModel({
|
||||
serverId,
|
||||
subject,
|
||||
plugin,
|
||||
listing,
|
||||
detail,
|
||||
installOperation,
|
||||
}: {
|
||||
serverId: string;
|
||||
subject: PluginPageSubject;
|
||||
plugin?: PluginListItem;
|
||||
listing?: PluginListing;
|
||||
/** The listing with its overview, once its own request returns. */
|
||||
detail?: PluginListingDetail;
|
||||
installOperation: PluginOperation | undefined;
|
||||
}): PluginPageModel {
|
||||
const { i18n } = useTranslation();
|
||||
const directory = usePluginDirectory(serverId);
|
||||
const meta = useListingMeta();
|
||||
const pluginId = plugin?.id ?? "";
|
||||
const installedPlugin = useInstalledPlugin(serverId, pluginId);
|
||||
const evaluationError = usePluginEvaluationError(serverId, pluginId);
|
||||
const pluginOperation = usePluginOperation(serverId, pluginId);
|
||||
const updates = usePluginUpdates(serverId);
|
||||
const text = listing ? listingText(listing, i18n.language) : undefined;
|
||||
const requestedScreen = subject.kind === "installed" ? subject.screenId : undefined;
|
||||
return {
|
||||
name: plugin ? pluginDisplayName(plugin) : (text?.name ?? subjectId(subject)),
|
||||
description: text?.description ?? plugin?.description,
|
||||
metaLine: listing
|
||||
? listingMetaLine(meta(listing), directory.query.data, listing)
|
||||
: installedMetaLine(plugin),
|
||||
authorAvatar: listing && listingAuthorAvatar(listing),
|
||||
error: plugin ? (evaluationError ?? plugin.error) : failedError(installOperation),
|
||||
updateError: failedUpdateError(pluginOperation, updates.data, pluginId),
|
||||
screenId: plugin ? (requestedScreen ?? installedPlugin?.settingsScreens[0]?.id) : undefined,
|
||||
overview: detail && listingOverview(detail, i18n.language),
|
||||
};
|
||||
}
|
||||
|
||||
function subjectId(subject: PluginPageSubject): string {
|
||||
return subject.kind === "listing" ? subject.listingId : subject.pluginId;
|
||||
}
|
||||
|
||||
function listingOverview(listing: PluginListingDetail, language: string): string {
|
||||
return (listing.overviewTranslations?.[language] ?? listing.readme).trim();
|
||||
}
|
||||
|
||||
function listingMetaLine(
|
||||
meta: string,
|
||||
index: PluginListingIndex | undefined,
|
||||
listing: PluginListing,
|
||||
): string {
|
||||
const category = listing.categories[0];
|
||||
return category && index ? `${meta} · ${categoryLabel(index, category)}` : meta;
|
||||
}
|
||||
|
||||
function installedMetaLine(plugin: PluginListItem | undefined): string | undefined {
|
||||
return plugin?.name && plugin.name !== plugin.id ? plugin.id : undefined;
|
||||
}
|
||||
|
||||
function failedError(operation: PluginOperation | undefined): string | undefined {
|
||||
return operation?.status === "failed" ? operation.error : undefined;
|
||||
}
|
||||
|
||||
/** Shown with the Update button it belongs to, as on the Installed tab's Updates row. */
|
||||
function failedUpdateError(
|
||||
operation: PluginOperation | undefined,
|
||||
updates: PluginUpdatePreview[] | undefined,
|
||||
pluginId: string,
|
||||
): string | undefined {
|
||||
if (operation?.kind !== "update" || !updates?.some((preview) => preview.id === pluginId))
|
||||
return undefined;
|
||||
return failedError(operation);
|
||||
}
|
||||
|
||||
function HeaderControl({
|
||||
serverId,
|
||||
plugin,
|
||||
listing,
|
||||
installOperation,
|
||||
onInstall,
|
||||
onRemoved,
|
||||
}: {
|
||||
serverId: string;
|
||||
plugin?: PluginListItem;
|
||||
listing?: PluginListing;
|
||||
installOperation: PluginOperation | undefined;
|
||||
onInstall(listing: PluginListing): void;
|
||||
onRemoved(): void;
|
||||
}) {
|
||||
if (plugin)
|
||||
return (
|
||||
<InstalledControls
|
||||
serverId={serverId}
|
||||
plugin={plugin}
|
||||
repositoryUrl={listing?.repository.url}
|
||||
onRemoved={onRemoved}
|
||||
/>
|
||||
);
|
||||
if (!listing) return null;
|
||||
return (
|
||||
<ListingInstallControl
|
||||
listing={listing}
|
||||
operation={installOperation}
|
||||
onInstall={onInstall}
|
||||
primary
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin's page: what it is, its install or on/off control, its own settings, its overview and
|
||||
* the facts about where it came from. The same page serves an installed plugin and a listing.
|
||||
*/
|
||||
export function PluginPage({
|
||||
serverId,
|
||||
subject,
|
||||
onBack,
|
||||
}: {
|
||||
serverId: string;
|
||||
subject: PluginPageSubject;
|
||||
onBack(): void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const compact = useIsCompactFormFactor();
|
||||
const { plugin, listingId, listing, detail, loading } = useResolvedPlugin(serverId, subject);
|
||||
const installOperation = usePluginOperation(serverId, listingId);
|
||||
const model = usePluginPageModel({
|
||||
serverId,
|
||||
subject,
|
||||
plugin,
|
||||
listing,
|
||||
detail: detail.data,
|
||||
installOperation,
|
||||
});
|
||||
const [installTarget, setInstallTarget] = useState<InstallTarget | null>(null);
|
||||
const install = useCallback(
|
||||
(target: PluginListing) => setInstallTarget({ kind: "listing", listing: target }),
|
||||
[],
|
||||
);
|
||||
const closeInstall = useCallback(() => setInstallTarget(null), []);
|
||||
// An installed page finds its listing through the plugin, which uninstalling removes. A listed
|
||||
// plugin's page becomes its listing's page, offering Install again; any other returns to Plugins.
|
||||
const onRemoved = useCallback(() => {
|
||||
if (subject.kind !== "installed") return;
|
||||
if (listingId) router.replace(buildPluginListingRoute(serverId, listingId));
|
||||
else onBack();
|
||||
}, [listingId, onBack, serverId, subject.kind]);
|
||||
|
||||
const back = compact ? null : (
|
||||
<Button onPress={onBack} variant="ghost" leftIcon={ArrowLeft} style={styles.back}>
|
||||
{t("settings.plugins.screens.backToPlugins")}
|
||||
</Button>
|
||||
);
|
||||
if (loading)
|
||||
return (
|
||||
<View>
|
||||
{back}
|
||||
<PluginPageSkeleton />
|
||||
</View>
|
||||
);
|
||||
if (!plugin && !listing)
|
||||
return (
|
||||
<Alert
|
||||
size="sm"
|
||||
variant="warning"
|
||||
title={t("settings.plugins.gallery.missing")}
|
||||
description={detail.error ? pluginErrorText(detail.error) : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<View>
|
||||
{back}
|
||||
<View style={styles.header}>
|
||||
<PluginTile name={model.name} icon={listing?.icon} size="lg" />
|
||||
<View style={styles.identity}>
|
||||
<View style={styles.nameRow}>
|
||||
<Text style={styles.name} numberOfLines={1} testID="plugin-page-name">
|
||||
{model.name}
|
||||
</Text>
|
||||
{plugin && model.error ? (
|
||||
<StatusBadge label={t("settings.plugins.status.failed")} variant="error" size="xs" />
|
||||
) : null}
|
||||
</View>
|
||||
{model.metaLine ? (
|
||||
<PluginMeta text={model.metaLine} authorAvatar={model.authorAvatar} />
|
||||
) : null}
|
||||
</View>
|
||||
<HeaderControl
|
||||
serverId={serverId}
|
||||
plugin={plugin}
|
||||
listing={listing}
|
||||
installOperation={installOperation}
|
||||
onInstall={install}
|
||||
onRemoved={onRemoved}
|
||||
/>
|
||||
</View>
|
||||
<PluginPageBody
|
||||
serverId={serverId}
|
||||
plugin={plugin}
|
||||
listing={listing}
|
||||
overviewLoading={detail.isLoading}
|
||||
model={model}
|
||||
/>
|
||||
{installTarget ? (
|
||||
<InstallPluginSheet serverId={serverId} target={installTarget} onClose={closeInstall} />
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginPageBody({
|
||||
serverId,
|
||||
plugin,
|
||||
listing,
|
||||
overviewLoading,
|
||||
model,
|
||||
}: {
|
||||
serverId: string;
|
||||
plugin?: PluginListItem;
|
||||
listing?: PluginListing;
|
||||
overviewLoading: boolean;
|
||||
model: PluginPageModel;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
{plugin ? <PluginsOffAlert serverId={serverId} /> : null}
|
||||
{model.description ? <Text style={styles.description}>{model.description}</Text> : null}
|
||||
{model.error ? (
|
||||
<View style={styles.block}>
|
||||
<Alert size="sm" variant="error" title={model.error} />
|
||||
</View>
|
||||
) : null}
|
||||
{model.updateError ? (
|
||||
<View style={styles.block}>
|
||||
<Alert size="sm" variant="error" title={model.updateError} />
|
||||
</View>
|
||||
) : null}
|
||||
{listing ? <MediaStrip urls={listingImages(listing)} /> : null}
|
||||
{plugin && model.screenId ? (
|
||||
// The plugin's settings screen brings its own sections.
|
||||
<PluginSettingsContent serverId={serverId} pluginId={plugin.id} screenId={model.screenId} />
|
||||
) : null}
|
||||
{model.overview || (listing && overviewLoading) ? (
|
||||
<SettingsSection title={t("settings.plugins.gallery.sections.overview")}>
|
||||
{model.overview ? (
|
||||
<SettingsCard>
|
||||
<View style={styles.overview}>
|
||||
<MarkdownRenderer text={model.overview} external />
|
||||
</View>
|
||||
</SettingsCard>
|
||||
) : (
|
||||
<OverviewSkeleton />
|
||||
)}
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
<Details plugin={plugin} listing={listing} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
back: { alignSelf: "flex-start", paddingHorizontal: 0, marginBottom: theme.spacing[4] },
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
marginBottom: theme.spacing[4],
|
||||
},
|
||||
identity: { flex: 1, minWidth: 0, gap: theme.spacing[1] },
|
||||
nameRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
|
||||
name: { flexShrink: 1, color: theme.colors.foreground, fontSize: theme.fontSize.xl },
|
||||
controls: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
|
||||
description: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
marginBottom: theme.spacing[6],
|
||||
},
|
||||
block: { marginBottom: theme.spacing[6] },
|
||||
// The settings row's padding, so the overview's text sits where a row's label would.
|
||||
overview: { padding: theme.spacing[4] },
|
||||
mediaStrip: { flexGrow: 0, marginBottom: theme.spacing[6] },
|
||||
mediaRow: { gap: theme.spacing[3] },
|
||||
media: {
|
||||
height: 180,
|
||||
aspectRatio: 16 / 10,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
overflow: "hidden",
|
||||
},
|
||||
mediaImage: { width: "100%", height: "100%" },
|
||||
detailValue: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
maxWidth: 320,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,202 @@
|
||||
import { ChevronRight } from "lucide-react-native";
|
||||
import { useCallback, useMemo, type ReactNode } from "react";
|
||||
import { Image, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { foregroundIconColorMapping, mutedIconColorMapping } from "@/components/ui/icon-color";
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { ICON_SIZE } from "@/styles/theme";
|
||||
|
||||
const ThemedChevron = withUnistyles(ChevronRight);
|
||||
type HoverState = PressableStateCallbackType & { hovered?: boolean };
|
||||
|
||||
/** A plugin's icon, or its initial on a neutral tile when it has none. */
|
||||
export function PluginTile({
|
||||
name,
|
||||
icon,
|
||||
size = "md",
|
||||
}: {
|
||||
name: string;
|
||||
icon?: string;
|
||||
size?: "md" | "lg";
|
||||
}) {
|
||||
const tileStyle = useMemo(() => [styles.tile, size === "lg" && styles.tileLarge], [size]);
|
||||
const source = useMemo(() => (icon ? { uri: icon } : null), [icon]);
|
||||
if (source) return <Image source={source} style={tileStyle} accessibilityIgnoresInvertColors />;
|
||||
return (
|
||||
<View style={tileStyle}>
|
||||
<Text style={size === "lg" ? styles.initialLarge : styles.initial}>
|
||||
{name.slice(0, 1).toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** A listing author's GitHub picture, before their name in a meta line. */
|
||||
export function AuthorAvatar({ url }: { url: string }) {
|
||||
const source = useMemo(() => ({ uri: url }), [url]);
|
||||
return <Image source={source} style={styles.avatar} accessibilityIgnoresInvertColors />;
|
||||
}
|
||||
|
||||
/** A line of facts, led by the author's picture when the plugin is listed. */
|
||||
export function PluginMeta({
|
||||
text,
|
||||
authorAvatar,
|
||||
testID,
|
||||
}: {
|
||||
text: string;
|
||||
authorAvatar?: string;
|
||||
testID?: string;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.metaRow} testID={testID}>
|
||||
{authorAvatar ? <AuthorAvatar url={authorAvatar} /> : null}
|
||||
<Text style={styles.metaText} numberOfLines={1}>
|
||||
{text}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PluginRowProps {
|
||||
name: string;
|
||||
icon?: string;
|
||||
/** Beside the name, such as a Failed badge. */
|
||||
badge?: ReactNode;
|
||||
description?: string;
|
||||
/** Who made it and which version, or how many installs. */
|
||||
meta?: string;
|
||||
/** The listing author's picture, before the meta line. */
|
||||
authorAvatar?: string;
|
||||
/** Replaces the meta line: what went wrong, in the row the user is looking at. */
|
||||
error?: string;
|
||||
/** The row's control: a switch, or the install or update button. The chevron follows it. */
|
||||
children?: ReactNode;
|
||||
onPress(): void;
|
||||
accessibilityLabel: string;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One plugin in a card: icon, name, a line of description and a line of facts. The whole row opens
|
||||
* the plugin's page; the control in the trailing slot acts without opening it.
|
||||
*/
|
||||
export function PluginRow({
|
||||
name,
|
||||
icon,
|
||||
badge,
|
||||
description,
|
||||
meta,
|
||||
authorAvatar,
|
||||
error,
|
||||
children,
|
||||
onPress,
|
||||
accessibilityLabel,
|
||||
testID,
|
||||
}: PluginRowProps) {
|
||||
const rowStyle = useCallback(
|
||||
({ hovered }: HoverState) => [settingsStyles.row, styles.row, hovered && styles.hovered],
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<PressHighlight
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
onPress={onPress}
|
||||
style={rowStyle}
|
||||
highlightStyle={styles.pressed}
|
||||
testID={testID}
|
||||
>
|
||||
{({ hovered }: HoverState) => (
|
||||
<>
|
||||
<PluginTile name={name} icon={icon} />
|
||||
<View style={styles.text}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{name}
|
||||
</Text>
|
||||
{badge}
|
||||
</View>
|
||||
{description ? (
|
||||
<Text style={styles.description} numberOfLines={1}>
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
{error ? (
|
||||
<Text accessibilityRole="alert" style={styles.error} numberOfLines={2}>
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
{meta && !error ? (
|
||||
<View style={styles.meta}>
|
||||
<PluginMeta text={meta} authorAvatar={authorAvatar} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
{children}
|
||||
<View style={styles.chevron}>
|
||||
<ThemedChevron
|
||||
size={ICON_SIZE.sm}
|
||||
uniProps={hovered ? foregroundIconColorMapping : mutedIconColorMapping}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</PressHighlight>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: { gap: theme.spacing[3] },
|
||||
hovered: { backgroundColor: theme.colors.surface2 },
|
||||
pressed: { backgroundColor: theme.colors.surface3 },
|
||||
tile: {
|
||||
width: theme.spacing[8],
|
||||
height: theme.spacing[8],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "hidden",
|
||||
},
|
||||
tileLarge: {
|
||||
width: theme.spacing[12],
|
||||
height: theme.spacing[12],
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
},
|
||||
initial: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base },
|
||||
initialLarge: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xl },
|
||||
text: { flex: 1, minWidth: 0 },
|
||||
titleRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
|
||||
title: { flexShrink: 1, color: theme.colors.foreground, fontSize: theme.fontSize.base },
|
||||
description: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
marginTop: theme.spacing[0.5],
|
||||
},
|
||||
meta: { marginTop: theme.spacing[0.5] },
|
||||
metaRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1.5] },
|
||||
metaText: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foregroundExtraMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
avatar: {
|
||||
width: theme.iconSize.sm,
|
||||
height: theme.iconSize.sm,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
error: {
|
||||
color: theme.colors.statusDanger,
|
||||
fontSize: theme.fontSize.sm,
|
||||
marginTop: theme.spacing[0.5],
|
||||
},
|
||||
// Optical: the chevron's ink sits about 4px inside its box; this puts it on the card's trailing
|
||||
// rail, the same as SettingsRowHeader.
|
||||
chevron: { marginRight: -theme.spacing[1] },
|
||||
}));
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/contexts/toast-api-context";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { useHostLabel } from "./install-sheet";
|
||||
import { pluginErrorText } from "./error-text";
|
||||
|
||||
/** Says that installed plugins are not running on this host, with the switch that runs them. */
|
||||
export function PluginsOffAlert({ serverId }: { serverId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const hostLabel = useHostLabel(serverId);
|
||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||
const [turningOn, setTurningOn] = useState(false);
|
||||
const turnOn = useCallback(async () => {
|
||||
setTurningOn(true);
|
||||
try {
|
||||
await patchConfig({ pluginsEnabled: true });
|
||||
} catch (error) {
|
||||
toast.error(pluginErrorText(error));
|
||||
} finally {
|
||||
setTurningOn(false);
|
||||
}
|
||||
}, [patchConfig, toast]);
|
||||
if (!config || config.pluginsEnabled === true) return null;
|
||||
return (
|
||||
<View style={styles.block}>
|
||||
<Alert
|
||||
size="sm"
|
||||
variant="warning"
|
||||
title={t("settings.plugins.gallery.off.title", { host: hostLabel })}
|
||||
testID="plugins-off"
|
||||
>
|
||||
<Button variant="outline" onPress={turnOn} loading={turningOn}>
|
||||
{t("settings.plugins.gallery.actions.turnOn")}
|
||||
</Button>
|
||||
</Alert>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
block: { marginBottom: theme.spacing[6] },
|
||||
}));
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { usePluginList } from "@/plugins/plugin-list";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { pluginUpdatesQueryKey } from "./operations";
|
||||
|
||||
// The directory changes when a maintainer publishes, not while someone browses. These queries
|
||||
// refetch on mount only once stale, so opening a plugin's page does not fetch them again.
|
||||
const DIRECTORY_STALE_MS = 10 * 60_000;
|
||||
|
||||
function useHostReady(serverId: string) {
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const connected = useHostRuntimeIsConnected(serverId);
|
||||
return { client, ready: Boolean(client && connected) };
|
||||
}
|
||||
|
||||
/** The host's plugin directory. `supported` is false until the host proxies the registry. */
|
||||
export function usePluginDirectory(serverId: string) {
|
||||
const { client, ready } = useHostReady(serverId);
|
||||
// COMPAT(pluginRegistry): added in v0.16.0, remove gate after 2027-05-15.
|
||||
const supported = useHostFeature(serverId, "pluginRegistry");
|
||||
const query = useFetchQuery({
|
||||
queryKey: ["plugin-directory", serverId] as const,
|
||||
queryFn: () => {
|
||||
if (!client) throw new Error("Plugin host is offline");
|
||||
return client.listPluginRegistry();
|
||||
},
|
||||
enabled: ready && supported,
|
||||
dataShape: "value",
|
||||
staleTimeMs: DIRECTORY_STALE_MS,
|
||||
refetchOnMount: true,
|
||||
});
|
||||
return { supported, query };
|
||||
}
|
||||
|
||||
/** One listing with its overview. */
|
||||
export function usePluginListing(serverId: string, listingId: string | undefined) {
|
||||
const { client, ready } = useHostReady(serverId);
|
||||
// COMPAT(pluginRegistry): added in v0.16.0, remove gate after 2027-05-15.
|
||||
const supported = useHostFeature(serverId, "pluginRegistry");
|
||||
return useFetchQuery({
|
||||
queryKey: ["plugin-listing", serverId, listingId] as const,
|
||||
queryFn: () => {
|
||||
if (!client || !listingId) throw new Error("Plugin host is offline");
|
||||
return client.getRegistryPlugin(listingId);
|
||||
},
|
||||
enabled: ready && supported && listingId !== undefined,
|
||||
dataShape: "value",
|
||||
staleTimeMs: DIRECTORY_STALE_MS,
|
||||
refetchOnMount: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Installed plugins with a newer reviewed version, by plugin ID. */
|
||||
export function usePluginUpdates(serverId: string) {
|
||||
const { client, ready } = useHostReady(serverId);
|
||||
// COMPAT(pluginSourceUpdates): added in v0.8.0, remove gate after 2027-03-16.
|
||||
const supported = useHostFeature(serverId, "pluginSourceUpdates");
|
||||
return useFetchQuery({
|
||||
queryKey: pluginUpdatesQueryKey(serverId),
|
||||
queryFn: () => {
|
||||
if (!client) throw new Error("Plugin host is offline");
|
||||
return client.previewPluginUpdates();
|
||||
},
|
||||
enabled: ready && supported,
|
||||
dataShape: "list",
|
||||
staleTimeMs: DIRECTORY_STALE_MS,
|
||||
refetchOnMount: true,
|
||||
select: (previews) => previews.filter((preview) => preview.outcome === "update"),
|
||||
});
|
||||
}
|
||||
|
||||
/** Refetches the plugin list when another client or the CLI changes it. */
|
||||
function usePluginCatalogFeed(serverId: string, refetch: () => unknown) {
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const connected = useHostRuntimeIsConnected(serverId);
|
||||
const supported = useHostFeature(serverId, "pluginManagement");
|
||||
useEffect(() => {
|
||||
if (!client || !connected || !supported) return;
|
||||
const observation = client.observeEvents(["status.plugin_catalog_changed"]);
|
||||
observation.subscribe({
|
||||
snapshot: () => {},
|
||||
update: (message) => {
|
||||
if (message.type === "status" && message.payload.status === "plugin_catalog_changed")
|
||||
void refetch();
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
void observation
|
||||
.release()
|
||||
.catch((error) => console.warn("[Plugins] Failed to release settings feed", error));
|
||||
};
|
||||
}, [client, connected, supported, refetch]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The host's plugins, refetched whenever another client or the CLI changes them. A change also
|
||||
* re-checks updates, whose proposals name the revision each plugin had when checked.
|
||||
*/
|
||||
export function useLivePluginList(serverId: string) {
|
||||
const plugins = usePluginList(serverId);
|
||||
const queryClient = useQueryClient();
|
||||
const { refetch } = plugins;
|
||||
const refresh = useCallback(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: pluginUpdatesQueryKey(serverId) });
|
||||
return refetch();
|
||||
}, [queryClient, refetch, serverId]);
|
||||
usePluginCatalogFeed(serverId, refresh);
|
||||
return plugins;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/** An installed plugin's page. Its settings screens open at `buildPluginSettingsRoute`. */
|
||||
export function buildPluginPageRoute(serverId: string, pluginId: string) {
|
||||
return {
|
||||
pathname: "/settings/hosts/[serverId]/plugins/[pluginId]" as const,
|
||||
params: { serverId, pluginId },
|
||||
};
|
||||
}
|
||||
|
||||
/** A directory listing's page, by its `owner/slug` ID. */
|
||||
export function buildPluginListingRoute(serverId: string, listingId: string) {
|
||||
const [owner = "", slug = ""] = listingId.split("/");
|
||||
return {
|
||||
pathname: "/settings/hosts/[serverId]/plugin-directory/[owner]/[slug]" as const,
|
||||
params: { serverId, owner, slug },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useMemo } from "react";
|
||||
import { View, type Animated } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { SettingsCard } from "@/components/settings";
|
||||
import { SkeletonPulse, useSkeletonPulse } from "@/components/ui/skeleton";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
|
||||
const ROWS = [0, 1, 2, 3];
|
||||
const LINES = [0, 1, 2];
|
||||
|
||||
function RowSkeleton({ pulse }: { pulse: Animated.Value }) {
|
||||
const rowStyle = useMemo(() => [settingsStyles.row, styles.row], []);
|
||||
return (
|
||||
<View style={rowStyle}>
|
||||
<SkeletonPulse pulse={pulse} style={styles.tile} />
|
||||
<View style={styles.text}>
|
||||
<SkeletonPulse pulse={pulse} style={styles.title} />
|
||||
<SkeletonPulse pulse={pulse} style={styles.line} />
|
||||
</View>
|
||||
<SkeletonPulse pulse={pulse} style={styles.control} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** A card of plugin rows while the list or the directory loads. */
|
||||
export function PluginRowsSkeleton({ testID }: { testID?: string }) {
|
||||
const pulse = useSkeletonPulse();
|
||||
return (
|
||||
<SettingsCard testID={testID}>
|
||||
{ROWS.map((row) => (
|
||||
<RowSkeleton key={row} pulse={pulse} />
|
||||
))}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** Lines of text in a card, where the overview will be. */
|
||||
export function OverviewSkeleton() {
|
||||
const pulse = useSkeletonPulse();
|
||||
return (
|
||||
<SettingsCard>
|
||||
<View style={styles.overview}>
|
||||
{LINES.map((line) => (
|
||||
<SkeletonPulse key={line} pulse={pulse} style={line === 2 ? styles.short : styles.line} />
|
||||
))}
|
||||
</View>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** The plugin page's header and description before anything about the plugin is known. */
|
||||
export function PluginPageSkeleton() {
|
||||
const pulse = useSkeletonPulse();
|
||||
return (
|
||||
<View testID="plugin-page-skeleton">
|
||||
<View style={styles.header}>
|
||||
<SkeletonPulse pulse={pulse} style={styles.tileLarge} />
|
||||
<View style={styles.text}>
|
||||
<SkeletonPulse pulse={pulse} style={styles.name} />
|
||||
<SkeletonPulse pulse={pulse} style={styles.title} />
|
||||
</View>
|
||||
<SkeletonPulse pulse={pulse} style={styles.control} />
|
||||
</View>
|
||||
<View style={styles.description}>
|
||||
<SkeletonPulse pulse={pulse} style={styles.line} />
|
||||
<SkeletonPulse pulse={pulse} style={styles.short} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// The shapes take the size of what they stand for: a row's tile and text, the page's large tile and
|
||||
// name, and a button.
|
||||
const styles = StyleSheet.create((theme) => {
|
||||
const shape = { backgroundColor: theme.colors.surface2, borderRadius: theme.borderRadius.sm };
|
||||
return {
|
||||
row: { gap: theme.spacing[3] },
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
marginBottom: theme.spacing[4],
|
||||
},
|
||||
text: { flex: 1, minWidth: 0, gap: theme.spacing[2] },
|
||||
tile: {
|
||||
...shape,
|
||||
width: theme.spacing[8],
|
||||
height: theme.spacing[8],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
tileLarge: {
|
||||
...shape,
|
||||
width: theme.spacing[12],
|
||||
height: theme.spacing[12],
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
},
|
||||
name: { ...shape, width: 180, height: theme.fontSize.xl },
|
||||
title: { ...shape, width: 140, height: theme.fontSize.sm },
|
||||
line: { ...shape, width: "100%", maxWidth: 420, height: theme.fontSize.sm },
|
||||
short: { ...shape, width: "60%", maxWidth: 260, height: theme.fontSize.sm },
|
||||
control: { ...shape, width: 72, height: 28, borderRadius: theme.borderRadius.md },
|
||||
overview: { padding: theme.spacing[4], gap: theme.spacing[3] },
|
||||
description: { gap: theme.spacing[2], marginBottom: theme.spacing[6] },
|
||||
};
|
||||
});
|
||||
@@ -3,13 +3,17 @@ import { useFetchQuery } from "@/data/query";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
|
||||
export function pluginListQueryKey(serverId: string) {
|
||||
return ["plugins", serverId] as const;
|
||||
}
|
||||
|
||||
/** A host's configured plugins, as listed on Settings → Plugins. One cache entry per host. */
|
||||
export function usePluginList(serverId: string, options: { enabled?: boolean } = {}) {
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const connected = useHostRuntimeIsConnected(serverId);
|
||||
const supported = useHostFeature(serverId, "pluginManagement");
|
||||
return useFetchQuery({
|
||||
queryKey: ["plugins", serverId] as const,
|
||||
queryKey: pluginListQueryKey(serverId),
|
||||
queryFn: async () => {
|
||||
if (!client) throw new Error("Plugin host is offline");
|
||||
return client.listPlugins();
|
||||
|
||||
@@ -283,6 +283,12 @@ export function usePluginCatalogStatus(serverId: string): PluginCatalogStatus {
|
||||
);
|
||||
}
|
||||
|
||||
/** Why the app could not run the plugin's client bundle, updated when the host's catalog changes. */
|
||||
export function usePluginEvaluationError(serverId: string, pluginId: string): string | undefined {
|
||||
const read = () => pluginRegistry.getEvaluationError(serverId, pluginId);
|
||||
return useSyncExternalStore(pluginRegistry.subscribe, read, read);
|
||||
}
|
||||
|
||||
export function usePluginInstallations(pluginId: string): InstalledPlugin[] {
|
||||
const installed = useInstalledPlugins();
|
||||
return useMemo(() => installed.filter((plugin) => plugin.id === pluginId), [installed, pluginId]);
|
||||
|
||||
@@ -3,10 +3,8 @@ import { useTranslation } from "react-i18next";
|
||||
import { Platform, ScrollView, Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { router } from "expo-router";
|
||||
import { ArrowLeft, Settings } from "lucide-react-native";
|
||||
import type { PluginHostProps } from "@getpaseo/plugin/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
|
||||
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
@@ -18,7 +16,6 @@ import type { InstalledPlugin } from "../types";
|
||||
import { PluginInstallationProvider } from "../installation-provider";
|
||||
import { SurfaceErrorBoundary } from "../surface-error-boundary";
|
||||
import { toPluginTheme } from "../theme";
|
||||
import { buildPluginSettingsRoute } from "./routes";
|
||||
|
||||
interface SettingsIdentity {
|
||||
serverId: string;
|
||||
@@ -26,32 +23,6 @@ interface SettingsIdentity {
|
||||
screenId: string;
|
||||
}
|
||||
|
||||
const ThemedSettingsIcon = withUnistyles(Settings);
|
||||
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
/** Opens the plugin's settings at its first screen. Renders nothing when it has none. */
|
||||
export function PluginSettingsButton({ serverId, pluginId }: Omit<SettingsIdentity, "screenId">) {
|
||||
const { t } = useTranslation();
|
||||
const plugin = useInstalledPlugin(serverId, pluginId);
|
||||
const supported = useHostFeature(serverId, "pluginSettings");
|
||||
const firstScreenId = plugin?.settingsScreens[0]?.id;
|
||||
const open = useCallback(() => {
|
||||
if (firstScreenId) router.push(buildPluginSettingsRoute(serverId, pluginId, firstScreenId));
|
||||
}, [serverId, pluginId, firstScreenId]);
|
||||
const compact = useIsCompactFormFactor();
|
||||
if (!supported || !firstScreenId) return null;
|
||||
return (
|
||||
<ToolbarButton
|
||||
label={t("settings.plugins.actions.settings")}
|
||||
compact={compact}
|
||||
onPress={open}
|
||||
testID={`plugin-settings-${pluginId}`}
|
||||
>
|
||||
<ThemedSettingsIcon size={paneContentToolbarIconSize(compact)} uniProps={mutedIconMapping} />
|
||||
</ToolbarButton>
|
||||
);
|
||||
}
|
||||
|
||||
function renderNoIcon() {
|
||||
return null;
|
||||
}
|
||||
@@ -172,30 +143,12 @@ function SettingsContent({
|
||||
const ThemedSettingsContent = withUnistyles(SettingsContent);
|
||||
// Settings routes carry no screen params.
|
||||
const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
|
||||
export function PluginSettingsContent({
|
||||
onBackToPlugins,
|
||||
showBackToPlugins,
|
||||
...identity
|
||||
}: SettingsIdentity & { onBackToPlugins: () => void; showBackToPlugins: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View>
|
||||
{showBackToPlugins ? (
|
||||
<Button
|
||||
onPress={onBackToPlugins}
|
||||
variant="ghost"
|
||||
leftIcon={ArrowLeft}
|
||||
style={styles.backButton}
|
||||
>
|
||||
{t("settings.plugins.screens.backToPlugins")}
|
||||
</Button>
|
||||
) : null}
|
||||
<ThemedSettingsContent {...identity} uniProps={themeMapping} />
|
||||
</View>
|
||||
);
|
||||
|
||||
/** One of the plugin's settings screens, with tabs to the others. Renders inside its plugin page. */
|
||||
export function PluginSettingsContent(identity: SettingsIdentity) {
|
||||
return <ThemedSettingsContent {...identity} uniProps={themeMapping} />;
|
||||
}
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
message: { color: theme.colors.foregroundMuted },
|
||||
tabs: { flexGrow: 0, marginBottom: theme.spacing[4] },
|
||||
backButton: { alignSelf: "flex-start", paddingHorizontal: 0, marginBottom: theme.spacing[4] },
|
||||
}));
|
||||
|
||||
@@ -108,9 +108,12 @@ import {
|
||||
HostWorkspacesPage,
|
||||
HostTerminalsPage,
|
||||
} from "@/screens/settings/host-page";
|
||||
import { PluginSettingsContent } from "@/plugins/settings";
|
||||
import { usePluginDisplayName } from "@/plugins/plugin-list";
|
||||
import { HostPluginsPage } from "@/screens/settings/plugins-page";
|
||||
import {
|
||||
PluginGalleryMenu,
|
||||
PluginGalleryPage,
|
||||
PluginPage,
|
||||
type PluginPageSubject,
|
||||
} from "@/plugins/gallery";
|
||||
import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-page";
|
||||
import { HostVoicePage } from "@/screens/settings/voice/voice-page";
|
||||
import ProjectsScreen from "@/screens/projects-screen";
|
||||
@@ -280,7 +283,7 @@ function renderHostSettingsContent(
|
||||
case "terminals":
|
||||
return <HostTerminalsPage serverId={view.serverId} />;
|
||||
case "plugins":
|
||||
return <HostPluginsPage serverId={view.serverId} />;
|
||||
return <PluginGalleryPage serverId={view.serverId} />;
|
||||
case "host":
|
||||
return <HostSettingsPage serverId={view.serverId} onHostRemoved={onHostRemoved} />;
|
||||
}
|
||||
@@ -987,7 +990,7 @@ function SettingsSidebar({
|
||||
let selectedHostSection: HostSectionSlug | null = null;
|
||||
if (view.kind === "host") selectedHostSection = view.section;
|
||||
if (view.kind === "project") selectedHostSection = "projects";
|
||||
if (view.kind === "plugin") selectedHostSection = "plugins";
|
||||
if (view.kind === "plugin" || view.kind === "pluginListing") selectedHostSection = "plugins";
|
||||
|
||||
const sidebarBody = (
|
||||
<>
|
||||
@@ -1130,7 +1133,12 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
// The host the four sections scope to: the host on the active view, otherwise
|
||||
// the picker choice, otherwise the connected local daemon, otherwise the first host.
|
||||
const activeHostServerId = useMemo(() => {
|
||||
if (view.kind === "host" || view.kind === "project" || view.kind === "plugin")
|
||||
if (
|
||||
view.kind === "host" ||
|
||||
view.kind === "project" ||
|
||||
view.kind === "plugin" ||
|
||||
view.kind === "pluginListing"
|
||||
)
|
||||
return view.serverId;
|
||||
return resolveActiveHostServerId({
|
||||
selectedServerId: selectedSettingsHostServerId,
|
||||
@@ -1263,10 +1271,20 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
returnFromSettings({ kind: "root" });
|
||||
}, []);
|
||||
|
||||
// The header names the plugin; its tabs name the open settings screen.
|
||||
const pluginDisplayName = usePluginDisplayName(view.kind === "plugin" ? view : null);
|
||||
// A plugin's page draws its own header; the compact back header names the plugin.
|
||||
const pluginPage = useMemo((): { serverId: string; subject: PluginPageSubject } | null => {
|
||||
if (view.kind === "plugin")
|
||||
return {
|
||||
serverId: view.serverId,
|
||||
subject: { kind: "installed", pluginId: view.pluginId, screenId: view.screenId },
|
||||
};
|
||||
if (view.kind === "pluginListing")
|
||||
return { serverId: view.serverId, subject: { kind: "listing", listingId: view.listingId } };
|
||||
return null;
|
||||
}, [view]);
|
||||
const detailTitle = ((): string | undefined => {
|
||||
if (view.kind === "plugin") return pluginDisplayName;
|
||||
// The plugin page heads itself with the plugin's name and icon.
|
||||
if (pluginPage) return undefined;
|
||||
if (view.kind === "host") {
|
||||
const item = HOST_SECTION_ITEMS.find((s) => s.id === view.section);
|
||||
return item ? t(item.labelKey) : undefined;
|
||||
@@ -1281,15 +1299,23 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
const pluginGalleryServerId =
|
||||
view.kind === "host" && view.section === "plugins" ? view.serverId : null;
|
||||
const detailActions = useMemo(
|
||||
() =>
|
||||
pluginGalleryServerId ? <PluginGalleryMenu serverId={pluginGalleryServerId} /> : undefined,
|
||||
[pluginGalleryServerId],
|
||||
);
|
||||
// The gallery and a plugin's page share one width, so opening a plugin keeps the page edges put.
|
||||
const detailWidth = pluginGalleryServerId || pluginPage ? "wide" : "default";
|
||||
|
||||
const content: ReactNode = (() => {
|
||||
if (view.kind === "plugin")
|
||||
if (pluginPage)
|
||||
return (
|
||||
<PluginSettingsContent
|
||||
serverId={view.serverId}
|
||||
pluginId={view.pluginId}
|
||||
screenId={view.screenId}
|
||||
onBackToPlugins={handleBackFromDetail}
|
||||
showBackToPlugins={!isCompactLayout}
|
||||
<PluginPage
|
||||
serverId={pluginPage.serverId}
|
||||
subject={pluginPage.subject}
|
||||
onBack={handleBackFromDetail}
|
||||
/>
|
||||
);
|
||||
if (view.kind === "host") {
|
||||
@@ -1373,7 +1399,12 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
if (isCompactLayout) {
|
||||
return (
|
||||
<View style={styles.container} onLayout={onContentLayout}>
|
||||
<PageLayout title={detailTitle} onBack={handleBackFromDetail}>
|
||||
<PageLayout
|
||||
title={detailTitle}
|
||||
onBack={handleBackFromDetail}
|
||||
actions={detailActions}
|
||||
width={detailWidth}
|
||||
>
|
||||
{content}
|
||||
</PageLayout>
|
||||
</View>
|
||||
@@ -1402,6 +1433,8 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
<View style={desktopStyles.contentPane} testID="settings-detail-pane">
|
||||
<PageLayout
|
||||
title={detailTitle}
|
||||
actions={detailActions}
|
||||
width={detailWidth}
|
||||
titleTestID="settings-detail-header-title"
|
||||
testID="settings-detail-scroll-body"
|
||||
>
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { openPluginInstallForm } from "./plugin-install-form-model";
|
||||
|
||||
describe("plugin install form", () => {
|
||||
it("keeps the edited source in the submission until explicitly reset", () => {
|
||||
const model = openPluginInstallForm();
|
||||
|
||||
model.setSource("owner/review");
|
||||
|
||||
expect(model.getSubmission()).toEqual({ source: "owner/review" });
|
||||
|
||||
model.reset();
|
||||
expect(model.getState()).toEqual({
|
||||
source: "",
|
||||
canSubmit: false,
|
||||
resetKey: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("trims the submitted source", () => {
|
||||
const model = openPluginInstallForm();
|
||||
model.setSource(" /plugins/review ");
|
||||
expect(model.getSubmission()).toEqual({ source: "/plugins/review" });
|
||||
});
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
export interface PluginInstallFormState {
|
||||
source: string;
|
||||
canSubmit: boolean;
|
||||
resetKey: number;
|
||||
}
|
||||
|
||||
export interface PluginInstallSubmission {
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface PluginInstallFormModel {
|
||||
getState(): PluginInstallFormState;
|
||||
subscribe(listener: () => void): () => void;
|
||||
close(): void;
|
||||
setSource(value: string): void;
|
||||
getSubmission(): PluginInstallSubmission;
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
export function openPluginInstallForm(): PluginInstallFormModel {
|
||||
const listeners = new Set<() => void>();
|
||||
let state: PluginInstallFormState = {
|
||||
source: "",
|
||||
canSubmit: false,
|
||||
resetKey: 0,
|
||||
};
|
||||
|
||||
const publish = (next: Pick<PluginInstallFormState, "source" | "resetKey">) => {
|
||||
state = { ...next, canSubmit: next.source.trim().length > 0 };
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
|
||||
return {
|
||||
getState: () => state,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
close() {
|
||||
listeners.clear();
|
||||
},
|
||||
setSource(source) {
|
||||
publish({ ...state, source });
|
||||
},
|
||||
getSubmission() {
|
||||
const source = state.source.trim();
|
||||
return { source };
|
||||
},
|
||||
reset() {
|
||||
publish({ source: "", resetKey: state.resetKey + 1 });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolvePluginPageState } from "./plugins-page-state";
|
||||
|
||||
describe("resolvePluginPageState", () => {
|
||||
it.each([
|
||||
["offline", false, false, false, false, 0],
|
||||
["unsupported", true, false, false, false, 0],
|
||||
["loading", true, true, true, false, 0],
|
||||
["error", true, true, false, true, 0],
|
||||
["empty", true, true, false, false, 0],
|
||||
["ready", true, true, false, false, 1],
|
||||
] as const)("resolves %s", (expected, connected, supported, loading, error, pluginCount) => {
|
||||
expect(resolvePluginPageState({ connected, supported, loading, error, pluginCount })).toBe(
|
||||
expected,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
export type PluginPageState = "offline" | "unsupported" | "loading" | "error" | "empty" | "ready";
|
||||
|
||||
export function resolvePluginPageState(input: {
|
||||
connected: boolean;
|
||||
supported: boolean;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
pluginCount: number;
|
||||
}): PluginPageState {
|
||||
if (!input.connected) return "offline";
|
||||
if (!input.supported) return "unsupported";
|
||||
if (input.loading) return "loading";
|
||||
if (input.error) return "error";
|
||||
return input.pluginCount === 0 ? "empty" : "ready";
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
import { subscriptionFixture } from "@/runtime/subscription-fixture";
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { i18n as testI18n } from "@/i18n/i18next";
|
||||
import React, { type ReactElement } from "react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { PluginListItem, PluginLogEntry } from "@getpaseo/protocol/messages";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { HostPluginsPage } from "./plugins-page";
|
||||
|
||||
void testI18n;
|
||||
|
||||
const runtime = vi.hoisted(() => ({
|
||||
connected: true,
|
||||
supported: true,
|
||||
logsSupported: true,
|
||||
client: null as DaemonClient | null,
|
||||
}));
|
||||
|
||||
vi.mock("@/runtime/host-runtime", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/runtime/host-runtime")>()),
|
||||
useHostRuntimeClient: () => runtime.client,
|
||||
useHostRuntimeIsConnected: () => runtime.connected,
|
||||
}));
|
||||
|
||||
vi.mock("@/runtime/host-features", () => ({
|
||||
useHostFeature: (_serverId: string, feature: string) => {
|
||||
if (feature === "pluginLogs") return runtime.logsSupported;
|
||||
return runtime.supported;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/adaptive-modal-sheet", async () => {
|
||||
const ReactModule = await vi.importActual<typeof import("react")>("react");
|
||||
const actual = await vi.importActual<typeof import("@/components/adaptive-modal-sheet")>(
|
||||
"@/components/adaptive-modal-sheet",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
AdaptiveModalSheet: ({
|
||||
header,
|
||||
children,
|
||||
onClose,
|
||||
}: {
|
||||
header: { title: string; actions?: React.ReactNode };
|
||||
children: React.ReactNode;
|
||||
onClose(): void;
|
||||
}) =>
|
||||
ReactModule.createElement(
|
||||
"div",
|
||||
{ role: "dialog" },
|
||||
ReactModule.createElement("h2", null, header.title),
|
||||
header.actions,
|
||||
ReactModule.createElement("button", { type: "button", onClick: onClose }, "Close"),
|
||||
children,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-native-reanimated", () => ({
|
||||
default: { View: "div" },
|
||||
Keyframe: class {
|
||||
duration() {
|
||||
return this;
|
||||
}
|
||||
},
|
||||
Easing: { ease: "ease", inOut: (value: unknown) => value },
|
||||
interpolateColor: (value: number, _input: number[], output: string[]) =>
|
||||
value >= 1 ? output[1] : output[0],
|
||||
useAnimatedStyle: (factory: () => unknown) => factory(),
|
||||
useDerivedValue: (factory: () => unknown) => ({ value: factory() }),
|
||||
withTiming: (value: unknown) => value,
|
||||
}));
|
||||
|
||||
vi.mock("@gorhom/bottom-sheet", async () => {
|
||||
const ReactModule = await vi.importActual<typeof import("react")>("react");
|
||||
const { ScrollView, TextInput } =
|
||||
await vi.importActual<typeof import("react-native")>("react-native");
|
||||
return {
|
||||
BottomSheetBackdrop: () => null,
|
||||
BottomSheetHandle: () => null,
|
||||
BottomSheetModal: ReactModule.forwardRef(() => null),
|
||||
BottomSheetScrollView: ScrollView,
|
||||
BottomSheetTextInput: TextInput,
|
||||
useBottomSheetInternal: () => null,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-native-safe-area-context", () => ({
|
||||
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
||||
}));
|
||||
|
||||
function plugin(enabled = true): PluginListItem {
|
||||
return {
|
||||
id: "example",
|
||||
description: "Reviews changes before merge",
|
||||
path: "/plugins/example",
|
||||
enabled,
|
||||
status: enabled ? "running" : "disabled",
|
||||
};
|
||||
}
|
||||
|
||||
async function selectPluginAction(action: string): Promise<void> {
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Actions for example" }));
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: action }));
|
||||
}
|
||||
|
||||
function createClient() {
|
||||
return {
|
||||
observeEvents: () =>
|
||||
subscriptionFixture(
|
||||
Promise.resolve({ events: ["status.plugin_catalog_changed"] }),
|
||||
() => () => {},
|
||||
),
|
||||
getDaemonConfig: vi.fn(async () => ({ config: { pluginsEnabled: true } })),
|
||||
patchDaemonConfig: vi.fn(async () => ({ config: { pluginsEnabled: true } })),
|
||||
listPlugins: vi.fn(async (): Promise<PluginListItem[]> => []),
|
||||
installPluginSource: vi.fn(async () => plugin()),
|
||||
reloadPlugin: vi.fn(async () => plugin()),
|
||||
enablePlugin: vi.fn(async () => plugin()),
|
||||
disablePlugin: vi.fn(async () => plugin(false)),
|
||||
removePlugin: vi.fn(async () => undefined),
|
||||
getPluginLogs: vi.fn(async (): Promise<PluginLogEntry[]> => []),
|
||||
};
|
||||
}
|
||||
|
||||
type PluginClient = ReturnType<typeof createClient>;
|
||||
|
||||
function renderPage(client: PluginClient | null): void {
|
||||
runtime.client = client as unknown as DaemonClient | null;
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
const element: ReactElement = (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HostPluginsPage serverId="host-a" />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
render(element);
|
||||
}
|
||||
|
||||
describe("HostPluginsPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("React", React);
|
||||
runtime.connected = true;
|
||||
runtime.supported = true;
|
||||
runtime.logsSupported = true;
|
||||
runtime.client = null;
|
||||
vi.stubGlobal(
|
||||
"confirm",
|
||||
vi.fn(() => true),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("renders the offline state", () => {
|
||||
runtime.connected = false;
|
||||
renderPage(null);
|
||||
|
||||
expect(screen.getByRole("alert").textContent).toContain("Plugin host is offline");
|
||||
});
|
||||
|
||||
it("renders a catalog error and retries through the real query", async () => {
|
||||
const client = createClient();
|
||||
client.listPlugins.mockRejectedValueOnce(new Error("catalog exploded"));
|
||||
renderPage(client);
|
||||
|
||||
expect(await screen.findByText("Unable to load plugins")).toBeDefined();
|
||||
expect(screen.getByText("catalog exploded")).toBeDefined();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
|
||||
await waitFor(() => expect(client.listPlugins).toHaveBeenCalledTimes(2));
|
||||
expect(await screen.findByText("No plugins configured")).toBeDefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["reloadPlugin", "Reload"],
|
||||
["removePlugin", "Remove"],
|
||||
] as const)("runs %s from the plugin actions menu", async (method, action) => {
|
||||
const client = createClient();
|
||||
client.listPlugins.mockResolvedValue([plugin()]);
|
||||
renderPage(client);
|
||||
|
||||
await selectPluginAction(action);
|
||||
|
||||
await waitFor(() => expect(client[method]).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("hides the logs action when the host does not advertise support", async () => {
|
||||
runtime.logsSupported = false;
|
||||
const client = createClient();
|
||||
client.listPlugins.mockResolvedValue([plugin()]);
|
||||
renderPage(client);
|
||||
|
||||
await screen.findByText("example");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Actions for example" }));
|
||||
expect(screen.queryByRole("menuitem", { name: "Logs" })).toBeNull();
|
||||
});
|
||||
|
||||
it("opens readable stdout and stderr logs and refreshes them", async () => {
|
||||
const client = createClient();
|
||||
client.listPlugins.mockResolvedValue([plugin()]);
|
||||
client.getPluginLogs.mockResolvedValue([
|
||||
{
|
||||
sequence: 1,
|
||||
timestamp: "2026-08-16T12:00:00.000Z",
|
||||
stream: "stdout",
|
||||
message: "ready",
|
||||
},
|
||||
{
|
||||
sequence: 2,
|
||||
timestamp: "2026-08-16T12:00:01.000Z",
|
||||
stream: "stderr",
|
||||
message: "warning",
|
||||
},
|
||||
]);
|
||||
renderPage(client);
|
||||
|
||||
await selectPluginAction("Logs");
|
||||
|
||||
expect(await screen.findByRole("dialog")).toBeDefined();
|
||||
expect(screen.getByText("Logs: example")).toBeDefined();
|
||||
expect(await screen.findByText("ready")).toBeDefined();
|
||||
expect(screen.getByText("warning")).toBeDefined();
|
||||
expect(screen.getByText(/stdout/)).toBeDefined();
|
||||
expect(screen.getByText(/stderr/)).toBeDefined();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
await waitFor(() => expect(client.getPluginLogs).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("shows empty and recoverable error states for plugin logs", async () => {
|
||||
const client = createClient();
|
||||
client.listPlugins.mockResolvedValue([plugin()]);
|
||||
client.getPluginLogs.mockRejectedValueOnce(new Error("logs exploded"));
|
||||
renderPage(client);
|
||||
|
||||
await selectPluginAction("Logs");
|
||||
expect(await screen.findByText("Unable to load plugin logs")).toBeDefined();
|
||||
expect(screen.getByText("logs exploded")).toBeDefined();
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Refresh" }).at(-1)!);
|
||||
await waitFor(() => expect(client.getPluginLogs).toHaveBeenCalledTimes(2));
|
||||
expect(await screen.findByText("No plugin output yet")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,590 +0,0 @@
|
||||
import { formatPluginInstallation } from "@getpaseo/protocol/plugin-source-reference";
|
||||
import { PluginSettingsButton } from "@/plugins/settings";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import type { PluginListItem, PluginLogEntry } from "@getpaseo/protocol/messages";
|
||||
import { MoreHorizontal, RotateCw, Trash2 } from "lucide-react-native";
|
||||
import {
|
||||
AdaptiveModalSheet,
|
||||
SheetHeaderIconButton,
|
||||
type SheetHeader,
|
||||
} from "@/components/adaptive-modal-sheet";
|
||||
import { SettingsCard, SettingsRow } from "@/components/settings";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ExternalLink } from "@/components/ui/external-link";
|
||||
import { Field, FormTextInput } from "@/components/ui/form-field";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { SettingsSection } from "@/components/settings/headings/settings-section";
|
||||
import { resolvePluginPageState } from "@/screens/settings/plugins-page-state";
|
||||
import { openPluginInstallForm } from "@/screens/settings/plugin-install-form-model";
|
||||
import { pluginRegistry, useInstalledPlugins } from "@/plugins/registry";
|
||||
import { pluginDisplayName, usePluginList } from "@/plugins/plugin-list";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
|
||||
const PLUGIN_SOURCE_DOCS_URL = "https://paseo.sh/docs/plugins/reference#plugin-sources";
|
||||
type PluginRowAction = "reload" | "enable" | "disable" | "remove";
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function pluginRowAction(action: string | undefined): PluginRowAction | undefined {
|
||||
if (action === "reload" || action === "enable" || action === "disable" || action === "remove") {
|
||||
return action;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function PluginActionsMenu({
|
||||
plugin,
|
||||
pending,
|
||||
pendingAction,
|
||||
onAction,
|
||||
onOpenLogs,
|
||||
supportsLogs,
|
||||
}: {
|
||||
plugin: PluginListItem;
|
||||
pending: boolean;
|
||||
pendingAction?: PluginRowAction;
|
||||
onAction(action: PluginRowAction, plugin: PluginListItem): void;
|
||||
onOpenLogs(plugin: PluginListItem): void;
|
||||
supportsLogs: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const reload = useCallback(() => onAction("reload", plugin), [onAction, plugin]);
|
||||
const remove = useCallback(() => onAction("remove", plugin), [onAction, plugin]);
|
||||
const openLogs = useCallback(() => onOpenLogs(plugin), [onOpenLogs, plugin]);
|
||||
const removeIcon = useMemo(() => <Trash2 size={16} color={styles.dangerIcon.color} />, []);
|
||||
const menuLabel = t("settings.plugins.actions.menu", { name: pluginDisplayName(plugin) });
|
||||
|
||||
return (
|
||||
<DropdownMenu compactMode="sheet">
|
||||
<DropdownMenuTrigger
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={menuLabel}
|
||||
disabled={pending}
|
||||
hitSlop={8}
|
||||
style={styles.menuButton}
|
||||
>
|
||||
<MoreHorizontal size={18} color={styles.menuIcon.color} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={220} sheetTitle={menuLabel}>
|
||||
{supportsLogs ? (
|
||||
<DropdownMenuItem onSelect={openLogs} disabled={pending}>
|
||||
{t("settings.plugins.logs.action")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
onSelect={reload}
|
||||
disabled={pending || !plugin.enabled}
|
||||
status={pendingAction === "reload" ? "pending" : "idle"}
|
||||
pendingLabel={t("settings.plugins.actions.reloading")}
|
||||
>
|
||||
{t("settings.plugins.actions.reload")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
destructive
|
||||
leading={removeIcon}
|
||||
onSelect={remove}
|
||||
disabled={pending}
|
||||
status={pendingAction === "remove" ? "pending" : "idle"}
|
||||
pendingLabel={t("settings.plugins.actions.removing")}
|
||||
>
|
||||
{t("settings.plugins.actions.remove")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginRow({
|
||||
plugin,
|
||||
serverId,
|
||||
clientError,
|
||||
pending,
|
||||
pendingAction,
|
||||
onAction,
|
||||
onOpenLogs,
|
||||
supportsLogs,
|
||||
}: {
|
||||
plugin: PluginListItem;
|
||||
serverId: string;
|
||||
clientError?: string;
|
||||
pending: boolean;
|
||||
pendingAction?: PluginRowAction;
|
||||
onAction(action: PluginRowAction, plugin: PluginListItem): void;
|
||||
onOpenLogs(plugin: PluginListItem): void;
|
||||
supportsLogs: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const toggle = useCallback(
|
||||
(enabled: boolean) => onAction(enabled ? "enable" : "disable", plugin),
|
||||
[onAction, plugin],
|
||||
);
|
||||
const status = clientError ? "failed" : plugin.status;
|
||||
let badgeVariant: "success" | "error" | "muted" = "muted";
|
||||
if (status === "running") badgeVariant = "success";
|
||||
else if (status === "failed") badgeVariant = "error";
|
||||
const statusLabel = t(`settings.plugins.status.${status}`);
|
||||
const statusBadge = useMemo(
|
||||
() => <StatusBadge label={statusLabel} variant={badgeVariant} />,
|
||||
[badgeVariant, statusLabel],
|
||||
);
|
||||
const name = pluginDisplayName(plugin);
|
||||
// A display name hides the id that the CLI and plugin routes use, so show it beneath.
|
||||
const shownId = name === plugin.id ? null : plugin.id;
|
||||
const hint = useMemo(
|
||||
() =>
|
||||
shownId || plugin.description || plugin.installation ? (
|
||||
<View>
|
||||
{shownId ? <Text style={styles.pluginSource}>{shownId}</Text> : null}
|
||||
{plugin.description ? (
|
||||
<Text style={styles.pluginDescription}>{plugin.description}</Text>
|
||||
) : null}
|
||||
{plugin.installation ? (
|
||||
<Text style={styles.pluginSource}>{formatPluginInstallation(plugin.installation)}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
) : undefined,
|
||||
[shownId, plugin.description, plugin.installation],
|
||||
);
|
||||
const toggleLabel = `${name}: ${t(
|
||||
plugin.enabled ? "settings.plugins.actions.disable" : "settings.plugins.actions.enable",
|
||||
)}`;
|
||||
return (
|
||||
<SettingsRow
|
||||
label={name}
|
||||
labelAccessory={statusBadge}
|
||||
hint={hint}
|
||||
error={clientError ?? plugin.error}
|
||||
testID={`plugin-row-${plugin.id}`}
|
||||
>
|
||||
<View style={styles.pluginControls} accessibilityLabel={`${name} ${statusLabel}`}>
|
||||
<PluginSettingsButton serverId={serverId} pluginId={plugin.id} />
|
||||
<Switch
|
||||
value={plugin.enabled}
|
||||
onValueChange={toggle}
|
||||
disabled={pending}
|
||||
accessibilityLabel={toggleLabel}
|
||||
/>
|
||||
<PluginActionsMenu
|
||||
plugin={plugin}
|
||||
pending={pending}
|
||||
pendingAction={pendingAction}
|
||||
onAction={onAction}
|
||||
onOpenLogs={onOpenLogs}
|
||||
supportsLogs={supportsLogs}
|
||||
/>
|
||||
</View>
|
||||
</SettingsRow>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginLogsSheet({
|
||||
client,
|
||||
plugin,
|
||||
serverId,
|
||||
onClose,
|
||||
}: {
|
||||
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
|
||||
plugin: PluginListItem;
|
||||
serverId: string;
|
||||
onClose(): void;
|
||||
}) {
|
||||
const pluginId = plugin.id;
|
||||
const { t } = useTranslation();
|
||||
const logs = useFetchQuery({
|
||||
queryKey: ["plugin-logs", serverId, pluginId],
|
||||
queryFn: () => client.getPluginLogs(pluginId),
|
||||
dataShape: "list",
|
||||
staleTimeMs: 0,
|
||||
});
|
||||
const refresh = useCallback(() => {
|
||||
void logs.refetch();
|
||||
}, [logs]);
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
title: t("settings.plugins.logs.title", { name: pluginDisplayName(plugin) }),
|
||||
actions: (
|
||||
<SheetHeaderIconButton
|
||||
icon={RotateCw}
|
||||
label={
|
||||
logs.isFetching
|
||||
? t("settings.plugins.logs.refreshing")
|
||||
: t("settings.plugins.logs.refresh")
|
||||
}
|
||||
onPress={refresh}
|
||||
busy={logs.isFetching}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[logs.isFetching, plugin, refresh, t],
|
||||
);
|
||||
|
||||
let content = <Text style={styles.logsState}>{t("settings.plugins.logs.loading")}</Text>;
|
||||
if (logs.isError) {
|
||||
content = (
|
||||
<Alert
|
||||
size="sm"
|
||||
variant="error"
|
||||
title={t("settings.plugins.logs.errorTitle")}
|
||||
description={errorMessage(logs.error)}
|
||||
>
|
||||
<Button variant="outline" onPress={refresh}>
|
||||
{t("settings.plugins.logs.refresh")}
|
||||
</Button>
|
||||
</Alert>
|
||||
);
|
||||
} else if (!logs.isLoading) {
|
||||
content = logs.data?.length ? (
|
||||
<View style={styles.logList}>
|
||||
{logs.data.map((entry: PluginLogEntry) => (
|
||||
<View key={entry.sequence} style={styles.logEntry}>
|
||||
<Text style={entry.stream === "stderr" ? styles.logStderr : styles.logStdout}>
|
||||
{entry.timestamp} {entry.stream}
|
||||
</Text>
|
||||
<Text style={styles.logMessage}>{entry.message}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.logsState}>{t("settings.plugins.logs.empty")}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet header={header} visible onClose={onClose} testID="plugin-logs-sheet">
|
||||
{content}
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
export function HostPluginsPage({ serverId }: { serverId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const connected = useHostRuntimeIsConnected(serverId);
|
||||
const supported = useHostFeature(serverId, "pluginManagement");
|
||||
// COMPAT(pluginSourceInstallation): added in v0.8.0; remove gate after 2027-03-16 once daemon floor supports source identifiers.
|
||||
const sourceInstallSupported = useHostFeature(serverId, "pluginSourceInstallation");
|
||||
// COMPAT(pluginLogs): added in v0.4.0, remove gate after 2027-08-16.
|
||||
const logsSupported = useHostFeature(serverId, "pluginLogs");
|
||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||
const refreshQueue = useRef(Promise.resolve());
|
||||
useInstalledPlugins();
|
||||
const [installForm] = useState(openPluginInstallForm);
|
||||
const installState = useSyncExternalStore(
|
||||
installForm.subscribe,
|
||||
installForm.getState,
|
||||
installForm.getState,
|
||||
);
|
||||
const sourceDocsLink = useMemo(
|
||||
() => (
|
||||
<ExternalLink
|
||||
href={PLUGIN_SOURCE_DOCS_URL}
|
||||
label={t("settings.plugins.docs")}
|
||||
testID="plugin-source-docs-link"
|
||||
/>
|
||||
),
|
||||
[t],
|
||||
);
|
||||
const [logsPlugin, setLogsPlugin] = useState<PluginListItem | null>(null);
|
||||
const [feedback, setFeedback] = useState<{ kind: "success" | "error"; message: string } | null>(
|
||||
null,
|
||||
);
|
||||
const plugins = usePluginList(serverId);
|
||||
const refetchPlugins = plugins.refetch;
|
||||
const refresh = useCallback(async () => {
|
||||
if (!client) return;
|
||||
const refetch = async () => {
|
||||
await refetchPlugins();
|
||||
};
|
||||
refreshQueue.current = refreshQueue.current.then(refetch, refetch);
|
||||
await refreshQueue.current;
|
||||
}, [client, refetchPlugins]);
|
||||
useEffect(() => {
|
||||
return () => installForm.close();
|
||||
}, [installForm]);
|
||||
useEffect(() => {
|
||||
if (!client || !connected || !supported) return;
|
||||
const observation = client.observeEvents(["status.plugin_catalog_changed"]);
|
||||
observation.subscribe({
|
||||
snapshot: () => {},
|
||||
update: (message) => {
|
||||
if (message.type === "status" && message.payload.status === "plugin_catalog_changed")
|
||||
void refresh();
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
void observation
|
||||
.release()
|
||||
.catch((error) => console.warn("[Plugins] Failed to release settings feed", error));
|
||||
};
|
||||
}, [client, connected, supported, refresh]);
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (operation: {
|
||||
action: string;
|
||||
pluginId?: string;
|
||||
run: () => Promise<string>;
|
||||
}) => operation.run(),
|
||||
onSuccess: async (message) => {
|
||||
setFeedback({ kind: "success", message });
|
||||
await refresh();
|
||||
},
|
||||
onError: (error) => setFeedback({ kind: "error", message: errorMessage(error) }),
|
||||
});
|
||||
const install = useCallback(() => {
|
||||
if (!client || !sourceInstallSupported || !installState.canSubmit) return;
|
||||
const input = installForm.getSubmission();
|
||||
setFeedback(null);
|
||||
mutation.mutate({
|
||||
action: "install",
|
||||
run: async () => {
|
||||
const installed = await client.installPluginSource(input);
|
||||
installForm.reset();
|
||||
return t("settings.plugins.feedback.installed", { name: pluginDisplayName(installed) });
|
||||
},
|
||||
});
|
||||
}, [client, installForm, installState.canSubmit, mutation, sourceInstallSupported, t]);
|
||||
const action = useCallback(
|
||||
(name: PluginRowAction, plugin: PluginListItem) => {
|
||||
if (!client) return;
|
||||
const pluginName = pluginDisplayName(plugin);
|
||||
const run = async () => {
|
||||
if (name === "remove") {
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("settings.plugins.removeConfirmTitle", { name: pluginName }),
|
||||
message: t("settings.plugins.removeConfirmMessage"),
|
||||
confirmLabel: t("settings.plugins.actions.remove"),
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) return t("settings.plugins.feedback.kept", { name: pluginName });
|
||||
await client.removePlugin(plugin.id);
|
||||
} else {
|
||||
await client[`${name}Plugin`](plugin.id);
|
||||
}
|
||||
return t(`settings.plugins.feedback.${name}`, { name: pluginName });
|
||||
};
|
||||
setFeedback(null);
|
||||
mutation.mutate({ action: name, pluginId: plugin.id, run });
|
||||
},
|
||||
[client, mutation, t],
|
||||
);
|
||||
const toggleGlobal = useCallback(
|
||||
(enabled: boolean) => {
|
||||
setFeedback(null);
|
||||
mutation.mutate({
|
||||
action: enabled ? "global-enable" : "global-disable",
|
||||
run: async () => {
|
||||
await patchConfig({ pluginsEnabled: enabled });
|
||||
return enabled
|
||||
? t("settings.plugins.feedback.globalEnabled")
|
||||
: t("settings.plugins.feedback.globalDisabled");
|
||||
},
|
||||
});
|
||||
},
|
||||
[mutation, patchConfig, t],
|
||||
);
|
||||
const pageState = resolvePluginPageState({
|
||||
connected,
|
||||
supported,
|
||||
loading: plugins.isLoading,
|
||||
error: plugins.isError,
|
||||
pluginCount: plugins.data?.length ?? 0,
|
||||
});
|
||||
const retry = useCallback(() => {
|
||||
void refetchPlugins();
|
||||
}, [refetchPlugins]);
|
||||
const closeLogs = useCallback(() => setLogsPlugin(null), []);
|
||||
|
||||
if (pageState === "offline") {
|
||||
return (
|
||||
<Alert
|
||||
size="md"
|
||||
variant="warning"
|
||||
title={t("settings.plugins.states.offlineTitle")}
|
||||
description={t("settings.plugins.states.offlineDescription")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (pageState === "unsupported") {
|
||||
return <Alert size="md" variant="warning" title={t("settings.plugins.states.updateTitle")} />;
|
||||
}
|
||||
|
||||
let catalogContent = <Alert size="sm" title={t("settings.plugins.states.loading")} />;
|
||||
if (pageState === "error") {
|
||||
catalogContent = (
|
||||
<Alert
|
||||
size="sm"
|
||||
variant="error"
|
||||
title={t("settings.plugins.states.errorTitle")}
|
||||
description={errorMessage(plugins.error)}
|
||||
>
|
||||
<Button variant="outline" onPress={retry}>
|
||||
{t("settings.plugins.states.retry")}
|
||||
</Button>
|
||||
</Alert>
|
||||
);
|
||||
} else if (pageState !== "loading") {
|
||||
catalogContent = (
|
||||
<SettingsCard>
|
||||
{plugins.data?.length ? (
|
||||
plugins.data.map((plugin) => {
|
||||
const clientError = pluginRegistry.getEvaluationError(serverId, plugin.id);
|
||||
const pending = mutation.isPending && mutation.variables?.pluginId === plugin.id;
|
||||
return (
|
||||
<PluginRow
|
||||
key={plugin.id}
|
||||
serverId={serverId}
|
||||
plugin={plugin}
|
||||
clientError={clientError}
|
||||
pending={mutation.isPending}
|
||||
pendingAction={pending ? pluginRowAction(mutation.variables?.action) : undefined}
|
||||
onAction={action}
|
||||
onOpenLogs={setLogsPlugin}
|
||||
supportsLogs={logsSupported}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<View style={styles.empty}>
|
||||
<Text style={settingsStyles.rowHint}>{t("settings.plugins.states.empty")}</Text>
|
||||
</View>
|
||||
)}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<SettingsSection title={t("settings.plugins.title")}>
|
||||
<Alert
|
||||
size="sm"
|
||||
variant="warning"
|
||||
title={t("settings.plugins.trustedTitle")}
|
||||
description={t("settings.plugins.trustedDescription")}
|
||||
/>
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.plugins.globalTitle")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{t("settings.plugins.globalHint")}</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={config?.pluginsEnabled === true}
|
||||
onValueChange={toggleGlobal}
|
||||
disabled={mutation.isPending}
|
||||
accessibilityLabel={t("settings.plugins.globalTitle")}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
{sourceInstallSupported ? (
|
||||
<View style={[settingsStyles.card, styles.install]}>
|
||||
<Field label={t("settings.plugins.sourceLabel")} trailing={sourceDocsLink}>
|
||||
<FormTextInput
|
||||
initialValue=""
|
||||
resetKey={installState.resetKey}
|
||||
onChangeText={installForm.setSource}
|
||||
placeholder={t("settings.plugins.sourcePlaceholder")}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={!mutation.isPending}
|
||||
accessibilityLabel={t("settings.plugins.sourceLabel")}
|
||||
/>
|
||||
</Field>
|
||||
<Button onPress={install} disabled={!installState.canSubmit || mutation.isPending}>
|
||||
{mutation.isPending && mutation.variables?.action === "install"
|
||||
? t("settings.plugins.installing")
|
||||
: t("settings.plugins.install")}
|
||||
</Button>
|
||||
</View>
|
||||
) : (
|
||||
<Alert
|
||||
size="sm"
|
||||
variant="warning"
|
||||
title={t("settings.plugins.states.sourceUpdateTitle")}
|
||||
/>
|
||||
)}
|
||||
{feedback ? (
|
||||
<Alert
|
||||
size="sm"
|
||||
variant={feedback.kind}
|
||||
title={feedback.message}
|
||||
testID="plugin-management-feedback"
|
||||
/>
|
||||
) : null}
|
||||
{catalogContent}
|
||||
</SettingsSection>
|
||||
{client && logsPlugin ? (
|
||||
<PluginLogsSheet
|
||||
client={client}
|
||||
plugin={logsPlugin}
|
||||
serverId={serverId}
|
||||
onClose={closeLogs}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
pluginDescription: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
marginTop: theme.spacing[1],
|
||||
},
|
||||
pluginSource: {
|
||||
color: theme.colors.foregroundExtraMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
marginTop: theme.spacing[1],
|
||||
},
|
||||
install: { padding: theme.spacing[4], gap: theme.spacing[3] },
|
||||
pluginControls: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
|
||||
menuButton: { padding: theme.spacing[1], borderRadius: theme.borderRadius.sm },
|
||||
menuIcon: { color: theme.colors.foregroundMuted },
|
||||
dangerIcon: { color: theme.colors.statusDanger },
|
||||
empty: { padding: theme.spacing[4], alignItems: "center" },
|
||||
logsState: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
textAlign: "center",
|
||||
paddingVertical: theme.spacing[6],
|
||||
},
|
||||
logList: { gap: theme.spacing[2] },
|
||||
logEntry: {
|
||||
padding: theme.spacing[3],
|
||||
gap: theme.spacing[1],
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
logStdout: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
logStderr: {
|
||||
color: theme.colors.statusDanger,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
logMessage: {
|
||||
color: theme.colors.foreground,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
}));
|
||||
@@ -27,8 +27,12 @@ export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView
|
||||
onExpandedChange,
|
||||
children,
|
||||
}: OverviewGroupProps) {
|
||||
const scrollRef = useRef<ScrollView>(null);
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const aggregateSummary = useOverviewSummaryLabel(group.summary);
|
||||
const scrollToLatest = useCallback(() => {
|
||||
scrollRef.current?.scrollToEnd({ animated: false });
|
||||
}, []);
|
||||
const hold = useDisclosureHold();
|
||||
const toggle = useCallback(() => {
|
||||
const apply = () => onExpandedChange(group.run.id, !expanded);
|
||||
@@ -40,8 +44,19 @@ export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView
|
||||
onExpandedChange(group.run.id, false);
|
||||
}, [group.run.id, onExpandedChange]);
|
||||
const renderDetails = useCallback(
|
||||
() => <InlineGroupDetails latestCallId={group.run.latest.id}>{children}</InlineGroupDetails>,
|
||||
[children, group.run.latest.id],
|
||||
() => (
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.content}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator
|
||||
onContentSizeChange={scrollToLatest}
|
||||
>
|
||||
<IndentRail>{children}</IndentRail>
|
||||
</ScrollView>
|
||||
),
|
||||
[children, scrollToLatest],
|
||||
);
|
||||
|
||||
if (isCompact) {
|
||||
@@ -78,35 +93,6 @@ export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView
|
||||
);
|
||||
});
|
||||
|
||||
function InlineGroupDetails({
|
||||
latestCallId,
|
||||
children,
|
||||
}: {
|
||||
latestCallId: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const scrollRef = useRef<ScrollView>(null);
|
||||
const lastRevealedCall = useRef<string | null>(null);
|
||||
const revealLatestCall = useCallback(() => {
|
||||
// A disclosure changes height without appending a call. Keep the reader's place then.
|
||||
if (lastRevealedCall.current === latestCallId) return;
|
||||
lastRevealedCall.current = latestCallId;
|
||||
scrollRef.current?.scrollToEnd({ animated: false });
|
||||
}, [latestCallId]);
|
||||
return (
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.content}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator
|
||||
onContentSizeChange={revealLatestCall}
|
||||
>
|
||||
<IndentRail>{children}</IndentRail>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
scroll: {
|
||||
maxHeight: TOOL_CALL_GROUP_MAX_HEIGHT,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@getpaseo/protocol/binary-frames/index";
|
||||
import { messageTooLargeError } from "./daemon-client-transport-utils.js";
|
||||
import type { HostAdd, HostUpdate, HostDirectory } from "@getpaseo/protocol/hosts";
|
||||
import type { PluginListingDetail, PluginListingIndex } from "@getpaseo/protocol/plugin-registry";
|
||||
import { legacyUsageIcon } from "./legacy-usage-icons.js";
|
||||
import { subscribeTimeline, type TimelineMessage } from "./timeline-subscription/index.js";
|
||||
import { ProviderSnapshotUpdates } from "./provider-snapshots/index.js";
|
||||
@@ -6575,6 +6576,38 @@ export class DaemonClient {
|
||||
return payload.plugins;
|
||||
}
|
||||
|
||||
private requirePluginRegistry(): void {
|
||||
// COMPAT(pluginRegistry): added in v0.16.0; remove after 2027-05-15 once daemon floor proxies the registry.
|
||||
if (this.getLastServerInfoMessage()?.features?.pluginRegistry !== true)
|
||||
throw new Error("Update the host to browse plugins.");
|
||||
}
|
||||
|
||||
/** The host's default plugin registry directory. */
|
||||
async listPluginRegistry(): Promise<PluginListingIndex> {
|
||||
this.requirePluginRegistry();
|
||||
const requestId = this.createRequestId();
|
||||
const payload = await this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: { type: "plugin.registry.list.request", requestId },
|
||||
responseType: "plugin.registry.list.response",
|
||||
timeout: 60_000,
|
||||
});
|
||||
return payload.index;
|
||||
}
|
||||
|
||||
/** One registry listing with its overview. */
|
||||
async getRegistryPlugin(id: string): Promise<PluginListingDetail> {
|
||||
this.requirePluginRegistry();
|
||||
const requestId = this.createRequestId();
|
||||
const payload = await this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: { type: "plugin.registry.get.request", requestId, id },
|
||||
responseType: "plugin.registry.get.response",
|
||||
timeout: 60_000,
|
||||
});
|
||||
return payload.plugin;
|
||||
}
|
||||
|
||||
private requirePluginUpdates(): void {
|
||||
// COMPAT(pluginSourceUpdates): added in v0.8.0; remove after 2027-03-16 once daemon floor supports reviewed updates.
|
||||
if (this.getLastServerInfoMessage()?.features?.pluginSourceUpdates !== true)
|
||||
|
||||
@@ -19,7 +19,12 @@ import {
|
||||
HostsRouteFrameSchema,
|
||||
HostsRouteClosedSchema,
|
||||
} from "./hosts.js";
|
||||
import { PluginRegistryIdentitySchema } from "./plugin-registry.js";
|
||||
import {
|
||||
PluginRegistryIdSchema,
|
||||
PluginRegistryIdentitySchema,
|
||||
PluginListingDetailSchema,
|
||||
PluginListingIndexSchema,
|
||||
} from "./plugin-registry.js";
|
||||
import { AgentProfileSchema, AgentSkillSelectionSchema } from "./agent-profile.js";
|
||||
export {
|
||||
AgentProfileSchema,
|
||||
@@ -1673,6 +1678,17 @@ export const PluginSourceStatusRequestSchema = z.object({
|
||||
pluginId: PluginIdSchema.optional(),
|
||||
});
|
||||
|
||||
export const PluginRegistryListRequestSchema = z.object({
|
||||
type: z.literal("plugin.registry.list.request"),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const PluginRegistryGetRequestSchema = z.object({
|
||||
type: z.literal("plugin.registry.get.request"),
|
||||
requestId: z.string(),
|
||||
id: PluginRegistryIdSchema,
|
||||
});
|
||||
|
||||
export const PluginSourceUpdateRequestSchema = z.object({
|
||||
type: z.literal("plugin.source.update.request"),
|
||||
requestId: z.string(),
|
||||
@@ -3560,6 +3576,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
PluginDirectoryInspectRequestSchema,
|
||||
PluginSourceInstallRequestSchema,
|
||||
PluginSourceStatusRequestSchema,
|
||||
PluginRegistryListRequestSchema,
|
||||
PluginRegistryGetRequestSchema,
|
||||
PluginSourceUpdateRequestSchema,
|
||||
PluginUpdatePreviewRequestSchema,
|
||||
PluginUpdateApplyRequestSchema,
|
||||
@@ -3973,6 +3991,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
pluginSourceInstallation: z.boolean().optional(),
|
||||
// COMPAT(pluginSourceUpdates): added in v0.8.0; remove gate after 2027-03-16 once daemon floor supports reviewed updates.
|
||||
pluginSourceUpdates: z.boolean().optional(),
|
||||
// COMPAT(pluginRegistry): added in v0.16.0, remove gate after 2027-05-15.
|
||||
pluginRegistry: z.boolean().optional(),
|
||||
// COMPAT(pluginThemes): added in v0.5.0, remove gate after 2027-08-20.
|
||||
// A daemon that predates this flag keeps `addTheme` in the server bundle it compiles,
|
||||
// so a theme plugin cannot start there at all.
|
||||
@@ -7259,6 +7279,17 @@ export const PluginSourceStatusItemSchema = z.object({
|
||||
});
|
||||
export type PluginSourceStatusItem = z.infer<typeof PluginSourceStatusItemSchema>;
|
||||
|
||||
/** The host's plugin directory index, fetched by the daemon from its configured registry. */
|
||||
export const PluginRegistryListResponseSchema = z.object({
|
||||
type: z.literal("plugin.registry.list.response"),
|
||||
payload: z.object({ requestId: z.string(), index: PluginListingIndexSchema }),
|
||||
});
|
||||
|
||||
export const PluginRegistryGetResponseSchema = z.object({
|
||||
type: z.literal("plugin.registry.get.response"),
|
||||
payload: z.object({ requestId: z.string(), plugin: PluginListingDetailSchema }),
|
||||
});
|
||||
|
||||
export const PluginSourceStatusResponseSchema = z.object({
|
||||
type: z.literal("plugin.source.status.response"),
|
||||
payload: z.object({ requestId: z.string(), plugins: z.array(PluginSourceStatusItemSchema) }),
|
||||
@@ -7409,6 +7440,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
PluginDirectoryInspectResponseSchema,
|
||||
PluginSourceInstallResponseSchema,
|
||||
PluginSourceStatusResponseSchema,
|
||||
PluginRegistryListResponseSchema,
|
||||
PluginRegistryGetResponseSchema,
|
||||
PluginSourceUpdateResponseSchema,
|
||||
PluginUpdatePreviewResponseSchema,
|
||||
PluginUpdateApplyResponseSchema,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
PluginListingIndexSchema,
|
||||
PluginRegistryIndexSchema,
|
||||
PublishedPluginDetailSchema,
|
||||
pluginMediaKind,
|
||||
@@ -81,3 +82,15 @@ describe("plugin listing translations", () => {
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin listings sent to apps", () => {
|
||||
it("keep a listing whose artifact kind or index version the app does not know", () => {
|
||||
const future = { ...LEGACY_PLUGIN, id: "acme/future", artifact: { kind: "oci", image: "x" } };
|
||||
const index = PluginListingIndexSchema.parse({
|
||||
...LEGACY_INDEX,
|
||||
schemaVersion: 2,
|
||||
plugins: [LEGACY_PLUGIN, future],
|
||||
});
|
||||
expect(index.plugins.map((plugin) => plugin.artifact.kind)).toEqual(["git", "oci"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,12 +79,43 @@ export const PluginRegistryIndexSchema = z.object({
|
||||
featured: z.array(z.string()),
|
||||
generatedAt: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* A listing as a host sends it to apps. The registry's schema is closed, so its artifact is open
|
||||
* here: an app older than its host keeps listing plugins whose artifact kind it does not know, and
|
||||
* installs them through the host by ID.
|
||||
*/
|
||||
const PluginListingArtifactSchema = z.object({
|
||||
kind: z.string(),
|
||||
package: z.string().optional(),
|
||||
version: z.string().optional(),
|
||||
commit: z.string().optional(),
|
||||
});
|
||||
export const PluginListingSchema = PublishedPluginSchema.extend({
|
||||
artifact: PluginListingArtifactSchema,
|
||||
});
|
||||
export const PluginListingDetailSchema = PublishedPluginDetailSchema.extend({
|
||||
artifact: PluginListingArtifactSchema,
|
||||
});
|
||||
export const PluginListingIndexSchema = PluginRegistryIndexSchema.extend({
|
||||
schemaVersion: z.number().int(),
|
||||
plugins: z.array(PluginListingSchema),
|
||||
});
|
||||
export type PluginListing = z.infer<typeof PluginListingSchema>;
|
||||
export type PluginListingDetail = z.infer<typeof PluginListingDetailSchema>;
|
||||
export type PluginListingIndex = z.infer<typeof PluginListingIndexSchema>;
|
||||
|
||||
export type PluginRegistryIdentity = z.infer<typeof PluginRegistryIdentitySchema>;
|
||||
export type PluginRegistries = z.infer<typeof PluginRegistriesSchema>;
|
||||
export type PublishedPlugin = z.infer<typeof PublishedPluginSchema>;
|
||||
export type PublishedPluginDetail = z.infer<typeof PublishedPluginDetailSchema>;
|
||||
export type PluginRegistryIndex = z.infer<typeof PluginRegistryIndexSchema>;
|
||||
|
||||
export const DEFAULT_PLUGIN_REGISTRY_URL = "https://plugins.paseo.sh";
|
||||
|
||||
export function parsePluginRegistryReference(
|
||||
source: string,
|
||||
defaultUrl = "https://plugins.paseo.sh",
|
||||
defaultUrl = DEFAULT_PLUGIN_REGISTRY_URL,
|
||||
): PluginRegistryIdentity | null {
|
||||
const segments = source.split("/");
|
||||
if (segments.length === 2 && PluginRegistryIdSchema.safeParse(source).success)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import { PluginIdSchema } from "@getpaseo/protocol/plugin-config";
|
||||
|
||||
export const PLUGIN_TIMELINE_DATA_MAX_BYTES = 64 * 1024;
|
||||
|
||||
@@ -14,6 +15,15 @@ const MARKER_COST = 32;
|
||||
const SHORT_STRING = 1024;
|
||||
const MAX_DEPTH = 64;
|
||||
|
||||
// The app registers renderer kinds under the plugin ID rule, so stored kinds must use that format.
|
||||
export function assertPluginTimelineKind(kind: string): void {
|
||||
if (!PluginIdSchema.safeParse(kind).success) {
|
||||
throw new Error(
|
||||
`Plugin timeline item kind must start with a lowercase letter and contain only lowercase letters, digits, and dashes: ${JSON.stringify(kind)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertPluginTimelineDataSize(data: JsonValue): void {
|
||||
const serializedBytes = Buffer.byteLength(JSON.stringify(data), "utf8");
|
||||
if (serializedBytes > PLUGIN_TIMELINE_DATA_MAX_BYTES) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, test } from "vitest";
|
||||
import type { CanUseTool, PermissionMode, Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import type { AgentPermissionRequest } from "../../agent-sdk-types.js";
|
||||
import type { AgentPermissionRequest, AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
|
||||
// The injected SDK adapter supplies requests through Claude's permission callback.
|
||||
@@ -90,3 +90,62 @@ test.each([
|
||||
await plan.session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("bypass allows execution without publishing permission events and follows live mode changes", async () => {
|
||||
let requestPermission: CanUseTool | undefined;
|
||||
const query = Object.assign((async function* () {})(), {
|
||||
async setPermissionMode(_mode: PermissionMode) {},
|
||||
close() {},
|
||||
});
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
resolveBinary: async () => "/test/claude",
|
||||
queryFactory: ({ options }) => {
|
||||
requestPermission = options.canUseTool;
|
||||
return query as Query;
|
||||
},
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
modeId: "bypassPermissions",
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
try {
|
||||
await session.setMode("bypassPermissions");
|
||||
if (!requestPermission) throw new Error("SDK permission callback was not installed");
|
||||
const controller = new AbortController();
|
||||
const input = { command: "echo hello" };
|
||||
const result = requestPermission("Bash", input, {
|
||||
signal: controller.signal,
|
||||
toolUseID: "execution",
|
||||
decisionReason: "Explicit ask rule",
|
||||
});
|
||||
// Abort also settles the pre-fix callback, so this repro never hangs.
|
||||
controller.abort();
|
||||
await expect(result).resolves.toMatchObject({ behavior: "allow", updatedInput: input });
|
||||
expect(events.filter((event) => event.type.startsWith("permission_"))).toEqual([]);
|
||||
|
||||
for (const [mode, tool, toolInput] of [
|
||||
["bypassPermissions", "AskUserQuestion", { questions: [] }],
|
||||
["bypassPermissions", "ExitPlanMode", { plan: "Review this" }],
|
||||
["default", "Bash", input],
|
||||
["plan", "Bash", input],
|
||||
] as const) {
|
||||
await session.setMode(mode);
|
||||
const resolution = requestPermission(tool, toolInput, {
|
||||
signal: new AbortController().signal,
|
||||
toolUseID: tool,
|
||||
});
|
||||
const requestEvent = events.at(-1);
|
||||
expect(requestEvent?.type).toBe("permission_requested");
|
||||
if (requestEvent?.type !== "permission_requested")
|
||||
throw new Error("Missing permission request");
|
||||
await session.respondToPermission(requestEvent.request.id, { behavior: "deny" });
|
||||
await expect(resolution).resolves.toMatchObject({ behavior: "deny" });
|
||||
}
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1004,7 +1004,7 @@ describe("ClaudeAgentSession features", () => {
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
}).createSession({ provider: "claude", cwd: process.cwd(), modeId: "bypassPermissions" });
|
||||
}).createSession({ provider: "claude", cwd: process.cwd(), modeId: "default" });
|
||||
const events: AgentStreamEvent[] = [];
|
||||
const unsubscribe = session.subscribe((event) => events.push(event));
|
||||
|
||||
|
||||
@@ -5307,6 +5307,13 @@ class ClaudeAgentSession implements AgentSession {
|
||||
input,
|
||||
options,
|
||||
): Promise<PermissionResult> => {
|
||||
// Decide before publishing anything: bypassed tools never enter the permission UI.
|
||||
// Claude also routes questions and plan review through this callback.
|
||||
const needsUserAnswer = toolName === "AskUserQuestion" || toolName === "ExitPlanMode";
|
||||
if (this.currentMode === "bypassPermissions" && !needsUserAnswer) {
|
||||
return { behavior: "allow", updatedInput: input, toolUseID: options.toolUseID };
|
||||
}
|
||||
|
||||
const requestId = `permission-${randomUUID()}`;
|
||||
const kind = resolvePermissionKind(toolName, input);
|
||||
const requestInput = normalizeClaudeAskUserQuestionRequestInput(toolName, input);
|
||||
|
||||
@@ -149,6 +149,8 @@ const INBOUND_PERMISSION = {
|
||||
"plugin.rpc.subscribe.request": "daemon.manage",
|
||||
"plugin.source.install.request": "daemon.manage",
|
||||
"plugin.source.status.request": "daemon.manage",
|
||||
"plugin.registry.list.request": "daemon.manage",
|
||||
"plugin.registry.get.request": "daemon.manage",
|
||||
"plugin.source.update.request": "daemon.manage",
|
||||
"plugin.source.update.preview.request": "daemon.manage",
|
||||
"plugin.source.update.apply.request": "daemon.manage",
|
||||
@@ -399,6 +401,8 @@ const OUTBOUND_PERMISSION = {
|
||||
"plugin.rpc.closed": "daemon.manage",
|
||||
"plugin.source.install.response": "daemon.manage",
|
||||
"plugin.source.status.response": "daemon.manage",
|
||||
"plugin.registry.list.response": "daemon.manage",
|
||||
"plugin.registry.get.response": "daemon.manage",
|
||||
"plugin.source.update.response": "daemon.manage",
|
||||
"plugin.source.update.preview.response": "daemon.manage",
|
||||
"plugin.source.update.apply.response": "daemon.manage",
|
||||
|
||||
@@ -260,7 +260,7 @@ test("legacy diffs retain successful bodies when an oversized sibling is removed
|
||||
]);
|
||||
await rm(join(cwd, "b.txt"));
|
||||
await manager.scheduleRefreshForCwd(cwd);
|
||||
await expect.poll(() => updates.at(-1)?.error).toBeNull();
|
||||
await expect.poll(() => updates.at(-1)?.error, { timeout: 10000 }).toBeNull();
|
||||
expect(updates.at(-1)?.files[0]).toMatchObject({
|
||||
path: "a.ts",
|
||||
hunks: [
|
||||
@@ -271,7 +271,7 @@ test("legacy diffs retain successful bodies when an oversized sibling is removed
|
||||
});
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
test("legacy subscriptions reselect the preferred base when a remote ref appears", async () => {
|
||||
await withCheckout(async ({ cwd, manager, git }) => {
|
||||
|
||||
@@ -47,7 +47,9 @@ test("the recursive watcher remembers newly announced files before reconciliatio
|
||||
const path = join(root, "new.txt");
|
||||
await writeFile(path, "created");
|
||||
await expect
|
||||
.poll(() => changes.some((event) => event.path === path && event.type === "create"))
|
||||
.poll(() => changes.some((event) => event.path === path && event.type === "create"), {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toBe(true);
|
||||
// Consumers have seen this file. A later audit needs it in its comparison
|
||||
// inventory even if the native watcher coalesces the subsequent delete.
|
||||
@@ -55,7 +57,9 @@ test("the recursive watcher remembers newly announced files before reconciliatio
|
||||
await rm(path);
|
||||
await backend.updateIgnore();
|
||||
await expect
|
||||
.poll(() => changes.some((event) => event.path === path && event.type === "delete"))
|
||||
.poll(() => changes.some((event) => event.path === path && event.type === "delete"), {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toBe(true);
|
||||
} finally {
|
||||
active = false;
|
||||
|
||||
@@ -396,6 +396,15 @@ export class PluginService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Reads the default registry's directory. Not queued: it touches no local state. */
|
||||
listRegistry() {
|
||||
return this.requireManagedSources().listRegistry();
|
||||
}
|
||||
|
||||
getRegistryPlugin(id: string) {
|
||||
return this.requireManagedSources().getRegistryPlugin(id);
|
||||
}
|
||||
|
||||
async statusSources(pluginId?: string): Promise<PluginSourceStatusItem[]> {
|
||||
return this.enqueue(async () => {
|
||||
const sources = this.configStore.get().plugins ?? {};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user