diff --git a/CHANGELOG.md b/CHANGELOG.md index bdd70e5..cb569e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Hovering a branch badge fades the rows its tip cannot reach + ([#17](https://github.com/ShortArrow/gitreant/issues/17)). +- Each submodule graph collapses to a narrow strip with its name, giving + the commit rows their width back; the choice is kept per repository + ([#18](https://github.com/ShortArrow/gitreant/issues/18)). - Debian packages for amd64 and arm64, attached to each release and installable with `apt` from `pkg.shortarrow.jp`, and a `SHA256SUMS` for every release asset ([ADR 0034](https://github.com/ShortArrow/gitreant/blob/main/docs/adr/0034-debian-packages.md)). +### Changed + +- A submodule graph's nodes behave like the main graph's: hovering shows the + commit's hash, and the context menu copies its id and its web page on the + submodule's own origin. The dashed pointer links no longer cover the node + they end on. + ### Fixed - The arm64 Linux binary starts on glibc 2.35 and later, Debian 12 and diff --git a/docs/FEATURES.jp.md b/docs/FEATURES.jp.md index 8b47099..6ba3c04 100644 --- a/docs/FEATURES.jp.md +++ b/docs/FEATURES.jp.md @@ -18,9 +18,9 @@ gitreant でできることを分野別にまとめる。起動方法とコマ - **レーンと色はサーバで計算する。** チェックアウト中のブランチを左端の直線に置き、分岐は右へ、マージは右から合流する。ブランチの線は端から端まで同じ色で描く。 - **HEAD とマージ。** HEAD のコミットは中抜きの輪で描く。マージコミットのメッセージは薄く表示する。 - **ノードの操作。** ノードにホバーするとハッシュを表示し、クリックでそのコミットを選ぶ。右クリックでコミット行と同じメニューを開き、フル ID のコピー、タグやブランチの作成ができる。origin が github.com か gitlab.com にあれば、コミットの Web ページの URL をコピーしたり、そのページを開いたりもできる。ハッシュのクリックでフル ID をコピーする。 -- **バッジ。** ブランチ・タグ・stash を別々のバッジで描く。リモートのブランチやリモートに push 済みのタグには、リモート名の区切りを付ける。 +- **バッジ。** ブランチ・タグ・stash を別々のバッジで描く。リモートのブランチやリモートに push 済みのタグには、リモート名の区切りを付ける。ブランチのバッジにホバーすると、読み込み済みのコミットのうち、その先端からたどれない行を薄くする。 - **stash。** stash は 1 つのノードにたたんで描く。設定で、index と未追跡ファイルの内部コミットを点線で開いて見せられる。 -- **サブモジュールのグラフ。** サブモジュールを持つリポジトリでは、サブモジュールのグラフを左側に並べ、サブモジュールのポインタを動かしたコミットどうしを点線で結ぶ。設定で切り替えられる。 +- **サブモジュールのグラフ。** サブモジュールを持つリポジトリでは、サブモジュールのグラフを左側に並べ、サブモジュールのポインタを動かしたコミットどうしを点線で結ぶ。設定で切り替えられる。サブモジュールごとにグラフを名前だけの細い帯にたため、行の文字に幅を譲れる。たたんだ状態はリポジトリごとに保存する。サブモジュールのノードにホバーするとそのコミットを表示し、右クリックで ID か、サブモジュール自身の origin にあるコミットの Web ページをコピーできる。 - **作者のアイコン。** GitHub の作者アイコンを各行に表示する。設定で消せる。 ## コミットの詳細と差分 diff --git a/docs/FEATURES.md b/docs/FEATURES.md index f810e6a..6c3aafc 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -41,12 +41,17 @@ see the [README](../README.md); design decisions live in [adr/](adr/). 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. + separate segment. Hovering a branch badge fades the rows its tip cannot + reach, among the loaded commits. - **Stashes.** A stash folds to one node. A setting reveals its index and untracked-files commits, linked by dashed lines. - **Submodule graphs.** For a repository with submodules, each submodule's graph sits to the left, with dashed links between the commits that moved - its pointer. A setting turns this off. + its pointer. A setting turns this off. Each submodule's graph collapses + to a narrow strip with its name, giving the rows their width back; the + choice is kept per repository. Hovering a submodule's node names + its commit, and its context menu copies the id or, from the submodule's + own origin, the commit's web page. - **Author avatars.** Rows show the author's GitHub avatar; a setting hides them. diff --git a/frontend/e2e/global-setup.ts b/frontend/e2e/global-setup.ts index 4144f3b..fc09f06 100644 --- a/frontend/e2e/global-setup.ts +++ b/frontend/e2e/global-setup.ts @@ -316,7 +316,10 @@ function makeRepoN(): string { } /** repoO: a superproject with one submodule, so the drawer can group them. - * The submodule is added over the file transport (opt-in for recent git). */ + * The submodule is added over the file transport (opt-in for recent git). + * The submodule's clone then names a github.com origin (never fetched) and + * the superproject has none, so a web page can only come from the + * submodule's own origin. */ function makeRepoO(): string { const child = initRepo("repoO-sub"); commit(child, "sub-1", 1_700_000_000); @@ -331,6 +334,12 @@ function makeRepoO(): string { "sub", ]); commit(parent, "add submodule", 1_700_000_010); + git(path.join(parent, "sub"), [ + "remote", + "set-url", + "origin", + "https://github.com/example/repoO-sub.git", + ]); return parent; } @@ -419,6 +428,7 @@ export default async function globalSetup(_config: FullConfig) { path.join(tmpDir, "fixtures.json"), JSON.stringify( { + repoA, repoC, repoD, repoE, diff --git a/frontend/e2e/graph.spec.ts b/frontend/e2e/graph.spec.ts index 2ba0512..6a67330 100644 --- a/frontend/e2e/graph.spec.ts +++ b/frontend/e2e/graph.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "@playwright/test"; +import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; import path from "node:path"; import { commit, git } from "./git"; @@ -1953,6 +1954,100 @@ test.describe.serial("gitreant UI", () => { await expect(subRow).toHaveCount(0); }); + test("submodule graph nodes name their commit and open its menu", async ({ + page, + context, + }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await page.getByTestId("add-input").fill(fixtures.repoO); + await page.getByTestId("add-submit").click(); + await page.locator('[data-repo-name="repoO"]').click(); + const node = page.getByTestId("submodule-node"); + await expect(node).toHaveCount(1); + + // Hovering names the commit the way a main graph node does, with the + // submodule it belongs to. + await node.hover(); + const tip = page.getByTestId("node-tip"); + await expect(tip).toHaveText(/^[0-9a-f]{7} sub: sub-1$/); + const short = (await tip.textContent())!.slice(0, 7); + await page.mouse.move(0, 0); + + // The menu copies the id and the page on the submodule's own origin; + // refs cannot be created there, since they would land in another + // repository. + await node.click({ button: "right" }); + const menu = page.getByTestId("commit-menu"); + await expect(menu.locator(".ctx-menu-note")).toHaveText(short); + await expect(menu.getByTestId("commit-menu-tag")).toHaveCount(0); + await expect(menu.getByTestId("commit-menu-branch")).toHaveCount(0); + await menu.getByTestId("commit-menu-copy-hash").click(); + const id = await page.evaluate(() => navigator.clipboard.readText()); + expect(id.startsWith(short)).toBe(true); + await node.click({ button: "right" }); + await menu.getByTestId("commit-menu-copy-url").click(); + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe( + `https://github.com/example/repoO-sub/commit/${id}`, + ); + + // Restore the served set. + const repoO = page.locator('[data-repo-name="repoO"]'); + await repoO.hover(); + await repoO.getByTestId("repo-remove").click(); + await expect(page.locator('[data-repo-name="repoO"]')).toHaveCount(0); + }); + + test("a submodule graph collapses to a strip and stays collapsed", async ({ + page, + }) => { + await page.getByTestId("add-input").fill(fixtures.repoO); + await page.getByTestId("add-submit").click(); + await page.locator('[data-repo-name="repoO"]').click(); + const region = page.getByTestId("submodule-region"); + await expect(page.getByTestId("submodule-node")).toHaveCount(1); + await expect(page.getByTestId("submodule-edge")).toHaveCount(1); + const wide = (await page.getByTestId("graph").getAttribute("width"))!; + + // Collapsing leaves a named strip: no nodes, no links, a narrower graph. + // The whole button takes the press, even where the dashed link to the + // submodule's newest commit runs past it. + const toggle = (await page.getByTestId("region-collapse").boundingBox())!; + await page.mouse.click( + toggle.x + toggle.width / 2, + toggle.y + toggle.height - 3, + ); + await expect(page.getByTestId("submodule-node")).toHaveCount(0); + await expect(page.getByTestId("submodule-edge")).toHaveCount(0); + const strip = region.getByTestId("region-expand"); + await expect(strip).toContainText("sub"); + const inside = await strip.evaluate((g) => { + const rect = g.querySelector("rect")!.getBoundingClientRect(); + const text = g.querySelector("text")!.getBoundingClientRect(); + return text.left >= rect.left - 0.5 && text.right <= rect.right + 0.5; + }); + expect(inside).toBe(true); + const narrow = (await page.getByTestId("graph").getAttribute("width"))!; + expect(Number(narrow)).toBeLessThan(Number(wide)); + + // The choice is kept per repository across a reload. + await page.reload(); + await page.locator('[data-repo-name="repoO"]').click(); + await expect(region.getByTestId("region-expand")).toBeVisible(); + await expect(page.getByTestId("submodule-node")).toHaveCount(0); + + // The strip expands it again, from the keyboard too. + await region.getByTestId("region-expand").focus(); + await page.keyboard.press("Enter"); + await expect(page.getByTestId("submodule-node")).toHaveCount(1); + await expect(page.getByTestId("submodule-edge")).toHaveCount(1); + + // Restore the served set. + const repoO = page.locator('[data-repo-name="repoO"]'); + await repoO.hover(); + await repoO.getByTestId("repo-remove").click(); + await expect(page.locator('[data-repo-name="repoO"]')).toHaveCount(0); + }); + test("a superproject draws its submodule graph with dashed pointer links", async ({ page, }) => { @@ -2091,6 +2186,54 @@ test.describe.serial("gitreant UI", () => { await expect(hash).toHaveText(shown, { timeout: 3000 }); }); + test("hovering a branch badge fades the rows it cannot reach", async ({ + page, + }) => { + await page.locator('[data-repo-name="repoA"]').click(); + const rows = page.getByTestId("commit-row"); + await expect(rows).toHaveCount(5); + const faded = page.locator(".commit-faded"); + + // feature reaches feature-1, main-1 and root; the merge and main-2 + // came later on main. + await page.locator(".badge-kind-branch", { hasText: "feature" }).hover(); + await expect(faded).toHaveCount(2); + await expect(rows.filter({ hasText: "merge feature" })).toHaveClass( + /commit-faded/, + ); + await expect(rows.filter({ hasText: "main-2" })).toHaveClass( + /commit-faded/, + ); + await expect(rows.filter({ hasText: "feature-1" })).not.toHaveClass( + /commit-faded/, + ); + + // Leaving the badge restores every row. + await page.mouse.move(0, 0); + await expect(faded).toHaveCount(0); + + // A reload that moves the hovered badge to another row unmounts it + // before the pointer leaves; the fade must not outlive it. + const featureTip = execFileSync("git", ["rev-parse", "feature"], { + cwd: fixtures.repoA, + encoding: "utf8", + }).trim(); + try { + await page.locator(".badge-kind-branch", { hasText: "feature" }).hover(); + await expect(faded).toHaveCount(2); + git(fixtures.repoA, ["branch", "-f", "feature", "feature~1"]); + await page.request.post("/api/refresh"); + await expect( + rows.filter({ hasText: "main-1" }).locator(".badge-kind-branch"), + ).toHaveCount(1); + await page.mouse.move(0, 0); + await expect(faded).toHaveCount(0); + } finally { + git(fixtures.repoA, ["branch", "-f", "feature", featureTip]); + await page.request.post("/api/refresh"); + } + }); + test("graph nodes show their hash on hover and open the commit menu", async ({ page, }) => { diff --git a/frontend/src/CommitMenu.tsx b/frontend/src/CommitMenu.tsx index aa07a27..a8fe93d 100644 --- a/frontend/src/CommitMenu.tsx +++ b/frontend/src/CommitMenu.tsx @@ -23,8 +23,10 @@ export function CommitMenu({ 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; + /** Absent when tags cannot be created here (a submodule's commit). */ + onCreateTag?: (name: string) => void; + /** Absent when branches cannot be created here. */ + onCreateBranch?: (name: string) => void; onClose: () => void; }) { const t = useT(); @@ -86,20 +88,24 @@ export function CommitMenu({ > )} - - + {onCreateTag && ( + + )} + {onCreateBranch && ( + + )} > ) : (