mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 00:14:54 +00:00
fix(plugins): New workspace items remount when the project or host changes
An item stayed mounted across a project switch, and its Combobox kept the previous project's results (same query key, cached data) selectable until the next search returned, so an issue from the old project could set the branch and metadata on the new one. A Combobox cannot detect that its inline search now means another project, so the item host keys the items by host and project id. Items and their search results start fresh for the new target, and a mount effect sets up again after the model clears the branch and metadata.
This commit is contained in:
@@ -717,6 +717,13 @@ remembering the isolation), so a command from a mount effect does the same as a
|
||||
picker stays in the picker's own handler. Add a capability by extending the store;
|
||||
do not read screen state from a plugin component.
|
||||
|
||||
The item host (`PluginNewWorkspaceItems`) keys the items by `(serverId, project id)`, so a change of
|
||||
project or host remounts them. That is what keeps an item's own state and a `Combobox`'s cached
|
||||
results from outliving their project: the `Combobox` cannot tell that an author's inline `search`
|
||||
now means another project, because its identity changes every render. The store clears `branch`
|
||||
and `metadata` on the same change in `syncInputs`, and a remounted item's mount effect sets them
|
||||
again.
|
||||
|
||||
`ensureWorkspace` sends the store's `branch` as `source.branchName` whenever the worktree cuts a
|
||||
new branch (`startsNewBranch`, which mirrors the daemon: anything but a checkout, including no base
|
||||
at all on a detached HEAD), and its `metadata` on `workspace.create` when it is not empty. The
|
||||
|
||||
@@ -64,6 +64,24 @@ test("a branch and metadata chosen for one project are not sent for another", as
|
||||
);
|
||||
});
|
||||
|
||||
test("an item's Issue list does not keep the previous project's results after a project switch", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
await withNewWorkspaceIssuesExample(
|
||||
page,
|
||||
{ compact: false, otherProject: true, searchesByProject: true },
|
||||
async (screen) => {
|
||||
await screen.openIssueList();
|
||||
await screen.expectIssueListText(`Issue of ${screen.firstProjectName}`, true);
|
||||
await screen.closeIssueList();
|
||||
await screen.switchToOtherProject();
|
||||
await screen.openIssueList();
|
||||
await screen.expectIssueListText(`Issue of ${screen.firstProjectName}`, false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("an isolation set from a mount effect is remembered like a pick", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await withNewWorkspaceIssuesExample(
|
||||
|
||||
@@ -47,6 +47,41 @@ export default function contribute(client) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An item whose search depends on the project: it answers with the project's name, and never
|
||||
* answers for the project named "other", so a list that still shows the first project's answer
|
||||
* there is stale.
|
||||
*/
|
||||
async function replaceWithProjectSearchItem(directory: string) {
|
||||
await writeFile(
|
||||
path.join(directory, "index.client.tsx"),
|
||||
`import { useNewWorkspace } from "@getpaseo/plugin/client";
|
||||
import { Combobox } from "@getpaseo/plugin/client/ui";
|
||||
function ProjectIssues() {
|
||||
const { project } = useNewWorkspace();
|
||||
const name = project?.name ?? "";
|
||||
return (
|
||||
<Combobox
|
||||
title="Issue"
|
||||
icon="CircleDot"
|
||||
value={null}
|
||||
search={() =>
|
||||
name.includes("other")
|
||||
? new Promise(() => {})
|
||||
: Promise.resolve([{ id: name, label: "Issue of " + name }])
|
||||
}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export default function contribute(client) {
|
||||
client.addNewWorkspaceItem({ id: "project-issues", Component: ProjectIssues });
|
||||
return () => {};
|
||||
}
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
type Client = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
|
||||
/** The daemon fills in the git runtime after it creates the workspace, so wait for the branch. */
|
||||
@@ -72,6 +107,8 @@ export async function withNewWorkspaceIssuesExample(
|
||||
compact: boolean;
|
||||
detachedHead?: boolean;
|
||||
setsOnMount?: boolean;
|
||||
/** Installs an item whose Issue list depends on the project instead of the example's. */
|
||||
searchesByProject?: boolean;
|
||||
/** A second project the screen can switch to. */
|
||||
otherProject?: boolean;
|
||||
},
|
||||
@@ -80,6 +117,12 @@ export async function withNewWorkspaceIssuesExample(
|
||||
chooseIssue(label: string): Promise<void>;
|
||||
createWorkspace(options?: { prompt?: string }): Promise<{ branch: string; metadata: unknown }>;
|
||||
switchToOtherProject(): Promise<void>;
|
||||
openIssueList(): Promise<void>;
|
||||
closeIssueList(): Promise<void>;
|
||||
/** The Issue list shows `text`, or does not show it when `shown` is false. */
|
||||
expectIssueListText(text: string, shown: boolean): Promise<void>;
|
||||
/** The name the first project's items read as `project.name`. */
|
||||
firstProjectName: string;
|
||||
expectIsolation(isolation: "local" | "worktree"): Promise<void>;
|
||||
/** Removes the plugin, leaves the screen, and opens New workspace for the project again. */
|
||||
reopenWithoutPlugin(): Promise<void>;
|
||||
@@ -88,6 +131,7 @@ export async function withNewWorkspaceIssuesExample(
|
||||
const client = await connectNewWorkspaceDaemonClient();
|
||||
const previous = await client.getDaemonConfig();
|
||||
const example = await copyPluginExample("new-workspace-issues");
|
||||
if (options.searchesByProject) await replaceWithProjectSearchItem(example.directory);
|
||||
if (options.setsOnMount) await replaceWithMountEffectItem(example.directory);
|
||||
const repo = await createTempGitRepo("new-workspace-issues-", { branches: ["main"] });
|
||||
const otherRepo = options.otherProject
|
||||
@@ -118,6 +162,17 @@ export async function withNewWorkspaceIssuesExample(
|
||||
await page.getByRole("button", { name: "Issue", exact: true }).click();
|
||||
await page.getByText(label, { exact: true }).click();
|
||||
}),
|
||||
firstProjectName: project.projectDisplayName,
|
||||
openIssueList: () =>
|
||||
test.step("open the Issue list", async () => {
|
||||
await page.getByRole("button", { name: "Issue", exact: true }).click();
|
||||
}),
|
||||
closeIssueList: () => test.step("close the Issue list", () => page.keyboard.press("Escape")),
|
||||
expectIssueListText: (text, shown) =>
|
||||
test.step(`the Issue list ${shown ? "shows" : "does not show"} "${text}"`, async () => {
|
||||
const list = page.getByTestId("combobox-desktop-container").last();
|
||||
await expect(list.getByText(text, { exact: true })).toHaveCount(shown ? 1 : 0);
|
||||
}),
|
||||
switchToOtherProject: () =>
|
||||
test.step("switch to the other project", async () => {
|
||||
if (!other) throw new Error("Open the example with otherProject to switch projects");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { Fragment, useMemo } from "react";
|
||||
import type { PluginHostProps } from "@getpaseo/plugin/client";
|
||||
import { PluginNewWorkspaceProvider } from "@getpaseo/plugin/client/host";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
@@ -38,7 +38,9 @@ function useServerPlugins(serverId: string): InstalledPlugin[] {
|
||||
|
||||
/**
|
||||
* The selected host's plugin items on the New workspace screen, drawn as setup fields. They read
|
||||
* the screen's choices and command them through the setup model.
|
||||
* the screen's choices and command them through the setup model. Items are mounted for one
|
||||
* project on one host: a change of either remounts them, so nothing an item fetched or chose for
|
||||
* the previous target stays on screen, and an item that sets up on mount sets up for the new one.
|
||||
*/
|
||||
export function PluginNewWorkspaceItems({
|
||||
serverId,
|
||||
@@ -53,15 +55,17 @@ export function PluginNewWorkspaceItems({
|
||||
return (
|
||||
<ComposerControlSurfaceProvider value="field">
|
||||
<ComposerControlLockProvider value={screen.locked}>
|
||||
{plugins.map((plugin) => (
|
||||
<ThemedPluginNewWorkspaceItems
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
screen={screen}
|
||||
setup={setup}
|
||||
uniProps={pluginThemeMapping}
|
||||
/>
|
||||
))}
|
||||
<Fragment key={`${serverId}\n${screen.project?.id ?? ""}`}>
|
||||
{plugins.map((plugin) => (
|
||||
<ThemedPluginNewWorkspaceItems
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
screen={screen}
|
||||
setup={setup}
|
||||
uniProps={pluginThemeMapping}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
</ComposerControlLockProvider>
|
||||
</ComposerControlSurfaceProvider>
|
||||
);
|
||||
|
||||
@@ -2033,6 +2033,7 @@ export default function contribute(client: PluginClientContext) {
|
||||
Commands follow the [composer item](#composer-items) rules: each one acts on the screen's state
|
||||
when it runs, so `await newWorkspace.setBranch(name)` followed by `newWorkspace.setMetadata(patch)`
|
||||
changes both, and its promise resolves once the change has taken effect. A command works from your component's first effect, including a mount effect. Payloads are JSON values.
|
||||
Your items are mounted for one project on one host. When the user changes either, Paseo remounts them and clears `branch` and `metadata`, so state and `Combobox` results for the previous project do not carry over; an item that sets up in a mount effect sets up again for the new project.
|
||||
`host`, `project`, `baseRef` and `metadata` are frozen. While the screen creates the workspace it is locked: your fields render
|
||||
disabled and every command rejects with "The New workspace screen is locked while the workspace is
|
||||
being created." A callback that returns a promise keeps the control disabled until it settles, and a
|
||||
|
||||
@@ -1708,7 +1708,7 @@ export default function contribute(client: PluginClientContext) {
|
||||
| `branch`、`setBranch` | 新 worktree 所创建的分支名称。`null` 表示由 Paseo 选择名称。隔离为本地或起点为变更请求时会保留该值,它为任何新建分支的 worktree 命名,起点可以是默认基线或所选分支;检出已有分支或变更请求的 worktree 不适用。 |
|
||||
| `metadata`、`setMetadata` | 所创建工作区的[元数据](/docs/plugins/hooks#metadata),而不是第一个 Agent 的元数据。顶层键替换,`null` 删除该键。主机不支持元数据时以 “Update the host to use metadata.” 拒绝。 |
|
||||
|
||||
命令遵循 [Composer 项](#composer-items)的规则:每个命令都按界面运行时的状态执行,所以 `await newWorkspace.setBranch(name)` 之后再调用 `newWorkspace.setMetadata(patch)` 会同时生效,命令的 Promise 在更改生效后完成。命令从组件的第一个 effect 起即可使用,包括挂载时的 effect。载荷是 JSON 值。`host`、`project`、`baseRef` 和 `metadata` 是冻结的。界面创建工作区期间会被锁定:你的字段显示为禁用,所有命令都会以 "The New workspace screen is locked while the workspace is being created." 拒绝。返回 Promise 的回调在完成之前会让控件保持禁用,被拒绝时会以错误提示向用户显示。
|
||||
命令遵循 [Composer 项](#composer-items)的规则:每个命令都按界面运行时的状态执行,所以 `await newWorkspace.setBranch(name)` 之后再调用 `newWorkspace.setMetadata(patch)` 会同时生效,命令的 Promise 在更改生效后完成。命令从组件的第一个 effect 起即可使用,包括挂载时的 effect。载荷是 JSON 值。你的项针对某一台主机上的某一个项目挂载。用户更改其中任何一个时,Paseo 会重新挂载你的项并清除 `branch` 和 `metadata`,因此上一个项目的状态和 `Combobox` 结果不会保留;在挂载 effect 中做初始化的项会针对新项目再次初始化。`host`、`project`、`baseRef` 和 `metadata` 是冻结的。界面创建工作区期间会被锁定:你的字段显示为禁用,所有命令都会以 "The New workspace screen is locked while the workspace is being created." 拒绝。返回 Promise 的回调在完成之前会让控件保持禁用,被拒绝时会以错误提示向用户显示。
|
||||
|
||||
`Combobox` 是带可搜索列表的字段,结果由你的代码提供:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user