diff --git a/components/repository/branch/actions.tmpl b/components/repository/branch/actions.tmpl
new file mode 100644
index 00000000..a4c308ea
--- /dev/null
+++ b/components/repository/branch/actions.tmpl
@@ -0,0 +1,15 @@
+{{$page := .Page}}{{$branch := .Branch}}
+{{if .CanWrite}}
+ {{svg "octicon-git-branch" 16}}{{ctx.Locale.Tr "repo.branch.new_branch"}}
+ {{svg "octicon-pencil" 16}}{{ctx.Locale.Tr "repo.settings.rename_branch"}}
+{{end}}
+{{if and .IsDefault $page.Permission.IsAdmin (not $page.Repository.IsArchived)}}
+ {{svg "octicon-arrow-switch" 16}}{{ctx.Locale.Tr "repo.settings.branches.switch_default_branch"}}
+{{end}}
+{{if not $page.DisableDownloadSourceArchives}}
+ {{svg "octicon-file-zip" 16}}{{ctx.Locale.Tr "repo.download_zip"}}
+ {{svg "octicon-file-zip" 16}}{{ctx.Locale.Tr "repo.download_tar"}}
+{{end}}
+{{if $page.EnableFeed}}
+ {{svg "octicon-rss" 16}}{{ctx.Locale.Tr "rss_feed"}}
+{{end}}
diff --git a/components/repository/branch/branch.css b/components/repository/branch/branch.css
new file mode 100644
index 00000000..7a60e9c4
--- /dev/null
+++ b/components/repository/branch/branch.css
@@ -0,0 +1,240 @@
+/* GitHub's branch tables: a 14px heading over a box whose header names the columns, and a 48px row per branch. */
+.github-branches-search {
+ margin-bottom: 16px;
+}
+
+.github-branches-search .theme-search-field {
+ width: 100%;
+}
+
+.theme-branch-section + .theme-branch-section {
+ margin-top: 24px;
+}
+
+.theme-branch-section-title {
+ margin: 0 0 8px;
+ font-size: 14px;
+ font-weight: 600;
+ line-height: 21px;
+}
+
+.theme-branch-box {
+ border: 1px solid var(--color-light-border);
+ border-radius: var(--border-radius);
+}
+
+.theme-branch-table {
+ width: 100%;
+ border-collapse: collapse;
+ table-layout: fixed;
+ font-size: 14px;
+}
+
+/* The box does not clip, so the row menus can open past it; the header rounds its own corners. */
+.theme-branch-table th:first-child {
+ border-top-left-radius: var(--border-radius);
+}
+
+.theme-branch-table th:last-child {
+ border-top-right-radius: var(--border-radius);
+}
+
+.theme-branch-table th {
+ height: 40px;
+ padding: 0 8px;
+ border-bottom: 1px solid var(--color-light-border);
+ background: var(--color-box-header);
+ color: var(--color-text-light-1);
+ font-size: 12px;
+ font-weight: 600;
+ text-align: left;
+}
+
+.theme-branch-table td {
+ height: 48px;
+ padding: 0 8px;
+ border-top: 1px solid var(--color-light-border);
+ vertical-align: middle;
+}
+
+.theme-branch-table tbody tr:first-child td {
+ border-top: 0;
+}
+
+.theme-branch-table :is(th, td):first-child {
+ padding-left: 16px;
+}
+
+.theme-branch-table :is(th, td):last-child {
+ padding-right: 16px;
+}
+
+.theme-branch-table .theme-branch-updated {
+ width: 160px;
+}
+
+.theme-branch-table .theme-branch-status {
+ width: 104px;
+}
+
+.theme-branch-table .theme-branch-divergence {
+ width: 144px;
+}
+
+.theme-branch-table .theme-branch-pull {
+ width: 136px;
+}
+
+.theme-branch-table .theme-branch-actions {
+ width: 88px;
+}
+
+.theme-branch-name-row,
+.theme-branch-updated-inner,
+.theme-branch-actions-inner {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ min-width: 0;
+}
+
+.theme-branch-name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.theme-branch-copy {
+ flex: none;
+ color: var(--color-text-light-1);
+}
+
+.theme-branch-protected {
+ display: inline-flex;
+ color: var(--color-text-light-1);
+}
+
+.theme-branch-updated {
+ color: var(--color-text-light-1);
+ font-size: 12px;
+}
+
+.theme-branch-updated-inner {
+ gap: 8px;
+}
+
+.theme-branch-updated-inner img.ui.avatar {
+ border-radius: 50%;
+}
+
+.theme-branch-deleted .theme-branch-name {
+ color: var(--color-text-light-1);
+ text-decoration: line-through;
+}
+
+/* Behind and ahead: the counts meet at a divider, each bar growing away from it in proportion to the commits. */
+.theme-branch-table th.theme-branch-divergence {
+ text-align: center;
+}
+
+.theme-branch-table th.theme-branch-divergence > span + span::before {
+ content: "|";
+ margin: 0 6px;
+ color: var(--color-light-border);
+}
+
+.theme-branch-divergence-graph {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ font-size: 12px;
+}
+
+.theme-branch-behind,
+.theme-branch-ahead {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 0 6px;
+}
+
+.theme-branch-behind {
+ align-items: flex-end;
+ border-right: 1px solid var(--color-light-border);
+}
+
+.theme-branch-bar {
+ display: block;
+ width: 0;
+ max-width: 100%;
+ height: 4px;
+ background: var(--github-progressBar-track-bgColor);
+}
+
+.theme-branch-behind .theme-branch-bar {
+ border-radius: 6px 0 0 6px;
+}
+
+.theme-branch-ahead .theme-branch-bar {
+ border-radius: 0 6px 6px 0;
+}
+
+.theme-branch-pull-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ color: var(--color-text-light-1);
+}
+
+.theme-branch-pull-open .svg {
+ color: var(--github-fgColor-success);
+}
+
+.theme-branch-pull-merged .svg {
+ color: var(--github-fgColor-done);
+}
+
+.theme-branch-pull-closed .svg {
+ color: var(--color-red);
+}
+
+.theme-branch-pull-draft .svg {
+ color: var(--color-text-light-1);
+}
+
+.theme-branch-included {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ color: var(--color-text-light-1);
+ font-size: 12px;
+}
+
+.theme-branch-actions-inner {
+ justify-content: flex-end;
+}
+
+.theme-branch-delete:hover {
+ color: var(--color-red);
+}
+
+.theme-branch-actions .theme-dropdown > summary.theme-control {
+ width: 32px;
+ padding: 0;
+ justify-content: center;
+ color: var(--color-text-light-1);
+}
+
+.theme-branch-actions .theme-dropdown-panel {
+ width: 224px;
+}
+
+/* Phones keep the name, the pull request and the actions; GitHub drops the other columns too. */
+@media (max-width: 767.98px) {
+ .theme-branch-table :is(.theme-branch-updated, .theme-branch-status, .theme-branch-divergence) {
+ display: none;
+ }
+
+ .theme-branch-table .theme-branch-pull {
+ width: 88px;
+ }
+}
diff --git a/components/repository/branch/row.tmpl b/components/repository/branch/row.tmpl
new file mode 100644
index 00000000..00a816f0
--- /dev/null
+++ b/components/repository/branch/row.tmpl
@@ -0,0 +1,74 @@
+{{- /* One branch of components/repository/branch/table: its name, who pushed when, its checks, how far it is behind
+and ahead of the default branch, its pull request, then delete or restore and a menu with Forgejo's other actions. */ -}}
+{{$page := .Page}}{{$branch := .Branch}}{{$db := $branch.DBBranch}}
+{{$canWrite := and $page.IsWriter (not $page.Repository.IsArchived) (not $page.IsMirror)}}
+{{$compareLink := ""}}
+{{if $page.DefaultBranchBranch}}
+ {{$headOwner := ""}}{{if ne $page.Repository.Owner.Name $page.Owner.Name}}{{$headOwner = print (PathEscape $page.Owner.Name) ":"}}{{end}}
+ {{$compareLink = print $page.RepoLink "/compare/" (PathEscapeSegments $page.DefaultBranchBranch.DBBranch.Name) "..." $headOwner (PathEscapeSegments $db.Name)}}
+{{end}}
+
+
+
+ {{if $db.IsDeleted}}
+
{{$db.Name}}
+ {{else}}
+
{{$db.Name}}
+ {{end}}
+
{{svg "octicon-copy" 16}}
+ {{if $branch.IsProtected}}
{{svg "octicon-shield-lock" 16}} {{end}}
+
+
+
+ {{if $db.IsDeleted}}
+ {{ctx.Locale.Tr "repo.branch.deleted_by" $db.DeletedBy.Name}} {{DateUtils.TimeSince $db.DeletedUnix}}
+ {{else}}
+ {{if $db.Pusher}}{{ctx.AvatarUtils.Avatar $db.Pusher 16}}{{end}}{{DateUtils.TimeSince $db.CommitTime}}
+ {{end}}
+
+
+ {{if not $db.IsDeleted}}{{template "repo/commit_statuses" dict "Status" (index $page.CommitStatus $db.CommitID) "Statuses" (index $page.CommitStatuses $db.CommitID)}}{{end}}
+
+
+ {{if and (not .IsDefault) (not $db.IsDeleted) $page.DefaultBranchBranch}}
+ {{$total := Eval $branch.CommitsBehind "+" $branch.CommitsAhead}}
+
+ {{$branch.CommitsBehind}}
+ {{$branch.CommitsAhead}}
+
+ {{end}}
+
+
+ {{if .IsDefault}}
+ {{else if not ($page.Permission.CanRead $page.UnitTypePullRequests)}}
+ {{if $compareLink}}{{ctx.Locale.Tr "compare.branches.title"}} {{end}}
+ {{else if or (not $branch.LatestPullRequest) (and $branch.LatestPullRequest.HasMerged $branch.MergeMovedOn)}}
+ {{if $branch.IsIncluded}}
+ {{svg "octicon-git-pull-request" 16}}{{ctx.Locale.Tr "repo.branch.included"}}
+ {{else if and (not $db.IsDeleted) $page.AllowsPulls (gt $branch.CommitsAhead 0) $compareLink}}
+ {{if $page.CanPull}}{{ctx.Locale.Tr "repo.pulls.compare_changes"}}{{else}}{{ctx.Locale.Tr "action.compare_branch"}}{{end}}
+ {{end}}
+ {{else}}
+ {{$pull := $branch.LatestPullRequest}}
+ {{$state := "open"}}{{$icon := "octicon-git-pull-request"}}{{$label := ctx.Locale.Tr "repo.issues.open_title"}}
+ {{if $pull.HasMerged}}{{$state = "merged"}}{{$icon = "octicon-git-merge"}}{{$label = ctx.Locale.Tr "repo.pulls.merged"}}
+ {{else if $pull.Issue.IsClosed}}{{$state = "closed"}}{{$icon = "octicon-git-pull-request-closed"}}{{$label = ctx.Locale.Tr "repo.issues.closed_title"}}
+ {{else if $pull.IsWorkInProgress ctx}}{{$state = "draft"}}{{$icon = "octicon-git-pull-request-draft"}}{{$label = ctx.Locale.Tr "repo.issues.draft_title"}}{{end}}
+ {{svg $icon 16}}{{if not $pull.IsSameRepo}}{{$pull.BaseRepo.FullName}}{{end}}#{{$pull.Issue.Index}}
+ {{end}}
+
+
+
+ {{if and $canWrite (not .IsDefault) (not $branch.IsProtected)}}
+ {{if $db.IsDeleted}}
+ {{svg "octicon-reply" 16}}
+ {{else}}
+ {{svg "octicon-trash" 16}}
+ {{end}}
+ {{end}}
+ {{if not $db.IsDeleted}}
+ {{template "components/common/dropdown/dropdown" dict "Kind" "actions" "Icon" "octicon-kebab-horizontal" "IconOnly" true "Quiet" true "Label" (ctx.Locale.Tr "profile.actions.tooltip") "Items" "components/repository/branch/actions" "Data" (dict "Context" $page.Context "Page" $page "Branch" $db "IsDefault" .IsDefault "CanWrite" $canWrite)}}
+ {{end}}
+
+
+
diff --git a/components/repository/branch/table.tmpl b/components/repository/branch/table.tmpl
new file mode 100644
index 00000000..c617dfa0
--- /dev/null
+++ b/components/repository/branch/table.tmpl
@@ -0,0 +1,28 @@
+{{- /* GitHub's branch table: a heading, then a box with column headers and a row per branch. Page is the page context,
+Title the heading, and either Default the default branch or Branches the other rows. */ -}}
+
+ {{.Title}}
+
+
+
+
+ Branch
+ Updated
+ Check status
+ Behind Ahead
+ Pull request
+ {{ctx.Locale.Tr "profile.actions.tooltip"}}
+
+
+
+ {{if .Default}}
+ {{template "components/repository/branch/row" dict "Page" .Page "Branch" .Default "IsDefault" true}}
+ {{else}}
+ {{range .Branches}}
+ {{template "components/repository/branch/row" dict "Page" $.Page "Branch" .}}
+ {{end}}
+ {{end}}
+
+
+
+
diff --git a/src/build-output.test.ts b/src/build-output.test.ts
index 1c894dd3..8fd40491 100644
--- a/src/build-output.test.ts
+++ b/src/build-output.test.ts
@@ -562,6 +562,23 @@ describe("Forgejo 15 native integration", () => {
expect(read("templates", "base", "component_assets.tmpl")).toContain("/css/components/pages/commits.css");
});
+ it("lists branches in GitHub's branch tables with Forgejo's branch actions", () => {
+ const read = (...parts: string[]) => fs.readFileSync(path.join(ROOT_DIR, ...parts), "utf-8");
+ const page = read("templates", "repo", "branch", "list.tmpl");
+ expect(page).not.toContain('template "repo/sub_menu"');
+ expect(page).toContain('"Default" .DefaultBranchBranch');
+ expect(page).toContain('id="create-branch-modal"');
+ expect(page).toContain('id="rename-branch-modal"');
+ const row = read("components", "repository", "branch", "row.tmpl");
+ expect(row).toContain("delete-button delete-branch-button");
+ expect(row).toContain("restore-branch-button");
+ expect(row).toContain('class="theme-branch-name"');
+ const actions = read("components", "repository", "branch", "actions.tmpl");
+ expect(actions).toContain("show-create-branch-modal");
+ expect(actions).toContain("show-rename-branch-modal");
+ expect(read("styles", "components", "issues", "issue.ts")).toContain(".theme-branch-name {");
+ });
+
it("matches GitHub's repository About, visibility, and code-search details", () => {
const headerStyle = fs.readFileSync(path.join(ROOT_DIR, "styles", "templates", "repo", "header.ts"), "utf-8");
const headerTemplate = fs.readFileSync(path.join(ROOT_DIR, "templates", "repo", "home_header.tmpl"), "utf-8");
diff --git a/styles/components/issues/issue.ts b/styles/components/issues/issue.ts
index 125cd57b..50d0a6b8 100644
--- a/styles/components/issues/issue.ts
+++ b/styles/components/issues/issue.ts
@@ -73,10 +73,11 @@ const babel = css`
}
`;
-// PR 分支标签
+// PR 分支标签, and GitHub's branch name on the branches page
const prBranch = css`
.repository.view.issue .pull-desc code,
- #issue-list .branches .branch {
+ #issue-list .branches .branch,
+ .theme-branch-name {
color: ${themeVars.github.fgColor.accent};
background-color: ${themeVars.github.bgColor.accent.muted};
border-radius: ${otherThemeVars.border.radius};
diff --git a/templates/base/component_assets.tmpl b/templates/base/component_assets.tmpl
index 0d33d2f6..df32cd6d 100644
--- a/templates/base/component_assets.tmpl
+++ b/templates/base/component_assets.tmpl
@@ -18,6 +18,7 @@
+
diff --git a/templates/repo/branch/list.tmpl b/templates/repo/branch/list.tmpl
new file mode 100644
index 00000000..c1be7658
--- /dev/null
+++ b/templates/repo/branch/list.tmpl
@@ -0,0 +1,82 @@
+{{/* GitHub's branches page: a heading with New branch, the search, then the default branch and the other branches in
+GitHub's branch tables. Forgejo's dialogs for creating, renaming and deleting a branch stay as they are. */}}
+{{template "base/head" .}}
+
+
+ {{template "base/alert" .}}
+
+
{{ctx.Locale.Tr "repo.branches"}}
+ {{if and .IsWriter (not .Repository.IsArchived) (not .Repository.IsMirror) .DefaultBranchBranch}}
+
{{ctx.Locale.Tr "repo.branch.new_branch"}}
+ {{end}}
+
+
+ {{if and .DefaultBranchBranch (not .Keyword)}}
+ {{template "components/repository/branch/table" dict "Page" . "Title" (ctx.Locale.Tr "repo.default_branch") "Default" .DefaultBranchBranch}}
+ {{end}}
+ {{if .Branches}}
+ {{template "components/repository/branch/table" dict "Page" . "Title" (ctx.Locale.Tr "repo.branches") "Branches" .Branches}}
+ {{else if .Keyword}}
+
+ {{svg "octicon-git-branch" 24}}
+
{{ctx.Locale.Tr "search.no_results"}}
+
+ {{end}}
+ {{template "base/paginate" .}}
+
+
+
+
+
+
+
{{ctx.Locale.Tr "repo.branch.delete_desc"}}
+
+ {{template "base/modal_actions_confirm" .}}
+
+
+
+
+ {{ctx.Locale.Tr "repo.branch.new_branch"}}
+
+
+
+
+
+
+ {{ctx.Locale.Tr "repo.settings.rename_branch"}}
+
+
{{ctx.Locale.Tr "repo.branch.warning_rename_default_branch"}}
+
+
+
+
+{{template "base/footer" .}}