tenseleyflow/shithub / 621070d

Browse files

feat(repo): GitHub-style Code dropdown with HTTPS/SSH clone URLs + copy

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
621070d56c298e18e3091dc4911fdde56d00441a
Parents
6aaaf84
Tree
2ac6a60

10 changed files

StatusFile+-
M internal/auth/reserved.go 4 0
A internal/orgs/create.go 110 0
A internal/orgs/orgs.go 63 0
A internal/orgs/pgerror.go 10 0
M internal/web/handlers/repo/code.go 14 11
M internal/web/handlers/repo/repo.go 13 2
M internal/web/render/octicons.go 5 0
M internal/web/static/css/shithub.css 31 0
M internal/web/templates/_layout.html 30 0
M internal/web/templates/repo/tree.html 22 0
internal/auth/reserved.gomodified
@@ -82,6 +82,10 @@ var reservedNames = map[string]struct{}{
82
 	"shithub":       {},
82
 	"shithub":       {},
83
 	"shithubd":      {},
83
 	"shithubd":      {},
84
 	"shithubbot":    {},
84
 	"shithubbot":    {},
85
+	// S29 — notification subscribe/unsubscribe per thread.
86
+	"threads":       {},
87
+	// S30 — invitation accept/decline.
88
+	"invitations":   {},
85
 }
89
 }
86
 
90
 
87
 // IsReserved reports whether name is on the reserved list. The check is
91
 // IsReserved reports whether name is on the reserved list. The check is
internal/orgs/create.goadded
@@ -0,0 +1,110 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package orgs
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+	"fmt"
9
+	"regexp"
10
+	"strings"
11
+
12
+	"github.com/jackc/pgx/v5/pgtype"
13
+
14
+	"github.com/tenseleyFlow/shithub/internal/auth"
15
+	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
16
+)
17
+
18
+// CreateParams describes a create-org request.
19
+type CreateParams struct {
20
+	Slug             string
21
+	DisplayName      string
22
+	Description      string
23
+	BillingEmail     string
24
+	CreatedByUserID  int64
25
+}
26
+
27
+// slugRE mirrors the username pattern (lowercase letters, digits,
28
+// hyphens; cannot start or end with a hyphen). Same shape as
29
+// users.username so the namespace unification is genuinely uniform.
30
+var slugRE = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$`)
31
+
32
+// Create validates the slug, opens a tx, inserts the org, and adds
33
+// the creator as the sole owner. Slug uniqueness against the
34
+// principals table is enforced by the DB-level PK on principals — a
35
+// concurrent insert with the same slug fails with a unique-violation
36
+// from the trigger, which we translate to ErrSlugTaken.
37
+func Create(ctx context.Context, deps Deps, p CreateParams) (orgsdb.Org, error) {
38
+	slug := strings.ToLower(strings.TrimSpace(p.Slug))
39
+	if slug == "" {
40
+		return orgsdb.Org{}, ErrEmptySlug
41
+	}
42
+	if len(slug) > 39 {
43
+		return orgsdb.Org{}, ErrSlugTooLong
44
+	}
45
+	if !slugRE.MatchString(slug) {
46
+		return orgsdb.Org{}, ErrSlugInvalid
47
+	}
48
+	if auth.IsReserved(slug) {
49
+		return orgsdb.Org{}, ErrSlugReserved
50
+	}
51
+	if p.CreatedByUserID == 0 {
52
+		return orgsdb.Org{}, errors.New("orgs: CreatedByUserID is required")
53
+	}
54
+
55
+	tx, err := deps.Pool.Begin(ctx)
56
+	if err != nil {
57
+		return orgsdb.Org{}, err
58
+	}
59
+	committed := false
60
+	defer func() {
61
+		if !committed {
62
+			_ = tx.Rollback(ctx)
63
+		}
64
+	}()
65
+
66
+	q := orgsdb.New()
67
+	displayName := strings.TrimSpace(p.DisplayName)
68
+	if displayName == "" {
69
+		displayName = slug
70
+	}
71
+	row, err := q.CreateOrg(ctx, tx, orgsdb.CreateOrgParams{
72
+		Slug:            slug,
73
+		DisplayName:     displayName,
74
+		Description:     strings.TrimSpace(p.Description),
75
+		BillingEmail:    strings.TrimSpace(p.BillingEmail),
76
+		CreatedByUserID: pgtype.Int8{Int64: p.CreatedByUserID, Valid: true},
77
+	})
78
+	if err != nil {
79
+		if isUniqueViolation(err) {
80
+			// Either the orgs.slug UNIQUE index OR the principals PK
81
+			// fired. Either way, the slug is taken (by another org or
82
+			// by an existing user/redirect).
83
+			return orgsdb.Org{}, ErrSlugTaken
84
+		}
85
+		return orgsdb.Org{}, fmt.Errorf("create org: %w", err)
86
+	}
87
+
88
+	if err := q.AddOrgMember(ctx, tx, orgsdb.AddOrgMemberParams{
89
+		OrgID:            row.ID,
90
+		UserID:           p.CreatedByUserID,
91
+		Role:             orgsdb.OrgRoleOwner,
92
+		InvitedByUserID:  pgtype.Int8{Valid: false},
93
+	}); err != nil {
94
+		return orgsdb.Org{}, fmt.Errorf("seed owner: %w", err)
95
+	}
96
+
97
+	if err := tx.Commit(ctx); err != nil {
98
+		return orgsdb.Org{}, fmt.Errorf("commit: %w", err)
99
+	}
100
+	committed = true
101
+	return row, nil
102
+}
103
+
104
+// isUniqueViolation reports whether err is a Postgres unique-key
105
+// violation (SQLSTATE 23505). The orgs.slug UNIQUE index, the
106
+// principals PK, and any concurrent-insert race surface as 23505.
107
+func isUniqueViolation(err error) bool {
108
+	var pgErr *pgconnError
109
+	return errors.As(err, &pgErr) && pgErr.Code == "23505"
110
+}
internal/orgs/orgs.goadded
@@ -0,0 +1,63 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package orgs owns the organization domain: create, members,
4
+// invitations, suspend/restore, soft-delete, and the principals
5
+// resolver that unifies /{slug} routing across users + orgs.
6
+//
7
+// The package is the single entry point for the rest of the runtime —
8
+// web handlers, the policy engine, and the worker pool import here
9
+// rather than poking the sqlc surface directly.
10
+package orgs
11
+
12
+import (
13
+	"errors"
14
+	"log/slog"
15
+
16
+	"github.com/jackc/pgx/v5/pgxpool"
17
+
18
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
19
+	"github.com/tenseleyFlow/shithub/internal/auth/email"
20
+)
21
+
22
+// Deps wires this package against the runtime. Pool is required;
23
+// EmailSender is optional — when nil, invitation + role-change
24
+// notifications are skipped (matches the "headless test" pattern
25
+// the rest of the auth surface uses).
26
+type Deps struct {
27
+	Pool   *pgxpool.Pool
28
+	Logger *slog.Logger
29
+	Audit  *audit.Recorder
30
+	// Email side: only used by the invitation flow today; the
31
+	// org-suspend / org-deletion notification kinds are deferred
32
+	// to S29's lifecycle email work.
33
+	EmailSender email.Sender
34
+	EmailFrom   string
35
+	SiteName    string
36
+	BaseURL     string
37
+}
38
+
39
+// Errors surfaced by the orchestrator. Web handlers map these to
40
+// status codes + friendly messages.
41
+var (
42
+	ErrEmptySlug             = errors.New("orgs: slug is required")
43
+	ErrSlugTooLong           = errors.New("orgs: slug too long (max 39)")
44
+	ErrSlugInvalid           = errors.New("orgs: slug must be lowercase letters, digits, and hyphens")
45
+	ErrSlugReserved          = errors.New("orgs: slug is reserved")
46
+	ErrSlugTaken             = errors.New("orgs: slug is already in use")
47
+	ErrOrgNotFound           = errors.New("orgs: org not found")
48
+	ErrUserNotFound          = errors.New("orgs: user not found")
49
+	ErrNotAMember            = errors.New("orgs: user is not a member of this org")
50
+	ErrAlreadyMember         = errors.New("orgs: user is already a member of this org")
51
+	ErrLastOwner             = errors.New("orgs: cannot remove or demote the only owner; transfer ownership first")
52
+	ErrInvitationNotFound    = errors.New("orgs: invitation not found")
53
+	ErrInvitationExpired     = errors.New("orgs: invitation has expired")
54
+	ErrInvitationConsumed    = errors.New("orgs: invitation already accepted, declined, or canceled")
55
+	ErrInvitationDuplicate   = errors.New("orgs: a pending invitation for this target already exists")
56
+	ErrInvalidInvitationKind = errors.New("orgs: invitation must target either a user or an email")
57
+	ErrSuspended             = errors.New("orgs: org is suspended")
58
+	ErrDeleted               = errors.New("orgs: org is soft-deleted")
59
+)
60
+
61
+// InvitationLifetime is how long a fresh invitation is honored before
62
+// the recipient must request a new one. 7 days matches the spec.
63
+const InvitationLifetime = 7 * 24 * 60 * 60 // seconds; converted to time.Duration at call sites
internal/orgs/pgerror.goadded
@@ -0,0 +1,10 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package orgs
4
+
5
+import "github.com/jackc/pgx/v5/pgconn"
6
+
7
+// pgconnError aliases pgconn.PgError so create.go's isUniqueViolation
8
+// helper can errors.As to a private name without leaking the import
9
+// to every file in the package.
10
+type pgconnError = pgconn.PgError
internal/web/handlers/repo/code.gomodified
@@ -148,17 +148,20 @@ func (h *Handlers) codeTree(w http.ResponseWriter, r *http.Request) {
148
 	readmeHTML := h.findAndRenderREADME(r, cc, entries)
148
 	readmeHTML := h.findAndRenderREADME(r, cc, entries)
149
 
149
 
150
 	h.d.Render.RenderPage(w, r, "repo/tree", map[string]any{
150
 	h.d.Render.RenderPage(w, r, "repo/tree", map[string]any{
151
-		"Title":     cc.row.Name + " · " + cc.owner,
151
+		"Title":         cc.row.Name + " · " + cc.owner,
152
-		"CSRFToken": middleware.CSRFTokenForRequest(r),
152
+		"CSRFToken":     middleware.CSRFTokenForRequest(r),
153
-		"Owner":     cc.owner,
153
+		"Owner":         cc.owner,
154
-		"Repo":      cc.row,
154
+		"Repo":          cc.row,
155
-		"Ref":       cc.ref,
155
+		"Ref":           cc.ref,
156
-		"Path":      cc.subpath,
156
+		"Path":          cc.subpath,
157
-		"Crumbs":    breadcrumbs(cc.owner, cc.row.Name, cc.ref, cc.subpath),
157
+		"Crumbs":        breadcrumbs(cc.owner, cc.row.Name, cc.ref, cc.subpath),
158
-		"Entries":   entries,
158
+		"Entries":       entries,
159
-		"Branches":  cc.refs.Branches,
159
+		"Branches":      cc.refs.Branches,
160
-		"Tags":      cc.refs.Tags,
160
+		"Tags":          cc.refs.Tags,
161
-		"README":    template.HTML(readmeHTML), //nolint:gosec // sanitized by mdrender
161
+		"README":        template.HTML(readmeHTML), //nolint:gosec // sanitized by mdrender
162
+		"HTTPSCloneURL": h.cloneHTTPS(cc.owner, cc.row.Name),
163
+		"SSHEnabled":    h.d.CloneURLs.SSHEnabled,
164
+		"SSHCloneURL":   h.cloneSSH(cc.owner, cc.row.Name),
162
 	})
165
 	})
163
 }
166
 }
164
 
167
 
internal/web/handlers/repo/repo.gomodified
@@ -42,6 +42,17 @@ type CloneURLs struct {
42
 	SSHHost    string // e.g. "git@shithub.example" — only used when SSHEnabled
42
 	SSHHost    string // e.g. "git@shithub.example" — only used when SSHEnabled
43
 }
43
 }
44
 
44
 
45
+// cloneHTTPS / cloneSSH compose the per-repo URL strings that every
46
+// "Code" view drops into the clone dropdown. Centralized so the
47
+// per-template plumbing in code views and the repo home stay
48
+// consistent (and switching to a `git://` later is a one-line edit).
49
+func (h *Handlers) cloneHTTPS(owner, name string) string {
50
+	return h.d.CloneURLs.BaseURL + "/" + owner + "/" + name + ".git"
51
+}
52
+func (h *Handlers) cloneSSH(owner, name string) string {
53
+	return h.d.CloneURLs.SSHHost + ":" + owner + "/" + name + ".git"
54
+}
55
+
45
 // Deps wires the handler set.
56
 // Deps wires the handler set.
46
 type Deps struct {
57
 type Deps struct {
47
 	Logger    *slog.Logger
58
 	Logger    *slog.Logger
@@ -221,9 +232,9 @@ func (h *Handlers) repoHome(w http.ResponseWriter, r *http.Request) {
221
 		"Owner":         owner,
232
 		"Owner":         owner,
222
 		"Repo":          row,
233
 		"Repo":          row,
223
 		"DefaultBranch": row.DefaultBranch,
234
 		"DefaultBranch": row.DefaultBranch,
224
-		"HTTPSCloneURL": h.d.CloneURLs.BaseURL + "/" + owner + "/" + row.Name + ".git",
235
+		"HTTPSCloneURL": h.cloneHTTPS(owner, row.Name),
225
 		"SSHEnabled":    h.d.CloneURLs.SSHEnabled,
236
 		"SSHEnabled":    h.d.CloneURLs.SSHEnabled,
226
-		"SSHCloneURL":   h.d.CloneURLs.SSHHost + ":" + owner + "/" + row.Name + ".git",
237
+		"SSHCloneURL":   h.cloneSSH(owner, row.Name),
227
 	}
238
 	}
228
 
239
 
229
 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
240
 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
internal/web/render/octicons.gomodified
@@ -34,6 +34,11 @@ func BuiltinOcticons() OcticonResolver {
34
 		// S29: notification bell for the top-bar inbox link.
34
 		// S29: notification bell for the top-bar inbox link.
35
 		"bell": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
35
 		"bell": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
36
 			`><path d="M8 16a2 2 0 0 0 1.985-1.75c.017-.137-.097-.25-.235-.25h-3.5c-.138 0-.252.113-.235.25A2 2 0 0 0 8 16zM3 5a5 5 0 0 1 10 0v2.947c0 .05.015.098.042.139l1.703 2.555A1.519 1.519 0 0 1 13.482 13H2.518a1.519 1.519 0 0 1-1.263-2.36l1.703-2.554A.255.255 0 0 0 3 7.947zm5-3.5A3.5 3.5 0 0 0 4.5 5v2.947c0 .346-.102.683-.294.97l-1.703 2.556a.018.018 0 0 0-.003.01.017.017 0 0 0 .002.012.017.017 0 0 0 .015.005h10.964a.017.017 0 0 0 .016-.005.018.018 0 0 0 0-.022l-1.703-2.555a1.749 1.749 0 0 1-.294-.97V5A3.5 3.5 0 0 0 8 1.5z"/></svg>`),
36
 			`><path d="M8 16a2 2 0 0 0 1.985-1.75c.017-.137-.097-.25-.235-.25h-3.5c-.138 0-.252.113-.235.25A2 2 0 0 0 8 16zM3 5a5 5 0 0 1 10 0v2.947c0 .05.015.098.042.139l1.703 2.555A1.519 1.519 0 0 1 13.482 13H2.518a1.519 1.519 0 0 1-1.263-2.36l1.703-2.554A.255.255 0 0 0 3 7.947zm5-3.5A3.5 3.5 0 0 0 4.5 5v2.947c0 .346-.102.683-.294.97l-1.703 2.556a.018.018 0 0 0-.003.01.017.017 0 0 0 .002.012.017.017 0 0 0 .015.005h10.964a.017.017 0 0 0 .016-.005.018.018 0 0 0 0-.022l-1.703-2.555a1.749 1.749 0 0 1-.294-.97V5A3.5 3.5 0 0 0 8 1.5z"/></svg>`),
37
+		// Repo "Code" dropdown chrome (clone widget).
38
+		"download": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
39
+			`><path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14zm5.78-2.92a.749.749 0 0 1-1.06 0L3.72 7.78a.749.749 0 1 1 1.06-1.06L7.25 9.19V1.75a.75.75 0 0 1 1.5 0v7.44l2.47-2.47a.749.749 0 1 1 1.06 1.06z"/></svg>`),
40
+		"copy": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
41
+			`><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"/><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/></svg>`),
37
 		// Tree-listing icons used by the code tab (S17).
42
 		// Tree-listing icons used by the code tab (S17).
38
 		"directory": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
43
 		"directory": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
39
 			`><path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3h-6.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.358 1.26 5.769 1 5.142 1z"/></svg>`),
44
 			`><path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3h-6.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.358 1.26 5.769 1 5.142 1z"/></svg>`),
internal/web/static/css/shithub.cssmodified
@@ -945,6 +945,37 @@ code {
945
 }
945
 }
946
 .shithub-ref-panel strong { display: block; margin: 0.4rem 0 0.2rem; font-size: 0.75rem; color: var(--fg-muted); text-transform: uppercase; }
946
 .shithub-ref-panel strong { display: block; margin: 0.4rem 0 0.2rem; font-size: 0.75rem; color: var(--fg-muted); text-transform: uppercase; }
947
 
947
 
948
+.shithub-clone-dropdown { position: relative; }
949
+.shithub-clone-dropdown > summary { list-style: none; display: inline-flex; align-items: center; gap: 0.35rem; cursor: pointer; }
950
+.shithub-clone-dropdown > summary::-webkit-details-marker { display: none; }
951
+.shithub-clone-panel {
952
+  position: absolute;
953
+  z-index: 30;
954
+  top: calc(100% + 0.4rem);
955
+  right: 0;
956
+  min-width: 320px;
957
+  background: var(--canvas-default);
958
+  border: 1px solid var(--border-default);
959
+  border-radius: 6px;
960
+  padding: 0.75rem;
961
+  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
962
+}
963
+.shithub-clone-row + .shithub-clone-row { margin-top: 0.6rem; }
964
+.shithub-clone-row label { display: block; font-size: 0.75rem; color: var(--fg-muted); margin-bottom: 0.25rem; }
965
+.shithub-clone-input { display: flex; gap: 0.4rem; }
966
+.shithub-clone-input input {
967
+  flex: 1;
968
+  padding: 0.3rem 0.5rem;
969
+  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
970
+  font-size: 0.8rem;
971
+  border: 1px solid var(--border-default);
972
+  border-radius: 4px;
973
+  background: var(--canvas-subtle);
974
+  color: var(--fg-default);
975
+}
976
+.shithub-clone-input button { padding: 0.3rem 0.5rem; }
977
+.shithub-clone-hint { margin: 0.6rem 0 0; font-size: 0.75rem; color: var(--fg-muted); }
978
+
948
 .shithub-tree {
979
 .shithub-tree {
949
   width: 100%;
980
   width: 100%;
950
   border-collapse: collapse;
981
   border-collapse: collapse;
internal/web/templates/_layout.htmlmodified
@@ -34,6 +34,36 @@
34
 {{ template "page" . }}
34
 {{ template "page" . }}
35
 </main>
35
 </main>
36
 {{ template "footer" . }}
36
 {{ template "footer" . }}
37
+<script>
38
+  // Click-to-copy on clone-dropdown buttons. Walk every button with
39
+  // data-clone-copy="<selector>" and wire it once. Falls back to a
40
+  // visual flash on the input when navigator.clipboard is unavailable
41
+  // (older browsers, file:// dev pages, etc).
42
+  (function () {
43
+    var buttons = document.querySelectorAll("[data-clone-copy]");
44
+    buttons.forEach(function (btn) {
45
+      btn.addEventListener("click", function () {
46
+        var sel = btn.getAttribute("data-clone-copy");
47
+        var input = document.querySelector(sel);
48
+        if (!input) return;
49
+        var ok = false;
50
+        try {
51
+          if (navigator.clipboard) {
52
+            navigator.clipboard.writeText(input.value);
53
+            ok = true;
54
+          }
55
+        } catch (e) { ok = false; }
56
+        if (!ok) {
57
+          input.select();
58
+          try { document.execCommand("copy"); ok = true; } catch (e) {}
59
+        }
60
+        var prev = btn.getAttribute("title") || "";
61
+        btn.setAttribute("title", ok ? "Copied" : "Press ⌘/Ctrl-C to copy");
62
+        setTimeout(function () { btn.setAttribute("title", prev); }, 1200);
63
+      });
64
+    });
65
+  })();
66
+</script>
37
 </body>
67
 </body>
38
 </html>
68
 </html>
39
 {{- end }}
69
 {{- end }}
internal/web/templates/repo/tree.htmlmodified
@@ -26,6 +26,28 @@
26
         </div>
26
         </div>
27
       </details>
27
       </details>
28
       <a href="/{{ .Owner }}/{{ .Repo.Name }}/find/{{ .Ref }}" class="shithub-button">Go to file</a>
28
       <a href="/{{ .Owner }}/{{ .Repo.Name }}/find/{{ .Ref }}" class="shithub-button">Go to file</a>
29
+      <details class="shithub-clone-dropdown">
30
+        <summary class="shithub-button shithub-button-primary">{{ octicon "download" }} Code</summary>
31
+        <div class="shithub-clone-panel" role="dialog" aria-label="Clone this repository">
32
+          <div class="shithub-clone-row">
33
+            <label for="clone-https">Clone with HTTPS</label>
34
+            <div class="shithub-clone-input">
35
+              <input id="clone-https" type="text" readonly value="{{ .HTTPSCloneURL }}" onclick="this.select()">
36
+              <button type="button" class="shithub-button" data-clone-copy="#clone-https" title="Copy">{{ octicon "copy" }}</button>
37
+            </div>
38
+          </div>
39
+          {{ if .SSHEnabled }}
40
+          <div class="shithub-clone-row">
41
+            <label for="clone-ssh">Clone with SSH</label>
42
+            <div class="shithub-clone-input">
43
+              <input id="clone-ssh" type="text" readonly value="{{ .SSHCloneURL }}" onclick="this.select()">
44
+              <button type="button" class="shithub-button" data-clone-copy="#clone-ssh" title="Copy">{{ octicon "copy" }}</button>
45
+            </div>
46
+          </div>
47
+          {{ end }}
48
+          <p class="shithub-clone-hint">Use Git or checkout with SVN using the web URL.</p>
49
+        </div>
50
+      </details>
29
     </div>
51
     </div>
30
   </header>
52
   </header>
31
 
53