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..2ba0512 100644
--- a/frontend/e2e/graph.spec.ts
+++ b/frontend/e2e/graph.spec.ts
@@ -1695,6 +1695,52 @@ 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();
+ await expect(menu).toHaveCount(0);
+ expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(url);
+
+ // The page opens in a new tab (stubbed: the test never goes online),
+ // and the menu closes behind it.
+ await context.route("https://github.com/**", (route) =>
+ route.fulfill({ body: "stub" }),
+ );
+ await node.click({ button: "right" });
+ const [tab] = await Promise.all([
+ context.waitForEvent("page"),
+ menu.getByTestId("commit-menu-open-url").click(),
+ ]);
+ expect(tab.url()).toBe(url);
+ await tab.close();
+ await expect(menu).toHaveCount(0);
+
+ // 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 +2114,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..aa07a27 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,34 @@ export function CommitMenu({
{shortId(commit)}
{stage === null ? (
<>
+
+ {webUrl && (
+ <>
+
+
+ {t("openCommitUrl")}
+
+ >
+ )}