mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-11 10:57:07 +00:00
* feat(app): rename sidebar workspaces in place on wide web layouts At lg and up on web, Rename from a sidebar row's kebab or context menu turns the title into an input instead of opening the modal. The input fills a box exactly one title line tall and its focus outline paints outside that box, so the row never moves. Enter or blur saves, Escape discards. Narrower and native layouts keep the modal; the native-wide device flows drive it. The field stays on screen, read-only, holding the submitted title until the renamed workspace arrives. The daemon answers workspace.title.set before it pushes the workspace update, and queues that update behind any in-flight ones for the workspace, so closing on the answer would show the old title. While editing, the row detaches its press, drag and context-menu handlers rather than becoming disabled: a disabled row sets aria-disabled, which makes the input inside it read as disabled. A ContextMenuTrigger with its menu off now leaves the browser's own menu alone, so copy and paste work in the field. The rename modal moves into the new workspace-rename module, and the unused sidebar-workspace-row.tsx is removed. * fix(app): keep a title renamed elsewhere when an untouched inline rename closes The unchanged check compared the field's text against the workspace's live title, while the field keeps the text it opened with. A rename from another client during an untouched edit made closing the field send the old title back. The inline session now owns its starting text for both the field and the unchanged check. * fix(app): hold a saved inline title against the title it replaced The field closed once the row's title moved off the one editing started from. When another client renamed the workspace while the field was open, that title had already moved, so a save closed the field on the daemon's answer and showed the other client's title until the saved one arrived. The session now records the title at submit and waits for it to change. * fix(app): close an inline rename that submits the row's current title Saving the title another client had just set left the row's title where it was, so the saved phase waited forever with the field read-only and the row's press and menu handlers off. A submit matching the current title now closes without a request. * fix(app): finish an inline rename when the row shows the accepted title The saved phase treated any title change as confirmation of this save. A rename from another client delivered before ours closed the field early and showed that title, and saving a title the row already had never changed it, leaving the field read-only and the row's handlers off. The daemon's response carries the accepted title; the field now closes once the row shows it. That rule also covers a submit matching the current title, so the special-case comparison for it is gone. * fix(app): close an inline rename that a later rename supersedes The daemon builds each workspace update from its latest record, so a rename committed after ours was accepted can be published in its place and ours never appears. Waiting for the accepted title then left the field read-only and the row's handlers off. The saved phase now ends on the first title change the row renders after the answer, or on the accepted title if it is already showing. The session tracks the row's title while saving so a rename delivered before the answer does not count.
200 lines
7.5 KiB
TypeScript
200 lines
7.5 KiB
TypeScript
import { writeFile, unlink } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { expect, test, type Page } from "../support/fixtures";
|
|
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
|
import { gotoAppShell } from "../support/helpers/app";
|
|
import {
|
|
expectNoBranchSwitcherInWorkspaceHeader,
|
|
expectWorkspaceBranch,
|
|
openChangesPanel,
|
|
switchBranchFromChangesPanel,
|
|
} from "../support/helpers/branch-switcher";
|
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
|
import { getServerId } from "../support/helpers/server-id";
|
|
import { readWorktreeBranchInfo } from "../support/helpers/workspace";
|
|
import {
|
|
switchWorkspaceViaSidebar,
|
|
waitForSidebarHydration,
|
|
} from "../support/helpers/workspace-ui";
|
|
|
|
// Hold the real daemon response until the test has observed the pending UI.
|
|
async function holdBranchSuggestions(page: Page) {
|
|
let requestCount = 0;
|
|
let release!: () => void;
|
|
let gate = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
|
|
const server = browser.connectToServer();
|
|
browser.onMessage((message) => {
|
|
if (typeof message === "string") {
|
|
const envelope = JSON.parse(message);
|
|
if (envelope.type === "session" && envelope.message?.type === "branch_suggestions_request")
|
|
requestCount++;
|
|
}
|
|
server.send(message);
|
|
});
|
|
server.onMessage(async (message) => {
|
|
if (typeof message === "string") {
|
|
const envelope = JSON.parse(message);
|
|
if (
|
|
envelope.type === "session" &&
|
|
envelope.message?.type === "branch_suggestions_response"
|
|
) {
|
|
await gate;
|
|
}
|
|
}
|
|
browser.send(message);
|
|
});
|
|
});
|
|
return {
|
|
release: () => release(),
|
|
holdNextResponse: () => {
|
|
gate = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
},
|
|
requestCount: () => requestCount,
|
|
};
|
|
}
|
|
|
|
async function expectInitialBranchLoading(page: Page, release: () => void): Promise<void> {
|
|
await page.getByRole("button", { name: "Current branch: main. Press to switch branch." }).click();
|
|
const picker = page.getByTestId("combobox-desktop-container");
|
|
await expect(picker.getByText("Loading...", { exact: true })).toBeVisible();
|
|
await expect(picker.getByText("No branches found.", { exact: true })).toHaveCount(0);
|
|
release();
|
|
await expect(picker.getByText("dev", { exact: true })).toBeVisible();
|
|
await page.keyboard.press("Escape");
|
|
await expect(picker).not.toBeVisible();
|
|
}
|
|
|
|
async function expectFailedRefreshRecovery(
|
|
page: Page,
|
|
repoPath: string,
|
|
responses: Awaited<ReturnType<typeof holdBranchSuggestions>>,
|
|
) {
|
|
// Expire the actual query freshness window before reopening the cached picker.
|
|
const started = Date.now();
|
|
await expect.poll(() => Date.now() - started, { timeout: 20_000 }).toBeGreaterThan(15_000);
|
|
const picker = page.getByTestId("combobox-desktop-container");
|
|
const packedRefs = path.join(repoPath, ".git", "packed-refs");
|
|
await writeFile(packedRefs, "invalid packed refs\n");
|
|
try {
|
|
await page
|
|
.getByRole("button", { name: "Current branch: dev. Press to switch branch." })
|
|
.click();
|
|
await expect(picker.getByRole("alert")).toHaveText(
|
|
"Could not load branches. Reopen the picker to try again.",
|
|
);
|
|
} finally {
|
|
await unlink(packedRefs);
|
|
}
|
|
await expect(picker.getByText("No branches found.", { exact: true })).toHaveCount(0);
|
|
responses.holdNextResponse();
|
|
const beforeRetry = responses.requestCount();
|
|
await page.keyboard.press("Escape");
|
|
await page.getByRole("button", { name: "Current branch: dev. Press to switch branch." }).click();
|
|
await expect(picker.getByRole("alert")).toHaveText("Loading...");
|
|
await expect.poll(responses.requestCount).toBe(beforeRetry + 1);
|
|
await expect(picker).toBeVisible();
|
|
responses.release();
|
|
await expect(picker.getByRole("alert")).toHaveCount(0);
|
|
await expect(picker.getByText("main", { exact: true })).toBeVisible();
|
|
await page.keyboard.press("Escape");
|
|
await expectWorkspaceBranch(page, "dev");
|
|
expect(responses.requestCount()).toBe(beforeRetry + 1);
|
|
}
|
|
|
|
async function renameWorkspaceViaSidebar(
|
|
page: Page,
|
|
input: { workspaceId: string; title: string },
|
|
): Promise<void> {
|
|
const serverId = getServerId();
|
|
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${input.workspaceId}`);
|
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
await row.hover();
|
|
|
|
const kebab = page.getByTestId(`sidebar-workspace-kebab-${serverId}:${input.workspaceId}`);
|
|
await expect(kebab).toBeVisible({ timeout: 10_000 });
|
|
await kebab.click();
|
|
|
|
const renameItem = page.getByTestId(
|
|
`sidebar-workspace-menu-rename-${serverId}:${input.workspaceId}`,
|
|
);
|
|
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
|
await renameItem.click();
|
|
|
|
// Desktop-width sidebars rename in place.
|
|
const renameInput = page.getByTestId(
|
|
`sidebar-workspace-rename-input-${serverId}:${input.workspaceId}`,
|
|
);
|
|
await expect(renameInput).toBeFocused({ timeout: 10_000 });
|
|
await renameInput.fill(input.title);
|
|
await renameInput.press("Enter");
|
|
await expect(renameInput).toHaveCount(0, { timeout: 15_000 });
|
|
}
|
|
|
|
test.describe("Branch switcher", () => {
|
|
// The first test after a spec-file switch can fail while the shared daemon
|
|
// releases stale sessions from the previous spec; one retry stabilizes it.
|
|
test.describe.configure({ retries: 1 });
|
|
|
|
test("a custom workspace title stays in the header while the diff panel switches the real branch", async ({
|
|
page,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
const serverId = getServerId();
|
|
const workspace = await seedWorkspace({
|
|
repoPrefix: "branch-coherence-",
|
|
repo: { branches: ["main", "dev"] },
|
|
});
|
|
|
|
try {
|
|
expect(workspace.workspaceName).toBe("main");
|
|
|
|
const releaseBranches = await holdBranchSuggestions(page);
|
|
await gotoAppShell(page);
|
|
await waitForSidebarHydration(page);
|
|
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: workspace.workspaceId });
|
|
|
|
const customTitle = "Payments Refactor";
|
|
await renameWorkspaceViaSidebar(page, {
|
|
workspaceId: workspace.workspaceId,
|
|
title: customTitle,
|
|
});
|
|
|
|
// The header shows the custom title verbatim (a plain static title), never a
|
|
// branch name, and the branch switcher does not live in the header.
|
|
const headerTitle = page
|
|
.getByTestId("workspace-header-title")
|
|
.filter({ visible: true })
|
|
.first();
|
|
await expect(headerTitle).toHaveText(customTitle, { timeout: 30_000 });
|
|
await expectNoBranchSwitcherInWorkspaceHeader(page);
|
|
|
|
// The diff panel's switcher tracks the real branch ("main"), not the title,
|
|
// and switching it checks out the real branch on disk.
|
|
await openChangesPanel(page);
|
|
await expectWorkspaceBranch(page, "main");
|
|
await expectInitialBranchLoading(page, releaseBranches.release);
|
|
await switchBranchFromChangesPanel(page, { from: "main", to: "dev" });
|
|
await expectWorkspaceBranch(page, "dev");
|
|
await expectFailedRefreshRecovery(page, workspace.repoPath, releaseBranches);
|
|
|
|
// The custom title is unaffected by the branch switch.
|
|
await expect(headerTitle).toHaveText(customTitle, { timeout: 30_000 });
|
|
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
(await readWorktreeBranchInfo({ worktreePath: workspace.repoPath })).currentBranch,
|
|
{ timeout: 30_000 },
|
|
)
|
|
.toBe("dev");
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
});
|