diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts index e59531fe7ca9..f12bea4904bb 100644 --- a/apps/mobile/src/features/home/homeThreadList.test.ts +++ b/apps/mobile/src/features/home/homeThreadList.test.ts @@ -524,7 +524,7 @@ describe("buildHomeThreadGroups", () => { expect(groups[0]?.threads.map((thread) => thread.environmentId)).toEqual([remoteEnvironmentId]); }); - it("matches web repository, repository-path, and separate grouping modes", () => { + it("keeps monorepo workspaces separate in every web grouping mode", () => { const environmentId = EnvironmentId.make("environment-1"); const repositoryIdentity = { canonicalKey: "github.com/t3tools/t3code", @@ -564,17 +564,11 @@ describe("buildHomeThreadGroups", () => { }), ); - expect(buildGroups(projects, threads, { projectGroupingMode: "repository" })).toHaveLength(1); - expect( - buildGroups(projects, threads, { projectGroupingMode: "repository_path" }).map( - (group) => group.title, - ), - ).toEqual(["Mobile", "Web"]); - expect( - buildGroups(projects, threads, { projectGroupingMode: "separate" }).map( - (group) => group.title, - ), - ).toEqual(["Mobile", "Web"]); + for (const projectGroupingMode of ["repository", "repository_path", "separate"] as const) { + expect( + buildGroups(projects, threads, { projectGroupingMode }).map((group) => group.title), + ).toEqual(["Mobile", "Web"]); + } }); it("default view shows only threads from the last 5 days", () => { diff --git a/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx index 951168fefcf6..bcc897b64ad0 100644 --- a/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx @@ -24,12 +24,7 @@ const GROUPING_OPTIONS: ReadonlyArray<{ { mode: "repository", label: "Group by repository", - description: "Matching repositories appear as one project.", - }, - { - mode: "repository_path", - label: "Group by repository path", - description: "Keep monorepo paths separate.", + description: "Matching checkouts appear as one project. Nested workspaces stay separate.", }, { mode: "separate", diff --git a/apps/mobile/src/state/project-grouping.logic.ts b/apps/mobile/src/state/project-grouping.logic.ts index 3cd01174b5dd..5dede4cd66be 100644 --- a/apps/mobile/src/state/project-grouping.logic.ts +++ b/apps/mobile/src/state/project-grouping.logic.ts @@ -1,4 +1,7 @@ -import type { ProjectGroupingSettings } from "@t3tools/client-runtime/state/project-grouping"; +import { + normalizeProjectGroupingMode, + type ProjectGroupingSettings, +} from "@t3tools/client-runtime/state/project-grouping"; import type { SidebarProjectGroupingMode } from "@t3tools/contracts"; import type { Preferences } from "../persistence/mobile-preferences"; @@ -12,9 +15,10 @@ export function resolveMobileProjectGroupingSettings( preferences: Preferences, ): ProjectGroupingSettings { return { - sidebarProjectGroupingMode: + sidebarProjectGroupingMode: normalizeProjectGroupingMode( preferences.projectGroupingMode ?? - (preferences.projectGroupingEnabled === false ? "separate" : "repository"), + (preferences.projectGroupingEnabled === false ? "separate" : "repository"), + ), sidebarProjectGroupingOverrides: {}, }; } diff --git a/apps/mobile/src/state/project-grouping.test.ts b/apps/mobile/src/state/project-grouping.test.ts index 6995ea463ad0..a8b0b8c312ba 100644 --- a/apps/mobile/src/state/project-grouping.test.ts +++ b/apps/mobile/src/state/project-grouping.test.ts @@ -15,9 +15,16 @@ describe("mobile project grouping preferences", () => { expect( resolveMobileProjectGroupingSettings({ projectGroupingEnabled: false, - projectGroupingMode: "repository_path", + projectGroupingMode: "repository", }).sidebarProjectGroupingMode, - ).toBe("repository_path"); + ).toBe("repository"); + }); + + it("reads the legacy repository_path mode as repository", () => { + expect( + resolveMobileProjectGroupingSettings({ projectGroupingMode: "repository_path" }) + .sidebarProjectGroupingMode, + ).toBe("repository"); }); it("dual-writes the legacy boolean for rollback compatibility", () => { diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index c0e16c7cce71..7c75a78e182e 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -227,7 +227,7 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { const EMPTY_THREAD_JUMP_LABELS = new Map(); const PROJECT_GROUPING_MODE_LABELS: Record = { repository: "Group by repository", - repository_path: "Group by repository path", + repository_path: "Group by repository", separate: "Keep separate", }; const SIDEBAR_ICON_ACTION_BUTTON_CLASS = @@ -269,9 +269,8 @@ function projectExpansionPreferenceKeys(project: SidebarProjectSnapshot): string function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): string { switch (mode) { case "repository": - return "Projects from the same repository share one sidebar row."; case "repository_path": - return "Projects group only when both the repository and repo-relative path match."; + return "Checkouts of one repository path share a sidebar row. Nested workspaces stay separate."; case "separate": return "Every project path gets its own sidebar row."; } @@ -2561,9 +2560,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec {PROJECT_GROUPING_MODE_LABELS.repository} - - {PROJECT_GROUPING_MODE_LABELS.repository_path} - {PROJECT_GROUPING_MODE_LABELS.separate} diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index 938000e01002..7fd42e92641a 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -30,6 +30,7 @@ import { toastManager } from "../ui/toast"; import { Switch } from "../ui/switch"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; +import { normalizeProjectGroupingMode } from "../../logicalProject"; import { PROJECT_GROUPING_MODE_LABELS } from "./ProjectSettingsPanel"; import { ProjectDefaultActionsSettings } from "./ProjectDefaultActionsSettings"; import { searchableSetting } from "./settingsSearch"; @@ -420,7 +421,7 @@ export function ProjectDefaultsSettings({ } control={ diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index d88644fb7e3c..9e554359bb7d 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -124,7 +124,7 @@ const ProjectIconPickerDialog = lazy(() => export const PROJECT_GROUPING_MODE_LABELS: Record = { repository: "Group by repository", - repository_path: "Group by repository path", + repository_path: "Group by repository", separate: "Keep separate", }; @@ -1308,9 +1308,6 @@ function ProjectDetail({ {PROJECT_GROUPING_MODE_LABELS.repository} - - {PROJECT_GROUPING_MODE_LABELS.repository_path} - {PROJECT_GROUPING_MODE_LABELS.separate} diff --git a/apps/web/src/logicalProject.ts b/apps/web/src/logicalProject.ts index d75c4c2de902..0c99aab0ea56 100644 --- a/apps/web/src/logicalProject.ts +++ b/apps/web/src/logicalProject.ts @@ -6,6 +6,7 @@ export { derivePhysicalProjectKeyFromPath, deriveProjectGroupingOverrideKey, getProjectOrderKey, + normalizeProjectGroupingMode, resolveProjectGroupingMode, selectProjectGroupingSettings, type ProjectGroupingMode, diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index c76c18544df2..e6257f0d1369 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -19,6 +19,16 @@ Reset that list to use shared actions again. Existing project actions are preser Project names, icons, removal, and importing actions from a checkout remain project-specific. When there are several checkouts, the checkout picker selects which actions and grouping to edit. +## Project grouping + +Project grouping combines checkouts of one repository folder into a single row. A project at +`~/code/app` and the same folder on another machine or in a worktree share that row. + +Folders inside a repository stay separate. Add `~/code/app` and `~/code/app/services/api` as two +projects, and each keeps its own row. Start a thread in either one. + +Turn grouping off to give every checkout its own row. + ## Project icons Choose an icon, emoji, or image from the project to make it easier to recognize. The choice applies diff --git a/packages/client-runtime/src/state/projectGrouping.test.ts b/packages/client-runtime/src/state/projectGrouping.test.ts index 4884c3b99bbc..43d12bb34b02 100644 --- a/packages/client-runtime/src/state/projectGrouping.test.ts +++ b/packages/client-runtime/src/state/projectGrouping.test.ts @@ -6,6 +6,8 @@ import { chooseLoadBalancedEnvironment } from "../load-balancing.ts"; import { buildProjectGroups, derivePhysicalProjectKey, + normalizeProjectGroupingMode, + selectProjectGroupingSettings, type ProjectGroupingSettings, } from "./projectGrouping.ts"; @@ -136,6 +138,62 @@ describe("buildProjectGroups", () => { } }); + it("keeps a monorepo workspace out of its parent repository's group", () => { + const rootIdentity = { ...repositoryIdentity, rootPath: "/work/t3code" }; + const projects = [ + makeProject("root", "/work/t3code", { repositoryIdentity: rootIdentity }), + makeProject("web", "/work/t3code/apps/web", { repositoryIdentity: rootIdentity }), + makeProject("api", "/work/t3code/services/api", { repositoryIdentity: rootIdentity }), + ]; + + for (const mode of ["repository", "repository_path"] as const) { + const groups = buildProjectGroups({ projects, settings: settings(mode) }); + expect(groups.map((group) => group.label)).toEqual(["root", "web", "api"]); + } + }); + + it("keeps a nested workspace separate when the repository is a filesystem root", () => { + const rootIdentity = { ...repositoryIdentity, rootPath: "/" }; + const groups = buildProjectGroups({ + projects: [ + makeProject("root", "/", { repositoryIdentity: rootIdentity }), + makeProject("api", "/services/api", { repositoryIdentity: rootIdentity }), + ], + settings: settings("repository"), + }); + + expect(groups.map((group) => group.label)).toEqual(["root", "api"]); + }); + + it("keeps a nested workspace separate when the repository is a Windows drive root", () => { + const driveIdentity = { ...repositoryIdentity, rootPath: "C:\\" }; + const groups = buildProjectGroups({ + projects: [ + makeProject("drive", "C:\\", { repositoryIdentity: driveIdentity }), + makeProject("drive-api", "C:\\services\\api", { repositoryIdentity: driveIdentity }), + ], + settings: settings("repository"), + }); + + expect(groups.map((group) => group.label)).toEqual(["drive", "drive-api"]); + }); + + it("groups checkouts of one monorepo workspace across environments", () => { + const projects = [ + makeProject("local", "/work/t3code/apps/web", { + repositoryIdentity: { ...repositoryIdentity, rootPath: "/work/t3code" }, + }), + makeProject("remote", "/srv/t3code/apps/web", { + environmentId: EnvironmentId.make("remote-environment"), + repositoryIdentity: { ...repositoryIdentity, rootPath: "/srv/t3code" }, + }), + ]; + + const groups = buildProjectGroups({ projects, settings: settings("repository") }); + expect(groups).toHaveLength(1); + expect(groups[0]?.members.map((member) => member.project.id)).toEqual(["local", "remote"]); + }); + it("uses a shared custom title as the repository group's label", () => { const projects = [ makeProject("first", "/work/t3code", { title: "Custom project" }), @@ -279,3 +337,20 @@ describe("buildProjectGroups", () => { expect(groups[0]?.members.map((member) => member.project.id)).toEqual(["winner", "sibling"]); }); }); + +describe("selectProjectGroupingSettings", () => { + it("reads the legacy repository_path preference as repository", () => { + expect(normalizeProjectGroupingMode("repository_path")).toBe("repository"); + expect(normalizeProjectGroupingMode("separate")).toBe("separate"); + + const settings = selectProjectGroupingSettings({ + sidebarProjectGroupingMode: "repository_path", + sidebarProjectGroupingOverrides: { "environment:/work/t3code": "repository_path" }, + } as never); + + expect(settings.sidebarProjectGroupingMode).toBe("repository"); + expect(settings.sidebarProjectGroupingOverrides).toEqual({ + "environment:/work/t3code": "repository", + }); + }); +}); diff --git a/packages/client-runtime/src/state/projectGrouping.ts b/packages/client-runtime/src/state/projectGrouping.ts index ce5c984214fd..27e99a868d3d 100644 --- a/packages/client-runtime/src/state/projectGrouping.ts +++ b/packages/client-runtime/src/state/projectGrouping.ts @@ -16,10 +16,26 @@ export interface ProjectGroupingSettings { export type ProjectGroupingMode = SidebarProjectGroupingMode; +/** + * Maps the legacy "repository_path" preference onto "repository". Both group + * checkouts of one repository path, so callers and pickers only ever see the + * two modes that still differ. + */ +export function normalizeProjectGroupingMode( + mode: SidebarProjectGroupingMode, +): SidebarProjectGroupingMode { + return mode === "repository_path" ? "repository" : mode; +} + export function selectProjectGroupingSettings(settings: ClientSettings): ProjectGroupingSettings { return { - sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, - sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, + sidebarProjectGroupingMode: normalizeProjectGroupingMode(settings.sidebarProjectGroupingMode), + sidebarProjectGroupingOverrides: Object.fromEntries( + Object.entries(settings.sidebarProjectGroupingOverrides).map(([key, mode]) => [ + key, + normalizeProjectGroupingMode(mode), + ]), + ), }; } @@ -55,8 +71,12 @@ function deriveRepositoryRelativeProjectPath( return ""; } + // A repository rooted at a filesystem root ("/" or "c:\\") already ends with + // its separator; appending another one stops every nested path from matching. const separator = normalizedRootPath.includes("\\") ? "\\" : "/"; - const rootPrefix = `${normalizedRootPath}${separator}`; + const rootPrefix = normalizedRootPath.endsWith(separator) + ? normalizedRootPath + : `${normalizedRootPath}${separator}`; if (!normalizedProjectPath.startsWith(rootPrefix)) { return null; } @@ -96,19 +116,20 @@ export function resolveProjectGroupingMode( ); } +/** + * Groups checkouts of one repository path, so the same workspace opened in + * several environments or worktrees shares a row. Nested workspaces keep their + * repo-relative path in the key: a monorepo package is its own project, and + * collapsing it into the repository row would leave no way to target it. + */ function deriveRepositoryScopedKey( project: Pick, - groupingMode: SidebarProjectGroupingMode, ): string | null { const canonicalKey = project.repositoryIdentity?.canonicalKey; if (!canonicalKey) { return null; } - if (groupingMode === "repository") { - return canonicalKey; - } - const relativeProjectPath = deriveRepositoryRelativeProjectPath(project); if (relativeProjectPath === null) { return canonicalKey; @@ -134,7 +155,7 @@ export function deriveLogicalProjectKey( } return ( - deriveRepositoryScopedKey(project, groupingMode) ?? + deriveRepositoryScopedKey(project) ?? derivePhysicalProjectKey(project) ?? scopedProjectKey(scopeProjectRef(project.environmentId, project.id)) ); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 3491103da94f..e9a098b2fa3c 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -52,6 +52,9 @@ export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; +// "repository_path" is a legacy alias of "repository": both group checkouts of +// one repository path and keep nested workspaces separate. Stored preferences +// still carry it, so it stays decodable. export const SidebarProjectGroupingMode = Schema.Literals([ "repository", "repository_path",