authorgravatar for 47115683+tachytelicdetonation@users.noreply.github.comtachytelicdetonation <47115683+tachytelicdetonation@users.noreply.github.com> 2026-10-13 15:24:04-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-13 15:24:04-05:00
log6947f0d9d85dd92e74b13a70bd3d97a27d899506
treeb68b152b9a6d24ecd1044545b982fa27e584b1ac
parent84cb13412ebebd6533e8a3de1579c40ae001573e
signature Signed by PGP key B5690EEEBB952194

fix(web): keep clone folder names readable in the setup project picker (#12474)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Yash Singh <saiansh2525@gmail.com>

3 files changed, 96 insertions(+), 11 deletions(-)

apps/web/src/components/onboarding/WelcomeWizard.tsx+37-11
......@@ -47,6 +47,7 @@ import {
4747 resolveOnboardingLandingProject,
4848 resolveOnboardingProjectId,
4949 type OnboardingProjectGroup,
50 splitOnboardingProjectPath,
5051} from "../../onboarding/projectImport.logic";
5152import {
5253 getOnboardingProviderState,
......@@ -1622,7 +1623,7 @@ function ImportCandidateList({
16221623 <ImportCandidateRow
16231624 key={candidate.key}
16241625 candidate={candidate}
1625 label={candidate.path}
1626 path={candidate.path}
16261627 nested
16271628 checked={selectedKeys.has(candidate.key)}
16281629 onCheckedChange={(checked) => setKeys([candidate.key], checked)}
......@@ -1653,7 +1654,7 @@ function ImportRepositoryGroup({
16531654 <ImportCandidateRow
16541655 candidate={only}
16551656 label={group.label}
1656 {...(group.repository === null ? {} : { secondary: only.path })}
1657 path={only.path}
16571658 checked={selectedKeys.has(only.key)}
16581659 onCheckedChange={(checked) => onToggle([only.key], checked)}
16591660 />
......@@ -1682,7 +1683,7 @@ function ImportRepositoryGroup({
16821683 <ImportCandidateRow
16831684 key={candidate.key}
16841685 candidate={candidate}
1685 label={candidate.path}
1686 path={candidate.path}
16861687 nested
16871688 checked={selectedKeys.has(candidate.key)}
16881689 onCheckedChange={(checked) => onToggle([candidate.key], checked)}
......@@ -1693,17 +1694,39 @@ function ImportRepositoryGroup({
16931694 );
16941695}
16951696
1697/**
1698 * Clones of one repository and plain folders differ only by folder name, so
1699 * the name stays visible and the shared parent path truncates first.
1700 */
1701function ProjectPathText({
1702 path,
1703 className,
1704}: {
1705 readonly path: string;
1706 readonly className?: string;
1707}) {
1708 const { name, parent } = splitOnboardingProjectPath(path);
1709 return (
1710 <span className={cn("flex min-w-0 items-baseline font-mono", className)}>
1711 {parent.length > 0 ? <span className="truncate">{parent}</span> : null}
1712 <span className="max-w-full shrink-0 truncate">{name}</span>
1713 </span>
1714 );
1715}
1716
16961717function ImportCandidateRow({
16971718 candidate,
16981719 label,
1699 secondary,
1720 path,
17001721 nested = false,
17011722 checked,
17021723 onCheckedChange,
17031724}: {
17041725 readonly candidate: ImportCandidate;
1705 readonly label: string;
1706 readonly secondary?: string;
1726 /** Text primary label. Nested rows omit it and are labelled by their path. */
1727 readonly label?: string;
1728 /** Shown with the folder name kept visible while the parent path truncates. */
1729 readonly path?: string;
17071730 readonly nested?: boolean;
17081731 readonly checked: boolean;
17091732 readonly onCheckedChange: (checked: boolean) => void;
......@@ -1720,11 +1743,14 @@ function ImportCandidateRow({
17201743 <TooltipTrigger
17211744 render={<span className="flex min-w-0 flex-1 items-baseline gap-2 truncate" />}
17221745 >
1723 <span className={cn("truncate", nested ? "font-mono text-xs" : "text-sm font-medium")}>
1724 {label}
1725 </span>
1726 {secondary !== undefined ? (
1727 <span className="truncate font-mono text-2xs text-muted-foreground">{secondary}</span>
1746 {label !== undefined ? (
1747 <span className="truncate text-sm font-medium">{label}</span>
1748 ) : null}
1749 {path !== undefined ? (
1750 <ProjectPathText
1751 path={path}
1752 className={label === undefined ? "text-xs" : "text-2xs text-muted-foreground"}
1753 />
17281754 ) : null}
17291755 </TooltipTrigger>
17301756 <TooltipPopup variant="code">{candidate.path}</TooltipPopup>
apps/web/src/onboarding/projectImport.logic.test.ts+39
......@@ -7,6 +7,7 @@ import {
77 onboardingProjectKey,
88 resolveOnboardingLandingProject,
99 resolveOnboardingProjectId,
10 splitOnboardingProjectPath,
1011} from "./projectImport.logic";
1112
1213const now = Date.parse("2026-08-22T12:00:00.000Z");
......@@ -349,3 +350,41 @@ describe("projects on multiple computers", () => {
349350 expect(partitionOnboardingProjects([first, second], now).recent).toEqual([first, second]);
350351 });
351352});
353
354describe("splitOnboardingProjectPath", () => {
355 it("keeps sibling checkouts that share a prefix distinguishable by name", () => {
356 const left = splitOnboardingProjectPath("/Users/me/Projects/clients/acme/t3code");
357 const right = splitOnboardingProjectPath("/Users/me/Projects/clients/acme/t3code-worktree");
358 expect(left).toEqual({ name: "t3code", parent: "/Users/me/Projects/clients/acme/" });
359 expect(right).toEqual({ name: "t3code-worktree", parent: "/Users/me/Projects/clients/acme/" });
360 expect(left.name).not.toBe(right.name);
361 });
362
363 it("splits Windows paths and ignores a trailing separator", () => {
364 expect(splitOnboardingProjectPath("C:\\Users\\me\\t3code\\")).toEqual({
365 name: "t3code",
366 parent: "C:\\Users\\me\\",
367 });
368 });
369
370 it("keeps a root or bare name whole", () => {
371 expect(splitOnboardingProjectPath("/")).toEqual({ name: "/", parent: "" });
372 expect(splitOnboardingProjectPath("/t3code")).toEqual({ name: "t3code", parent: "/" });
373 expect(splitOnboardingProjectPath("t3code")).toEqual({ name: "t3code", parent: "" });
374 expect(splitOnboardingProjectPath("t3code/")).toEqual({ name: "t3code", parent: "" });
375 });
376
377 it.each(["C:\\", "C:/", "\\\\server\\share", "\\\\server\\share\\", "//server/share/"])(
378 "keeps the filesystem root %s whole",
379 (path) => {
380 expect(splitOnboardingProjectPath(path)).toEqual({ name: path, parent: "" });
381 },
382 );
383
384 it("splits a folder below a network share", () => {
385 expect(splitOnboardingProjectPath("\\\\server\\share\\project\\")).toEqual({
386 name: "project",
387 parent: "\\\\server\\share\\",
388 });
389 });
390});
apps/web/src/onboarding/projectImport.logic.ts+20
......@@ -135,6 +135,26 @@ export function resolveOnboardingLandingProject<T>(
135135 return undefined;
136136}
137137
138/**
139 * Splits a scan path into the folder name and the directory above it (with
140 * its trailing separator), so a picker row can keep the name visible and let
141 * the shared prefix truncate. Handles both separators because candidates
142 * come from any environment.
143 */
144export function splitOnboardingProjectPath(path: string): {
145 readonly name: string;
146 readonly parent: string;
147} {
148 if (/^[A-Za-z]:[\\/]+$/.test(path) || /^[\\/]{2}[^\\/]+[\\/][^\\/]+[\\/]*$/.test(path)) {
149 return { name: path, parent: "" };
150 }
151 const trimmed = path.replace(/[\\/]+$/, "");
152 const separatorIndex = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
153 if (trimmed.length === 0) return { name: path, parent: "" };
154 if (separatorIndex < 0) return { name: trimmed, parent: "" };
155 return { name: trimmed.slice(separatorIndex + 1), parent: trimmed.slice(0, separatorIndex + 1) };
156}
157
138158/** Paths identify projects only within the computer that owns them. */
139159export function onboardingProjectKey(environmentId: EnvironmentId, path: string): string {
140160 return JSON.stringify([environmentId, path]);