mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 12:35:35 +02:00
fix(projects): show owner/repo label for GitHub resource URLs (MUL-5221) (#5795)
* fix(projects): show owner/repo label for GitHub resource URLs instead of truncated full URL Long GitHub URLs in the project-resources sidebar are end-truncated by CSS `truncate`, making similar repos like org/very-long-repo-a and org/very-long-repo-b indistinguishable from the visible prefix alone. Extract just the `owner/repo` segment from the URL for display; keep the full URL in the tooltip so it remains accessible on hover. Applied to both the resource row and the repository picker popover. Custom labels are unaffected. Fixes #5771 Co-authored-by: multica-agent <github@multica.ai> * fix(projects): strip .git suffix and handle scp SSH URLs in githubShortLabel; add middle-truncation for long repo names Addresses #5795 review feedback: - Strip .git suffix (example-org/very-long-repo-name.git → without .git) - Handle git@github.com:owner/repo.git (scp shorthand) which new URL() rejects - Add midTruncate() so repos sharing a long common prefix are distinguishable by their tail (customer-alpha vs customer-beta) rather than being end-truncated identically by CSS Fixes #5771. Co-authored-by: multica-agent <github@multica.ai> * refactor(projects): share githubShortLabel across project resource UIs Move githubShortLabel/midTruncate out of project-resources-section into packages/views/common/github-url.ts so the create-project repository picker reuses the same owner/repo shortening (it was still rendering full URLs) and recognize the www.github.com host. Add github-url.test.ts covering the https / ssh / scp / enterprise / malformed cases plus middle-truncation. Addresses #5795 review feedback: shared helper, create-project coverage, tests. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai> Co-authored-by: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com>
This commit is contained in:
87
packages/views/common/github-url.test.ts
Normal file
87
packages/views/common/github-url.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
35
packages/views/common/github-url.ts
Normal file
35
packages/views/common/github-url.ts
Normal file
@@ -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)}`;
|
||||
}
|
||||
@@ -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({
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className={cn("truncate flex-1 text-left", className)}>
|
||||
{url}
|
||||
{githubShortLabel(url)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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 }) {
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className="truncate flex-1">{repo.url}</span>
|
||||
<span className="truncate flex-1">{githubShortLabel(repo.url)}</span>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="top">{repo.url}</TooltipContent>
|
||||
@@ -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 (
|
||||
<div className="flex items-center gap-2 text-xs group">
|
||||
|
||||
Reference in New Issue
Block a user