From d0071edc3164e53342e5f03e6fa9df26a1f68a7c Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Wed, 5 Aug 2026 18:47:43 -0700 Subject: [PATCH] feat(remote): label mirrored projects with their server --- .../components/common/ProjectRemoteServer.tsx | 129 ++++++++++++++++++ .../thread/ProjectSwitchMenu.test.tsx | 23 ++++ .../components/thread/ProjectSwitchMenu.tsx | 72 ++++++++-- .../parts/SidebarFlatThreadList.test.tsx | 21 +++ .../Sidebar/parts/SidebarFlatThreadList.tsx | 17 ++- .../parts/SidebarProjectFilter.test.tsx | 25 ++++ .../Sidebar/parts/SidebarProjectFilter.tsx | 74 ++++++---- .../parts/SidebarProjectHeader.test.tsx | 10 +- .../Sidebar/parts/SidebarProjectHeader.tsx | 47 ++----- .../Sidebar/parts/SidebarProjectSection.tsx | 1 - 10 files changed, 334 insertions(+), 85 deletions(-) create mode 100644 src/renderer/components/common/ProjectRemoteServer.tsx diff --git a/src/renderer/components/common/ProjectRemoteServer.tsx b/src/renderer/components/common/ProjectRemoteServer.tsx new file mode 100644 index 00000000..00b5810e --- /dev/null +++ b/src/renderer/components/common/ProjectRemoteServer.tsx @@ -0,0 +1,129 @@ +import { Server } from "lucide-react"; +import { useShallow } from "zustand/shallow"; +import type { Project } from "@/shared/contracts"; +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"; + +/** What a surface needs to show that a project lives on another machine. */ +export interface ProjectRemoteServerInfo { + /** The project mirrors a project hosted on a paired machine. */ + readonly isRemote: boolean; + /** Machine name to display, when its pairing record is still known. */ + readonly serverName: string | undefined; + /** Live connection status of that machine, when known. */ + readonly status: RemoteServerStatus | undefined; +} + +const LOCAL: ProjectRemoteServerInfo = { + isRemote: false, + serverName: undefined, + status: undefined, +}; + +const serverByDesktopId = createArrayKeyedMap( + (servers) => new Map(servers.map((server) => [server.desktopId, server])), +); + +/** + * Resolver for a project's hosting machine, shared by every surface that lists + * projects (sidebar sections, flat thread rows, the project filter, the + * composer switcher). + * + * Returns a lookup rather than the info itself so list surfaces can resolve + * many projects from one subscription — calling a hook per row is not allowed. + */ +export function useProjectRemoteServerLookup(): ( + project: Project | undefined, +) => ProjectRemoteServerInfo { + const servers = useRemoteServersStore((state) => state.servers); + // Only the status is displayed, and the runtime map is rebuilt wholesale on + // every snapshot refresh — comparing the statuses alone keeps thread and + // project traffic from re-rendering every project list. + const statuses = useRemoteServersStore( + useShallow((state) => { + const byDesktopId: Record = {}; + for (const [desktopId, runtime] of Object.entries(state.runtime)) { + byDesktopId[desktopId] = runtime.status; + } + return byDesktopId; + }), + ); + return (project) => { + const desktopId = project?.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, + serverName: server ? desktopTitle(server.label) : undefined, + status: statuses[desktopId], + }; + }; +} + +/** Single-project form, for surfaces that render exactly one project. */ +export function useProjectRemoteServer(project: Project): ProjectRemoteServerInfo { + return useProjectRemoteServerLookup()(project); +} + +/** + * Machine glyph for a mirrored project, carrying the pairing status light. The + * light is omitted when the machine is unknown, since there is no connection to + * report — the bare glyph still marks the project as non-local. + */ +export function ProjectRemoteServerIcon(props: { + info: ProjectRemoteServerInfo; + /** + * Glyph size and colour, so the icon sits at the same weight as whatever + * icons it stands beside; the status light keeps its own palette. + */ + className?: string | undefined; +}) { + const { info } = props; + if (!info.isRemote && !info.serverName) return null; + return ( + + + {info.serverName ? ( + + ) : null} + + ); +} + +const CHIP_SIZE = { + /** Dense sidebar rows, where the chip inherits a 10px tag. */ + sm: { icon: "size-3 text-muted/60", name: "max-w-20 text-muted/60" }, + /** Menu rows, which set their own type scale. */ + md: { icon: "size-3.5 text-muted/60", name: "max-w-24 text-xs text-muted/60" }, +} as const; + +/** + * Machine glyph plus its name — the trailing half of a project label wherever + * projects are listed. Renders nothing for a local project, so callers can drop + * it in beside the project name without a guard. + */ +export function ProjectRemoteServerChip(props: { + info: ProjectRemoteServerInfo; + size?: keyof typeof CHIP_SIZE; +}) { + const { info } = props; + if (!info.isRemote && !info.serverName) return null; + const size = CHIP_SIZE[props.size ?? "sm"]; + return ( + <> + + {info.serverName ? ( + {info.serverName} + ) : null} + + ); +} diff --git a/src/renderer/components/thread/ProjectSwitchMenu.test.tsx b/src/renderer/components/thread/ProjectSwitchMenu.test.tsx index ab776349..bb4e003a 100644 --- a/src/renderer/components/thread/ProjectSwitchMenu.test.tsx +++ b/src/renderer/components/thread/ProjectSwitchMenu.test.tsx @@ -4,6 +4,7 @@ import { renderWithI18n as render } from "@/renderer/testUtils/i18n"; import type { Project } from "@/shared/contracts"; import { HOME_PROJECT_ID, HOME_PROJECT_NAME } from "@/shared/homeScope"; import { useAppStore } from "@/renderer/state/appStore"; +import { useRemoteServersStore } from "@/renderer/state/remoteServersStore"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; import { useWorkspaceStore } from "@/renderer/state/workspaceStore"; import { ProjectSwitchMenu } from "./ProjectSwitchMenu"; @@ -39,6 +40,7 @@ async function openMenu() { describe("ProjectSwitchMenu", () => { beforeEach(() => { localStorage.clear(); + useRemoteServersStore.setState({ servers: [], runtime: {} }); useSharedSettings.setState({ workspaces: [ { id: "w1", name: "Work", icon: "briefcase", createdAt: "2026-07-27T00:00:00.000Z" }, @@ -106,6 +108,27 @@ describe("ProjectSwitchMenu", () => { expect(useWorkspaceStore.getState().lastProjectIdByWorkspace).toEqual({ w1: "c" }); }); + it("names the hosting machine on a mirrored project, in the trigger and the menu", async () => { + const mirrored = { + ...project("r", "Alpha", "w1"), + remoteServerId: "desktop-1", + remoteId: "rp-1", + } as Project; + useRemoteServersStore.setState({ + servers: [{ desktopId: "desktop-1", label: "Poracode on MacBook 16" }], + runtime: { "desktop-1": { status: "online", projects: [], threads: [] } }, + } as never); + useAppStore.setState({ projects: [workProject, mirrored] }); + + render(); + + // Two projects share the name "Alpha"; only the mirrored one is machine-tagged. + expect(screen.getByRole("button", { name: "Switch project" })).toHaveTextContent("MacBook 16"); + const menu = await openMenu(); + const items = within(menu).getAllByRole("menuitemradio"); + expect(items.map((item) => item.textContent)).toEqual(["Alpha", "AlphaMacBook 16"]); + }); + it("labels the trigger with a draft that outlived a workspace switch", async () => { render(); diff --git a/src/renderer/components/thread/ProjectSwitchMenu.tsx b/src/renderer/components/thread/ProjectSwitchMenu.tsx index a6a79166..0f6755d0 100644 --- a/src/renderer/components/thread/ProjectSwitchMenu.tsx +++ b/src/renderer/components/thread/ProjectSwitchMenu.tsx @@ -13,9 +13,17 @@ import { useResponsiveMenu, } from "@/renderer/components/common/ResponsiveMenuSurface"; import { TuxIcon } from "@/renderer/components/common/TuxIcon"; +import { + ProjectRemoteServerIcon, + useProjectRemoteServerLookup, + type ProjectRemoteServerInfo, +} from "@/renderer/components/common/ProjectRemoteServer"; import { useProjectSwitchGroups, type ProjectSwitchEntry } from "./projectSwitchGroups"; -function LocationIcon(props: { kind: Project["location"]["kind"]; className?: string }) { +function LocationIcon(props: { + kind: Project["location"]["kind"]; + className?: string | undefined; +}) { if (props.kind === "wsl") { return ( @@ -30,6 +38,31 @@ function LocationIcon(props: { kind: Project["location"]["kind"]; className?: st return ; } +/** + * Leading glyph for a project row. A mirrored project is marked by the machine + * hosting it rather than by its path kind — which machine it lives on is what + * distinguishes it from the same-named project on this one. + */ +function ProjectIcon(props: { + project: Project; + remote: ProjectRemoteServerInfo; + className?: string | undefined; +}) { + if (isHomeProject(props.project)) { + return ; + } + if (props.remote.isRemote) { + // Same weight as the location/Home glyphs it replaces in the same list. + return ( + + ); + } + return ; +} + export function ProjectSwitchMenu(props: { currentProjectId: string; variant: "hero" | "compact"; @@ -45,6 +78,7 @@ export function ProjectSwitchMenu(props: { // unreachable from the composer, and picking one moves the workspace along // with the draft (see `handleSelect`). const { all, inWorkspace, others, activeWorkspaceName } = useProjectSwitchGroups(); + const remoteServerFor = useProjectRemoteServerLookup(); const openDraft = useAppStore((state) => state.openDraft); const replacePaneId = useAppStore((state) => state.replacePaneId); const discardDraftContent = useAppStore((state) => state.discardDraftContent); @@ -57,10 +91,17 @@ export function ProjectSwitchMenu(props: { const current = all.find((entry) => entry.project.id === currentProjectId)?.project; const isHomeCurrent = isHomeProjectId(currentProjectId); const label = isHomeCurrent ? HOME_PROJECT_NAME : (current?.name ?? t`Select project`); + const currentRemote = remoteServerFor(current); const triggerIcon = isHomeCurrent ? ( ) : current ? ( - + + ) : null; + // The machine trails the name, so the project stays the thing you read first. + const triggerMachine = currentRemote.serverName ? ( + + {currentRemote.serverName} + ) : null; const isDisabled = all.length <= 1; @@ -91,6 +132,7 @@ export function ProjectSwitchMenu(props: { const isHome = isHomeProject(project); const itemLabel = isHome ? HOME_PROJECT_NAME : project.name; const selected = project.id === currentProjectId; + const remote = remoteServerFor(project); return ( {props.projects.map((project) => { const selected = selectedProjects.has(project.id); + const remote = remoteServerFor(project); return (