chore: added clone button eec14b33
Steve · 2026-06-09 08:12 7 file(s) · +173 −36
apps/kepler/.env.example +7 −0
8 8
KEPLER_SITE_NAME=kepler
9 9
# Public base URL, used for Open Graph / social meta tags
10 10
KEPLER_BASE_URL=http://localhost:4747
11 +
# HTTPS entry in the repo-home Clone menu (URL = <base>/<repo>.git).
12 +
# Point at the host that actually serves git (e.g. softserve). Empty = omit.
13 +
KEPLER_CLONE_BASE_URL=
14 +
# SSH (scp-style) entry in the Clone menu (URL = <user@host>:<repo>.git).
15 +
# Include the user, e.g. git@git.example.com. Empty = omit.
16 +
# If both clone vars are empty the Clone button is hidden.
17 +
KEPLER_CLONE_SSH_HOST=
apps/kepler/README.md +5 −0
33 33
| `KEPLER_REPO_ROOT` | `./repos` | dir of bare repos (`*.git/`) or normal repos |
34 34
| `KEPLER_SITE_NAME` | `kepler` | shown in header + feed |
35 35
| `KEPLER_BASE_URL` | `http://localhost:4747` | public URL for Open Graph / social meta tags |
36 +
| `KEPLER_CLONE_BASE_URL` | _(empty)_ | HTTPS entry in the repo-home Clone menu; URL = `<base>/<repo>.git`. Point at the host that actually serves git (e.g. softserve), not kepler |
37 +
| `KEPLER_CLONE_SSH_HOST` | _(empty)_ | SSH (scp-style) entry in the Clone menu; URL = `<user@host>:<repo>.git`, e.g. `git@git.example.com`. Include the user |
38 +
39 +
The repo-home **Clone** dropdown shows whichever of the two are set; if both
40 +
are empty the button is hidden.
36 41
37 42
## Repo discovery
38 43
apps/kepler/app.go +27 −23
11 11
var appFS embed.FS
12 12
13 13
type App struct {
14 -
	Log       *slog.Logger
15 -
	Templates map[string]*template.Template
16 -
	RepoRoot  string
17 -
	SiteName  string
18 -
	BaseURL   string
14 +
	Log          *slog.Logger
15 +
	Templates    map[string]*template.Template
16 +
	RepoRoot     string
17 +
	SiteName     string
18 +
	BaseURL      string
19 +
	CloneBaseURL string
20 +
	CloneSSHHost string
19 21
}
20 22
21 23
type pageBase struct {
31 33
32 34
type repoPageData struct {
33 35
	pageBase
34 -
	Repo         RepoSummary
35 -
	Ref          string
36 -
	ReadmeHTML   template.HTML
37 -
	HasReadme    bool
38 -
	Branches     []RefInfo
39 -
	Tags         []RefInfo
40 -
	Commits      []CommitInfo
41 -
	DefaultRef   string
42 -
	LatestCommit CommitInfo
43 -
	HasLatest    bool
44 -
	Entries      []TreeEntry
36 +
	Repo          RepoSummary
37 +
	Ref           string
38 +
	ReadmeHTML    template.HTML
39 +
	HasReadme     bool
40 +
	Branches      []RefInfo
41 +
	Tags          []RefInfo
42 +
	Commits       []CommitInfo
43 +
	DefaultRef    string
44 +
	LatestCommit  CommitInfo
45 +
	HasLatest     bool
46 +
	Entries       []TreeEntry
47 +
	CloneHTTPSURL string
48 +
	CloneSSHURL   string
45 49
}
46 50
47 51
type treePageData struct {
56 60
57 61
type blobPageData struct {
58 62
	pageBase
59 -
	Repo        RepoSummary
60 -
	Ref         string
61 -
	DefaultRef  string
62 -
	Path        string
63 -
	Breadcrumbs []Breadcrumb
64 -
	Binary      bool
65 -
	Size        int64
63 +
	Repo            RepoSummary
64 +
	Ref             string
65 +
	DefaultRef      string
66 +
	Path            string
67 +
	Breadcrumbs     []Breadcrumb
68 +
	Binary          bool
69 +
	Size            int64
66 70
	HighlightedHTML template.HTML
67 71
}
68 72
apps/kepler/handlers.go +7 −0
53 53
		Entries:      entries,
54 54
	}
55 55
56 +
	if a.CloneBaseURL != "" {
57 +
		data.CloneHTTPSURL = a.CloneBaseURL + "/" + summary.Name + ".git"
58 +
	}
59 +
	if a.CloneSSHHost != "" {
60 +
		data.CloneSSHURL = a.CloneSSHHost + ":" + summary.Name + ".git"
61 +
	}
62 +
56 63
	if c, err := resolveRef(repo, summary.DefaultRef); err == nil {
57 64
		if tree, err := c.Tree(); err == nil {
58 65
			if src, ok := findReadme(tree); ok {
apps/kepler/main.go +9 −5
17 17
	root := config.Getenv("KEPLER_REPO_ROOT", "./repos")
18 18
	siteName := config.Getenv("KEPLER_SITE_NAME", "kepler")
19 19
	baseURL := config.Getenv("KEPLER_BASE_URL", "http://localhost:4747")
20 +
	cloneBaseURL := config.Getenv("KEPLER_CLONE_BASE_URL", "")
21 +
	cloneSSHHost := config.Getenv("KEPLER_CLONE_SSH_HOST", "")
20 22
21 23
	tmpl, err := buildTemplates()
22 24
	if err != nil {
24 26
	}
25 27
26 28
	app := &App{
27 -
		Log:       logger,
28 -
		Templates: tmpl,
29 -
		RepoRoot:  root,
30 -
		SiteName:  siteName,
31 -
		BaseURL:   strings.TrimRight(baseURL, "/"),
29 +
		Log:          logger,
30 +
		Templates:    tmpl,
31 +
		RepoRoot:     root,
32 +
		SiteName:     siteName,
33 +
		BaseURL:      strings.TrimRight(baseURL, "/"),
34 +
		CloneBaseURL: strings.TrimRight(cloneBaseURL, "/"),
35 +
		CloneSSHHost: strings.TrimSpace(cloneSSHHost),
32 36
	}
33 37
34 38
	addr := config.Getenv("HOST", "127.0.0.1") + ":" + config.Getenv("PORT", "4747")
apps/kepler/static/styles.css +57 −2
138 138
    .repo-nav .tab:first-of-type { margin-left: 0; }
139 139
    .tab { padding: 0.4rem 0.6rem; }
140 140
141 +
    .repo-head { flex-direction: column; gap: 0.75rem; align-items: stretch; }
142 +
    .clone-toggle { width: 100%; }
143 +
    .clone-menu { left: 0; right: 0; min-width: 0; }
141 144
    .last-commit { flex-wrap: wrap; gap: 0.25rem 0.75rem; padding: 0.5rem 0.75rem; }
142 145
    .last-commit .subject {
143 146
        flex: 1 1 100%;
170 173
.breadcrumbs { font-size: 13px; margin-bottom: 1rem; color: var(--fg-dim); }
171 174
.breadcrumbs a { color: var(--fg); }
172 175
173 -
/* --- Last commit bar --- */
176 +
/* --- Repo head (last commit + clone) --- */
177 +
178 +
.repo-head {
179 +
    display: flex;
180 +
    align-items: stretch;
181 +
    gap: 1rem;
182 +
    margin-bottom: 1rem;
183 +
}
174 184
175 185
.last-commit {
176 186
    display: flex;
180 190
    padding: 0.4rem 0.75rem;
181 191
    font-size: 13px;
182 192
    overflow: hidden;
183 -
    margin-bottom: 1rem;
193 +
    flex: 1;
194 +
    min-width: 0;
184 195
}
185 196
.last-commit .subject { flex: 1; }
197 +
198 +
.clone-dropdown { position: relative; }
199 +
.clone-toggle {
200 +
    height: 100%;
201 +
    background: var(--bg-alt);
202 +
    border: 1px solid var(--border);
203 +
    color: var(--fg);
204 +
    padding: 0.4rem 0.9rem;
205 +
    font: inherit;
206 +
    font-size: 13px;
207 +
    cursor: pointer;
208 +
    white-space: nowrap;
209 +
}
210 +
.clone-toggle::after { content: " \25be"; }
211 +
.clone-toggle:hover { background: var(--border); }
212 +
213 +
.clone-menu {
214 +
    position: absolute;
215 +
    top: calc(100% + 4px);
216 +
    right: 0;
217 +
    z-index: 10;
218 +
    min-width: 18rem;
219 +
    background: var(--bg);
220 +
    border: 1px solid var(--border);
221 +
    padding: 0.5rem;
222 +
    display: flex;
223 +
    flex-direction: column;
224 +
    gap: 0.5rem;
225 +
}
226 +
.clone-menu[hidden] { display: none; }
227 +
.clone-option { display: flex; flex-direction: column; gap: 0.25rem; }
228 +
.clone-label { font-size: 11px; color: var(--fg-dim); text-transform: uppercase; }
229 +
.clone-option .copy-btn { align-self: flex-end; }
230 +
.clone-url {
231 +
    display: block;
232 +
    color: var(--fg);
233 +
    font-family: var(--mono);
234 +
    font-size: 12px;
235 +
    background: var(--bg-alt);
236 +
    border: 1px solid var(--border);
237 +
    padding: 0.25rem 0.4rem;
238 +
    white-space: nowrap;
239 +
    overflow: auto hidden;
240 +
}
186 241
187 242
/* --- Repo home grid --- */
188 243
apps/kepler/templates/repo.html +61 −6
6 6
<a href="/{{.Repo.Name}}/refs" class="tab">refs</a>
7 7
{{end}}
8 8
{{define "content"}}
9 -
{{if .HasLatest}}
10 -
<div class="last-commit">
11 -
    <span class="hash"><a href="/{{.Repo.Name}}/commit/{{.LatestCommit.SHA}}">{{.LatestCommit.ShortSHA}}</a></span>
12 -
    <span class="subject">{{.LatestCommit.Subject}}</span>
13 -
    <span class="author">{{.LatestCommit.Author}}</span>
14 -
    <span class="date">{{timeAgo .LatestCommit.When}}</span>
9 +
{{if or .HasLatest .CloneHTTPSURL .CloneSSHURL}}
10 +
<div class="repo-head">
11 +
    {{if .HasLatest}}
12 +
    <div class="last-commit">
13 +
        <span class="hash"><a href="/{{.Repo.Name}}/commit/{{.LatestCommit.SHA}}">{{.LatestCommit.ShortSHA}}</a></span>
14 +
        <span class="subject">{{.LatestCommit.Subject}}</span>
15 +
        <span class="author">{{.LatestCommit.Author}}</span>
16 +
        <span class="date">{{timeAgo .LatestCommit.When}}</span>
17 +
    </div>
18 +
    {{end}}
19 +
    {{if or .CloneHTTPSURL .CloneSSHURL}}
20 +
    <div class="clone-dropdown">
21 +
        <button type="button" class="clone-toggle" aria-expanded="false">Clone</button>
22 +
        <div class="clone-menu" hidden>
23 +
            {{if .CloneHTTPSURL}}
24 +
            <div class="clone-option">
25 +
                <span class="clone-label">HTTPS</span>
26 +
                <code class="clone-url">{{.CloneHTTPSURL}}</code>
27 +
                <button type="button" class="copy-btn clone-copy" data-clone="{{.CloneHTTPSURL}}">copy</button>
28 +
            </div>
29 +
            {{end}}
30 +
            {{if .CloneSSHURL}}
31 +
            <div class="clone-option">
32 +
                <span class="clone-label">SSH</span>
33 +
                <code class="clone-url">{{.CloneSSHURL}}</code>
34 +
                <button type="button" class="copy-btn clone-copy" data-clone="{{.CloneSSHURL}}">copy</button>
35 +
            </div>
36 +
            {{end}}
37 +
        </div>
38 +
    </div>
39 +
    {{end}}
15 40
</div>
16 41
{{end}}
17 42
43 68
        {{end}}
44 69
    </section>
45 70
</div>
71 +
<script>
72 +
document.querySelectorAll(".clone-dropdown").forEach(function (dd) {
73 +
    var toggle = dd.querySelector(".clone-toggle");
74 +
    var menu = dd.querySelector(".clone-menu");
75 +
    toggle.addEventListener("click", function (e) {
76 +
        e.stopPropagation();
77 +
        var open = menu.hidden;
78 +
        menu.hidden = !open;
79 +
        toggle.setAttribute("aria-expanded", String(open));
80 +
    });
81 +
    document.addEventListener("click", function (e) {
82 +
        if (!dd.contains(e.target)) {
83 +
            menu.hidden = true;
84 +
            toggle.setAttribute("aria-expanded", "false");
85 +
        }
86 +
    });
87 +
});
88 +
document.querySelectorAll(".clone-copy").forEach(function (btn) {
89 +
    btn.addEventListener("click", async function () {
90 +
        try {
91 +
            await navigator.clipboard.writeText(btn.dataset.clone);
92 +
            var prev = btn.textContent;
93 +
            btn.textContent = "copied";
94 +
            setTimeout(function () { btn.textContent = prev; }, 1500);
95 +
        } catch (e) {
96 +
            btn.textContent = "failed";
97 +
        }
98 +
    });
99 +
});
100 +
</script>
46 101
{{end}}