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
129 changes: 129 additions & 0 deletions src/renderer/components/common/ProjectRemoteServer.tsx
Original file line number Diff line number Diff line change
@@ -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<RemoteServerRecord, string, RemoteServerRecord>(
(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<string, RemoteServerStatus> = {};
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 (
<span className="relative flex shrink-0">
<Server className={props.className ?? "size-3 text-muted/60"} />
{info.serverName ? (
<RemoteServerStatusDot
status={info.status ?? "offline"}
className="absolute -right-0.5 -bottom-0.5"
/>
) : null}
</span>
);
}

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 (
<>
<ProjectRemoteServerIcon info={info} className={size.icon} />
{info.serverName ? (
<span className={`shrink-0 truncate ${size.name}`}>{info.serverName}</span>
) : null}
</>
);
}
23 changes: 23 additions & 0 deletions src/renderer/components/thread/ProjectSwitchMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -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(<ProjectSwitchMenu currentProjectId="r" variant="compact" />);

// 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(<ProjectSwitchMenu currentProjectId="b" variant="compact" />);

Expand Down
72 changes: 58 additions & 14 deletions src/renderer/components/thread/ProjectSwitchMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<span className={`${props.className ?? "size-3.5"} relative shrink-0 text-muted`}>
Expand All @@ -30,6 +38,31 @@ function LocationIcon(props: { kind: Project["location"]["kind"]; className?: st
return <FolderOpen className={className} />;
}

/**
* 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 <House className={`${props.className ?? "size-4"} shrink-0 text-muted`} />;
}
if (props.remote.isRemote) {
// Same weight as the location/Home glyphs it replaces in the same list.
return (
<ProjectRemoteServerIcon
info={props.remote}
className={`${props.className ?? "size-4"} text-muted`}
/>
);
}
return <LocationIcon kind={props.project.location.kind} className={props.className} />;
}

export function ProjectSwitchMenu(props: {
currentProjectId: string;
variant: "hero" | "compact";
Expand All @@ -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);
Expand All @@ -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 ? (
<House className="size-3.5 shrink-0 text-muted" />
) : current ? (
<LocationIcon kind={current.location.kind} className="size-3.5" />
<ProjectIcon project={current} remote={currentRemote} className="size-3.5" />
) : null;
// The machine trails the name, so the project stays the thing you read first.
const triggerMachine = currentRemote.serverName ? (
<span className="min-w-0 shrink truncate text-xs text-muted/60">
{currentRemote.serverName}
</span>
) : null;
const isDisabled = all.length <= 1;

Expand Down Expand Up @@ -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 (
<button
key={project.id}
Expand All @@ -102,12 +144,13 @@ export function ProjectSwitchMenu(props: {
handleSelect(project.id);
}}
>
{isHome ? (
<House className="size-4 shrink-0 text-muted" />
) : (
<LocationIcon kind={project.location.kind} />
)}
<span className="flex-1 truncate">{itemLabel}</span>
<ProjectIcon project={project} remote={remote} />
<span className="min-w-0 flex-1 truncate">{itemLabel}</span>
{remote.serverName ? (
<span className="max-w-28 shrink-0 truncate text-xs text-muted/60">
{remote.serverName}
</span>
) : null}
{otherWorkspaceName ? (
<span className="shrink-0 truncate text-xs text-muted">{otherWorkspaceName}</span>
) : null}
Expand All @@ -121,15 +164,14 @@ export function ProjectSwitchMenu(props: {
return entries.map(({ project, otherWorkspaceName }) => {
const isHome = isHomeProject(project);
const itemLabel = isHome ? HOME_PROJECT_NAME : project.name;
const remote = remoteServerFor(project);
// Machine and workspace are both "where this project lives" — one slot.
const description = [remote.serverName, otherWorkspaceName].filter(Boolean).join(" · ");
return (
<Dropdown.Item key={project.id} id={project.id} textValue={itemLabel}>
{isHome ? (
<House className="size-4 shrink-0 text-muted" />
) : (
<LocationIcon kind={project.location.kind} />
)}
<ProjectIcon project={project} remote={remote} />
<Label>{itemLabel}</Label>
{otherWorkspaceName ? <Description>{otherWorkspaceName}</Description> : null}
{description ? <Description>{description}</Description> : null}
</Dropdown.Item>
);
});
Expand Down Expand Up @@ -167,6 +209,7 @@ export function ProjectSwitchMenu(props: {
<>
{triggerIcon}
<span className="min-w-0 truncate">{label}</span>
{triggerMachine}
</>
)}
{!isDisabled ? <ChevronDown className="size-3 shrink-0 text-muted/60" /> : null}
Expand Down Expand Up @@ -245,6 +288,7 @@ export function ProjectSwitchMenu(props: {
>
{triggerIcon}
<span className="min-w-0 truncate">{label}</span>
{triggerMachine}
{!isDisabled ? (
<ChevronDown className="size-3 shrink-0 opacity-60 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100" />
) : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,27 @@ describe("SidebarFlatThreadList", () => {
expect(screen.getByText(/thread:r1 in Mac Poracode/)).toBeInTheDocument();
});

it("tags remote-project rows with the machine name; local rows carry none", () => {
useRemoteServersStore.setState({
servers: [{ desktopId: "desktop-1", label: "Poracode on MacBook 16" }],
runtime: { "desktop-1": { status: "online", projects: [], threads: [] } },
} as never);
useAppStore.setState({
projects: [homeProject, localProject, unreachableRemoteProject],
threads: [
makeThread("p1", "local-1", "2026-08-01T10:00:00.000Z"),
makeThread("r1", "remote-1", "2026-08-03T10:00:00.000Z"),
],
});

render(<SidebarFlatThreadList sortMode="updated" />);

const remoteRow = screen.getByText(/thread:r1 in Mac Poracode/).closest("[data-testid=row]");
expect(remoteRow).toHaveTextContent("MacBook 16");
const localRow = screen.getByText(/thread:p1 in Poracode/).closest("[data-testid=row]");
expect(localRow).not.toHaveTextContent("MacBook 16");
});

it("hides Home threads when home scope is disabled", () => {
useSharedSettings.setState({ homeScopeEnabled: false } as never);
useAppStore.setState({
Expand Down
Loading