Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
49 changes: 49 additions & 0 deletions apps/web/src/components/ProjectEnvironmentLabel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { EnvironmentId, EnvironmentMachineKind } from "@t3tools/contracts";

import { cn } from "~/lib/utils";
import type { SidebarProjectSnapshot } from "~/sidebarProjectGrouping";
import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";

/**
* Machine icon for a project group's row: the first non-primary member's
* machine, or none when the group only lives on this device. Matches the
* thread rows and command palette, which draw an icon for remote work only.
*/
export function resolveProjectGroupMachine(input: {
readonly group: Pick<SidebarProjectSnapshot, "memberProjects">;
readonly primaryEnvironmentId: EnvironmentId | null;
readonly machineByEnvironmentId: ReadonlyMap<EnvironmentId, EnvironmentMachineKind>;
}): EnvironmentMachineKind | null {
const remoteMember = input.group.memberProjects.find(
(member) => member.environmentId !== input.primaryEnvironmentId,
);
return remoteMember
? (input.machineByEnvironmentId.get(remoteMember.environmentId) ?? null)
: null;
}

/**
* Muted "where this project lives" suffix for a project picker row. Callers
* render it only while the catalog spans environments (see
* projectGroupsSpanEnvironments), so single-machine users see no change.
*/
export function ProjectEnvironmentLabel(props: {
readonly labels: readonly string[];
readonly machine: EnvironmentMachineKind | null;
readonly className?: string;
}) {
if (props.labels.length === 0) return null;
return (
<span
className={cn(
"ml-auto flex min-w-0 max-w-[40%] shrink-0 items-center gap-1 font-normal text-muted-foreground text-xs",
props.className,
)}
>
{props.machine ? (
<EnvironmentMachineIcon aria-hidden kind={props.machine} className="size-3 shrink-0" />
) : null}
<span className="truncate">{props.labels.join(" · ")}</span>
</span>
);
}
56 changes: 52 additions & 4 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ import {
import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject";
import {
buildSidebarProjectSnapshots,
projectGroupsSpanEnvironments,
type SidebarProjectSnapshot,
} from "../sidebarProjectGrouping";
import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
Expand Down Expand Up @@ -141,6 +142,7 @@ import type { SidebarThreadSummary } from "../types";
import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
import { cn } from "~/lib/utils";
import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
import { ProjectEnvironmentLabel, resolveProjectGroupMachine } from "./ProjectEnvironmentLabel";
import { buildThreadActionMenuItems } from "./threadActionMenu.logic";
import {
animateSidebarLayoutChanges,
Expand Down Expand Up @@ -2333,14 +2335,22 @@ export default function Sidebar() {
// while the popup search filters the same collection.
const projectScopeItems = useMemo(
() => [
{ value: "all", label: "All projects" },
{ value: "all", label: "All projects", environmentLabels: [] as readonly string[] },
...projectGroups.map((project) => ({
value: project.projectKey,
label: project.displayName,
environmentLabels: project.environmentLabels,
})),
],
[projectGroups],
);
// Same-named projects on two machines are only told apart by where they
// live, so rows carry their environment once the catalog spans more than
// one; a single-machine catalog stays as it was.
const showProjectEnvironments = useMemo(
() => projectGroupsSpanEnvironments(projectGroups),
[projectGroups],
);
const projectGroupByScopeKey = useMemo(
() => new Map(projectGroups.map((project) => [project.projectKey, project] as const)),
[projectGroups],
Expand Down Expand Up @@ -2370,9 +2380,19 @@ export default function Sidebar() {
activeScopeKey: projectScopeKey,
query: projectScopeMenuState.query,
matches: (item, query) =>
projectScopeFilter.contains(item, query, (candidate) => candidate.label),
projectScopeFilter.contains(item, query, (candidate) => candidate.label) ||
(showProjectEnvironments &&
item.environmentLabels.some((label) =>
projectScopeFilter.contains(label, query, (candidate) => candidate),
)),
}),
[projectScopeFilter, projectScopeItems, projectScopeKey, projectScopeMenuState.query],
[
projectScopeFilter,
projectScopeItems,
projectScopeKey,
projectScopeMenuState.query,
showProjectEnvironments,
],
);
const scopedProjectGroup = useMemo(
() =>
Expand Down Expand Up @@ -4449,11 +4469,29 @@ export default function Sidebar() {
<span className="min-w-0 flex-1 truncate">
{scopedProjectGroup?.displayName ?? "All projects"}
</span>
{scopedProjectGroup && showProjectEnvironments ? (
<ProjectEnvironmentLabel
labels={scopedProjectGroup.environmentLabels}
machine={resolveProjectGroupMachine({
group: scopedProjectGroup,
primaryEnvironmentId,
machineByEnvironmentId: environmentMachineById,
})}
/>
) : null}
<ChevronDownIcon className="-mr-px size-4 shrink-0" />
</ComboboxTrigger>
<ComboboxPopup
align="start"
className="w-(--anchor-width) min-w-0 overflow-hidden"
// Anchor-wide by default; grows past the trigger only when
// rows carry environment labels, up to 20rem, so a project
// name and where it lives both stay readable.
className={cn(
"min-w-0 overflow-hidden",
showProjectEnvironments
? "w-max min-w-(--anchor-width) max-w-80"
: "w-(--anchor-width)",
)}
>
<ComboboxSearchInput
aria-label="Search projects"
Expand Down Expand Up @@ -4504,6 +4542,16 @@ export default function Sidebar() {
<FolderIcon className="size-4 shrink-0" />
)}
<span className="min-w-0 flex-1 truncate text-sm">{item.label}</span>
{project && showProjectEnvironments ? (
<ProjectEnvironmentLabel
labels={project.environmentLabels}
machine={resolveProjectGroupMachine({
group: project,
primaryEnvironmentId,
machineByEnvironmentId: environmentMachineById,
})}
/>
) : null}
{project ? (
<Button
size="icon-xs"
Expand Down
42 changes: 40 additions & 2 deletions apps/web/src/components/chat/DraftHeroHeadline.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { DraftId } from "~/composerDraftStore";
import { useComposerDraftStore } from "~/composerDraftStore";
import type { ScopedProjectRef } from "@t3tools/contracts";
import { resolveEnvironmentMachineKind, type ScopedProjectRef } from "@t3tools/contracts";
import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
import { FolderPlusIcon } from "lucide-react";
import { useCallback, useMemo } from "react";
Expand All @@ -12,9 +12,11 @@ import { selectProjectGroupingSettings } from "~/logicalProject";
import {
buildSidebarProjectPickerEntries,
buildSidebarProjectSnapshots,
projectGroupsSpanEnvironments,
} from "~/sidebarProjectGrouping";
import { useProjects, useThreadShells } from "~/state/entities";
import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";
import { ProjectEnvironmentLabel, resolveProjectGroupMachine } from "../ProjectEnvironmentLabel";
import { ProjectFavicon } from "../ProjectFavicon";
import { sortLogicalProjectsForSidebar } from "../Sidebar.logic";
import {
Expand Down Expand Up @@ -83,6 +85,26 @@ export function DraftHeroHeadline({
threads,
],
);
// Same-named projects on two machines are only told apart by where they
// live, so rows carry their environment once the catalog spans more than
// one; a single-machine catalog stays as it was.
const showProjectEnvironments = useMemo(
() => projectGroupsSpanEnvironments(projectGroups),
[projectGroups],
);
const environmentMachineById = useMemo(
() =>
new Map(
environments.map(
(environment) =>
[
environment.environmentId,
resolveEnvironmentMachineKind(environment.serverConfig),
] as const,
),
),
[environments],
);
const projectPickerEntries = useMemo(
() =>
buildSidebarProjectPickerEntries({
Expand Down Expand Up @@ -167,6 +189,10 @@ export function DraftHeroHeadline({
}}
>
{projectPickerEntries.map(({ group }) => {
const environmentLabel =
showProjectEnvironments && group.environmentLabels.length > 0
? group.environmentLabels.join(" · ")
: null;
return (
<MenuRadioItem
key={group.projectKey}
Expand All @@ -180,9 +206,21 @@ export function DraftHeroHeadline({
{group.displayName}
</TooltipTrigger>
<TooltipPopup side="top" className="max-w-80">
{group.displayName}
{environmentLabel
? `${group.displayName} · ${environmentLabel}`
: group.displayName}
</TooltipPopup>
</Tooltip>
{environmentLabel ? (
<ProjectEnvironmentLabel
labels={group.environmentLabels}
machine={resolveProjectGroupMachine({
group,
primaryEnvironmentId,
machineByEnvironmentId: environmentMachineById,
})}
/>
) : null}
</MenuRadioItem>
);
})}
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/settings/scopedSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ const group: SidebarProjectSnapshot = {
allRemoteMembersAreDesktopLocal: false,
allRemoteMembersAreWsl: false,
remoteEnvironmentLabels: [server.label, laptop.label],
environmentLabels: ["Local", server.label, laptop.label],
};
const project = resolveSettingsScope({ project: group.projectKey }, [group], environments);
const checkout = resolveSettingsScope(
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/settings/settingsScope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ function group(
allRemoteMembersAreDesktopLocal: false,
allRemoteMembersAreWsl: false,
remoteEnvironmentLabels: [],
environmentLabels: ["Local"],
};
}

Expand Down
38 changes: 38 additions & 0 deletions apps/web/src/environmentGrouping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
buildPhysicalToLogicalProjectKeyMap,
buildSidebarProjectPickerEntries,
buildSidebarProjectSnapshots,
projectGroupsSpanEnvironments,
} from "./sidebarProjectGrouping";
import { orderItemsByPreferredIds } from "./components/Sidebar.logic";
import { legacyProjectCwdPreferenceKey } from "./uiStateStore";
Expand Down Expand Up @@ -81,6 +82,43 @@ describe("environment grouping", () => {
expect(projectGroupCount).toBe(1);
});

it("labels where each project group lives once the catalog spans environments", () => {
const grouped = makeProject({ repositoryIdentity });
const groupedRemote = makeProject({
id: ProjectId.make("project-remote"),
environmentId: remoteEnvironmentId,
repositoryIdentity,
});
const separateLocal = makeProject({
id: ProjectId.make("workbench-local"),
title: "workbench",
workspaceRoot: "/tmp/workbench",
});
const separateRemote = makeProject({
id: ProjectId.make("workbench-remote"),
environmentId: remoteEnvironmentId,
title: "workbench",
workspaceRoot: "/tmp/workbench",
});
const build = (projects: Project[]) =>
buildSidebarProjectSnapshots({
projects,
settings: defaultGroupingSettings,
primaryEnvironmentId,
resolveEnvironmentLabel: (environmentId) =>
environmentId === remoteEnvironmentId ? "Mac mini" : "Primary",
});

const groups = build([grouped, groupedRemote, separateLocal, separateRemote]);
expect(groups.map((group) => group.environmentLabels)).toEqual([
["Local", "Mac mini"],
["Local"],
["Mac mini"],
]);
expect(projectGroupsSpanEnvironments(groups)).toBe(true);
expect(projectGroupsSpanEnvironments(build([grouped, separateLocal]))).toBe(false);
});

it("keeps projects without repository identity physically scoped", () => {
const primary = makeProject();
const remote = makeProject({
Expand Down
28 changes: 28 additions & 0 deletions apps/web/src/sidebarProjectGrouping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,24 @@ export interface SidebarProjectSnapshot extends Project {
memberProjects: readonly SidebarProjectGroupMember[];
memberProjectRefs: readonly ScopedProjectRef[];
remoteEnvironmentLabels: readonly string[];
// Where the group lives, in member order: "Local" for the primary
// environment, the saved label for every other one. Project pickers show
// this when the catalog spans environments so two projects with the same
// name stay apart; see projectGroupsSpanEnvironments.
environmentLabels: readonly string[];
}

export function projectGroupsSpanEnvironments(
groups: ReadonlyArray<Pick<SidebarProjectSnapshot, "memberProjects">>,
): boolean {
const environmentIds = new Set<EnvironmentId>();
for (const group of groups) {
for (const member of group.memberProjects) {
environmentIds.add(member.environmentId);
if (environmentIds.size > 1) return true;
}
}
return false;
}

export interface SidebarProjectPickerEntry {
Expand Down Expand Up @@ -96,6 +114,15 @@ export function buildSidebarProjectSnapshots(input: {
const remoteEnvironmentLabels = remoteMembers
.flatMap((member) => (member.environmentLabel ? [member.environmentLabel] : []))
.filter((label, index, labels) => labels.indexOf(label) === index);
const environmentLabels = members
.flatMap((member) =>
member.environmentId === input.primaryEnvironmentId
? ["Local"]
: member.environmentLabel
? [member.environmentLabel]
: [],
Comment thread
maria-rcks marked this conversation as resolved.
Outdated
)
.filter((label, index, labels) => labels.indexOf(label) === index);
const isDesktopLocal = input.isDesktopLocalEnvironment ?? (() => false);
const isWsl = input.isWslEnvironment ?? (() => false);
const allRemoteMembersAreDesktopLocal =
Expand All @@ -116,6 +143,7 @@ export function buildSidebarProjectSnapshots(input: {
memberProjects: members,
memberProjectRefs: group.memberProjectRefs,
remoteEnvironmentLabels,
environmentLabels,
};
});
}
Expand Down
Loading