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 {...@@ -47,6 +47,7 @@ import {
47 resolveOnboardingLandingProject,47 resolveOnboardingLandingProject,
48 resolveOnboardingProjectId,48 resolveOnboardingProjectId,
49 type OnboardingProjectGroup,49 type OnboardingProjectGroup,
50 splitOnboardingProjectPath,
50} from "../../onboarding/projectImport.logic";51} from "../../onboarding/projectImport.logic";
51import {52import {
52 getOnboardingProviderState,53 getOnboardingProviderState,
...@@ -1622,7 +1623,7 @@ function ImportCandidateList({...@@ -1622,7 +1623,7 @@ function ImportCandidateList({
1622 <ImportCandidateRow1623 <ImportCandidateRow
1623 key={candidate.key}1624 key={candidate.key}
1624 candidate={candidate}1625 candidate={candidate}
1625 label={candidate.path}1626 path={candidate.path}
1626 nested1627 nested
1627 checked={selectedKeys.has(candidate.key)}1628 checked={selectedKeys.has(candidate.key)}
1628 onCheckedChange={(checked) => setKeys([candidate.key], checked)}1629 onCheckedChange={(checked) => setKeys([candidate.key], checked)}
...@@ -1653,7 +1654,7 @@ function ImportRepositoryGroup({...@@ -1653,7 +1654,7 @@ function ImportRepositoryGroup({
1653 <ImportCandidateRow1654 <ImportCandidateRow
1654 candidate={only}1655 candidate={only}
1655 label={group.label}1656 label={group.label}
1656 {...(group.repository === null ? {} : { secondary: only.path })}1657 path={only.path}
1657 checked={selectedKeys.has(only.key)}1658 checked={selectedKeys.has(only.key)}
1658 onCheckedChange={(checked) => onToggle([only.key], checked)}1659 onCheckedChange={(checked) => onToggle([only.key], checked)}
1659 />1660 />
...@@ -1682,7 +1683,7 @@ function ImportRepositoryGroup({...@@ -1682,7 +1683,7 @@ function ImportRepositoryGroup({
1682 <ImportCandidateRow1683 <ImportCandidateRow
1683 key={candidate.key}1684 key={candidate.key}
1684 candidate={candidate}1685 candidate={candidate}
1685 label={candidate.path}1686 path={candidate.path}
1686 nested1687 nested
1687 checked={selectedKeys.has(candidate.key)}1688 checked={selectedKeys.has(candidate.key)}
1688 onCheckedChange={(checked) => onToggle([candidate.key], checked)}1689 onCheckedChange={(checked) => onToggle([candidate.key], checked)}
...@@ -1693,17 +1694,39 @@ function ImportRepositoryGroup({...@@ -1693,17 +1694,39 @@ function ImportRepositoryGroup({
1693 );1694 );
1694}1695}
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
1696function ImportCandidateRow({1717function ImportCandidateRow({
1697 candidate,1718 candidate,
1698 label,1719 label,
1699 secondary,1720 path,
1700 nested = false,1721 nested = false,
1701 checked,1722 checked,
1702 onCheckedChange,1723 onCheckedChange,
1703}: {1724}: {
1704 readonly candidate: ImportCandidate;1725 readonly candidate: ImportCandidate;
1705 readonly label: string;1726 /** Text primary label. Nested rows omit it and are labelled by their path. */
1706 readonly secondary?: string;1727 readonly label?: string;
1728 /** Shown with the folder name kept visible while the parent path truncates. */
1729 readonly path?: string;
1707 readonly nested?: boolean;1730 readonly nested?: boolean;
1708 readonly checked: boolean;1731 readonly checked: boolean;
1709 readonly onCheckedChange: (checked: boolean) => void;1732 readonly onCheckedChange: (checked: boolean) => void;
...@@ -1720,11 +1743,14 @@ function ImportCandidateRow({...@@ -1720,11 +1743,14 @@ function ImportCandidateRow({
1720 <TooltipTrigger1743 <TooltipTrigger
1721 render={<span className="flex min-w-0 flex-1 items-baseline gap-2 truncate" />}1744 render={<span className="flex min-w-0 flex-1 items-baseline gap-2 truncate" />}
1722 >1745 >
1723 <span className={cn("truncate", nested ? "font-mono text-xs" : "text-sm font-medium")}>1746 {label !== undefined ? (
1724 {label}1747 <span className="truncate text-sm font-medium">{label}</span>
1725 </span>1748 ) : null}
1726 {secondary !== undefined ? (1749 {path !== undefined ? (
1727 <span className="truncate font-mono text-2xs text-muted-foreground">{secondary}</span>1750 <ProjectPathText
1751 path={path}
1752 className={label === undefined ? "text-xs" : "text-2xs text-muted-foreground"}
1753 />
1728 ) : null}1754 ) : null}
1729 </TooltipTrigger>1755 </TooltipTrigger>
1730 <TooltipPopup variant="code">{candidate.path}</TooltipPopup>1756 <TooltipPopup variant="code">{candidate.path}</TooltipPopup>
apps/web/src/onboarding/projectImport.logic.test.ts+39
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7 onboardingProjectKey,7 onboardingProjectKey,
8 resolveOnboardingLandingProject,8 resolveOnboardingLandingProject,
9 resolveOnboardingProjectId,9 resolveOnboardingProjectId,
10 splitOnboardingProjectPath,
10} from "./projectImport.logic";11} from "./projectImport.logic";
1112
12const now = Date.parse("2026-08-22T12:00:00.000Z");13const now = Date.parse("2026-08-22T12:00:00.000Z");
...@@ -349,3 +350,41 @@ describe("projects on multiple computers", () => {...@@ -349,3 +350,41 @@ describe("projects on multiple computers", () => {
349 expect(partitionOnboardingProjects([first, second], now).recent).toEqual([first, second]);350 expect(partitionOnboardingProjects([first, second], now).recent).toEqual([first, second]);
350 });351 });
351});352});
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>(...@@ -135,6 +135,26 @@ export function resolveOnboardingLandingProject<T>(
135 return undefined;135 return undefined;
136}136}
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
138/** Paths identify projects only within the computer that owns them. */158/** Paths identify projects only within the computer that owns them. */
139export function onboardingProjectKey(environmentId: EnvironmentId, path: string): string {159export function onboardingProjectKey(environmentId: EnvironmentId, path: string): string {
140 return JSON.stringify([environmentId, path]);160 return JSON.stringify([environmentId, path]);