Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 58 additions & 4 deletions src/renderer/components/common/ProjectRemoteServer.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { Server } from "lucide-react";
import { FolderOpen, House, Monitor, Server } from "lucide-react";
import { useShallow } from "zustand/shallow";
import type { Project } from "@/shared/contracts";
import { isHomeProject } from "@/shared/homeScope";
import { desktopTitle } from "@/shared/remote/desktopLabel";
import { createArrayKeyedMap } from "@/renderer/state/derivations";
import { remoteOwner } from "@/renderer/state/remoteProjection";
import { useRemoteServersStore } from "@/renderer/state/remoteServersStore";
import type { RemoteServerRecord, RemoteServerStatus } from "@/renderer/state/remoteServers/types";
import { RemoteServerStatusDot } from "./RemoteServerStatusDot";
import { TuxIcon } from "./TuxIcon";

/** What a surface needs to show that a project lives on another machine. */
export interface ProjectRemoteServerInfo {
Expand All @@ -18,6 +20,12 @@ export interface ProjectRemoteServerInfo {
readonly status: RemoteServerStatus | undefined;
}

interface ProjectRemoteServerSource {
readonly remoteServerId?: string | undefined;
readonly remoteId?: string | undefined;
readonly location?: { readonly remoteServerId?: string | undefined } | undefined;
}

const LOCAL: ProjectRemoteServerInfo = {
isRemote: false,
serverName: undefined,
Expand All @@ -37,7 +45,7 @@ const serverByDesktopId = createArrayKeyedMap<RemoteServerRecord, string, Remote
* many projects from one subscription — calling a hook per row is not allowed.
*/
export function useProjectRemoteServerLookup(): (
project: Project | undefined,
project: ProjectRemoteServerSource | undefined,
) => ProjectRemoteServerInfo {
const servers = useRemoteServersStore((state) => state.servers);
// Only the status is displayed, and the runtime map is rebuilt wholesale on
Expand All @@ -53,13 +61,16 @@ export function useProjectRemoteServerLookup(): (
}),
);
return (project) => {
const desktopId = project?.remoteServerId;
const desktopId = project?.remoteServerId ?? project?.location?.remoteServerId;
if (!desktopId || !project) return LOCAL;
const server = serverByDesktopId(servers, desktopId);
return {
// An unpaired-but-mirrored project still reads as non-local, so the
// glyph shows even once the machine record is gone.
isRemote: remoteOwner(project) !== undefined || server !== undefined,
isRemote:
remoteOwner(project) !== undefined ||
project.location?.remoteServerId !== undefined ||
server !== undefined,
serverName: server ? desktopTitle(server.label) : undefined,
status: statuses[desktopId],
};
Expand Down Expand Up @@ -102,6 +113,49 @@ export function ProjectRemoteServerIcon(props: {
);
}

export function ProjectLocationIcon(props: {
location: Project["location"];
className?: string | undefined;
}) {
if (props.location.kind === "wsl") {
return (
<span
className={`${props.className ?? "size-3.5"} relative shrink-0 text-muted`}
aria-hidden="true"
>
<TuxIcon className="absolute left-1/2 top-1/2 h-3.5 w-6 -translate-x-1/2 -translate-y-1/2" />
</span>
);
}
const className = `${props.className ?? "size-4"} shrink-0 text-muted`;
return props.location.kind === "windows" ? (
<Monitor className={className} />
) : (
<FolderOpen className={className} />
);
}

/** Leading glyph shared by project selectors: Home, host machine, or local path kind. */
export function ProjectSelectorIcon(props: {
project: Project;
remote: ProjectRemoteServerInfo;
className?: string | undefined;
}) {
if (isHomeProject(props.project)) {
return <House className={`${props.className ?? "size-4"} shrink-0 text-muted`} />;
}
if (props.remote.isRemote) {
return (
<ProjectRemoteServerIcon
info={props.remote}
className={`${props.className ?? "size-3.5"} text-muted`}
dotClassName="size-1"
/>
);
}
return <ProjectLocationIcon location={props.project.location} className={props.className} />;
}

const CHIP_SIZE = {
/** The flat list's 10px row tags, where even the dense glyph reads heavy. */
xs: { icon: "size-2.5 text-muted/60", dot: "size-1", name: "max-w-20 text-muted/60" },
Expand Down
96 changes: 96 additions & 0 deletions src/renderer/components/common/Select.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { fireEvent, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithI18n as render } from "@/renderer/testUtils/i18n";
import { LARGE_DROPDOWN_VIRTUALIZATION_THRESHOLD } from "./dropdownVirtualization";
import { Select, type SelectOption } from "./Select";

const responsiveMenuState = vi.hoisted(() => ({ mobile: false }));

vi.mock("@/renderer/bridge", () => ({
isRemoteSession: () => responsiveMenuState.mobile,
}));

vi.mock("./ResponsiveMenuSurface", async (importOriginal) => ({
...(await importOriginal<typeof import("./ResponsiveMenuSurface")>()),
useResponsiveMenu: () => ({ mobile: responsiveMenuState.mobile }),
}));

const options: SelectOption[] = [
{
id: "alpha",
label: "Alpha",
icon: <span data-testid="alpha-icon" />,
detail: "C:\\Alpha",
},
{
id: "beta",
label: "Beta",
icon: <span data-testid="beta-icon" />,
detail: "C:\\Beta",
},
];

describe("Select rich options", () => {
beforeEach(() => {
responsiveMenuState.mobile = false;
});

it("renders icon and detail in the desktop trigger and selects a rich option", async () => {
const onChange = vi.fn<(value: string) => void>();
render(<Select aria-label="Project" options={options} value="alpha" onChange={onChange} />);

const trigger = screen.getByLabelText("Project");
expect(trigger).toHaveTextContent("AlphaC:\\Alpha");
expect(trigger.querySelector('[data-testid="alpha-icon"]')).not.toBeNull();

fireEvent.click(trigger);
const beta = await screen.findByRole("option", { name: /Beta/u });
expect(beta).toHaveTextContent("C:\\Beta");
expect(beta.querySelector('[data-testid="beta-icon"]')).not.toBeNull();
fireEvent.click(beta);

expect(onChange).toHaveBeenCalledWith("beta");
});

it("renders and selects rich options in the mobile drawer", async () => {
responsiveMenuState.mobile = true;
const onChange = vi.fn<(value: string) => void>();
render(<Select aria-label="Project" options={options} value="alpha" onChange={onChange} />);

const trigger = screen.getByRole("button", { name: "Project" });
expect(trigger).toHaveTextContent("AlphaC:\\Alpha");
fireEvent.click(trigger);

const beta = await screen.findByRole("button", { name: /Beta/u });
expect(beta).toHaveTextContent("C:\\Beta");
fireEvent.click(beta);

expect(onChange).toHaveBeenCalledWith("beta");
});

it("renders rich rows when the desktop list is virtualized", async () => {
const virtualizedOptions = Array.from(
{ length: LARGE_DROPDOWN_VIRTUALIZATION_THRESHOLD + 1 },
(_, index): SelectOption => ({
id: `project-${index}`,
label: `Project ${index}`,
icon: <span data-testid={`project-${index}-icon`} />,
detail: `C:\\Project ${index}`,
}),
);
render(
<Select
aria-label="Project"
options={virtualizedOptions}
value="project-0"
onChange={() => {}}
/>,
);

fireEvent.click(screen.getByLabelText("Project"));

const first = await screen.findByRole("option", { name: /Project 0/u });
expect(first).toHaveTextContent("C:\\Project 0");
expect(first.querySelector('[data-testid="project-0-icon"]')).not.toBeNull();
});
});
55 changes: 49 additions & 6 deletions src/renderer/components/common/Select.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState, type ComponentProps } from "react";
import { useState, type ComponentProps, type ReactNode } from "react";
import {
Description,
Label,
ListBox,
ListLayout,
Expand All @@ -18,6 +19,8 @@ import { ResponsiveMenuSurface, useResponsiveMenu } from "./ResponsiveMenuSurfac
export interface SelectOption {
id: string;
label: string;
icon?: ReactNode;
detail?: string;
}

export interface SelectProps extends Omit<
Expand All @@ -37,14 +40,14 @@ export function Select(props: SelectProps) {
const { mobile } = useResponsiveMenu();
const [isOpen, setIsOpen] = useState(false);
const selectedValue = value && options.some((option) => option.id === value) ? value : null;
const selectedOption = options.find((option) => option.id === selectedValue);
const isVirtualized = options.length > LARGE_DROPDOWN_VIRTUALIZATION_THRESHOLD;

// Mobile PWA: a HeroUI select-listbox popover anchored to a small trigger is
// cramped on a phone. Render an input-styled trigger that opens a bottom
// drawer of finger-sized rows instead. `mobile === isRemoteSession()`, so the
// desktop HeroSelect below never runs on the phone and is left untouched.
if (mobile) {
const selectedOption = options.find((option) => option.id === selectedValue);
const placeholder = typeof rest.placeholder === "string" ? rest.placeholder : t`Select…`;
// Settings pass the field name via `aria-label` (the visible label lives on
// the surrounding SettingRow), so fall back to it for the trigger + heading.
Expand All @@ -65,9 +68,15 @@ export function Select(props: SelectProps) {
if (!rest.isDisabled) setIsOpen(true);
}}
>
<span className={`flex-1 truncate ${selectedOption ? "" : "text-muted"}`}>
{selectedOption?.icon}
<span className={`min-w-0 flex-1 truncate ${selectedOption ? "" : "text-muted"}`}>
{selectedOption?.label ?? placeholder}
</span>
{selectedOption?.detail ? (
<span className="min-w-0 shrink truncate text-xs text-muted/60">
{selectedOption.detail}
</span>
) : null}
<ChevronDown className="size-4 shrink-0 text-muted" />
</button>
}
Expand All @@ -86,7 +95,13 @@ export function Select(props: SelectProps) {
onChange(option.id);
}}
>
<span className="flex-1 truncate">{option.label}</span>
{option.icon}
<span className="min-w-0 flex-1 truncate">{option.label}</span>
{option.detail ? (
<span className="max-w-28 shrink-0 truncate text-xs text-muted/60">
{option.detail}
</span>
) : null}
{selected ? <Check className="size-4 shrink-0 text-accent" /> : null}
</button>
);
Expand All @@ -104,7 +119,19 @@ export function Select(props: SelectProps) {
// reserves room for the checkmark so it overlaps long labels. The
// `pe-7` utility (utilities layer) restores it.
<ListBox.Item key={option.id} id={option.id} textValue={option.label} className="pe-7">
{option.label}
{option.icon || option.detail ? (
<>
{option.icon}
<div className="flex min-w-0 flex-1 flex-col">
<Label className="truncate">{option.label}</Label>
{option.detail ? (
<Description className="truncate">{option.detail}</Description>
) : null}
</div>
</>
) : (
option.label
)}
<ListBox.ItemIndicator />
</ListBox.Item>
))}
Expand All @@ -119,7 +146,23 @@ export function Select(props: SelectProps) {
>
{label ? <Label>{label}</Label> : null}
<HeroSelect.Trigger>
<HeroSelect.Value />
<HeroSelect.Value>
{({ defaultChildren, isPlaceholder }) =>
!isPlaceholder && selectedOption?.icon ? (
<span className="flex min-w-0 items-center gap-2">
{selectedOption.icon}
<span className="min-w-0 truncate">{selectedOption.label}</span>
{selectedOption.detail ? (
<span className="min-w-0 shrink truncate text-xs text-muted/60">
{selectedOption.detail}
</span>
) : null}
</span>
) : (
defaultChildren
)
}
</HeroSelect.Value>
<HeroSelect.Indicator />
</HeroSelect.Trigger>
<HeroSelect.Popover {...popoverProps}>
Expand Down
44 changes: 32 additions & 12 deletions src/renderer/components/mcp/McpProjectDestinationDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import type { ReactNode } from "react";
import { Description, Dropdown, Header, Label } from "@heroui/react";
import { Trans, useLingui } from "@lingui/react/macro";
import type { ProjectLocation } from "@/shared/contracts";
import { TuxIcon } from "@/renderer/components/common";
import {
ProjectLocationIcon,
ProjectRemoteServerIcon,
useProjectRemoteServerLookup,
} from "@/renderer/components/common/ProjectRemoteServer";

export const GLOBAL_MCP_DESTINATION_ID = "user";
export const MCP_WSL_DESTINATION_PREFIX = "wsl:";
Expand All @@ -27,23 +31,39 @@ export function mcpProjectLocationLabel(location: ProjectLocation): string {
}

export function McpProjectDropdownItemContent(props: { project: McpProjectDestination }) {
const remote = useProjectRemoteServerLookup()(props.project);
return (
<>
<Label>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">{props.project.name}</span>
{props.project.location.kind === "wsl" ? (
<span className="relative size-4 shrink-0 text-muted" aria-hidden="true">
<TuxIcon className="absolute left-1/2 top-1/2 h-3.5 w-6 -translate-x-1/2 -translate-y-1/2" />
</span>
) : null}
</span>
</Label>
<Description>{mcpProjectLocationLabel(props.project.location)}</Description>
{remote.isRemote ? (
<ProjectRemoteServerIcon info={remote} className="size-3.5 text-muted" />
) : (
<ProjectLocationIcon location={props.project.location} />
)}
<Label>{props.project.name}</Label>
<Description>
{remote.serverName ?? mcpProjectLocationLabel(props.project.location)}
</Description>
</>
);
}

export function McpProjectDropdownTriggerContent(props: { project: McpProjectDestination }) {
const remote = useProjectRemoteServerLookup()(props.project);
return (
<span className="flex min-w-0 items-center gap-2">
{remote.isRemote ? (
<ProjectRemoteServerIcon info={remote} className="size-3.5 text-muted" />
) : (
<ProjectLocationIcon location={props.project.location} className="size-3.5" />
)}
<span className="min-w-0 truncate">{props.project.name}</span>
{remote.serverName ? (
<span className="min-w-0 shrink truncate text-xs text-muted/60">{remote.serverName}</span>
) : null}
</span>
);
}

export function McpProjectDestinationDropdown(props: {
trigger: ReactNode;
value: string;
Expand Down
Loading