From 27bcba46eb09075954349f882a2d7e25c137154a Mon Sep 17 00:00:00 2001 From: ShortArrow Date: Thu, 15 Oct 2026 17:05:36 +0900 Subject: [PATCH 1/2] feat(ui): copy a commit's id and copy or open its web page from the commit menu The web page covers github.com and gitlab.com, the hosts known by name (#15). --- CHANGELOG.md | 7 ++ docs/FEATURES.jp.md | 2 +- docs/FEATURES.md | 5 +- docs/ROADMAP.jp.md | 1 + docs/ROADMAP.md | 4 ++ frontend/e2e/graph.spec.ts | 40 +++++++++++ frontend/src/CommitMenu.tsx | 41 ++++++++++- frontend/src/RepoCard.tsx | 4 ++ frontend/src/api.ts | 3 + frontend/src/i18n.ts | 4 ++ frontend/src/stories/CommitMenu.stories.tsx | 10 +++ frontend/src/styles.css | 7 +- src/app/view.rs | 9 +++ src/git/repo.rs | 77 ++++++++++++++++++--- 14 files changed, 196 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 270d3e2..df32e36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- The commit menu, on a row or a graph node, copies the full commit id and, + when the origin is on github.com or gitlab.com, copies or opens the + commit's web page + ([#15](https://github.com/ShortArrow/gitreant/issues/15)). + ### Changed - "Diff all" is a toggle: pressed while every file's diff is open, it closes diff --git a/docs/FEATURES.jp.md b/docs/FEATURES.jp.md index e61b051..8b47099 100644 --- a/docs/FEATURES.jp.md +++ b/docs/FEATURES.jp.md @@ -17,7 +17,7 @@ gitreant でできることを分野別にまとめる。起動方法とコマ - **レーンと色はサーバで計算する。** チェックアウト中のブランチを左端の直線に置き、分岐は右へ、マージは右から合流する。ブランチの線は端から端まで同じ色で描く。 - **HEAD とマージ。** HEAD のコミットは中抜きの輪で描く。マージコミットのメッセージは薄く表示する。 -- **ノードの操作。** ノードにホバーするとハッシュを表示し、クリックでそのコミットを選ぶ。右クリックでコミット行と同じメニュー(タグやブランチの作成)を開く。ハッシュのクリックでフル ID をコピーする。 +- **ノードの操作。** ノードにホバーするとハッシュを表示し、クリックでそのコミットを選ぶ。右クリックでコミット行と同じメニューを開き、フル ID のコピー、タグやブランチの作成ができる。origin が github.com か gitlab.com にあれば、コミットの Web ページの URL をコピーしたり、そのページを開いたりもできる。ハッシュのクリックでフル ID をコピーする。 - **バッジ。** ブランチ・タグ・stash を別々のバッジで描く。リモートのブランチやリモートに push 済みのタグには、リモート名の区切りを付ける。 - **stash。** stash は 1 つのノードにたたんで描く。設定で、index と未追跡ファイルの内部コミットを点線で開いて見せられる。 - **サブモジュールのグラフ。** サブモジュールを持つリポジトリでは、サブモジュールのグラフを左側に並べ、サブモジュールのポインタを動かしたコミットどうしを点線で結ぶ。設定で切り替えられる。 diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 4de1205..f810e6a 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -36,8 +36,9 @@ see the [README](../README.md); design decisions live in [adr/](adr/). - **HEAD and merges.** The HEAD commit is a hollow ring; merge commit messages are dimmed. - **Nodes.** Hovering a node shows its hash and clicking selects the - commit. Its context menu is the commit row's (create a tag or a branch). - Clicking a hash copies the full id. + commit. Its context menu is the commit row's: copy the full id, create a + tag or a branch, and, when the origin is on github.com or gitlab.com, copy + or open the commit's web page. Clicking a hash copies the full id. - **Badges.** Branches, tags and the stash render as distinct badges. Remote branches, and tags pushed to a remote, carry the remote's name as a separate segment. diff --git a/docs/ROADMAP.jp.md b/docs/ROADMAP.jp.md index ba436de..04a3cdf 100644 --- a/docs/ROADMAP.jp.md +++ b/docs/ROADMAP.jp.md @@ -51,6 +51,7 @@ - Codeberg / Forgejo / Gitea: `fj` または REST API - **縮退規則**: CLI 不在・未認証・対象外ホストでは静かに無効(現行の gh と同じ) - リモート URL 正規化(`github_web_url`)をフォージ別のプロバイダに一般化するところから始める +- **自前で立てたホスト**: コミットメニューの Web ページ(`commit_url_base`)は、github.com と gitlab.com を名前で判別するだけである。自前の GitLab や GitHub Enterprise はホスト名だけでは見分けられないので、ホストとそのフォージを指定する設定が要る ## 作業ツリー(ADR 0027 / 0028 の残課題) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index fbe5fce..47ae943 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -67,6 +67,10 @@ keyed off the remote URL's host. on unknown hosts (same as gh today) - Start by generalizing the remote-URL normalization (`github_web_url`) into per-forge providers +- **Self-hosted hosts**: the commit menu's web page (`commit_url_base`) + knows github.com and gitlab.com by name only. A self-hosted GitLab or + GitHub Enterprise cannot be told from other hosts by its name, so it needs + a setting that names the host and its forge ## Working tree (follow-ups to ADR 0027 / 0028) diff --git a/frontend/e2e/graph.spec.ts b/frontend/e2e/graph.spec.ts index 76e3ccf..25261a7 100644 --- a/frontend/e2e/graph.spec.ts +++ b/frontend/e2e/graph.spec.ts @@ -1695,6 +1695,42 @@ test.describe.serial("gitreant UI", () => { await expect(page.locator('[data-repo-name="repoK"]')).toHaveCount(0); }); + test("the commit menu copies the hash and the commit's web page", async ({ + page, + context, + }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await page.getByTestId("add-input").fill(fixtures.repoK); + await page.getByTestId("add-submit").click(); + await expect(page.locator('[data-tab-name="repoK"]')).toBeVisible(); + + const node = page.getByTestId("graph-node").first(); + const menu = page.getByTestId("commit-menu"); + await node.click({ button: "right" }); + await menu.getByTestId("commit-menu-copy-hash").click(); + await expect(menu).toHaveCount(0); + const id = await page.evaluate(() => navigator.clipboard.readText()); + expect(id).toMatch(/^[0-9a-f]{40}$/); + + // repoK's origin is on github.com: its commit page can be copied or + // opened in a new tab. + const url = `https://github.com/example/repoK/commit/${id}`; + await node.click({ button: "right" }); + await menu.getByTestId("commit-menu-copy-url").click(); + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(url); + await node.click({ button: "right" }); + const open = menu.getByTestId("commit-menu-open-url"); + await expect(open).toHaveAttribute("href", url); + await expect(open).toHaveAttribute("target", "_blank"); + await page.keyboard.press("Escape"); + + // Restore the served set. + const repoK = page.locator('[data-repo-name="repoK"]'); + await repoK.hover(); + await repoK.getByTestId("repo-remove").click(); + await expect(page.locator('[data-repo-name="repoK"]')).toHaveCount(0); + }); + test("the branch badge menu copies, checks out and merges", async ({ page, context, @@ -2068,6 +2104,10 @@ test.describe.serial("gitreant UI", () => { const menu = page.getByTestId("commit-menu"); await expect(menu).toBeVisible(); await expect(menu.locator(".ctx-menu-note")).toHaveText(shown); + // repoA has no origin on a known host, so no web page to offer. + await expect(menu.getByTestId("commit-menu-copy-hash")).toBeVisible(); + await expect(menu.getByTestId("commit-menu-copy-url")).toHaveCount(0); + await expect(menu.getByTestId("commit-menu-open-url")).toHaveCount(0); await page.keyboard.press("Escape"); await expect(menu).toHaveCount(0); diff --git a/frontend/src/CommitMenu.tsx b/frontend/src/CommitMenu.tsx index 08a6e8c..1c06ebc 100644 --- a/frontend/src/CommitMenu.tsx +++ b/frontend/src/CommitMenu.tsx @@ -3,14 +3,16 @@ import { shortId } from "./graph"; import { useT } from "./settings"; /** - * Context menu on a commit row: a list of actions first (create tag, create - * branch), and a name input once one is chosen — so further commands can - * slot in without fighting an always-open input. + * Context menu on a commit row or node: a list of actions first (copy the + * id, copy or open the commit's web page, create tag, create branch), and a + * name input once a ref is to be created — so further commands can slot in + * without fighting an always-open input. */ export function CommitMenu({ x, y, commit, + webUrl, onCreateTag, onCreateBranch, onClose, @@ -19,6 +21,8 @@ export function CommitMenu({ y: number; /** The full commit id the actions apply to. */ commit: string; + /** The commit's page on the origin's host; absent when it has none. */ + webUrl?: string; onCreateTag: (name: string) => void; onCreateBranch: (name: string) => void; onClose: () => void; @@ -26,6 +30,10 @@ export function CommitMenu({ const t = useT(); const [stage, setStage] = useState<"tag" | "branch" | null>(null); const [name, setName] = useState(""); + const copyAndClose = (text: string) => { + navigator.clipboard?.writeText(text).catch(() => {}); + onClose(); + }; useEffect(() => { const close = (e: MouseEvent | KeyboardEvent) => { @@ -50,6 +58,33 @@ export function CommitMenu({ {shortId(commit)} {stage === null ? ( <> + + {webUrl && ( + <> + + + {t("openCommitUrl")} + + + )} {webUrl && ( <> @@ -80,6 +80,7 @@ export function CommitMenu({ target="_blank" rel="noreferrer" onClick={onClose} + onAuxClick={onClose} > {t("openCommitUrl")} diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index d94e499..854b84f 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -135,6 +135,7 @@ const en = { tagNamePlaceholder: "Tag name…", createBranch: "Create branch", branchNamePlaceholder: "Branch name…", + copyCommitId: "Copy commit id", copyCommitUrl: "Copy web page URL", openCommitUrl: "Open web page", // Commit details @@ -295,8 +296,9 @@ const ja: Record = { tagNamePlaceholder: "タグ名…", createBranch: "ブランチを作成", branchNamePlaceholder: "ブランチ名…", - copyCommitUrl: "Web ページの URL をコピー", - openCommitUrl: "Web ページを開く", + copyCommitId: "コミットIDをコピー", + copyCommitUrl: "WebページのURLをコピー", + openCommitUrl: "Webページを開く", loading: "読み込み中…", expandBody: "全文を表示", collapseBody: "本文をたたむ", diff --git a/src/git/repo.rs b/src/git/repo.rs index ad4b7b1..2b9067e 100644 --- a/src/git/repo.rs +++ b/src/git/repo.rs @@ -235,17 +235,26 @@ pub fn commit_url_base(clone_url: &str) -> Option { } /// The repository path of a clone URL on `host`, in its HTTPS, scp-like or -/// `ssh://` form, without ".git" and trailing slashes. +/// `ssh://` form, without ".git" and trailing slashes. None unless every +/// segment is a plain name (letters, digits, `.`, `_`, `-`; not `.`, `..` +/// or GitLab's `-` separator) and the path is not a wiki's. fn repo_path_on<'a>(host: &str, clone_url: &'a str) -> Option<&'a str> { let rest = clone_url .strip_prefix(&format!("https://{host}/")) .or_else(|| clone_url.strip_prefix(&format!("git@{host}:"))) - .or_else(|| clone_url.strip_prefix(&format!("ssh://git@{host}/")))?; - Some( - rest.strip_suffix(".git") - .unwrap_or(rest) - .trim_end_matches('/'), - ) + .or_else(|| clone_url.strip_prefix(&format!("ssh://git@{host}/")))? + .trim_end_matches('/'); + let path = rest + .strip_suffix(".git") + .unwrap_or(rest) + .trim_end_matches('/'); + let plain = |segment: &str| { + !matches!(segment, "" | "." | ".." | "-") + && segment + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) + }; + (path.split('/').all(plain) && !path.ends_with(".wiki")).then_some(path) } /// A commit collected before ordering. @@ -498,6 +507,33 @@ mod tests { assert_eq!(commit_url_base("../local/path"), None); } + #[test] + fn commit_url_base_drops_a_git_suffix_before_a_trailing_slash() { + assert_eq!( + commit_url_base("https://github.com/o/r.git/").as_deref(), + Some("https://github.com/o/r/commit/") + ); + assert_eq!( + commit_url_base("https://gitlab.com/g/r.git/").as_deref(), + Some("https://gitlab.com/g/r/-/commit/") + ); + } + + #[test] + fn commit_url_base_rejects_paths_that_name_no_project() { + for url in [ + "https://github.com/o/r.wiki.git", + "https://gitlab.com/g/s/r.wiki.git", + "https://gitlab.com/g/r?private_token=T", + "https://github.com/o/r#frag", + "git@gitlab.com:g/r.git ", + "https://gitlab.com/g/r/-/tree/main", + "https://gitlab.com/g/../r", + ] { + assert_eq!(commit_url_base(url), None, "for {url}"); + } + } + fn oid(hex: &str) -> gix::ObjectId { gix::ObjectId::from_hex(hex.as_bytes()).expect("valid 40-hex object id") }