Improve sidebar workspace readability and host identity (#2790)
* fix(app): align turn footer actions Keep running-turn actions in the same position as completed-turn actions and use the shared small icon size for both copy and fork controls. * feat(app): improve sidebar workspace identity Make dense sidebar workspace rows easier to scan and let each device control how connected hosts are identified. * fix(app): keep host badges readable Use a compact display menu and keep identity color on the host icon so translated controls fit and badge text retains standard contrast. * fix(app): announce selected sidebar badge mode * fix(app): retain icon-only host names for screen readers * fix(app): name hoisted workspace rows * fix(app): retain host identity in workspace labels * fix(app): retain workspace status in row labels * fix(app): defer host appearance until identity resolves * fix(app): serialize host appearance writes * fix(app): serialize host registry writes * fix(app): bound sidebar host badge labels * test(app): match workspace status labels * fix(app): consolidate sidebar identity behavior * test(app): assert status grouping behavior * fix(app): keep sidebar row actions accessible * fix(app): stabilize optimistic message layout
This commit is contained in:
+3
-1
@@ -225,6 +225,8 @@ Status pills are `palette.<color>[300]` foreground on a 10%-alpha background of
|
|||||||
|
|
||||||
Status dots — the small filled circles next to a host or agent name — are `borderRadius.full` filled with the status color (`statusSuccess`, `statusWarning`, `statusDanger`, or `foregroundMuted`). They sit in the trailing slot of a sidebar row or as a leading marker on a status pill.
|
Status dots — the small filled circles next to a host or agent name — are `borderRadius.full` filled with the status color (`statusSuccess`, `statusWarning`, `statusDanger`, or `foregroundMuted`). They sit in the trailing slot of a sidebar row or as a leading marker on a status pill.
|
||||||
|
|
||||||
|
Identity badges — the project icon and the sidebar host badge — do not use the theme palette. They draw from the fixed ten-color identity table in `packages/app/src/styles/identity-colors.ts`, whose hexes are held to one contrast band so a color identifies rather than ranks. Identity color is a fill-only contract: project icons use it as a fill with a white letter, while host badges use it on their icon and retain the normal muted text color. The table is theme-independent by design; do not fork it per theme, and do not add hexes to it without recomputing the band.
|
||||||
|
|
||||||
The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `packages/app/src/components/agent-list.tsx:607-632`, and `packages/app/src/components/sidebar-workspace-list.tsx:2889-2894` are drift to be removed. New code uses `<StatusBadge>`.
|
The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `packages/app/src/components/agent-list.tsx:607-632`, and `packages/app/src/components/sidebar-workspace-list.tsx:2889-2894` are drift to be removed. New code uses `<StatusBadge>`.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -236,7 +238,7 @@ The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `
|
|||||||
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
|
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
|
||||||
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
|
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
|
||||||
- The word "checkout" in UI strings or identifiers. The term is "workspace".
|
- The word "checkout" in UI strings or identifiers. The term is "workspace".
|
||||||
- New color tokens or hardcoded hex outside the palette. Status pill rgba backgrounds are the documented pattern (§12), not a license.
|
- New color tokens or hardcoded hex outside the palette. Status pill rgba backgrounds and the identity color table are the documented exceptions (§13), not a license.
|
||||||
- Placeholder text dimmed beyond `foregroundMuted`. No extra opacity, no italics, no ghost-text.
|
- Placeholder text dimmed beyond `foregroundMuted`. No extra opacity, no italics, no ghost-text.
|
||||||
- `onPointerEnter` and `onPointerLeave`. They do not fire on native iOS. Hover uses Pressable's `onHoverIn`/`onHoverOut` gated with `isHovered || isCompact || isNative`.
|
- `onPointerEnter` and `onPointerLeave`. They do not fire on native iOS. Hover uses Pressable's `onHoverIn`/`onHoverOut` gated with `isHovered || isCompact || isNative`.
|
||||||
- Raw DOM APIs without an `isWeb` guard.
|
- Raw DOM APIs without an `isWeb` guard.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
|||||||
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
|
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
|
||||||
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
|
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
|
||||||
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
|
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
|
||||||
|
- **Host badge** — The sidebar pill naming which host a workspace lives on. Only drawn once the visible sidebar spans more than one host. Its name, color, and display mode (**Name** / **Icon only** / **Hidden**) are per-host, per-device, and live client-side on `HostProfile.appearance`; nothing about it reaches the daemon. UI: the "Appearance" group on a host's settings page. Code: `HostBadge` (`packages/app/src/components/sidebar/host-badge.tsx`), `selectHostBadges` (`packages/app/src/hosts/appearance.ts`). Don't confuse with **Status pill**, which reports state rather than identity.
|
||||||
- **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym.
|
- **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym.
|
||||||
- **Placement** — One workspace's stable foreign-key relationship to its project plus its git checkout snapshot. Internal. An explicit creation `projectId` is authoritative when active.
|
- **Placement** — One workspace's stable foreign-key relationship to its project plus its git checkout snapshot. Internal. An explicit creation `projectId` is authoritative when active.
|
||||||
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` in `WorkspaceGitRuntimePayloadSchema` (`packages/protocol/src/messages.ts:2136`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
|
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` in `WorkspaceGitRuntimePayloadSchema` (`packages/protocol/src/messages.ts:2136`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
|
||||||
|
|||||||
@@ -660,6 +660,19 @@ function expectAtomicIdleToRunningTransition(frames: TurnFrame[]): void {
|
|||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function expectSubmittedMessageHeightStable(frames: TurnFrame[]): void {
|
||||||
|
const first = frames.findIndex((frame) => frame.userRow.visible);
|
||||||
|
const baseline = frames[first]?.userRow.rect?.height;
|
||||||
|
const changes = frames
|
||||||
|
.slice(first)
|
||||||
|
.flatMap((frame, offset) =>
|
||||||
|
geometryChanged(frame.userRow.rect?.height, baseline)
|
||||||
|
? [`frame ${first + offset}: submitted row height changed`]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
expect(changes, `submitted message geometry changed:\n${changes.join("\n")}`).toEqual([]);
|
||||||
|
}
|
||||||
|
|
||||||
function expectAtomicFirstPromptTransition(frames: TurnFrame[]): void {
|
function expectAtomicFirstPromptTransition(frames: TurnFrame[]): void {
|
||||||
expectRowContinuity(frames);
|
expectRowContinuity(frames);
|
||||||
const first = frames.findIndex((frame) => hasPaintedLayout(frame.userRow));
|
const first = frames.findIndex((frame) => hasPaintedLayout(frame.userRow));
|
||||||
@@ -828,6 +841,33 @@ test("keeps follow-up activity continuous when turn starts before response", asy
|
|||||||
expectAtomicIdleToRunningTransition(frames);
|
expectAtomicIdleToRunningTransition(frames);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("keeps a submitted message height stable when its canonical echo arrives", async ({
|
||||||
|
page,
|
||||||
|
streamingAgent,
|
||||||
|
}) => {
|
||||||
|
const gate = await installDaemonWebSocketGate(page);
|
||||||
|
await openAgentRoute(page, {
|
||||||
|
workspaceId: streamingAgent.workspaceId,
|
||||||
|
agentId: streamingAgent.agentId,
|
||||||
|
});
|
||||||
|
await expectAgentIdle(page);
|
||||||
|
|
||||||
|
const prompt = "Canonical echo must not resize this message.";
|
||||||
|
gate.holdNextAgentStreamItem("user_message");
|
||||||
|
await recordTurnFrames(page, prompt);
|
||||||
|
await submitMessage(page, prompt);
|
||||||
|
await gate.waitForHeldAgentStreamItem("user_message");
|
||||||
|
const submittedRow = page.getByTestId("user-message").filter({ hasText: prompt });
|
||||||
|
await expect(submittedRow).toBeVisible();
|
||||||
|
await recordPaintsFor(page, 80);
|
||||||
|
|
||||||
|
gate.releaseHeldAgentStreamItem("user_message");
|
||||||
|
await expect(submittedRow.getByTestId("rewind-menu-trigger")).toBeVisible();
|
||||||
|
await recordPaintsFor(page, 80);
|
||||||
|
|
||||||
|
expectSubmittedMessageHeightStable(await stopTurnFrameRecording(page));
|
||||||
|
});
|
||||||
|
|
||||||
test("keeps follow-up activity continuous when response arrives before running state", async ({
|
test("keeps follow-up activity continuous when response arrives before running state", async ({
|
||||||
page,
|
page,
|
||||||
streamingAgent,
|
streamingAgent,
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { expect, test as base } from "../support/fixtures";
|
||||||
|
import { gotoAppShell } from "../support/helpers/app";
|
||||||
|
import {
|
||||||
|
addConnectedHostAndReload,
|
||||||
|
chooseHostBadgeDisplay,
|
||||||
|
chooseHostColor,
|
||||||
|
expectHostAppearancePreview,
|
||||||
|
expectHostBadgeIconOnly,
|
||||||
|
expectHostBadgeName,
|
||||||
|
expectHostBadgeTinted,
|
||||||
|
expectNoHostBadge,
|
||||||
|
leaveHostAppearanceSettings,
|
||||||
|
openHostAppearanceSettings,
|
||||||
|
reloadPreservingHostRegistry,
|
||||||
|
renameHostFromSettings,
|
||||||
|
waitForConnectedHost,
|
||||||
|
} from "../support/helpers/hosts";
|
||||||
|
import {
|
||||||
|
type IsolatedHostDaemon,
|
||||||
|
startIsolatedHostDaemon,
|
||||||
|
} from "../support/helpers/isolated-host-daemon";
|
||||||
|
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||||
|
import { getServerId } from "../support/helpers/server-id";
|
||||||
|
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||||
|
|
||||||
|
const PRIMARY_HOST_LABEL = "Primary Host";
|
||||||
|
const SECONDARY_HOST_LABEL = "Secondary Host";
|
||||||
|
|
||||||
|
interface TwoHostSidebar {
|
||||||
|
primaryServerId: string;
|
||||||
|
primaryWorkspaceId: string;
|
||||||
|
secondaryServerId: string;
|
||||||
|
secondaryWorkspaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The host badge only appears once the visible sidebar spans more than one host, and that count
|
||||||
|
// runs over visible projects — so an offline host with no workspaces will not raise it. Two real
|
||||||
|
// daemons, each with a seeded workspace, is the minimum shape that exercises the feature.
|
||||||
|
const test = base.extend<{ twoHostSidebar: TwoHostSidebar }>({
|
||||||
|
twoHostSidebar: async ({ page }, provide) => {
|
||||||
|
const secondaryHost: IsolatedHostDaemon = await startIsolatedHostDaemon(
|
||||||
|
"host-appearance-secondary",
|
||||||
|
);
|
||||||
|
let primary: SeededWorkspace | null = null;
|
||||||
|
let secondary: SeededWorkspace | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
primary = await seedWorkspace({
|
||||||
|
repoPrefix: "host-appearance-primary-",
|
||||||
|
title: "Primary workspace",
|
||||||
|
});
|
||||||
|
secondary = await seedWorkspace({
|
||||||
|
repoPrefix: "host-appearance-secondary-",
|
||||||
|
title: "Secondary workspace",
|
||||||
|
port: secondaryHost.port,
|
||||||
|
});
|
||||||
|
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await addConnectedHostAndReload(page, {
|
||||||
|
serverId: secondaryHost.serverId,
|
||||||
|
label: SECONDARY_HOST_LABEL,
|
||||||
|
port: secondaryHost.port,
|
||||||
|
primaryLabel: PRIMARY_HOST_LABEL,
|
||||||
|
});
|
||||||
|
await waitForConnectedHost(page, {
|
||||||
|
serverId: secondaryHost.serverId,
|
||||||
|
endpoint: `localhost:${secondaryHost.port}`,
|
||||||
|
});
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
|
||||||
|
await provide({
|
||||||
|
primaryServerId: getServerId(),
|
||||||
|
primaryWorkspaceId: primary.workspaceId,
|
||||||
|
secondaryServerId: secondaryHost.serverId,
|
||||||
|
secondaryWorkspaceId: secondary.workspaceId,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await primary?.cleanup().catch(() => undefined);
|
||||||
|
await secondary?.cleanup().catch(() => undefined);
|
||||||
|
await secondaryHost.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Host appearance", () => {
|
||||||
|
test.describe.configure({ timeout: 180_000 });
|
||||||
|
|
||||||
|
test("renaming a host renames its sidebar badge", async ({ page, twoHostSidebar }) => {
|
||||||
|
const badge = {
|
||||||
|
serverId: twoHostSidebar.secondaryServerId,
|
||||||
|
workspaceId: twoHostSidebar.secondaryWorkspaceId,
|
||||||
|
};
|
||||||
|
await expectHostBadgeName(page, { ...badge, hostName: SECONDARY_HOST_LABEL });
|
||||||
|
|
||||||
|
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
|
||||||
|
await renameHostFromSettings(page, "Build Box");
|
||||||
|
await leaveHostAppearanceSettings(page);
|
||||||
|
|
||||||
|
await expectHostBadgeName(page, { ...badge, hostName: "Build Box" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("long host names leave room for the workspace title and actions", async ({
|
||||||
|
page,
|
||||||
|
twoHostSidebar,
|
||||||
|
}) => {
|
||||||
|
const hostName = "A very long remote build host name that must not take over the sidebar";
|
||||||
|
const workspaceKey = `${twoHostSidebar.secondaryServerId}:${twoHostSidebar.secondaryWorkspaceId}`;
|
||||||
|
|
||||||
|
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
|
||||||
|
await renameHostFromSettings(page, hostName);
|
||||||
|
await leaveHostAppearanceSettings(page);
|
||||||
|
|
||||||
|
const row = page.getByTestId(`sidebar-workspace-row-${workspaceKey}`);
|
||||||
|
const badge = row.getByTestId(`sidebar-host-badge-${twoHostSidebar.secondaryServerId}`);
|
||||||
|
await expect(badge).toHaveText(hostName);
|
||||||
|
await row.hover();
|
||||||
|
|
||||||
|
const title = row.getByText("Secondary workspace", { exact: true });
|
||||||
|
const kebab = page.getByTestId(`sidebar-workspace-kebab-${workspaceKey}`);
|
||||||
|
await expect(kebab).toBeVisible();
|
||||||
|
const [badgeBox, titleBox, rowBox, kebabBox] = await Promise.all([
|
||||||
|
badge.boundingBox(),
|
||||||
|
title.boundingBox(),
|
||||||
|
row.boundingBox(),
|
||||||
|
kebab.boundingBox(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(badgeBox).not.toBeNull();
|
||||||
|
expect(titleBox).not.toBeNull();
|
||||||
|
expect(rowBox).not.toBeNull();
|
||||||
|
expect(kebabBox).not.toBeNull();
|
||||||
|
expect(titleBox!.width).toBeGreaterThan(0);
|
||||||
|
expect(kebabBox!.x + kebabBox!.width).toBeLessThanOrEqual(rowBox!.x + rowBox!.width);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("picking a color identifies the badge", async ({ page, twoHostSidebar }) => {
|
||||||
|
const badge = {
|
||||||
|
serverId: twoHostSidebar.secondaryServerId,
|
||||||
|
workspaceId: twoHostSidebar.secondaryWorkspaceId,
|
||||||
|
hostName: SECONDARY_HOST_LABEL,
|
||||||
|
};
|
||||||
|
|
||||||
|
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
|
||||||
|
await chooseHostColor(page, "Teal");
|
||||||
|
await leaveHostAppearanceSettings(page);
|
||||||
|
|
||||||
|
await expectHostBadgeTinted(page, { ...badge, color: "teal" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("icon only keeps the badge and drops the name", async ({ page, twoHostSidebar }) => {
|
||||||
|
const badge = {
|
||||||
|
serverId: twoHostSidebar.secondaryServerId,
|
||||||
|
workspaceId: twoHostSidebar.secondaryWorkspaceId,
|
||||||
|
hostName: SECONDARY_HOST_LABEL,
|
||||||
|
};
|
||||||
|
|
||||||
|
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
|
||||||
|
await chooseHostBadgeDisplay(page, "Icon only");
|
||||||
|
await leaveHostAppearanceSettings(page);
|
||||||
|
|
||||||
|
await expectHostBadgeIconOnly(page, badge);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hiding one host leaves the other host's badge visible", async ({
|
||||||
|
page,
|
||||||
|
twoHostSidebar,
|
||||||
|
}) => {
|
||||||
|
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
|
||||||
|
await chooseHostBadgeDisplay(page, "Hidden");
|
||||||
|
await leaveHostAppearanceSettings(page);
|
||||||
|
|
||||||
|
await expectNoHostBadge(page, {
|
||||||
|
serverId: twoHostSidebar.secondaryServerId,
|
||||||
|
workspaceId: twoHostSidebar.secondaryWorkspaceId,
|
||||||
|
hostName: SECONDARY_HOST_LABEL,
|
||||||
|
});
|
||||||
|
await expectHostBadgeName(page, {
|
||||||
|
serverId: twoHostSidebar.primaryServerId,
|
||||||
|
workspaceId: twoHostSidebar.primaryWorkspaceId,
|
||||||
|
hostName: PRIMARY_HOST_LABEL,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("name, color, and display survive a reload", async ({ page, twoHostSidebar }) => {
|
||||||
|
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
|
||||||
|
await renameHostFromSettings(page, "Build Box");
|
||||||
|
await chooseHostColor(page, "Amber");
|
||||||
|
await chooseHostBadgeDisplay(page, "Icon only");
|
||||||
|
await leaveHostAppearanceSettings(page);
|
||||||
|
|
||||||
|
await reloadPreservingHostRegistry(page);
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
|
||||||
|
await expectHostBadgeIconOnly(page, {
|
||||||
|
serverId: twoHostSidebar.secondaryServerId,
|
||||||
|
workspaceId: twoHostSidebar.secondaryWorkspaceId,
|
||||||
|
hostName: "Build Box",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the settings preview shows the badge as configured", async ({ page, twoHostSidebar }) => {
|
||||||
|
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
|
||||||
|
await renameHostFromSettings(page, "Build Box");
|
||||||
|
await chooseHostColor(page, "Emerald");
|
||||||
|
|
||||||
|
await expectHostAppearancePreview(page, {
|
||||||
|
serverId: twoHostSidebar.secondaryServerId,
|
||||||
|
hostName: "Build Box",
|
||||||
|
color: "emerald",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -116,7 +116,13 @@ async function startTrackingSidebarStatusGroups(page: import("@playwright/test")
|
|||||||
|
|
||||||
capture();
|
capture();
|
||||||
const observer = new MutationObserver(capture);
|
const observer = new MutationObserver(capture);
|
||||||
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
|
observer.observe(document.body, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
characterData: true,
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["data-testid"],
|
||||||
|
});
|
||||||
win.__workspaceStatusGroupObserver = observer;
|
win.__workspaceStatusGroupObserver = observer;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -159,7 +165,6 @@ async function expectWorkspaceStatusGroupEvents(input: {
|
|||||||
rowTestId: string;
|
rowTestId: string;
|
||||||
includes: string;
|
includes: string;
|
||||||
excludes: string;
|
excludes: string;
|
||||||
includesIndicator?: string;
|
|
||||||
excludesIndicator?: string;
|
excludesIndicator?: string;
|
||||||
}) {
|
}) {
|
||||||
await waitForWorkspaceStatusGroupEvent({
|
await waitForWorkspaceStatusGroupEvent({
|
||||||
@@ -172,11 +177,6 @@ async function expectWorkspaceStatusGroupEvents(input: {
|
|||||||
);
|
);
|
||||||
expect(createdWorkspaceEvents.map((event) => event.bucket)).toContain(input.includes);
|
expect(createdWorkspaceEvents.map((event) => event.bucket)).toContain(input.includes);
|
||||||
expect(createdWorkspaceEvents.filter((event) => event.bucket === input.excludes)).toEqual([]);
|
expect(createdWorkspaceEvents.filter((event) => event.bucket === input.excludes)).toEqual([]);
|
||||||
if (input.includesIndicator) {
|
|
||||||
expect(createdWorkspaceEvents.map((event) => event.indicatorTestId)).toContain(
|
|
||||||
input.includesIndicator,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (input.excludesIndicator) {
|
if (input.excludesIndicator) {
|
||||||
expect(
|
expect(
|
||||||
createdWorkspaceEvents.filter((event) => event.indicatorTestId === input.excludesIndicator),
|
createdWorkspaceEvents.filter((event) => event.indicatorTestId === input.excludesIndicator),
|
||||||
@@ -612,7 +612,6 @@ test.describe("New workspace flow", () => {
|
|||||||
rowTestId,
|
rowTestId,
|
||||||
includes: "running",
|
includes: "running",
|
||||||
excludes: "done",
|
excludes: "done",
|
||||||
includesIndicator: "workspace-status-indicator-running",
|
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
await tempRepo.cleanup();
|
await tempRepo.cleanup();
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { test } from "../support/fixtures";
|
||||||
|
import { gotoAppShell } from "../support/helpers/app";
|
||||||
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
|
import {
|
||||||
|
closeProjectContextMenu,
|
||||||
|
closeWorkspaceContextMenu,
|
||||||
|
expectProjectContextMenuActions,
|
||||||
|
expectWorkspaceContextMenuActions,
|
||||||
|
expectWorkspaceContextMenuOwnsAttention,
|
||||||
|
expectWorkspaceRowHoverCleared,
|
||||||
|
openProjectContextMenu,
|
||||||
|
openWorkspaceContextMenu,
|
||||||
|
showWorkspaceHoverCard,
|
||||||
|
} from "../support/helpers/sidebar";
|
||||||
|
|
||||||
|
test.describe("Sidebar context menus", () => {
|
||||||
|
test("right-clicking workspace and project rows opens their actions at the pointer", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "sidebar-context-menu-" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
|
||||||
|
await showWorkspaceHoverCard(page, workspace.workspaceId);
|
||||||
|
await openWorkspaceContextMenu(page, workspace.workspaceId);
|
||||||
|
await expectWorkspaceContextMenuActions(page, workspace.workspaceId);
|
||||||
|
await expectWorkspaceContextMenuOwnsAttention(page);
|
||||||
|
|
||||||
|
await closeWorkspaceContextMenu(page, workspace.workspaceId);
|
||||||
|
await expectWorkspaceRowHoverCleared(page, workspace.workspaceId);
|
||||||
|
await openProjectContextMenu(page, workspace.projectKey);
|
||||||
|
await expectProjectContextMenuActions(page, workspace.projectKey);
|
||||||
|
|
||||||
|
await closeProjectContextMenu(page, workspace.projectKey);
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -149,6 +149,12 @@ test.describe("Model B sidebar shape", () => {
|
|||||||
await expect(workspaceRow(page, activeMock.workspaceId).first()).toBeVisible({
|
await expect(workspaceRow(page, activeMock.workspaceId).first()).toBeVisible({
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
});
|
});
|
||||||
|
await expect(workspaceRow(page, idleProject.workspaceId).first()).toHaveAccessibleName(
|
||||||
|
`${idleProject.projectDisplayName}, ${idleProject.workspaceName}`,
|
||||||
|
);
|
||||||
|
await expect(workspaceRow(page, activeMock.workspaceId).first()).toHaveAccessibleName(
|
||||||
|
/Working$/,
|
||||||
|
);
|
||||||
|
|
||||||
// Only workspace rows are shown — no tab/agent/terminal leaves leak into
|
// Only workspace rows are shown — no tab/agent/terminal leaves leak into
|
||||||
// the status view.
|
// the status view.
|
||||||
|
|||||||
@@ -102,5 +102,11 @@ function projectRow(page: Page, project: StatusProject) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function workspaceRow(page: Page, workspaceName: string) {
|
function workspaceRow(page: Page, workspaceName: string) {
|
||||||
return page.getByRole("button", { name: workspaceName, exact: true });
|
return page.getByRole("button", {
|
||||||
|
name: new RegExp(`^${escapeRegExp(workspaceName)}(?:,|$)`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,6 +176,10 @@ function agentStreamEventKey(type: string): string {
|
|||||||
return `agent-stream-event:${type}`;
|
return `agent-stream-event:${type}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function agentStreamItemKey(type: string): string {
|
||||||
|
return `agent-stream-item:${type}`;
|
||||||
|
}
|
||||||
|
|
||||||
function agentUpdateKey(agentId: string, status: string): string {
|
function agentUpdateKey(agentId: string, status: string): string {
|
||||||
return `agent-update:${agentId}:${status}`;
|
return `agent-update:${agentId}:${status}`;
|
||||||
}
|
}
|
||||||
@@ -526,6 +530,12 @@ export async function installDaemonWebSocketGate(page: Page) {
|
|||||||
(message) => readAgentStreamEventType(message) === type,
|
(message) => readAgentStreamEventType(message) === type,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
holdNextAgentStreamItem(type: string): void {
|
||||||
|
pendingServerMessageHolds.set(
|
||||||
|
agentStreamItemKey(type),
|
||||||
|
(message) => readAgentStreamItemType(message) === type,
|
||||||
|
);
|
||||||
|
},
|
||||||
async waitForHeldServerMessage(type?: string): Promise<void> {
|
async waitForHeldServerMessage(type?: string): Promise<void> {
|
||||||
const key = type ? serverMessageKey(type) : null;
|
const key = type ? serverMessageKey(type) : null;
|
||||||
while (!heldServerMessages.some((message) => key === null || message.key === key)) {
|
while (!heldServerMessages.some((message) => key === null || message.key === key)) {
|
||||||
@@ -574,6 +584,22 @@ export async function installDaemonWebSocketGate(page: Page) {
|
|||||||
heldServerMessage.browser.send(follower);
|
heldServerMessage.browser.send(follower);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async waitForHeldAgentStreamItem(type: string): Promise<void> {
|
||||||
|
const key = agentStreamItemKey(type);
|
||||||
|
while (!heldServerMessages.some((message) => message.key === key)) {
|
||||||
|
await new Promise<void>((resolve) => heldServerMessageWaiters.add(resolve));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
releaseHeldAgentStreamItem(type: string): void {
|
||||||
|
const key = agentStreamItemKey(type);
|
||||||
|
const index = heldServerMessages.findIndex((message) => message.key === key);
|
||||||
|
const [heldServerMessage] = index >= 0 ? heldServerMessages.splice(index, 1) : [];
|
||||||
|
if (!heldServerMessage) throw new Error("No held agent stream item to release");
|
||||||
|
heldServerMessage.browser.send(heldServerMessage.message);
|
||||||
|
for (const follower of heldServerMessage.agentStreamFollowers) {
|
||||||
|
heldServerMessage.browser.send(follower);
|
||||||
|
}
|
||||||
|
},
|
||||||
requestTimelineTail(agentId: string): void {
|
requestTimelineTail(agentId: string): void {
|
||||||
if (!latestServer) throw new Error("No daemon WebSocket is connected");
|
if (!latestServer) throw new Error("No daemon WebSocket is connected");
|
||||||
latestServer.send(
|
latestServer.send(
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
import { identityColor, type IdentityColorName } from "@/styles/identity-colors";
|
||||||
|
import { openSettings } from "./app";
|
||||||
import { buildSeededHost } from "./daemon-registry";
|
import { buildSeededHost } from "./daemon-registry";
|
||||||
|
import { clickSettingsBackToWorkspace, openHostSection, selectSettingsHost } from "./settings";
|
||||||
|
|
||||||
const REGISTRY_KEY = "@paseo:daemon-registry";
|
const REGISTRY_KEY = "@paseo:daemon-registry";
|
||||||
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
|
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
|
||||||
@@ -140,3 +143,113 @@ export async function toggleHostFilter(page: Page, serverId: string): Promise<vo
|
|||||||
export async function selectAllHostsFilter(page: Page): Promise<void> {
|
export async function selectAllHostsFilter(page: Page): Promise<void> {
|
||||||
await page.getByTestId("sidebar-host-filter-all").click();
|
await page.getByTestId("sidebar-host-filter-all").click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function openHostAppearanceSettings(page: Page, serverId: string): Promise<void> {
|
||||||
|
await openSettings(page);
|
||||||
|
await selectSettingsHost(page, serverId);
|
||||||
|
await openHostSection(page, serverId, "host");
|
||||||
|
await expect(page.getByTestId("host-appearance-preview")).toBeVisible({ timeout: 15_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings replaces the workspace sidebar, so badge assertions need the app shell back. Going
|
||||||
|
// through the in-app back control (rather than a fresh goto) also keeps the auto-seed fixture
|
||||||
|
// from rewriting the host registry, since init scripts only re-run on a real navigation.
|
||||||
|
export async function leaveHostAppearanceSettings(page: Page): Promise<void> {
|
||||||
|
await clickSettingsBackToWorkspace(page);
|
||||||
|
await expect(page.getByTestId("host-appearance-preview")).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameHostFromSettings(page: Page, name: string): Promise<void> {
|
||||||
|
await page.getByTestId("host-page-label-edit-button").click();
|
||||||
|
const input = page.getByTestId("host-page-rename-modal-input");
|
||||||
|
await expect(input).toBeVisible();
|
||||||
|
await input.fill(name);
|
||||||
|
await page.getByTestId("host-page-rename-modal-submit").click();
|
||||||
|
await expect(input).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function chooseHostColor(page: Page, colorName: string): Promise<void> {
|
||||||
|
await page.getByRole("button", { name: /^Color, / }).click();
|
||||||
|
await page.getByRole("button", { name: colorName, exact: true }).click();
|
||||||
|
await expect(page.getByRole("button", { name: `Color, ${colorName}` })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function chooseHostBadgeDisplay(
|
||||||
|
page: Page,
|
||||||
|
option: "Name" | "Icon only" | "Hidden",
|
||||||
|
): Promise<void> {
|
||||||
|
await page.getByTestId("host-appearance-badge-display").click();
|
||||||
|
await page.getByRole("button", { name: option, exact: true }).click();
|
||||||
|
await expect(page.getByRole("button", { name: `Sidebar badge, ${option}` })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The badge is visual-only; the workspace row owns its accessible name. Test IDs keep visual
|
||||||
|
// assertions from coupling to that row-level accessibility contract.
|
||||||
|
function hostBadge(page: Page, input: HostBadgeTarget) {
|
||||||
|
return page
|
||||||
|
.getByTestId(`sidebar-workspace-row-${input.serverId}:${input.workspaceId}`)
|
||||||
|
.getByTestId(`sidebar-host-badge-${input.serverId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HostBadgeTarget {
|
||||||
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
hostName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectHostBadgeName(page: Page, target: HostBadgeTarget): Promise<void> {
|
||||||
|
const badge = hostBadge(page, target);
|
||||||
|
await expect(badge).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(badge).toHaveText(target.hostName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectHostBadgeIconOnly(page: Page, target: HostBadgeTarget): Promise<void> {
|
||||||
|
const badge = hostBadge(page, target);
|
||||||
|
await expect(badge).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(badge).not.toHaveText(target.hostName);
|
||||||
|
await expect(
|
||||||
|
page.getByTestId(`sidebar-workspace-row-${target.serverId}:${target.workspaceId}`),
|
||||||
|
).toHaveAccessibleName(new RegExp(target.hostName));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectNoHostBadge(page: Page, target: HostBadgeTarget): Promise<void> {
|
||||||
|
await expect(hostBadge(page, target)).toHaveCount(0, { timeout: 15_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectHostBadgeTinted(
|
||||||
|
page: Page,
|
||||||
|
target: HostBadgeTarget & { color: IdentityColorName },
|
||||||
|
): Promise<void> {
|
||||||
|
const badge = hostBadge(page, target);
|
||||||
|
await expect(badge).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(badge.getByText(target.hostName)).toHaveCSS("color", "rgb(113, 113, 122)");
|
||||||
|
await expect(badge.locator("svg")).toHaveAttribute("stroke", identityColor(target.color));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectHostAppearancePreview(
|
||||||
|
page: Page,
|
||||||
|
input: { serverId: string; hostName: string; color: IdentityColorName },
|
||||||
|
): Promise<void> {
|
||||||
|
const preview = page.getByTestId("host-appearance-preview");
|
||||||
|
await expect(preview).toBeVisible();
|
||||||
|
const badge = preview.getByTestId(`sidebar-host-badge-${input.serverId}`);
|
||||||
|
await expect(badge).toHaveText(input.hostName);
|
||||||
|
await expect(badge.locator("svg")).toHaveAttribute("stroke", identityColor(input.color));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The auto-seed fixture rewrites the host registry on every navigation. Setting the
|
||||||
|
// disable-once flag to the current nonce makes it skip exactly one reload, which is what proves
|
||||||
|
// the appearance survived rather than being reseeded.
|
||||||
|
export async function reloadPreservingHostRegistry(page: Page): Promise<void> {
|
||||||
|
await page.evaluate(
|
||||||
|
(keys) => {
|
||||||
|
const nonce = localStorage.getItem(keys.nonce);
|
||||||
|
if (!nonce) {
|
||||||
|
throw new Error("Expected the e2e seed nonce before reloading.");
|
||||||
|
}
|
||||||
|
localStorage.setItem(keys.disableSeedOnce, nonce);
|
||||||
|
},
|
||||||
|
{ nonce: SEED_NONCE_KEY, disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY },
|
||||||
|
);
|
||||||
|
await page.reload();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,110 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
import { getServerId } from "./server-id";
|
import { getServerId } from "./server-id";
|
||||||
|
|
||||||
|
interface ContextMenuAnchor {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openContextMenuAtRowPoint(
|
||||||
|
page: Page,
|
||||||
|
rowTestID: string,
|
||||||
|
menuTestID: string,
|
||||||
|
): Promise<ContextMenuAnchor> {
|
||||||
|
const row = page.getByTestId(rowTestID);
|
||||||
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||||
|
const bounds = await row.boundingBox();
|
||||||
|
if (!bounds) throw new Error(`Could not measure ${rowTestID}`);
|
||||||
|
|
||||||
|
const anchor = {
|
||||||
|
x: bounds.x + Math.min(60, bounds.width / 2),
|
||||||
|
y: bounds.y + bounds.height / 2,
|
||||||
|
};
|
||||||
|
await page.mouse.click(anchor.x, anchor.y, { button: "right" });
|
||||||
|
await expect(page.getByTestId(menuTestID)).toBeVisible({ timeout: 10_000 });
|
||||||
|
return anchor;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectContextMenuAtPointer(
|
||||||
|
page: Page,
|
||||||
|
menuTestID: string,
|
||||||
|
anchor: ContextMenuAnchor,
|
||||||
|
): Promise<void> {
|
||||||
|
const bounds = await page.getByTestId(menuTestID).boundingBox();
|
||||||
|
if (!bounds) throw new Error(`Could not measure ${menuTestID}`);
|
||||||
|
expect(Math.abs(bounds.x - anchor.x)).toBeLessThanOrEqual(2);
|
||||||
|
expect(Math.abs(bounds.y - (anchor.y + 4))).toBeLessThanOrEqual(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openWorkspaceContextMenu(page: Page, workspaceId: string): Promise<void> {
|
||||||
|
const workspaceKey = `${getServerId()}:${workspaceId}`;
|
||||||
|
const menuTestID = `sidebar-workspace-context-menu-${workspaceKey}`;
|
||||||
|
const anchor = await openContextMenuAtRowPoint(
|
||||||
|
page,
|
||||||
|
`sidebar-workspace-row-${workspaceKey}`,
|
||||||
|
menuTestID,
|
||||||
|
);
|
||||||
|
await expectContextMenuAtPointer(page, menuTestID, anchor);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function showWorkspaceHoverCard(page: Page, workspaceId: string): Promise<void> {
|
||||||
|
const workspaceKey = `${getServerId()}:${workspaceId}`;
|
||||||
|
await page.getByTestId(`sidebar-workspace-row-${workspaceKey}`).hover();
|
||||||
|
await expect(page.getByTestId("workspace-hover-card")).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeWorkspaceContextMenu(page: Page, workspaceId: string): Promise<void> {
|
||||||
|
const workspaceKey = `${getServerId()}:${workspaceId}`;
|
||||||
|
await page.getByTestId(`sidebar-workspace-context-menu-${workspaceKey}-backdrop`).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectWorkspaceContextMenuOwnsAttention(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByTestId("workspace-hover-card")).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectWorkspaceRowHoverCleared(
|
||||||
|
page: Page,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const workspaceKey = `${getServerId()}:${workspaceId}`;
|
||||||
|
await expect(page.getByTestId(`sidebar-workspace-kebab-${workspaceKey}`)).toBeHidden();
|
||||||
|
await expect(page.getByTestId("workspace-hover-card")).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectWorkspaceContextMenuActions(
|
||||||
|
page: Page,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const workspaceKey = `${getServerId()}:${workspaceId}`;
|
||||||
|
const menu = page.getByTestId(`sidebar-workspace-context-menu-${workspaceKey}`);
|
||||||
|
await expect(menu.getByRole("button", { name: "Copy path" })).toBeVisible();
|
||||||
|
await expect(menu.getByRole("button", { name: "Rename workspace" })).toBeVisible();
|
||||||
|
await expect(menu.getByRole("button", { name: "Archive" })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openProjectContextMenu(page: Page, projectViewKey: string): Promise<void> {
|
||||||
|
const menuTestID = `sidebar-project-context-menu-${projectViewKey}`;
|
||||||
|
const anchor = await openContextMenuAtRowPoint(
|
||||||
|
page,
|
||||||
|
`sidebar-project-row-${projectViewKey}`,
|
||||||
|
menuTestID,
|
||||||
|
);
|
||||||
|
await expectContextMenuAtPointer(page, menuTestID, anchor);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeProjectContextMenu(page: Page, projectViewKey: string): Promise<void> {
|
||||||
|
await page.getByTestId(`sidebar-project-context-menu-${projectViewKey}-backdrop`).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectProjectContextMenuActions(
|
||||||
|
page: Page,
|
||||||
|
projectViewKey: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const menu = page.getByTestId(`sidebar-project-context-menu-${projectViewKey}`);
|
||||||
|
await expect(menu.getByRole("button", { name: "Open project settings" })).toBeVisible();
|
||||||
|
await expect(menu.getByRole("button", { name: "Remove project" })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
export async function selectWorkspaceInSidebar(page: Page, workspaceId: string): Promise<void> {
|
export async function selectWorkspaceInSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||||
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import React, { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("react-native", () => ({
|
||||||
|
Platform: { OS: "web" },
|
||||||
|
View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) => (
|
||||||
|
<div data-testid={testID}>{children}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-native-unistyles", () => ({
|
||||||
|
StyleSheet: {
|
||||||
|
create: (factory: unknown) =>
|
||||||
|
typeof factory === "function"
|
||||||
|
? (factory as (theme: Record<string, unknown>) => unknown)({
|
||||||
|
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24 },
|
||||||
|
colors: {
|
||||||
|
foregroundMuted: "#aaa",
|
||||||
|
palette: { amber: { 500: "#f0b429", 700: "#b7791f" } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: factory,
|
||||||
|
},
|
||||||
|
useUnistyles: () => ({ rt: { breakpoint: "md" } }),
|
||||||
|
withUnistyles: <T,>(component: T) => component,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/message", () => ({
|
||||||
|
AssistantTurnFooter: () => null,
|
||||||
|
LiveElapsed: () => <span data-testid="running-turn-timestamp" />,
|
||||||
|
STREAM_METADATA_FONT_SIZE: 11,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/assistant-fork-menu", () => ({
|
||||||
|
AssistantForkMenu: () => <button data-testid="running-turn-fork" type="button" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/synced-loader", () => ({
|
||||||
|
SyncedLoader: () => <span data-testid="running-turn-loader" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/retained-panel", () => ({
|
||||||
|
useRetainedPanelActive: () => true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { TurnFooter } from "./turn-footer";
|
||||||
|
|
||||||
|
const unusedRunningTurnStrategy = null as unknown as React.ComponentProps<
|
||||||
|
typeof TurnFooter
|
||||||
|
>["strategy"];
|
||||||
|
|
||||||
|
describe("TurnFooter", () => {
|
||||||
|
let root: Root | null = null;
|
||||||
|
let container: HTMLDivElement | null = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
||||||
|
value: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) {
|
||||||
|
act(() => root?.unmount());
|
||||||
|
}
|
||||||
|
root = null;
|
||||||
|
container?.remove();
|
||||||
|
container = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("places the running-turn fork between the loader and timestamp", () => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root?.render(
|
||||||
|
<TurnFooter
|
||||||
|
isRunning
|
||||||
|
inFlightTurnStartedAt={new Date("2026-08-01T10:00:00.000Z")}
|
||||||
|
host={null}
|
||||||
|
strategy={unusedRunningTurnStrategy}
|
||||||
|
supportsTimelineCursor
|
||||||
|
onForkInFlightTurn={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const footer = container.querySelector('[data-testid="turn-working-indicator"]');
|
||||||
|
const controls = Array.from(footer?.querySelectorAll("[data-testid]") ?? []).map((node) =>
|
||||||
|
node.getAttribute("data-testid"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(controls).toEqual([
|
||||||
|
"running-turn-loader",
|
||||||
|
"running-turn-fork",
|
||||||
|
"running-turn-timestamp",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -130,6 +130,8 @@ const WorkingIndicator = memo(function WorkingIndicator({
|
|||||||
<View style={stylesheet.workingLoader}>
|
<View style={stylesheet.workingLoader}>
|
||||||
<ThemedSyncedLoader size={14} uniProps={workingIndicatorColorMapping} />
|
<ThemedSyncedLoader size={14} uniProps={workingIndicatorColorMapping} />
|
||||||
</View>
|
</View>
|
||||||
|
{/* Match the completed-turn footer: actions precede timing metadata. */}
|
||||||
|
{onForkInFlightTurn ? <AssistantForkMenu onFork={onForkInFlightTurn} /> : null}
|
||||||
{inFlightTurnStartedAt ? (
|
{inFlightTurnStartedAt ? (
|
||||||
<LiveElapsed
|
<LiveElapsed
|
||||||
startedAt={inFlightTurnStartedAt}
|
startedAt={inFlightTurnStartedAt}
|
||||||
@@ -138,9 +140,6 @@ const WorkingIndicator = memo(function WorkingIndicator({
|
|||||||
testID="turn-working-elapsed"
|
testID="turn-working-elapsed"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{/* Sits in the same 24px gapped row as the loader and elapsed timer, so
|
|
||||||
fork is reachable without waiting for the run to finish. */}
|
|
||||||
{onForkInFlightTurn ? <AssistantForkMenu onFork={onForkInFlightTurn} /> : null}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import type { Theme } from "@/styles/theme";
|
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||||
|
|
||||||
export type AssistantForkTarget = "tab" | "workspace";
|
export type AssistantForkTarget = "tab" | "workspace";
|
||||||
|
|
||||||
@@ -69,7 +69,10 @@ export const AssistantForkMenu = memo(function AssistantForkMenu({
|
|||||||
[t],
|
[t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const forkIcon = useMemo(() => <ThemedSplit size={16} uniProps={foregroundColorMapping} />, []);
|
const forkIcon = useMemo(
|
||||||
|
() => <ThemedSplit size={ICON_SIZE.sm} uniProps={foregroundColorMapping} />,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu open={isOpen} onOpenChange={handleOpenChange}>
|
<DropdownMenu open={isOpen} onOpenChange={handleOpenChange}>
|
||||||
@@ -85,7 +88,7 @@ export const AssistantForkMenu = memo(function AssistantForkMenu({
|
|||||||
>
|
>
|
||||||
{({ hovered, open }) => (
|
{({ hovered, open }) => (
|
||||||
<ThemedSplit
|
<ThemedSplit
|
||||||
size={16}
|
size={ICON_SIZE.sm}
|
||||||
uniProps={hovered || open ? foregroundColorMapping : foregroundMutedColorMapping}
|
uniProps={hovered || open ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -36,11 +36,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
additions: {
|
additions: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
color: theme.colors.diffAddition,
|
color: theme.colors.diffStatAddition,
|
||||||
},
|
},
|
||||||
deletions: {
|
deletions: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
color: theme.colors.diffDeletion,
|
color: theme.colors.diffStatDeletion,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ interface SidebarSharedProps {
|
|||||||
pinnedGroups: PinnedSidebarGroups;
|
pinnedGroups: PinnedSidebarGroups;
|
||||||
projects: SidebarProjectEntry[];
|
projects: SidebarProjectEntry[];
|
||||||
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||||
projectNamesByViewKey: Map<string, string>;
|
|
||||||
isInitialLoad: boolean;
|
isInitialLoad: boolean;
|
||||||
isRevalidating: boolean;
|
isRevalidating: boolean;
|
||||||
isManualRefresh: boolean;
|
isManualRefresh: boolean;
|
||||||
@@ -133,7 +132,6 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
|||||||
const {
|
const {
|
||||||
projects,
|
projects,
|
||||||
workspaceEntriesByKey,
|
workspaceEntriesByKey,
|
||||||
projectNamesByViewKey,
|
|
||||||
isInitialLoad,
|
isInitialLoad,
|
||||||
isRevalidating,
|
isRevalidating,
|
||||||
refreshAll,
|
refreshAll,
|
||||||
@@ -239,7 +237,6 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
|||||||
pinnedGroups,
|
pinnedGroups,
|
||||||
projects,
|
projects,
|
||||||
workspaceEntriesByKey,
|
workspaceEntriesByKey,
|
||||||
projectNamesByViewKey,
|
|
||||||
isInitialLoad,
|
isInitialLoad,
|
||||||
isRevalidating,
|
isRevalidating,
|
||||||
isManualRefresh,
|
isManualRefresh,
|
||||||
@@ -593,7 +590,6 @@ function MobileSidebar({
|
|||||||
pinnedGroups,
|
pinnedGroups,
|
||||||
projects,
|
projects,
|
||||||
workspaceEntriesByKey,
|
workspaceEntriesByKey,
|
||||||
projectNamesByViewKey,
|
|
||||||
isInitialLoad,
|
isInitialLoad,
|
||||||
isRevalidating,
|
isRevalidating,
|
||||||
isManualRefresh,
|
isManualRefresh,
|
||||||
@@ -710,7 +706,6 @@ function MobileSidebar({
|
|||||||
pinnedGroups={pinnedGroups}
|
pinnedGroups={pinnedGroups}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||||
projectNamesByViewKey={projectNamesByViewKey}
|
|
||||||
isRefreshing={isManualRefresh && isRevalidating}
|
isRefreshing={isManualRefresh && isRevalidating}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
onWorkspacePress={handleWorkspacePress}
|
onWorkspacePress={handleWorkspacePress}
|
||||||
@@ -741,7 +736,6 @@ function DesktopSidebar({
|
|||||||
pinnedGroups,
|
pinnedGroups,
|
||||||
projects,
|
projects,
|
||||||
workspaceEntriesByKey,
|
workspaceEntriesByKey,
|
||||||
projectNamesByViewKey,
|
|
||||||
isInitialLoad,
|
isInitialLoad,
|
||||||
isRevalidating,
|
isRevalidating,
|
||||||
isManualRefresh,
|
isManualRefresh,
|
||||||
@@ -878,7 +872,6 @@ function DesktopSidebar({
|
|||||||
pinnedGroups={pinnedGroups}
|
pinnedGroups={pinnedGroups}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||||
projectNamesByViewKey={projectNamesByViewKey}
|
|
||||||
isRefreshing={isManualRefresh && isRevalidating}
|
isRefreshing={isManualRefresh && isRevalidating}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
onAddProject={handleOpenProject}
|
onAddProject={handleOpenProject}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ import {
|
|||||||
FileSymlink,
|
FileSymlink,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import type { Theme } from "@/styles/theme";
|
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
@@ -373,6 +373,7 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
|||||||
alignSelf: "flex-end",
|
alignSelf: "flex-end",
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
height: 24,
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
marginTop: theme.spacing[2],
|
marginTop: theme.spacing[2],
|
||||||
},
|
},
|
||||||
@@ -1034,9 +1035,9 @@ export const TurnCopyButton = memo(function TurnCopyButton({
|
|||||||
? turnCopyButtonStylesheet.iconHoveredColor.color
|
? turnCopyButtonStylesheet.iconHoveredColor.color
|
||||||
: turnCopyButtonStylesheet.iconColor.color;
|
: turnCopyButtonStylesheet.iconColor.color;
|
||||||
return copied ? (
|
return copied ? (
|
||||||
<Check size={16} color={iconColor} />
|
<Check size={ICON_SIZE.sm} color={iconColor} />
|
||||||
) : (
|
) : (
|
||||||
<Copy size={16} color={iconColor} />
|
<Copy size={ICON_SIZE.sm} color={iconColor} />
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
View,
|
View,
|
||||||
type ViewStyle,
|
type ViewStyle,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { deriveProjectIconColor } from "@/utils/project-icon-color";
|
import { deriveIdentityColorName, identityColor } from "@/styles/identity-colors";
|
||||||
|
|
||||||
const WHITE_TEXT = { color: "#ffffff" } as const;
|
const WHITE_TEXT = { color: "#ffffff" } as const;
|
||||||
|
|
||||||
@@ -29,7 +29,10 @@ export function ProjectIconView({
|
|||||||
}) {
|
}) {
|
||||||
const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]);
|
const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]);
|
||||||
const fallbackStyles = useMemo(
|
const fallbackStyles = useMemo(
|
||||||
() => [fallbackStyle, { backgroundColor: deriveProjectIconColor(projectViewKey) }],
|
() => [
|
||||||
|
fallbackStyle,
|
||||||
|
{ backgroundColor: identityColor(deriveIdentityColorName(projectViewKey)) },
|
||||||
|
],
|
||||||
[fallbackStyle, projectViewKey],
|
[fallbackStyle, projectViewKey],
|
||||||
);
|
);
|
||||||
const textStyles = useMemo(() => [textStyle, WHITE_TEXT], [textStyle]);
|
const textStyles = useMemo(() => [textStyle, WHITE_TEXT], [textStyle]);
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { seedSessionWorkspaces } from "@/test/seed-session";
|
|||||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
|
import { defaultHostAppearance } from "@/hosts/appearance";
|
||||||
|
|
||||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||||
default: {
|
default: {
|
||||||
@@ -146,6 +147,7 @@ function makeHost(): HostProfile {
|
|||||||
return {
|
return {
|
||||||
serverId: SERVER_ID,
|
serverId: SERVER_ID,
|
||||||
label: "Render Count Host",
|
label: "Render Count Host",
|
||||||
|
appearance: defaultHostAppearance(),
|
||||||
lifecycle: {},
|
lifecycle: {},
|
||||||
connections: [],
|
connections: [],
|
||||||
preferredConnectionId: null,
|
preferredConnectionId: null,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
ScrollView,
|
ScrollView,
|
||||||
type GestureResponderEvent,
|
type GestureResponderEvent,
|
||||||
type PressableStateCallbackType,
|
type PressableStateCallbackType,
|
||||||
|
type StyleProp,
|
||||||
|
type ViewStyle,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||||
@@ -19,6 +21,8 @@ import {
|
|||||||
type ReactElement,
|
type ReactElement,
|
||||||
type MutableRefObject,
|
type MutableRefObject,
|
||||||
type Ref,
|
type Ref,
|
||||||
|
type ComponentProps,
|
||||||
|
type PropsWithChildren,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { router, usePathname, type Href } from "expo-router";
|
import { router, usePathname, type Href } from "expo-router";
|
||||||
@@ -68,7 +72,13 @@ import {
|
|||||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||||
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
|
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
|
||||||
import { useShowShortcutBadges } from "@/hooks/use-show-shortcut-badges";
|
import { useShowShortcutBadges } from "@/hooks/use-show-shortcut-badges";
|
||||||
import { ContextMenuTrigger, useContextMenu } from "@/components/ui/context-menu";
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
useContextMenu,
|
||||||
|
} from "@/components/ui/context-menu";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
@@ -84,7 +94,10 @@ import { confirmDialog } from "@/utils/confirm-dialog";
|
|||||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||||
import { SidebarStatusWorkspaceList } from "@/components/sidebar/sidebar-status-list";
|
import { SidebarStatusWorkspaceList } from "@/components/sidebar/sidebar-status-list";
|
||||||
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||||
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
|
import {
|
||||||
|
SidebarWorkspaceContextMenu,
|
||||||
|
SidebarWorkspaceMenu,
|
||||||
|
} from "@/components/sidebar/sidebar-workspace-menu";
|
||||||
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
||||||
import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
|
import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
|
||||||
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
|
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
|
||||||
@@ -93,6 +106,7 @@ import {
|
|||||||
SidebarWorkspaceRowFrame,
|
SidebarWorkspaceRowFrame,
|
||||||
SidebarWorkspaceRowContent,
|
SidebarWorkspaceRowContent,
|
||||||
SidebarWorkspaceShortcutBadge,
|
SidebarWorkspaceShortcutBadge,
|
||||||
|
resolveTrailingActionVisibility,
|
||||||
SidebarWorkspaceTrailingActionBase,
|
SidebarWorkspaceTrailingActionBase,
|
||||||
SidebarWorkspaceTrailingActionOverlay,
|
SidebarWorkspaceTrailingActionOverlay,
|
||||||
SidebarWorkspaceTrailingActionSlot,
|
SidebarWorkspaceTrailingActionSlot,
|
||||||
@@ -126,7 +140,8 @@ import {
|
|||||||
} from "@/constants/platform";
|
} from "@/constants/platform";
|
||||||
import { getDesktopHost } from "@/desktop/host";
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item";
|
import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item";
|
||||||
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
import { useLocalDaemonServerId, useLocalDaemonServerIdState } from "@/hooks/use-is-local-daemon";
|
||||||
|
import { selectHostBadges, type HostBadgeModel } from "@/hosts/appearance";
|
||||||
|
|
||||||
const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey;
|
const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey;
|
||||||
|
|
||||||
@@ -212,7 +227,6 @@ interface SidebarWorkspaceListProps {
|
|||||||
pinnedGroups: PinnedSidebarGroups;
|
pinnedGroups: PinnedSidebarGroups;
|
||||||
projects: SidebarProjectEntry[];
|
projects: SidebarProjectEntry[];
|
||||||
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||||
projectNamesByViewKey: Map<string, string>;
|
|
||||||
collapsedProjectKeys: ReadonlySet<string>;
|
collapsedProjectKeys: ReadonlySet<string>;
|
||||||
onToggleProjectCollapsed: (projectViewKey: string) => void;
|
onToggleProjectCollapsed: (projectViewKey: string) => void;
|
||||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||||
@@ -255,7 +269,9 @@ interface ProjectHeaderRowProps {
|
|||||||
|
|
||||||
interface WorkspaceRowInnerProps {
|
interface WorkspaceRowInnerProps {
|
||||||
workspace: SidebarWorkspaceEntry;
|
workspace: SidebarWorkspaceEntry;
|
||||||
subtitle?: string | null;
|
hostBadge?: HostBadgeModel | null;
|
||||||
|
leadingProjectName?: string | null;
|
||||||
|
leadingProjectIconDataUri?: string | null;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
shortcutNumber: number | null;
|
shortcutNumber: number | null;
|
||||||
showShortcutBadge: boolean;
|
showShortcutBadge: boolean;
|
||||||
@@ -280,7 +296,7 @@ interface WorkspaceRowInnerProps {
|
|||||||
reserveIdleStatusIndicatorSpace?: boolean;
|
reserveIdleStatusIndicatorSpace?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PrBadge({ hint }: { hint: PrHint }) {
|
export function PrBadge({ hint, style }: { hint: PrHint; style?: StyleProp<ViewStyle> }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
@@ -299,6 +315,18 @@ export function PrBadge({ hint }: { hint: PrHint }) {
|
|||||||
const handleHoverIn = useCallback(() => setIsHovered(true), []);
|
const handleHoverIn = useCallback(() => setIsHovered(true), []);
|
||||||
const handleHoverOut = useCallback(() => setIsHovered(false), []);
|
const handleHoverOut = useCallback(() => setIsHovered(false), []);
|
||||||
|
|
||||||
|
// Callers that place the badge in a list of icon+text rows pass that row's layout in, so the
|
||||||
|
// icon and text land on the same rails as their neighbors instead of on the badge's tighter
|
||||||
|
// inline spacing.
|
||||||
|
const pressableStyle = useCallback(
|
||||||
|
({ pressed }: PressableStateCallbackType) => [
|
||||||
|
prBadgeStyles.badge,
|
||||||
|
style,
|
||||||
|
pressed && prBadgeStyles.badgePressed,
|
||||||
|
],
|
||||||
|
[style],
|
||||||
|
);
|
||||||
|
|
||||||
const textStyle = isHovered
|
const textStyle = isHovered
|
||||||
? [prBadgeStyles.text, prBadgeStyles.textHovered]
|
? [prBadgeStyles.text, prBadgeStyles.textHovered]
|
||||||
: prBadgeStyles.text;
|
: prBadgeStyles.text;
|
||||||
@@ -317,7 +345,7 @@ export function PrBadge({ hint }: { hint: PrHint }) {
|
|||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
onHoverIn={handleHoverIn}
|
onHoverIn={handleHoverIn}
|
||||||
onHoverOut={handleHoverOut}
|
onHoverOut={handleHoverOut}
|
||||||
style={prBadgePressableStyle}
|
style={pressableStyle}
|
||||||
>
|
>
|
||||||
{isHovered ? (
|
{isHovered ? (
|
||||||
<ThemedExternalLink size={12} uniProps={iconUniProps} />
|
<ThemedExternalLink size={12} uniProps={iconUniProps} />
|
||||||
@@ -332,10 +360,6 @@ export function PrBadge({ hint }: { hint: PrHint }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function prBadgePressableStyle({ pressed }: PressableStateCallbackType) {
|
|
||||||
return [prBadgeStyles.badge, pressed && prBadgeStyles.badgePressed];
|
|
||||||
}
|
|
||||||
|
|
||||||
function projectKebabStyle({
|
function projectKebabStyle({
|
||||||
hovered = false,
|
hovered = false,
|
||||||
}: PressableStateCallbackType & { hovered?: boolean }) {
|
}: PressableStateCallbackType & { hovered?: boolean }) {
|
||||||
@@ -382,9 +406,11 @@ const prBadgeStyles = StyleSheet.create((theme) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
function ProjectRowTrailingActions({
|
function ProjectRowTrailingActions({
|
||||||
project,
|
projectViewKey,
|
||||||
displayName,
|
displayName,
|
||||||
worktreeTarget,
|
worktreeTarget,
|
||||||
|
settingsTarget,
|
||||||
|
projectPath,
|
||||||
isHovered,
|
isHovered,
|
||||||
isMobileBreakpoint,
|
isMobileBreakpoint,
|
||||||
isProjectActive,
|
isProjectActive,
|
||||||
@@ -392,9 +418,11 @@ function ProjectRowTrailingActions({
|
|||||||
onRemoveProject,
|
onRemoveProject,
|
||||||
removeProjectStatus,
|
removeProjectStatus,
|
||||||
}: {
|
}: {
|
||||||
project: SidebarProjectEntry;
|
projectViewKey: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
worktreeTarget: SidebarProjectHostTarget | null;
|
worktreeTarget: SidebarProjectHostTarget | null;
|
||||||
|
settingsTarget: { serverId: string; projectId: string } | null;
|
||||||
|
projectPath: string;
|
||||||
isHovered: boolean;
|
isHovered: boolean;
|
||||||
isMobileBreakpoint: boolean;
|
isMobileBreakpoint: boolean;
|
||||||
isProjectActive: boolean;
|
isProjectActive: boolean;
|
||||||
@@ -403,9 +431,6 @@ function ProjectRowTrailingActions({
|
|||||||
removeProjectStatus: "idle" | "pending" | "success";
|
removeProjectStatus: "idle" | "pending" | "success";
|
||||||
}) {
|
}) {
|
||||||
const actionsVisible = isHovered || platformIsNative || isMobileBreakpoint;
|
const actionsVisible = isHovered || platformIsNative || isMobileBreakpoint;
|
||||||
const localDaemonServerId = useLocalDaemonServerId();
|
|
||||||
const localProjectPath = resolveSidebarProjectLocalPath(project, localDaemonServerId);
|
|
||||||
const settingsTarget = project.hosts[0] ?? null;
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.projectTrailingActions}>
|
<View style={styles.projectTrailingActions}>
|
||||||
{worktreeTarget ? (
|
{worktreeTarget ? (
|
||||||
@@ -414,7 +439,7 @@ function ProjectRowTrailingActions({
|
|||||||
onPress={onBeginWorkspaceSetup}
|
onPress={onBeginWorkspaceSetup}
|
||||||
visible={actionsVisible}
|
visible={actionsVisible}
|
||||||
showShortcutHint={isProjectActive}
|
showShortcutHint={isProjectActive}
|
||||||
testID={`sidebar-project-new-worktree-${project.viewKey}`}
|
testID={`sidebar-project-new-worktree-${projectViewKey}`}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{onRemoveProject ? (
|
{onRemoveProject ? (
|
||||||
@@ -423,9 +448,9 @@ function ProjectRowTrailingActions({
|
|||||||
pointerEvents={actionsVisible ? "auto" : "none"}
|
pointerEvents={actionsVisible ? "auto" : "none"}
|
||||||
>
|
>
|
||||||
<ProjectKebabMenu
|
<ProjectKebabMenu
|
||||||
projectViewKey={project.viewKey}
|
projectViewKey={projectViewKey}
|
||||||
settingsTarget={settingsTarget}
|
settingsTarget={settingsTarget}
|
||||||
projectPath={localProjectPath}
|
projectPath={projectPath}
|
||||||
onRemoveProject={onRemoveProject}
|
onRemoveProject={onRemoveProject}
|
||||||
removeProjectStatus={removeProjectStatus}
|
removeProjectStatus={removeProjectStatus}
|
||||||
/>
|
/>
|
||||||
@@ -464,26 +489,6 @@ function ProjectKebabMenu({
|
|||||||
removeProjectStatus: "idle" | "pending" | "success";
|
removeProjectStatus: "idle" | "pending" | "success";
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const toast = useToast();
|
|
||||||
const handleOpenProjectSettings = useCallback(() => {
|
|
||||||
if (!settingsTarget) return;
|
|
||||||
router.navigate(buildProjectSettingsRoute(settingsTarget.serverId, settingsTarget.projectId));
|
|
||||||
}, [settingsTarget]);
|
|
||||||
const canOpenProjectSettings = settingsTarget !== null;
|
|
||||||
// Desktop-only: open a second window that lands on this project via the same
|
|
||||||
// open-project flow as a CLI launch. The project stays visible here too — no
|
|
||||||
// ownership, no move.
|
|
||||||
const canOpenInNewWindow = getIsElectron() && projectPath.trim().length > 0;
|
|
||||||
const handleOpenInNewWindow = useCallback(() => {
|
|
||||||
const trimmedPath = projectPath.trim();
|
|
||||||
if (trimmedPath.length === 0) return;
|
|
||||||
void getDesktopHost()
|
|
||||||
?.window?.openNew?.({ pendingOpenProjectPath: trimmedPath })
|
|
||||||
?.catch((error) => {
|
|
||||||
console.warn("[sidebar] openNew failed", error);
|
|
||||||
toast.error(t("sidebar.project.actions.openNewWindowFailed"));
|
|
||||||
});
|
|
||||||
}, [projectPath, t, toast]);
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
@@ -496,42 +501,108 @@ function ProjectKebabMenu({
|
|||||||
{renderKebabTriggerIcon}
|
{renderKebabTriggerIcon}
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" width={220}>
|
<DropdownMenuContent align="end" width={220}>
|
||||||
{canOpenProjectSettings ? (
|
<ProjectMenuItems
|
||||||
<DropdownMenuItem
|
surface="dropdown"
|
||||||
testID={`sidebar-project-menu-open-settings-${projectViewKey}`}
|
projectViewKey={projectViewKey}
|
||||||
leading={settingsLeadingIcon}
|
settingsTarget={settingsTarget}
|
||||||
onSelect={handleOpenProjectSettings}
|
projectPath={projectPath}
|
||||||
>
|
onRemoveProject={onRemoveProject}
|
||||||
{t("sidebar.project.actions.openSettings")}
|
removeProjectStatus={removeProjectStatus}
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
{canOpenInNewWindow ? (
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID={`sidebar-project-menu-open-new-window-${projectViewKey}`}
|
|
||||||
leading={openInNewWindowLeadingIcon}
|
|
||||||
onSelect={handleOpenInNewWindow}
|
|
||||||
>
|
|
||||||
{t("sidebar.project.actions.openNewWindow")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
<OpenInFileManagerMenuItem
|
|
||||||
path={projectPath}
|
|
||||||
testID={`sidebar-project-menu-open-folder-${projectViewKey}`}
|
|
||||||
/>
|
/>
|
||||||
<DropdownMenuItem
|
|
||||||
testID={`sidebar-project-menu-remove-${projectViewKey}`}
|
|
||||||
leading={trash2LeadingIcon}
|
|
||||||
status={removeProjectStatus}
|
|
||||||
pendingLabel={t("sidebar.project.actions.removing")}
|
|
||||||
onSelect={onRemoveProject}
|
|
||||||
>
|
|
||||||
{t("sidebar.project.actions.remove")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProjectMenuSurface = "context" | "dropdown";
|
||||||
|
|
||||||
|
function ProjectMenuItem({
|
||||||
|
surface,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: PropsWithChildren<
|
||||||
|
Omit<ComponentProps<typeof DropdownMenuItem>, "children"> & { surface: ProjectMenuSurface }
|
||||||
|
>) {
|
||||||
|
if (surface === "context") {
|
||||||
|
return <ContextMenuItem {...props}>{children}</ContextMenuItem>;
|
||||||
|
}
|
||||||
|
return <DropdownMenuItem {...props}>{children}</DropdownMenuItem>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectMenuItems({
|
||||||
|
surface,
|
||||||
|
projectViewKey,
|
||||||
|
settingsTarget,
|
||||||
|
projectPath,
|
||||||
|
onRemoveProject,
|
||||||
|
removeProjectStatus,
|
||||||
|
}: {
|
||||||
|
surface: ProjectMenuSurface;
|
||||||
|
projectViewKey: string;
|
||||||
|
settingsTarget: { serverId: string; projectId: string } | null;
|
||||||
|
projectPath: string;
|
||||||
|
onRemoveProject: () => void;
|
||||||
|
removeProjectStatus: "idle" | "pending" | "success";
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const toast = useToast();
|
||||||
|
const handleOpenProjectSettings = useCallback(() => {
|
||||||
|
if (!settingsTarget) return;
|
||||||
|
router.navigate(buildProjectSettingsRoute(settingsTarget.serverId, settingsTarget.projectId));
|
||||||
|
}, [settingsTarget]);
|
||||||
|
const canOpenInNewWindow = getIsElectron() && projectPath.trim().length > 0;
|
||||||
|
const handleOpenInNewWindow = useCallback(() => {
|
||||||
|
const trimmedPath = projectPath.trim();
|
||||||
|
if (trimmedPath.length === 0) return;
|
||||||
|
void getDesktopHost()
|
||||||
|
?.window?.openNew?.({ pendingOpenProjectPath: trimmedPath })
|
||||||
|
?.catch((error) => {
|
||||||
|
console.warn("[sidebar] openNew failed", error);
|
||||||
|
toast.error(t("sidebar.project.actions.openNewWindowFailed"));
|
||||||
|
});
|
||||||
|
}, [projectPath, t, toast]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{settingsTarget ? (
|
||||||
|
<ProjectMenuItem
|
||||||
|
surface={surface}
|
||||||
|
testID={`sidebar-project-menu-open-settings-${projectViewKey}`}
|
||||||
|
leading={settingsLeadingIcon}
|
||||||
|
onSelect={handleOpenProjectSettings}
|
||||||
|
>
|
||||||
|
{t("sidebar.project.actions.openSettings")}
|
||||||
|
</ProjectMenuItem>
|
||||||
|
) : null}
|
||||||
|
{canOpenInNewWindow ? (
|
||||||
|
<ProjectMenuItem
|
||||||
|
surface={surface}
|
||||||
|
testID={`sidebar-project-menu-open-new-window-${projectViewKey}`}
|
||||||
|
leading={openInNewWindowLeadingIcon}
|
||||||
|
onSelect={handleOpenInNewWindow}
|
||||||
|
>
|
||||||
|
{t("sidebar.project.actions.openNewWindow")}
|
||||||
|
</ProjectMenuItem>
|
||||||
|
) : null}
|
||||||
|
<OpenInFileManagerMenuItem
|
||||||
|
surface={surface}
|
||||||
|
path={projectPath}
|
||||||
|
testID={`sidebar-project-menu-open-folder-${projectViewKey}`}
|
||||||
|
/>
|
||||||
|
<ProjectMenuItem
|
||||||
|
surface={surface}
|
||||||
|
testID={`sidebar-project-menu-remove-${projectViewKey}`}
|
||||||
|
leading={trash2LeadingIcon}
|
||||||
|
status={removeProjectStatus}
|
||||||
|
pendingLabel={t("sidebar.project.actions.removing")}
|
||||||
|
onSelect={onRemoveProject}
|
||||||
|
>
|
||||||
|
{t("sidebar.project.actions.remove")}
|
||||||
|
</ProjectMenuItem>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function WorkspaceRowRightGroup({
|
function WorkspaceRowRightGroup({
|
||||||
workspace,
|
workspace,
|
||||||
isHovered,
|
isHovered,
|
||||||
@@ -572,8 +643,17 @@ function WorkspaceRowRightGroup({
|
|||||||
const workspacePath = workspace.workspaceDirectory ?? workspace.projectRootPath;
|
const workspacePath = workspace.workspaceDirectory ?? workspace.projectRootPath;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const showShortcut = showShortcutBadge && shortcutNumber !== null;
|
const showShortcut = showShortcutBadge && shortcutNumber !== null;
|
||||||
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
|
const {
|
||||||
const showKebabInSlot = showKebab && !showShortcut;
|
showDiffStat,
|
||||||
|
showKebab: showKebabInSlot,
|
||||||
|
showScrim,
|
||||||
|
} = resolveTrailingActionVisibility({
|
||||||
|
hasDiffStat: Boolean(workspace.diffStat),
|
||||||
|
hasArchiveAction: Boolean(onArchive),
|
||||||
|
isHovered,
|
||||||
|
isTouchPlatform,
|
||||||
|
showShortcut,
|
||||||
|
});
|
||||||
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
|
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -583,9 +663,7 @@ function WorkspaceRowRightGroup({
|
|||||||
) : null}
|
) : null}
|
||||||
{shouldRenderActionSlot ? (
|
{shouldRenderActionSlot ? (
|
||||||
<SidebarWorkspaceTrailingActionSlot>
|
<SidebarWorkspaceTrailingActionSlot>
|
||||||
<SidebarWorkspaceTrailingActionBase
|
<SidebarWorkspaceTrailingActionBase visible={showDiffStat}>
|
||||||
visible={Boolean(workspace.diffStat && !showKebabInSlot && !showShortcut)}
|
|
||||||
>
|
|
||||||
{workspace.diffStat ? (
|
{workspace.diffStat ? (
|
||||||
<DiffStat
|
<DiffStat
|
||||||
additions={workspace.diffStat.additions}
|
additions={workspace.diffStat.additions}
|
||||||
@@ -593,7 +671,7 @@ function WorkspaceRowRightGroup({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</SidebarWorkspaceTrailingActionBase>
|
</SidebarWorkspaceTrailingActionBase>
|
||||||
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot}>
|
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot} scrim={showScrim}>
|
||||||
{onArchive ? (
|
{onArchive ? (
|
||||||
<SidebarWorkspaceMenu
|
<SidebarWorkspaceMenu
|
||||||
workspaceKey={workspace.workspaceKey}
|
workspaceKey={workspace.workspaceKey}
|
||||||
@@ -784,7 +862,11 @@ function ProjectHeaderRow({
|
|||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
}: ProjectHeaderRowProps) {
|
}: ProjectHeaderRowProps) {
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const [contextMenuOpen, setContextMenuOpen] = useState(false);
|
||||||
const isMobileBreakpoint = useIsCompactFormFactor();
|
const isMobileBreakpoint = useIsCompactFormFactor();
|
||||||
|
const localDaemonServerId = useLocalDaemonServerId();
|
||||||
|
const projectPath = resolveSidebarProjectLocalPath(project, localDaemonServerId);
|
||||||
|
const settingsTarget = project.hosts[0] ?? null;
|
||||||
const handleBeginWorkspaceSetup = useCallback(() => {
|
const handleBeginWorkspaceSetup = useCallback(() => {
|
||||||
if (!worktreeTarget) {
|
if (!worktreeTarget) {
|
||||||
return;
|
return;
|
||||||
@@ -818,8 +900,14 @@ function ProjectHeaderRow({
|
|||||||
onPress();
|
onPress();
|
||||||
}, [interaction.didLongPressRef, onPress]);
|
}, [interaction.didLongPressRef, onPress]);
|
||||||
|
|
||||||
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
const handlePointerEnter = useCallback(() => {
|
||||||
|
if (!contextMenuOpen) setIsHovered(true);
|
||||||
|
}, [contextMenuOpen]);
|
||||||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||||||
|
const handleContextMenuOpenChange = useCallback((open: boolean) => {
|
||||||
|
setContextMenuOpen(open);
|
||||||
|
if (open) setIsHovered(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const projectRowStyle = useCallback(
|
const projectRowStyle = useCallback(
|
||||||
({ pressed }: PressableStateCallbackType) => [
|
({ pressed }: PressableStateCallbackType) => [
|
||||||
@@ -852,9 +940,11 @@ function ProjectHeaderRow({
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<ProjectRowTrailingActions
|
<ProjectRowTrailingActions
|
||||||
project={project}
|
projectViewKey={project.viewKey}
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
worktreeTarget={worktreeTarget}
|
worktreeTarget={worktreeTarget}
|
||||||
|
settingsTarget={settingsTarget}
|
||||||
|
projectPath={projectPath}
|
||||||
isHovered={isHovered}
|
isHovered={isHovered}
|
||||||
isMobileBreakpoint={isMobileBreakpoint}
|
isMobileBreakpoint={isMobileBreakpoint}
|
||||||
isProjectActive={isProjectActive}
|
isProjectActive={isProjectActive}
|
||||||
@@ -870,8 +960,32 @@ function ProjectHeaderRow({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (menuController) {
|
if (!onRemoveProject) {
|
||||||
return (
|
return (
|
||||||
|
<View
|
||||||
|
{...dragAttributes}
|
||||||
|
{...dragHandleProps?.listeners}
|
||||||
|
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
|
||||||
|
onPointerEnter={handlePointerEnter}
|
||||||
|
onPointerLeave={handlePointerLeave}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
style={projectRowStyle}
|
||||||
|
onPressIn={interaction.handlePressIn}
|
||||||
|
onTouchMove={interaction.handleTouchMove}
|
||||||
|
onPressOut={interaction.handlePressOut}
|
||||||
|
onPress={handlePress}
|
||||||
|
testID={`sidebar-project-row-${project.viewKey}`}
|
||||||
|
>
|
||||||
|
{rowChildren}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu open={contextMenuOpen} onOpenChange={handleContextMenuOpenChange}>
|
||||||
<View
|
<View
|
||||||
{...dragAttributes}
|
{...dragAttributes}
|
||||||
{...dragHandleProps?.listeners}
|
{...dragHandleProps?.listeners}
|
||||||
@@ -892,35 +1006,29 @@ function ProjectHeaderRow({
|
|||||||
{rowChildren}
|
{rowChildren}
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
</View>
|
</View>
|
||||||
);
|
<ContextMenuContent
|
||||||
}
|
align="start"
|
||||||
|
width={220}
|
||||||
return (
|
testID={`sidebar-project-context-menu-${project.viewKey}`}
|
||||||
<View
|
|
||||||
{...dragAttributes}
|
|
||||||
{...dragHandleProps?.listeners}
|
|
||||||
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
|
|
||||||
onPointerEnter={handlePointerEnter}
|
|
||||||
onPointerLeave={handlePointerLeave}
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
accessibilityRole="button"
|
|
||||||
style={projectRowStyle}
|
|
||||||
onPressIn={interaction.handlePressIn}
|
|
||||||
onTouchMove={interaction.handleTouchMove}
|
|
||||||
onPressOut={interaction.handlePressOut}
|
|
||||||
onPress={handlePress}
|
|
||||||
testID={`sidebar-project-row-${project.viewKey}`}
|
|
||||||
>
|
>
|
||||||
{rowChildren}
|
<ProjectMenuItems
|
||||||
</Pressable>
|
surface="context"
|
||||||
</View>
|
projectViewKey={project.viewKey}
|
||||||
|
settingsTarget={settingsTarget}
|
||||||
|
projectPath={projectPath}
|
||||||
|
onRemoveProject={onRemoveProject}
|
||||||
|
removeProjectStatus={removeProjectStatus}
|
||||||
|
/>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorkspaceRowInner({
|
function WorkspaceRowInner({
|
||||||
workspace,
|
workspace,
|
||||||
subtitle,
|
hostBadge,
|
||||||
|
leadingProjectName,
|
||||||
|
leadingProjectIconDataUri,
|
||||||
selected,
|
selected,
|
||||||
shortcutNumber,
|
shortcutNumber,
|
||||||
showShortcutBadge,
|
showShortcutBadge,
|
||||||
@@ -968,7 +1076,7 @@ function WorkspaceRowInner({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
|
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
|
||||||
{({ isHovered, hoverHandlers }) => {
|
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
|
||||||
const isDesktop = !isTouchPlatform;
|
const isDesktop = !isTouchPlatform;
|
||||||
const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
|
const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
|
||||||
const hasRunningService = workspace.scripts.some(
|
const hasRunningService = workspace.scripts.some(
|
||||||
@@ -991,7 +1099,24 @@ function WorkspaceRowInner({
|
|||||||
style={styles.workspaceRowContainer}
|
style={styles.workspaceRowContainer}
|
||||||
{...hoverHandlers}
|
{...hoverHandlers}
|
||||||
>
|
>
|
||||||
<Pressable
|
<SidebarWorkspaceContextMenu
|
||||||
|
contextMenuOpen={contextMenuOpen}
|
||||||
|
onContextMenuOpenChange={onContextMenuOpenChange}
|
||||||
|
workspace={workspace}
|
||||||
|
leadingProjectName={leadingProjectName}
|
||||||
|
hostBadgeLabel={hostBadge?.label}
|
||||||
|
workspaceKey={workspace.workspaceKey}
|
||||||
|
onCopyPath={onCopyPath}
|
||||||
|
onCopyBranchName={onCopyBranchName}
|
||||||
|
onRename={onRename}
|
||||||
|
onArchive={onArchive}
|
||||||
|
archiveLabel={archiveLabel}
|
||||||
|
archiveStatus={archiveStatus}
|
||||||
|
archivePendingLabel={archivePendingLabel}
|
||||||
|
archiveShortcutKeys={archiveShortcutKeys}
|
||||||
|
isPinned={isPinned}
|
||||||
|
onTogglePin={onTogglePin}
|
||||||
|
openInFileManagerPath={workspace.workspaceDirectory}
|
||||||
disabled={isArchiving}
|
disabled={isArchiving}
|
||||||
aria-selected={selected}
|
aria-selected={selected}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
@@ -1005,7 +1130,9 @@ function WorkspaceRowInner({
|
|||||||
>
|
>
|
||||||
<SidebarWorkspaceRowContent
|
<SidebarWorkspaceRowContent
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
subtitle={subtitle}
|
hostBadge={hostBadge}
|
||||||
|
leadingProjectName={leadingProjectName}
|
||||||
|
leadingProjectIconDataUri={leadingProjectIconDataUri}
|
||||||
scriptIconKind={scriptIconKind}
|
scriptIconKind={scriptIconKind}
|
||||||
isHovered={isHovered}
|
isHovered={isHovered}
|
||||||
isLoading={isArchiving || isCreating}
|
isLoading={isArchiving || isCreating}
|
||||||
@@ -1033,7 +1160,7 @@ function WorkspaceRowInner({
|
|||||||
onTogglePin={onTogglePin}
|
onTogglePin={onTogglePin}
|
||||||
/>
|
/>
|
||||||
</SidebarWorkspaceRowContent>
|
</SidebarWorkspaceRowContent>
|
||||||
</Pressable>
|
</SidebarWorkspaceContextMenu>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -1043,7 +1170,9 @@ function WorkspaceRowInner({
|
|||||||
|
|
||||||
function WorkspaceRowWithMenu({
|
function WorkspaceRowWithMenu({
|
||||||
workspace,
|
workspace,
|
||||||
subtitle,
|
hostBadge,
|
||||||
|
leadingProjectName,
|
||||||
|
leadingProjectIconDataUri,
|
||||||
selected,
|
selected,
|
||||||
shortcutNumber,
|
shortcutNumber,
|
||||||
showShortcutBadge,
|
showShortcutBadge,
|
||||||
@@ -1058,7 +1187,9 @@ function WorkspaceRowWithMenu({
|
|||||||
isCreating = false,
|
isCreating = false,
|
||||||
}: {
|
}: {
|
||||||
workspace: SidebarWorkspaceEntry;
|
workspace: SidebarWorkspaceEntry;
|
||||||
subtitle?: string | null;
|
hostBadge?: HostBadgeModel | null;
|
||||||
|
leadingProjectName?: string | null;
|
||||||
|
leadingProjectIconDataUri?: string | null;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
shortcutNumber: number | null;
|
shortcutNumber: number | null;
|
||||||
showShortcutBadge: boolean;
|
showShortcutBadge: boolean;
|
||||||
@@ -1186,7 +1317,9 @@ function WorkspaceRowWithMenu({
|
|||||||
<>
|
<>
|
||||||
<WorkspaceRowInner
|
<WorkspaceRowInner
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
subtitle={subtitle}
|
hostBadge={hostBadge}
|
||||||
|
leadingProjectName={leadingProjectName}
|
||||||
|
leadingProjectIconDataUri={leadingProjectIconDataUri}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
shortcutNumber={shortcutNumber}
|
shortcutNumber={shortcutNumber}
|
||||||
showShortcutBadge={showShortcutBadge}
|
showShortcutBadge={showShortcutBadge}
|
||||||
@@ -1227,7 +1360,9 @@ function WorkspaceRowWithMenu({
|
|||||||
interface WorkspaceRowItemProps {
|
interface WorkspaceRowItemProps {
|
||||||
workspace: SidebarWorkspacePlacement;
|
workspace: SidebarWorkspacePlacement;
|
||||||
workspaceEntry: SidebarWorkspaceEntry | null;
|
workspaceEntry: SidebarWorkspaceEntry | null;
|
||||||
subtitle?: string | null;
|
hostBadge?: HostBadgeModel | null;
|
||||||
|
leadingProjectName?: string | null;
|
||||||
|
leadingProjectIconDataUri?: string | null;
|
||||||
shortcutNumber: number | null;
|
shortcutNumber: number | null;
|
||||||
showShortcutBadge: boolean;
|
showShortcutBadge: boolean;
|
||||||
canCopyBranchName: boolean;
|
canCopyBranchName: boolean;
|
||||||
@@ -1246,7 +1381,9 @@ interface WorkspaceRowItemProps {
|
|||||||
function WorkspaceRowItem({
|
function WorkspaceRowItem({
|
||||||
workspace,
|
workspace,
|
||||||
workspaceEntry,
|
workspaceEntry,
|
||||||
subtitle,
|
hostBadge,
|
||||||
|
leadingProjectName,
|
||||||
|
leadingProjectIconDataUri,
|
||||||
shortcutNumber,
|
shortcutNumber,
|
||||||
showShortcutBadge,
|
showShortcutBadge,
|
||||||
canCopyBranchName,
|
canCopyBranchName,
|
||||||
@@ -1272,7 +1409,9 @@ function WorkspaceRowItem({
|
|||||||
return (
|
return (
|
||||||
<WorkspaceRow
|
<WorkspaceRow
|
||||||
workspaceEntry={workspaceEntry}
|
workspaceEntry={workspaceEntry}
|
||||||
subtitle={subtitle}
|
hostBadge={hostBadge}
|
||||||
|
leadingProjectName={leadingProjectName}
|
||||||
|
leadingProjectIconDataUri={leadingProjectIconDataUri}
|
||||||
shortcutNumber={shortcutNumber}
|
shortcutNumber={shortcutNumber}
|
||||||
showShortcutBadge={showShortcutBadge}
|
showShortcutBadge={showShortcutBadge}
|
||||||
canCopyBranchName={canCopyBranchName}
|
canCopyBranchName={canCopyBranchName}
|
||||||
@@ -1313,7 +1452,9 @@ function areWorkspaceRowItemPropsEqual(
|
|||||||
return (
|
return (
|
||||||
previous.workspace === next.workspace &&
|
previous.workspace === next.workspace &&
|
||||||
previous.workspaceEntry === next.workspaceEntry &&
|
previous.workspaceEntry === next.workspaceEntry &&
|
||||||
previous.subtitle === next.subtitle &&
|
previous.hostBadge === next.hostBadge &&
|
||||||
|
previous.leadingProjectName === next.leadingProjectName &&
|
||||||
|
previous.leadingProjectIconDataUri === next.leadingProjectIconDataUri &&
|
||||||
previous.shortcutNumber === next.shortcutNumber &&
|
previous.shortcutNumber === next.shortcutNumber &&
|
||||||
previous.showShortcutBadge === next.showShortcutBadge &&
|
previous.showShortcutBadge === next.showShortcutBadge &&
|
||||||
previous.canCopyBranchName === next.canCopyBranchName &&
|
previous.canCopyBranchName === next.canCopyBranchName &&
|
||||||
@@ -1333,7 +1474,9 @@ const MemoWorkspaceRowItem = memo(WorkspaceRowItem, areWorkspaceRowItemPropsEqua
|
|||||||
|
|
||||||
function WorkspaceRow({
|
function WorkspaceRow({
|
||||||
workspaceEntry,
|
workspaceEntry,
|
||||||
subtitle,
|
hostBadge,
|
||||||
|
leadingProjectName,
|
||||||
|
leadingProjectIconDataUri,
|
||||||
shortcutNumber,
|
shortcutNumber,
|
||||||
showShortcutBadge,
|
showShortcutBadge,
|
||||||
onPress,
|
onPress,
|
||||||
@@ -1348,7 +1491,9 @@ function WorkspaceRow({
|
|||||||
selected,
|
selected,
|
||||||
}: {
|
}: {
|
||||||
workspaceEntry: SidebarWorkspaceEntry | null;
|
workspaceEntry: SidebarWorkspaceEntry | null;
|
||||||
subtitle?: string | null;
|
hostBadge?: HostBadgeModel | null;
|
||||||
|
leadingProjectName?: string | null;
|
||||||
|
leadingProjectIconDataUri?: string | null;
|
||||||
shortcutNumber: number | null;
|
shortcutNumber: number | null;
|
||||||
showShortcutBadge: boolean;
|
showShortcutBadge: boolean;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
@@ -1369,7 +1514,9 @@ function WorkspaceRow({
|
|||||||
return (
|
return (
|
||||||
<WorkspaceRowWithMenu
|
<WorkspaceRowWithMenu
|
||||||
workspace={workspaceEntry}
|
workspace={workspaceEntry}
|
||||||
subtitle={subtitle}
|
hostBadge={hostBadge}
|
||||||
|
leadingProjectName={leadingProjectName}
|
||||||
|
leadingProjectIconDataUri={leadingProjectIconDataUri}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
shortcutNumber={shortcutNumber}
|
shortcutNumber={shortcutNumber}
|
||||||
showShortcutBadge={showShortcutBadge}
|
showShortcutBadge={showShortcutBadge}
|
||||||
@@ -1407,8 +1554,7 @@ function ProjectBlock({
|
|||||||
dragGestureHostPresented,
|
dragGestureHostPresented,
|
||||||
creatingWorkspaceIds,
|
creatingWorkspaceIds,
|
||||||
activeWorkspaceSelection,
|
activeWorkspaceSelection,
|
||||||
hostLabelByServerId,
|
hostBadgeByServerId,
|
||||||
showHostLabels,
|
|
||||||
supportsMultiplicityByServerId,
|
supportsMultiplicityByServerId,
|
||||||
supportsPinningByServerId,
|
supportsPinningByServerId,
|
||||||
onToggleWorkspacePin,
|
onToggleWorkspacePin,
|
||||||
@@ -1433,8 +1579,7 @@ function ProjectBlock({
|
|||||||
dragGestureHostPresented?: boolean;
|
dragGestureHostPresented?: boolean;
|
||||||
creatingWorkspaceIds: ReadonlySet<string>;
|
creatingWorkspaceIds: ReadonlySet<string>;
|
||||||
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
||||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
|
||||||
showHostLabels: boolean;
|
|
||||||
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
|
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
|
||||||
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
||||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||||
@@ -1481,9 +1626,7 @@ function ProjectBlock({
|
|||||||
<MemoWorkspaceRowItem
|
<MemoWorkspaceRowItem
|
||||||
workspace={item}
|
workspace={item}
|
||||||
workspaceEntry={workspaceEntriesByKey.get(item.workspaceKey) ?? null}
|
workspaceEntry={workspaceEntriesByKey.get(item.workspaceKey) ?? null}
|
||||||
subtitle={
|
hostBadge={hostBadgeByServerId.get(item.serverId) ?? null}
|
||||||
showHostLabels ? (hostLabelByServerId.get(item.serverId) ?? item.serverId) : null
|
|
||||||
}
|
|
||||||
shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null}
|
shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null}
|
||||||
showShortcutBadge={showShortcutBadges}
|
showShortcutBadge={showShortcutBadges}
|
||||||
canCopyBranchName={project.projectKind === "git"}
|
canCopyBranchName={project.projectKind === "git"}
|
||||||
@@ -1503,10 +1646,9 @@ function ProjectBlock({
|
|||||||
project.projectKind,
|
project.projectKind,
|
||||||
onToggleWorkspacePin,
|
onToggleWorkspacePin,
|
||||||
supportsPinningByServerId,
|
supportsPinningByServerId,
|
||||||
showHostLabels,
|
|
||||||
activeWorkspaceSelection,
|
activeWorkspaceSelection,
|
||||||
creatingWorkspaceIds,
|
creatingWorkspaceIds,
|
||||||
hostLabelByServerId,
|
hostBadgeByServerId,
|
||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
selectionEnabled,
|
selectionEnabled,
|
||||||
shortcutIndexByWorkspaceKey,
|
shortcutIndexByWorkspaceKey,
|
||||||
@@ -1681,8 +1823,7 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
|
|||||||
previous.selectionEnabled === next.selectionEnabled &&
|
previous.selectionEnabled === next.selectionEnabled &&
|
||||||
previous.showShortcutBadges === next.showShortcutBadges &&
|
previous.showShortcutBadges === next.showShortcutBadges &&
|
||||||
previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey &&
|
previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey &&
|
||||||
previous.hostLabelByServerId === next.hostLabelByServerId &&
|
previous.hostBadgeByServerId === next.hostBadgeByServerId &&
|
||||||
previous.showHostLabels === next.showHostLabels &&
|
|
||||||
previous.supportsMultiplicityByServerId === next.supportsMultiplicityByServerId &&
|
previous.supportsMultiplicityByServerId === next.supportsMultiplicityByServerId &&
|
||||||
previous.supportsPinningByServerId === next.supportsPinningByServerId &&
|
previous.supportsPinningByServerId === next.supportsPinningByServerId &&
|
||||||
previous.onToggleWorkspacePin === next.onToggleWorkspacePin &&
|
previous.onToggleWorkspacePin === next.onToggleWorkspacePin &&
|
||||||
@@ -1734,7 +1875,6 @@ export function SidebarWorkspaceList({
|
|||||||
pinnedGroups,
|
pinnedGroups,
|
||||||
projects,
|
projects,
|
||||||
workspaceEntriesByKey,
|
workspaceEntriesByKey,
|
||||||
projectNamesByViewKey,
|
|
||||||
collapsedProjectKeys,
|
collapsedProjectKeys,
|
||||||
onToggleProjectCollapsed,
|
onToggleProjectCollapsed,
|
||||||
shortcutIndexByWorkspaceKey,
|
shortcutIndexByWorkspaceKey,
|
||||||
@@ -1750,18 +1890,22 @@ export function SidebarWorkspaceList({
|
|||||||
}: SidebarWorkspaceListProps) {
|
}: SidebarWorkspaceListProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const hosts = useHosts();
|
const hosts = useHosts();
|
||||||
const hostLabelByServerId = useMemo(() => {
|
const localServerId = useLocalDaemonServerId();
|
||||||
const labels = new Map<string, string>();
|
const localDaemon = useLocalDaemonServerIdState();
|
||||||
for (const host of hosts) {
|
const hostBadgeByServerId = useMemo(
|
||||||
labels.set(host.serverId, host.label?.trim() || host.serverId);
|
() =>
|
||||||
}
|
selectHostBadges({
|
||||||
return labels;
|
hosts,
|
||||||
}, [hosts]);
|
localServerId,
|
||||||
|
localHostResolutionPending: localDaemon.status !== "resolved",
|
||||||
|
enabled: shouldShowSidebarHostLabels(projects),
|
||||||
|
}),
|
||||||
|
[hosts, localDaemon.status, localServerId, projects],
|
||||||
|
);
|
||||||
const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
|
const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
|
||||||
const supportsMultiplicityByServerId = useHostFeatureMap(serverIds, "workspaceMultiplicity");
|
const supportsMultiplicityByServerId = useHostFeatureMap(serverIds, "workspaceMultiplicity");
|
||||||
const supportsPinningByServerId = useHostFeatureMap(serverIds, "workspacePinning");
|
const supportsPinningByServerId = useHostFeatureMap(serverIds, "workspacePinning");
|
||||||
const onToggleWorkspacePin = useSidebarWorkspacePinController();
|
const onToggleWorkspacePin = useSidebarWorkspacePinController();
|
||||||
const showHostLabels = useMemo(() => shouldShowSidebarHostLabels(projects), [projects]);
|
|
||||||
// Status mode drops the project grouping, so its rows carry their own project
|
// Status mode drops the project grouping, so its rows carry their own project
|
||||||
// icon. Project mode fetches the same icons inside ProjectModeList for its
|
// icon. Project mode fetches the same icons inside ProjectModeList for its
|
||||||
// project headers, so only the active mode requests them.
|
// project headers, so only the active mode requests them.
|
||||||
@@ -1779,12 +1923,10 @@ export function SidebarWorkspaceList({
|
|||||||
statusGroups={statusGroups}
|
statusGroups={statusGroups}
|
||||||
pinnedGroups={pinnedGroups}
|
pinnedGroups={pinnedGroups}
|
||||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||||
projectNamesByViewKey={projectNamesByViewKey}
|
|
||||||
projectIconByProjectViewKey={statusProjectIconByProjectViewKey}
|
projectIconByProjectViewKey={statusProjectIconByProjectViewKey}
|
||||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||||
onWorkspacePress={onWorkspacePress}
|
onWorkspacePress={onWorkspacePress}
|
||||||
hostLabelByServerId={hostLabelByServerId}
|
hostBadgeByServerId={hostBadgeByServerId}
|
||||||
showHostLabels={showHostLabels}
|
|
||||||
supportsPinningByServerId={supportsPinningByServerId}
|
supportsPinningByServerId={supportsPinningByServerId}
|
||||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||||
listHeaderComponent={listHeaderComponent}
|
listHeaderComponent={listHeaderComponent}
|
||||||
@@ -1804,8 +1946,7 @@ export function SidebarWorkspaceList({
|
|||||||
parentGestureRef={parentGestureRef}
|
parentGestureRef={parentGestureRef}
|
||||||
dragGestureHostPresented={dragGestureHostPresented}
|
dragGestureHostPresented={dragGestureHostPresented}
|
||||||
pathname={pathname}
|
pathname={pathname}
|
||||||
hostLabelByServerId={hostLabelByServerId}
|
hostBadgeByServerId={hostBadgeByServerId}
|
||||||
showHostLabels={showHostLabels}
|
|
||||||
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
|
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
|
||||||
supportsPinningByServerId={supportsPinningByServerId}
|
supportsPinningByServerId={supportsPinningByServerId}
|
||||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||||
@@ -1819,12 +1960,10 @@ function SidebarStatusModeWrapper({
|
|||||||
statusGroups,
|
statusGroups,
|
||||||
pinnedGroups,
|
pinnedGroups,
|
||||||
workspaceEntriesByKey,
|
workspaceEntriesByKey,
|
||||||
projectNamesByViewKey,
|
|
||||||
projectIconByProjectViewKey,
|
projectIconByProjectViewKey,
|
||||||
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
|
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
|
||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
hostLabelByServerId,
|
hostBadgeByServerId,
|
||||||
showHostLabels,
|
|
||||||
supportsPinningByServerId,
|
supportsPinningByServerId,
|
||||||
onToggleWorkspacePin,
|
onToggleWorkspacePin,
|
||||||
listHeaderComponent,
|
listHeaderComponent,
|
||||||
@@ -1832,12 +1971,10 @@ function SidebarStatusModeWrapper({
|
|||||||
statusGroups: StatusGroup[];
|
statusGroups: StatusGroup[];
|
||||||
pinnedGroups: PinnedSidebarGroups;
|
pinnedGroups: PinnedSidebarGroups;
|
||||||
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||||
projectNamesByViewKey: Map<string, string>;
|
|
||||||
projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
|
projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
|
||||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||||
onWorkspacePress?: () => void;
|
onWorkspacePress?: () => void;
|
||||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
|
||||||
showHostLabels: boolean;
|
|
||||||
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
||||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||||
listHeaderComponent?: ReactElement | null;
|
listHeaderComponent?: ReactElement | null;
|
||||||
@@ -1851,13 +1988,11 @@ function SidebarStatusModeWrapper({
|
|||||||
const entry = workspaceEntriesByKey.get(workspace.workspaceKey);
|
const entry = workspaceEntriesByKey.get(workspace.workspaceKey);
|
||||||
return entry ? [entry] : [];
|
return entry ? [entry] : [];
|
||||||
})}
|
})}
|
||||||
projectNamesByViewKey={projectNamesByViewKey}
|
|
||||||
projectIconByProjectViewKey={projectIconByProjectViewKey}
|
projectIconByProjectViewKey={projectIconByProjectViewKey}
|
||||||
shortcutIndexByWorkspaceKey={_projectShortcutIndex}
|
shortcutIndexByWorkspaceKey={_projectShortcutIndex}
|
||||||
showShortcutBadges={showShortcutBadges}
|
showShortcutBadges={showShortcutBadges}
|
||||||
onWorkspacePress={onWorkspacePress}
|
onWorkspacePress={onWorkspacePress}
|
||||||
hostLabelByServerId={hostLabelByServerId}
|
hostBadgeByServerId={hostBadgeByServerId}
|
||||||
showHostLabels={showHostLabels}
|
|
||||||
supportsPinningByServerId={supportsPinningByServerId}
|
supportsPinningByServerId={supportsPinningByServerId}
|
||||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||||
listHeaderComponent={listHeaderComponent}
|
listHeaderComponent={listHeaderComponent}
|
||||||
@@ -1879,18 +2014,13 @@ function ProjectModeList({
|
|||||||
parentGestureRef,
|
parentGestureRef,
|
||||||
dragGestureHostPresented,
|
dragGestureHostPresented,
|
||||||
pathname,
|
pathname,
|
||||||
hostLabelByServerId,
|
hostBadgeByServerId,
|
||||||
showHostLabels,
|
|
||||||
supportsMultiplicityByServerId,
|
supportsMultiplicityByServerId,
|
||||||
supportsPinningByServerId,
|
supportsPinningByServerId,
|
||||||
onToggleWorkspacePin,
|
onToggleWorkspacePin,
|
||||||
}: Omit<
|
}: Omit<SidebarWorkspaceListProps, "statusGroups" | "groupMode" | "isRefreshing" | "onRefresh"> & {
|
||||||
SidebarWorkspaceListProps,
|
|
||||||
"statusGroups" | "projectNamesByViewKey" | "groupMode" | "isRefreshing" | "onRefresh"
|
|
||||||
> & {
|
|
||||||
pathname: string;
|
pathname: string;
|
||||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
|
||||||
showHostLabels: boolean;
|
|
||||||
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
|
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
|
||||||
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
||||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||||
@@ -2095,8 +2225,7 @@ function ProjectModeList({
|
|||||||
dragGestureHostPresented={dragGestureHostPresented}
|
dragGestureHostPresented={dragGestureHostPresented}
|
||||||
creatingWorkspaceIds={creatingWorkspaceIds}
|
creatingWorkspaceIds={creatingWorkspaceIds}
|
||||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||||
hostLabelByServerId={hostLabelByServerId}
|
hostBadgeByServerId={hostBadgeByServerId}
|
||||||
showHostLabels={showHostLabels}
|
|
||||||
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
|
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
|
||||||
supportsPinningByServerId={supportsPinningByServerId}
|
supportsPinningByServerId={supportsPinningByServerId}
|
||||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||||
@@ -2108,8 +2237,7 @@ function ProjectModeList({
|
|||||||
activeWorkspaceSelection,
|
activeWorkspaceSelection,
|
||||||
handleWorktreeCreated,
|
handleWorktreeCreated,
|
||||||
handleWorkspaceReorder,
|
handleWorkspaceReorder,
|
||||||
hostLabelByServerId,
|
hostBadgeByServerId,
|
||||||
showHostLabels,
|
|
||||||
supportsMultiplicityByServerId,
|
supportsMultiplicityByServerId,
|
||||||
supportsPinningByServerId,
|
supportsPinningByServerId,
|
||||||
onToggleWorkspacePin,
|
onToggleWorkspacePin,
|
||||||
@@ -2134,17 +2262,16 @@ function ProjectModeList({
|
|||||||
|
|
||||||
const renderPinnedChat = useCallback(
|
const renderPinnedChat = useCallback(
|
||||||
(workspace: SidebarWorkspacePlacement) => {
|
(workspace: SidebarWorkspacePlacement) => {
|
||||||
// A hoisted chat loses its project context, so surface the project name (plus
|
|
||||||
// host when the sidebar spans multiple hosts) as the subtitle.
|
|
||||||
const hostLabel = showHostLabels
|
|
||||||
? (hostLabelByServerId.get(workspace.serverId) ?? workspace.serverId)
|
|
||||||
: null;
|
|
||||||
return (
|
return (
|
||||||
<MemoWorkspaceRowItem
|
<MemoWorkspaceRowItem
|
||||||
key={workspace.workspaceKey}
|
key={workspace.workspaceKey}
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
workspaceEntry={workspaceEntriesByKey.get(workspace.workspaceKey) ?? null}
|
workspaceEntry={workspaceEntriesByKey.get(workspace.workspaceKey) ?? null}
|
||||||
subtitle={hostLabel ? `${workspace.projectName} · ${hostLabel}` : workspace.projectName}
|
hostBadge={hostBadgeByServerId.get(workspace.serverId) ?? null}
|
||||||
|
leadingProjectName={workspace.projectName}
|
||||||
|
leadingProjectIconDataUri={
|
||||||
|
projectIconByProjectViewKey.get(workspace.projectViewKey) ?? null
|
||||||
|
}
|
||||||
shortcutNumber={shortcutIndexByWorkspaceKey.get(workspace.workspaceKey) ?? null}
|
shortcutNumber={shortcutIndexByWorkspaceKey.get(workspace.workspaceKey) ?? null}
|
||||||
showShortcutBadge={showShortcutBadges}
|
showShortcutBadge={showShortcutBadges}
|
||||||
canCopyBranchName={workspace.projectKind === "git"}
|
canCopyBranchName={workspace.projectKind === "git"}
|
||||||
@@ -2160,14 +2287,14 @@ function ProjectModeList({
|
|||||||
[
|
[
|
||||||
activeWorkspaceSelection,
|
activeWorkspaceSelection,
|
||||||
creatingWorkspaceIds,
|
creatingWorkspaceIds,
|
||||||
hostLabelByServerId,
|
hostBadgeByServerId,
|
||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
selectionEnabled,
|
selectionEnabled,
|
||||||
shortcutIndexByWorkspaceKey,
|
shortcutIndexByWorkspaceKey,
|
||||||
showHostLabels,
|
|
||||||
showShortcutBadges,
|
showShortcutBadges,
|
||||||
supportsPinningByServerId,
|
supportsPinningByServerId,
|
||||||
onToggleWorkspacePin,
|
onToggleWorkspacePin,
|
||||||
|
projectIconByProjectViewKey,
|
||||||
workspaceEntriesByKey,
|
workspaceEntriesByKey,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { Text, View } from "react-native";
|
||||||
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
|
import { Server } from "lucide-react-native";
|
||||||
|
import { HOST_COLORS, type HostColor } from "@/hosts/appearance";
|
||||||
|
import { identityColor } from "@/styles/identity-colors";
|
||||||
|
import type { Theme } from "@/styles/theme";
|
||||||
|
|
||||||
|
const ThemedServer = withUnistyles(Server);
|
||||||
|
const MAX_NAMED_BADGE_WIDTH = 96;
|
||||||
|
|
||||||
|
// The identity table is theme-independent, so its icon mapping is fixed for the life of the
|
||||||
|
// module. It must not be rebuilt per render: a fresh `uniProps` identity makes withUnistyles
|
||||||
|
// re-subscribe every pass.
|
||||||
|
function buildIconMappings(): Record<HostColor, (theme: Theme) => { color: string }> {
|
||||||
|
const byColor = {} as Record<HostColor, (theme: Theme) => { color: string }>;
|
||||||
|
for (const color of HOST_COLORS) {
|
||||||
|
byColor[color] =
|
||||||
|
color === "none"
|
||||||
|
? (theme: Theme) => ({ color: theme.colors.foregroundMuted })
|
||||||
|
: () => ({ color: identityColor(color) });
|
||||||
|
}
|
||||||
|
return byColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICON_MAPPINGS = buildIconMappings();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which host a workspace lives on, as a pill after the title. Only rendered in multi-host
|
||||||
|
* setups — see selectHostBadges. It's a badge rather than a second line of text because the
|
||||||
|
* row has to stay scannable at one line per workspace.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export function HostBadge({
|
||||||
|
serverId,
|
||||||
|
label,
|
||||||
|
color,
|
||||||
|
showLabel,
|
||||||
|
}: {
|
||||||
|
serverId: string;
|
||||||
|
label: string;
|
||||||
|
color: HostColor;
|
||||||
|
showLabel: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[hostBadgeStyles.badge, showLabel ? null : hostBadgeStyles.badgeIconOnly]}
|
||||||
|
testID={`sidebar-host-badge-${serverId}`}
|
||||||
|
>
|
||||||
|
<ThemedServer size={9} uniProps={ICON_MAPPINGS[color]} />
|
||||||
|
{showLabel ? (
|
||||||
|
<Text style={hostBadgeStyles.text} numberOfLines={1}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostBadgeStyles = StyleSheet.create((theme) => ({
|
||||||
|
// Sized off the title's 20pt line box so the pill never grows the row. A named badge must
|
||||||
|
// leave room for the workspace title and trailing actions, even when a host has an arbitrary
|
||||||
|
// custom label. The full host name remains in the row's accessible label and hover card.
|
||||||
|
badge: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 3,
|
||||||
|
height: 16,
|
||||||
|
paddingLeft: 4,
|
||||||
|
paddingRight: 6,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
maxWidth: MAX_NAMED_BADGE_WIDTH,
|
||||||
|
minWidth: 0,
|
||||||
|
flexShrink: 1,
|
||||||
|
},
|
||||||
|
// Icon-only drops the label, so the asymmetric padding that made room for text leaves a
|
||||||
|
// lopsided pill. Symmetric padding recenters the icon.
|
||||||
|
badgeIconOnly: {
|
||||||
|
paddingLeft: 4,
|
||||||
|
paddingRight: 4,
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
lineHeight: 14,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
flexShrink: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -1,21 +1,27 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
import { ActivityIndicator, View, type ViewStyle } from "react-native";
|
import { ActivityIndicator, View, type ViewStyle } from "react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native";
|
import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native";
|
||||||
import { ProjectIconView } from "@/components/project-icon-view";
|
import { ProjectIconView } from "@/components/project-icon-view";
|
||||||
import { SyncedLoader } from "@/components/synced-loader";
|
import { PulsingStatusDot } from "@/components/sidebar/pulsing-status-dot";
|
||||||
import { STATUS_BUCKET_LABELS } from "@/hooks/sidebar-status-view-model";
|
import { STATUS_BUCKET_LABELS } from "@/hooks/sidebar-status-view-model";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||||
import { getProjectStatusBadgeContent } from "@/utils/project-status-badge-content";
|
import {
|
||||||
|
getProjectStatusBadgeContent,
|
||||||
|
type ProjectStatusBadgeContent,
|
||||||
|
type ProjectStatusBadgeDotBucket,
|
||||||
|
} from "@/utils/project-status-badge-content";
|
||||||
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
||||||
|
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||||
|
|
||||||
// Every surfaced status shares one badge shell, so the ring never changes size or position
|
// Every surfaced status shares one badge shell, so the badge never changes size or position
|
||||||
// between states. Only the thing inside it changes.
|
// between states. Only the thing inside it changes.
|
||||||
const STATUS_BADGE_SIZE = 14;
|
const STATUS_BADGE_SIZE = 14;
|
||||||
const STATUS_BADGE_BORDER_WIDTH = 1;
|
const STATUS_BADGE_BORDER_WIDTH = 1;
|
||||||
// Centers the badge on the icon's bottom-right corner: half in, half out.
|
// How far the badge overhangs the icon's bottom-right corner. Well short of half the badge:
|
||||||
const STATUS_BADGE_OFFSET = -(STATUS_BADGE_SIZE / 2);
|
// centered on the corner it reads as hanging off the icon rather than sitting on it, and the
|
||||||
|
// sidebar row has no padding there to absorb the overhang.
|
||||||
|
const STATUS_BADGE_OFFSET = -5;
|
||||||
// Both glyphs must be EVEN. The shell's interior is 12pt (14 minus the 1pt ring on each side),
|
// Both glyphs must be EVEN. The shell's interior is 12pt (14 minus the 1pt ring on each side),
|
||||||
// so a centered glyph of size N sits at a (12 - N) / 2 offset — fractional for odd N, which the
|
// so a centered glyph of size N sits at a (12 - N) / 2 offset — fractional for odd N, which the
|
||||||
// browser snaps to a device-pixel boundary and renders visibly off-center (an odd size measured
|
// browser snaps to a device-pixel boundary and renders visibly off-center (an odd size measured
|
||||||
@@ -26,17 +32,12 @@ const STATUS_BADGE_OFFSET = -(STATUS_BADGE_SIZE / 2);
|
|||||||
// against the 8pt dot — the two states read as the same-diameter disc.
|
// against the 8pt dot — the two states read as the same-diameter disc.
|
||||||
const STATUS_BADGE_DOT_SIZE = 8;
|
const STATUS_BADGE_DOT_SIZE = 8;
|
||||||
const STATUS_BADGE_ALERT_SIZE = 10;
|
const STATUS_BADGE_ALERT_SIZE = 10;
|
||||||
// SyncedLoader clamps its dot size at 2px, so sizes 5-10 all render the same 5x8 dot grid.
|
// Matches the workspace title's lineHeight (sidebar-workspace-row-content's
|
||||||
const STATUS_BADGE_LOADER_SIZE = 7;
|
// workspaceBranchText) so the icon centers on the title rather than floating above it.
|
||||||
// The dot-comet's 6 divs are geometrically centered, but the snake reads a hair right inside
|
const LEADING_SLOT_HEIGHT = 20;
|
||||||
// the round circle. This optical nudge (pt, leftward) lands its visual center on the circle
|
|
||||||
// center — the play-button-triangle trick. Dialed in visually: -0.67pt ≈ 2 device px left at
|
|
||||||
// 3x (a full -1pt / 3px read a touch too far left).
|
|
||||||
const STATUS_BADGE_LOADER_NUDGE_X = -0.67;
|
|
||||||
|
|
||||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||||
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
||||||
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
|
|
||||||
|
|
||||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
@@ -44,12 +45,6 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
|
|||||||
const amberColorMapping = (theme: Theme) => ({
|
const amberColorMapping = (theme: Theme) => ({
|
||||||
color: theme.colors.palette.amber[500],
|
color: theme.colors.palette.amber[500],
|
||||||
});
|
});
|
||||||
const syncedLoaderColorMapping = (theme: Theme) => ({
|
|
||||||
color:
|
|
||||||
theme.colorScheme === "light"
|
|
||||||
? theme.colors.palette.amber[700]
|
|
||||||
: theme.colors.palette.amber[500],
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Leading slot of a sidebar project row: chevron on hover, archive spinner while removing,
|
* Leading slot of a sidebar project row: chevron on hover, archive spinner while removing,
|
||||||
@@ -73,9 +68,6 @@ export function ProjectLeadingVisual({
|
|||||||
showChevron?: boolean;
|
showChevron?: boolean;
|
||||||
isArchiving?: boolean;
|
isArchiving?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(displayName);
|
|
||||||
const placeholderInitial = placeholderLabel.charAt(0).toUpperCase();
|
|
||||||
|
|
||||||
if (showChevron && chevron !== null) {
|
if (showChevron && chevron !== null) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.projectLeadingVisualSlot}>
|
<View style={styles.projectLeadingVisualSlot}>
|
||||||
@@ -95,7 +87,7 @@ export function ProjectLeadingVisual({
|
|||||||
return (
|
return (
|
||||||
<ProjectStatusIndicator
|
<ProjectStatusIndicator
|
||||||
iconDataUri={iconDataUri}
|
iconDataUri={iconDataUri}
|
||||||
placeholderInitial={placeholderInitial}
|
displayName={displayName}
|
||||||
projectViewKey={projectViewKey}
|
projectViewKey={projectViewKey}
|
||||||
statusBucket={statusBucket}
|
statusBucket={statusBucket}
|
||||||
/>
|
/>
|
||||||
@@ -103,46 +95,65 @@ export function ProjectLeadingVisual({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The project icon (the lettered box) is what marks a row as a *project* rather than a
|
// The project icon (the lettered box) is what marks a row as a *project* rather than a
|
||||||
// workspace, so it always stays. Status rides along as a corner badge instead of replacing
|
// workspace, so it always stays and status annotates it instead of replacing it. Every
|
||||||
// the icon: "working" and "needs input" keep the same amber loader/alert glyph a workspace
|
// surfaced bucket lands in the identical corner badge — an amber alert glyph for needs_input,
|
||||||
// row uses, the quieter buckets get a dot, and "done" shows nothing. Every one of those sits
|
// a colored disc for the rest, nothing for done — so the badge reads as one fixed shell and
|
||||||
// in the identical ringed badge, so the badge reads as one fixed shell across all states.
|
// only its contents change.
|
||||||
export function ProjectStatusIndicator({
|
export function ProjectStatusIndicator({
|
||||||
iconDataUri,
|
iconDataUri,
|
||||||
placeholderInitial,
|
displayName,
|
||||||
projectViewKey,
|
projectViewKey,
|
||||||
statusBucket,
|
statusBucket,
|
||||||
|
loading = false,
|
||||||
|
testID,
|
||||||
}: {
|
}: {
|
||||||
iconDataUri: string | null;
|
iconDataUri: string | null;
|
||||||
placeholderInitial: string;
|
displayName: string;
|
||||||
projectViewKey: string;
|
projectViewKey: string;
|
||||||
statusBucket: SidebarStateBucket | null;
|
statusBucket: SidebarStateBucket | null;
|
||||||
|
loading?: boolean;
|
||||||
|
testID?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const placeholderInitial = projectIconPlaceholderLabelFromDisplayName(displayName)
|
||||||
|
.charAt(0)
|
||||||
|
.toUpperCase();
|
||||||
|
// A row that's still starting up and a row that's working are both "busy", and at 8pt
|
||||||
|
// there's no room to draw the difference — so `loading` just resolves to the running bucket
|
||||||
|
// and they share one badge.
|
||||||
|
const badgeBucket = loading ? "running" : statusBucket;
|
||||||
|
const badgeContent = getProjectStatusBadgeContent(badgeBucket);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={styles.projectLeadingVisualSlot}
|
style={styles.projectLeadingVisualSlot}
|
||||||
testID={
|
testID={
|
||||||
statusBucket && statusBucket !== "done"
|
testID ??
|
||||||
|
(statusBucket && statusBucket !== "done"
|
||||||
? `project-status-indicator-${statusBucket}`
|
? `project-status-indicator-${statusBucket}`
|
||||||
: "project-icon-only"
|
: "project-icon-only")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ProjectIcon
|
<View style={styles.projectIconBox}>
|
||||||
iconDataUri={iconDataUri}
|
<ProjectIcon
|
||||||
placeholderInitial={placeholderInitial}
|
iconDataUri={iconDataUri}
|
||||||
projectViewKey={projectViewKey}
|
placeholderInitial={placeholderInitial}
|
||||||
/>
|
projectViewKey={projectViewKey}
|
||||||
<ProjectStatusBadge statusBucket={statusBucket} />
|
/>
|
||||||
|
{badgeContent === null || badgeBucket === null ? null : (
|
||||||
|
<ProjectStatusBadge content={badgeContent} statusBucket={badgeBucket} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectStatusBadge({ statusBucket }: { statusBucket: SidebarStateBucket | null }) {
|
function ProjectStatusBadge({
|
||||||
const content = getProjectStatusBadgeContent(statusBucket);
|
content,
|
||||||
if (content === null || statusBucket === null) {
|
statusBucket,
|
||||||
return null;
|
}: {
|
||||||
}
|
content: ProjectStatusBadgeContent;
|
||||||
|
statusBucket: SidebarStateBucket;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
role="status"
|
role="status"
|
||||||
@@ -150,28 +161,20 @@ function ProjectStatusBadge({ statusBucket }: { statusBucket: SidebarStateBucket
|
|||||||
style={styles.statusBadge}
|
style={styles.statusBadge}
|
||||||
testID="project-status-badge"
|
testID="project-status-badge"
|
||||||
>
|
>
|
||||||
{renderProjectStatusBadgeContent(content)}
|
{content.kind === "alert" ? (
|
||||||
|
<ThemedCircleAlert size={STATUS_BADGE_ALERT_SIZE} uniProps={amberColorMapping} />
|
||||||
|
) : (
|
||||||
|
<ProjectStatusDot bucket={content.bucket} />
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderProjectStatusBadgeContent(
|
function ProjectStatusDot({ bucket }: { bucket: ProjectStatusBadgeDotBucket }) {
|
||||||
content: NonNullable<ReturnType<typeof getProjectStatusBadgeContent>>,
|
if (bucket !== "running") {
|
||||||
): ReactNode {
|
return <View testID="project-status-dot" style={getStatusDotColorStyle(bucket)} />;
|
||||||
switch (content.kind) {
|
|
||||||
case "loader":
|
|
||||||
// Nudged left a hair so the comet's visual center sits on the circle center (see the
|
|
||||||
// nudge constant). The wrapper carries the transform; the loader itself is untouched.
|
|
||||||
return (
|
|
||||||
<View style={styles.runningLoaderNudge}>
|
|
||||||
<ThemedSyncedLoader size={STATUS_BADGE_LOADER_SIZE} uniProps={syncedLoaderColorMapping} />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
case "alert":
|
|
||||||
return <ThemedCircleAlert size={STATUS_BADGE_ALERT_SIZE} uniProps={amberColorMapping} />;
|
|
||||||
case "dot":
|
|
||||||
return <View testID="project-status-dot" style={getStatusDotColorStyle(content.bucket)} />;
|
|
||||||
}
|
}
|
||||||
|
return <PulsingStatusDot testID="project-status-dot" style={styles.statusDotRunning} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectIcon({
|
function ProjectIcon({
|
||||||
@@ -205,29 +208,43 @@ function ProjectInlineChevron({ chevron }: { chevron: "expand" | "collapse" | nu
|
|||||||
return <ChevronRight size={14} color="#9ca3af" />;
|
return <ChevronRight size={14} color="#9ca3af" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusDotColorStyle(bucket: "failed" | "attention"): ViewStyle {
|
function getStatusDotColorStyle(
|
||||||
|
bucket: Exclude<ProjectStatusBadgeDotBucket, "running">,
|
||||||
|
): ViewStyle {
|
||||||
return bucket === "failed" ? styles.statusDotFailed : styles.statusDotAttention;
|
return bucket === "failed" ? styles.statusDotFailed : styles.statusDotAttention;
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => {
|
const styles = StyleSheet.create((theme) => {
|
||||||
// Dot statuses sit *inside* the shell rather than replacing it, so "ready to review" and
|
// Dot statuses sit *inside* the shell rather than replacing it, so they carry the same ring
|
||||||
// "failed" carry the same ring as the loader and alert. The geometry is shared here and
|
// the alert does. The geometry is shared here and baked into each colored variant so the
|
||||||
// baked into each colored variant so the style prop stays a single stable object.
|
// style prop stays a single stable object; the colors come from the one bucket-to-color map
|
||||||
const statusDotBase = {
|
// so this badge can't drift from the status dots everywhere else.
|
||||||
width: STATUS_BADGE_DOT_SIZE,
|
const statusDot = (bucket: ProjectStatusBadgeDotBucket) =>
|
||||||
height: STATUS_BADGE_DOT_SIZE,
|
({
|
||||||
borderRadius: theme.borderRadius.full,
|
width: STATUS_BADGE_DOT_SIZE,
|
||||||
} as const;
|
height: STATUS_BADGE_DOT_SIZE,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
backgroundColor: getStatusDotColor({ theme, bucket }) ?? undefined,
|
||||||
|
}) as const;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
// The slot is as tall as the title's line box, not as tall as the icon, and centers the
|
||||||
|
// icon inside it. Rows lay their leading visual out with alignItems:flex-start, so a
|
||||||
|
// 16pt slot next to a 20pt line box puts the icon 2pt above the title and the kebab —
|
||||||
|
// which is why the workspace status indicator is also 20 tall. Keep the two in step.
|
||||||
projectLeadingVisualSlot: {
|
projectLeadingVisualSlot: {
|
||||||
position: "relative",
|
|
||||||
width: theme.iconSize.md,
|
width: theme.iconSize.md,
|
||||||
height: theme.iconSize.md,
|
height: LEADING_SLOT_HEIGHT,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
|
// Anchors the corner badge to the icon rather than to the taller slot.
|
||||||
|
projectIconBox: {
|
||||||
|
position: "relative",
|
||||||
|
width: theme.iconSize.md,
|
||||||
|
height: theme.iconSize.md,
|
||||||
|
},
|
||||||
projectIcon: {
|
projectIcon: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
@@ -243,9 +260,9 @@ const styles = StyleSheet.create((theme) => {
|
|||||||
projectIconFallbackText: {
|
projectIconFallbackText: {
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
},
|
},
|
||||||
// The one shell every status shares. It straddles the icon's bottom-right corner (half in,
|
// The shell the alert and dot statuses share. It straddles the icon's bottom-right corner
|
||||||
// half out) so the lettered project box stays readable, and its ring is sidebar-colored so
|
// (half in, half out) so the lettered project box stays readable, and its ring is
|
||||||
// the badge separates from the icon underneath rather than blending into it.
|
// sidebar-colored so the badge separates from the icon underneath rather than blending in.
|
||||||
statusBadge: {
|
statusBadge: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
right: STATUS_BADGE_OFFSET,
|
right: STATUS_BADGE_OFFSET,
|
||||||
@@ -260,17 +277,8 @@ const styles = StyleSheet.create((theme) => {
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
// Optical-centering nudge for the running dot-comet (see the nudge constant).
|
statusDotRunning: statusDot("running"),
|
||||||
runningLoaderNudge: {
|
statusDotFailed: statusDot("failed"),
|
||||||
transform: [{ translateX: STATUS_BADGE_LOADER_NUDGE_X }],
|
statusDotAttention: statusDot("attention"),
|
||||||
},
|
|
||||||
statusDotFailed: {
|
|
||||||
...statusDotBase,
|
|
||||||
backgroundColor: theme.colors.palette.red[500],
|
|
||||||
},
|
|
||||||
statusDotAttention: {
|
|
||||||
...statusDotBase,
|
|
||||||
backgroundColor: theme.colors.palette.green[500],
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import type { StyleProp, ViewStyle } from "react-native";
|
||||||
|
import Animated, {
|
||||||
|
cancelAnimation,
|
||||||
|
Easing,
|
||||||
|
makeMutable,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useReducedMotion,
|
||||||
|
withRepeat,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||||
|
|
||||||
|
// The busy affordance for a sidebar row. Every status in the sidebar is a dot; busy is the
|
||||||
|
// one that moves. Callers own the dot's geometry and color and pass it in as a style — this
|
||||||
|
// module owns nothing but the pulse.
|
||||||
|
//
|
||||||
|
// Opacity only, and shallow: the dot sits in a column of static dots in neighboring rows, so
|
||||||
|
// anything that changes its size or drops it near invisible reads as jitter down the list.
|
||||||
|
// One full cycle is out-and-back, so the visible period is twice the half period.
|
||||||
|
const PULSE_HALF_PERIOD_MS = 900;
|
||||||
|
const PULSE_MIN_OPACITY = 0.65;
|
||||||
|
|
||||||
|
// One clock for every pulsing dot in the app. Dots that each start their own animation drift
|
||||||
|
// apart as rows mount at different times, and a column of them fading out of step is noise —
|
||||||
|
// the same reason SyncedLoader drives all its instances from a single step. The value *is*
|
||||||
|
// the opacity, so subscribers read it directly and there is nothing per-instance to fall out
|
||||||
|
// of phase.
|
||||||
|
//
|
||||||
|
// Refcounted so the animation only runs while at least one dot is mounted: an unattended
|
||||||
|
// withRepeat keeps the UI thread's frame loop awake forever.
|
||||||
|
const pulseClock = makeMutable(1);
|
||||||
|
let pulseClockSubscribers = 0;
|
||||||
|
|
||||||
|
function acquirePulseClock(): void {
|
||||||
|
pulseClockSubscribers += 1;
|
||||||
|
if (pulseClockSubscribers > 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pulseClock.value = withRepeat(
|
||||||
|
withTiming(PULSE_MIN_OPACITY, {
|
||||||
|
duration: PULSE_HALF_PERIOD_MS,
|
||||||
|
easing: Easing.inOut(Easing.quad),
|
||||||
|
}),
|
||||||
|
-1,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function releasePulseClock(): void {
|
||||||
|
pulseClockSubscribers -= 1;
|
||||||
|
if (pulseClockSubscribers > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cancelAnimation(pulseClock);
|
||||||
|
pulseClock.value = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A status dot, breathing in step with every other one on screen. */
|
||||||
|
export function PulsingStatusDot({
|
||||||
|
style,
|
||||||
|
testID,
|
||||||
|
}: {
|
||||||
|
style: StyleProp<ViewStyle>;
|
||||||
|
testID?: string;
|
||||||
|
}) {
|
||||||
|
const active = useRetainedPanelActive();
|
||||||
|
const reduceMotion = useReducedMotion();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active || reduceMotion) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
acquirePulseClock();
|
||||||
|
return releasePulseClock;
|
||||||
|
}, [active, reduceMotion]);
|
||||||
|
|
||||||
|
const pulseStyle = useAnimatedStyle(() => ({ opacity: pulseClock.value }));
|
||||||
|
|
||||||
|
// Reduced motion leaves the dot fully opaque. It still carries the running color, and the
|
||||||
|
// status is spelled out in the surrounding row's accessible label either way.
|
||||||
|
return <Animated.View testID={testID} style={[style, reduceMotion ? null : pulseStyle]} />;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"
|
|||||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
import { type SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
import { type SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||||
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||||
|
import type { HostBadgeModel } from "@/hosts/appearance";
|
||||||
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
|
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
@@ -35,12 +36,16 @@ import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attentio
|
|||||||
import {
|
import {
|
||||||
SidebarWorkspaceRowFrame,
|
SidebarWorkspaceRowFrame,
|
||||||
SidebarWorkspaceRowContent,
|
SidebarWorkspaceRowContent,
|
||||||
|
resolveTrailingActionVisibility,
|
||||||
SidebarWorkspaceTrailingActionBase,
|
SidebarWorkspaceTrailingActionBase,
|
||||||
SidebarWorkspaceTrailingActionOverlay,
|
SidebarWorkspaceTrailingActionOverlay,
|
||||||
SidebarWorkspaceTrailingActionSlot,
|
SidebarWorkspaceTrailingActionSlot,
|
||||||
} from "@/components/sidebar/sidebar-workspace-row-content";
|
} from "@/components/sidebar/sidebar-workspace-row-content";
|
||||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||||
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
|
import {
|
||||||
|
SidebarWorkspaceContextMenu,
|
||||||
|
SidebarWorkspaceMenu,
|
||||||
|
} from "@/components/sidebar/sidebar-workspace-menu";
|
||||||
import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
|
import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
|
||||||
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
|
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
|
||||||
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
|
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
|
||||||
@@ -50,7 +55,6 @@ import type { ToggleSidebarWorkspacePin } from "@/hooks/use-sidebar-workspace-pi
|
|||||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
});
|
});
|
||||||
const blueColorMapping = (theme: Theme) => ({ color: theme.colors.palette.blue[500] });
|
|
||||||
const amberColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] });
|
const amberColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] });
|
||||||
const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] });
|
const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] });
|
||||||
const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] });
|
const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] });
|
||||||
@@ -64,13 +68,11 @@ const ThemedCircleX = withUnistyles(CircleX);
|
|||||||
interface StatusWorkspaceListProps {
|
interface StatusWorkspaceListProps {
|
||||||
groups: StatusGroup[];
|
groups: StatusGroup[];
|
||||||
pinnedWorkspaces: SidebarWorkspaceEntry[];
|
pinnedWorkspaces: SidebarWorkspaceEntry[];
|
||||||
projectNamesByViewKey: Map<string, string>;
|
|
||||||
projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
|
projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
|
||||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||||
showShortcutBadges: boolean;
|
showShortcutBadges: boolean;
|
||||||
onWorkspacePress?: () => void;
|
onWorkspacePress?: () => void;
|
||||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
|
||||||
showHostLabels: boolean;
|
|
||||||
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
||||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||||
listHeaderComponent?: ReactNode;
|
listHeaderComponent?: ReactNode;
|
||||||
@@ -79,13 +81,11 @@ interface StatusWorkspaceListProps {
|
|||||||
export function SidebarStatusWorkspaceList({
|
export function SidebarStatusWorkspaceList({
|
||||||
groups,
|
groups,
|
||||||
pinnedWorkspaces,
|
pinnedWorkspaces,
|
||||||
projectNamesByViewKey,
|
|
||||||
projectIconByProjectViewKey,
|
projectIconByProjectViewKey,
|
||||||
shortcutIndexByWorkspaceKey,
|
shortcutIndexByWorkspaceKey,
|
||||||
showShortcutBadges,
|
showShortcutBadges,
|
||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
hostLabelByServerId,
|
hostBadgeByServerId,
|
||||||
showHostLabels,
|
|
||||||
supportsPinningByServerId,
|
supportsPinningByServerId,
|
||||||
onToggleWorkspacePin,
|
onToggleWorkspacePin,
|
||||||
listHeaderComponent,
|
listHeaderComponent,
|
||||||
@@ -118,10 +118,8 @@ export function SidebarStatusWorkspaceList({
|
|||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
{...buildStatusRowProjectPresentation({
|
{...buildStatusRowProjectPresentation({
|
||||||
workspace,
|
workspace,
|
||||||
projectNamesByViewKey,
|
|
||||||
projectIconByProjectViewKey,
|
projectIconByProjectViewKey,
|
||||||
hostLabelByServerId,
|
hostBadgeByServerId,
|
||||||
showHostLabels,
|
|
||||||
})}
|
})}
|
||||||
shortcutNumber={statusShortcutIndex.get(workspace.workspaceKey) ?? null}
|
shortcutNumber={statusShortcutIndex.get(workspace.workspaceKey) ?? null}
|
||||||
showShortcutBadge={showShortcutBadges}
|
showShortcutBadge={showShortcutBadges}
|
||||||
@@ -145,13 +143,11 @@ export function SidebarStatusWorkspaceList({
|
|||||||
<StatusGroupList
|
<StatusGroupList
|
||||||
groups={groups}
|
groups={groups}
|
||||||
collapsedStatusGroupKeys={collapsedStatusGroupKeys}
|
collapsedStatusGroupKeys={collapsedStatusGroupKeys}
|
||||||
projectNamesByViewKey={projectNamesByViewKey}
|
|
||||||
projectIconByProjectViewKey={projectIconByProjectViewKey}
|
projectIconByProjectViewKey={projectIconByProjectViewKey}
|
||||||
shortcutIndex={statusShortcutIndex}
|
shortcutIndex={statusShortcutIndex}
|
||||||
showShortcutBadges={showShortcutBadges}
|
showShortcutBadges={showShortcutBadges}
|
||||||
onWorkspacePress={onWorkspacePress}
|
onWorkspacePress={onWorkspacePress}
|
||||||
hostLabelByServerId={hostLabelByServerId}
|
hostBadgeByServerId={hostBadgeByServerId}
|
||||||
showHostLabels={showHostLabels}
|
|
||||||
supportsPinningByServerId={supportsPinningByServerId}
|
supportsPinningByServerId={supportsPinningByServerId}
|
||||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||||
/>
|
/>
|
||||||
@@ -186,25 +182,21 @@ export function SidebarStatusWorkspaceList({
|
|||||||
function StatusGroupList({
|
function StatusGroupList({
|
||||||
groups,
|
groups,
|
||||||
collapsedStatusGroupKeys,
|
collapsedStatusGroupKeys,
|
||||||
projectNamesByViewKey,
|
|
||||||
projectIconByProjectViewKey,
|
projectIconByProjectViewKey,
|
||||||
shortcutIndex,
|
shortcutIndex,
|
||||||
showShortcutBadges,
|
showShortcutBadges,
|
||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
hostLabelByServerId,
|
hostBadgeByServerId,
|
||||||
showHostLabels,
|
|
||||||
supportsPinningByServerId,
|
supportsPinningByServerId,
|
||||||
onToggleWorkspacePin,
|
onToggleWorkspacePin,
|
||||||
}: {
|
}: {
|
||||||
groups: StatusGroup[];
|
groups: StatusGroup[];
|
||||||
collapsedStatusGroupKeys: ReadonlySet<string>;
|
collapsedStatusGroupKeys: ReadonlySet<string>;
|
||||||
projectNamesByViewKey: Map<string, string>;
|
|
||||||
projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
|
projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
|
||||||
shortcutIndex: Map<string, number>;
|
shortcutIndex: Map<string, number>;
|
||||||
showShortcutBadges: boolean;
|
showShortcutBadges: boolean;
|
||||||
onWorkspacePress?: () => void;
|
onWorkspacePress?: () => void;
|
||||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
|
||||||
showHostLabels: boolean;
|
|
||||||
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
||||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||||
}) {
|
}) {
|
||||||
@@ -215,13 +207,11 @@ function StatusGroupList({
|
|||||||
key={group.bucket}
|
key={group.bucket}
|
||||||
group={group}
|
group={group}
|
||||||
collapsed={collapsedStatusGroupKeys.has(group.bucket)}
|
collapsed={collapsedStatusGroupKeys.has(group.bucket)}
|
||||||
projectNamesByViewKey={projectNamesByViewKey}
|
|
||||||
projectIconByProjectViewKey={projectIconByProjectViewKey}
|
projectIconByProjectViewKey={projectIconByProjectViewKey}
|
||||||
shortcutIndex={shortcutIndex}
|
shortcutIndex={shortcutIndex}
|
||||||
showShortcutBadges={showShortcutBadges}
|
showShortcutBadges={showShortcutBadges}
|
||||||
onWorkspacePress={onWorkspacePress}
|
onWorkspacePress={onWorkspacePress}
|
||||||
hostLabelByServerId={hostLabelByServerId}
|
hostBadgeByServerId={hostBadgeByServerId}
|
||||||
showHostLabels={showHostLabels}
|
|
||||||
supportsPinningByServerId={supportsPinningByServerId}
|
supportsPinningByServerId={supportsPinningByServerId}
|
||||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||||
/>
|
/>
|
||||||
@@ -233,25 +223,21 @@ function StatusGroupList({
|
|||||||
function StatusGroupRows({
|
function StatusGroupRows({
|
||||||
group,
|
group,
|
||||||
collapsed,
|
collapsed,
|
||||||
projectNamesByViewKey,
|
|
||||||
projectIconByProjectViewKey,
|
projectIconByProjectViewKey,
|
||||||
shortcutIndex,
|
shortcutIndex,
|
||||||
showShortcutBadges,
|
showShortcutBadges,
|
||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
hostLabelByServerId,
|
hostBadgeByServerId,
|
||||||
showHostLabels,
|
|
||||||
supportsPinningByServerId,
|
supportsPinningByServerId,
|
||||||
onToggleWorkspacePin,
|
onToggleWorkspacePin,
|
||||||
}: {
|
}: {
|
||||||
group: StatusGroup;
|
group: StatusGroup;
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
projectNamesByViewKey: Map<string, string>;
|
|
||||||
projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
|
projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
|
||||||
shortcutIndex: Map<string, number>;
|
shortcutIndex: Map<string, number>;
|
||||||
showShortcutBadges: boolean;
|
showShortcutBadges: boolean;
|
||||||
onWorkspacePress?: () => void;
|
onWorkspacePress?: () => void;
|
||||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
|
||||||
showHostLabels: boolean;
|
|
||||||
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
||||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||||
}) {
|
}) {
|
||||||
@@ -276,10 +262,8 @@ function StatusGroupRows({
|
|||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
{...buildStatusRowProjectPresentation({
|
{...buildStatusRowProjectPresentation({
|
||||||
workspace,
|
workspace,
|
||||||
projectNamesByViewKey,
|
|
||||||
projectIconByProjectViewKey,
|
projectIconByProjectViewKey,
|
||||||
hostLabelByServerId,
|
hostBadgeByServerId,
|
||||||
showHostLabels,
|
|
||||||
})}
|
})}
|
||||||
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
|
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
|
||||||
showShortcutBadge={showShortcutBadges}
|
showShortcutBadge={showShortcutBadges}
|
||||||
@@ -301,47 +285,24 @@ function StatusGroupRows({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status mode breaks the project grouping, so the row needs the project name to stay
|
|
||||||
// legible; the host is appended after a middle dot once labels are active.
|
|
||||||
function buildStatusRowSubtitle({
|
|
||||||
projectName,
|
|
||||||
hostLabel,
|
|
||||||
}: {
|
|
||||||
projectName: string;
|
|
||||||
hostLabel: string | null;
|
|
||||||
}): string {
|
|
||||||
if (!hostLabel) {
|
|
||||||
return projectName;
|
|
||||||
}
|
|
||||||
return projectName ? `${projectName} · ${hostLabel}` : hostLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StatusRowProjectPresentation {
|
interface StatusRowProjectPresentation {
|
||||||
subtitle: string;
|
hostBadge: HostBadgeModel | null;
|
||||||
projectName: string;
|
projectName: string;
|
||||||
projectIconDataUri: string | null;
|
projectIconDataUri: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildStatusRowProjectPresentation({
|
function buildStatusRowProjectPresentation({
|
||||||
workspace,
|
workspace,
|
||||||
projectNamesByViewKey,
|
|
||||||
projectIconByProjectViewKey,
|
projectIconByProjectViewKey,
|
||||||
hostLabelByServerId,
|
hostBadgeByServerId,
|
||||||
showHostLabels,
|
|
||||||
}: {
|
}: {
|
||||||
workspace: SidebarWorkspaceEntry;
|
workspace: SidebarWorkspaceEntry;
|
||||||
projectNamesByViewKey: Map<string, string>;
|
|
||||||
projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
|
projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
|
||||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
|
||||||
showHostLabels: boolean;
|
|
||||||
}): StatusRowProjectPresentation {
|
}): StatusRowProjectPresentation {
|
||||||
const projectName = projectNamesByViewKey.get(workspace.projectViewKey) ?? "";
|
|
||||||
const hostLabel = showHostLabels
|
|
||||||
? (hostLabelByServerId.get(workspace.serverId) ?? workspace.serverId)
|
|
||||||
: null;
|
|
||||||
return {
|
return {
|
||||||
subtitle: buildStatusRowSubtitle({ projectName, hostLabel }),
|
hostBadge: hostBadgeByServerId.get(workspace.serverId) ?? null,
|
||||||
projectName,
|
projectName: workspace.projectName,
|
||||||
projectIconDataUri: projectIconByProjectViewKey.get(workspace.projectViewKey) ?? null,
|
projectIconDataUri: projectIconByProjectViewKey.get(workspace.projectViewKey) ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -422,7 +383,7 @@ function StatusGroupIcon({ bucket }: { bucket: StatusGroup["bucket"] }) {
|
|||||||
case "attention":
|
case "attention":
|
||||||
return <ThemedCircleCheck size={14} uniProps={greenColorMapping} />;
|
return <ThemedCircleCheck size={14} uniProps={greenColorMapping} />;
|
||||||
case "running":
|
case "running":
|
||||||
return <ThemedCircleDot size={14} uniProps={blueColorMapping} />;
|
return <ThemedCircleDot size={14} uniProps={amberColorMapping} />;
|
||||||
case "done":
|
case "done":
|
||||||
return <ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />;
|
return <ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
}
|
}
|
||||||
@@ -430,7 +391,7 @@ function StatusGroupIcon({ bucket }: { bucket: StatusGroup["bucket"] }) {
|
|||||||
|
|
||||||
const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||||
workspace,
|
workspace,
|
||||||
subtitle,
|
hostBadge,
|
||||||
projectName,
|
projectName,
|
||||||
projectIconDataUri,
|
projectIconDataUri,
|
||||||
shortcutNumber,
|
shortcutNumber,
|
||||||
@@ -441,7 +402,7 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
|||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
}: {
|
}: {
|
||||||
workspace: SidebarWorkspaceEntry;
|
workspace: SidebarWorkspaceEntry;
|
||||||
subtitle: string;
|
hostBadge: HostBadgeModel | null;
|
||||||
projectName: string;
|
projectName: string;
|
||||||
projectIconDataUri: string | null;
|
projectIconDataUri: string | null;
|
||||||
shortcutNumber: number | null;
|
shortcutNumber: number | null;
|
||||||
@@ -465,7 +426,7 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
|||||||
return (
|
return (
|
||||||
<StatusWorkspaceRowWithMenu
|
<StatusWorkspaceRowWithMenu
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
subtitle={subtitle}
|
hostBadge={hostBadge}
|
||||||
projectName={projectName}
|
projectName={projectName}
|
||||||
projectIconDataUri={projectIconDataUri}
|
projectIconDataUri={projectIconDataUri}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
@@ -481,7 +442,7 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
|||||||
|
|
||||||
function StatusWorkspaceRowWithMenu({
|
function StatusWorkspaceRowWithMenu({
|
||||||
workspace,
|
workspace,
|
||||||
subtitle,
|
hostBadge,
|
||||||
projectName,
|
projectName,
|
||||||
projectIconDataUri,
|
projectIconDataUri,
|
||||||
selected,
|
selected,
|
||||||
@@ -493,7 +454,7 @@ function StatusWorkspaceRowWithMenu({
|
|||||||
onPress,
|
onPress,
|
||||||
}: {
|
}: {
|
||||||
workspace: SidebarWorkspaceEntry;
|
workspace: SidebarWorkspaceEntry;
|
||||||
subtitle: string;
|
hostBadge: HostBadgeModel | null;
|
||||||
projectName: string;
|
projectName: string;
|
||||||
projectIconDataUri: string | null;
|
projectIconDataUri: string | null;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
@@ -603,7 +564,7 @@ function StatusWorkspaceRowWithMenu({
|
|||||||
<>
|
<>
|
||||||
<StatusWorkspaceRowInner
|
<StatusWorkspaceRowInner
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
subtitle={subtitle}
|
hostBadge={hostBadge}
|
||||||
projectName={projectName}
|
projectName={projectName}
|
||||||
projectIconDataUri={projectIconDataUri}
|
projectIconDataUri={projectIconDataUri}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
@@ -640,7 +601,7 @@ function StatusWorkspaceRowWithMenu({
|
|||||||
|
|
||||||
function StatusWorkspaceRowInner({
|
function StatusWorkspaceRowInner({
|
||||||
workspace,
|
workspace,
|
||||||
subtitle,
|
hostBadge,
|
||||||
projectName,
|
projectName,
|
||||||
projectIconDataUri,
|
projectIconDataUri,
|
||||||
selected,
|
selected,
|
||||||
@@ -662,7 +623,7 @@ function StatusWorkspaceRowInner({
|
|||||||
reserveIdleStatusIndicatorSpace = true,
|
reserveIdleStatusIndicatorSpace = true,
|
||||||
}: {
|
}: {
|
||||||
workspace: SidebarWorkspaceEntry;
|
workspace: SidebarWorkspaceEntry;
|
||||||
subtitle: string;
|
hostBadge: HostBadgeModel | null;
|
||||||
projectName: string;
|
projectName: string;
|
||||||
projectIconDataUri: string | null;
|
projectIconDataUri: string | null;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
@@ -699,15 +660,43 @@ function StatusWorkspaceRowInner({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarWorkspaceRowFrame workspace={workspace}>
|
<SidebarWorkspaceRowFrame workspace={workspace}>
|
||||||
{({ isHovered, hoverHandlers }) => {
|
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
|
||||||
const showShortcut = showShortcutBadge && shortcutNumber !== null;
|
const showShortcut = showShortcutBadge && shortcutNumber !== null;
|
||||||
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
|
const {
|
||||||
const showKebabInSlot = showKebab && !showShortcut;
|
showDiffStat,
|
||||||
|
showKebab: showKebabInSlot,
|
||||||
|
showScrim,
|
||||||
|
} = resolveTrailingActionVisibility({
|
||||||
|
hasDiffStat: Boolean(workspace.diffStat),
|
||||||
|
hasArchiveAction: Boolean(onArchive),
|
||||||
|
isHovered,
|
||||||
|
isTouchPlatform,
|
||||||
|
showShortcut,
|
||||||
|
});
|
||||||
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
|
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
|
||||||
const workspaceRowStyle = getStatusWorkspaceRowStyle({ selected, isHovered });
|
const workspaceRowStyle = getStatusWorkspaceRowStyle({ selected, isHovered });
|
||||||
return (
|
return (
|
||||||
<View style={styles.workspaceRowContainer} {...hoverHandlers}>
|
<View style={styles.workspaceRowContainer} {...hoverHandlers}>
|
||||||
<Pressable
|
<SidebarWorkspaceContextMenu
|
||||||
|
contextMenuOpen={contextMenuOpen}
|
||||||
|
onContextMenuOpenChange={onContextMenuOpenChange}
|
||||||
|
workspace={workspace}
|
||||||
|
leadingProjectName={projectName}
|
||||||
|
hostBadgeLabel={hostBadge?.label}
|
||||||
|
scriptIconKind={scriptIconKind}
|
||||||
|
workspaceKey={workspace.workspaceKey}
|
||||||
|
onCopyPath={onCopyPath}
|
||||||
|
onCopyBranchName={onCopyBranchName}
|
||||||
|
onRename={onRename}
|
||||||
|
onMarkAsRead={onMarkAsRead}
|
||||||
|
onArchive={onArchive}
|
||||||
|
archiveLabel={archiveLabel}
|
||||||
|
archiveStatus={archiveStatus}
|
||||||
|
archivePendingLabel={archivePendingLabel}
|
||||||
|
archiveShortcutKeys={archiveShortcutKeys}
|
||||||
|
isPinned={isPinned}
|
||||||
|
onTogglePin={onTogglePin}
|
||||||
|
openInFileManagerPath={workspace.workspaceDirectory}
|
||||||
disabled={isArchiving}
|
disabled={isArchiving}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityState={accessibilityState}
|
accessibilityState={accessibilityState}
|
||||||
@@ -717,9 +706,9 @@ function StatusWorkspaceRowInner({
|
|||||||
>
|
>
|
||||||
<SidebarWorkspaceRowContent
|
<SidebarWorkspaceRowContent
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
subtitle={subtitle}
|
hostBadge={hostBadge}
|
||||||
subtitleProjectName={projectName}
|
leadingProjectName={projectName}
|
||||||
subtitleProjectIconDataUri={projectIconDataUri}
|
leadingProjectIconDataUri={projectIconDataUri}
|
||||||
scriptIconKind={scriptIconKind}
|
scriptIconKind={scriptIconKind}
|
||||||
isHovered={isHovered}
|
isHovered={isHovered}
|
||||||
isLoading={isArchiving}
|
isLoading={isArchiving}
|
||||||
@@ -730,8 +719,9 @@ function StatusWorkspaceRowInner({
|
|||||||
{shouldRenderActionSlot ? (
|
{shouldRenderActionSlot ? (
|
||||||
<StatusWorkspaceActionSlot
|
<StatusWorkspaceActionSlot
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
showBase={Boolean(workspace.diffStat && !showKebabInSlot && !showShortcut)}
|
showBase={showDiffStat}
|
||||||
showKebab={showKebabInSlot}
|
showKebab={showKebabInSlot}
|
||||||
|
showScrim={showScrim}
|
||||||
isPinned={isPinned}
|
isPinned={isPinned}
|
||||||
onTogglePin={onTogglePin}
|
onTogglePin={onTogglePin}
|
||||||
onCopyPath={onCopyPath}
|
onCopyPath={onCopyPath}
|
||||||
@@ -746,7 +736,7 @@ function StatusWorkspaceRowInner({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</SidebarWorkspaceRowContent>
|
</SidebarWorkspaceRowContent>
|
||||||
</Pressable>
|
</SidebarWorkspaceContextMenu>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -758,6 +748,7 @@ function StatusWorkspaceActionSlot({
|
|||||||
workspace,
|
workspace,
|
||||||
showBase,
|
showBase,
|
||||||
showKebab,
|
showKebab,
|
||||||
|
showScrim,
|
||||||
isPinned,
|
isPinned,
|
||||||
onTogglePin,
|
onTogglePin,
|
||||||
onCopyPath,
|
onCopyPath,
|
||||||
@@ -773,6 +764,7 @@ function StatusWorkspaceActionSlot({
|
|||||||
workspace: SidebarWorkspaceEntry;
|
workspace: SidebarWorkspaceEntry;
|
||||||
showBase: boolean;
|
showBase: boolean;
|
||||||
showKebab: boolean;
|
showKebab: boolean;
|
||||||
|
showScrim: boolean;
|
||||||
isPinned?: boolean;
|
isPinned?: boolean;
|
||||||
onTogglePin?: () => void;
|
onTogglePin?: () => void;
|
||||||
onCopyPath?: () => void;
|
onCopyPath?: () => void;
|
||||||
@@ -795,7 +787,7 @@ function StatusWorkspaceActionSlot({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</SidebarWorkspaceTrailingActionBase>
|
</SidebarWorkspaceTrailingActionBase>
|
||||||
<SidebarWorkspaceTrailingActionOverlay visible={showKebab}>
|
<SidebarWorkspaceTrailingActionOverlay visible={showKebab} scrim={showScrim}>
|
||||||
{showKebab && onArchive ? (
|
{showKebab && onArchive ? (
|
||||||
<SidebarWorkspaceMenu
|
<SidebarWorkspaceMenu
|
||||||
workspaceKey={workspace.workspaceKey}
|
workspaceKey={workspace.workspaceKey}
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
import { View } from "react-native";
|
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
|
||||||
import { ProjectIconView } from "@/components/project-icon-view";
|
|
||||||
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decorative project marker for a sidebar row's subtitle line. Status mode breaks
|
|
||||||
* the project grouping, so the icon is what makes two same-named branches from
|
|
||||||
* different projects tell apart at a glance — the project name next to it carries
|
|
||||||
* the meaning, so the icon itself stays out of the accessibility tree.
|
|
||||||
*/
|
|
||||||
export function SidebarSubtitleProjectIcon({
|
|
||||||
projectViewKey,
|
|
||||||
projectName,
|
|
||||||
iconDataUri,
|
|
||||||
testID,
|
|
||||||
}: {
|
|
||||||
projectViewKey: string;
|
|
||||||
projectName: string;
|
|
||||||
iconDataUri: string | null;
|
|
||||||
testID?: string;
|
|
||||||
}) {
|
|
||||||
const placeholderInitial = projectIconPlaceholderLabelFromDisplayName(projectName)
|
|
||||||
.charAt(0)
|
|
||||||
.toUpperCase();
|
|
||||||
if (!iconDataUri && !placeholderInitial) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={styles.slot}
|
|
||||||
testID={testID}
|
|
||||||
accessibilityElementsHidden
|
|
||||||
importantForAccessibility="no-hide-descendants"
|
|
||||||
>
|
|
||||||
<ProjectIconView
|
|
||||||
iconDataUri={iconDataUri}
|
|
||||||
initial={placeholderInitial}
|
|
||||||
projectViewKey={projectViewKey}
|
|
||||||
imageStyle={styles.icon}
|
|
||||||
fallbackStyle={styles.fallback}
|
|
||||||
textStyle={styles.fallbackText}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
|
||||||
slot: {
|
|
||||||
width: 12,
|
|
||||||
// Match the 14px subtitle line so the icon does not change row density.
|
|
||||||
height: 14,
|
|
||||||
flexShrink: 0,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
icon: {
|
|
||||||
width: 12,
|
|
||||||
height: 12,
|
|
||||||
borderRadius: theme.borderRadius.sm,
|
|
||||||
},
|
|
||||||
fallback: {
|
|
||||||
width: 12,
|
|
||||||
height: 12,
|
|
||||||
borderRadius: theme.borderRadius.sm,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
fallbackText: {
|
|
||||||
fontSize: 8,
|
|
||||||
lineHeight: 12,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo, type ComponentProps, type PropsWithChildren, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { type PressableStateCallbackType } from "react-native";
|
import { type PressableStateCallbackType } from "react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import { Archive, CircleCheck, Copy, MoreVertical, Pencil, Pin, PinOff } from "lucide-react-native";
|
import { Archive, CircleCheck, Copy, MoreVertical, Pencil, Pin, PinOff } from "lucide-react-native";
|
||||||
import { isNative, isWeb } from "@/constants/platform";
|
import { isNative, isWeb } from "@/constants/platform";
|
||||||
|
import { getForgePresentation, normalizeForge } from "@/git/forge";
|
||||||
|
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||||
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||||
import {
|
import {
|
||||||
@@ -12,8 +15,15 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
} from "@/components/ui/context-menu";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item";
|
import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item";
|
||||||
|
import { resolveSidebarWorkspaceAccessibilityLabel } from "@/components/sidebar/sidebar-workspace-title";
|
||||||
|
|
||||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||||
@@ -46,7 +56,7 @@ function renderTriggerIcon({ hovered }: { hovered?: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SidebarWorkspaceMenuProps {
|
export interface SidebarWorkspaceMenuProps {
|
||||||
workspaceKey: string;
|
workspaceKey: string;
|
||||||
onCopyPath?: () => void;
|
onCopyPath?: () => void;
|
||||||
onCopyBranchName?: () => void;
|
onCopyBranchName?: () => void;
|
||||||
@@ -62,6 +72,121 @@ interface SidebarWorkspaceMenuProps {
|
|||||||
openInFileManagerPath?: string | null;
|
openInFileManagerPath?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SidebarWorkspaceMenuItemsProps extends Omit<SidebarWorkspaceMenuProps, "onArchive"> {
|
||||||
|
onArchive?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MenuSurface = "context" | "dropdown";
|
||||||
|
|
||||||
|
function WorkspaceMenuItem({
|
||||||
|
surface,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: PropsWithChildren<
|
||||||
|
Omit<ComponentProps<typeof DropdownMenuItem>, "children"> & { surface: MenuSurface }
|
||||||
|
>) {
|
||||||
|
if (surface === "context") {
|
||||||
|
return <ContextMenuItem {...props}>{children}</ContextMenuItem>;
|
||||||
|
}
|
||||||
|
return <DropdownMenuItem {...props}>{children}</DropdownMenuItem>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarWorkspaceMenuItems({
|
||||||
|
surface,
|
||||||
|
workspaceKey,
|
||||||
|
onCopyPath,
|
||||||
|
onCopyBranchName,
|
||||||
|
onRename,
|
||||||
|
onMarkAsRead,
|
||||||
|
onArchive,
|
||||||
|
archiveLabel,
|
||||||
|
archiveStatus,
|
||||||
|
archivePendingLabel,
|
||||||
|
archiveShortcutKeys,
|
||||||
|
isPinned,
|
||||||
|
onTogglePin,
|
||||||
|
openInFileManagerPath,
|
||||||
|
}: SidebarWorkspaceMenuItemsProps & { surface: MenuSurface }): ReactNode {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const archiveTrailing = useMemo(
|
||||||
|
() => (archiveShortcutKeys && !isNative ? <Shortcut chord={archiveShortcutKeys} /> : null),
|
||||||
|
[archiveShortcutKeys],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{onCopyPath ? (
|
||||||
|
<WorkspaceMenuItem
|
||||||
|
surface={surface}
|
||||||
|
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
|
||||||
|
leading={copyLeadingIcon}
|
||||||
|
onSelect={onCopyPath}
|
||||||
|
>
|
||||||
|
{t("sidebar.workspace.actions.copyPath")}
|
||||||
|
</WorkspaceMenuItem>
|
||||||
|
) : null}
|
||||||
|
{onCopyBranchName ? (
|
||||||
|
<WorkspaceMenuItem
|
||||||
|
surface={surface}
|
||||||
|
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
|
||||||
|
leading={copyLeadingIcon}
|
||||||
|
onSelect={onCopyBranchName}
|
||||||
|
>
|
||||||
|
{t("sidebar.workspace.actions.copyBranchName")}
|
||||||
|
</WorkspaceMenuItem>
|
||||||
|
) : null}
|
||||||
|
{onRename ? (
|
||||||
|
<WorkspaceMenuItem
|
||||||
|
surface={surface}
|
||||||
|
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
|
||||||
|
leading={renameLeadingIcon}
|
||||||
|
onSelect={onRename}
|
||||||
|
>
|
||||||
|
{t("sidebar.workspace.actions.rename")}
|
||||||
|
</WorkspaceMenuItem>
|
||||||
|
) : null}
|
||||||
|
{onMarkAsRead ? (
|
||||||
|
<WorkspaceMenuItem
|
||||||
|
surface={surface}
|
||||||
|
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
|
||||||
|
leading={markAsReadLeadingIcon}
|
||||||
|
onSelect={onMarkAsRead}
|
||||||
|
>
|
||||||
|
Mark as read
|
||||||
|
</WorkspaceMenuItem>
|
||||||
|
) : null}
|
||||||
|
{onTogglePin ? (
|
||||||
|
<WorkspaceMenuItem
|
||||||
|
surface={surface}
|
||||||
|
testID={`sidebar-workspace-menu-pin-${workspaceKey}`}
|
||||||
|
leading={isPinned ? unpinLeadingIcon : pinLeadingIcon}
|
||||||
|
onSelect={onTogglePin}
|
||||||
|
>
|
||||||
|
{isPinned ? t("sidebar.workspace.actions.unpin") : t("sidebar.workspace.actions.pin")}
|
||||||
|
</WorkspaceMenuItem>
|
||||||
|
) : null}
|
||||||
|
<OpenInFileManagerMenuItem
|
||||||
|
surface={surface}
|
||||||
|
path={openInFileManagerPath}
|
||||||
|
testID={`sidebar-workspace-menu-open-folder-${workspaceKey}`}
|
||||||
|
/>
|
||||||
|
{onArchive ? (
|
||||||
|
<WorkspaceMenuItem
|
||||||
|
surface={surface}
|
||||||
|
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
|
||||||
|
leading={archiveLeadingIcon}
|
||||||
|
trailing={archiveTrailing}
|
||||||
|
status={archiveStatus}
|
||||||
|
pendingLabel={archivePendingLabel}
|
||||||
|
onSelect={onArchive}
|
||||||
|
>
|
||||||
|
{archiveLabel ?? t("sidebar.workspace.actions.archive")}
|
||||||
|
</WorkspaceMenuItem>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SidebarWorkspaceMenu({
|
export function SidebarWorkspaceMenu({
|
||||||
workspaceKey,
|
workspaceKey,
|
||||||
onCopyPath,
|
onCopyPath,
|
||||||
@@ -78,11 +203,6 @@ export function SidebarWorkspaceMenu({
|
|||||||
openInFileManagerPath,
|
openInFileManagerPath,
|
||||||
}: SidebarWorkspaceMenuProps) {
|
}: SidebarWorkspaceMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const archiveTrailing = useMemo(
|
|
||||||
() => (archiveShortcutKeys && !isNative ? <Shortcut chord={archiveShortcutKeys} /> : null),
|
|
||||||
[archiveShortcutKeys],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
@@ -95,70 +215,120 @@ export function SidebarWorkspaceMenu({
|
|||||||
{renderTriggerIcon}
|
{renderTriggerIcon}
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" width={260}>
|
<DropdownMenuContent align="end" width={260}>
|
||||||
{onCopyPath ? (
|
<SidebarWorkspaceMenuItems
|
||||||
<DropdownMenuItem
|
surface="dropdown"
|
||||||
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
|
workspaceKey={workspaceKey}
|
||||||
leading={copyLeadingIcon}
|
onCopyPath={onCopyPath}
|
||||||
onSelect={onCopyPath}
|
onCopyBranchName={onCopyBranchName}
|
||||||
>
|
onRename={onRename}
|
||||||
{t("sidebar.workspace.actions.copyPath")}
|
onMarkAsRead={onMarkAsRead}
|
||||||
</DropdownMenuItem>
|
onArchive={onArchive}
|
||||||
) : null}
|
archiveLabel={archiveLabel}
|
||||||
{onCopyBranchName ? (
|
archiveStatus={archiveStatus}
|
||||||
<DropdownMenuItem
|
archivePendingLabel={archivePendingLabel}
|
||||||
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
|
archiveShortcutKeys={archiveShortcutKeys}
|
||||||
leading={copyLeadingIcon}
|
isPinned={isPinned}
|
||||||
onSelect={onCopyBranchName}
|
onTogglePin={onTogglePin}
|
||||||
>
|
openInFileManagerPath={openInFileManagerPath}
|
||||||
{t("sidebar.workspace.actions.copyBranchName")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
{onRename ? (
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
|
|
||||||
leading={renameLeadingIcon}
|
|
||||||
onSelect={onRename}
|
|
||||||
>
|
|
||||||
{t("sidebar.workspace.actions.rename")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
{onMarkAsRead ? (
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
|
|
||||||
leading={markAsReadLeadingIcon}
|
|
||||||
onSelect={onMarkAsRead}
|
|
||||||
>
|
|
||||||
Mark as read
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
{onTogglePin ? (
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID={`sidebar-workspace-menu-pin-${workspaceKey}`}
|
|
||||||
leading={isPinned ? unpinLeadingIcon : pinLeadingIcon}
|
|
||||||
onSelect={onTogglePin}
|
|
||||||
>
|
|
||||||
{isPinned ? t("sidebar.workspace.actions.unpin") : t("sidebar.workspace.actions.pin")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
<OpenInFileManagerMenuItem
|
|
||||||
path={openInFileManagerPath}
|
|
||||||
testID={`sidebar-workspace-menu-open-folder-${workspaceKey}`}
|
|
||||||
/>
|
/>
|
||||||
<DropdownMenuItem
|
|
||||||
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
|
|
||||||
leading={archiveLeadingIcon}
|
|
||||||
trailing={archiveTrailing}
|
|
||||||
status={archiveStatus}
|
|
||||||
pendingLabel={archivePendingLabel}
|
|
||||||
onSelect={onArchive}
|
|
||||||
>
|
|
||||||
{archiveLabel ?? t("sidebar.workspace.actions.archive")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ContextTriggerProps = Omit<
|
||||||
|
ComponentProps<typeof ContextMenuTrigger>,
|
||||||
|
"children" | "enabledOnMobile"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function SidebarWorkspaceContextMenu({
|
||||||
|
children,
|
||||||
|
contextMenuOpen,
|
||||||
|
onContextMenuOpenChange,
|
||||||
|
workspace,
|
||||||
|
leadingProjectName,
|
||||||
|
hostBadgeLabel,
|
||||||
|
scriptIconKind,
|
||||||
|
workspaceKey,
|
||||||
|
onCopyPath,
|
||||||
|
onCopyBranchName,
|
||||||
|
onRename,
|
||||||
|
onMarkAsRead,
|
||||||
|
onArchive,
|
||||||
|
archiveLabel,
|
||||||
|
archiveStatus,
|
||||||
|
archivePendingLabel,
|
||||||
|
archiveShortcutKeys,
|
||||||
|
isPinned,
|
||||||
|
onTogglePin,
|
||||||
|
openInFileManagerPath,
|
||||||
|
accessibilityLabel,
|
||||||
|
...triggerProps
|
||||||
|
}: PropsWithChildren<
|
||||||
|
SidebarWorkspaceMenuItemsProps &
|
||||||
|
ContextTriggerProps & {
|
||||||
|
contextMenuOpen: boolean;
|
||||||
|
onContextMenuOpenChange: (open: boolean) => void;
|
||||||
|
workspace: SidebarWorkspaceEntry;
|
||||||
|
leadingProjectName?: string | null;
|
||||||
|
hostBadgeLabel?: string | null;
|
||||||
|
scriptIconKind?: "service" | "command" | null;
|
||||||
|
}
|
||||||
|
>) {
|
||||||
|
const {
|
||||||
|
settings: { workspaceTitleSource },
|
||||||
|
} = useAppSettings();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const pullRequestLabel = workspace.prHint
|
||||||
|
? t("workspace.git.pr.accessibility.pullRequest", {
|
||||||
|
number: workspace.prHint.number,
|
||||||
|
context: getForgePresentation(normalizeForge(workspace.prHint.forge)).changeRequestContext,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const rowAccessibilityLabel = resolveSidebarWorkspaceAccessibilityLabel({
|
||||||
|
workspace,
|
||||||
|
workspaceTitleSource,
|
||||||
|
leadingProjectName,
|
||||||
|
hostBadgeLabel,
|
||||||
|
pullRequestLabel,
|
||||||
|
scriptLabel: scriptIconKind ? t("workspace.status.scriptsAvailable") : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu open={contextMenuOpen} onOpenChange={onContextMenuOpenChange}>
|
||||||
|
<ContextMenuTrigger
|
||||||
|
{...triggerProps}
|
||||||
|
enabledOnMobile={false}
|
||||||
|
accessibilityLabel={accessibilityLabel ?? rowAccessibilityLabel}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent
|
||||||
|
align="start"
|
||||||
|
width={260}
|
||||||
|
testID={`sidebar-workspace-context-menu-${workspaceKey}`}
|
||||||
|
>
|
||||||
|
<SidebarWorkspaceMenuItems
|
||||||
|
surface="context"
|
||||||
|
workspaceKey={workspaceKey}
|
||||||
|
onCopyPath={onCopyPath}
|
||||||
|
onCopyBranchName={onCopyBranchName}
|
||||||
|
onRename={onRename}
|
||||||
|
onMarkAsRead={onMarkAsRead}
|
||||||
|
onArchive={onArchive}
|
||||||
|
archiveLabel={archiveLabel}
|
||||||
|
archiveStatus={archiveStatus}
|
||||||
|
archivePendingLabel={archivePendingLabel}
|
||||||
|
archiveShortcutKeys={archiveShortcutKeys}
|
||||||
|
isPinned={isPinned}
|
||||||
|
onTogglePin={onTogglePin}
|
||||||
|
openInFileManagerPath={openInFileManagerPath}
|
||||||
|
/>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function triggerStyle({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) {
|
function triggerStyle({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) {
|
||||||
return [styles.trigger, hovered && styles.triggerHovered];
|
return [styles.trigger, hovered && styles.triggerHovered];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { memo, useCallback, useId, useMemo, useState, type ReactNode } from "react";
|
||||||
import { memo, useCallback, useMemo, useState, type ReactNode } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Pressable, Text, View, type GestureResponderEvent, type ViewStyle } from "react-native";
|
import {
|
||||||
|
Pressable,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
type GestureResponderEvent,
|
||||||
|
type PressableStateCallbackType,
|
||||||
|
type ViewStyle,
|
||||||
|
} from "react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
|
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
|
||||||
import {
|
import {
|
||||||
CircleAlert,
|
CircleAlert,
|
||||||
ExternalLink,
|
|
||||||
Folder,
|
Folder,
|
||||||
FolderGit2,
|
FolderGit2,
|
||||||
GitPullRequest,
|
GitPullRequest,
|
||||||
@@ -13,53 +19,82 @@ import {
|
|||||||
Monitor,
|
Monitor,
|
||||||
SquareTerminal,
|
SquareTerminal,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { SidebarSubtitleProjectIcon } from "@/components/sidebar/sidebar-subtitle-project-icon";
|
import { HostBadge } from "@/components/sidebar/host-badge";
|
||||||
|
import { ProjectStatusIndicator } from "@/components/sidebar/project-leading-visual";
|
||||||
|
import { PulsingStatusDot } from "@/components/sidebar/pulsing-status-dot";
|
||||||
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
|
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
|
||||||
import { SyncedLoader } from "@/components/synced-loader";
|
import type { HostBadgeModel } from "@/hosts/appearance";
|
||||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||||
import { useAppSettings } from "@/hooks/use-settings";
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import type { PrHint } from "@/git/use-pr-status-query";
|
import type { PrHint } from "@/git/use-pr-status-query";
|
||||||
import { getForgePresentation, normalizeForge } from "@/git/forge";
|
import { getForgePresentation, normalizeForge } from "@/git/forge";
|
||||||
import { ForgeBrandIcon } from "@/git/forge-icon";
|
|
||||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||||
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
|
import { getStatusDotColor, isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
|
||||||
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title";
|
import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title";
|
||||||
|
|
||||||
|
// The scrim spans more than the kebab so the fade starts left of the diff stat. Solid from
|
||||||
|
// SCRIM_SOLID_OFFSET rightward, which keeps the kebab itself off the gradient entirely.
|
||||||
|
const SCRIM_WIDTH = 48;
|
||||||
|
const SCRIM_SOLID_OFFSET = "55%";
|
||||||
|
|
||||||
const DEFAULT_STATUS_DOT_SIZE = 7;
|
const DEFAULT_STATUS_DOT_SIZE = 7;
|
||||||
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
||||||
const DEFAULT_STATUS_DOT_OFFSET = 0;
|
const DEFAULT_STATUS_DOT_OFFSET = 0;
|
||||||
const EMPHASIZED_STATUS_DOT_OFFSET = -1;
|
const EMPHASIZED_STATUS_DOT_OFFSET = -1;
|
||||||
|
|
||||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
|
||||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||||
const amberColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] });
|
const amberColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] });
|
||||||
const syncedLoaderColorMapping = (theme: Theme) => ({
|
|
||||||
color:
|
|
||||||
theme.colorScheme === "light"
|
|
||||||
? theme.colors.palette.amber[700]
|
|
||||||
: theme.colors.palette.amber[500],
|
|
||||||
});
|
|
||||||
const blueColorMapping = (theme: Theme) => ({ color: theme.colors.palette.blue[500] });
|
const blueColorMapping = (theme: Theme) => ({ color: theme.colors.palette.blue[500] });
|
||||||
const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] });
|
const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] });
|
||||||
const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] });
|
const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] });
|
||||||
const purpleColorMapping = (theme: Theme) => ({ color: theme.colors.palette.purple[500] });
|
const purpleColorMapping = (theme: Theme) => ({ color: theme.colors.palette.purple[500] });
|
||||||
|
|
||||||
const ThemedExternalLink = withUnistyles(ExternalLink);
|
|
||||||
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
||||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
|
||||||
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
||||||
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
|
|
||||||
const ThemedMonitor = withUnistyles(Monitor);
|
const ThemedMonitor = withUnistyles(Monitor);
|
||||||
const ThemedFolder = withUnistyles(Folder);
|
const ThemedFolder = withUnistyles(Folder);
|
||||||
const ThemedFolderGit2 = withUnistyles(FolderGit2);
|
const ThemedFolderGit2 = withUnistyles(FolderGit2);
|
||||||
const ThemedGlobe = withUnistyles(Globe);
|
const ThemedGlobe = withUnistyles(Globe);
|
||||||
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
||||||
|
|
||||||
function renderChecksBadgeForgeIcon(icon: string) {
|
/**
|
||||||
return <ForgeBrandIcon iconKind={icon} size={10} uniProps={redColorMapping} />;
|
* react-native-svg's extractGradient reads stopColor off the child elements structurally,
|
||||||
|
* without rendering them, so wrapping Stop itself in withUnistyles hides the color from it and
|
||||||
|
* the native gradient silently falls back to black. Theme the whole SVG instead and keep real
|
||||||
|
* Stop elements as direct children of the gradient.
|
||||||
|
*/
|
||||||
|
function TrailingActionScrimSvg({ gradientId, color }: { gradientId: string; color: string }) {
|
||||||
|
return (
|
||||||
|
<Svg width="100%" height="100%" preserveAspectRatio="none">
|
||||||
|
<Defs>
|
||||||
|
<SvgLinearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
{/* Same color at both ends, varying only stopOpacity. Interpolating a hex toward
|
||||||
|
`transparent` goes through black in some engines and leaves a grey fringe. */}
|
||||||
|
<Stop offset="0%" stopColor={color} stopOpacity={0} />
|
||||||
|
<Stop offset={SCRIM_SOLID_OFFSET} stopColor={color} stopOpacity={1} />
|
||||||
|
<Stop offset="100%" stopColor={color} stopOpacity={1} />
|
||||||
|
</SvgLinearGradient>
|
||||||
|
</Defs>
|
||||||
|
<Rect x="0" y="0" width="100%" height="100%" fill={`url(#${gradientId})`} />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemedTrailingActionScrimSvg = withUnistyles(TrailingActionScrimSvg);
|
||||||
|
|
||||||
|
const scrimColorMapping = (theme: Theme) => ({ color: theme.colors.surfaceSidebarHover });
|
||||||
|
|
||||||
|
function pullRequestIconStyle({
|
||||||
|
hovered,
|
||||||
|
pressed,
|
||||||
|
}: PressableStateCallbackType & { hovered?: boolean }) {
|
||||||
|
return [
|
||||||
|
pullRequestIconStyles.pressable,
|
||||||
|
(Boolean(hovered) || pressed) && pullRequestIconStyles.pressableActive,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
type SidebarWorkspaceScriptIconKind = "service" | "command";
|
type SidebarWorkspaceScriptIconKind = "service" | "command";
|
||||||
@@ -73,29 +108,48 @@ export function SidebarWorkspaceRowFrame({
|
|||||||
isDragging?: boolean;
|
isDragging?: boolean;
|
||||||
children: (input: {
|
children: (input: {
|
||||||
isHovered: boolean;
|
isHovered: boolean;
|
||||||
|
contextMenuOpen: boolean;
|
||||||
|
onContextMenuOpenChange: (open: boolean) => void;
|
||||||
hoverHandlers: { onPointerEnter: () => void; onPointerLeave: () => void };
|
hoverHandlers: { onPointerEnter: () => void; onPointerLeave: () => void };
|
||||||
}) => ReactNode;
|
}) => ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
const [contextMenuOpen, setContextMenuOpen] = useState(false);
|
||||||
|
const handlePointerEnter = useCallback(() => {
|
||||||
|
if (!contextMenuOpen) setIsHovered(true);
|
||||||
|
}, [contextMenuOpen]);
|
||||||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||||||
|
const handleContextMenuOpenChange = useCallback((open: boolean) => {
|
||||||
|
setContextMenuOpen(open);
|
||||||
|
if (open) setIsHovered(false);
|
||||||
|
}, []);
|
||||||
const hoverHandlers = useMemo(
|
const hoverHandlers = useMemo(
|
||||||
() => ({ onPointerEnter: handlePointerEnter, onPointerLeave: handlePointerLeave }),
|
() => ({ onPointerEnter: handlePointerEnter, onPointerLeave: handlePointerLeave }),
|
||||||
[handlePointerEnter, handlePointerLeave],
|
[handlePointerEnter, handlePointerLeave],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceHoverCard workspace={workspace} prHint={workspace.prHint} isDragging={isDragging}>
|
<WorkspaceHoverCard
|
||||||
{children({ isHovered, hoverHandlers })}
|
workspace={workspace}
|
||||||
|
prHint={workspace.prHint}
|
||||||
|
isDragging={isDragging}
|
||||||
|
disabled={contextMenuOpen}
|
||||||
|
>
|
||||||
|
{children({
|
||||||
|
isHovered: isHovered && !contextMenuOpen,
|
||||||
|
contextMenuOpen,
|
||||||
|
onContextMenuOpenChange: handleContextMenuOpenChange,
|
||||||
|
hoverHandlers,
|
||||||
|
})}
|
||||||
</WorkspaceHoverCard>
|
</WorkspaceHoverCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowContent({
|
export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowContent({
|
||||||
workspace,
|
workspace,
|
||||||
subtitle,
|
hostBadge,
|
||||||
subtitleProjectName = null,
|
leadingProjectName = null,
|
||||||
subtitleProjectIconDataUri = null,
|
leadingProjectIconDataUri = null,
|
||||||
scriptIconKind = null,
|
scriptIconKind = null,
|
||||||
isHovered,
|
isHovered,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -106,10 +160,10 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
|||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
workspace: SidebarWorkspaceEntry;
|
workspace: SidebarWorkspaceEntry;
|
||||||
subtitle?: string | null;
|
hostBadge?: HostBadgeModel | null;
|
||||||
/** Project named by the subtitle. Set it to lead the subtitle line with that project's icon. */
|
/** Hoisted rows use their project icon as the leading visual because no project row contains them. */
|
||||||
subtitleProjectName?: string | null;
|
leadingProjectName?: string | null;
|
||||||
subtitleProjectIconDataUri?: string | null;
|
leadingProjectIconDataUri?: string | null;
|
||||||
scriptIconKind?: SidebarWorkspaceScriptIconKind | null;
|
scriptIconKind?: SidebarWorkspaceScriptIconKind | null;
|
||||||
isHovered: boolean;
|
isHovered: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@@ -124,28 +178,51 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
|||||||
settings: { workspaceTitleSource },
|
settings: { workspaceTitleSource },
|
||||||
} = useAppSettings();
|
} = useAppSettings();
|
||||||
const workspaceLabel = resolveSidebarWorkspacePrimaryLabel({ workspace, workspaceTitleSource });
|
const workspaceLabel = resolveSidebarWorkspacePrimaryLabel({ workspace, workspaceTitleSource });
|
||||||
|
const hasTitleAccessory = Boolean(scriptIconKind || workspace.prHint || hostBadge);
|
||||||
const workspaceBranchTextStyle = useMemo(
|
const workspaceBranchTextStyle = useMemo(
|
||||||
() => [
|
() => [
|
||||||
styles.workspaceBranchText,
|
styles.workspaceBranchText,
|
||||||
scriptIconKind ? styles.workspaceBranchTextWithAccessory : styles.workspaceBranchTextFlexible,
|
hasTitleAccessory
|
||||||
|
? styles.workspaceBranchTextWithAccessory
|
||||||
|
: styles.workspaceBranchTextFlexible,
|
||||||
isHovered && styles.workspaceBranchTextHovered,
|
isHovered && styles.workspaceBranchTextHovered,
|
||||||
isCreating && styles.workspaceBranchTextCreating,
|
isCreating && styles.workspaceBranchTextCreating,
|
||||||
],
|
],
|
||||||
[scriptIconKind, isHovered, isCreating],
|
[hasTitleAccessory, isHovered, isCreating],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.workspaceRowContent}>
|
<View style={styles.workspaceRowContent}>
|
||||||
<View style={styles.workspaceRowMain}>
|
<View style={styles.workspaceRowMain}>
|
||||||
<WorkspaceStatusIndicator
|
{leadingProjectName ? (
|
||||||
bucket={workspace.statusBucket}
|
<ProjectStatusIndicator
|
||||||
workspaceKind={workspace.workspaceKind}
|
iconDataUri={leadingProjectIconDataUri}
|
||||||
loading={isLoading}
|
displayName={leadingProjectName}
|
||||||
reserveIdleSpace={reserveIdleStatusIndicatorSpace}
|
projectViewKey={workspace.projectViewKey}
|
||||||
/>
|
statusBucket={workspace.statusBucket}
|
||||||
|
loading={isLoading}
|
||||||
|
testID={`sidebar-row-project-icon-${workspace.workspaceKey}`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<WorkspaceStatusIndicator
|
||||||
|
bucket={workspace.statusBucket}
|
||||||
|
workspaceKind={workspace.workspaceKind}
|
||||||
|
loading={isLoading}
|
||||||
|
reserveIdleSpace={reserveIdleStatusIndicatorSpace}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<View style={styles.workspaceContentColumn}>
|
<View style={styles.workspaceContentColumn}>
|
||||||
<View style={styles.workspaceTitleRow}>
|
<View style={styles.workspaceTitleRow}>
|
||||||
<View style={styles.workspaceTitleLeft}>
|
<View style={styles.workspaceTitleLeft}>
|
||||||
|
{workspace.prHint ? <PullRequestIcon hint={workspace.prHint} /> : null}
|
||||||
|
{hostBadge ? (
|
||||||
|
<HostBadge
|
||||||
|
serverId={hostBadge.serverId}
|
||||||
|
label={hostBadge.label}
|
||||||
|
color={hostBadge.color}
|
||||||
|
showLabel={hostBadge.showLabel}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<Text style={workspaceBranchTextStyle} numberOfLines={1}>
|
<Text style={workspaceBranchTextStyle} numberOfLines={1}>
|
||||||
{workspaceLabel}
|
{workspaceLabel}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -153,27 +230,6 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
|||||||
</View>
|
</View>
|
||||||
<View style={sidebarWorkspaceRowStyles.rowRight}>{children}</View>
|
<View style={sidebarWorkspaceRowStyles.rowRight}>{children}</View>
|
||||||
</View>
|
</View>
|
||||||
{subtitle ? (
|
|
||||||
<View style={styles.workspaceSubtitleRow}>
|
|
||||||
{subtitleProjectName ? (
|
|
||||||
<SidebarSubtitleProjectIcon
|
|
||||||
projectViewKey={workspace.projectViewKey}
|
|
||||||
projectName={subtitleProjectName}
|
|
||||||
iconDataUri={subtitleProjectIconDataUri}
|
|
||||||
testID={`sidebar-row-project-icon-${workspace.workspaceKey}`}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<Text style={styles.workspaceSubtitle} numberOfLines={1}>
|
|
||||||
{subtitle}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
{workspace.prHint ? (
|
|
||||||
<View style={styles.workspacePrBadgeRow}>
|
|
||||||
<PrBadge hint={workspace.prHint} />
|
|
||||||
<ChecksBadge checks={workspace.prHint.checks} forge={workspace.prHint.forge} />
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{showShortcutBadge && shortcutNumber !== null ? (
|
{showShortcutBadge && shortcutNumber !== null ? (
|
||||||
@@ -212,20 +268,22 @@ function WorkspaceStatusIndicator({
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
reserveIdleSpace?: boolean;
|
reserveIdleSpace?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket });
|
// Busy is a pulsing dot here for the same reason it is on a project icon: every status in
|
||||||
|
// the sidebar is a dot, and a row with a project icon simply moves that dot onto the icon.
|
||||||
|
// A row starting up and a row working are both busy, so they share the dot and differ only
|
||||||
|
// in testID.
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.workspaceStatusDot} testID="workspace-status-indicator-loading">
|
<View style={styles.workspaceStatusDot} testID="workspace-status-indicator-loading">
|
||||||
<ThemedLoadingSpinner size={8} uniProps={foregroundMutedColorMapping} />
|
<PulsingStatusDot style={styles.standaloneRunningDot} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldShowSyncedLoader) {
|
if (shouldRenderSyncedStatusLoader({ bucket })) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.workspaceStatusDot} testID="workspace-status-indicator-running">
|
<View style={styles.workspaceStatusDot} testID="workspace-status-indicator-running">
|
||||||
<ThemedSyncedLoader size={11} uniProps={syncedLoaderColorMapping} />
|
<PulsingStatusDot style={styles.standaloneRunningDot} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -247,8 +305,14 @@ function WorkspaceStatusIndicator({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (bucket === "done") {
|
if (bucket === "done") {
|
||||||
|
// An idle row still gets a dot rather than an empty slot. Nested rows are marked as
|
||||||
|
// workspaces by indentation alone, and with nothing in the leading slot the rail has no
|
||||||
|
// edge to read against — a workspace carrying its own glyph starts looking like a project
|
||||||
|
// header. The dot is muted to half opacity so it holds the rail without reporting status.
|
||||||
return reserveIdleSpace ? (
|
return reserveIdleSpace ? (
|
||||||
<View style={styles.workspaceStatusDot} testID="workspace-status-indicator-done" />
|
<View style={styles.workspaceStatusDot} testID="workspace-status-indicator-done">
|
||||||
|
<View style={styles.idleStatusDot} />
|
||||||
|
</View>
|
||||||
) : null;
|
) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,9 +368,8 @@ function StatusDotOverlay({
|
|||||||
return <View style={overlayStyle} />;
|
return <View style={overlayStyle} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PrBadge({ hint }: { hint: PrHint }) {
|
function PullRequestIcon({ hint }: { hint: PrHint }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
|
||||||
const handlePress = useCallback(
|
const handlePress = useCallback(
|
||||||
(event: GestureResponderEvent) => {
|
(event: GestureResponderEvent) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -314,21 +377,9 @@ function PrBadge({ hint }: { hint: PrHint }) {
|
|||||||
},
|
},
|
||||||
[hint.url],
|
[hint.url],
|
||||||
);
|
);
|
||||||
const textStyle = useMemo(
|
|
||||||
() => (isHovered ? [prBadgeStyles.text, prBadgeStyles.textHovered] : prBadgeStyles.text),
|
|
||||||
[isHovered],
|
|
||||||
);
|
|
||||||
const iconUniProps = isHovered ? foregroundColorMapping : getPrIconUniMapping(hint.state);
|
|
||||||
const presentation = getForgePresentation(normalizeForge(hint.forge));
|
const presentation = getForgePresentation(normalizeForge(hint.forge));
|
||||||
|
|
||||||
const handlePressIn = useCallback((event: GestureResponderEvent) => event.stopPropagation(), []);
|
const handlePressIn = useCallback((event: GestureResponderEvent) => event.stopPropagation(), []);
|
||||||
const handleHoverIn = useCallback(() => setIsHovered(true), []);
|
|
||||||
const handleHoverOut = useCallback(() => setIsHovered(false), []);
|
|
||||||
|
|
||||||
const pressableStyle = useMemo(
|
|
||||||
() => [prBadgeStyles.badge, isHovered && prBadgeStyles.badgePressed],
|
|
||||||
[isHovered],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -340,36 +391,13 @@ function PrBadge({ hint }: { hint: PrHint }) {
|
|||||||
hitSlop={4}
|
hitSlop={4}
|
||||||
onPressIn={handlePressIn}
|
onPressIn={handlePressIn}
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
onHoverIn={handleHoverIn}
|
style={pullRequestIconStyle}
|
||||||
onHoverOut={handleHoverOut}
|
|
||||||
style={pressableStyle}
|
|
||||||
>
|
>
|
||||||
{isHovered ? (
|
<ThemedGitPullRequest size={12} uniProps={getPrIconUniMapping(hint.state)} />
|
||||||
<ThemedExternalLink size={12} uniProps={iconUniProps} />
|
|
||||||
) : (
|
|
||||||
<ThemedGitPullRequest size={12} uniProps={iconUniProps} />
|
|
||||||
)}
|
|
||||||
<Text style={textStyle} numberOfLines={1}>
|
|
||||||
{presentation.numberPrefix}
|
|
||||||
{hint.number}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChecksBadge({ checks, forge }: { checks: PrHint["checks"]; forge: PrHint["forge"] }) {
|
|
||||||
if (!checks || checks.length === 0) return null;
|
|
||||||
const failed = checks.filter((check) => check.status === "failure").length;
|
|
||||||
if (failed === 0) return null;
|
|
||||||
const icon = getForgePresentation(normalizeForge(forge)).icon;
|
|
||||||
return (
|
|
||||||
<View style={checksBadgeStyles.badge}>
|
|
||||||
{renderChecksBadgeForgeIcon(icon)}
|
|
||||||
<Text style={checksBadgeStyles.text}>{failed} failed</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPrIconUniMapping(state: PrHint["state"]) {
|
function getPrIconUniMapping(state: PrHint["state"]) {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case "merged":
|
case "merged":
|
||||||
@@ -396,38 +424,16 @@ function getStatusDotColorStyle(bucket: SidebarStateBucket) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const prBadgeStyles = StyleSheet.create((theme) => ({
|
const pullRequestIconStyles = StyleSheet.create(() => ({
|
||||||
badge: {
|
pressable: {
|
||||||
flexDirection: "row",
|
height: 20,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 2,
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
badgePressed: {
|
pressableActive: {
|
||||||
opacity: 0.82,
|
opacity: 0.82,
|
||||||
},
|
},
|
||||||
text: {
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
fontWeight: theme.fontWeight.normal,
|
|
||||||
lineHeight: 14,
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
},
|
|
||||||
textHovered: {
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const checksBadgeStyles = StyleSheet.create((theme) => ({
|
|
||||||
badge: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 2,
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
fontWeight: theme.fontWeight.normal,
|
|
||||||
lineHeight: 14,
|
|
||||||
color: theme.colors.palette.red[500],
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({
|
export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({
|
||||||
@@ -469,6 +475,13 @@ export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({
|
|||||||
top: 0,
|
top: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
},
|
},
|
||||||
|
trailingActionScrim: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
width: SCRIM_WIDTH,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export function SidebarWorkspaceShortcutBadge({ number }: { number: number }) {
|
export function SidebarWorkspaceShortcutBadge({ number }: { number: number }) {
|
||||||
@@ -479,6 +492,34 @@ export function SidebarWorkspaceShortcutBadge({ number }: { number: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the trailing slot shows for a row. Derived in one place because three row renderers
|
||||||
|
* share it: the two project-mode rows and the status-mode row. The rule used to be copied
|
||||||
|
* into each of them and immediately drifted — one call site kept hiding the diff after the
|
||||||
|
* others stopped.
|
||||||
|
*
|
||||||
|
* The diff survives the kebab on hover and fades under the scrim instead of blinking out.
|
||||||
|
* Touch has no hover, so its permanent kebab still hides the diff outright rather than
|
||||||
|
* scrimming an unhovered row whose background doesn't match the gradient.
|
||||||
|
*/
|
||||||
|
export function resolveTrailingActionVisibility({
|
||||||
|
hasDiffStat,
|
||||||
|
hasArchiveAction,
|
||||||
|
isHovered,
|
||||||
|
isTouchPlatform,
|
||||||
|
showShortcut,
|
||||||
|
}: {
|
||||||
|
hasDiffStat: boolean;
|
||||||
|
hasArchiveAction: boolean;
|
||||||
|
isHovered: boolean;
|
||||||
|
isTouchPlatform: boolean;
|
||||||
|
showShortcut: boolean;
|
||||||
|
}): { showDiffStat: boolean; showKebab: boolean; showScrim: boolean } {
|
||||||
|
const showKebab = Boolean(hasArchiveAction && (isHovered || isTouchPlatform)) && !showShortcut;
|
||||||
|
const showDiffStat = hasDiffStat && !showShortcut && (isHovered || !showKebab);
|
||||||
|
return { showDiffStat, showKebab, showScrim: showDiffStat && showKebab };
|
||||||
|
}
|
||||||
|
|
||||||
export function SidebarWorkspaceTrailingActionSlot({ children }: { children: ReactNode }) {
|
export function SidebarWorkspaceTrailingActionSlot({ children }: { children: ReactNode }) {
|
||||||
return <View style={sidebarWorkspaceRowStyles.trailingActionSlot}>{children}</View>;
|
return <View style={sidebarWorkspaceRowStyles.trailingActionSlot}>{children}</View>;
|
||||||
}
|
}
|
||||||
@@ -496,13 +537,42 @@ export function SidebarWorkspaceTrailingActionBase({
|
|||||||
|
|
||||||
export function SidebarWorkspaceTrailingActionOverlay({
|
export function SidebarWorkspaceTrailingActionOverlay({
|
||||||
visible,
|
visible,
|
||||||
|
scrim = false,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
|
/** Fade the row into the kebab when something (the diff stat) is still rendered behind it. */
|
||||||
|
scrim?: boolean;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
if (!visible || !children) return null;
|
if (!visible || !children) return null;
|
||||||
return <View style={sidebarWorkspaceRowStyles.trailingActionOverlay}>{children}</View>;
|
return (
|
||||||
|
<>
|
||||||
|
{scrim ? <TrailingActionScrim /> : null}
|
||||||
|
<View style={sidebarWorkspaceRowStyles.trailingActionOverlay}>{children}</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The row's own background, faded in from the right, sitting between the diff stat and the
|
||||||
|
* kebab. The kebab lands on fully opaque background while the diff dissolves underneath it
|
||||||
|
* rather than blinking out — hiding the diff outright was the old behavior and it cost a
|
||||||
|
* visible flicker on every hover.
|
||||||
|
*
|
||||||
|
* Anchored to the trailing slot, which is position:relative. Wider than the slot on purpose:
|
||||||
|
* the fade has to start before the diff stat does or the diff's left edge cuts off hard.
|
||||||
|
*/
|
||||||
|
function TrailingActionScrim() {
|
||||||
|
// useId's output contains characters that are not legal inside url(#...) — React 19 wraps
|
||||||
|
// ids in guillemets, React 18 in colons — and an unresolvable fill paints nothing at all.
|
||||||
|
// Keep the per-instance uniqueness, drop everything a fragment reference can't carry.
|
||||||
|
const gradientId = `sidebar-scrim-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
||||||
|
return (
|
||||||
|
<View style={sidebarWorkspaceRowStyles.trailingActionScrim} pointerEvents="none">
|
||||||
|
<ThemedTrailingActionScrimSvg gradientId={gradientId} uniProps={scrimColorMapping} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
@@ -528,7 +598,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
workspaceTitleLeft: {
|
workspaceTitleLeft: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[1],
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
@@ -557,6 +627,19 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderRadius: theme.borderRadius.full,
|
borderRadius: theme.borderRadius.full,
|
||||||
backgroundColor: theme.colors.palette.green[500],
|
backgroundColor: theme.colors.palette.green[500],
|
||||||
},
|
},
|
||||||
|
standaloneRunningDot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
backgroundColor: getStatusDotColor({ theme, bucket: "running" }) ?? undefined,
|
||||||
|
},
|
||||||
|
idleStatusDot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
backgroundColor: theme.colors.foregroundExtraMuted,
|
||||||
|
opacity: 0.3,
|
||||||
|
},
|
||||||
workspaceBranchText: {
|
workspaceBranchText: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
@@ -583,25 +666,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
workspaceBranchTextHovered: {
|
workspaceBranchTextHovered: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
},
|
},
|
||||||
workspaceSubtitleRow: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[1],
|
|
||||||
minWidth: 0,
|
|
||||||
},
|
|
||||||
workspaceSubtitle: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
lineHeight: 14,
|
|
||||||
flexShrink: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
},
|
|
||||||
workspacePrBadgeRow: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
marginTop: theme.spacing[1],
|
|
||||||
},
|
|
||||||
statusDotNeedsInput: {
|
statusDotNeedsInput: {
|
||||||
backgroundColor: theme.colors.palette.amber[500],
|
backgroundColor: theme.colors.palette.amber[500],
|
||||||
borderColor: theme.colors.surface0,
|
borderColor: theme.colors.surface0,
|
||||||
@@ -611,7 +675,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderColor: theme.colors.surface0,
|
borderColor: theme.colors.surface0,
|
||||||
},
|
},
|
||||||
statusDotRunning: {
|
statusDotRunning: {
|
||||||
backgroundColor: theme.colors.palette.blue[500],
|
backgroundColor: getStatusDotColor({ theme, bucket: "running" }) ?? undefined,
|
||||||
borderColor: theme.colors.surface0,
|
borderColor: theme.colors.surface0,
|
||||||
},
|
},
|
||||||
statusDotAttention: {
|
statusDotAttention: {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { memo, useCallback, useMemo, useState, type Ref } from "react";
|
import { memo, useCallback, useMemo, useState, type Ref } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View, Text, Pressable } from "react-native";
|
import { View, Text } from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
|
import type { HostBadgeModel } from "@/hosts/appearance";
|
||||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||||
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
|
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
|
||||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||||
@@ -20,10 +21,14 @@ import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-ar
|
|||||||
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
|
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||||
import { isNative as platformIsNative } from "@/constants/platform";
|
import { isNative as platformIsNative } from "@/constants/platform";
|
||||||
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
||||||
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
|
import {
|
||||||
|
SidebarWorkspaceContextMenu,
|
||||||
|
SidebarWorkspaceMenu,
|
||||||
|
} from "@/components/sidebar/sidebar-workspace-menu";
|
||||||
import {
|
import {
|
||||||
SidebarWorkspaceRowFrame,
|
SidebarWorkspaceRowFrame,
|
||||||
SidebarWorkspaceRowContent,
|
SidebarWorkspaceRowContent,
|
||||||
|
resolveTrailingActionVisibility,
|
||||||
SidebarWorkspaceTrailingActionBase,
|
SidebarWorkspaceTrailingActionBase,
|
||||||
SidebarWorkspaceTrailingActionOverlay,
|
SidebarWorkspaceTrailingActionOverlay,
|
||||||
SidebarWorkspaceTrailingActionSlot,
|
SidebarWorkspaceTrailingActionSlot,
|
||||||
@@ -38,8 +43,8 @@ interface SidebarWorkspaceRowProps {
|
|||||||
showShortcutBadge: boolean;
|
showShortcutBadge: boolean;
|
||||||
canCopyBranchName: boolean;
|
canCopyBranchName: boolean;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
/** Secondary line under the name (status grouping shows the project name). */
|
/** The host pill after the title. Absent → the sidebar spans one host, or this one is hidden. */
|
||||||
subtitle?: string | null;
|
hostBadge?: HostBadgeModel | null;
|
||||||
/** Project grouping only: shows a transient "creating" affordance. */
|
/** Project grouping only: shows a transient "creating" affordance. */
|
||||||
isCreating?: boolean;
|
isCreating?: boolean;
|
||||||
/** Project grouping only: drag-to-reorder wiring. Absent → not draggable. */
|
/** Project grouping only: drag-to-reorder wiring. Absent → not draggable. */
|
||||||
@@ -55,7 +60,7 @@ export function SidebarWorkspaceRow({
|
|||||||
showShortcutBadge,
|
showShortcutBadge,
|
||||||
canCopyBranchName,
|
canCopyBranchName,
|
||||||
onPress,
|
onPress,
|
||||||
subtitle,
|
hostBadge,
|
||||||
isCreating = false,
|
isCreating = false,
|
||||||
drag,
|
drag,
|
||||||
isDragging = false,
|
isDragging = false,
|
||||||
@@ -175,7 +180,7 @@ export function SidebarWorkspaceRow({
|
|||||||
selected={selected}
|
selected={selected}
|
||||||
shortcutNumber={shortcutNumber}
|
shortcutNumber={shortcutNumber}
|
||||||
showShortcutBadge={showShortcutBadge}
|
showShortcutBadge={showShortcutBadge}
|
||||||
subtitle={subtitle}
|
hostBadge={hostBadge}
|
||||||
isCreating={isCreating}
|
isCreating={isCreating}
|
||||||
isArchiving={isArchiving}
|
isArchiving={isArchiving}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
@@ -211,7 +216,7 @@ interface WorkspaceRowBodyProps {
|
|||||||
selected: boolean;
|
selected: boolean;
|
||||||
shortcutNumber: number | null;
|
shortcutNumber: number | null;
|
||||||
showShortcutBadge: boolean;
|
showShortcutBadge: boolean;
|
||||||
subtitle?: string | null;
|
hostBadge?: HostBadgeModel | null;
|
||||||
isCreating: boolean;
|
isCreating: boolean;
|
||||||
isArchiving: boolean;
|
isArchiving: boolean;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
@@ -234,7 +239,7 @@ function WorkspaceRowBody({
|
|||||||
selected,
|
selected,
|
||||||
shortcutNumber,
|
shortcutNumber,
|
||||||
showShortcutBadge,
|
showShortcutBadge,
|
||||||
subtitle,
|
hostBadge,
|
||||||
isCreating,
|
isCreating,
|
||||||
isArchiving,
|
isArchiving,
|
||||||
onPress,
|
onPress,
|
||||||
@@ -276,7 +281,7 @@ function WorkspaceRowBody({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
|
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
|
||||||
{({ isHovered, hoverHandlers }) => {
|
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
|
||||||
const isDesktop = !isTouchPlatform;
|
const isDesktop = !isTouchPlatform;
|
||||||
const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
|
const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
|
||||||
const hasRunningService = workspace.scripts.some(
|
const hasRunningService = workspace.scripts.some(
|
||||||
@@ -297,7 +302,23 @@ function WorkspaceRowBody({
|
|||||||
style={styles.workspaceRowContainer}
|
style={styles.workspaceRowContainer}
|
||||||
{...hoverHandlers}
|
{...hoverHandlers}
|
||||||
>
|
>
|
||||||
<Pressable
|
<SidebarWorkspaceContextMenu
|
||||||
|
contextMenuOpen={contextMenuOpen}
|
||||||
|
onContextMenuOpenChange={onContextMenuOpenChange}
|
||||||
|
workspace={workspace}
|
||||||
|
hostBadgeLabel={hostBadge?.label}
|
||||||
|
scriptIconKind={scriptIconKind}
|
||||||
|
workspaceKey={workspace.workspaceKey}
|
||||||
|
onCopyPath={onCopyPath}
|
||||||
|
onCopyBranchName={onCopyBranchName}
|
||||||
|
onRename={onRename}
|
||||||
|
onMarkAsRead={onMarkAsRead}
|
||||||
|
onArchive={onArchive}
|
||||||
|
archiveLabel={archiveLabel}
|
||||||
|
archiveStatus={archiveStatus}
|
||||||
|
archivePendingLabel={archivePendingLabel}
|
||||||
|
archiveShortcutKeys={archiveShortcutKeys}
|
||||||
|
openInFileManagerPath={workspace.workspaceDirectory}
|
||||||
disabled={isArchiving}
|
disabled={isArchiving}
|
||||||
aria-selected={selected}
|
aria-selected={selected}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
@@ -311,7 +332,7 @@ function WorkspaceRowBody({
|
|||||||
>
|
>
|
||||||
<SidebarWorkspaceRowContent
|
<SidebarWorkspaceRowContent
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
subtitle={subtitle}
|
hostBadge={hostBadge}
|
||||||
scriptIconKind={scriptIconKind}
|
scriptIconKind={scriptIconKind}
|
||||||
isHovered={isHovered}
|
isHovered={isHovered}
|
||||||
isLoading={isArchiving || isCreating}
|
isLoading={isArchiving || isCreating}
|
||||||
@@ -337,7 +358,7 @@ function WorkspaceRowBody({
|
|||||||
onMarkAsRead={onMarkAsRead}
|
onMarkAsRead={onMarkAsRead}
|
||||||
/>
|
/>
|
||||||
</SidebarWorkspaceRowContent>
|
</SidebarWorkspaceRowContent>
|
||||||
</Pressable>
|
</SidebarWorkspaceContextMenu>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -380,8 +401,17 @@ function WorkspaceRowTrailingActions({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const showShortcut = showShortcutBadge && shortcutNumber !== null;
|
const showShortcut = showShortcutBadge && shortcutNumber !== null;
|
||||||
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
|
const {
|
||||||
const showKebabInSlot = showKebab && !showShortcut;
|
showDiffStat,
|
||||||
|
showKebab: showKebabInSlot,
|
||||||
|
showScrim,
|
||||||
|
} = resolveTrailingActionVisibility({
|
||||||
|
hasDiffStat: Boolean(workspace.diffStat),
|
||||||
|
hasArchiveAction: Boolean(onArchive),
|
||||||
|
isHovered,
|
||||||
|
isTouchPlatform,
|
||||||
|
showShortcut,
|
||||||
|
});
|
||||||
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
|
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -391,9 +421,7 @@ function WorkspaceRowTrailingActions({
|
|||||||
) : null}
|
) : null}
|
||||||
{shouldRenderActionSlot ? (
|
{shouldRenderActionSlot ? (
|
||||||
<SidebarWorkspaceTrailingActionSlot>
|
<SidebarWorkspaceTrailingActionSlot>
|
||||||
<SidebarWorkspaceTrailingActionBase
|
<SidebarWorkspaceTrailingActionBase visible={showDiffStat}>
|
||||||
visible={Boolean(workspace.diffStat && !showKebabInSlot && !showShortcut)}
|
|
||||||
>
|
|
||||||
{workspace.diffStat ? (
|
{workspace.diffStat ? (
|
||||||
<DiffStat
|
<DiffStat
|
||||||
additions={workspace.diffStat.additions}
|
additions={workspace.diffStat.additions}
|
||||||
@@ -401,7 +429,7 @@ function WorkspaceRowTrailingActions({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</SidebarWorkspaceTrailingActionBase>
|
</SidebarWorkspaceTrailingActionBase>
|
||||||
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot}>
|
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot} scrim={showScrim}>
|
||||||
{onArchive ? (
|
{onArchive ? (
|
||||||
<SidebarWorkspaceMenu
|
<SidebarWorkspaceMenu
|
||||||
workspaceKey={workspace.workspaceKey}
|
workspaceKey={workspace.workspaceKey}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title";
|
import {
|
||||||
|
resolveSidebarWorkspaceAccessibilityLabel,
|
||||||
|
resolveSidebarWorkspacePrimaryLabel,
|
||||||
|
} from "@/components/sidebar/sidebar-workspace-title";
|
||||||
|
|
||||||
describe("resolveSidebarWorkspacePrimaryLabel", () => {
|
describe("resolveSidebarWorkspacePrimaryLabel", () => {
|
||||||
it("uses the workspace name in title mode", () => {
|
it("uses the workspace name in title mode", () => {
|
||||||
@@ -29,3 +32,45 @@ describe("resolveSidebarWorkspacePrimaryLabel", () => {
|
|||||||
expect(label).toBe("Local folder");
|
expect(label).toBe("Local folder");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolveSidebarWorkspaceAccessibilityLabel", () => {
|
||||||
|
it("includes the visible host badge with the workspace title", () => {
|
||||||
|
const label = resolveSidebarWorkspaceAccessibilityLabel({
|
||||||
|
workspace: { name: "Investigate search", currentBranch: "fix/search", statusBucket: "done" },
|
||||||
|
workspaceTitleSource: "title",
|
||||||
|
hostBadgeLabel: "Build host",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(label).toBe("Investigate search, Build host");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("owns every visual row contributor in one accessible label", () => {
|
||||||
|
const label = resolveSidebarWorkspaceAccessibilityLabel({
|
||||||
|
workspace: {
|
||||||
|
name: "Investigate search",
|
||||||
|
currentBranch: "fix/search",
|
||||||
|
statusBucket: "running",
|
||||||
|
},
|
||||||
|
workspaceTitleSource: "branch",
|
||||||
|
leadingProjectName: "Search project",
|
||||||
|
hostBadgeLabel: "Build host",
|
||||||
|
pullRequestLabel: "Pull request 42",
|
||||||
|
scriptLabel: "Scripts available",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(label).toBe(
|
||||||
|
"Search project, fix/search, Build host, Pull request 42, Scripts available, Working",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the idle status from the workspace label", () => {
|
||||||
|
const label = resolveSidebarWorkspaceAccessibilityLabel({
|
||||||
|
workspace: { name: "Investigate search", currentBranch: "fix/search", statusBucket: "done" },
|
||||||
|
workspaceTitleSource: "title",
|
||||||
|
leadingProjectName: "Search project",
|
||||||
|
hostBadgeLabel: "Build host",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(label).toBe("Search project, Investigate search, Build host");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||||
import type { WorkspaceTitleSource } from "@/hooks/use-settings";
|
import type { WorkspaceTitleSource } from "@/hooks/use-settings";
|
||||||
|
import { STATUS_BUCKET_LABELS } from "@/hooks/sidebar-status-view-model";
|
||||||
|
|
||||||
export function resolveSidebarWorkspacePrimaryLabel(input: {
|
export function resolveSidebarWorkspacePrimaryLabel(input: {
|
||||||
workspace: Pick<SidebarWorkspaceEntry, "name" | "currentBranch">;
|
workspace: Pick<SidebarWorkspaceEntry, "name" | "currentBranch">;
|
||||||
@@ -10,3 +11,25 @@ export function resolveSidebarWorkspacePrimaryLabel(input: {
|
|||||||
}
|
}
|
||||||
return input.workspace.name;
|
return input.workspace.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveSidebarWorkspaceAccessibilityLabel(input: {
|
||||||
|
workspace: Pick<SidebarWorkspaceEntry, "name" | "currentBranch" | "statusBucket">;
|
||||||
|
workspaceTitleSource: WorkspaceTitleSource;
|
||||||
|
leadingProjectName?: string | null;
|
||||||
|
hostBadgeLabel?: string | null;
|
||||||
|
pullRequestLabel?: string | null;
|
||||||
|
scriptLabel?: string | null;
|
||||||
|
}): string {
|
||||||
|
return [
|
||||||
|
input.leadingProjectName,
|
||||||
|
resolveSidebarWorkspacePrimaryLabel(input),
|
||||||
|
input.hostBadgeLabel,
|
||||||
|
input.pullRequestLabel,
|
||||||
|
input.scriptLabel,
|
||||||
|
input.workspace.statusBucket === "done"
|
||||||
|
? null
|
||||||
|
: STATUS_BUCKET_LABELS[input.workspace.statusBucket],
|
||||||
|
]
|
||||||
|
.filter((label): label is string => Boolean(label))
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
CircleX,
|
CircleX,
|
||||||
Copy,
|
Copy,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
FileDiff,
|
||||||
Folder,
|
Folder,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Server,
|
Server,
|
||||||
@@ -94,12 +95,14 @@ interface WorkspaceHoverCardProps {
|
|||||||
workspace: SidebarWorkspaceEntry;
|
workspace: SidebarWorkspaceEntry;
|
||||||
prHint: PrHint | null;
|
prHint: PrHint | null;
|
||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkspaceHoverCard({
|
export function WorkspaceHoverCard({
|
||||||
workspace,
|
workspace,
|
||||||
prHint,
|
prHint,
|
||||||
isDragging,
|
isDragging,
|
||||||
|
disabled = false,
|
||||||
children,
|
children,
|
||||||
}: PropsWithChildren<WorkspaceHoverCardProps>): ReactNode {
|
}: PropsWithChildren<WorkspaceHoverCardProps>): ReactNode {
|
||||||
const isCompact = useIsCompactFormFactor();
|
const isCompact = useIsCompactFormFactor();
|
||||||
@@ -109,7 +112,12 @@ export function WorkspaceHoverCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceHoverCardDesktop workspace={workspace} prHint={prHint} isDragging={isDragging}>
|
<WorkspaceHoverCardDesktop
|
||||||
|
workspace={workspace}
|
||||||
|
prHint={prHint}
|
||||||
|
isDragging={isDragging}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</WorkspaceHoverCardDesktop>
|
</WorkspaceHoverCardDesktop>
|
||||||
);
|
);
|
||||||
@@ -119,6 +127,7 @@ function WorkspaceHoverCardDesktop({
|
|||||||
workspace,
|
workspace,
|
||||||
prHint,
|
prHint,
|
||||||
isDragging,
|
isDragging,
|
||||||
|
disabled = false,
|
||||||
children,
|
children,
|
||||||
}: PropsWithChildren<WorkspaceHoverCardProps>): ReactElement {
|
}: PropsWithChildren<WorkspaceHoverCardProps>): ReactElement {
|
||||||
const triggerRef = useRef<View>(null);
|
const triggerRef = useRef<View>(null);
|
||||||
@@ -145,10 +154,10 @@ function WorkspaceHoverCardDesktop({
|
|||||||
const handleTriggerEnter = useCallback(() => {
|
const handleTriggerEnter = useCallback(() => {
|
||||||
triggerHoveredRef.current = true;
|
triggerHoveredRef.current = true;
|
||||||
clearGraceTimer();
|
clearGraceTimer();
|
||||||
if (!isDragging) {
|
if (!isDragging && !disabled) {
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}
|
}
|
||||||
}, [clearGraceTimer, isDragging]);
|
}, [clearGraceTimer, disabled, isDragging]);
|
||||||
|
|
||||||
const handleTriggerLeave = useCallback(() => {
|
const handleTriggerLeave = useCallback(() => {
|
||||||
triggerHoveredRef.current = false;
|
triggerHoveredRef.current = false;
|
||||||
@@ -166,13 +175,13 @@ function WorkspaceHoverCardDesktop({
|
|||||||
onLeaveSafeZone: scheduleClose,
|
onLeaveSafeZone: scheduleClose,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close when drag starts
|
// Close while another row interaction owns attention.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isDragging) {
|
if (isDragging || disabled) {
|
||||||
clearGraceTimer();
|
clearGraceTimer();
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}
|
}
|
||||||
}, [isDragging, clearGraceTimer]);
|
}, [clearGraceTimer, disabled, isDragging]);
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -285,6 +294,16 @@ function WorkspaceHoverCardContent({
|
|||||||
{workspace.name}
|
{workspace.name}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
{prHint ? <PrBadge hint={prHint} style={styles.cardInfoRow} /> : null}
|
||||||
|
{workspace.diffStat ? (
|
||||||
|
<View style={styles.cardInfoRow}>
|
||||||
|
<ThemedFileDiff size={12} uniProps={foregroundMutedColorMapping} />
|
||||||
|
<DiffStat
|
||||||
|
additions={workspace.diffStat.additions}
|
||||||
|
deletions={workspace.diffStat.deletions}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
<HostRow serverId={workspace.serverId} />
|
<HostRow serverId={workspace.serverId} />
|
||||||
{workspace.currentBranch ? (
|
{workspace.currentBranch ? (
|
||||||
<CopyableInfoRow
|
<CopyableInfoRow
|
||||||
@@ -304,17 +323,6 @@ function WorkspaceHoverCardContent({
|
|||||||
testID="hover-card-workspace-cwd"
|
testID="hover-card-workspace-cwd"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{prHint || workspace.diffStat ? (
|
|
||||||
<View style={styles.cardMetaRow}>
|
|
||||||
{workspace.diffStat ? (
|
|
||||||
<DiffStat
|
|
||||||
additions={workspace.diffStat.additions}
|
|
||||||
deletions={workspace.diffStat.deletions}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{prHint ? <PrBadge hint={prHint} /> : null}
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
{prHint?.checks && prHint.checks.length > 0 ? (
|
{prHint?.checks && prHint.checks.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<View style={styles.separator} />
|
<View style={styles.separator} />
|
||||||
@@ -334,6 +342,7 @@ function WorkspaceHoverCardContent({
|
|||||||
const ThemedGitBranch = withUnistyles(GitBranch);
|
const ThemedGitBranch = withUnistyles(GitBranch);
|
||||||
const ThemedFolder = withUnistyles(Folder);
|
const ThemedFolder = withUnistyles(Folder);
|
||||||
const ThemedServer = withUnistyles(Server);
|
const ThemedServer = withUnistyles(Server);
|
||||||
|
const ThemedFileDiff = withUnistyles(FileDiff);
|
||||||
|
|
||||||
type CardInfoIcon = React.ComponentType<React.ComponentProps<typeof ThemedGitBranch>>;
|
type CardInfoIcon = React.ComponentType<React.ComponentProps<typeof ThemedGitBranch>>;
|
||||||
|
|
||||||
@@ -596,13 +605,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
cardMetaRow: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 6,
|
|
||||||
paddingHorizontal: theme.spacing[3],
|
|
||||||
paddingBottom: theme.spacing[2],
|
|
||||||
},
|
|
||||||
cardInfoRow: {
|
cardInfoRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { useSidebarViewStore } from "@/stores/sidebar-view-store";
|
|||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import { WorkspaceShortcutTargetsSubscriber } from "./workspace-shortcut-targets-subscriber";
|
import { WorkspaceShortcutTargetsSubscriber } from "./workspace-shortcut-targets-subscriber";
|
||||||
import { SidebarModelProvider } from "./sidebar/sidebar-model";
|
import { SidebarModelProvider } from "./sidebar/sidebar-model";
|
||||||
|
import { defaultHostAppearance } from "@/hosts/appearance";
|
||||||
|
|
||||||
vi.hoisted(() => {
|
vi.hoisted(() => {
|
||||||
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
|
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
|
||||||
@@ -51,6 +52,7 @@ function hostProfile(serverId = "srv"): HostProfile {
|
|||||||
return {
|
return {
|
||||||
serverId,
|
serverId,
|
||||||
label: "Shortcut Host",
|
label: "Shortcut Host",
|
||||||
|
appearance: defaultHostAppearance(),
|
||||||
lifecycle: {},
|
lifecycle: {},
|
||||||
connections: [],
|
connections: [],
|
||||||
preferredConnectionId: null,
|
preferredConnectionId: null,
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import {
|
|||||||
} from "./app-diagnostic-report";
|
} from "./app-diagnostic-report";
|
||||||
import type { HostRuntimeSnapshot } from "@/runtime/host-runtime";
|
import type { HostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
|
import { defaultHostAppearance } from "@/hosts/appearance";
|
||||||
|
|
||||||
function makeHost(): HostProfile {
|
function makeHost(): HostProfile {
|
||||||
return {
|
return {
|
||||||
serverId: "srv-secret",
|
serverId: "srv-secret",
|
||||||
label: "Secret host",
|
label: "Secret host",
|
||||||
|
appearance: defaultHostAppearance(),
|
||||||
lifecycle: {},
|
lifecycle: {},
|
||||||
preferredConnectionId: "direct:secret.example.test:6767",
|
preferredConnectionId: "direct:secret.example.test:6767",
|
||||||
createdAt: "2026-06-25T00:00:00.000Z",
|
createdAt: "2026-06-25T00:00:00.000Z",
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
type HostAppearanceSource,
|
||||||
|
defaultHostAppearance,
|
||||||
|
normalizeStoredHostAppearance,
|
||||||
|
resolveHostBadgeDisplay,
|
||||||
|
selectHostBadges,
|
||||||
|
} from "@/hosts/appearance";
|
||||||
|
|
||||||
|
function host(
|
||||||
|
serverId: string,
|
||||||
|
label: string,
|
||||||
|
appearance = defaultHostAppearance(),
|
||||||
|
): HostAppearanceSource {
|
||||||
|
return { serverId, label, appearance };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("normalizeStoredHostAppearance", () => {
|
||||||
|
it("defaults when the stored registry predates the field or holds junk", () => {
|
||||||
|
const expected = { color: "none", badgeDisplay: null };
|
||||||
|
expect(normalizeStoredHostAppearance(undefined)).toEqual(expected);
|
||||||
|
expect(normalizeStoredHostAppearance(null)).toEqual(expected);
|
||||||
|
expect(normalizeStoredHostAppearance("x")).toEqual(expected);
|
||||||
|
expect(normalizeStoredHostAppearance({})).toEqual(expected);
|
||||||
|
expect(normalizeStoredHostAppearance({ color: "chartreuse" })).toEqual(expected);
|
||||||
|
expect(normalizeStoredHostAppearance({ badgeDisplay: "loud" })).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips a value the user actually chose", () => {
|
||||||
|
expect(normalizeStoredHostAppearance({ color: "teal", badgeDisplay: "icon" })).toEqual({
|
||||||
|
color: "teal",
|
||||||
|
badgeDisplay: "icon",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveHostBadgeDisplay", () => {
|
||||||
|
it("hides the badge for the local host until the user chooses", () => {
|
||||||
|
expect(
|
||||||
|
resolveHostBadgeDisplay({ appearance: defaultHostAppearance(), isLocalHost: true }),
|
||||||
|
).toBe("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names a remote host until the user chooses", () => {
|
||||||
|
expect(
|
||||||
|
resolveHostBadgeDisplay({ appearance: defaultHostAppearance(), isLocalHost: false }),
|
||||||
|
).toBe("name");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers an explicit choice over either default", () => {
|
||||||
|
expect(
|
||||||
|
resolveHostBadgeDisplay({
|
||||||
|
appearance: { color: "none", badgeDisplay: "icon" },
|
||||||
|
isLocalHost: true,
|
||||||
|
}),
|
||||||
|
).toBe("icon");
|
||||||
|
expect(
|
||||||
|
resolveHostBadgeDisplay({
|
||||||
|
appearance: { color: "none", badgeDisplay: "hidden" },
|
||||||
|
isLocalHost: false,
|
||||||
|
}),
|
||||||
|
).toBe("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defers only the default while local-host detection is unresolved", () => {
|
||||||
|
expect(
|
||||||
|
resolveHostBadgeDisplay({
|
||||||
|
appearance: defaultHostAppearance(),
|
||||||
|
isLocalHost: false,
|
||||||
|
localHostResolutionPending: true,
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
resolveHostBadgeDisplay({
|
||||||
|
appearance: { color: "none", badgeDisplay: "icon" },
|
||||||
|
isLocalHost: false,
|
||||||
|
localHostResolutionPending: true,
|
||||||
|
}),
|
||||||
|
).toBe("icon");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("selectHostBadges", () => {
|
||||||
|
it("shows no badges while the sidebar spans a single host", () => {
|
||||||
|
const badges = selectHostBadges({
|
||||||
|
hosts: [host("alpha", "Alpha"), host("beta", "Beta")],
|
||||||
|
localServerId: null,
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
expect(badges.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits a host the user hid and keeps its sibling", () => {
|
||||||
|
const badges = selectHostBadges({
|
||||||
|
hosts: [
|
||||||
|
host("alpha", "Alpha", { color: "none", badgeDisplay: "hidden" }),
|
||||||
|
host("beta", "Beta"),
|
||||||
|
],
|
||||||
|
localServerId: null,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
expect(badges.has("alpha")).toBe(false);
|
||||||
|
expect(badges.get("beta")).toEqual({
|
||||||
|
serverId: "beta",
|
||||||
|
label: "Beta",
|
||||||
|
color: "none",
|
||||||
|
showLabel: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an icon-only host in the map without its label", () => {
|
||||||
|
const badges = selectHostBadges({
|
||||||
|
hosts: [host("alpha", "Alpha", { color: "teal", badgeDisplay: "icon" })],
|
||||||
|
localServerId: null,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
expect(badges.get("alpha")).toEqual({
|
||||||
|
serverId: "alpha",
|
||||||
|
label: "Alpha",
|
||||||
|
color: "teal",
|
||||||
|
showLabel: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the server id when the label is blank", () => {
|
||||||
|
const badges = selectHostBadges({
|
||||||
|
hosts: [host("alpha", " ")],
|
||||||
|
localServerId: null,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
expect(badges.get("alpha")?.label).toBe("alpha");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides an untouched local host while its remote sibling shows", () => {
|
||||||
|
const badges = selectHostBadges({
|
||||||
|
hosts: [host("alpha", "Alpha"), host("beta", "Beta")],
|
||||||
|
localServerId: "alpha",
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
expect(badges.has("alpha")).toBe(false);
|
||||||
|
expect(badges.get("beta")?.label).toBe("Beta");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits untouched badges while local-host detection is unresolved", () => {
|
||||||
|
const badges = selectHostBadges({
|
||||||
|
hosts: [host("alpha", "Alpha"), host("beta", "Beta")],
|
||||||
|
localServerId: null,
|
||||||
|
localHostResolutionPending: true,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
expect(badges.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { IDENTITY_COLOR_NAMES, type IdentityColorName } from "@/styles/identity-colors";
|
||||||
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
|
|
||||||
|
export type HostColor = "none" | IdentityColorName;
|
||||||
|
|
||||||
|
export const HOST_COLORS: readonly HostColor[] = ["none", ...IDENTITY_COLOR_NAMES];
|
||||||
|
|
||||||
|
export type HostBadgeDisplay = "name" | "icon" | "hidden";
|
||||||
|
|
||||||
|
export const HOST_BADGE_DISPLAYS: readonly HostBadgeDisplay[] = ["name", "icon", "hidden"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-device host presentation. `badgeDisplay` is null while the user has not chosen,
|
||||||
|
* because the default differs by host (local hides, remote shows) and local-ness is only
|
||||||
|
* knowable from a desktop-only async query — never at parse time.
|
||||||
|
*/
|
||||||
|
export interface HostAppearance {
|
||||||
|
color: HostColor;
|
||||||
|
badgeDisplay: HostBadgeDisplay | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultHostAppearance(): HostAppearance {
|
||||||
|
return { color: "none", badgeDisplay: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHostColor(value: unknown): value is HostColor {
|
||||||
|
return HOST_COLORS.some((color) => color === value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHostBadgeDisplay(value: unknown): value is HostBadgeDisplay {
|
||||||
|
return HOST_BADGE_DISPLAYS.some((display) => display === value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeStoredHostAppearance(value: unknown): HostAppearance {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
return defaultHostAppearance();
|
||||||
|
}
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
color: isHostColor(record.color) ? record.color : "none",
|
||||||
|
badgeDisplay: isHostBadgeDisplay(record.badgeDisplay) ? record.badgeDisplay : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveHostBadgeDisplay(input: {
|
||||||
|
appearance: HostAppearance;
|
||||||
|
isLocalHost: boolean;
|
||||||
|
localHostResolutionPending?: boolean;
|
||||||
|
}): HostBadgeDisplay | null {
|
||||||
|
if (input.appearance.badgeDisplay) {
|
||||||
|
return input.appearance.badgeDisplay;
|
||||||
|
}
|
||||||
|
if (input.localHostResolutionPending) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return input.isLocalHost ? "hidden" : "name";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostBadgeModel {
|
||||||
|
serverId: string;
|
||||||
|
label: string;
|
||||||
|
color: HostColor;
|
||||||
|
showLabel: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HostAppearanceSource = Pick<HostProfile, "serverId" | "label" | "appearance">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sidebar's whole host-badge decision, resolved once per host list. Rows look their
|
||||||
|
* badge up by serverId and render whatever they find; a host that should show no badge is
|
||||||
|
* simply absent from the map.
|
||||||
|
*/
|
||||||
|
export function selectHostBadges(input: {
|
||||||
|
hosts: readonly HostAppearanceSource[];
|
||||||
|
localServerId: string | null;
|
||||||
|
localHostResolutionPending?: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
}): ReadonlyMap<string, HostBadgeModel> {
|
||||||
|
const badges = new Map<string, HostBadgeModel>();
|
||||||
|
if (!input.enabled) {
|
||||||
|
return badges;
|
||||||
|
}
|
||||||
|
for (const host of input.hosts) {
|
||||||
|
const display = resolveHostBadgeDisplay({
|
||||||
|
appearance: host.appearance,
|
||||||
|
isLocalHost: host.serverId === input.localServerId,
|
||||||
|
localHostResolutionPending: input.localHostResolutionPending,
|
||||||
|
});
|
||||||
|
if (display === null || display === "hidden") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
badges.set(host.serverId, {
|
||||||
|
serverId: host.serverId,
|
||||||
|
label: host.label.trim() || host.serverId,
|
||||||
|
color: host.appearance.color,
|
||||||
|
showLabel: display === "name",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return badges;
|
||||||
|
}
|
||||||
@@ -1938,6 +1938,41 @@ export const ar: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
host: {
|
host: {
|
||||||
|
appearance: {
|
||||||
|
title: "المظهر",
|
||||||
|
name: {
|
||||||
|
label: "الاسم",
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
label: "اللون",
|
||||||
|
accessibilityLabel: "اللون، {{value}}",
|
||||||
|
options: {
|
||||||
|
none: "افتراضي",
|
||||||
|
violet: "بنفسجي",
|
||||||
|
sky: "سماوي",
|
||||||
|
emerald: "زمردي",
|
||||||
|
orange: "برتقالي",
|
||||||
|
pink: "وردي",
|
||||||
|
indigo: "نيلي",
|
||||||
|
teal: "أزرق مخضر",
|
||||||
|
red: "أحمر",
|
||||||
|
amber: "كهرماني",
|
||||||
|
blue: "أزرق",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
label: "شارة الشريط الجانبي",
|
||||||
|
accessibilityLabel: "شارة الشريط الجانبي، {{value}}",
|
||||||
|
options: {
|
||||||
|
name: "الاسم",
|
||||||
|
icon: "أيقونة فقط",
|
||||||
|
hidden: "مخفي",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
workspaceName: "my-workspace",
|
||||||
|
},
|
||||||
|
},
|
||||||
notFound: "لم يتم العثور على Host",
|
notFound: "لم يتم العثور على Host",
|
||||||
badges: {
|
badges: {
|
||||||
relay: "تتابع",
|
relay: "تتابع",
|
||||||
|
|||||||
@@ -265,7 +265,7 @@ export const en = {
|
|||||||
copyCode: "Copy code",
|
copyCode: "Copy code",
|
||||||
copyTurn: "Copy turn",
|
copyTurn: "Copy turn",
|
||||||
copyMessage: "Copy message",
|
copyMessage: "Copy message",
|
||||||
forkMenu: "Fork chat",
|
forkMenu: "Fork chat from here",
|
||||||
forkInNewTab: "Fork in a new tab",
|
forkInNewTab: "Fork in a new tab",
|
||||||
forkInNewWorkspace: "Fork in a new workspace",
|
forkInNewWorkspace: "Fork in a new workspace",
|
||||||
forkUnavailable: "Update the host to use this.",
|
forkUnavailable: "Update the host to use this.",
|
||||||
@@ -1949,6 +1949,41 @@ export const en = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
host: {
|
host: {
|
||||||
|
appearance: {
|
||||||
|
title: "Appearance",
|
||||||
|
name: {
|
||||||
|
label: "Name",
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
label: "Color",
|
||||||
|
accessibilityLabel: "Color, {{value}}",
|
||||||
|
options: {
|
||||||
|
none: "Default",
|
||||||
|
violet: "Violet",
|
||||||
|
sky: "Sky",
|
||||||
|
emerald: "Emerald",
|
||||||
|
orange: "Orange",
|
||||||
|
pink: "Pink",
|
||||||
|
indigo: "Indigo",
|
||||||
|
teal: "Teal",
|
||||||
|
red: "Red",
|
||||||
|
amber: "Amber",
|
||||||
|
blue: "Blue",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
label: "Sidebar badge",
|
||||||
|
accessibilityLabel: "Sidebar badge, {{value}}",
|
||||||
|
options: {
|
||||||
|
name: "Name",
|
||||||
|
icon: "Icon only",
|
||||||
|
hidden: "Hidden",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
workspaceName: "my-workspace",
|
||||||
|
},
|
||||||
|
},
|
||||||
notFound: "Host not found",
|
notFound: "Host not found",
|
||||||
badges: {
|
badges: {
|
||||||
relay: "Relay",
|
relay: "Relay",
|
||||||
|
|||||||
@@ -1988,6 +1988,41 @@ export const es: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
host: {
|
host: {
|
||||||
|
appearance: {
|
||||||
|
title: "Apariencia",
|
||||||
|
name: {
|
||||||
|
label: "Nombre",
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
label: "Color",
|
||||||
|
accessibilityLabel: "Color, {{value}}",
|
||||||
|
options: {
|
||||||
|
none: "Predeterminado",
|
||||||
|
violet: "Violeta",
|
||||||
|
sky: "Cielo",
|
||||||
|
emerald: "Esmeralda",
|
||||||
|
orange: "Naranja",
|
||||||
|
pink: "Rosa",
|
||||||
|
indigo: "Índigo",
|
||||||
|
teal: "Verde azulado",
|
||||||
|
red: "Rojo",
|
||||||
|
amber: "Ámbar",
|
||||||
|
blue: "Azul",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
label: "Insignia de la barra lateral",
|
||||||
|
accessibilityLabel: "Insignia de la barra lateral, {{value}}",
|
||||||
|
options: {
|
||||||
|
name: "Nombre",
|
||||||
|
icon: "Solo icono",
|
||||||
|
hidden: "Oculta",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
workspaceName: "my-workspace",
|
||||||
|
},
|
||||||
|
},
|
||||||
notFound: "Hostno encontrado",
|
notFound: "Hostno encontrado",
|
||||||
badges: {
|
badges: {
|
||||||
relay: "Relé",
|
relay: "Relé",
|
||||||
|
|||||||
@@ -1992,6 +1992,41 @@ export const fr: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
host: {
|
host: {
|
||||||
|
appearance: {
|
||||||
|
title: "Apparence",
|
||||||
|
name: {
|
||||||
|
label: "Nom",
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
label: "Couleur",
|
||||||
|
accessibilityLabel: "Couleur, {{value}}",
|
||||||
|
options: {
|
||||||
|
none: "Par défaut",
|
||||||
|
violet: "Violet",
|
||||||
|
sky: "Ciel",
|
||||||
|
emerald: "Émeraude",
|
||||||
|
orange: "Orange",
|
||||||
|
pink: "Rose",
|
||||||
|
indigo: "Indigo",
|
||||||
|
teal: "Sarcelle",
|
||||||
|
red: "Rouge",
|
||||||
|
amber: "Ambre",
|
||||||
|
blue: "Bleu",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
label: "Badge de la barre latérale",
|
||||||
|
accessibilityLabel: "Badge de la barre latérale, {{value}}",
|
||||||
|
options: {
|
||||||
|
name: "Nom",
|
||||||
|
icon: "Icône seule",
|
||||||
|
hidden: "Masqué",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
workspaceName: "my-workspace",
|
||||||
|
},
|
||||||
|
},
|
||||||
notFound: "Hostintrouvable",
|
notFound: "Hostintrouvable",
|
||||||
badges: {
|
badges: {
|
||||||
relay: "Relais",
|
relay: "Relais",
|
||||||
|
|||||||
@@ -1957,6 +1957,41 @@ export const ja: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
host: {
|
host: {
|
||||||
|
appearance: {
|
||||||
|
title: "外観",
|
||||||
|
name: {
|
||||||
|
label: "名前",
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
label: "色",
|
||||||
|
accessibilityLabel: "色、{{value}}",
|
||||||
|
options: {
|
||||||
|
none: "デフォルト",
|
||||||
|
violet: "バイオレット",
|
||||||
|
sky: "スカイ",
|
||||||
|
emerald: "エメラルド",
|
||||||
|
orange: "オレンジ",
|
||||||
|
pink: "ピンク",
|
||||||
|
indigo: "インディゴ",
|
||||||
|
teal: "ティール",
|
||||||
|
red: "レッド",
|
||||||
|
amber: "アンバー",
|
||||||
|
blue: "ブルー",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
label: "サイドバーのバッジ",
|
||||||
|
accessibilityLabel: "サイドバーのバッジ、{{value}}",
|
||||||
|
options: {
|
||||||
|
name: "名前",
|
||||||
|
icon: "アイコンのみ",
|
||||||
|
hidden: "非表示",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
workspaceName: "my-workspace",
|
||||||
|
},
|
||||||
|
},
|
||||||
notFound: "ホストが見つかりません",
|
notFound: "ホストが見つかりません",
|
||||||
badges: {
|
badges: {
|
||||||
relay: "リレー",
|
relay: "リレー",
|
||||||
|
|||||||
@@ -1972,6 +1972,41 @@ export const ptBR: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
host: {
|
host: {
|
||||||
|
appearance: {
|
||||||
|
title: "Aparência",
|
||||||
|
name: {
|
||||||
|
label: "Nome",
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
label: "Cor",
|
||||||
|
accessibilityLabel: "Cor, {{value}}",
|
||||||
|
options: {
|
||||||
|
none: "Padrão",
|
||||||
|
violet: "Violeta",
|
||||||
|
sky: "Céu",
|
||||||
|
emerald: "Esmeralda",
|
||||||
|
orange: "Laranja",
|
||||||
|
pink: "Rosa",
|
||||||
|
indigo: "Índigo",
|
||||||
|
teal: "Verde-azulado",
|
||||||
|
red: "Vermelho",
|
||||||
|
amber: "Âmbar",
|
||||||
|
blue: "Azul",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
label: "Selo da barra lateral",
|
||||||
|
accessibilityLabel: "Selo da barra lateral, {{value}}",
|
||||||
|
options: {
|
||||||
|
name: "Nome",
|
||||||
|
icon: "Somente ícone",
|
||||||
|
hidden: "Oculto",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
workspaceName: "my-workspace",
|
||||||
|
},
|
||||||
|
},
|
||||||
notFound: "Host não encontrado",
|
notFound: "Host não encontrado",
|
||||||
badges: {
|
badges: {
|
||||||
relay: "Relay",
|
relay: "Relay",
|
||||||
|
|||||||
@@ -1976,6 +1976,41 @@ export const ru: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
host: {
|
host: {
|
||||||
|
appearance: {
|
||||||
|
title: "Оформление",
|
||||||
|
name: {
|
||||||
|
label: "Имя",
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
label: "Цвет",
|
||||||
|
accessibilityLabel: "Цвет, {{value}}",
|
||||||
|
options: {
|
||||||
|
none: "По умолчанию",
|
||||||
|
violet: "Фиолетовый",
|
||||||
|
sky: "Небесный",
|
||||||
|
emerald: "Изумрудный",
|
||||||
|
orange: "Оранжевый",
|
||||||
|
pink: "Розовый",
|
||||||
|
indigo: "Индиго",
|
||||||
|
teal: "Бирюзовый",
|
||||||
|
red: "Красный",
|
||||||
|
amber: "Янтарный",
|
||||||
|
blue: "Синий",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
label: "Значок на боковой панели",
|
||||||
|
accessibilityLabel: "Значок на боковой панели, {{value}}",
|
||||||
|
options: {
|
||||||
|
name: "Имя",
|
||||||
|
icon: "Только значок",
|
||||||
|
hidden: "Скрыт",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
workspaceName: "my-workspace",
|
||||||
|
},
|
||||||
|
},
|
||||||
notFound: "Host не найден",
|
notFound: "Host не найден",
|
||||||
badges: {
|
badges: {
|
||||||
relay: "Реле",
|
relay: "Реле",
|
||||||
|
|||||||
@@ -1916,6 +1916,41 @@ export const zhCN: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
host: {
|
host: {
|
||||||
|
appearance: {
|
||||||
|
title: "外观",
|
||||||
|
name: {
|
||||||
|
label: "名称",
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
label: "颜色",
|
||||||
|
accessibilityLabel: "颜色,{{value}}",
|
||||||
|
options: {
|
||||||
|
none: "默认",
|
||||||
|
violet: "紫罗兰",
|
||||||
|
sky: "天蓝",
|
||||||
|
emerald: "翠绿",
|
||||||
|
orange: "橙色",
|
||||||
|
pink: "粉色",
|
||||||
|
indigo: "靛蓝",
|
||||||
|
teal: "青色",
|
||||||
|
red: "红色",
|
||||||
|
amber: "琥珀",
|
||||||
|
blue: "蓝色",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
label: "侧边栏徽章",
|
||||||
|
accessibilityLabel: "侧边栏徽章,{{value}}",
|
||||||
|
options: {
|
||||||
|
name: "名称",
|
||||||
|
icon: "仅图标",
|
||||||
|
hidden: "隐藏",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
workspaceName: "my-workspace",
|
||||||
|
},
|
||||||
|
},
|
||||||
notFound: "Host 未找到",
|
notFound: "Host 未找到",
|
||||||
badges: {
|
badges: {
|
||||||
relay: "Relay",
|
relay: "Relay",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer";
|
|||||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||||
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
|
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
|
||||||
import type { HostConnection, HostProfile } from "@/types/host-connection";
|
import type { HostConnection, HostProfile } from "@/types/host-connection";
|
||||||
|
import { defaultHostAppearance } from "@/hosts/appearance";
|
||||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||||
import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent";
|
import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent";
|
||||||
@@ -348,6 +349,7 @@ function makeHost(input?: Partial<HostProfile>): HostProfile {
|
|||||||
return {
|
return {
|
||||||
serverId: input?.serverId ?? "srv_test",
|
serverId: input?.serverId ?? "srv_test",
|
||||||
label: input?.label ?? "test host",
|
label: input?.label ?? "test host",
|
||||||
|
appearance: input?.appearance ?? defaultHostAppearance(),
|
||||||
lifecycle: input?.lifecycle ?? {},
|
lifecycle: input?.lifecycle ?? {},
|
||||||
connections: input?.connections ?? [direct, relay],
|
connections: input?.connections ?? [direct, relay],
|
||||||
preferredConnectionId: input?.preferredConnectionId ?? direct.id,
|
preferredConnectionId: input?.preferredConnectionId ?? direct.id,
|
||||||
@@ -446,6 +448,21 @@ function createMemoryHostRuntimeStorage(entries: Record<string, string> = {}): H
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createAppearanceStore(storage: HostRuntimeStorage): HostRuntimeStore {
|
||||||
|
return new HostRuntimeStore({
|
||||||
|
storage,
|
||||||
|
deps: {
|
||||||
|
createClient: () => {
|
||||||
|
throw new Error("createClient should not be called");
|
||||||
|
},
|
||||||
|
connectToDaemon: async () => {
|
||||||
|
throw new Error("connectToDaemon should not be called");
|
||||||
|
},
|
||||||
|
getClientId: async () => "cid_test_appearance",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function onceHostListMatches(store: HostRuntimeStore, predicate: () => boolean): Promise<void> {
|
function onceHostListMatches(store: HostRuntimeStore, predicate: () => boolean): Promise<void> {
|
||||||
if (predicate()) {
|
if (predicate()) {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
@@ -1477,6 +1494,151 @@ describe("HostRuntimeStore", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("exposes the default appearance for a host stored before the field existed", async () => {
|
||||||
|
const storage = createMemoryHostRuntimeStorage();
|
||||||
|
await storage.setItem(
|
||||||
|
"@paseo:daemon-registry",
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
serverId: "srv_legacy",
|
||||||
|
label: "Legacy",
|
||||||
|
connections: [
|
||||||
|
{ id: "socket:/tmp/legacy.sock", type: "directSocket", path: "/tmp/legacy.sock" },
|
||||||
|
],
|
||||||
|
preferredConnectionId: "socket:/tmp/legacy.sock",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
await storage.setItem("@paseo:e2e", "1");
|
||||||
|
const store = createAppearanceStore(storage);
|
||||||
|
|
||||||
|
const registryLoaded = onceHostListMatches(store, () => store.isHostRegistryLoaded());
|
||||||
|
store.boot();
|
||||||
|
await registryLoaded;
|
||||||
|
|
||||||
|
expect(store.getHosts()[0]?.appearance).toEqual({ color: "none", badgeDisplay: null });
|
||||||
|
|
||||||
|
store.syncHosts([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records a chosen host color and writes it through to storage", async () => {
|
||||||
|
const host = makeHost({ serverId: "srv_appearance", updatedAt: new Date(0).toISOString() });
|
||||||
|
const storage = createMemoryHostRuntimeStorage();
|
||||||
|
await storage.setItem("@paseo:daemon-registry", JSON.stringify([host]));
|
||||||
|
await storage.setItem("@paseo:e2e", "1");
|
||||||
|
const store = createAppearanceStore(storage);
|
||||||
|
|
||||||
|
const registryLoaded = onceHostListMatches(store, () => store.isHostRegistryLoaded());
|
||||||
|
store.boot();
|
||||||
|
await registryLoaded;
|
||||||
|
|
||||||
|
const hostListChanged = onceHostListMatches(
|
||||||
|
store,
|
||||||
|
() => store.getHosts()[0]?.appearance.color === "teal",
|
||||||
|
);
|
||||||
|
await store.setHostColor("srv_appearance", "teal");
|
||||||
|
await hostListChanged;
|
||||||
|
|
||||||
|
const updated = store.getHosts()[0];
|
||||||
|
expect(updated?.appearance).toEqual({ color: "teal", badgeDisplay: null });
|
||||||
|
expect(updated?.updatedAt).not.toBe(host.updatedAt);
|
||||||
|
|
||||||
|
const persisted = await storage.getItem("@paseo:daemon-registry");
|
||||||
|
expect(JSON.parse(persisted ?? "[]")[0].appearance).toEqual({
|
||||||
|
color: "teal",
|
||||||
|
badgeDisplay: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
store.syncHosts([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records a chosen badge display without disturbing the color", async () => {
|
||||||
|
const host = makeHost({
|
||||||
|
serverId: "srv_appearance",
|
||||||
|
appearance: { color: "amber", badgeDisplay: null },
|
||||||
|
});
|
||||||
|
const storage = createMemoryHostRuntimeStorage();
|
||||||
|
await storage.setItem("@paseo:daemon-registry", JSON.stringify([host]));
|
||||||
|
await storage.setItem("@paseo:e2e", "1");
|
||||||
|
const store = createAppearanceStore(storage);
|
||||||
|
|
||||||
|
const registryLoaded = onceHostListMatches(store, () => store.isHostRegistryLoaded());
|
||||||
|
store.boot();
|
||||||
|
await registryLoaded;
|
||||||
|
|
||||||
|
const hostListChanged = onceHostListMatches(
|
||||||
|
store,
|
||||||
|
() => store.getHosts()[0]?.appearance.badgeDisplay === "icon",
|
||||||
|
);
|
||||||
|
await store.setHostBadgeDisplay("srv_appearance", "icon");
|
||||||
|
await hostListChanged;
|
||||||
|
|
||||||
|
expect(store.getHosts()[0]?.appearance).toEqual({ color: "amber", badgeDisplay: "icon" });
|
||||||
|
|
||||||
|
const persisted = await storage.getItem("@paseo:daemon-registry");
|
||||||
|
expect(JSON.parse(persisted ?? "[]")[0].appearance).toEqual({
|
||||||
|
color: "amber",
|
||||||
|
badgeDisplay: "icon",
|
||||||
|
});
|
||||||
|
|
||||||
|
store.syncHosts([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps host appearance unchanged when persistence fails", async () => {
|
||||||
|
const host = makeHost({ serverId: "srv_appearance" });
|
||||||
|
const storage = createMemoryHostRuntimeStorage();
|
||||||
|
await storage.setItem("@paseo:daemon-registry", JSON.stringify([host]));
|
||||||
|
await storage.setItem("@paseo:e2e", "1");
|
||||||
|
const store = createAppearanceStore(storage);
|
||||||
|
|
||||||
|
const registryLoaded = onceHostListMatches(store, () => store.isHostRegistryLoaded());
|
||||||
|
store.boot();
|
||||||
|
await registryLoaded;
|
||||||
|
|
||||||
|
storage.setItem = async () => {
|
||||||
|
throw new Error("disk full");
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(store.setHostColor("srv_appearance", "teal")).rejects.toThrow("disk full");
|
||||||
|
expect(store.getHosts()[0]?.appearance).toEqual(defaultHostAppearance());
|
||||||
|
|
||||||
|
store.syncHosts([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serializes overlapping host appearance writes", async () => {
|
||||||
|
const host = makeHost({ serverId: "srv_appearance" });
|
||||||
|
const storage = createMemoryHostRuntimeStorage();
|
||||||
|
await storage.setItem("@paseo:daemon-registry", JSON.stringify([host]));
|
||||||
|
await storage.setItem("@paseo:e2e", "1");
|
||||||
|
const store = createAppearanceStore(storage);
|
||||||
|
|
||||||
|
const registryLoaded = onceHostListMatches(store, () => store.isHostRegistryLoaded());
|
||||||
|
store.boot();
|
||||||
|
await registryLoaded;
|
||||||
|
|
||||||
|
const firstWrite = createDeferred<void>();
|
||||||
|
let writeCount = 0;
|
||||||
|
const setItem = storage.setItem.bind(storage);
|
||||||
|
storage.setItem = async (key, value) => {
|
||||||
|
writeCount += 1;
|
||||||
|
if (writeCount === 1) await firstWrite.promise;
|
||||||
|
await setItem(key, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const color = store.setHostColor("srv_appearance", "teal");
|
||||||
|
const display = store.setHostBadgeDisplay("srv_appearance", "icon");
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(writeCount).toBe(1);
|
||||||
|
|
||||||
|
firstWrite.resolve();
|
||||||
|
await Promise.all([color, display]);
|
||||||
|
|
||||||
|
expect(store.getHosts()[0]?.appearance).toEqual({ color: "teal", badgeDisplay: "icon" });
|
||||||
|
const persistedHosts = JSON.parse((await storage.getItem("@paseo:daemon-registry")) ?? "[]");
|
||||||
|
expect(persistedHosts[0]?.appearance).toEqual({ color: "teal", badgeDisplay: "icon" });
|
||||||
|
store.syncHosts([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("bootstraps agent directory subscription when host transitions online", async () => {
|
it("bootstraps agent directory subscription when host transitions online", async () => {
|
||||||
const host = makeHost({
|
const host = makeHost({
|
||||||
connections: [
|
connections: [
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type HostConnection,
|
type HostConnection,
|
||||||
type HostProfile,
|
type HostProfile,
|
||||||
} from "@/types/host-connection";
|
} from "@/types/host-connection";
|
||||||
|
import { defaultHostAppearance, type HostBadgeDisplay, type HostColor } from "@/hosts/appearance";
|
||||||
import {
|
import {
|
||||||
buildDaemonWebSocketUrl,
|
buildDaemonWebSocketUrl,
|
||||||
buildRelayWebSocketUrl,
|
buildRelayWebSocketUrl,
|
||||||
@@ -1347,6 +1348,7 @@ export class HostRuntimeStore {
|
|||||||
private hostListVersion = 0;
|
private hostListVersion = 0;
|
||||||
private hostRegistryLoaded = false;
|
private hostRegistryLoaded = false;
|
||||||
private hosts: HostProfile[] = [];
|
private hosts: HostProfile[] = [];
|
||||||
|
private hostAppearanceMutationTail: Promise<void> = Promise.resolve();
|
||||||
private hostRegistryStatus: HostRegistryStatus = "loading";
|
private hostRegistryStatus: HostRegistryStatus = "loading";
|
||||||
private deps: HostRuntimeControllerDeps;
|
private deps: HostRuntimeControllerDeps;
|
||||||
private lastConnectionStatusByServer = new Map<string, HostRuntimeConnectionStatus>();
|
private lastConnectionStatusByServer = new Map<string, HostRuntimeConnectionStatus>();
|
||||||
@@ -1461,7 +1463,9 @@ export class HostRuntimeStore {
|
|||||||
this.hostRegistryStatus = "ready";
|
this.hostRegistryStatus = "ready";
|
||||||
this.emitHostList();
|
this.emitHostList();
|
||||||
if (shouldPersistHosts) {
|
if (shouldPersistHosts) {
|
||||||
void this.persistHosts();
|
void this.persistHosts().catch((error) =>
|
||||||
|
console.error("[HostRuntime] Failed to persist host registry", error),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1626,7 +1630,9 @@ export class HostRuntimeStore {
|
|||||||
);
|
);
|
||||||
this.emitHostList();
|
this.emitHostList();
|
||||||
this.emit(newServerId);
|
this.emit(newServerId);
|
||||||
void this.persistHosts();
|
void this.persistHosts().catch((error) =>
|
||||||
|
console.error("[HostRuntime] Failed to persist host registry", error),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsertDirectConnection(input: {
|
async upsertDirectConnection(input: {
|
||||||
@@ -1664,6 +1670,7 @@ export class HostRuntimeStore {
|
|||||||
const probeHost: HostProfile = {
|
const probeHost: HostProfile = {
|
||||||
serverId: "",
|
serverId: "",
|
||||||
label: input.label ?? input.connection.id,
|
label: input.label ?? input.connection.id,
|
||||||
|
appearance: defaultHostAppearance(),
|
||||||
lifecycle: {},
|
lifecycle: {},
|
||||||
connections: [input.connection],
|
connections: [input.connection],
|
||||||
preferredConnectionId: input.connection.id,
|
preferredConnectionId: input.connection.id,
|
||||||
@@ -1782,14 +1789,59 @@ export class HostRuntimeStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async renameHost(serverId: string, label: string): Promise<void> {
|
private async updateHost(
|
||||||
const next = this.hosts.map((h) =>
|
serverId: string,
|
||||||
h.serverId === serverId ? { ...h, label, updatedAt: new Date().toISOString() } : h,
|
apply: (host: HostProfile) => HostProfile,
|
||||||
|
): Promise<void> {
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
const next = this.hosts.map((host) =>
|
||||||
|
host.serverId === serverId ? { ...apply(host), updatedAt } : host,
|
||||||
);
|
);
|
||||||
this.setHostsAndSync(next);
|
this.setHostsAndSync(next);
|
||||||
await this.persistHosts();
|
await this.persistHosts();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async renameHost(serverId: string, label: string): Promise<void> {
|
||||||
|
await this.updateHost(serverId, (host) => ({ ...host, label }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async setHostColor(serverId: string, color: HostColor): Promise<void> {
|
||||||
|
await this.updateHostAppearance(serverId, (host) => ({
|
||||||
|
...host,
|
||||||
|
appearance: { ...host.appearance, color },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async setHostBadgeDisplay(serverId: string, badgeDisplay: HostBadgeDisplay): Promise<void> {
|
||||||
|
await this.updateHostAppearance(serverId, (host) => ({
|
||||||
|
...host,
|
||||||
|
appearance: { ...host.appearance, badgeDisplay },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateHostAppearance(
|
||||||
|
serverId: string,
|
||||||
|
apply: (host: HostProfile) => HostProfile,
|
||||||
|
): Promise<void> {
|
||||||
|
const update = this.hostAppearanceMutationTail.then(() =>
|
||||||
|
this.applyHostAppearance(serverId, apply),
|
||||||
|
);
|
||||||
|
this.hostAppearanceMutationTail = update.catch(() => undefined);
|
||||||
|
return update;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyHostAppearance(
|
||||||
|
serverId: string,
|
||||||
|
apply: (host: HostProfile) => HostProfile,
|
||||||
|
): Promise<void> {
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
const next = this.hosts.map((host) =>
|
||||||
|
host.serverId === serverId ? { ...apply(host), updatedAt } : host,
|
||||||
|
);
|
||||||
|
await this.persistHosts(next);
|
||||||
|
this.setHostsAndSync(next);
|
||||||
|
}
|
||||||
|
|
||||||
async removeHost(serverId: string): Promise<void> {
|
async removeHost(serverId: string): Promise<void> {
|
||||||
const remaining = this.hosts.filter((daemon) => daemon.serverId !== serverId);
|
const remaining = this.hosts.filter((daemon) => daemon.serverId !== serverId);
|
||||||
this.setHostsAndSync(remaining);
|
this.setHostsAndSync(remaining);
|
||||||
@@ -1848,7 +1900,9 @@ export class HostRuntimeStore {
|
|||||||
])
|
])
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
void this.persistHosts();
|
void this.persistHosts().catch((error) =>
|
||||||
|
console.error("[HostRuntime] Failed to persist host registry", error),
|
||||||
|
);
|
||||||
return next.find((daemon) => daemon.serverId === input.serverId) as HostProfile;
|
return next.find((daemon) => daemon.serverId === input.serverId) as HostProfile;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1866,12 +1920,8 @@ export class HostRuntimeStore {
|
|||||||
this.emitHostList();
|
this.emitHostList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async persistHosts(): Promise<void> {
|
private async persistHosts(hosts = this.hosts): Promise<void> {
|
||||||
try {
|
await this.storage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(hosts));
|
||||||
await this.storage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(this.hosts));
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[HostRuntime] Failed to persist host registry", error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private emitHostList(): void {
|
private emitHostList(): void {
|
||||||
@@ -2421,6 +2471,8 @@ export interface HostMutations {
|
|||||||
label?: string,
|
label?: string,
|
||||||
) => Promise<HostProfile>;
|
) => Promise<HostProfile>;
|
||||||
renameHost: (serverId: string, label: string) => Promise<void>;
|
renameHost: (serverId: string, label: string) => Promise<void>;
|
||||||
|
setHostColor: (serverId: string, color: HostColor) => Promise<void>;
|
||||||
|
setHostBadgeDisplay: (serverId: string, badgeDisplay: HostBadgeDisplay) => Promise<void>;
|
||||||
removeHost: (serverId: string) => Promise<void>;
|
removeHost: (serverId: string) => Promise<void>;
|
||||||
removeConnection: (serverId: string, connectionId: string) => Promise<void>;
|
removeConnection: (serverId: string, connectionId: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -2435,6 +2487,9 @@ export function useHostMutations(): HostMutations {
|
|||||||
upsertConnectionFromOffer: (offer, label) => store.upsertConnectionFromOffer(offer, label),
|
upsertConnectionFromOffer: (offer, label) => store.upsertConnectionFromOffer(offer, label),
|
||||||
upsertConnectionFromOfferUrl: (url, label) => store.upsertConnectionFromOfferUrl(url, label),
|
upsertConnectionFromOfferUrl: (url, label) => store.upsertConnectionFromOfferUrl(url, label),
|
||||||
renameHost: (serverId, label) => store.renameHost(serverId, label),
|
renameHost: (serverId, label) => store.renameHost(serverId, label),
|
||||||
|
setHostColor: (serverId, color) => store.setHostColor(serverId, color),
|
||||||
|
setHostBadgeDisplay: (serverId, badgeDisplay) =>
|
||||||
|
store.setHostBadgeDisplay(serverId, badgeDisplay),
|
||||||
removeHost: (serverId) => store.removeHost(serverId),
|
removeHost: (serverId) => store.removeHost(serverId),
|
||||||
removeConnection: (serverId, connectionId) => store.removeConnection(serverId, connectionId),
|
removeConnection: (serverId, connectionId) => store.removeConnection(serverId, connectionId),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,357 @@
|
|||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import type { TFunction } from "i18next";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||||
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
|
import { ChevronDown, Pencil } from "lucide-react-native";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||||
|
import { HostBadge } from "@/components/sidebar/host-badge";
|
||||||
|
import { useToast } from "@/contexts/toast-context";
|
||||||
|
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||||
|
import {
|
||||||
|
HOST_BADGE_DISPLAYS,
|
||||||
|
HOST_COLORS,
|
||||||
|
resolveHostBadgeDisplay,
|
||||||
|
type HostBadgeDisplay,
|
||||||
|
type HostColor,
|
||||||
|
} from "@/hosts/appearance";
|
||||||
|
import { useLocalDaemonServerIdState } from "@/hooks/use-is-local-daemon";
|
||||||
|
import { useHostMutations } from "@/runtime/host-runtime";
|
||||||
|
import { identityColor } from "@/styles/identity-colors";
|
||||||
|
import { settingsStyles } from "@/styles/settings";
|
||||||
|
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||||
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
|
|
||||||
|
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||||
|
const ThemedPencil = withUnistyles(Pencil);
|
||||||
|
|
||||||
|
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||||
|
|
||||||
|
function dropdownTriggerStyle({ pressed }: PressableStateCallbackType) {
|
||||||
|
return pressed ? [styles.trigger, styles.triggerPressed] : styles.trigger;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HostRenameButton({ host }: { host: HostProfile }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { renameHost } = useHostMutations();
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
async (value: string) => {
|
||||||
|
const nextLabel = value.trim();
|
||||||
|
if (nextLabel === host.label.trim()) return;
|
||||||
|
await renameHost(host.serverId, nextLabel);
|
||||||
|
},
|
||||||
|
[host.label, host.serverId, renameHost],
|
||||||
|
);
|
||||||
|
|
||||||
|
const openEditor = useCallback(() => setIsEditing(true), []);
|
||||||
|
const closeEditor = useCallback(() => setIsEditing(false), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Pressable
|
||||||
|
onPress={openEditor}
|
||||||
|
hitSlop={8}
|
||||||
|
style={styles.renameButton}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t("settings.host.daemon.rename.editLabel")}
|
||||||
|
testID="host-page-label-edit-button"
|
||||||
|
>
|
||||||
|
<ThemedPencil size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<AdaptiveRenameModal
|
||||||
|
visible={isEditing}
|
||||||
|
title={t("settings.host.daemon.rename.title")}
|
||||||
|
initialValue={host.label}
|
||||||
|
placeholder={t("settings.host.daemon.rename.placeholder")}
|
||||||
|
submitLabel={t("settings.host.daemon.rename.submit")}
|
||||||
|
onClose={closeEditor}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
testID="host-page-rename-modal"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorLabel(t: TFunction, color: HostColor): string {
|
||||||
|
return t(`settings.host.appearance.color.options.${color}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function badgeDisplayLabel(t: TFunction, display: HostBadgeDisplay): string {
|
||||||
|
return t(`settings.host.appearance.badge.options.${display}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ColorSwatch({ color }: { color: HostColor }) {
|
||||||
|
const swatchStyle = useMemo(
|
||||||
|
() => [styles.swatch, color === "none" ? null : { backgroundColor: identityColor(color) }],
|
||||||
|
[color],
|
||||||
|
);
|
||||||
|
return <View style={swatchStyle} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ColorMenuItem({
|
||||||
|
color,
|
||||||
|
selected,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
color: HostColor;
|
||||||
|
selected: boolean;
|
||||||
|
onChange: (color: HostColor) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const handleSelect = useCallback(() => onChange(color), [color, onChange]);
|
||||||
|
const leading = useMemo(() => <ColorSwatch color={color} />, [color]);
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem selected={selected} onSelect={handleSelect} leading={leading}>
|
||||||
|
{colorLabel(t, color)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ColorRow({ color, onChange }: { color: HostColor; onChange: (color: HostColor) => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const selectedLabel = colorLabel(t, color);
|
||||||
|
return (
|
||||||
|
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||||
|
<View style={settingsStyles.rowContent}>
|
||||||
|
<Text style={settingsStyles.rowTitle}>{t("settings.host.appearance.color.label")}</Text>
|
||||||
|
</View>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
style={dropdownTriggerStyle}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t("settings.host.appearance.color.accessibilityLabel", {
|
||||||
|
value: selectedLabel,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ColorSwatch color={color} />
|
||||||
|
<Text style={styles.triggerText}>{selectedLabel}</Text>
|
||||||
|
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent side="bottom" align="end" width={200}>
|
||||||
|
{HOST_COLORS.map((option) => (
|
||||||
|
<ColorMenuItem
|
||||||
|
key={option}
|
||||||
|
color={option}
|
||||||
|
selected={option === color}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BadgeDisplayRow({
|
||||||
|
badgeDisplay,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
badgeDisplay: HostBadgeDisplay;
|
||||||
|
onChange: (badgeDisplay: HostBadgeDisplay) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const selectedLabel = badgeDisplayLabel(t, badgeDisplay);
|
||||||
|
return (
|
||||||
|
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||||
|
<View style={settingsStyles.rowContent}>
|
||||||
|
<Text style={settingsStyles.rowTitle}>{t("settings.host.appearance.badge.label")}</Text>
|
||||||
|
</View>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
testID="host-appearance-badge-display"
|
||||||
|
style={dropdownTriggerStyle}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t("settings.host.appearance.badge.accessibilityLabel", {
|
||||||
|
value: selectedLabel,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text style={styles.triggerText}>{selectedLabel}</Text>
|
||||||
|
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent side="bottom" align="end" width={200}>
|
||||||
|
{HOST_BADGE_DISPLAYS.map((option) => (
|
||||||
|
<BadgeDisplayMenuItem
|
||||||
|
key={option}
|
||||||
|
display={option}
|
||||||
|
selected={option === badgeDisplay}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BadgeDisplayMenuItem({
|
||||||
|
display,
|
||||||
|
selected,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
display: HostBadgeDisplay;
|
||||||
|
selected: boolean;
|
||||||
|
onChange: (display: HostBadgeDisplay) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const handleSelect = useCallback(() => onChange(display), [display, onChange]);
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem selected={selected} onSelect={handleSelect}>
|
||||||
|
{badgeDisplayLabel(t, display)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the badge exactly as the sidebar will draw it, next to a sample workspace title on
|
||||||
|
* the sidebar surface. The real component, never a restyled copy — a preview that can drift
|
||||||
|
* from the thing it previews is worse than no preview.
|
||||||
|
*/
|
||||||
|
function BadgePreview({
|
||||||
|
host,
|
||||||
|
badgeDisplay,
|
||||||
|
}: {
|
||||||
|
host: HostProfile;
|
||||||
|
badgeDisplay: HostBadgeDisplay;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<View style={styles.preview} testID="host-appearance-preview">
|
||||||
|
<Text style={styles.previewTitle} numberOfLines={1}>
|
||||||
|
{t("settings.host.appearance.preview.workspaceName")}
|
||||||
|
</Text>
|
||||||
|
{badgeDisplay === "hidden" ? null : (
|
||||||
|
<HostBadge
|
||||||
|
serverId={host.serverId}
|
||||||
|
label={host.label}
|
||||||
|
color={host.appearance.color}
|
||||||
|
showLabel={badgeDisplay === "name"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HostAppearanceSection({ host }: { host: HostProfile }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const toast = useToast();
|
||||||
|
const { setHostColor, setHostBadgeDisplay } = useHostMutations();
|
||||||
|
const localDaemon = useLocalDaemonServerIdState();
|
||||||
|
const isLocalHost = localDaemon.status === "resolved" && localDaemon.serverId === host.serverId;
|
||||||
|
const badgeDisplay = resolveHostBadgeDisplay({
|
||||||
|
appearance: host.appearance,
|
||||||
|
isLocalHost,
|
||||||
|
localHostResolutionPending: localDaemon.status !== "resolved",
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleColorChange = useCallback(
|
||||||
|
async (color: HostColor) => {
|
||||||
|
try {
|
||||||
|
await setHostColor(host.serverId, color);
|
||||||
|
} catch {
|
||||||
|
toast.error(t("errors.unableToSave"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[host.serverId, setHostColor, t, toast],
|
||||||
|
);
|
||||||
|
const handleBadgeDisplayChange = useCallback(
|
||||||
|
async (next: HostBadgeDisplay) => {
|
||||||
|
try {
|
||||||
|
await setHostBadgeDisplay(host.serverId, next);
|
||||||
|
} catch {
|
||||||
|
toast.error(t("errors.unableToSave"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[host.serverId, setHostBadgeDisplay, t, toast],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection title={t("settings.host.appearance.title")}>
|
||||||
|
<View style={settingsStyles.card}>
|
||||||
|
<View style={settingsStyles.row}>
|
||||||
|
<View style={settingsStyles.rowContent}>
|
||||||
|
<Text style={settingsStyles.rowTitle}>{t("settings.host.appearance.name.label")}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.nameValue}>
|
||||||
|
<Text style={styles.nameText} numberOfLines={1}>
|
||||||
|
{host.label}
|
||||||
|
</Text>
|
||||||
|
<HostRenameButton host={host} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<ColorRow color={host.appearance.color} onChange={handleColorChange} />
|
||||||
|
{badgeDisplay === null ? null : (
|
||||||
|
<>
|
||||||
|
<BadgeDisplayRow badgeDisplay={badgeDisplay} onChange={handleBadgeDisplayChange} />
|
||||||
|
<BadgePreview host={host} badgeDisplay={badgeDisplay} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
trigger: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
paddingVertical: theme.spacing[1],
|
||||||
|
paddingHorizontal: theme.spacing[2],
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
triggerPressed: {
|
||||||
|
opacity: 0.85,
|
||||||
|
},
|
||||||
|
triggerText: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
swatch: {
|
||||||
|
width: ICON_SIZE.md,
|
||||||
|
height: ICON_SIZE.md,
|
||||||
|
borderRadius: ICON_SIZE.md / 2,
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
renameButton: {
|
||||||
|
padding: theme.spacing[1],
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
},
|
||||||
|
nameValue: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
flexShrink: 1,
|
||||||
|
},
|
||||||
|
nameText: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
flexShrink: 1,
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
borderTopWidth: theme.borderWidth[1],
|
||||||
|
borderTopColor: theme.colors.border,
|
||||||
|
backgroundColor: theme.colors.surfaceSidebar,
|
||||||
|
},
|
||||||
|
previewTitle: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
flexShrink: 1,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -22,7 +22,6 @@ import {
|
|||||||
resolveTerminalProfiles,
|
resolveTerminalProfiles,
|
||||||
} from "@getpaseo/protocol/terminal-profiles";
|
} from "@getpaseo/protocol/terminal-profiles";
|
||||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
|
||||||
import { SettingsTextAreaCard } from "@/components/settings-textarea";
|
import { SettingsTextAreaCard } from "@/components/settings-textarea";
|
||||||
import { Alert as InlineAlert } from "@/components/ui/alert";
|
import { Alert as InlineAlert } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -56,6 +55,7 @@ import {
|
|||||||
import { ProvidersSection } from "@/screens/settings/providers-section";
|
import { ProvidersSection } from "@/screens/settings/providers-section";
|
||||||
import { ProviderUsageSettingsSection } from "@/provider-usage/settings-section";
|
import { ProviderUsageSettingsSection } from "@/provider-usage/settings-section";
|
||||||
import { useProviderUsage } from "@/provider-usage/use-provider-usage";
|
import { useProviderUsage } from "@/provider-usage/use-provider-usage";
|
||||||
|
import { HostAppearanceSection } from "@/screens/settings/host-appearance-section";
|
||||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { settingsStyles } from "@/styles/settings";
|
import { settingsStyles } from "@/styles/settings";
|
||||||
@@ -368,11 +368,12 @@ export function HostSettingsPage({
|
|||||||
<Text style={styles.daemonHeaderLabel} numberOfLines={1}>
|
<Text style={styles.daemonHeaderLabel} numberOfLines={1}>
|
||||||
{host.label}
|
{host.label}
|
||||||
</Text>
|
</Text>
|
||||||
<HostRenameButton host={host} />
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<HostStatusBadges serverId={serverId} />
|
<HostStatusBadges serverId={serverId} />
|
||||||
|
|
||||||
|
<HostAppearanceSection host={host} />
|
||||||
|
|
||||||
{isLocalDaemon ? <LocalDaemonSection /> : null}
|
{isLocalDaemon ? <LocalDaemonSection /> : null}
|
||||||
|
|
||||||
{!isLocalDaemon ? <UpdateDaemonCard key={host.serverId} host={host} /> : null}
|
{!isLocalDaemon ? <UpdateDaemonCard key={host.serverId} host={host} /> : null}
|
||||||
@@ -382,51 +383,6 @@ export function HostSettingsPage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HostRenameButton({ host }: { host: HostProfile }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const { renameHost } = useHostMutations();
|
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
|
||||||
async (value: string) => {
|
|
||||||
const nextLabel = value.trim();
|
|
||||||
if (nextLabel === host.label.trim()) return;
|
|
||||||
await renameHost(host.serverId, nextLabel);
|
|
||||||
},
|
|
||||||
[host.label, host.serverId, renameHost],
|
|
||||||
);
|
|
||||||
|
|
||||||
const openEditor = useCallback(() => setIsEditing(true), []);
|
|
||||||
const closeEditor = useCallback(() => setIsEditing(false), []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Pressable
|
|
||||||
onPress={openEditor}
|
|
||||||
hitSlop={8}
|
|
||||||
style={styles.identityEditButton}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={t("settings.host.daemon.rename.editLabel")}
|
|
||||||
testID="host-page-label-edit-button"
|
|
||||||
>
|
|
||||||
<Pencil size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<AdaptiveRenameModal
|
|
||||||
visible={isEditing}
|
|
||||||
title={t("settings.host.daemon.rename.title")}
|
|
||||||
initialValue={host.label}
|
|
||||||
placeholder={t("settings.host.daemon.rename.placeholder")}
|
|
||||||
submitLabel={t("settings.host.daemon.rename.submit")}
|
|
||||||
onClose={closeEditor}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
testID="host-page-rename-modal"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConnectionsSection({ host }: { host: HostProfile }) {
|
function ConnectionsSection({ host }: { host: HostProfile }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { removeConnection } = useHostMutations();
|
const { removeConnection } = useHostMutations();
|
||||||
@@ -1856,10 +1812,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
marginHorizontal: theme.spacing[4],
|
marginHorizontal: theme.spacing[4],
|
||||||
marginBottom: theme.spacing[4],
|
marginBottom: theme.spacing[4],
|
||||||
},
|
},
|
||||||
identityEditButton: {
|
|
||||||
padding: theme.spacing[1],
|
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
},
|
|
||||||
daemonHeader: {
|
daemonHeader: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
IDENTITY_COLOR_NAMES,
|
||||||
|
deriveIdentityColorName,
|
||||||
|
identityColor,
|
||||||
|
} from "@/styles/identity-colors";
|
||||||
|
|
||||||
|
function contrastRatio(foreground: string, background: string): number {
|
||||||
|
const luminance = (hex: string) => {
|
||||||
|
const channels = [1, 3, 5].map(
|
||||||
|
(index) => Number.parseInt(hex.slice(index, index + 2), 16) / 255,
|
||||||
|
);
|
||||||
|
const linear = channels.map((channel) =>
|
||||||
|
channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4,
|
||||||
|
);
|
||||||
|
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
|
||||||
|
};
|
||||||
|
const [lighter, darker] = [luminance(foreground), luminance(background)].sort((a, b) => b - a);
|
||||||
|
return (lighter + 0.05) / (darker + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("identity colors", () => {
|
||||||
|
it("keeps ten colors so existing derived assignments stay put", () => {
|
||||||
|
expect(IDENTITY_COLOR_NAMES.length).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives the same color a project key had before the palette moved", () => {
|
||||||
|
expect(identityColor(deriveIdentityColorName("paseo"))).toBe("#368080");
|
||||||
|
expect(identityColor(deriveIdentityColorName("my-project"))).toBe("#7a6aa8");
|
||||||
|
expect(identityColor(deriveIdentityColorName("a"))).toBe("#b06260");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps every fill in the white-letter contrast band", () => {
|
||||||
|
for (const color of IDENTITY_COLOR_NAMES) {
|
||||||
|
expect(contrastRatio(identityColor(color), "#ffffff")).toBeGreaterThanOrEqual(4.2);
|
||||||
|
expect(contrastRatio(identityColor(color), "#ffffff")).toBeLessThanOrEqual(4.8);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// Muted tones, all landing in a 4.2-4.8:1 contrast band against the white letter on top.
|
||||||
|
//
|
||||||
|
// Two properties matter more than the individual hues. First, low chroma: these squares sit
|
||||||
|
// in a sidebar row next to a status dot, a PR glyph and a diff stat, and at full saturation
|
||||||
|
// the project icon wins a fight it shouldn't be in. Second, even luminance: the previous set
|
||||||
|
// was raw Tailwind 500s spanning 1.9:1 (amber, where the white letter was barely visible) to
|
||||||
|
// 4.5:1 (indigo), so a project's color decided how loud it was in the list. Holding every
|
||||||
|
// swatch to one contrast band makes the color identifying rather than ranking.
|
||||||
|
//
|
||||||
|
// Retune with the contrast band, not by eye — a hue nudged for looks quietly re-weights that
|
||||||
|
// project against the other nine.
|
||||||
|
//
|
||||||
|
// Order is load-bearing: `deriveIdentityColorName` indexes into this array, so reordering
|
||||||
|
// silently recolors every existing project icon.
|
||||||
|
export const IDENTITY_COLOR_NAMES = [
|
||||||
|
"violet",
|
||||||
|
"sky",
|
||||||
|
"emerald",
|
||||||
|
"orange",
|
||||||
|
"pink",
|
||||||
|
"indigo",
|
||||||
|
"teal",
|
||||||
|
"red",
|
||||||
|
"amber",
|
||||||
|
"blue",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type IdentityColorName = (typeof IDENTITY_COLOR_NAMES)[number];
|
||||||
|
|
||||||
|
const IDENTITY_COLORS: Record<IdentityColorName, string> = {
|
||||||
|
violet: "#7a6aa8",
|
||||||
|
sky: "#3d7ea6",
|
||||||
|
emerald: "#388068",
|
||||||
|
orange: "#a4673a",
|
||||||
|
pink: "#b05c80",
|
||||||
|
indigo: "#6a70b8",
|
||||||
|
teal: "#368080",
|
||||||
|
red: "#b06260",
|
||||||
|
amber: "#8f7838",
|
||||||
|
blue: "#5179b0",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function identityColor(name: IdentityColorName): string {
|
||||||
|
return IDENTITY_COLORS[name];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashIdentityKey(key: string): number {
|
||||||
|
let hash = 0;
|
||||||
|
for (const character of key) {
|
||||||
|
hash = (hash * 31 + character.charCodeAt(0)) >>> 0;
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveIdentityColorName(key: string): IdentityColorName {
|
||||||
|
return IDENTITY_COLOR_NAMES[hashIdentityKey(key) % IDENTITY_COLOR_NAMES.length];
|
||||||
|
}
|
||||||
@@ -109,15 +109,25 @@ export const baseColors = {
|
|||||||
|
|
||||||
export type ThemeName = "light" | "dark" | "zinc" | "midnight" | "claude" | "ghostty";
|
export type ThemeName = "light" | "dark" | "zinc" | "midnight" | "claude" | "ghostty";
|
||||||
|
|
||||||
// Diff stat colors — light uses muted tones, dark uses the brighter palette values
|
// Diff colors — light uses muted tones, dark uses the brighter palette values.
|
||||||
|
//
|
||||||
|
// Two tiers, because the +/- serves two jobs. In the diff view the color *is* the signal and
|
||||||
|
// has to survive being scanned line by line, so it stays saturated. In a row's metadata the
|
||||||
|
// counts are a footnote next to a title and a subtitle; at full saturation they read as the
|
||||||
|
// loudest thing in the row. The Stat tier keeps the hue but drops the chroma to roughly the
|
||||||
|
// weight of foregroundMuted, so it sits with the subtitle instead of shouting over it.
|
||||||
const lightDiffColors = {
|
const lightDiffColors = {
|
||||||
diffAddition: "#15803d", // green-700 — readable on white without screaming
|
diffAddition: "#15803d", // green-700 — readable on white without screaming
|
||||||
diffDeletion: "#b91c1c", // red-700
|
diffDeletion: "#b91c1c", // red-700
|
||||||
|
diffStatAddition: "#32794a", // softened green-700
|
||||||
|
diffStatDeletion: "#a43c39", // softened red-700
|
||||||
};
|
};
|
||||||
|
|
||||||
const darkDiffColors = {
|
const darkDiffColors = {
|
||||||
diffAddition: "#4ade80", // green-400
|
diffAddition: "#4ade80", // green-400
|
||||||
diffDeletion: "#ef4444", // red-500
|
diffDeletion: "#ef4444", // red-500
|
||||||
|
diffStatAddition: "#61bf82",
|
||||||
|
diffStatDeletion: "#d36461",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Status colors — semantic signals for success/danger/warning/merged. Used by
|
// Status colors — semantic signals for success/danger/warning/merged. Used by
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { defaultHostAppearance } from "@/hosts/appearance";
|
||||||
import {
|
import {
|
||||||
normalizeStoredHostProfile,
|
normalizeStoredHostProfile,
|
||||||
orderHostsLocalFirst,
|
orderHostsLocalFirst,
|
||||||
resolveActiveHostServerId,
|
resolveActiveHostServerId,
|
||||||
|
upsertHostConnectionInProfiles,
|
||||||
|
type HostConnection,
|
||||||
type HostProfile,
|
type HostProfile,
|
||||||
} from "./host-connection";
|
} from "./host-connection";
|
||||||
|
|
||||||
@@ -10,6 +13,7 @@ function makeHost(serverId: string): HostProfile {
|
|||||||
return {
|
return {
|
||||||
serverId,
|
serverId,
|
||||||
label: serverId,
|
label: serverId,
|
||||||
|
appearance: defaultHostAppearance(),
|
||||||
lifecycle: {},
|
lifecycle: {},
|
||||||
connections: [],
|
connections: [],
|
||||||
preferredConnectionId: null,
|
preferredConnectionId: null,
|
||||||
@@ -114,6 +118,63 @@ describe("normalizeStoredHostProfile", () => {
|
|||||||
daemonPublicKeyB64: "pubkey",
|
daemonPublicKeyB64: "pubkey",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("gives a host stored before appearance existed the default appearance", () => {
|
||||||
|
const profile = normalizeStoredHostProfile({
|
||||||
|
serverId: "srv_old",
|
||||||
|
connections: [
|
||||||
|
{ id: "socket:/tmp/paseo.sock", type: "directSocket", path: "/tmp/paseo.sock" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(profile?.appearance).toEqual({ color: "none", badgeDisplay: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads a stored appearance the user chose", () => {
|
||||||
|
const profile = normalizeStoredHostProfile({
|
||||||
|
serverId: "srv_new",
|
||||||
|
appearance: { color: "teal", badgeDisplay: "icon" },
|
||||||
|
connections: [
|
||||||
|
{ id: "socket:/tmp/paseo.sock", type: "directSocket", path: "/tmp/paseo.sock" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(profile?.appearance).toEqual({ color: "teal", badgeDisplay: "icon" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("upsertHostConnectionInProfiles", () => {
|
||||||
|
const connection: HostConnection = {
|
||||||
|
id: "socket:/tmp/paseo.sock",
|
||||||
|
type: "directSocket",
|
||||||
|
path: "/tmp/paseo.sock",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("gives a newly discovered host the default appearance", () => {
|
||||||
|
const [profile] = upsertHostConnectionInProfiles({
|
||||||
|
profiles: [],
|
||||||
|
serverId: "srv_new",
|
||||||
|
connection,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(profile.appearance).toEqual({ color: "none", badgeDisplay: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the appearance the user chose when the host reconnects", () => {
|
||||||
|
const existing: HostProfile = {
|
||||||
|
...makeHost("srv_known"),
|
||||||
|
appearance: { color: "amber", badgeDisplay: "hidden" },
|
||||||
|
connections: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const [profile] = upsertHostConnectionInProfiles({
|
||||||
|
profiles: [existing],
|
||||||
|
serverId: "srv_known",
|
||||||
|
connection,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(profile.appearance).toEqual({ color: "amber", badgeDisplay: "hidden" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveActiveHostServerId", () => {
|
describe("resolveActiveHostServerId", () => {
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import {
|
|||||||
DirectTcpHostConnectionSchema,
|
DirectTcpHostConnectionSchema,
|
||||||
type DirectTcpHostConnection,
|
type DirectTcpHostConnection,
|
||||||
} from "@getpaseo/protocol/host-connection-schema";
|
} from "@getpaseo/protocol/host-connection-schema";
|
||||||
|
import {
|
||||||
|
type HostAppearance,
|
||||||
|
defaultHostAppearance,
|
||||||
|
normalizeStoredHostAppearance,
|
||||||
|
} from "@/hosts/appearance";
|
||||||
|
|
||||||
export { DirectTcpHostConnectionSchema, type DirectTcpHostConnection };
|
export { DirectTcpHostConnectionSchema, type DirectTcpHostConnection };
|
||||||
|
|
||||||
@@ -40,6 +45,7 @@ export type HostLifecycle = Record<string, never>;
|
|||||||
export interface HostProfile {
|
export interface HostProfile {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
appearance: HostAppearance;
|
||||||
lifecycle: HostLifecycle;
|
lifecycle: HostLifecycle;
|
||||||
connections: HostConnection[];
|
connections: HostConnection[];
|
||||||
preferredConnectionId: string | null;
|
preferredConnectionId: string | null;
|
||||||
@@ -172,6 +178,7 @@ export function upsertHostConnectionInProfiles(input: {
|
|||||||
const profile: HostProfile = {
|
const profile: HostProfile = {
|
||||||
serverId,
|
serverId,
|
||||||
label: derivedLabel,
|
label: derivedLabel,
|
||||||
|
appearance: defaultHostAppearance(),
|
||||||
lifecycle: defaultLifecycle(),
|
lifecycle: defaultLifecycle(),
|
||||||
connections: [input.connection],
|
connections: [input.connection],
|
||||||
preferredConnectionId: input.connection.id,
|
preferredConnectionId: input.connection.id,
|
||||||
@@ -372,6 +379,7 @@ export function normalizeStoredHostProfile(entry: unknown): HostProfile | null {
|
|||||||
return {
|
return {
|
||||||
serverId,
|
serverId,
|
||||||
label,
|
label,
|
||||||
|
appearance: normalizeStoredHostAppearance(record.appearance),
|
||||||
lifecycle: defaultLifecycle(),
|
lifecycle: defaultLifecycle(),
|
||||||
connections,
|
connections,
|
||||||
preferredConnectionId,
|
preferredConnectionId,
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
// Medium-saturation tones chosen so a white letter stays legible on top in both
|
|
||||||
// light and dark themes. Soft and colorful, but not pale pastels.
|
|
||||||
const PROJECT_ICON_COLORS = [
|
|
||||||
"#8b5cf6", // violet
|
|
||||||
"#0ea5e9", // sky
|
|
||||||
"#10b981", // emerald
|
|
||||||
"#f97316", // orange
|
|
||||||
"#ec4899", // pink
|
|
||||||
"#6366f1", // indigo
|
|
||||||
"#14b8a6", // teal
|
|
||||||
"#ef4444", // red
|
|
||||||
"#eab308", // amber
|
|
||||||
"#3b82f6", // blue
|
|
||||||
];
|
|
||||||
|
|
||||||
function hashProjectKey(projectKey: string): number {
|
|
||||||
let hash = 0;
|
|
||||||
for (const character of projectKey) {
|
|
||||||
hash = (hash * 31 + character.charCodeAt(0)) >>> 0;
|
|
||||||
}
|
|
||||||
return hash;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deriveProjectIconColor(projectKey: string): string {
|
|
||||||
return PROJECT_ICON_COLORS[hashProjectKey(projectKey) % PROJECT_ICON_COLORS.length];
|
|
||||||
}
|
|
||||||
@@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { getProjectStatusBadgeContent } from "./project-status-badge-content";
|
import { getProjectStatusBadgeContent } from "./project-status-badge-content";
|
||||||
|
|
||||||
describe("getProjectStatusBadgeContent", () => {
|
describe("getProjectStatusBadgeContent", () => {
|
||||||
it("shows the loader while running", () => {
|
it("shows a running dot while working", () => {
|
||||||
expect(getProjectStatusBadgeContent("running")).toEqual({ kind: "loader" });
|
expect(getProjectStatusBadgeContent("running")).toEqual({ kind: "dot", bucket: "running" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the alert when input is needed", () => {
|
it("shows the alert when input is needed", () => {
|
||||||
|
|||||||
@@ -1,29 +1,28 @@
|
|||||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||||
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
|
||||||
|
export type ProjectStatusBadgeDotBucket = "failed" | "attention" | "running";
|
||||||
|
|
||||||
export type ProjectStatusBadgeContent =
|
export type ProjectStatusBadgeContent =
|
||||||
| { kind: "loader" }
|
|
||||||
| { kind: "alert" }
|
| { kind: "alert" }
|
||||||
| { kind: "dot"; bucket: "failed" | "attention" };
|
| { kind: "dot"; bucket: ProjectStatusBadgeDotBucket };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What the collapsed-project status badge should render for a project's aggregate bucket,
|
* What the project status badge should render for a project's aggregate bucket, or null when
|
||||||
* or null when no badge should show at all. Kept as plain data (no React) so it's testable
|
* no badge should show at all. Kept as plain data (no React) so it's testable without JSDOM
|
||||||
* without JSDOM or component mounting — see docs/testing.md's two test categories.
|
* or component mounting — see docs/testing.md's two test categories.
|
||||||
*
|
*
|
||||||
* The "dot" variant is narrowed to failed/attention only: running renders the loader and
|
* Running is a dot like failed and attention rather than its own glyph. The badge is 14pt;
|
||||||
* needs_input renders the alert, so those two buckets can never reach a dot.
|
* anything with internal detail at that size loses to a solid disc, so the buckets separate
|
||||||
|
* by color and — for running alone — by pulsing. Only needs_input earns a glyph, because
|
||||||
|
* "someone must act" has to survive being the one amber state next to running.
|
||||||
*/
|
*/
|
||||||
export function getProjectStatusBadgeContent(
|
export function getProjectStatusBadgeContent(
|
||||||
statusBucket: SidebarStateBucket | null,
|
statusBucket: SidebarStateBucket | null,
|
||||||
): ProjectStatusBadgeContent | null {
|
): ProjectStatusBadgeContent | null {
|
||||||
if (shouldRenderSyncedStatusLoader({ bucket: statusBucket })) {
|
|
||||||
return { kind: "loader" };
|
|
||||||
}
|
|
||||||
if (statusBucket === "needs_input") {
|
if (statusBucket === "needs_input") {
|
||||||
return { kind: "alert" };
|
return { kind: "alert" };
|
||||||
}
|
}
|
||||||
if (statusBucket === "failed" || statusBucket === "attention") {
|
if (statusBucket === "failed" || statusBucket === "attention" || statusBucket === "running") {
|
||||||
return { kind: "dot", bucket: statusBucket };
|
return { kind: "dot", bucket: statusBucket };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ export function getStatusDotColor(input: {
|
|||||||
}): string | null {
|
}): string | null {
|
||||||
const { theme, bucket, showDoneAsInactive = false } = input;
|
const { theme, bucket, showDoneAsInactive = false } = input;
|
||||||
|
|
||||||
|
// Working and needs_input share amber: both mean "this agent has your turn in it". They
|
||||||
|
// never collide in practice because needs_input always draws the alert glyph, never a bare
|
||||||
|
// dot — see getProjectStatusBadgeContent.
|
||||||
if (bucket === "needs_input") {
|
if (bucket === "needs_input") {
|
||||||
return theme.colors.palette.amber[500];
|
return theme.colors.palette.amber[500];
|
||||||
}
|
}
|
||||||
@@ -15,7 +18,7 @@ export function getStatusDotColor(input: {
|
|||||||
return theme.colors.palette.red[500];
|
return theme.colors.palette.red[500];
|
||||||
}
|
}
|
||||||
if (bucket === "running") {
|
if (bucket === "running") {
|
||||||
return theme.colors.palette.blue[500];
|
return theme.colors.palette.amber[500];
|
||||||
}
|
}
|
||||||
if (bucket === "attention") {
|
if (bucket === "attention") {
|
||||||
return theme.colors.palette.green[500];
|
return theme.colors.palette.green[500];
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { FolderOpen } from "lucide-react-native";
|
import { FolderOpen } from "lucide-react-native";
|
||||||
import { withUnistyles } from "react-native-unistyles";
|
import { withUnistyles } from "react-native-unistyles";
|
||||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||||
|
import { ContextMenuItem } from "@/components/ui/context-menu";
|
||||||
import { getIsElectron } from "@/constants/platform";
|
import { getIsElectron } from "@/constants/platform";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
@@ -11,6 +12,7 @@ import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-op
|
|||||||
interface OpenInFileManagerMenuItemProps {
|
interface OpenInFileManagerMenuItemProps {
|
||||||
path?: string | null;
|
path?: string | null;
|
||||||
testID: string;
|
testID: string;
|
||||||
|
surface?: "context" | "dropdown";
|
||||||
}
|
}
|
||||||
|
|
||||||
const ThemedFolderOpen = withUnistyles(FolderOpen);
|
const ThemedFolderOpen = withUnistyles(FolderOpen);
|
||||||
@@ -21,7 +23,11 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
|
|||||||
|
|
||||||
const leadingIcon = <ThemedFolderOpen size={14} uniProps={foregroundMutedColorMapping} />;
|
const leadingIcon = <ThemedFolderOpen size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
|
|
||||||
export function OpenInFileManagerMenuItem({ path, testID }: OpenInFileManagerMenuItemProps) {
|
export function OpenInFileManagerMenuItem({
|
||||||
|
path,
|
||||||
|
testID,
|
||||||
|
surface = "dropdown",
|
||||||
|
}: OpenInFileManagerMenuItemProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const isElectron = getIsElectron();
|
const isElectron = getIsElectron();
|
||||||
@@ -46,9 +52,17 @@ export function OpenInFileManagerMenuItem({ path, testID }: OpenInFileManagerMen
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const label = t("sidebar.project.actions.openFolder");
|
||||||
|
if (surface === "context") {
|
||||||
|
return (
|
||||||
|
<ContextMenuItem testID={testID} leading={leadingIcon} onSelect={openInFileManager}>
|
||||||
|
{label}
|
||||||
|
</ContextMenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem testID={testID} leading={leadingIcon} onSelect={openInFileManager}>
|
<DropdownMenuItem testID={testID} leading={leadingIcon} onSelect={openInFileManager}>
|
||||||
{t("sidebar.project.actions.openFolder")}
|
{label}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user