Skip to content
Closed
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
22 changes: 7 additions & 15 deletions apps/web/src/components/LegacySidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,9 @@ import {
deriveProjectGroupingOverrideKey,
getProjectOrderKey,
selectProjectGroupingSettings,
withProjectGroupingOverride,
} from "../logicalProject";
import { PROJECT_GROUPING_MODE_LABELS } from "./settings/ProjectGroupingSelect";
import type { SidebarThreadSummary } from "../types";
import {
buildPhysicalToLogicalProjectKeyMap,
Expand All @@ -229,11 +231,6 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = {
easing: "ease-out",
} as const;
const EMPTY_THREAD_JUMP_LABELS = new Map<string, string>();
const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = {
repository: "Group by repository",
repository_path: "Group by repository path",
separate: "Keep separate",
};
const SIDEBAR_ICON_ACTION_BUTTON_CLASS =
"inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-0.75 text-icon-muted hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring";

Expand Down Expand Up @@ -2213,17 +2210,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
return;
}

const overrideKey = deriveProjectGroupingOverrideKey(projectGroupingTarget);
const nextOverrides = {
...projectGroupingSettings.sidebarProjectGroupingOverrides,
};
if (projectGroupingSelection === "inherit") {
delete nextOverrides[overrideKey];
} else {
nextOverrides[overrideKey] = projectGroupingSelection;
}
updateSettings({
sidebarProjectGroupingOverrides: nextOverrides,
sidebarProjectGroupingOverrides: withProjectGroupingOverride(
projectGroupingSettings.sidebarProjectGroupingOverrides,
projectGroupingTarget,
projectGroupingSelection,
),
});
closeProjectGroupingDialog();
}, [
Expand Down
68 changes: 68 additions & 0 deletions apps/web/src/components/settings/ProjectGroupingSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { SidebarProjectGroupingMode } from "@t3tools/contracts";
import * as Schema from "effect/Schema";

import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings";
import {
deriveProjectGroupingOverrideKey,
selectProjectGroupingSettings,
withProjectGroupingOverride,
} from "../../logicalProject";
import type { SidebarProjectGroupMember } from "../../sidebarProjectGrouping";
import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";

export const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = {
repository: "Group by repository",
repository_path: "Group by repository path",
separate: "Keep separate",
};

const isGroupingMode = Schema.is(SidebarProjectGroupingMode);

/**
* Picks one checkout's grouping rule. Overrides are client settings keyed by
* environment and path, so they apply on this device only.
*/
export function ProjectGroupingSelect({ member }: { member: SidebarProjectGroupMember }) {
const settings = useClientSettings(selectProjectGroupingSettings);
const updateClientSettings = useUpdateClientSettings();
const selection =
settings.sidebarProjectGroupingOverrides[deriveProjectGroupingOverrideKey(member)] ?? "inherit";

return (
<Select
value={selection}
onValueChange={(value) => {
if (value === selection || (value !== "inherit" && !isGroupingMode(value))) return;
void updateClientSettings({
sidebarProjectGroupingOverrides: withProjectGroupingOverride(
settings.sidebarProjectGroupingOverrides,
member,
value,
),
});
}}
>
<SelectTrigger
size="sm"
className="w-full sm:w-56"
aria-label={`Grouping for ${member.workspaceRoot}`}
>
<SelectValue>
{selection === "inherit"
? `Default (${PROJECT_GROUPING_MODE_LABELS[settings.sidebarProjectGroupingMode]})`
: PROJECT_GROUPING_MODE_LABELS[selection]}
</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
<SelectItem hideIndicator value="inherit">
Use default
</SelectItem>
{SidebarProjectGroupingMode.literals.map((mode) => (
<SelectItem key={mode} hideIndicator value={mode}>
{PROJECT_GROUPING_MODE_LABELS[mode]}
</SelectItem>
))}
</SelectPopup>
</Select>
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
27 changes: 19 additions & 8 deletions apps/web/src/components/settings/ProjectSettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
} from "./ProjectFaviconPickerDialog";
import { ProjectActionsSettings } from "./ProjectActionsSettings";
import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings";
import { ProjectGroupingSelect } from "./ProjectGroupingSelect";
import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic";
import { useSettingsProjectGroups } from "./useSettingsProjectGroups";

Expand Down Expand Up @@ -392,14 +393,17 @@ function ProjectDetail({
title={member.environmentLabel ?? "Environment"}
description={member.workspaceRoot}
control={
<Button
size="sm"
variant="outline"
onClick={() => void removeMembers([member])}
aria-label={`Remove checkout ${member.workspaceRoot}`}
>
Remove
</Button>
<div className="flex items-center gap-2">
<ProjectGroupingSelect member={member} />
<Button
size="sm"
variant="outline"
onClick={() => void removeMembers([member])}
aria-label={`Remove checkout ${member.workspaceRoot}`}
>
Remove
</Button>
</div>
}
/>
))}
Expand Down Expand Up @@ -488,6 +492,13 @@ function ProjectDetail({
</div>
}
/>
{hasMultipleCheckouts ? null : (
<SettingsRow
title="Grouping"
description="Whether this checkout joins other checkouts of the same repository. Saved on this device."
control={<ProjectGroupingSelect member={group.memberProjects[0]!} />}
/>
)}
</SettingsSection>
<ProjectDefaultsSettings category="project" />
<ProjectActionsSettings />
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/logicalProject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export {
getProjectOrderKey,
resolveProjectGroupingMode,
selectProjectGroupingSettings,
withProjectGroupingOverride,
type ProjectGroupingMode,
type ProjectGroupingSettings,
type ProjectGroup,
Expand Down
11 changes: 11 additions & 0 deletions docs/user/project-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,17 @@ When no image is found, web and desktop show a two-character monogram with a col
from the icon palette, derived from the saved project name. For example, `Nebula` becomes `NA`,
`Silver Orchard` becomes `SO`, and `M7 Forge` becomes `M7`.

## Project grouping

With **Settings → General → Project grouping** on, checkouts of the same repository share one
project, even across environments. The repository comes from the checkout's `upstream` remote,
then `origin`, so a fork with an `upstream` remote joins the original repository's project.

To split one checkout out, open the project and change its rule under **Grouping**, or under
**Checkouts** when the project has several. **Keep separate** gives the checkout its own project, and
**Use default** puts it back. These rules are saved on the device you set them on, and mobile
only follows the global setting.

## Keep the default branch current

In Source Control, enable **Automatically pull** to keep the default-branch checkout up to date
Expand Down
19 changes: 12 additions & 7 deletions packages/client-runtime/src/state/projectGrouping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { chooseLoadBalancedEnvironment } from "../load-balancing.ts";
import {
buildProjectGroups,
derivePhysicalProjectKey,
withProjectGroupingOverride,
type ProjectGroupingSettings,
} from "./projectGrouping.ts";

Expand Down Expand Up @@ -171,23 +172,27 @@ describe("buildProjectGroups", () => {
expect(groups.map((group) => group.label)).toEqual(["t3code", "t3code-2", "t3code-3"]);
});

it("applies a physical-project override without dropping its siblings", () => {
it("applies a physical-project override without dropping its siblings, and removes it", () => {
const first = makeProject("t3code", "/work/t3code");
const second = makeProject("t3code-2", "/work/t3code-2");
const third = makeProject("t3code-3", "/work/t3code-3");
const groups = buildProjectGroups({
projects: [first, second, third],
settings: settings("repository", {
[derivePhysicalProjectKey(second)]: "separate",
}),
});
const projects = [first, second, third];
const overrides = withProjectGroupingOverride({}, second, "separate");
const groups = buildProjectGroups({ projects, settings: settings("repository", overrides) });

expect(overrides).toEqual({ [derivePhysicalProjectKey(second)]: "separate" });
expect(groups).toHaveLength(2);
expect(groups.flatMap((group) => group.members.map((member) => member.project.id))).toEqual([
"t3code",
"t3code-3",
"t3code-2",
]);

const restored = withProjectGroupingOverride(overrides, second, "inherit");
expect(restored).toEqual({});
expect(
buildProjectGroups({ projects, settings: settings("repository", restored) }),
).toHaveLength(1);
});

it("dedupes stale registrations at one physical path using the freshest project", () => {
Expand Down
14 changes: 14 additions & 0 deletions packages/client-runtime/src/state/projectGrouping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,20 @@ export function resolveProjectGroupingMode(
);
}

/**
* Returns the overrides with one checkout's grouping rule set, or removed when
* `mode` is `"inherit"` so the checkout follows the global mode again.
*/
export function withProjectGroupingOverride(
overrides: ProjectGroupingSettings["sidebarProjectGroupingOverrides"],
project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">,
mode: SidebarProjectGroupingMode | "inherit",
): ProjectGroupingSettings["sidebarProjectGroupingOverrides"] {
const key = deriveProjectGroupingOverrideKey(project);
const { [key]: _previous, ...rest } = overrides;
return mode === "inherit" ? rest : { ...rest, [key]: mode };
}

function deriveRepositoryScopedKey(
project: Pick<EnvironmentProject, "workspaceRoot" | "repositoryIdentity">,
groupingMode: SidebarProjectGroupingMode,
Expand Down
Loading