mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-15 21:04:54 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03b7bf9a92 | ||
|
|
a01098ed31 | ||
|
|
4421f0473a | ||
|
|
2a432b1e0a | ||
|
|
1c175b36b3 | ||
|
|
aa9984b899 |
@@ -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
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user