Skip to content
71 changes: 26 additions & 45 deletions apps/web/src/components/settings/ProjectSettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,6 @@ import {
} from "../../providerInstances";
import { getCustomModelOptionsByInstance } from "../../modelSelection";
import {
buildSidebarProjectSnapshots,
type SidebarProjectGroupMember,
type SidebarProjectSnapshot,
} from "../../sidebarProjectGrouping";
Expand Down Expand Up @@ -115,6 +114,7 @@ import {
ProjectFaviconPickerDialog,
} from "./ProjectFaviconPickerDialog";
import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic";
import { useSettingsProjectGroups } from "./useSettingsProjectGroups";

const ProjectIconPickerDialog = lazy(() =>
import("./ProjectIconPickerDialog").then((module) => ({
Expand All @@ -128,41 +128,18 @@ export const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, st
separate: "Keep separate",
};

/** Logical project groups for the settings page, sorted by display name. */
export function useSettingsProjectGroups(): SidebarProjectSnapshot[] {
const projects = useProjects();
const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
const primaryEnvironmentId = usePrimaryEnvironmentId();
const { environments } = useEnvironments();
const environmentLabelById = useMemo(
() =>
new Map(
environments.map((environment) => [environment.environmentId, environment.label] as const),
),
[environments],
);
return useMemo(
() =>
buildSidebarProjectSnapshots({
projects,
settings: projectGroupingSettings,
primaryEnvironmentId,
resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null,
}).sort((a, b) => a.displayName.localeCompare(b.displayName)),
[environmentLabelById, primaryEnvironmentId, projectGroupingSettings, projects],
);
}

function memberKey(member: { environmentId: string; id: string }): string {
return `${member.environmentId}:${member.id}`;
}

export function ProjectSettingsPanel({
projectKey,
environmentId = null,
checkoutKey = null,
}: {
projectKey: string;
environmentId?: EnvironmentId | null;
checkoutKey?: string | null;
}) {
const groups = useSettingsProjectGroups();
const navigate = useNavigate();
Expand All @@ -171,45 +148,58 @@ export function ProjectSettingsPanel({
const members = useMemo(
() =>
selected?.memberProjects.filter(
(member) => environmentId === null || member.environmentId === environmentId,
(member) =>
(environmentId === null || member.environmentId === environmentId) &&
(checkoutKey === null || member.physicalProjectKey === checkoutKey),
) ?? [],
[selected, environmentId],
[selected, environmentId, checkoutKey],
);

// Remember the members of the last rendered group so a grouping-rule change
// (which changes the group key) can follow the project to its new group.
const lastSelectionRef = useRef<{
key: string;
environmentId: EnvironmentId | null;
checkoutKey: string | null;
memberKeys: string[];
} | null>(null);
useEffect(() => {
if (!selected || members.length === 0) return;
lastSelectionRef.current = {
key: selected.projectKey,
environmentId,
checkoutKey,
memberKeys: members.map((member) => member.physicalProjectKey),
};
}, [selected, members, environmentId]);
}, [selected, members, environmentId, checkoutKey]);

// A grouping-rule change replaces the group key mid-visit; follow the
// project to its new key instead of parking on the not-found state.
useEffect(() => {
if (members.length > 0) return;
const last = lastSelectionRef.current;
if (last?.key !== projectKey || last.environmentId !== environmentId) return;
if (
last?.key !== projectKey ||
last.environmentId !== environmentId ||
last.checkoutKey !== checkoutKey
)
return;
const successor = groups.find((group) =>
group.memberProjects.some((member) => last.memberKeys.includes(member.physicalProjectKey)),
);
if (successor) {
void navigate({
to: "/settings/projects",
search: { project: successor.projectKey, machine: environmentId ?? undefined },
search: {
project: successor.projectKey,
machine: environmentId ?? undefined,
checkout: checkoutKey ?? undefined,
},
replace: true,
hashScrollIntoView: false,
});
}
}, [groups, navigate, projectKey, members.length, environmentId]);
}, [groups, navigate, projectKey, members.length, environmentId, checkoutKey]);

if (!selected) {
return (
Expand All @@ -223,7 +213,7 @@ export function ProjectSettingsPanel({
if (members.length === 0)
return (
<p className="p-8 text-sm text-muted-foreground">
This project has no checkout on this machine.
This checkout is no longer available in the selected project and environment.
</p>
);
const scopedGroup = {
Expand All @@ -234,7 +224,7 @@ export function ProjectSettingsPanel({
};
return (
<ProjectDetail
key={`${selected.projectKey}:${environmentId ?? "all"}`}
key={`${selected.projectKey}:${environmentId ?? "all"}:${checkoutKey ?? "all"}`}
group={scopedGroup}
hasOtherMembers={members.length < selected.memberProjects.length}
/>
Expand Down Expand Up @@ -879,23 +869,14 @@ function ProjectDetail({
draftStore.clearProjectDraftThreadId(projectRef);
}

if (isWholeGroup) {
if (hasOtherMembers) {
void navigate({
to: "/settings/projects",
search: { project: group.projectKey, machine: undefined },
replace: true,
});
} else {
void navigate({ to: "/", replace: true });
}
if (isWholeGroup && !hasOtherMembers) {
void navigate({ to: "/", replace: true });
}
},
[
deleteProject,
group.displayName,
group.memberProjects.length,
group.projectKey,
hasOtherMembers,
navigate,
reportFailure,
Expand Down
179 changes: 32 additions & 147 deletions apps/web/src/components/settings/ProjectsSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,168 +1,53 @@
import { resolveEnvironmentMachineKind } from "@t3tools/contracts";
import { ChevronDownIcon, FolderIcon } from "lucide-react";
import { type ReactNode, useState } from "react";
import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
import { ProjectFavicon } from "../ProjectFavicon";
import { WorkspacePageContainer } from "../WorkspacePageContainer";
import { useEnvironments } from "../../state/environments";
import { Toggle, ToggleGroup } from "../ui/toggle-group";
import {
Combobox,
ComboboxEmpty,
ComboboxSearchInput,
ComboboxItem,
ComboboxList,
ComboboxPopup,
ComboboxTrigger,
} from "../ui/combobox";
import { selectTriggerVariants } from "../ui/select";
import { cn } from "../../lib/utils";
import { ProjectSettingsPanel, useSettingsProjectGroups } from "./ProjectSettingsPanel";
import { ProjectSettingsPanel } from "./ProjectSettingsPanel";
import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings";

function ScopePicker({
label,
value,
options,
onChange,
}: {
label: "project" | "machine";
value: string | null;
options: ReadonlyArray<{ value: string; label: string; icon?: ReactNode }>;
onChange: (value: string | null) => void;
}) {
const [query, setQuery] = useState("");
const selected = options.find((option) => option.value === value);
const allIcon =
label === "project" ? <FolderIcon aria-hidden className="size-3.5 shrink-0" /> : null;
const items = [{ value: "all", label: `All ${label}s`, icon: allIcon }, ...options];
return (
<Combobox
items={items}
value={items.find((item) => item.value === (value ?? "all")) ?? null}
inputValue={query}
onInputValueChange={setQuery}
onOpenChange={() => setQuery("")}
onValueChange={(next) => {
if (next) onChange(next.value === "all" ? null : next.value);
}}
>
<ComboboxTrigger
aria-label={`${label === "project" ? "Project" : "Machine"} scope`}
className={cn(selectTriggerVariants({ size: "compact" }), "w-auto min-w-0 max-w-52")}
>
<span className="flex min-w-0 items-center gap-1.5">
{value === null ? allIcon : selected?.icon}
<span className="truncate">
{value === null ? `All ${label}s` : (selected?.label ?? `Unavailable ${label}`)}
</span>
</span>
<ChevronDownIcon aria-hidden className="-me-1 size-3 opacity-50" />
</ComboboxTrigger>
<ComboboxPopup align="start" className="w-64">
<ComboboxSearchInput aria-label={`Search ${label}s`} placeholder={`Search ${label}s...`} />
<ComboboxEmpty>No matching {label}s.</ComboboxEmpty>
<ComboboxList>
{(item: (typeof items)[number]) => (
<ComboboxItem
key={item.value}
value={item}
contentClassName="flex min-w-0 items-center gap-2"
>
{item.icon}
<span className="truncate">{item.label}</span>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxPopup>
</Combobox>
);
}
import { SettingsScopeSelects } from "./SettingsScopeSelects";
import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope";
import { useSettingsProjectGroups } from "./useSettingsProjectGroups";

export function ProjectsSettings({
projectKey,
machineId,
value,
onScopeChange,
}: {
projectKey: string | null;
machineId: string | null;
onScopeChange: (project: string | null, machine: string | null) => void;
value: SettingsScopeSearch;
onScopeChange: (scope: SettingsScopeSearch) => void;
}) {
const groups = useSettingsProjectGroups();
const { environments } = useEnvironments();
const machine = environments.find((environment) => environment.environmentId === machineId);
const machineOptions = environments.map((environment) => ({
value: environment.environmentId,
label: environment.label,
icon: (
<EnvironmentMachineIcon
aria-hidden
kind={resolveEnvironmentMachineKind(environment.serverConfig)}
className="size-3.5 shrink-0"
/>
),
}));
const scope = resolveSettingsScope(value, groups, environments);
// The panel follows remembered members when grouping replaces a project key.
const projectScope =
scope.kind === "project" ||
scope.kind === "checkout" ||
(scope.kind === "unavailable" &&
(scope.reason === "project-missing" || scope.reason === "checkout-missing"));
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="scrollbar-gutter-both shrink-0 overflow-y-auto">
<WorkspacePageContainer className="pb-0">
<div
className="flex flex-wrap items-center gap-2"
role="group"
aria-label="Settings scope"
>
{environments.length > 3 ? (
<ScopePicker
label="machine"
value={machineId}
options={machineOptions}
onChange={(value) => onScopeChange(projectKey, value)}
/>
) : (
<ToggleGroup
aria-label="Machine scope"
variant="segmented"
className="max-w-full flex-wrap"
value={[machineId ?? "all"]}
onValueChange={(next) => {
const value = next[0];
if (value) onScopeChange(projectKey, value === "all" ? null : value);
}}
>
<Toggle value="all">All machines</Toggle>
{machineOptions.map((option) => (
<Toggle key={option.value} value={option.value} title={option.label}>
{option.icon}
<span className="max-w-36 truncate">{option.label}</span>
</Toggle>
))}
</ToggleGroup>
)}
<div className="ms-auto">
<ScopePicker
label="project"
value={projectKey}
options={groups.map((group) => ({
value: group.projectKey,
label: group.displayName,
icon: <ProjectFavicon project={group} className="size-3.5" />,
}))}
onChange={(value) => onScopeChange(value, machineId)}
/>
</div>
</div>
<WorkspacePageContainer className="items-end pb-0">
<SettingsScopeSelects
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
value={value}
groups={groups}
environments={environments}
onChange={onScopeChange}
/>
</WorkspacePageContainer>
</div>
{machineId !== null && !machine ? (
<p className="p-8 text-sm text-muted-foreground">This machine is no longer available.</p>
) : projectKey === null ? (
<ProjectDefaultsSettings environmentId={machine?.environmentId ?? null} />
) : (
{value.project && projectScope ? (
<ProjectSettingsPanel
projectKey={projectKey}
environmentId={machine?.environmentId ?? null}
projectKey={value.project}
environmentId={value.machine ? EnvironmentId.make(value.machine) : null}
checkoutKey={value.checkout ?? null}
/>
) : scope.kind === "unavailable" ? (
<p className="p-8 text-sm text-muted-foreground">{scope.message}</p>
) : (
<ProjectDefaultsSettings
environmentId={scope.kind === "environment" ? scope.environmentId : null}
/>
)}
</div>
);
}
import { EnvironmentId } from "@t3tools/contracts";
Loading
Loading