| author | |
| committer | |
| log | 6947f0d9d85dd92e74b13a70bd3d97a27d899506 |
| tree | b68b152b9a6d24ecd1044545b982fa27e584b1ac |
| parent | 84cb13412ebebd6533e8a3de1579c40ae001573e |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 47 | resolveOnboardingLandingProject, |
| 48 | 48 | resolveOnboardingProjectId, |
| 49 | 49 | type OnboardingProjectGroup, |
| 50 | splitOnboardingProjectPath, | |
| 50 | 51 | } from "../../onboarding/projectImport.logic"; |
| 51 | 52 | import { |
| 52 | 53 | getOnboardingProviderState, |
| ... | ... | @@ -1622,7 +1623,7 @@ function ImportCandidateList({ |
| 1622 | 1623 | <ImportCandidateRow |
| 1623 | 1624 | key={candidate.key} |
| 1624 | 1625 | candidate={candidate} |
| 1625 | label={candidate.path} | |
| 1626 | path={candidate.path} | |
| 1626 | 1627 | nested |
| 1627 | 1628 | checked={selectedKeys.has(candidate.key)} |
| 1628 | 1629 | onCheckedChange={(checked) => setKeys([candidate.key], checked)} |
| ... | ... | @@ -1653,7 +1654,7 @@ function ImportRepositoryGroup({ |
| 1653 | 1654 | <ImportCandidateRow |
| 1654 | 1655 | candidate={only} |
| 1655 | 1656 | label={group.label} |
| 1656 | {...(group.repository === null ? {} : { secondary: only.path })} | |
| 1657 | path={only.path} | |
| 1657 | 1658 | checked={selectedKeys.has(only.key)} |
| 1658 | 1659 | onCheckedChange={(checked) => onToggle([only.key], checked)} |
| 1659 | 1660 | /> |
| ... | ... | @@ -1682,7 +1683,7 @@ function ImportRepositoryGroup({ |
| 1682 | 1683 | <ImportCandidateRow |
| 1683 | 1684 | key={candidate.key} |
| 1684 | 1685 | candidate={candidate} |
| 1685 | label={candidate.path} | |
| 1686 | path={candidate.path} | |
| 1686 | 1687 | nested |
| 1687 | 1688 | checked={selectedKeys.has(candidate.key)} |
| 1688 | 1689 | onCheckedChange={(checked) => onToggle([candidate.key], checked)} |
| ... | ... | @@ -1693,17 +1694,39 @@ function ImportRepositoryGroup({ |
| 1693 | 1694 | ); |
| 1694 | 1695 | } |
| 1695 | 1696 | |
| 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 | */ | |
| 1701 | function 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 | ||
| 1696 | 1717 | function ImportCandidateRow({ |
| 1697 | 1718 | candidate, |
| 1698 | 1719 | label, |
| 1699 | secondary, | |
| 1720 | path, | |
| 1700 | 1721 | nested = false, |
| 1701 | 1722 | checked, |
| 1702 | 1723 | onCheckedChange, |
| 1703 | 1724 | }: { |
| 1704 | 1725 | 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; | |
| 1707 | 1730 | readonly nested?: boolean; |
| 1708 | 1731 | readonly checked: boolean; |
| 1709 | 1732 | readonly onCheckedChange: (checked: boolean) => void; |
| ... | ... | @@ -1720,11 +1743,14 @@ function ImportCandidateRow({ |
| 1720 | 1743 | <TooltipTrigger |
| 1721 | 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")}> | |
| 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 | /> | |
| 1728 | 1754 | ) : null} |
| 1729 | 1755 | </TooltipTrigger> |
| 1730 | 1756 | <TooltipPopup variant="code">{candidate.path}</TooltipPopup> |
apps/web/src/onboarding/projectImport.logic.test.ts+39| ... | ... | @@ -7,6 +7,7 @@ import { |
| 7 | 7 | onboardingProjectKey, |
| 8 | 8 | resolveOnboardingLandingProject, |
| 9 | 9 | resolveOnboardingProjectId, |
| 10 | splitOnboardingProjectPath, | |
| 10 | 11 | } from "./projectImport.logic"; |
| 11 | 12 | |
| 12 | 13 | const now = Date.parse("2026-08-22T12:00:00.000Z"); |
| ... | ... | @@ -349,3 +350,41 @@ describe("projects on multiple computers", () => { |
| 349 | 350 | expect(partitionOnboardingProjects([first, second], now).recent).toEqual([first, second]); |
| 350 | 351 | }); |
| 351 | 352 | }); |
| 353 | ||
| 354 | describe("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 | 135 | return undefined; |
| 136 | 136 | } |
| 137 | 137 | |
| 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 | */ | |
| 144 | export 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 | 158 | /** Paths identify projects only within the computer that owns them. */ |
| 139 | 159 | export function onboardingProjectKey(environmentId: EnvironmentId, path: string): string { |
| 140 | 160 | return JSON.stringify([environmentId, path]); |