mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-11 10:57:07 +00:00
Rename sidebar workspaces in place on wide web layouts (#9280)
* 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.
This commit is contained in:
@@ -125,11 +125,13 @@ async function renameWorkspaceViaSidebar(
|
||||
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
||||
await renameItem.click();
|
||||
|
||||
const modalPrefix = `sidebar-workspace-rename-modal-${serverId}:${input.workspaceId}`;
|
||||
const renameInput = page.getByTestId(`${modalPrefix}-input`);
|
||||
await expect(renameInput).toBeVisible({ timeout: 10_000 });
|
||||
// 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 page.getByTestId(`${modalPrefix}-submit`).click();
|
||||
await renameInput.press("Enter");
|
||||
await expect(renameInput).toHaveCount(0, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { gotoAppShell } from "../support/helpers/app";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
|
||||
function workspaceRowTestId(workspaceId: string): string {
|
||||
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||
@@ -12,9 +13,13 @@ function workspaceRenameModalTestId(workspaceId: string, suffix: string): string
|
||||
return `sidebar-workspace-rename-modal-${getServerId()}:${workspaceId}-${suffix}`;
|
||||
}
|
||||
|
||||
async function openRenameModal(page: Page, workspaceId: string) {
|
||||
function workspaceRenameInputTestId(workspaceId: string): string {
|
||||
return `sidebar-workspace-rename-input-${getServerId()}:${workspaceId}`;
|
||||
}
|
||||
|
||||
async function chooseRenameFromKebab(page: Page, workspaceId: string): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`);
|
||||
const row = page.getByTestId(workspaceRowTestId(workspaceId));
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.hover();
|
||||
|
||||
@@ -25,40 +30,87 @@ async function openRenameModal(page: Page, workspaceId: string) {
|
||||
const renameItem = page.getByTestId(`sidebar-workspace-menu-rename-${serverId}:${workspaceId}`);
|
||||
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
||||
await renameItem.click();
|
||||
}
|
||||
|
||||
const input = page.getByTestId(workspaceRenameModalTestId(workspaceId, "input"));
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
return input;
|
||||
/** Lets the app render a server message the gate just forwarded. */
|
||||
async function waitForTwoFrames(page: Page): Promise<void> {
|
||||
await page.evaluate(async () => {
|
||||
const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
await nextFrame();
|
||||
await nextFrame();
|
||||
});
|
||||
}
|
||||
|
||||
type DaemonWebSocketGate = Awaited<ReturnType<typeof installDaemonWebSocketGate>>;
|
||||
|
||||
/** Sets the title from a second daemon client and waits until this page has rendered it. */
|
||||
async function renameFromAnotherClient(
|
||||
page: Page,
|
||||
gate: DaemonWebSocketGate,
|
||||
workspace: Awaited<ReturnType<typeof seedWorkspace>>,
|
||||
title: string,
|
||||
): Promise<void> {
|
||||
gate.holdNextServerMessage("workspace_update");
|
||||
await workspace.client.setWorkspaceTitle(workspace.workspaceId, title);
|
||||
await gate.waitForHeldServerMessage("workspace_update");
|
||||
gate.releaseHeldServerMessage("workspace_update");
|
||||
await waitForTwoFrames(page);
|
||||
}
|
||||
|
||||
interface Box {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
async function rowBox(page: Page, workspaceId: string): Promise<Box> {
|
||||
const box = await page.getByTestId(workspaceRowTestId(workspaceId)).boundingBox();
|
||||
if (!box) throw new Error(`sidebar row ${workspaceId} has no layout box`);
|
||||
return box;
|
||||
}
|
||||
|
||||
// In Model B the workspace title is its identity: renaming sets a custom title
|
||||
// layered over the derived branch/directory name, and reconciliation never
|
||||
// touches it. The sidebar row shows the title verbatim — no branch mutation.
|
||||
test.describe("Sidebar workspace rename", () => {
|
||||
test("renaming via kebab sets a custom title that survives reload", async ({ page }) => {
|
||||
test("renaming in place sets a custom title without moving the row, and survives reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-" });
|
||||
|
||||
try {
|
||||
expect(workspace.workspaceName).toBe("main");
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const restingBox = await rowBox(page, workspace.workspaceId);
|
||||
|
||||
const input = await openRenameModal(page, workspace.workspaceId);
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
|
||||
await expect(input).toBeFocused();
|
||||
await expect(input).toBeEditable();
|
||||
await expect(input).toHaveValue("main");
|
||||
await expect(
|
||||
page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "input")),
|
||||
).toHaveCount(0);
|
||||
expect(await rowBox(page, workspace.workspaceId)).toEqual(restingBox);
|
||||
|
||||
const customTitle = "Payments Refactor";
|
||||
await input.fill(customTitle);
|
||||
await page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "submit")).click();
|
||||
// The whole title is selected, so typing replaces it.
|
||||
await page.keyboard.type(customTitle);
|
||||
expect(await rowBox(page, workspace.workspaceId)).toEqual(restingBox);
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
// The title is shown exactly as typed — not slugified into a branch name.
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toContainText(
|
||||
customTitle,
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
expect(await rowBox(page, workspace.workspaceId)).toEqual(restingBox);
|
||||
|
||||
// The custom title is backing metadata on the workspace: a full reload
|
||||
// re-resolves the descriptor from persistence and must not lose it. This
|
||||
@@ -73,6 +125,259 @@ test.describe("Sidebar workspace rename", () => {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("Escape discards an in-place rename and clicking away saves it", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-blur-" });
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
|
||||
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
|
||||
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
await expect(input).toBeFocused();
|
||||
await page.keyboard.type("Discarded title");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(input).toHaveCount(0);
|
||||
await expect(row).toContainText("main");
|
||||
await expect(row).not.toContainText("Discarded title");
|
||||
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
await expect(input).toBeFocused();
|
||||
// Clicking inside the field places the caret; it does not press the row underneath.
|
||||
await input.click();
|
||||
await expect(input).toBeFocused();
|
||||
await input.fill("Saved on blur");
|
||||
// Click empty space in the main pane, away from any control.
|
||||
await page.mouse.click(900, 700);
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(row).toContainText("Saved on blur");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("the submitted title stays on screen until the renamed workspace arrives", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-hold-" });
|
||||
|
||||
try {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
|
||||
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
|
||||
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
await expect(input).toBeFocused();
|
||||
await page.keyboard.type("Held title");
|
||||
// The daemon answers the rename before it pushes the renamed workspace.
|
||||
gate.holdNextServerMessage("workspace_update");
|
||||
await page.keyboard.press("Enter");
|
||||
await gate.waitForHeldServerMessage("workspace_update");
|
||||
|
||||
await expect(input).toHaveValue("Held title");
|
||||
await expect(input).not.toBeEditable();
|
||||
await expect(row).not.toContainText("main");
|
||||
|
||||
gate.releaseHeldServerMessage("workspace_update");
|
||||
await expect(input).toHaveCount(0);
|
||||
await expect(row).toContainText("Held title");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("an untouched in-place rename keeps a title set elsewhere while it was open", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-external-" });
|
||||
|
||||
try {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
|
||||
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
|
||||
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
await expect(input).toBeFocused();
|
||||
await expect(input).toHaveValue("main");
|
||||
|
||||
await renameFromAnotherClient(page, gate, workspace, "External title");
|
||||
|
||||
await page.mouse.click(900, 700);
|
||||
await expect(input).toHaveCount(0);
|
||||
await expect(row).toContainText("External title");
|
||||
expect(gate.getClientRequestCount("workspace.title.set.request")).toBe(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("saving after a rename elsewhere holds the submitted title until it arrives", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-external-save-" });
|
||||
|
||||
try {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
|
||||
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
|
||||
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
await expect(input).toBeFocused();
|
||||
await renameFromAnotherClient(page, gate, workspace, "External title");
|
||||
|
||||
await input.fill("Submitted title");
|
||||
gate.holdNextServerMessage("workspace_update");
|
||||
await page.keyboard.press("Enter");
|
||||
await gate.waitForHeldServerMessage("workspace_update");
|
||||
|
||||
await expect(input).toHaveValue("Submitted title");
|
||||
await expect(row).not.toContainText("External title");
|
||||
|
||||
gate.releaseHeldServerMessage("workspace_update");
|
||||
await expect(input).toHaveCount(0);
|
||||
await expect(row).toContainText("Submitted title");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("submitting the title another client just set closes the field", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-external-same-" });
|
||||
|
||||
try {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
|
||||
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
|
||||
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
await expect(input).toBeFocused();
|
||||
await renameFromAnotherClient(page, gate, workspace, "External title");
|
||||
|
||||
// The daemon accepts it, but the title does not change, so no renamed workspace follows.
|
||||
await input.fill("External title");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(input).toHaveCount(0);
|
||||
await expect(row).toContainText("External title");
|
||||
|
||||
// The row is a row again: its menu opens and renames.
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
await expect(input).toBeFocused();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a rename elsewhere delivered while saving does not close the field", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-external-pending-" });
|
||||
|
||||
try {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
|
||||
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
|
||||
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
await expect(input).toBeFocused();
|
||||
await input.fill("Submitted title");
|
||||
|
||||
// Another client's rename lands just before ours. The page receives, in daemon order: that
|
||||
// rename, our response, our renamed workspace. Each is held until the previous is rendered.
|
||||
gate.holdNextServerMessage("workspace_update");
|
||||
await workspace.client.setWorkspaceTitle(workspace.workspaceId, "External title");
|
||||
await gate.waitForHeldServerMessage("workspace_update");
|
||||
gate.holdNextServerMessage("workspace.title.set.response");
|
||||
gate.holdNextServerMessage("workspace_update");
|
||||
await page.keyboard.press("Enter");
|
||||
await gate.waitForHeldServerMessage("workspace.title.set.response");
|
||||
|
||||
// Releases the oldest held update: the other client's. Ours stays held behind it.
|
||||
gate.releaseHeldServerMessage("workspace_update");
|
||||
await gate.waitForHeldServerMessage("workspace_update");
|
||||
await waitForTwoFrames(page);
|
||||
await expect(input).toHaveValue("Submitted title");
|
||||
|
||||
gate.releaseHeldServerMessage("workspace.title.set.response");
|
||||
await waitForTwoFrames(page);
|
||||
await expect(input).toHaveValue("Submitted title");
|
||||
await expect(row).not.toContainText("External title");
|
||||
|
||||
gate.releaseHeldServerMessage("workspace_update");
|
||||
await expect(input).toHaveCount(0);
|
||||
await expect(row).toContainText("Submitted title");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a later rename that replaces the saved title closes the field", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-superseded-" });
|
||||
|
||||
try {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
|
||||
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
|
||||
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
await expect(input).toBeFocused();
|
||||
await input.fill("Submitted title");
|
||||
|
||||
// The daemon builds each update from its latest record, so a rename that lands after ours
|
||||
// is accepted can be published in its place. Our update stays held: the page never sees it.
|
||||
gate.holdNextServerMessage("workspace.title.set.response");
|
||||
gate.holdNextServerMessage("workspace_update");
|
||||
await page.keyboard.press("Enter");
|
||||
await gate.waitForHeldServerMessage("workspace.title.set.response");
|
||||
await gate.waitForHeldServerMessage("workspace_update");
|
||||
gate.releaseHeldServerMessage("workspace.title.set.response");
|
||||
await waitForTwoFrames(page);
|
||||
await expect(input).toHaveValue("Submitted title");
|
||||
|
||||
await workspace.client.setWorkspaceTitle(workspace.workspaceId, "Later title");
|
||||
await expect(input).toHaveCount(0);
|
||||
await expect(row).toContainText("Later title");
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
await expect(input).toBeFocused();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("below the lg breakpoint renaming opens the modal", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-modal-" });
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 800, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
|
||||
const input = page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "input"));
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId))).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await input.fill("Modal Title");
|
||||
await page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "submit")).click();
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toContainText(
|
||||
"Modal Title",
|
||||
);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function toggleBackgroundWorkspaces(page: Page): Promise<void> {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
useEffect,
|
||||
useRef,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
type MutableRefObject,
|
||||
type Ref,
|
||||
type ComponentProps,
|
||||
@@ -35,7 +36,7 @@ import type { Theme } from "@/styles/theme";
|
||||
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
|
||||
import { type GestureType } from "react-native-gesture-handler";
|
||||
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
|
||||
import { useSidebarWorkspaceRename } from "@/workspace-rename";
|
||||
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
|
||||
import { ExternalLink, Settings, MoreVertical, Plus, Trash2 } from "lucide-react-native";
|
||||
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||
@@ -287,6 +288,8 @@ interface WorkspaceRowInnerProps {
|
||||
onCopyBranchName?: () => void;
|
||||
onCopyPath?: () => void;
|
||||
onRename?: () => void;
|
||||
/** Present while the row is renamed in place: the row stops pressing, dragging and hovering. */
|
||||
titleEditor?: ReactNode;
|
||||
onMarkAsRead?: () => void;
|
||||
onMarkAsUnread?: () => void;
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
@@ -1075,6 +1078,7 @@ function WorkspaceRowInner({
|
||||
onCopyBranchName,
|
||||
onCopyPath,
|
||||
onRename,
|
||||
titleEditor = null,
|
||||
onMarkAsRead,
|
||||
onMarkAsUnread,
|
||||
archiveShortcutKeys,
|
||||
@@ -1116,9 +1120,14 @@ function WorkspaceRowInner({
|
||||
}, [interaction]);
|
||||
|
||||
const accessibilityState = useMemo(() => ({ selected }), [selected]);
|
||||
const isEditingTitle = titleEditor !== null;
|
||||
|
||||
return (
|
||||
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
|
||||
<SidebarWorkspaceRowFrame
|
||||
workspace={workspace}
|
||||
isDragging={isDragging}
|
||||
isEditingTitle={isEditingTitle}
|
||||
>
|
||||
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
|
||||
const isDesktop = !isTouchPlatform;
|
||||
const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null;
|
||||
@@ -1132,7 +1141,7 @@ function WorkspaceRowInner({
|
||||
return (
|
||||
<View
|
||||
{...dragAttributes}
|
||||
{...dragHandleProps?.listeners}
|
||||
{...(isEditingTitle ? null : dragHandleProps?.listeners)}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
|
||||
style={styles.workspaceRowContainer}
|
||||
{...hoverHandlers}
|
||||
@@ -1158,15 +1167,16 @@ function WorkspaceRowInner({
|
||||
onTogglePin={onTogglePin}
|
||||
openInFileManagerPath={workspace.workspaceDirectory}
|
||||
disabled={isArchiving}
|
||||
enabled={!isEditingTitle}
|
||||
aria-selected={selected}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
style={workspaceRowStyle}
|
||||
highlightStyle={styles.workspaceRowPressed}
|
||||
onPressIn={handleWorkspacePressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
onPressOut={handleWorkspacePressOut}
|
||||
onPress={handlePress}
|
||||
onPressIn={isEditingTitle ? undefined : handleWorkspacePressIn}
|
||||
onTouchMove={isEditingTitle ? undefined : interaction.handleTouchMove}
|
||||
onPressOut={isEditingTitle ? undefined : handleWorkspacePressOut}
|
||||
onPress={isEditingTitle ? undefined : handlePress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
<SidebarWorkspaceRowContent
|
||||
@@ -1182,6 +1192,7 @@ function WorkspaceRowInner({
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
titleEditor={titleEditor}
|
||||
>
|
||||
<WorkspaceRowRightGroup
|
||||
workspace={workspace}
|
||||
@@ -1251,7 +1262,7 @@ function WorkspaceRowWithMenu({
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
|
||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||
const rename = useSidebarWorkspaceRename(workspace);
|
||||
const isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
|
||||
const redirectAfterArchive = useCallback(() => {
|
||||
redirectIfArchivingActiveWorkspace({
|
||||
@@ -1287,14 +1298,6 @@ function WorkspaceRowWithMenu({
|
||||
clipboard.copyBranchName(workspace);
|
||||
}, [clipboard, workspace]);
|
||||
|
||||
const handleOpenRename = useCallback(() => {
|
||||
setIsRenameOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCloseRename = useCallback(() => {
|
||||
setIsRenameOpen(false);
|
||||
}, []);
|
||||
|
||||
const isPinned = workspace.pinnedAt != null;
|
||||
const handleTogglePin = useCallback(() => {
|
||||
onToggleWorkspacePin(workspace);
|
||||
@@ -1352,7 +1355,8 @@ function WorkspaceRowWithMenu({
|
||||
onArchive={handleArchive}
|
||||
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
|
||||
onCopyPath={handleCopyPath}
|
||||
onRename={handleOpenRename}
|
||||
onRename={rename.begin}
|
||||
titleEditor={rename.titleEditor}
|
||||
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
|
||||
onMarkAsUnread={canMarkUnread ? handleMarkAsUnread : undefined}
|
||||
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
||||
@@ -1360,12 +1364,7 @@ function WorkspaceRowWithMenu({
|
||||
onTogglePin={onTogglePin}
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
/>
|
||||
<WorkspaceRenameModal
|
||||
visible={isRenameOpen}
|
||||
workspace={workspace}
|
||||
onClose={handleCloseRename}
|
||||
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
|
||||
/>
|
||||
{rename.modal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
CircleX,
|
||||
} from "lucide-react-native";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
|
||||
import { useSidebarWorkspaceRename } from "@/workspace-rename";
|
||||
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
||||
@@ -637,7 +637,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
|
||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||
const rename = useSidebarWorkspaceRename(workspace);
|
||||
const isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
|
||||
|
||||
const redirectAfterArchive = useCallback(() => {
|
||||
@@ -674,8 +674,6 @@ function StatusWorkspaceRowWithMenu({
|
||||
clipboard.copyBranchName(workspace);
|
||||
}, [clipboard, workspace]);
|
||||
|
||||
const handleOpenRename = useCallback(() => setIsRenameOpen(true), []);
|
||||
const handleCloseRename = useCallback(() => setIsRenameOpen(false), []);
|
||||
const isPinned = workspace.pinnedAt != null;
|
||||
const handleTogglePin = useCallback(() => {
|
||||
onToggleWorkspacePin(workspace);
|
||||
@@ -728,7 +726,8 @@ function StatusWorkspaceRowWithMenu({
|
||||
onArchive={handleArchive}
|
||||
onCopyBranchName={workspace.projectKind === "git" ? handleCopyBranchName : undefined}
|
||||
onCopyPath={handleCopyPath}
|
||||
onRename={handleOpenRename}
|
||||
onRename={rename.begin}
|
||||
titleEditor={rename.titleEditor}
|
||||
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
|
||||
onMarkAsUnread={canMarkUnread ? handleMarkAsUnread : undefined}
|
||||
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
||||
@@ -740,12 +739,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
<WorkspaceRenameModal
|
||||
visible={isRenameOpen}
|
||||
workspace={workspace}
|
||||
onClose={handleCloseRename}
|
||||
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
|
||||
/>
|
||||
{rename.modal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -767,6 +761,8 @@ interface StatusWorkspaceRowInnerProps {
|
||||
onCopyBranchName?: () => void;
|
||||
onCopyPath?: () => void;
|
||||
onRename?: () => void;
|
||||
/** Present while the row is renamed in place: the row stops pressing, dragging and hovering. */
|
||||
titleEditor?: ReactNode;
|
||||
onMarkAsRead?: () => void;
|
||||
onMarkAsUnread?: () => void;
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
@@ -814,6 +810,7 @@ function StatusWorkspaceRowInnerContent({
|
||||
onCopyBranchName,
|
||||
onCopyPath,
|
||||
onRename,
|
||||
titleEditor = null,
|
||||
onMarkAsRead,
|
||||
onMarkAsUnread,
|
||||
archiveShortcutKeys,
|
||||
@@ -842,6 +839,7 @@ function StatusWorkspaceRowInnerContent({
|
||||
const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null;
|
||||
|
||||
const accessibilityState = useMemo(() => ({ selected }), [selected]);
|
||||
const isEditingTitle = titleEditor !== null;
|
||||
const didLongPressRef = dragInteraction?.didLongPressRef;
|
||||
const startDragPress = dragInteraction?.handlePressIn;
|
||||
const moveDragPress = dragInteraction?.handleTouchMove;
|
||||
@@ -866,7 +864,11 @@ function StatusWorkspaceRowInnerContent({
|
||||
}, [endDragPress]);
|
||||
|
||||
return (
|
||||
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
|
||||
<SidebarWorkspaceRowFrame
|
||||
workspace={workspace}
|
||||
isDragging={isDragging}
|
||||
isEditingTitle={isEditingTitle}
|
||||
>
|
||||
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
|
||||
const showShortcut = showShortcutBadge && shortcutNumber !== null;
|
||||
const {
|
||||
@@ -895,7 +897,7 @@ function StatusWorkspaceRowInnerContent({
|
||||
return (
|
||||
<View
|
||||
{...dragAttributes}
|
||||
{...dragHandleProps?.listeners}
|
||||
{...(isEditingTitle ? null : dragHandleProps?.listeners)}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
|
||||
style={styles.workspaceRowContainer}
|
||||
{...hoverHandlers}
|
||||
@@ -922,14 +924,15 @@ function StatusWorkspaceRowInnerContent({
|
||||
onTogglePin={onTogglePin}
|
||||
openInFileManagerPath={workspace.workspaceDirectory}
|
||||
disabled={isArchiving}
|
||||
enabled={!isEditingTitle}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
style={workspaceRowStyle}
|
||||
highlightStyle={styles.workspaceRowPressed}
|
||||
onPressIn={handlePressIn}
|
||||
onTouchMove={moveDragPress}
|
||||
onPressOut={handlePressOut}
|
||||
onPress={handlePress}
|
||||
onPressIn={isEditingTitle ? undefined : handlePressIn}
|
||||
onTouchMove={isEditingTitle ? undefined : moveDragPress}
|
||||
onPressOut={isEditingTitle ? undefined : handlePressOut}
|
||||
onPress={isEditingTitle ? undefined : handlePress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
<SidebarWorkspaceRowContent
|
||||
@@ -944,6 +947,7 @@ function StatusWorkspaceRowInnerContent({
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
titleEditor={titleEditor}
|
||||
>
|
||||
{renderSlot ? (
|
||||
<StatusWorkspaceActionSlot
|
||||
|
||||
@@ -46,10 +46,13 @@ const ThemedCircleAlert = withUnistyles(CircleAlert);
|
||||
export function SidebarWorkspaceRowFrame({
|
||||
workspace,
|
||||
isDragging = false,
|
||||
isEditingTitle = false,
|
||||
children,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
isDragging?: boolean;
|
||||
/** Hover would fade the kebab in over the title being edited and open the hover card. */
|
||||
isEditingTitle?: boolean;
|
||||
children: (input: {
|
||||
isHovered: boolean;
|
||||
contextMenuOpen: boolean;
|
||||
@@ -77,10 +80,10 @@ export function SidebarWorkspaceRowFrame({
|
||||
workspace={workspace}
|
||||
prHint={workspace.prHint}
|
||||
isDragging={isDragging}
|
||||
disabled={contextMenuOpen}
|
||||
disabled={contextMenuOpen || isEditingTitle}
|
||||
>
|
||||
{children({
|
||||
isHovered: isHovered && !contextMenuOpen && !isDragging,
|
||||
isHovered: isHovered && !contextMenuOpen && !isDragging && !isEditingTitle,
|
||||
contextMenuOpen,
|
||||
onContextMenuOpenChange: handleContextMenuOpenChange,
|
||||
hoverHandlers,
|
||||
@@ -102,6 +105,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
||||
shortcutNumber = null,
|
||||
showShortcutBadge = false,
|
||||
reserveIdleStatusIndicatorSpace = true,
|
||||
titleEditor = null,
|
||||
children,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
@@ -119,6 +123,8 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
||||
showShortcutBadge?: boolean;
|
||||
/** Keep the empty leading slot when the workspace has no active status. */
|
||||
reserveIdleStatusIndicatorSpace?: boolean;
|
||||
/** Takes the title's place, in a box exactly one title line tall, while renaming in place. */
|
||||
titleEditor?: ReactNode;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const {
|
||||
@@ -160,13 +166,17 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
||||
)}
|
||||
<View style={styles.workspaceContentColumn}>
|
||||
<View style={styles.workspaceTitleRow}>
|
||||
<Text
|
||||
style={workspaceBranchTextStyle}
|
||||
numberOfLines={1}
|
||||
testID={`sidebar-workspace-title-${workspace.workspaceKey}`}
|
||||
>
|
||||
{workspaceLabel}
|
||||
</Text>
|
||||
{titleEditor ? (
|
||||
<View style={styles.workspaceTitleEditorSlot}>{titleEditor}</View>
|
||||
) : (
|
||||
<Text
|
||||
style={workspaceBranchTextStyle}
|
||||
numberOfLines={1}
|
||||
testID={`sidebar-workspace-title-${workspace.workspaceKey}`}
|
||||
>
|
||||
{workspaceLabel}
|
||||
</Text>
|
||||
)}
|
||||
<View style={sidebarWorkspaceRowStyles.rowRight}>{children}</View>
|
||||
</View>
|
||||
<WorkspaceMetaRow
|
||||
@@ -557,6 +567,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
workspaceTitleEditorSlot: {
|
||||
height: TITLE_LINE_HEIGHT,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
workspaceBranchTextCreating: {
|
||||
opacity: 0.92,
|
||||
},
|
||||
|
||||
@@ -1,524 +0,0 @@
|
||||
import { memo, useCallback, useMemo, useState, type Ref } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, Text, type GestureResponderEvent } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { HostBadgeModel } from "@/hosts/appearance";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { density } from "@/styles/density";
|
||||
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
|
||||
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
|
||||
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import { useWorkspaceReadState } from "@/hooks/use-workspace-read-state";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import { isNative as platformIsNative } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
||||
import {
|
||||
SidebarWorkspaceContextMenu,
|
||||
SidebarWorkspaceMenu,
|
||||
} from "@/components/sidebar/sidebar-workspace-menu";
|
||||
import {
|
||||
SidebarWorkspaceRowFrame,
|
||||
SidebarWorkspaceRowContent,
|
||||
resolveTrailingActionVisibility,
|
||||
SidebarWorkspaceTrailingActionBase,
|
||||
SidebarWorkspaceTrailingActionOverlay,
|
||||
SidebarWorkspaceTrailingActionSlot,
|
||||
} from "@/components/sidebar/sidebar-workspace-row-content";
|
||||
import { useOpenKebabMenuVisibility } from "@/components/sidebar/use-open-kebab-menu-visibility";
|
||||
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
|
||||
import { selectWorkspaceServiceSummary } from "@/components/sidebar/workspace-meta-row";
|
||||
import {
|
||||
SidebarWorkspaceTrailingContent,
|
||||
useSidebarWorkspaceTrailing,
|
||||
type SidebarWorkspaceTrailing,
|
||||
} from "@/components/sidebar/workspace-trailing";
|
||||
|
||||
interface SidebarWorkspaceRowProps {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
selected: boolean;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
canCopyBranchName: boolean;
|
||||
onPress: () => void;
|
||||
/** The host pill after the title. Absent → the sidebar spans one host, or this one is hidden. */
|
||||
hostBadge?: HostBadgeModel | null;
|
||||
/** Project grouping only: shows a transient "creating" affordance. */
|
||||
isCreating?: boolean;
|
||||
/** Project grouping only: drag-to-reorder wiring. Absent → not draggable. */
|
||||
drag?: () => void;
|
||||
isDragging?: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
}
|
||||
|
||||
export function SidebarWorkspaceRow({
|
||||
workspace,
|
||||
selected,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
canCopyBranchName,
|
||||
onPress,
|
||||
hostBadge,
|
||||
isCreating = false,
|
||||
drag,
|
||||
isDragging = false,
|
||||
dragHandleProps,
|
||||
}: SidebarWorkspaceRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
|
||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||
const isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
|
||||
|
||||
const redirectAfterArchive = useCallback(() => {
|
||||
redirectIfArchivingActiveWorkspace({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
activeWorkspaceSelection: selected
|
||||
? { serverId: workspace.serverId, workspaceId: workspace.workspaceId }
|
||||
: null,
|
||||
});
|
||||
}, [selected, workspace]);
|
||||
|
||||
const archiveController = useWorkspaceArchive({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
name: workspace.name,
|
||||
...toWorktreeArchiveRisk(workspace),
|
||||
onArchiveStarted: redirectAfterArchive,
|
||||
onSetHiding: setIsHidingWorkspace,
|
||||
});
|
||||
|
||||
const handleArchive = useCallback(() => {
|
||||
if (isArchiving) {
|
||||
return;
|
||||
}
|
||||
archiveController.archive();
|
||||
}, [archiveController, isArchiving]);
|
||||
|
||||
const clipboard = useWorkspaceClipboardActions();
|
||||
const handleCopyPath = useCallback(() => {
|
||||
clipboard.copyPath(workspace);
|
||||
}, [clipboard, workspace]);
|
||||
|
||||
const handleCopyBranchName = useCallback(() => {
|
||||
clipboard.copyBranchName(workspace);
|
||||
}, [clipboard, workspace]);
|
||||
|
||||
const handleOpenRename = useCallback(() => {
|
||||
setIsRenameOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCloseRename = useCallback(() => {
|
||||
setIsRenameOpen(false);
|
||||
}, []);
|
||||
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
|
||||
const { hasClearableAttention, canMarkUnread, clearAttention, markUnread } =
|
||||
useWorkspaceReadState({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
const handleMarkAsRead = useCallback(() => {
|
||||
void clearAttention().catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read");
|
||||
});
|
||||
}, [clearAttention, toast]);
|
||||
const handleMarkAsUnread = useCallback(() => {
|
||||
void markUnread().catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to mark workspace as unread");
|
||||
});
|
||||
}, [markUnread, toast]);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: `workspace-archive-${workspace.workspaceKey}`,
|
||||
actions: ["workspace.archive"],
|
||||
enabled: selected && !isArchiving,
|
||||
priority: 0,
|
||||
handle: () => {
|
||||
handleArchive();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorkspaceRowBody
|
||||
workspace={workspace}
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
hostBadge={hostBadge}
|
||||
isCreating={isCreating}
|
||||
isArchiving={isArchiving}
|
||||
onPress={onPress}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
archiveLabel={t("sidebar.workspace.actions.archive")}
|
||||
archiveStatus={isArchiving ? "pending" : "idle"}
|
||||
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
|
||||
onArchive={handleArchive}
|
||||
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
|
||||
onCopyPath={handleCopyPath}
|
||||
onRename={handleOpenRename}
|
||||
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
|
||||
onMarkAsUnread={canMarkUnread ? handleMarkAsUnread : undefined}
|
||||
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
||||
/>
|
||||
<WorkspaceRenameModal
|
||||
visible={isRenameOpen}
|
||||
workspace={workspace}
|
||||
onClose={handleCloseRename}
|
||||
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface WorkspaceRowBodyProps {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
selected: boolean;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
hostBadge?: HostBadgeModel | null;
|
||||
isCreating: boolean;
|
||||
isArchiving: boolean;
|
||||
onPress: () => void;
|
||||
drag?: () => void;
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
archiveLabel?: string;
|
||||
archiveStatus?: "idle" | "pending" | "success";
|
||||
archivePendingLabel?: string;
|
||||
onArchive?: () => void;
|
||||
onCopyBranchName?: () => void;
|
||||
onCopyPath?: () => void;
|
||||
onRename?: () => void;
|
||||
onMarkAsRead?: () => void;
|
||||
onMarkAsUnread?: () => void;
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
}
|
||||
|
||||
function WorkspaceRowBody({
|
||||
workspace,
|
||||
selected,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
hostBadge,
|
||||
isCreating,
|
||||
isArchiving,
|
||||
onPress,
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
archiveLabel,
|
||||
archiveStatus = "idle",
|
||||
archivePendingLabel,
|
||||
onArchive,
|
||||
onCopyBranchName,
|
||||
onCopyPath,
|
||||
onRename,
|
||||
onMarkAsRead,
|
||||
onMarkAsUnread,
|
||||
archiveShortcutKeys,
|
||||
}: WorkspaceRowBodyProps) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const isTouchPlatform = platformIsNative || isCompact;
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const trailing = useSidebarWorkspaceTrailing();
|
||||
const draggable = Boolean(drag);
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag,
|
||||
menuController: null,
|
||||
});
|
||||
const {
|
||||
role: _dragRole,
|
||||
tabIndex: _dragTabIndex,
|
||||
"aria-roledescription": _dragRoleDescription,
|
||||
...dragAttributes
|
||||
} = dragHandleProps?.attributes ?? {};
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (interaction.didLongPressRef.current) {
|
||||
interaction.didLongPressRef.current = false;
|
||||
return;
|
||||
}
|
||||
onPress();
|
||||
}, [interaction.didLongPressRef, onPress]);
|
||||
const handleWorkspacePressIn = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
setIsPressed(true);
|
||||
if (draggable) interaction.handlePressIn(event);
|
||||
},
|
||||
[draggable, interaction],
|
||||
);
|
||||
const handleWorkspacePressOut = useCallback(() => {
|
||||
setIsPressed(false);
|
||||
if (draggable) interaction.handlePressOut();
|
||||
}, [draggable, interaction]);
|
||||
|
||||
const accessibilityState = useMemo(() => ({ selected }), [selected]);
|
||||
|
||||
return (
|
||||
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
|
||||
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
|
||||
const isDesktop = !isTouchPlatform;
|
||||
const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null;
|
||||
const workspaceRowStyle = getWorkspaceRowStyle({
|
||||
isDragging,
|
||||
isPressed,
|
||||
selected,
|
||||
isHovered,
|
||||
});
|
||||
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
|
||||
return (
|
||||
<View
|
||||
{...(draggable ? dragAttributes : {})}
|
||||
{...(draggable ? dragHandleProps?.listeners : {})}
|
||||
ref={
|
||||
draggable ? (dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>) : undefined
|
||||
}
|
||||
style={styles.workspaceRowContainer}
|
||||
{...hoverHandlers}
|
||||
>
|
||||
<SidebarWorkspaceContextMenu
|
||||
contextMenuOpen={contextMenuOpen}
|
||||
onContextMenuOpenChange={onContextMenuOpenChange}
|
||||
workspace={workspace}
|
||||
hostBadgeLabel={hostBadge?.label}
|
||||
serviceSummary={serviceSummary}
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onMarkAsUnread={onMarkAsUnread}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
archivePendingLabel={archivePendingLabel}
|
||||
archiveShortcutKeys={archiveShortcutKeys}
|
||||
openInFileManagerPath={workspace.workspaceDirectory}
|
||||
disabled={isArchiving}
|
||||
aria-selected={selected}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
style={workspaceRowStyle}
|
||||
highlightStyle={styles.workspaceRowPressed}
|
||||
onPressIn={handleWorkspacePressIn}
|
||||
onTouchMove={draggable ? interaction.handleTouchMove : undefined}
|
||||
onPressOut={handleWorkspacePressOut}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
<SidebarWorkspaceRowContent
|
||||
workspace={workspace}
|
||||
hostBadge={hostBadge}
|
||||
serviceSummary={serviceSummary}
|
||||
backdrop={backdrop}
|
||||
isHovered={isHovered}
|
||||
isLoading={isArchiving || isCreating}
|
||||
isCreating={isCreating}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
>
|
||||
<WorkspaceRowTrailingActions
|
||||
workspace={workspace}
|
||||
backdrop={backdrop}
|
||||
trailing={trailing}
|
||||
isHovered={isHovered}
|
||||
isTouchPlatform={isTouchPlatform}
|
||||
isCreating={isCreating}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
shortcutNumber={shortcutNumber}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
archivePendingLabel={archivePendingLabel}
|
||||
archiveShortcutKeys={archiveShortcutKeys}
|
||||
onArchive={onArchive}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onCopyPath={onCopyPath}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onMarkAsUnread={onMarkAsUnread}
|
||||
/>
|
||||
</SidebarWorkspaceRowContent>
|
||||
</SidebarWorkspaceContextMenu>
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
</SidebarWorkspaceRowFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceRowTrailingActions({
|
||||
workspace,
|
||||
backdrop,
|
||||
trailing,
|
||||
isHovered,
|
||||
isTouchPlatform,
|
||||
isCreating,
|
||||
showShortcutBadge,
|
||||
shortcutNumber,
|
||||
archiveLabel,
|
||||
archiveStatus,
|
||||
archivePendingLabel,
|
||||
archiveShortcutKeys,
|
||||
onArchive,
|
||||
onMarkAsRead,
|
||||
onMarkAsUnread,
|
||||
onCopyBranchName,
|
||||
onCopyPath,
|
||||
onRename,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
backdrop: SidebarSurfaceBackdrop;
|
||||
trailing: SidebarWorkspaceTrailing;
|
||||
isHovered: boolean;
|
||||
isTouchPlatform: boolean;
|
||||
isCreating: boolean;
|
||||
showShortcutBadge: boolean;
|
||||
shortcutNumber: number | null;
|
||||
archiveLabel?: string;
|
||||
archiveStatus?: "idle" | "pending" | "success";
|
||||
archivePendingLabel?: string;
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
onArchive?: () => void;
|
||||
onMarkAsRead?: () => void;
|
||||
onMarkAsUnread?: () => void;
|
||||
onCopyBranchName?: () => void;
|
||||
onCopyPath?: () => void;
|
||||
onRename?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const showShortcut = showShortcutBadge && shortcutNumber !== null;
|
||||
const {
|
||||
trailingPresentation,
|
||||
showKebab: showKebabInSlot,
|
||||
showArchiveButton,
|
||||
showScrim,
|
||||
renderSlot,
|
||||
reserveSlotWidth,
|
||||
} = resolveTrailingActionVisibility({
|
||||
workspace,
|
||||
trailing,
|
||||
hasArchiveAction: Boolean(onArchive),
|
||||
isHovered,
|
||||
isTouchPlatform,
|
||||
showShortcut,
|
||||
});
|
||||
const kebab = useOpenKebabMenuVisibility(showKebabInSlot);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isCreating ? (
|
||||
<Text style={styles.workspaceCreatingText}>{t("sidebar.workspace.status.creating")}</Text>
|
||||
) : null}
|
||||
{renderSlot ? (
|
||||
<SidebarWorkspaceTrailingActionSlot reserveWidth={reserveSlotWidth}>
|
||||
<SidebarWorkspaceTrailingActionBase presentation={trailingPresentation}>
|
||||
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
|
||||
</SidebarWorkspaceTrailingActionBase>
|
||||
<SidebarWorkspaceTrailingActionOverlay
|
||||
visible={kebab.showKebab}
|
||||
scrimBackdrop={showScrim ? backdrop : undefined}
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
onArchiveButtonPress={showArchiveButton ? onArchive : undefined}
|
||||
>
|
||||
{onArchive ? (
|
||||
<SidebarWorkspaceMenu
|
||||
{...kebab.menuProps}
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
serverId={workspace.serverId}
|
||||
workspaceId={workspace.workspaceId}
|
||||
workspaceLabels={workspace.labels}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onMarkAsUnread={onMarkAsUnread}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
archivePendingLabel={archivePendingLabel}
|
||||
archiveShortcutKeys={archiveShortcutKeys}
|
||||
/>
|
||||
) : null}
|
||||
</SidebarWorkspaceTrailingActionOverlay>
|
||||
</SidebarWorkspaceTrailingActionSlot>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getWorkspaceRowStyle({
|
||||
isDragging,
|
||||
isPressed,
|
||||
selected,
|
||||
isHovered,
|
||||
}: {
|
||||
isDragging: boolean;
|
||||
isPressed: boolean;
|
||||
selected: boolean;
|
||||
isHovered: boolean;
|
||||
}) {
|
||||
return [
|
||||
styles.workspaceRow,
|
||||
isHovered && styles.workspaceRowHovered,
|
||||
selected && styles.sidebarRowSelected,
|
||||
isDragging && styles.workspaceRowDragging,
|
||||
isPressed && styles.workspaceRowPressed,
|
||||
];
|
||||
}
|
||||
|
||||
export const MemoSidebarWorkspaceRow = memo(SidebarWorkspaceRow);
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
workspaceRowContainer: {
|
||||
position: "relative",
|
||||
},
|
||||
workspaceRow: {
|
||||
minHeight: density.rowHeight,
|
||||
marginBottom: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingLeft: theme.spacing[2],
|
||||
paddingRight: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
flexDirection: "column",
|
||||
alignItems: "stretch",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[1],
|
||||
userSelect: "none",
|
||||
},
|
||||
workspaceRowHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
workspaceRowPressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
workspaceRowDragging: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
transform: [{ scale: 1.02 }],
|
||||
zIndex: 3,
|
||||
...theme.shadow.md,
|
||||
},
|
||||
sidebarRowSelected: {
|
||||
backgroundColor: theme.colors.surfaceSidebarSelected,
|
||||
},
|
||||
workspaceCreatingText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
flexShrink: 0,
|
||||
},
|
||||
}));
|
||||
@@ -165,7 +165,9 @@ export function ContextMenuTrigger({
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
(event: unknown) => {
|
||||
if (isNative) return;
|
||||
// A trigger with no menu of its own leaves the browser's menu alone, e.g. copy and paste
|
||||
// in a field rendered inside it.
|
||||
if (isNative || !shouldEnableOnThisPlatform) return;
|
||||
if (typeof event === "object" && event !== null) {
|
||||
const preventDefault = Reflect.get(event, "preventDefault");
|
||||
const stopPropagation = Reflect.get(event, "stopPropagation");
|
||||
@@ -175,7 +177,7 @@ export function ContextMenuTrigger({
|
||||
onContextMenu?.(event);
|
||||
openAtEvent(event);
|
||||
},
|
||||
[onContextMenu, openAtEvent],
|
||||
[onContextMenu, openAtEvent, shouldEnableOnThisPlatform],
|
||||
);
|
||||
|
||||
const resolveDynamicStyle = useCallback(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
|
||||
import { WorkspaceRenameModal } from "@/workspace-rename";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
|
||||
// The subset of a workspace the rename dialog needs. Narrower than SidebarWorkspaceEntry so the
|
||||
// command center can build one from the active route selection without a sidebar row.
|
||||
export interface RenamableWorkspace {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
title?: string | null;
|
||||
}
|
||||
|
||||
export interface WorkspaceRenameModalProps {
|
||||
visible: boolean;
|
||||
workspace: RenamableWorkspace;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* Prefix for the modal's testIDs. The sidebar callers must keep passing
|
||||
* `sidebar-workspace-rename-modal-${workspaceKey}` — e2e/browser/sidebar-workspace-rename.spec.ts
|
||||
* builds its locators from exactly that prefix.
|
||||
*/
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the setWorkspaceTitle mutation and every rename string, so a caller only tracks its own
|
||||
* open/closed boolean. Errors surface inline inside AdaptiveRenameModal; the dialog stays open.
|
||||
*/
|
||||
export function WorkspaceRenameModal({
|
||||
visible,
|
||||
workspace,
|
||||
onClose,
|
||||
testID,
|
||||
}: WorkspaceRenameModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: async (title: string) => {
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||
if (!client) {
|
||||
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
|
||||
}
|
||||
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
|
||||
},
|
||||
});
|
||||
const renameAsync = renameMutation.mutateAsync;
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (value: string) => {
|
||||
await renameAsync(value.trim());
|
||||
},
|
||||
[renameAsync],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdaptiveRenameModal
|
||||
visible={visible}
|
||||
title={t("sidebar.workspace.rename.title")}
|
||||
initialValue={workspace.title ?? workspace.name}
|
||||
placeholder={workspace.name}
|
||||
submitLabel={t("sidebar.workspace.rename.submit")}
|
||||
onClose={onClose}
|
||||
onSubmit={handleSubmit}
|
||||
testID={testID}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { WorkspaceRenameModal } from "./internal/modal";
|
||||
export {
|
||||
useSidebarWorkspaceRename,
|
||||
type SidebarRenamableWorkspace,
|
||||
type SidebarWorkspaceRename,
|
||||
} from "./internal/use-sidebar-workspace-rename";
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import type { NativeSyntheticEvent, TextInputKeyPressEventData } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { EditingTextInput, type EditingTextInputHandle } from "@/components/ui/text-input";
|
||||
|
||||
const ThemedTextInput = withUnistyles(EditingTextInput, (theme) => ({
|
||||
placeholderTextColor: theme.colors.foregroundMuted,
|
||||
}));
|
||||
|
||||
export interface InlineTitleFieldProps {
|
||||
initialValue: string;
|
||||
placeholder: string;
|
||||
/** Holds the submitted text on screen while the daemon accepts it. */
|
||||
readOnly: boolean;
|
||||
onCommit: (text: string) => void;
|
||||
onCancel: () => void;
|
||||
testID: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single-line input that stands in for the row's title text. It reports exactly one outcome:
|
||||
* Enter and blur commit, Escape cancels. Enter blurs the input too, so without the settled latch
|
||||
* one Enter would commit twice.
|
||||
*
|
||||
* Its box is the slot it is rendered in (100% of the title line), and the outline that marks it
|
||||
* as editable paints outside that box, so swapping it in for the title never moves the row.
|
||||
*/
|
||||
export function InlineTitleField({
|
||||
initialValue,
|
||||
placeholder,
|
||||
readOnly,
|
||||
onCommit,
|
||||
onCancel,
|
||||
testID,
|
||||
}: InlineTitleFieldProps) {
|
||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||
const settledRef = useRef(false);
|
||||
const focusedRef = useRef(false);
|
||||
|
||||
// Rename starts from a menu item. The menu restores focus to its trigger as it closes, so
|
||||
// focusing in the same commit would lose to it and the blur would commit an unchanged title.
|
||||
useEffect(() => {
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
input.focus();
|
||||
input.setSelection({ start: 0, end: initialValue.length });
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [initialValue]);
|
||||
|
||||
const settle = useCallback((outcome: () => void) => {
|
||||
if (settledRef.current) return;
|
||||
settledRef.current = true;
|
||||
outcome();
|
||||
}, []);
|
||||
|
||||
const commit = useCallback(() => {
|
||||
settle(() => onCommit(inputRef.current?.getText() ?? initialValue));
|
||||
}, [initialValue, onCommit, settle]);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
focusedRef.current = true;
|
||||
}, []);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
// A blur before the first focus is the menu handing focus back, not the user leaving.
|
||||
if (!focusedRef.current) return;
|
||||
commit();
|
||||
}, [commit]);
|
||||
|
||||
const handleKeyPress = useCallback(
|
||||
(event: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
||||
if (event.nativeEvent.key !== "Escape") return;
|
||||
settle(onCancel);
|
||||
},
|
||||
[onCancel, settle],
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemedTextInput
|
||||
ref={inputRef}
|
||||
initialValue={initialValue}
|
||||
placeholder={placeholder}
|
||||
editable={!readOnly}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
spellCheck={false}
|
||||
selectTextOnFocus={false}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onSubmitEditing={commit}
|
||||
onKeyPress={handleKeyPress}
|
||||
style={styles.input}
|
||||
testID={testID}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
// Typography matches the sidebar title it replaces; the box comes from the slot.
|
||||
input: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
borderWidth: 0,
|
||||
backgroundColor: "transparent",
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: "400",
|
||||
includeFontPadding: false,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
outlineStyle: "solid",
|
||||
outlineWidth: 1,
|
||||
outlineOffset: 2,
|
||||
outlineColor: theme.colors.accent,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import {
|
||||
type RenamableWorkspace,
|
||||
useSetWorkspaceTitle,
|
||||
workspaceRenameInitialValue,
|
||||
} from "./set-workspace-title";
|
||||
|
||||
export interface WorkspaceRenameModalProps {
|
||||
visible: boolean;
|
||||
workspace: RenamableWorkspace;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* Prefix for the modal's testIDs. Sidebar rows use
|
||||
* `sidebar-workspace-rename-modal-${workspaceKey}` — the browser and native-wide e2e flows build
|
||||
* their locators from exactly that prefix.
|
||||
*/
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns every rename string, so a caller only tracks its own open/closed boolean. Errors surface
|
||||
* inline inside AdaptiveRenameModal; the dialog stays open.
|
||||
*/
|
||||
export function WorkspaceRenameModal({
|
||||
visible,
|
||||
workspace,
|
||||
onClose,
|
||||
testID,
|
||||
}: WorkspaceRenameModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const setWorkspaceTitle = useSetWorkspaceTitle(workspace);
|
||||
const submit = useCallback(
|
||||
async (title: string) => {
|
||||
await setWorkspaceTitle(title);
|
||||
},
|
||||
[setWorkspaceTitle],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdaptiveRenameModal
|
||||
visible={visible}
|
||||
title={t("sidebar.workspace.rename.title")}
|
||||
initialValue={workspaceRenameInitialValue(workspace)}
|
||||
placeholder={workspace.name}
|
||||
submitLabel={t("sidebar.workspace.rename.submit")}
|
||||
onClose={onClose}
|
||||
onSubmit={submit}
|
||||
testID={testID}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { UnistylesRuntime } from "react-native-unistyles";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
export type WorkspaceRenameSurface = "inline" | "modal";
|
||||
|
||||
/**
|
||||
* Read when a rename starts rather than subscribed to: the surface is chosen once per rename, and
|
||||
* a subscription would re-render every sidebar row on each breakpoint change.
|
||||
*
|
||||
* Native wide layouts keep the modal. Their sidebar rows are touch targets with a permanent
|
||||
* kebab, and the iPad overlay-dismissal flows drive the modal.
|
||||
*/
|
||||
export function resolveWorkspaceRenameSurface(): WorkspaceRenameSurface {
|
||||
if (!isWeb) return "modal";
|
||||
const breakpoint = UnistylesRuntime.breakpoint;
|
||||
return breakpoint === "lg" || breakpoint === "xl" ? "inline" : "modal";
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
|
||||
// The subset of a workspace a rename needs. Narrower than SidebarWorkspaceEntry so the command
|
||||
// center can build one from the active route selection without a sidebar row.
|
||||
export interface RenamableWorkspace {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
title?: string | null;
|
||||
}
|
||||
|
||||
/** What the rename field starts with: the custom title, else the derived name it overrides. */
|
||||
export function workspaceRenameInitialValue(workspace: RenamableWorkspace): string {
|
||||
return workspace.title ?? workspace.name;
|
||||
}
|
||||
|
||||
/** Resolves to the title the daemon accepted; rejects with a displayable message. */
|
||||
export function useSetWorkspaceTitle(
|
||||
workspace: Pick<RenamableWorkspace, "serverId" | "workspaceId">,
|
||||
): (title: string) => Promise<string | null> {
|
||||
const { t } = useTranslation();
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (title: string) => {
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||
if (!client) {
|
||||
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
|
||||
}
|
||||
const trimmed = title.trim();
|
||||
const accepted = await client.setWorkspaceTitle(
|
||||
workspace.workspaceId,
|
||||
trimmed.length === 0 ? null : trimmed,
|
||||
);
|
||||
return accepted.title;
|
||||
},
|
||||
});
|
||||
return mutation.mutateAsync;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { type ReactNode, useCallback, useState } from "react";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { InlineTitleField } from "./inline-title-field";
|
||||
import { WorkspaceRenameModal } from "./modal";
|
||||
import { resolveWorkspaceRenameSurface } from "./rename-surface";
|
||||
import {
|
||||
type RenamableWorkspace,
|
||||
useSetWorkspaceTitle,
|
||||
workspaceRenameInitialValue,
|
||||
} from "./set-workspace-title";
|
||||
|
||||
export interface SidebarRenamableWorkspace extends RenamableWorkspace {
|
||||
workspaceKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An inline session owns the text editing started from. The field keeps that text while it is
|
||||
* open, so a rename arriving from elsewhere must not make an untouched field read as an edit.
|
||||
*
|
||||
* `saving` and `saved` keep the field on screen holding the submitted text. The daemon answers
|
||||
* the request before it pushes the updated workspace, so closing on the answer would show the
|
||||
* old title for a beat. `saved` ends on the first title the row shows after the answer: ours, or
|
||||
* a later rename the daemon published in its place. The daemon builds each update from its
|
||||
* latest record, so a later rename can mean ours never appears. `titleSeen` is the row's title as
|
||||
* of the last render, so a rename delivered before the answer does not end `saved`.
|
||||
*/
|
||||
type InlineRenameSession = { kind: "inline"; initialValue: string } & (
|
||||
| { phase: "editing" }
|
||||
| { phase: "saving"; titleSeen: string | null }
|
||||
| { phase: "saved"; titleSeen: string | null; acceptedTitle: string | null }
|
||||
);
|
||||
|
||||
type RenameSession = { kind: "closed" } | { kind: "modal" } | InlineRenameSession;
|
||||
|
||||
const CLOSED: RenameSession = { kind: "closed" };
|
||||
|
||||
export interface SidebarWorkspaceRename {
|
||||
/** Starts a rename: in place on wide pointer layouts, in the rename modal otherwise. */
|
||||
begin: () => void;
|
||||
/** Rendered in place of the row's title while renaming in place; null otherwise. */
|
||||
titleEditor: ReactNode | null;
|
||||
/** Always render this; it is the modal for layouts that rename there. */
|
||||
modal: ReactNode;
|
||||
}
|
||||
|
||||
export function useSidebarWorkspaceRename(
|
||||
workspace: SidebarRenamableWorkspace,
|
||||
): SidebarWorkspaceRename {
|
||||
const toast = useToast();
|
||||
const setWorkspaceTitle = useSetWorkspaceTitle(workspace);
|
||||
const [session, setSession] = useState<RenameSession>(CLOSED);
|
||||
const currentTitle = workspace.title ?? null;
|
||||
|
||||
if (
|
||||
session.kind === "inline" &&
|
||||
session.phase === "saving" &&
|
||||
currentTitle !== session.titleSeen
|
||||
) {
|
||||
setSession({ ...session, titleSeen: currentTitle });
|
||||
}
|
||||
if (
|
||||
session.kind === "inline" &&
|
||||
session.phase === "saved" &&
|
||||
(currentTitle === session.acceptedTitle || currentTitle !== session.titleSeen)
|
||||
) {
|
||||
setSession(CLOSED);
|
||||
}
|
||||
|
||||
const initialValue = workspaceRenameInitialValue(workspace);
|
||||
const begin = useCallback(() => {
|
||||
setSession(
|
||||
resolveWorkspaceRenameSurface() === "inline"
|
||||
? { kind: "inline", phase: "editing", initialValue }
|
||||
: { kind: "modal" },
|
||||
);
|
||||
}, [initialValue]);
|
||||
|
||||
const close = useCallback(() => setSession(CLOSED), []);
|
||||
|
||||
const commit = useCallback(
|
||||
(text: string) => {
|
||||
if (session.kind !== "inline") return;
|
||||
const title = text.trim();
|
||||
if (title.length === 0 || title === session.initialValue) {
|
||||
setSession(CLOSED);
|
||||
return;
|
||||
}
|
||||
setSession({
|
||||
kind: "inline",
|
||||
initialValue: session.initialValue,
|
||||
phase: "saving",
|
||||
titleSeen: currentTitle,
|
||||
});
|
||||
setWorkspaceTitle(title).then(
|
||||
(acceptedTitle) =>
|
||||
setSession((previous) =>
|
||||
previous.kind === "inline" && previous.phase === "saving"
|
||||
? { ...previous, phase: "saved", acceptedTitle }
|
||||
: previous,
|
||||
),
|
||||
(error: unknown) => {
|
||||
setSession(CLOSED);
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
},
|
||||
);
|
||||
},
|
||||
[currentTitle, session, setWorkspaceTitle, toast],
|
||||
);
|
||||
|
||||
const titleEditor =
|
||||
session.kind === "inline" ? (
|
||||
<InlineTitleField
|
||||
initialValue={session.initialValue}
|
||||
placeholder={workspace.name}
|
||||
readOnly={session.phase !== "editing"}
|
||||
onCommit={commit}
|
||||
onCancel={close}
|
||||
testID={`sidebar-workspace-rename-input-${workspace.workspaceKey}`}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const modal = (
|
||||
<WorkspaceRenameModal
|
||||
visible={session.kind === "modal"}
|
||||
workspace={workspace}
|
||||
onClose={close}
|
||||
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
|
||||
/>
|
||||
);
|
||||
|
||||
return { begin, titleEditor, modal };
|
||||
}
|
||||
Reference in New Issue
Block a user