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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/FEATURES.jp.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ gitreant でできることを分野別にまとめる。起動方法とコマ

- **レーンと色はサーバで計算する。** チェックアウト中のブランチを左端の直線に置き、分岐は右へ、マージは右から合流する。ブランチの線は端から端まで同じ色で描く。
- **HEAD とマージ。** HEAD のコミットは中抜きの輪で描く。マージコミットのメッセージは薄く表示する。
- **ノードの操作。** ノードにホバーするとハッシュを表示し、クリックでそのコミットを選ぶ。右クリックでコミット行と同じメニュー(タグやブランチの作成)を開く。ハッシュのクリックでフル ID をコピーする。
- **ノードの操作。** ノードにホバーするとハッシュを表示し、クリックでそのコミットを選ぶ。右クリックでコミット行と同じメニューを開き、フル ID のコピー、タグやブランチの作成ができる。origin が github.com か gitlab.com にあれば、コミットの Web ページの URL をコピーしたり、そのページを開いたりもできる。ハッシュのクリックでフル ID をコピーする。
- **バッジ。** ブランチ・タグ・stash を別々のバッジで描く。リモートのブランチやリモートに push 済みのタグには、リモート名の区切りを付ける。
- **stash。** stash は 1 つのノードにたたんで描く。設定で、index と未追跡ファイルの内部コミットを点線で開いて見せられる。
- **サブモジュールのグラフ。** サブモジュールを持つリポジトリでは、サブモジュールのグラフを左側に並べ、サブモジュールのポインタを動かしたコミットどうしを点線で結ぶ。設定で切り替えられる。
Expand Down
5 changes: 3 additions & 2 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/ROADMAP.jp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 の残課題)

Expand Down
4 changes: 4 additions & 0 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
50 changes: 50 additions & 0 deletions frontend/e2e/graph.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);

Expand Down
42 changes: 39 additions & 3 deletions frontend/src/CommitMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -19,13 +21,19 @@ 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;
}) {
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) => {
Expand All @@ -50,6 +58,34 @@ export function CommitMenu({
<span className="ctx-menu-note">{shortId(commit)}</span>
{stage === null ? (
<>
<button
type="button"
data-testid="commit-menu-copy-hash"
onClick={() => copyAndClose(commit)}
>
{t("copyCommitId")}
</button>
{webUrl && (
<>
<button
type="button"
data-testid="commit-menu-copy-url"
onClick={() => copyAndClose(webUrl)}
>
{t("copyCommitUrl")}
</button>
<a
data-testid="commit-menu-open-url"
href={webUrl}
target="_blank"
rel="noreferrer"
onClick={onClose}
onAuxClick={onClose}
>
{t("openCommitUrl")}
</a>
</>
)}
<button
type="button"
data-testid="commit-menu-tag"
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/RepoCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,10 @@ export function RepoCard({
x={commitMenu.x}
y={commitMenu.y}
commit={commitMenu.commit}
webUrl={
repo.commit_url_base &&
`${repo.commit_url_base}${commitMenu.commit}`
}
onCreateTag={(name) =>
runBranchOp(
(repoId, value) => createTag(repoId, value, commitMenu.commit),
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ export interface RepoView {
head_branch?: string;
/** Web URL of the origin remote, when it points at github.com. */
github_url?: string;
/** A commit's web page on the origin's host (github.com or gitlab.com),
* less the full commit id that completes it. */
commit_url_base?: string;
/** The repository's other worktrees, so a branch badge can say where the
* branch is checked out (git refuses to switch to it here). */
worktrees?: WorktreeEntry[];
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,9 @@ 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
loading: "Loading…",
expandBody: "Show full message",
Expand Down Expand Up @@ -293,6 +296,9 @@ const ja: Record<MsgKey, string> = {
tagNamePlaceholder: "タグ名…",
createBranch: "ブランチを作成",
branchNamePlaceholder: "ブランチ名…",
copyCommitId: "コミットIDをコピー",
copyCommitUrl: "WebページのURLをコピー",
openCommitUrl: "Webページを開く",
loading: "読み込み中…",
expandBody: "全文を表示",
collapseBody: "本文をたたむ",
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/stories/CommitMenu.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,13 @@ export const Default: Story = {
onClose: () => console.log("close"),
},
};

/** The origin is on github.com or gitlab.com: the commit's web page joins
* the actions. */
export const WithWebPage: Story = {
args: {
...Default.args,
webUrl:
"https://github.com/ShortArrow/gitreant/commit/4043bc624ad30d6cd059d892a8ceb3b7cc9da79a",
},
};
7 changes: 5 additions & 2 deletions frontend/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1081,7 +1081,8 @@ body {
}

.ref-menu button,
.ctx-menu button {
.ctx-menu button,
.ctx-menu a {
text-align: left;
padding: 6px 10px;
background: transparent;
Expand All @@ -1092,10 +1093,12 @@ body {
font-size: 13px;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
white-space: nowrap;
text-decoration: none;
}

.ref-menu button:hover:not(:disabled),
.ctx-menu button:hover:not(:disabled) {
.ctx-menu button:hover:not(:disabled),
.ctx-menu a:hover {
background: color-mix(in srgb, var(--accent) 14%, transparent);
}

Expand Down
9 changes: 9 additions & 0 deletions src/app/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ pub struct RepoView {
/// Web URL of the origin remote, when it points at github.com.
#[serde(skip_serializing_if = "Option::is_none")]
pub github_url: Option<String>,
/// A commit's web page on the origin's host (github.com or gitlab.com),
/// less the full commit id that completes it.
#[serde(skip_serializing_if = "Option::is_none")]
pub commit_url_base: Option<String>,
/// The repository's other worktrees, so branch badges can say where a
/// branch is checked out (a branch cannot be switched to twice).
#[serde(skip_serializing_if = "Vec::is_empty")]
Expand Down Expand Up @@ -118,6 +122,7 @@ impl RepoView {
head: None,
head_branch: None,
github_url: None,
commit_url_base: None,
worktrees: Vec::new(),
refs: Vec::new(),
commits: Vec::new(),
Expand Down Expand Up @@ -330,6 +335,7 @@ pub fn build_view(id: &Path, data: &RepoData, uncommitted: usize) -> RepoView {
head: data.head.clone(),
head_branch: data.head_branch.clone(),
github_url: data.github_url.clone(),
commit_url_base: data.commit_url_base.clone(),
worktrees,
refs,
total: data.commits.len(),
Expand Down Expand Up @@ -382,6 +388,7 @@ mod tests {
head: None,
head_branch: None,
github_url: None,
commit_url_base: None,
worktrees: vec![],
};

Expand Down Expand Up @@ -419,6 +426,7 @@ mod tests {
head: head.map(str::to_string),
head_branch: head.map(|_| "main".to_string()),
github_url: None,
commit_url_base: None,
worktrees: vec![],
}
}
Expand Down Expand Up @@ -489,6 +497,7 @@ mod tests {
head: Some("A".to_string()),
head_branch: Some("main".to_string()),
github_url: None,
commit_url_base: None,
worktrees: vec![],
};

Expand Down
Loading
Loading