diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 5c6517e154a1..b1b366df8997 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -39,6 +39,7 @@ import { resolveOnboardingLandingProject, resolveOnboardingProjectId, type OnboardingProjectGroup, + splitOnboardingProjectPath, } from "../../onboarding/projectImport.logic"; import { getOnboardingProviderState, @@ -1352,7 +1353,7 @@ function ImportCandidateList({ setKeys([candidate.key], checked)} @@ -1383,7 +1384,7 @@ function ImportRepositoryGroup({ onToggle([only.key], checked)} /> @@ -1412,7 +1413,7 @@ function ImportRepositoryGroup({ onToggle([candidate.key], checked)} @@ -1423,17 +1424,39 @@ function ImportRepositoryGroup({ ); } +/** + * Clones of one repository and plain folders differ only by folder name, so + * the name stays visible and the shared parent path truncates first. + */ +function ProjectPathText({ + path, + className, +}: { + readonly path: string; + readonly className?: string; +}) { + const { name, parent } = splitOnboardingProjectPath(path); + return ( + + {parent.length > 0 ? {parent} : null} + {name} + + ); +} + function ImportCandidateRow({ candidate, label, - secondary, + path, nested = false, checked, onCheckedChange, }: { readonly candidate: ImportCandidate; - readonly label: string; - readonly secondary?: string; + /** Text primary label. Nested rows omit it and are labelled by their path. */ + readonly label?: string; + /** Shown with the folder name kept visible while the parent path truncates. */ + readonly path?: string; readonly nested?: boolean; readonly checked: boolean; readonly onCheckedChange: (checked: boolean) => void; @@ -1450,13 +1473,14 @@ function ImportCandidateRow({ } > - - {label} - - {secondary !== undefined ? ( - - {secondary} - + {label !== undefined ? ( + {label} + ) : null} + {path !== undefined ? ( + ) : null} {candidate.path} diff --git a/apps/web/src/onboarding/projectImport.logic.test.ts b/apps/web/src/onboarding/projectImport.logic.test.ts index a675cae4a538..4847b191071a 100644 --- a/apps/web/src/onboarding/projectImport.logic.test.ts +++ b/apps/web/src/onboarding/projectImport.logic.test.ts @@ -7,6 +7,7 @@ import { onboardingProjectKey, resolveOnboardingLandingProject, resolveOnboardingProjectId, + splitOnboardingProjectPath, } from "./projectImport.logic"; const now = Date.parse("2026-08-22T12:00:00.000Z"); @@ -349,3 +350,41 @@ describe("projects on multiple computers", () => { expect(partitionOnboardingProjects([first, second], now).recent).toEqual([first, second]); }); }); + +describe("splitOnboardingProjectPath", () => { + it("keeps sibling checkouts that share a prefix distinguishable by name", () => { + const left = splitOnboardingProjectPath("/Users/me/Projects/clients/acme/t3code"); + const right = splitOnboardingProjectPath("/Users/me/Projects/clients/acme/t3code-worktree"); + expect(left).toEqual({ name: "t3code", parent: "/Users/me/Projects/clients/acme/" }); + expect(right).toEqual({ name: "t3code-worktree", parent: "/Users/me/Projects/clients/acme/" }); + expect(left.name).not.toBe(right.name); + }); + + it("splits Windows paths and ignores a trailing separator", () => { + expect(splitOnboardingProjectPath("C:\\Users\\me\\t3code\\")).toEqual({ + name: "t3code", + parent: "C:\\Users\\me\\", + }); + }); + + it("keeps a root or bare name whole", () => { + expect(splitOnboardingProjectPath("/")).toEqual({ name: "/", parent: "" }); + expect(splitOnboardingProjectPath("/t3code")).toEqual({ name: "t3code", parent: "/" }); + expect(splitOnboardingProjectPath("t3code")).toEqual({ name: "t3code", parent: "" }); + expect(splitOnboardingProjectPath("t3code/")).toEqual({ name: "t3code", parent: "" }); + }); + + it.each(["C:\\", "C:/", "\\\\server\\share", "\\\\server\\share\\", "//server/share/"])( + "keeps the filesystem root %s whole", + (path) => { + expect(splitOnboardingProjectPath(path)).toEqual({ name: path, parent: "" }); + }, + ); + + it("splits a folder below a network share", () => { + expect(splitOnboardingProjectPath("\\\\server\\share\\project\\")).toEqual({ + name: "project", + parent: "\\\\server\\share\\", + }); + }); +}); diff --git a/apps/web/src/onboarding/projectImport.logic.ts b/apps/web/src/onboarding/projectImport.logic.ts index d7044b98b6af..86f34754c32e 100644 --- a/apps/web/src/onboarding/projectImport.logic.ts +++ b/apps/web/src/onboarding/projectImport.logic.ts @@ -135,6 +135,26 @@ export function resolveOnboardingLandingProject( return undefined; } +/** + * Splits a scan path into the folder name and the directory above it (with + * its trailing separator), so a picker row can keep the name visible and let + * the shared prefix truncate. Handles both separators because candidates + * come from any environment. + */ +export function splitOnboardingProjectPath(path: string): { + readonly name: string; + readonly parent: string; +} { + if (/^[A-Za-z]:[\\/]+$/.test(path) || /^[\\/]{2}[^\\/]+[\\/][^\\/]+[\\/]*$/.test(path)) { + return { name: path, parent: "" }; + } + const trimmed = path.replace(/[\\/]+$/, ""); + const separatorIndex = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); + if (trimmed.length === 0) return { name: path, parent: "" }; + if (separatorIndex < 0) return { name: trimmed, parent: "" }; + return { name: trimmed.slice(separatorIndex + 1), parent: trimmed.slice(0, separatorIndex + 1) }; +} + /** Paths identify projects only within the computer that owns them. */ export function onboardingProjectKey(environmentId: EnvironmentId, path: string): string { return JSON.stringify([environmentId, path]);