Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/FEATURES.jp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 の作者アイコンを各行に表示する。設定で消せる。

## コミットの詳細と差分
Expand Down
9 changes: 7 additions & 2 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
12 changes: 11 additions & 1 deletion frontend/e2e/global-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
}

Expand Down Expand Up @@ -419,6 +428,7 @@ export default async function globalSetup(_config: FullConfig) {
path.join(tmpDir, "fixtures.json"),
JSON.stringify(
{
repoA,
repoC,
repoD,
repoE,
Expand Down
143 changes: 143 additions & 0 deletions frontend/e2e/graph.spec.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
}) => {
Expand Down Expand Up @@ -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,
}) => {
Expand Down
40 changes: 23 additions & 17 deletions frontend/src/CommitMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -86,28 +88,32 @@ export function CommitMenu({
</a>
</>
)}
<button
type="button"
data-testid="commit-menu-tag"
onClick={() => setStage("tag")}
>
{t("createTag")}…
</button>
<button
type="button"
data-testid="commit-menu-branch"
onClick={() => setStage("branch")}
>
{t("createBranch")}…
</button>
{onCreateTag && (
<button
type="button"
data-testid="commit-menu-tag"
onClick={() => setStage("tag")}
>
{t("createTag")}…
</button>
)}
{onCreateBranch && (
<button
type="button"
data-testid="commit-menu-branch"
onClick={() => setStage("branch")}
>
{t("createBranch")}…
</button>
)}
</>
) : (
<form
onSubmit={(e) => {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed) return;
(stage === "tag" ? onCreateTag : onCreateBranch)(trimmed);
(stage === "tag" ? onCreateTag : onCreateBranch)?.(trimmed);
onClose();
}}
>
Expand Down
Loading
Loading