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
39 changes: 39 additions & 0 deletions apps/web/src/components/ProjectEnvironmentBadge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { EnvironmentId } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";

import { getProjectEnvironmentPresentation } from "./ProjectEnvironmentBadge";

const local = EnvironmentId.make("local");
const remote = EnvironmentId.make("remote");
const build = EnvironmentId.make("build");

describe("project scope environment descriptions", () => {
it("distinguishes local, remote and grouped projects with the same display name", () => {
const here = { environmentId: local, environmentLabel: "Laptop" };
const there = { environmentId: remote, environmentLabel: "Workstation" };
expect(getProjectEnvironmentPresentation([here], local)).toBeNull();
expect(getProjectEnvironmentPresentation([there], local)).toEqual({
environmentId: remote,
description: "On Workstation",
});
expect(getProjectEnvironmentPresentation([here, there], local)).toEqual({
environmentId: remote,
description: "Also on Workstation",
});
});

it("keeps hosted-client labels stable, deduplicated and explicit for unnamed environments", () => {
const members = [
{ environmentId: local, environmentLabel: "Workstation" },
{ environmentId: remote, environmentLabel: "Workstation" },
{ environmentId: build, environmentLabel: null },
];
expect(getProjectEnvironmentPresentation(members, null)).toEqual({
environmentId: build,
description: "On Remote, Workstation",
});
expect(getProjectEnvironmentPresentation(members.toReversed(), null)).toEqual(
getProjectEnvironmentPresentation(members, null),
);
});
});
34 changes: 23 additions & 11 deletions apps/web/src/components/ProjectEnvironmentBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,14 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
* render it only while the catalog spans environments (see
* projectGroupsSpanEnvironments), so single-machine users see no change.
*/
export function ProjectEnvironmentBadge(props: {
readonly group: Pick<SidebarProjectSnapshot, "memberProjects">;
readonly primaryEnvironmentId: EnvironmentId | null;
readonly machineByEnvironmentId: ReadonlyMap<EnvironmentId, EnvironmentMachineKind>;
}) {
export function getProjectEnvironmentPresentation(
members: ReadonlyArray<{ environmentId: EnvironmentId; environmentLabel: string | null }>,
primaryEnvironmentId: EnvironmentId | null,
) {
// Member order follows registration order and can differ between sessions,
// so sort by label to keep the icon and tooltip stable.
const remoteMembers = props.group.memberProjects
.filter((member) => member.environmentId !== props.primaryEnvironmentId)
const remoteMembers = members
.filter((member) => member.environmentId !== primaryEnvironmentId)
.map((member) => ({ ...member, environmentLabel: member.environmentLabel ?? "Remote" }))
.sort((a, b) => a.environmentLabel.localeCompare(b.environmentLabel));
const first = remoteMembers[0];
Expand All @@ -29,26 +28,39 @@ export function ProjectEnvironmentBadge(props: {
.map((member) => member.environmentLabel)
.filter((label, index, all) => all.indexOf(label) === index)
.join(", ");
const alsoHere = remoteMembers.length < props.group.memberProjects.length;
const alsoHere = remoteMembers.length < members.length;
const description = `${alsoHere ? "Also on" : "On"} ${labels}`;
return { environmentId: first.environmentId, description };
}

export function ProjectEnvironmentBadge(props: {
readonly group: Pick<SidebarProjectSnapshot, "memberProjects">;
readonly primaryEnvironmentId: EnvironmentId | null;
readonly machineByEnvironmentId: ReadonlyMap<EnvironmentId, EnvironmentMachineKind>;
}) {
const presentation = getProjectEnvironmentPresentation(
props.group.memberProjects,
props.primaryEnvironmentId,
);
if (!presentation) return null;
return (
<Tooltip>
<TooltipTrigger
render={
<span
role="img"
aria-label={description}
aria-label={presentation.description}
className="ml-auto inline-flex shrink-0 items-center text-muted-foreground"
/>
}
>
<EnvironmentMachineIcon
aria-hidden
kind={props.machineByEnvironmentId.get(first.environmentId) ?? "server"}
kind={props.machineByEnvironmentId.get(presentation.environmentId) ?? "server"}
className="size-3.5"
/>
</TooltipTrigger>
<TooltipPopup side="top">{description}</TooltipPopup>
<TooltipPopup side="top">{presentation.description}</TooltipPopup>
</Tooltip>
);
}
20 changes: 8 additions & 12 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -911,30 +911,26 @@ describe("filterSidebarProjectScopeItems", () => {
{ value: "alpha", label: "Alpha workspace" },
{ value: "beta", label: "Beta tools" },
] as const;
const filter = (activeScopeKey: string | null, query: string) =>
const filter = (query: string) =>
filterSidebarProjectScopeItems({
items,
activeScopeKey,
query,
matches: (item, candidate) =>
item.label.toLocaleLowerCase().includes(candidate.toLocaleLowerCase()),
});

it("omits the reset row when the sidebar is already unscoped", () => {
expect(filter(null, "")).toEqual(items.slice(1));
it("shows the default row first while the query is empty", () => {
expect(filter("")).toEqual(items);
expect(filter(" ")).toEqual(items);
});

it("shows the reset row first while a project scope is active", () => {
expect(filter("alpha", "")).toEqual(items);
});

it("hides the reset row while filtering an active scope", () => {
expect(filter("alpha", "all")).toEqual([]);
it("hides the default row while filtering", () => {
expect(filter("all")).toEqual([]);
});

it("returns matching projects in source order and supports no-match results", () => {
expect(filter(null, "WORK")).toEqual([items[1]]);
expect(filter(null, "missing")).toEqual([]);
expect(filter("WORK")).toEqual([items[1]]);
expect(filter("missing")).toEqual([]);
});
});

Expand Down
8 changes: 2 additions & 6 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -953,16 +953,12 @@ export function searchSidebarThreads<

export function filterSidebarProjectScopeItems<TItem extends { readonly value: string }>(input: {
items: readonly TItem[];
activeScopeKey: string | null;
query: string;
matches: (item: TItem, query: string) => boolean;
}): readonly TItem[] {
const projectItems = input.items.filter((item) => item.value !== "all");
const query = input.query.trim();
if (query.length > 0) {
return projectItems.filter((item) => input.matches(item, query));
}
return input.activeScopeKey === null ? projectItems : input.items;
if (query.length === 0) return input.items;
return input.items.filter((item) => item.value !== "all" && input.matches(item, query));
}

export interface SidebarProjectScopeMenuState {
Expand Down
Loading
Loading