From ef4868448aae78e4a15fb408c27fc8432d2c544e Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Sun, 9 Aug 2026 11:46:26 -0700 Subject: [PATCH] feat(renderer): show project location icons in project selectors - Add ProjectSelectorIcon to distinguish local vs remote projects across project pickers (Select, MCP destination, skills, schedules, project switch) - Extend common Select component to support custom value children - Add GitHub Actions to worktree group context menu and restrict GitHub Actions sidebar projects to active ones - Add tests for Select icons and context menu actions; update i18n catalogs --- .../components/common/ProjectRemoteServer.tsx | 62 +++++++++++- .../components/common/Select.test.tsx | 96 +++++++++++++++++++ src/renderer/components/common/Select.tsx | 55 +++++++++-- .../mcp/McpProjectDestinationDropdown.tsx | 44 ++++++--- .../components/mcp/McpServerEditor.tsx | 19 ++-- .../components/mcp/McpServersManager.test.tsx | 30 ++++++ .../components/skills/SkillsManager.tsx | 7 +- .../components/thread/ProjectSwitchMenu.tsx | 58 +---------- src/renderer/locales/de/messages.po | 1 + src/renderer/locales/en/messages.po | 1 + src/renderer/locales/es/messages.po | 1 + src/renderer/locales/fr/messages.po | 1 + src/renderer/locales/ja/messages.po | 1 + src/renderer/locales/ko/messages.po | 1 + src/renderer/locales/pl/messages.po | 1 + src/renderer/locales/pt-BR/messages.po | 1 + src/renderer/locales/ru/messages.po | 1 + src/renderer/locales/tr/messages.po | 1 + src/renderer/locales/uk/messages.po | 1 + src/renderer/locales/vi/messages.po | 1 + src/renderer/locales/zh-CN/messages.po | 1 + .../GitHubActionsSidebar.tsx | 50 +++++++--- .../GitHubActionsView.test.tsx | 36 ++++++- .../GitHubActionsView/GitHubActionsView.tsx | 3 +- .../useGitHubActionsViewModel.ts | 6 -- .../Sidebar/parts/SidebarProjectFilter.tsx | 24 +++-- .../parts/SidebarWorktreeGroup.test.tsx | 16 +++- .../Sidebar/parts/SidebarWorktreeGroup.tsx | 2 + .../Sidebar/parts/ThreadContextMenu.test.tsx | 11 +++ .../parts/Sidebar/parts/gitMenuIcons.tsx | 2 + .../parts/Sidebar/parts/useWorktreeActions.ts | 6 ++ .../views/SchedulesView/ScheduleEditor.tsx | 21 +++- .../SchedulesView/SchedulesView.test.tsx | 22 ++++- 33 files changed, 457 insertions(+), 126 deletions(-) create mode 100644 src/renderer/components/common/Select.test.tsx diff --git a/src/renderer/components/common/ProjectRemoteServer.tsx b/src/renderer/components/common/ProjectRemoteServer.tsx index 546c7ebe9..7ff91a2e7 100644 --- a/src/renderer/components/common/ProjectRemoteServer.tsx +++ b/src/renderer/components/common/ProjectRemoteServer.tsx @@ -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 { @@ -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, @@ -37,7 +45,7 @@ const serverByDesktopId = createArrayKeyedMap ProjectRemoteServerInfo { const servers = useRemoteServersStore((state) => state.servers); // Only the status is displayed, and the runtime map is rebuilt wholesale on @@ -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], }; @@ -102,6 +113,49 @@ export function ProjectRemoteServerIcon(props: { ); } +export function ProjectLocationIcon(props: { + location: Project["location"]; + className?: string | undefined; +}) { + if (props.location.kind === "wsl") { + return ( + + ); + } + const className = `${props.className ?? "size-4"} shrink-0 text-muted`; + return props.location.kind === "windows" ? ( + + ) : ( + + ); +} + +/** 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 ; + } + if (props.remote.isRemote) { + return ( + + ); + } + return ; +} + 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" }, diff --git a/src/renderer/components/common/Select.test.tsx b/src/renderer/components/common/Select.test.tsx new file mode 100644 index 000000000..62aad40e4 --- /dev/null +++ b/src/renderer/components/common/Select.test.tsx @@ -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()), + useResponsiveMenu: () => ({ mobile: responsiveMenuState.mobile }), +})); + +const options: SelectOption[] = [ + { + id: "alpha", + label: "Alpha", + icon: , + detail: "C:\\Alpha", + }, + { + id: "beta", + label: "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(); + + 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: , + detail: `C:\\Project ${index}`, + }), + ); + render( +