diff --git a/packages/views/common/github-url.test.ts b/packages/views/common/github-url.test.ts new file mode 100644 index 000000000..e12ab4b78 --- /dev/null +++ b/packages/views/common/github-url.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from "vitest"; +import { githubShortLabel, midTruncate } from "./github-url"; + +describe("githubShortLabel", () => { + it("extracts owner/repo from an https URL", () => { + expect(githubShortLabel("https://github.com/octocat/hello-world")).toBe( + "octocat/hello-world", + ); + }); + + it("ignores extra path segments after owner/repo", () => { + expect( + githubShortLabel("https://github.com/octocat/hello-world/tree/main"), + ).toBe("octocat/hello-world"); + }); + + it("strips a trailing .git suffix", () => { + expect(githubShortLabel("https://github.com/octocat/hello-world.git")).toBe( + "octocat/hello-world", + ); + }); + + it("handles the www.github.com host", () => { + expect(githubShortLabel("https://www.github.com/octocat/hello-world")).toBe( + "octocat/hello-world", + ); + }); + + it("handles ssh:// URLs and strips .git", () => { + expect( + githubShortLabel("ssh://git@github.com/octocat/hello-world.git"), + ).toBe("octocat/hello-world"); + }); + + it("handles scp-style shorthand (git@github.com:owner/repo.git)", () => { + expect(githubShortLabel("git@github.com:octocat/hello-world.git")).toBe( + "octocat/hello-world", + ); + }); + + it("handles scp-style shorthand without a .git suffix", () => { + expect(githubShortLabel("git@github.com:octocat/hello-world")).toBe( + "octocat/hello-world", + ); + }); + + it("middle-truncates so a shared long prefix stays distinguishable", () => { + const a = githubShortLabel( + "https://github.com/example-organization/very-long-repository-name-for-customer-alpha.git", + ); + const b = githubShortLabel( + "https://github.com/example-organization/very-long-repository-name-for-customer-beta.git", + ); + expect(a).not.toBe(b); + expect(a.length).toBeLessThanOrEqual(40); + expect(a.endsWith("alpha")).toBe(true); + expect(b.endsWith("beta")).toBe(true); + }); + + it("returns enterprise-host URLs unchanged", () => { + const url = "https://github.enterprise.com/octocat/hello-world"; + expect(githubShortLabel(url)).toBe(url); + }); + + it("returns malformed input unchanged", () => { + expect(githubShortLabel("not a url")).toBe("not a url"); + }); + + it("returns a github URL without a repo segment unchanged", () => { + const url = "https://github.com/octocat"; + expect(githubShortLabel(url)).toBe(url); + }); +}); + +describe("midTruncate", () => { + it("leaves short strings untouched", () => { + expect(midTruncate("short")).toBe("short"); + }); + + it("caps the result at maxLen and inserts an ellipsis", () => { + const out = midTruncate("a".repeat(100), 21); + expect(out.length).toBe(21); + expect(out).toContain("…"); + expect(out.startsWith("aaaaaaaaaa")).toBe(true); + expect(out.endsWith("aaaaaaaaaa")).toBe(true); + }); +}); diff --git a/packages/views/common/github-url.ts b/packages/views/common/github-url.ts new file mode 100644 index 000000000..e4898ed43 --- /dev/null +++ b/packages/views/common/github-url.ts @@ -0,0 +1,35 @@ +// For GitHub URLs, return "owner/repo" so truncated display labels stay +// meaningful when URLs share a long prefix. Handles: +// https://github.com/owner/repo[/...] +// https://www.github.com/owner/repo +// ssh://git@github.com/owner/repo.git +// git@github.com:owner/repo.git (scp shorthand) +// The .git suffix is stripped in all cases. When the resulting label is still +// long, middle-truncation keeps the distinguishing tail visible (the case where +// many repos share a long common prefix, e.g. customer-alpha vs customer-beta). +// Non-GitHub input (enterprise hosts, malformed strings) is returned unchanged. +export function githubShortLabel(url: string): string { + // scp shorthand — new URL() throws on these, so match before the try block. + const scp = url.match(/^(?:[^@/]+@)?github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/); + if (scp) return midTruncate(`${scp[1]}/${scp[2]}`); + try { + const u = new URL(url); + if (u.hostname === "github.com" || u.hostname === "www.github.com") { + const [owner, repo] = u.pathname.split("/").filter(Boolean); + if (owner && repo) return midTruncate(`${owner}/${repo.replace(/\.git$/, "")}`); + } + } catch { + // not a parseable URL — fall through and return as-is + } + return url; +} + +// Middle-truncate a string to at most maxLen characters, preserving both the +// leading and trailing portions. The trailing portion is what distinguishes +// repos that share a long common prefix (e.g. customer-alpha vs customer-beta). +export function midTruncate(s: string, maxLen = 40): string { + if (s.length <= maxLen) return s; + const tail = Math.floor((maxLen - 1) / 2); + const head = maxLen - 1 - tail; + return `${s.slice(0, head)}…${s.slice(-tail)}`; +} diff --git a/packages/views/modals/create-project.tsx b/packages/views/modals/create-project.tsx index 96efb8638..9885f09e2 100644 --- a/packages/views/modals/create-project.tsx +++ b/packages/views/modals/create-project.tsx @@ -60,6 +60,7 @@ import { import { ProjectStartDatePicker } from "../projects/components/project-start-date-picker"; import { ProjectDueDatePicker } from "../projects/components/project-due-date-picker"; import { PillButton } from "../common/pill-button"; +import { githubShortLabel } from "../common/github-url"; import { isDesktopShell, pickDirectory, @@ -79,7 +80,7 @@ function RepoUrlText({ - {url} + {githubShortLabel(url)} } /> diff --git a/packages/views/projects/components/project-resources-section.tsx b/packages/views/projects/components/project-resources-section.tsx index a255db55b..97c9b585e 100644 --- a/packages/views/projects/components/project-resources-section.tsx +++ b/packages/views/projects/components/project-resources-section.tsx @@ -44,6 +44,7 @@ import { type ValidateLocalDirectoryResult, } from "../../platform"; import { useT } from "../../i18n"; +import { githubShortLabel } from "../../common/github-url"; // Project Resources sidebar section. // @@ -326,7 +327,7 @@ export function ProjectResourcesSection({ projectId }: { projectId: string }) { {repo.url} + {githubShortLabel(repo.url)} } /> {repo.url} @@ -408,7 +409,7 @@ function ResourceRow({ const { t } = useT("projects"); if (isGithubRef(resource)) { const ref = resource.resource_ref; - const display = resource.label || (ref.ref ? `${ref.url} @ ${ref.ref}` : ref.url); + const display = resource.label || (ref.ref ? `${githubShortLabel(ref.url)} @ ${ref.ref}` : githubShortLabel(ref.url)); const tooltip = ref.ref ? `${ref.url}\nref: ${ref.ref}` : ref.url; return (