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")} + + + )}