Commit 9c8222e

Nick Faro committed on
Modernize repository navigation
commit 9c8222e62834db1837ea0bac48ef4617d360733f parent c2fd341
14 changed files +589−141
ModifiedREADME.md +12−5
@@ -79,7 +79,9 @@ The repository browser is served from that exact URL. Git appends
79 6. Publish `objects/info/packs`, individual refs, `HEAD`, and finally 79 6. Publish `objects/info/packs`, individual refs, `HEAD`, and finally
80 `info/refs`. 80 `info/refs`.
81 7. Generate changed source/raw pages, affected ancestor tree pages, and the 81 7. Generate changed source/raw pages, affected ancestor tree pages, and the
82 branch's commit and repository-summary pages. 82 branch's commit and repository-summary pages. A compact repository ref
83 manifest and per-branch file index power navigation without rebuilding every
84 existing page.
83 85
84 Git data is never rebuilt on an ordinary push. Two small commits produce two 86 Git data is never rebuilt on an ordinary push. Two small commits produce two
85 small packs. The mutable dumb-HTTP indexes are tiny and served with revalidation; 87 small packs. The mutable dumb-HTTP indexes are tiny and served with revalidation;
@@ -93,10 +95,13 @@ The generated browser includes:
93 - rendered GitHub-flavored Markdown README files; 95 - rendered GitHub-flavored Markdown README files;
94 - syntax highlighting and linked line numbers; 96 - syntax highlighting and linked line numbers;
95 - raw file URLs and image previews; 97 - raw file URLs and image previews;
96 - branch-aware paths; 98 - a live branch switcher with branch-aware paths;
99 - a searchable Go-to-file dialog;
97 - full commit history with contributor Gravatars; 100 - full commit history with contributor Gravatars;
98 - top contributors and Linguist-compatible language percentages; 101 - top contributors and Linguist-compatible language percentages;
99 - a clone popover with the public HTTPS URL; 102 - a clone popover with copy confirmation and the public HTTPS URL;
103 - a GitHub Importer shortcut for copying the repository and its history into a
104 new GitHub repository;
100 - an About panel derived from package metadata, the README, and the upstream 105 - an About panel derived from package metadata, the README, and the upstream
101 remote; 106 remote;
102 - incremental regeneration based on `git diff --name-status`. 107 - incremental regeneration based on `git diff --name-status`.
@@ -113,8 +118,10 @@ Contributor avatars are loaded from Gravatar using the normalized SHA-256 hash
113 of each commit author's email address. Gravatar's generated initials are used 118 of each commit author's email address. Gravatar's generated initials are used
114 when an author has no uploaded image. 119 when an author has no uploaded image.
115 120
116 The UI is an original implementation using familiar source-hosting patterns; 121 The browser uses [Primer Octicons](https://github.com/primer/octicons), GitHub's
117 it contains no copied GitHub HTML, CSS, logos, or assets. 122 MIT-licensed icon set. See `THIRD_PARTY_NOTICES.md` for attribution. The "Fork
123 to GitHub" action uses GitHub Importer; it creates an independent repository and
124 does not add the imported repository to a GitHub fork network.
118 125
119 ## Current constraints 126 ## Current constraints
120 127
AddedTHIRD_PARTY_NOTICES.md +28−0
@@ -0,0 +1,28 @@
1 # Third-party notices
2
3 ## Primer Octicons
4
5 Selected SVG paths from Primer Octicons 19.31.0 are embedded in Dumbforge's
6 generated repository browser.
7
8 Copyright (c) 2026 GitHub Inc.
9
10 MIT License
11
12 Permission is hereby granted, free of charge, to any person obtaining a copy
13 of this software and associated documentation files (the "Software"), to deal
14 in the Software without restriction, including without limitation the rights
15 to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16 copies of the Software, and to permit persons to whom the Software is
17 furnished to do so, subject to the following conditions:
18
19 The above copyright notice and this permission notice shall be included in all
20 copies or substantial portions of the Software.
21
22 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28 SOFTWARE.
Modifiedcmd/dumbforge/main.go +6−0
@@ -126,10 +126,16 @@ func siteCommand(ctx context.Context, args []string) (err error) {
126 generator.Progress = func(format string, args ...any) { 126 generator.Progress = func(format string, args ...any) {
127 fmt.Printf("dumbforge: "+format+"\n", args...) 127 fmt.Printf("dumbforge: "+format+"\n", args...)
128 } 128 }
129 generator.SetBranches(metadata.BranchNames(), strings.TrimPrefix(metadata.Head, "refs/heads/"))
129 build, err := generator.BuildBranch(ctx, *branch, "", oid, metadata.Head == "refs/heads/"+*branch) 130 build, err := generator.BuildBranch(ctx, *branch, "", oid, metadata.Head == "refs/heads/"+*branch)
130 if err != nil { 131 if err != nil {
131 return err 132 return err
132 } 133 }
134 navigation, err := generator.RepositoryNavigationBuild(nil)
135 if err != nil {
136 return err
137 }
138 build.Pages = append(build.Pages, navigation.Pages...)
133 fmt.Printf("dumbforge: generated %d site object(s)\n", len(build.Pages)) 139 fmt.Printf("dumbforge: generated %d site object(s)\n", len(build.Pages))
134 if err := lock.Refresh(ctx); err != nil { 140 if err := lock.Refresh(ctx); err != nil {
135 return err 141 return err
Modifiedinternal/helper/helper.go +14−1
@@ -137,11 +137,17 @@ func publishSite(ctx context.Context, publisher *publish.Publisher, result publi
137 generator.Progress = func(format string, args ...any) { 137 generator.Progress = func(format string, args ...any) {
138 fmt.Fprintf(stderr, "dumbforge: "+format+"\n", args...) 138 fmt.Fprintf(stderr, "dumbforge: "+format+"\n", args...)
139 } 139 }
140 generator.SetBranches(result.Branches, strings.TrimPrefix(result.Head, "refs/heads/"))
141 var deletedBranches []string
140 for _, update := range result.Updates { 142 for _, update := range result.Updates {
141 if !strings.HasPrefix(update.Dst, "refs/heads/") || update.NewOID == "" { 143 if !strings.HasPrefix(update.Dst, "refs/heads/") {
142 continue 144 continue
143 } 145 }
144 branch := strings.TrimPrefix(update.Dst, "refs/heads/") 146 branch := strings.TrimPrefix(update.Dst, "refs/heads/")
147 if update.NewOID == "" {
148 deletedBranches = append(deletedBranches, branch)
149 continue
150 }
145 build, err := generator.BuildBranch(ctx, branch, update.OldOID, update.NewOID, update.Dst == result.Head) 151 build, err := generator.BuildBranch(ctx, branch, update.OldOID, update.NewOID, update.Dst == result.Head)
146 if err == nil { 152 if err == nil {
147 err = generator.Publish(ctx, publisher.Store, build) 153 err = generator.Publish(ctx, publisher.Store, build)
@@ -150,6 +156,13 @@ func publishSite(ctx context.Context, publisher *publish.Publisher, result publi
150 fmt.Fprintf(stderr, "dumbforge: warning: site publication failed: %v\n", err) 156 fmt.Fprintf(stderr, "dumbforge: warning: site publication failed: %v\n", err)
151 } 157 }
152 } 158 }
159 navigation, err := generator.RepositoryNavigationBuild(deletedBranches)
160 if err == nil {
161 err = generator.Publish(ctx, publisher.Store, navigation)
162 }
163 if err != nil {
164 fmt.Fprintf(stderr, "dumbforge: warning: repository navigation publication failed: %v\n", err)
165 }
153 } 166 }
154 167
155 func parsePushes(specs []string) ([]publish.Update, error) { 168 func parsePushes(specs []string) ([]publish.Update, error) {
Modifiedinternal/publish/metadata.go +11−0
@@ -22,6 +22,17 @@ type Metadata struct {
22 Packs map[string]struct{} 22 Packs map[string]struct{}
23 } 23 }
24 24
25 func (m Metadata) BranchNames() []string {
26 var names []string
27 for name := range m.Refs {
28 if strings.HasPrefix(name, "refs/heads/") {
29 names = append(names, strings.TrimPrefix(name, "refs/heads/"))
30 }
31 }
32 sort.Strings(names)
33 return names
34 }
35
25 func LoadMetadata(ctx context.Context, store *s3store.Store) (Metadata, error) { 36 func LoadMetadata(ctx context.Context, store *s3store.Store) (Metadata, error) {
26 metadata := Metadata{ 37 metadata := Metadata{
27 Refs: map[string]Ref{}, 38 Refs: map[string]Ref{},
Modifiedinternal/publish/metadata_test.go +12−0
@@ -33,3 +33,15 @@ func TestPackListRoundTrip(t *testing.T) {
33 t.Fatalf("got %#v, want %#v", got, want) 33 t.Fatalf("got %#v, want %#v", got, want)
34 } 34 }
35 } 35 }
36
37 func TestBranchNames(t *testing.T) {
38 metadata := Metadata{Refs: map[string]Ref{
39 "refs/heads/topic": {OID: "1111111111111111111111111111111111111111"},
40 "refs/tags/v1": {OID: "2222222222222222222222222222222222222222"},
41 "refs/heads/main": {OID: "3333333333333333333333333333333333333333"},
42 }}
43 want := []string{"main", "topic"}
44 if got := metadata.BranchNames(); !reflect.DeepEqual(got, want) {
45 t.Fatalf("BranchNames() = %#v, want %#v", got, want)
46 }
47 }
Modifiedinternal/publish/publish.go +2−0
@@ -33,6 +33,7 @@ type Result struct {
33 PackName string 33 PackName string
34 PackObjects uint32 34 PackObjects uint32
35 Head string 35 Head string
36 Branches []string
36 } 37 }
37 38
38 type Publisher struct { 39 type Publisher struct {
@@ -131,6 +132,7 @@ func (p *Publisher) Push(ctx context.Context, updates []Update, expected map[str
131 metadata.Head = chooseHead(resolved) 132 metadata.Head = chooseHead(resolved)
132 } 133 }
133 result.Head = metadata.Head 134 result.Head = metadata.Head
135 result.Branches = metadata.BranchNames()
134 if err := lock.Refresh(ctx); err != nil { 136 if err := lock.Refresh(ctx); err != nil {
135 return Result{}, err 137 return Result{}, err
136 } 138 }
Modifiedinternal/site/icons.go +25−18
@@ -2,26 +2,33 @@ package site
2 2
3 import "html/template" 3 import "html/template"
4 4
5 // Paths are selected from Primer Octicons 19.31.0, distributed under the MIT
6 // license. See THIRD_PARTY_NOTICES.md.
7 var octiconPaths = map[string]string{
8 "repo": `<path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8ZM5 12.25a.25.25 0 0 1 .25-.25h3.5a.25.25 0 0 1 .25.25v3.25a.25.25 0 0 1-.4.2l-1.45-1.087a.249.249 0 0 0-.3 0L5.4 15.7a.25.25 0 0 1-.4-.2Z"/>`,
9 "code": `<path d="m11.28 3.22 4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L13.94 8l-3.72-3.72a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215Zm-6.56 0a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042L2.06 8l3.72 3.72a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L.47 8.53a.75.75 0 0 1 0-1.06Z"/>`,
10 "history": `<path d="m.427 1.927 1.215 1.215a8.002 8.002 0 1 1-1.6 5.685.75.75 0 1 1 1.493-.154 6.5 6.5 0 1 0 1.18-4.458l1.358 1.358A.25.25 0 0 1 3.896 6H.25A.25.25 0 0 1 0 5.75V2.104a.25.25 0 0 1 .427-.177ZM7.75 4a.75.75 0 0 1 .75.75v2.992l2.028.812a.75.75 0 0 1-.557 1.392l-2.5-1A.751.751 0 0 1 7 8.25v-3.5A.75.75 0 0 1 7.75 4Z"/>`,
11 "branch": `<path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/>`,
12 "chevron": `<path d="M12.78 5.22a.749.749 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.06 0L3.22 6.28a.749.749 0 1 1 1.06-1.06L8 8.939l3.72-3.719a.749.749 0 0 1 1.06 0Z"/>`,
13 "download": `<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 14Z"/><path d="M7.25 7.689V2a.75.75 0 0 1 1.5 0v5.689l1.97-1.969a.749.749 0 1 1 1.06 1.06l-3.25 3.25a.749.749 0 0 1-1.06 0L4.22 6.78a.749.749 0 1 1 1.06-1.06l1.97 1.969Z"/>`,
14 "copy": `<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"/>`,
15 "file": `<path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z"/>`,
16 "folder": `<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 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z"/>`,
17 "back": `<path d="M7.78 12.53a.75.75 0 0 1-1.06 0L2.47 8.28a.75.75 0 0 1 0-1.06l4.25-4.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042L4.81 7h7.44a.75.75 0 0 1 0 1.5H4.81l2.97 2.97a.75.75 0 0 1 0 1.06Z"/>`,
18 "book": `<path d="M0 1.75A.75.75 0 0 1 .75 1h4.253c1.227 0 2.317.59 3 1.501A3.743 3.743 0 0 1 11.006 1h4.245a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-4.507a2.25 2.25 0 0 0-1.591.659l-.622.621a.75.75 0 0 1-1.06 0l-.622-.621A2.25 2.25 0 0 0 5.258 13H.75a.75.75 0 0 1-.75-.75Zm7.251 10.324.004-5.073-.002-2.253A2.25 2.25 0 0 0 5.003 2.5H1.5v9h3.757a3.75 3.75 0 0 1 1.994.574ZM8.755 4.75l-.004 7.322a3.752 3.752 0 0 1 1.992-.572H14.5v-9h-3.495a2.25 2.25 0 0 0-2.25 2.25Z"/>`,
19 "link": `<path d="m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z"/>`,
20 "people": `<path d="M2 5.5a3.5 3.5 0 1 1 5.898 2.549 5.508 5.508 0 0 1 3.034 4.084.75.75 0 1 1-1.482.235 4 4 0 0 0-7.9 0 .75.75 0 0 1-1.482-.236A5.507 5.507 0 0 1 3.102 8.05 3.493 3.493 0 0 1 2 5.5ZM11 4a3.001 3.001 0 0 1 2.22 5.018 5.01 5.01 0 0 1 2.56 3.012.749.749 0 0 1-.885.954.752.752 0 0 1-.549-.514 3.507 3.507 0 0 0-2.522-2.372.75.75 0 0 1-.574-.73v-.352a.75.75 0 0 1 .416-.672A1.5 1.5 0 0 0 11 5.5.75.75 0 0 1 11 4Zm-5.5-.5a2 2 0 1 0-.001 3.999A2 2 0 0 0 5.5 3.5Z"/>`,
21 "external": `<path d="M3.75 2h3.5a.75.75 0 0 1 0 1.5h-3.5a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-3.5a.75.75 0 0 1 1.5 0v3.5A1.75 1.75 0 0 1 12.25 14h-8.5A1.75 1.75 0 0 1 2 12.25v-8.5C2 2.784 2.784 2 3.75 2Zm6.854-1h4.146a.25.25 0 0 1 .25.25v4.146a.25.25 0 0 1-.427.177L13.03 4.03 9.28 7.78a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l3.75-3.75-1.543-1.543A.25.25 0 0 1 10.604 1Z"/>`,
22 "check": `<path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"/>`,
23 "fork": `<path d="M5 5.372v.878c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.878a2.25 2.25 0 1 1 1.5 0v.878a2.25 2.25 0 0 1-2.25 2.25h-1.5v2.128a2.251 2.251 0 1 1-1.5 0V8.5h-1.5A2.25 2.25 0 0 1 3.5 6.25v-.878a2.25 2.25 0 1 1 1.5 0ZM5 3.25a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Zm6.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm-3 8.75a.75.75 0 1 0-1.5 0 .75.75 0 0 0 0-1.5Z"/>`,
24 "search": `<path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/>`,
25 "close": `<path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z"/>`,
26 }
27
5 func icon(name string) template.HTML { 28 func icon(name string) template.HTML {
6 icons := map[string]string{ 29 path, ok := octiconPaths[name]
7 "repo": `<svg class="icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 2.25A1.25 1.25 0 0 1 4.25 1h8.25v12H5a2 2 0 0 0-2 2V2.25Z"/><path d="M5 12.75V1M6.75 4h3.75"/></svg>`,
8 "code": `<svg class="icon" viewBox="0 0 16 16" aria-hidden="true"><path d="m5.25 3.5-4 4.5 4 4.5M10.75 3.5l4 4.5-4 4.5M9.25 2 6.75 14"/></svg>`,
9 "history": `<svg class="icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M2.25 5.25V1.5M2.25 1.5H6M2.25 1.5l2.3 2.3A6 6 0 1 1 2 8.7M8 4.75V8l2.25 1.5"/></svg>`,
10 "branch": `<svg class="icon" viewBox="0 0 16 16" aria-hidden="true"><circle cx="4" cy="3" r="1.75"/><circle cx="4" cy="13" r="1.75"/><circle cx="12" cy="5" r="1.75"/><path d="M4 4.75v6.5M5.75 11.25h.75A5.5 5.5 0 0 0 12 5.75"/></svg>`,
11 "chevron": `<svg class="icon icon-chevron" viewBox="0 0 16 16" aria-hidden="true"><path d="m4 6 4 4 4-4"/></svg>`,
12 "download": `<svg class="icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.5v8M4.75 6.5 8 9.75l3.25-3.25M2.5 11v2.5h11V11"/></svg>`,
13 "copy": `<svg class="icon" viewBox="0 0 16 16" aria-hidden="true"><rect x="5" y="5" width="8.5" height="8.5" rx="1.25"/><path d="M11 5V2.5H2.5V11H5"/></svg>`,
14 "file": `<svg class="icon file-svg" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 1.5h6l4 4v9H3v-13Z"/><path d="M9 1.5v4h4"/></svg>`,
15 "folder": `<svg class="icon folder-svg" viewBox="0 0 16 16" aria-hidden="true"><path d="M1.5 3.25h5l1.25 1.5h6.75v8.75h-13V3.25Z"/></svg>`,
16 "back": `<svg class="icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M6.5 3 2 7.5 6.5 12M2.5 7.5H14"/></svg>`,
17 "book": `<svg class="icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M2 2.5h4.25A1.75 1.75 0 0 1 8 4.25V14a2 2 0 0 0-2-2H2V2.5ZM14 2.5H9.75A1.75 1.75 0 0 0 8 4.25V14a2 2 0 0 1 2-2h4V2.5Z"/></svg>`,
18 "link": `<svg class="icon" viewBox="0 0 16 16" aria-hidden="true"><path d="m6.25 9.75 3.5-3.5M5.2 11.9l-1.1 1.1A2.9 2.9 0 0 1 0 8.9l2.1-2.1a2.9 2.9 0 0 1 4.1 0M10.8 4.1l1.1-1.1A2.9 2.9 0 1 1 16 7.1l-2.1 2.1a2.9 2.9 0 0 1-4.1 0"/></svg>`,
19 "people": `<svg class="icon" viewBox="0 0 16 16" aria-hidden="true"><circle cx="6" cy="5" r="2.5"/><path d="M1.5 14a4.5 4.5 0 0 1 9 0M11 3.25a2.25 2.25 0 0 1 0 4.5M11.5 10a4 4 0 0 1 3 4"/></svg>`,
20 "external": `<svg class="icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M9 2h5v5M14 2 8 8M7 3H2.5v10.5H13V9"/></svg>`,
21 }
22 value, ok := icons[name]
23 if !ok { 30 if !ok {
24 return "" 31 return ""
25 } 32 }
26 return template.HTML(value) 33 return template.HTML(`<svg class="icon octicon-` + name + `" viewBox="0 0 16 16" aria-hidden="true">` + path + `</svg>`)
27 } 34 }
Addedinternal/site/navigation.go +106−0
@@ -0,0 +1,106 @@
1 package site
2
3 import (
4 "context"
5 "encoding/json"
6 "path"
7 "sort"
8 )
9
10 type repositoryNavigation struct {
11 DefaultBranch string `json:"defaultBranch"`
12 Branches []string `json:"branches"`
13 }
14
15 type fileNavigation struct {
16 Files []fileNavigationEntry `json:"files"`
17 }
18
19 type fileNavigationEntry struct {
20 Path string `json:"path"`
21 URL string `json:"url"`
22 }
23
24 func (g *Generator) SetBranches(branches []string, defaultBranch string) {
25 seen := map[string]struct{}{}
26 g.Branches = g.Branches[:0]
27 for _, branch := range branches {
28 if branch == "" {
29 continue
30 }
31 if _, exists := seen[branch]; exists {
32 continue
33 }
34 seen[branch] = struct{}{}
35 g.Branches = append(g.Branches, branch)
36 }
37 sort.Strings(g.Branches)
38 g.DefaultBranch = defaultBranch
39 if g.DefaultBranch == "" && len(g.Branches) > 0 {
40 g.DefaultBranch = g.Branches[0]
41 }
42 }
43
44 func (g *Generator) ensureBranch(branch string, isHead bool) {
45 if isHead || g.DefaultBranch == "" {
46 g.DefaultBranch = branch
47 }
48 for _, existing := range g.Branches {
49 if existing == branch {
50 return
51 }
52 }
53 g.Branches = append(g.Branches, branch)
54 sort.Strings(g.Branches)
55 }
56
57 func (g *Generator) filesPage(ctx context.Context, branch, oid string) (Page, error) {
58 entries, err := g.listTreeRecursive(ctx, oid)
59 if err != nil {
60 return Page{}, err
61 }
62 manifest := fileNavigation{}
63 for _, entry := range entries {
64 if entry.Type == "tree" {
65 continue
66 }
67 manifest.Files = append(manifest.Files, fileNavigationEntry{
68 Path: entry.Name,
69 URL: routeURL(g.BaseURL, path.Join("blob", branch, entry.Name)),
70 })
71 }
72 sort.Slice(manifest.Files, func(i, j int) bool {
73 return manifest.Files[i].Path < manifest.Files[j].Path
74 })
75 body, err := json.Marshal(manifest)
76 if err != nil {
77 return Page{}, err
78 }
79 return Page{
80 Route: path.Join(".dumbforge/files", branch+".json"),
81 Body: body,
82 ContentType: "application/json; charset=utf-8",
83 Cache: siteCache,
84 }, nil
85 }
86
87 func (g *Generator) RepositoryNavigationBuild(deletedBranches []string) (Build, error) {
88 manifest := repositoryNavigation{
89 DefaultBranch: g.DefaultBranch,
90 Branches: append([]string(nil), g.Branches...),
91 }
92 body, err := json.Marshal(manifest)
93 if err != nil {
94 return Build{}, err
95 }
96 build := Build{Pages: []Page{{
97 Route: ".dumbforge/repository.json",
98 Body: body,
99 ContentType: "application/json; charset=utf-8",
100 Cache: siteCache,
101 }}}
102 for _, branch := range deletedBranches {
103 build.Deletes = append(build.Deletes, path.Join(".dumbforge/files", branch+".json"))
104 }
105 return build, nil
106 }
Modifiedinternal/site/site.go +23−7
@@ -44,13 +44,15 @@ type Build struct {
44 } 44 }
45 45
46 type Generator struct { 46 type Generator struct {
47 Git gitutil.Git 47 Git gitutil.Git
48 BaseURL string 48 BaseURL string
49 RepoName string 49 RepoName string
50 Owner string 50 Owner string
51 CloneURL string 51 CloneURL string
52 Progress func(format string, args ...any) 52 Branches []string
53 templates *template.Template 53 DefaultBranch string
54 Progress func(format string, args ...any)
55 templates *template.Template
54 } 56 }
55 57
56 type Commit struct { 58 type Commit struct {
@@ -95,6 +97,10 @@ type pageData struct {
95 CommitCount int 97 CommitCount int
96 CommitsURL string 98 CommitsURL string
97 CommitGroups []CommitGroup 99 CommitGroups []CommitGroup
100 BranchesURL string
101 FilesURL string
102 DefaultBranch string
103 GitHubImportURL string
98 Description string 104 Description string
99 Homepage string 105 Homepage string
100 HomepageLabel string 106 HomepageLabel string
@@ -136,6 +142,7 @@ func New(git gitutil.Git, baseURL, repoName, owner string) (*Generator, error) {
136 // pushes it only regenerates changed blobs and the directory pages that contain 142 // pushes it only regenerates changed blobs and the directory pages that contain
137 // them. The repository landing page is refreshed when isHead is true. 143 // them. The repository landing page is refreshed when isHead is true.
138 func (g *Generator) BuildBranch(ctx context.Context, branch, oldOID, newOID string, isHead bool) (Build, error) { 144 func (g *Generator) BuildBranch(ctx context.Context, branch, oldOID, newOID string, isHead bool) (Build, error) {
145 g.ensureBranch(branch, isHead)
139 summary, err := g.branchSummary(ctx, branch, newOID) 146 summary, err := g.branchSummary(ctx, branch, newOID)
140 if err != nil { 147 if err != nil {
141 return Build{}, err 148 return Build{}, err
@@ -152,6 +159,11 @@ func (g *Generator) BuildBranch(ctx context.Context, branch, oldOID, newOID stri
152 return Build{}, err 159 return Build{}, err
153 } 160 }
154 result.Pages = append(result.Pages, commitsPage) 161 result.Pages = append(result.Pages, commitsPage)
162 filesPage, err := g.filesPage(ctx, branch, newOID)
163 if err != nil {
164 return Build{}, err
165 }
166 result.Pages = append(result.Pages, filesPage)
155 167
156 if oldOID == "" { 168 if oldOID == "" {
157 entries, err := g.listTreeRecursive(ctx, newOID) 169 entries, err := g.listTreeRecursive(ctx, newOID)
@@ -461,6 +473,10 @@ func (g *Generator) pageData(kind, title, branch string, summary branchSummary)
461 CommitCount: summary.Count, 473 CommitCount: summary.Count,
462 CommitsURL: summary.CommitsURL, 474 CommitsURL: summary.CommitsURL,
463 CommitGroups: summary.Groups, 475 CommitGroups: summary.Groups,
476 BranchesURL: routeURL(g.BaseURL, ".dumbforge/repository.json"),
477 FilesURL: routeURL(g.BaseURL, path.Join(".dumbforge/files", branch+".json")),
478 DefaultBranch: g.DefaultBranch,
479 GitHubImportURL: "https://github.com/new/import?url=" + url.QueryEscape(g.CloneURL),
464 Description: summary.Repository.Description, 480 Description: summary.Repository.Description,
465 Homepage: summary.Repository.Homepage, 481 Homepage: summary.Repository.Homepage,
466 HomepageLabel: summary.Repository.HomepageLabel, 482 HomepageLabel: summary.Repository.HomepageLabel,
Modifiedinternal/site/site_test.go +41−3
@@ -32,13 +32,21 @@ func TestFullAndIncrementalBuild(t *testing.T) {
32 } 32 }
33 assertPage(t, full, "", "Hello bucket") 33 assertPage(t, full, "", "Hello bucket")
34 assertPage(t, full, "", "Clone with HTTPS") 34 assertPage(t, full, "", "Clone with HTTPS")
35 assertPage(t, full, "", "Fork to GitHub")
36 assertPage(t, full, "", "Go to file")
37 assertPage(t, full, "", "data-branch-menu")
38 assertPage(t, full, "", "data-copied-icon")
35 assertPage(t, full, "", "Languages") 39 assertPage(t, full, "", "Languages")
36 assertPage(t, full, "", "Contributors") 40 assertPage(t, full, "", "Contributors")
37 assertPage(t, full, "", "folder-svg") 41 assertPage(t, full, "", "octicon-folder")
38 assertPage(t, full, "blob/main/src/main.go", "chroma") 42 assertPage(t, full, "blob/main/src/main.go", "chroma")
39 assertPage(t, full, "tree/main/src", "main.go") 43 assertPage(t, full, "tree/main/src", "main.go")
40 assertPage(t, full, "commits/main", "Commit history") 44 assertPage(t, full, "commits/main", "Commit history")
41 assertPage(t, full, "commits/main", "gravatar.com/avatar") 45 assertPage(t, full, "commits/main", "gravatar.com/avatar")
46 assertPage(t, full, ".dumbforge/files/main.json", `"path":"src/main.go"`)
47 assertPageDoesNotContain(t, full, "", "Search this repository")
48 assertPageDoesNotContain(t, full, "", ">Docs<")
49 assertPageDoesNotContain(t, full, "", "Forged from a static bucket")
42 50
43 write(t, dir, "src/main.go", "package main\n\nfunc main() { println(\"changed\") }\n") 51 write(t, dir, "src/main.go", "package main\n\nfunc main() { println(\"changed\") }\n")
44 runGit(t, dir, "add", "src/main.go") 52 runGit(t, dir, "add", "src/main.go")
@@ -48,14 +56,31 @@ func TestFullAndIncrementalBuild(t *testing.T) {
48 if err != nil { 56 if err != nil {
49 t.Fatal(err) 57 t.Fatal(err)
50 } 58 }
51 if len(incremental.Pages) != 6 { 59 if len(incremental.Pages) != 7 {
52 t.Fatalf("incremental build generated %d pages, want 6", len(incremental.Pages)) 60 t.Fatalf("incremental build generated %d pages, want 7", len(incremental.Pages))
53 } 61 }
54 assertPage(t, incremental, "blob/main/src/main.go", "changed") 62 assertPage(t, incremental, "blob/main/src/main.go", "changed")
55 assertPage(t, incremental, "tree/main/src", "change one file") 63 assertPage(t, incremental, "tree/main/src", "change one file")
56 assertPage(t, incremental, "commits/main", "change one file") 64 assertPage(t, incremental, "commits/main", "change one file")
57 } 65 }
58 66
67 func TestRepositoryNavigationBuild(t *testing.T) {
68 generator, err := New(gitutil.Git{}, "https://example.invalid/repo.git", "repo.git", "owner")
69 if err != nil {
70 t.Fatal(err)
71 }
72 generator.SetBranches([]string{"topic", "main", "main"}, "main")
73 build, err := generator.RepositoryNavigationBuild([]string{"old"})
74 if err != nil {
75 t.Fatal(err)
76 }
77 assertPage(t, build, ".dumbforge/repository.json", `"defaultBranch":"main"`)
78 assertPage(t, build, ".dumbforge/repository.json", `"branches":["main","topic"]`)
79 if len(build.Deletes) != 1 || build.Deletes[0] != ".dumbforge/files/old.json" {
80 t.Fatalf("navigation deletes = %#v", build.Deletes)
81 }
82 }
83
59 func TestReadmeMetadataFallbacks(t *testing.T) { 84 func TestReadmeMetadataFallbacks(t *testing.T) {
60 readme := []byte("# Project\n\n![badge](badge.svg)\n\nA **small** [Git host](https://example.invalid).\n\nVisit the [project homepage](https://project.example/docs).\n") 85 readme := []byte("# Project\n\n![badge](badge.svg)\n\nA **small** [Git host](https://example.invalid).\n\nVisit the [project homepage](https://project.example/docs).\n")
61 if got := readmeDescription(readme); got != "A small Git host." { 86 if got := readmeDescription(readme); got != "A small Git host." {
@@ -89,6 +114,19 @@ func assertPage(t *testing.T, build Build, route, contains string) {
89 t.Fatalf("page %q was not generated", route) 114 t.Fatalf("page %q was not generated", route)
90 } 115 }
91 116
117 func assertPageDoesNotContain(t *testing.T, build Build, route, unwanted string) {
118 t.Helper()
119 for _, page := range build.Pages {
120 if page.Route == route && page.Root == (route == "") {
121 if strings.Contains(string(page.Body), unwanted) {
122 t.Fatalf("page %q unexpectedly contains %q", route, unwanted)
123 }
124 return
125 }
126 }
127 t.Fatalf("page %q was not generated", route)
128 }
129
92 func write(t *testing.T, dir, name, contents string) { 130 func write(t *testing.T, dir, name, contents string) {
93 t.Helper() 131 t.Helper()
94 filename := filepath.Join(dir, name) 132 filename := filepath.Join(dir, name)
Modifiedinternal/site/style.go +93−51
@@ -3,14 +3,14 @@ package site
3 const siteCSS = `:root { 3 const siteCSS = `:root {
4 --bg: #ffffff; 4 --bg: #ffffff;
5 --canvas: #f6f8fa; 5 --canvas: #f6f8fa;
6 --border: #d0d7de; 6 --canvas-subtle: #f8f9fa;
7 --border: #d1d9e0;
7 --border-muted: #d8dee4; 8 --border-muted: #d8dee4;
8 --text: #1f2328; 9 --text: #1f2328;
9 --muted: #656d76; 10 --muted: #59636e;
10 --blue: #0969da; 11 --blue: #0969da;
11 --green: #1f883d; 12 --green: #1f883d;
12 --green-hover: #1a7f37; 13 --green-hover: #1a7f37;
13 --nav: #25292e;
14 --shadow: 0 1px 0 rgba(31,35,40,.04); 14 --shadow: 0 1px 0 rgba(31,35,40,.04);
15 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; 15 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
16 color: var(--text); 16 color: var(--text);
@@ -19,72 +19,93 @@ const siteCSS = `:root {
19 * { box-sizing: border-box; } 19 * { box-sizing: border-box; }
20 [hidden] { display: none !important; } 20 [hidden] { display: none !important; }
21 body { margin: 0; min-width: 320px; background: var(--bg); font-size: 14px; line-height: 1.5; } 21 body { margin: 0; min-width: 320px; background: var(--bg); font-size: 14px; line-height: 1.5; }
22 body.dialog-open { overflow: hidden; }
22 a { color: var(--blue); text-decoration: none; } 23 a { color: var(--blue); text-decoration: none; }
23 a:hover { text-decoration: underline; } 24 a:hover { text-decoration: underline; }
24 button, input { font: inherit; } 25 button, input { font: inherit; }
25 .icon { width: 16px; height: 16px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.35; stroke-linecap: round; stroke-linejoin: round; vertical-align: text-bottom; } 26 .icon { display: inline-block; width: 16px; height: 16px; flex: none; fill: currentColor; stroke: none; overflow: visible; vertical-align: text-bottom; }
27 .icon-chevron { width: 12px; height: 12px; }
26 .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } 28 .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
27 .topbar { height: 64px; padding: 0 28px; display: flex; align-items: center; gap: 18px; background: var(--nav); color: white; } 29
28 .brand { display: inline-flex; align-items: center; gap: 9px; color: white; font-size: 16px; font-weight: 650; letter-spacing: -.2px; } 30 .topbar { min-height: 64px; padding: 16px 32px; display: flex; align-items: center; gap: 12px; background: var(--canvas); }
31 .brand { display: inline-grid; place-items: center; color: var(--text); }
29 .brand:hover { text-decoration: none; } 32 .brand:hover { text-decoration: none; }
30 .brand-mark { display: grid; place-items: center; width: 32px; height: 32px; border: 1px solid #6e7681; border-radius: 50%; font-family: ui-monospace, monospace; font-weight: 800; } 33 .brand-mark { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 50%; background: #24292f; color: #fff; font: 800 17px/1 ui-monospace, SFMono-Regular, Consolas, monospace; }
31 .top-search { width: 300px; height: 34px; border: 1px solid #57606a; border-radius: 6px; display: flex; align-items: center; padding: 0 12px; color: #b6bec8; font-size: 13px; } 34 .header-crumbs { min-width: 0; display: flex; align-items: center; gap: 8px; font-size: 14px; }
32 .top-link { margin-left: auto; color: white; font-weight: 600; } 35 .header-crumbs a { color: var(--text); white-space: nowrap; }
33 .repo-head { background: var(--canvas); border-bottom: 1px solid var(--border); padding: 22px 32px 0; } 36 .header-repo { overflow: hidden; font-weight: 650; text-overflow: ellipsis; }
34 .repo-title { max-width: 1280px; margin: 0 auto; display: flex; align-items: center; gap: 7px; font-size: 20px; } 37 .header-crumbs span { color: var(--muted); }
35 .repo-icon { color: var(--muted); display: inline-flex; margin-right: 2px; } 38 .repo-nav { background: var(--canvas); border-bottom: 1px solid var(--border); }
36 .repo-icon .icon { width: 20px; height: 20px; } 39 .tabs { max-width: 1280px; margin: 0 auto; padding: 0 24px; display: flex; gap: 8px; }
37 .repo-name { font-weight: 650; } 40 .tab { position: relative; display: flex; align-items: center; gap: 7px; padding: 8px 10px 10px; color: var(--text); font-size: 14px; font-weight: 500; }
38 .slash { color: var(--muted); } 41 .tab:hover { text-decoration: none; background: rgba(175,184,193,.18); border-radius: 6px; }
39 .visibility { color: var(--muted); border: 1px solid var(--border); border-radius: 999px; padding: 1px 8px; font-size: 12px; font-weight: 600; margin-left: 5px; }
40 .tabs { max-width: 1280px; margin: 20px auto 0; display: flex; gap: 8px; }
41 .tab { position: relative; display: flex; align-items: center; gap: 7px; padding: 9px 12px 12px; color: var(--text); font-weight: 500; }
42 .tab:hover { text-decoration: none; background: rgba(175,184,193,.16); border-radius: 6px 6px 0 0; }
43 .tab.active { font-weight: 650; } 42 .tab.active { font-weight: 650; }
44 .tab.active:after { content: ""; height: 2px; background: #fd8c73; position: absolute; left: 8px; right: 8px; bottom: -1px; } 43 .tab.active:after { content: ""; height: 2px; background: #fd8c73; position: absolute; left: 8px; right: 8px; bottom: -1px; }
45 .counter { display: inline-flex; align-items: center; justify-content: center; min-width: 20px; height: 20px; padding: 0 6px; border-radius: 999px; background: rgba(175,184,193,.25); color: var(--text); font-size: 12px; font-weight: 600; } 44 .counter { display: inline-flex; align-items: center; justify-content: center; min-width: 20px; height: 20px; padding: 0 6px; border-radius: 999px; background: rgba(175,184,193,.25); color: var(--text); font-size: 12px; font-weight: 600; }
46 .content-shell { max-width: 1280px; margin: 24px auto 64px; padding: 0 24px; } 45
47 .repo-grid { display: grid; grid-template-columns: minmax(0, 1fr) 285px; gap: 30px; } 46 .content-shell { max-width: 1280px; margin: 0 auto 64px; padding: 24px; }
47 .repo-overview-head { min-height: 34px; margin-bottom: 18px; display: flex; align-items: center; gap: 16px; }
48 .repo-title { min-width: 0; display: flex; align-items: center; gap: 7px; font-size: 20px; }
49 .repo-title > a { overflow: hidden; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
50 .repo-icon { display: inline-flex; color: var(--muted); }
51 .repo-icon .icon { width: 18px; height: 18px; }
52 .visibility { color: var(--muted); border: 1px solid var(--border); border-radius: 999px; padding: 1px 8px; font-size: 12px; font-weight: 600; }
53 .repo-overview-head .fork-button { margin-left: auto; }
54 .repo-grid { display: grid; grid-template-columns: minmax(0, 1fr) 296px; gap: 32px; }
48 .primary { min-width: 0; } 55 .primary { min-width: 0; }
49 .full-width { width: 100%; } 56 .full-width { width: 100%; }
50 .toolbar { min-height: 38px; margin-bottom: 14px; display: flex; align-items: center; gap: 10px; } 57
51 .branch-button, .clone-button, .blob-actions a, .blob-actions button { min-height: 32px; border: 1px solid rgba(31,35,40,.15); border-radius: 6px; background: #f6f8fa; box-shadow: var(--shadow); color: var(--text); display: inline-flex; align-items: center; gap: 7px; padding: 5px 12px; font-weight: 600; font-size: 13px; } 58 .toolbar { min-height: 34px; margin-bottom: 16px; display: flex; align-items: center; gap: 8px; }
52 .branch-button .icon-chevron { width: 12px; margin-left: 3px; color: var(--muted); } 59 .toolbar-spacer { flex: 1 1 auto; }
53 .clone-menu { position: relative; margin-left: auto; } 60 .branch-menu, .clone-menu { position: relative; }
54 .clone-button { background: var(--green); border-color: rgba(31,35,40,.15); color: white; cursor: pointer; } 61 .branch-button, .clone-button, .secondary-button, .blob-actions a, .blob-actions button { min-height: 32px; border: 1px solid rgba(31,35,40,.15); border-radius: 6px; background: var(--canvas); box-shadow: var(--shadow); color: var(--text); display: inline-flex; align-items: center; justify-content: center; gap: 7px; padding: 5px 12px; font-weight: 600; font-size: 13px; line-height: 20px; cursor: pointer; }
62 .branch-button:hover, .secondary-button:hover, .blob-actions a:hover, .blob-actions button:hover { background: #eff2f5; text-decoration: none; }
63 .branch-button { max-width: 240px; }
64 .branch-button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
65 .clone-button { background: var(--green); border-color: rgba(31,35,40,.15); color: white; }
55 .clone-button:hover { background: var(--green-hover); } 66 .clone-button:hover { background: var(--green-hover); }
56 .clone-button .icon-chevron { width: 12px; } 67 .fork-button { white-space: nowrap; }
57 .clone-popover { position: absolute; z-index: 10; top: calc(100% + 7px); right: 0; width: 380px; padding: 16px; border: 1px solid var(--border); border-radius: 8px; background: white; box-shadow: 0 8px 24px rgba(140,149,159,.35); } 68 .branch-popover, .clone-popover { position: absolute; z-index: 30; top: calc(100% + 7px); border: 1px solid var(--border); border-radius: 8px; background: white; box-shadow: 0 8px 24px rgba(140,149,159,.28); }
58 .clone-popover:before { content: ""; position: absolute; top: -6px; right: 22px; width: 10px; height: 10px; transform: rotate(45deg); border-left: 1px solid var(--border); border-top: 1px solid var(--border); background: white; } 69 .branch-popover { left: 0; width: 300px; overflow: hidden; }
70 .branch-popover h3 { margin: 0; padding: 12px 16px 10px; border-bottom: 1px solid var(--border-muted); font-size: 14px; }
71 .branch-list { max-height: 300px; padding: 6px 0; overflow-y: auto; }
72 .branch-option { min-height: 34px; padding: 6px 16px 6px 38px; position: relative; display: block; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
73 .branch-option:hover { background: var(--canvas); text-decoration: none; }
74 .branch-option.selected { font-weight: 650; }
75 .branch-option.selected:before { content: "✓"; position: absolute; left: 16px; color: var(--text); }
76 .clone-popover { right: 0; width: 390px; padding: 16px; }
59 .clone-popover h3 { margin: 0; font-size: 14px; } 77 .clone-popover h3 { margin: 0; font-size: 14px; }
60 .clone-popover p { margin: 3px 0 12px; color: var(--muted); font-size: 12px; } 78 .clone-popover p { margin: 3px 0 12px; color: var(--muted); font-size: 12px; }
61 .clone-input-group { display: flex; } 79 .clone-input-group { display: flex; }
62 .clone-input-group input { min-width: 0; flex: 1; height: 34px; padding: 5px 9px; border: 1px solid var(--border); border-radius: 6px 0 0 6px; color: var(--text); background: var(--canvas); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; } 80 .clone-input-group input { min-width: 0; flex: 1; height: 34px; padding: 5px 9px; border: 1px solid var(--border); border-radius: 6px 0 0 6px; color: var(--text); background: var(--canvas); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; }
63 .clone-input-group button { width: 38px; display: grid; place-items: center; border: 1px solid var(--border); border-left: 0; border-radius: 0 6px 6px 0; color: var(--text); background: var(--canvas); cursor: pointer; } 81 .clone-input-group button { width: 40px; display: grid; place-items: center; border: 1px solid var(--border); border-left: 0; border-radius: 0 6px 6px 0; color: var(--text); background: var(--canvas); cursor: pointer; }
64 .clone-input-group button:hover { background: #eef1f4; } 82 .clone-input-group button:hover { background: #eef1f4; }
83 .clone-input-group button.is-copied { color: var(--green); background: #dafbe1; }
65 .clone-popover > code { display: block; margin-top: 12px; padding: 9px 10px; overflow: hidden; border-radius: 6px; color: var(--muted); background: var(--canvas); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } 84 .clone-popover > code { display: block; margin-top: 12px; padding: 9px 10px; overflow: hidden; border-radius: 6px; color: var(--muted); background: var(--canvas); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
85 .menu-status { margin: 0; padding: 12px 16px; color: var(--muted); font-size: 13px; }
66 .toolbar-path { border-bottom: 1px solid var(--border-muted); padding-bottom: 14px; } 86 .toolbar-path { border-bottom: 1px solid var(--border-muted); padding-bottom: 14px; }
67 .breadcrumbs { min-width: 0; display: flex; align-items: center; gap: 6px; font-size: 16px; overflow: hidden; } 87 .breadcrumbs { min-width: 0; display: flex; align-items: center; gap: 6px; overflow: hidden; font-size: 14px; }
68 .breadcrumbs a { color: var(--text); white-space: nowrap; } 88 .breadcrumbs a { color: var(--text); white-space: nowrap; }
69 .breadcrumbs a:last-child { font-weight: 650; } 89 .breadcrumbs a:last-child { overflow: hidden; font-weight: 650; text-overflow: ellipsis; }
70 .breadcrumbs span { color: var(--muted); } 90 .breadcrumbs span { color: var(--muted); }
91
71 .tree-card, .readme-card, .blob-card { border: 1px solid var(--border); border-radius: 6px; overflow: hidden; background: var(--bg); } 92 .tree-card, .readme-card, .blob-card { border: 1px solid var(--border); border-radius: 6px; overflow: hidden; background: var(--bg); }
72 .commit-row { min-height: 54px; display: grid; grid-template-columns: 32px minmax(120px,1fr) auto auto auto; align-items: center; gap: 9px; padding: 9px 14px; background: var(--canvas); border-bottom: 1px solid var(--border); } 93 .commit-row { min-height: 54px; display: grid; grid-template-columns: 32px minmax(120px,1fr) auto auto auto; align-items: center; gap: 9px; padding: 9px 14px; background: var(--canvas); border-bottom: 1px solid var(--border); }
73 .avatar { width: 28px; height: 28px; display: block; border-radius: 50%; object-fit: cover; background: #eaeef2; } 94 .avatar { width: 28px; height: 28px; display: block; border-radius: 50%; object-fit: cover; background: #eaeef2; }
74 .latest-commit { min-width: 0; display: flex; align-items: baseline; gap: 9px; } 95 .latest-commit { min-width: 0; display: flex; align-items: baseline; gap: 9px; }
75 .latest-commit strong { flex: none; } 96 .latest-commit strong { flex: none; }
76 .commit-message { min-width: 0; overflow: hidden; color: var(--text); text-overflow: ellipsis; white-space: nowrap; } 97 .commit-message { min-width: 0; overflow: hidden; color: var(--text); text-overflow: ellipsis; white-space: nowrap; }
77 .commit-hash, .commit-row time { color: var(--muted); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; } 98 .commit-hash, .commit-row time { color: var(--muted); font: 12px ui-monospace, SFMono-Regular, Consolas, monospace; }
78 .commit-row time { font-family: inherit; white-space: nowrap; } 99 .commit-row time { font-family: inherit; white-space: nowrap; }
79 .commit-count-link { min-height: 30px; display: inline-flex; align-items: center; gap: 5px; padding: 4px 9px; border-left: 1px solid var(--border-muted); color: var(--text); white-space: nowrap; } 100 .commit-count-link { min-height: 30px; display: inline-flex; align-items: center; gap: 5px; padding: 4px 0 4px 10px; border-left: 1px solid var(--border-muted); color: var(--text); white-space: nowrap; }
80 .commit-count-link:hover { color: var(--blue); text-decoration: none; } 101 .commit-count-link:hover { color: var(--blue); text-decoration: none; }
81 .tree-rows { width: 100%; } 102 .tree-rows { width: 100%; }
82 .tree-row { min-height: 42px; display: grid; grid-template-columns: 24px minmax(180px, 2fr) minmax(120px, 3fr) 75px; align-items: center; gap: 8px; padding: 6px 16px; color: var(--text); border-top: 1px solid var(--border-muted); } 103 .tree-row { min-height: 42px; display: grid; grid-template-columns: 24px minmax(180px, 2fr) minmax(120px, 3fr) 75px; align-items: center; gap: 8px; padding: 6px 16px; color: var(--text); border-top: 1px solid var(--border-muted); }
83 .tree-row:first-child { border-top: 0; } 104 .tree-row:first-child { border-top: 0; }
84 .tree-row:hover { background: var(--canvas); text-decoration: none; } 105 .tree-row:hover { background: var(--canvas); text-decoration: none; }
85 .file-icon { display: inline-flex; color: #59636e; } 106 .file-icon { width: 18px; height: 18px; display: inline-grid; place-items: center; color: var(--muted); }
86 .file-icon.folder { color: #54aeff; } 107 .file-icon.folder { color: #54aeff; }
87 .file-icon .icon { width: 17px; height: 17px; stroke-width: 1.25; } 108 .file-icon .icon { width: 16px; height: 16px; }
88 .file-name { color: var(--blue); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 109 .file-name { color: var(--blue); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
89 .row-message { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 110 .row-message { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
90 .file-size { color: var(--muted); text-align: right; font-size: 12px; white-space: nowrap; } 111 .file-size { color: var(--muted); text-align: right; font-size: 12px; white-space: nowrap; }
@@ -104,7 +125,8 @@ button, input { font: inherit; }
104 .markdown-body pre { padding: 16px; overflow: auto; border-radius: 6px; background: var(--canvas); } 125 .markdown-body pre { padding: 16px; overflow: auto; border-radius: 6px; background: var(--canvas); }
105 .markdown-body pre code { padding: 0; background: transparent; } 126 .markdown-body pre code { padding: 0; background: transparent; }
106 .markdown-body blockquote { margin-left: 0; padding-left: 1em; color: var(--muted); border-left: .25em solid var(--border); } 127 .markdown-body blockquote { margin-left: 0; padding-left: 1em; color: var(--muted); border-left: .25em solid var(--border); }
107 .sidebar { padding-top: 48px; } 128
129 .sidebar { padding-top: 50px; }
108 .sidebar-section { padding: 0 0 20px; margin: 0 0 20px; border-bottom: 1px solid var(--border-muted); } 130 .sidebar-section { padding: 0 0 20px; margin: 0 0 20px; border-bottom: 1px solid var(--border-muted); }
109 .sidebar-section:last-child { border-bottom: 0; } 131 .sidebar-section:last-child { border-bottom: 0; }
110 .sidebar h2 { margin: 0 0 10px; font-size: 16px; } 132 .sidebar h2 { margin: 0 0 10px; font-size: 16px; }
@@ -113,6 +135,7 @@ button, input { font: inherit; }
113 .about-description { margin: 0 0 12px; color: var(--text); font-size: 16px; line-height: 1.45; } 135 .about-description { margin: 0 0 12px; color: var(--text); font-size: 16px; line-height: 1.45; }
114 .about-description.muted { color: var(--muted); } 136 .about-description.muted { color: var(--muted); }
115 .about-link { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 4px 0; color: var(--blue); font-weight: 600; } 137 .about-link { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 4px 0; color: var(--blue); font-weight: 600; }
138 .about-link .icon { width: 16px; height: 16px; }
116 .about-link span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 139 .about-link span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
117 .contributors-list { display: flex; flex-direction: column; gap: 8px; } 140 .contributors-list { display: flex; flex-direction: column; gap: 8px; }
118 .contributor { display: grid; grid-template-columns: 32px minmax(0,1fr) auto; align-items: center; gap: 8px; } 141 .contributor { display: grid; grid-template-columns: 32px minmax(0,1fr) auto; align-items: center; gap: 8px; }
@@ -127,14 +150,15 @@ button, input { font: inherit; }
127 .language-list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 150 .language-list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
128 .language-list li > span:last-child { color: var(--muted); } 151 .language-list li > span:last-child { color: var(--muted); }
129 .language-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--language-color); } 152 .language-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--language-color); }
153
130 .blob-card { border-radius: 6px; } 154 .blob-card { border-radius: 6px; }
131 .blob-heading { min-height: 54px; padding: 10px 14px; display: flex; align-items: center; gap: 12px; background: var(--canvas); border-bottom: 1px solid var(--border); } 155 .blob-heading { min-height: 54px; padding: 10px 14px; display: flex; align-items: center; gap: 12px; background: var(--canvas); border-bottom: 1px solid var(--border); }
132 .blob-heading > div:first-child { min-width: 0; display: flex; flex-direction: column; } 156 .blob-heading > div:first-child { min-width: 0; display: flex; flex-direction: column; }
133 .blob-heading strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 157 .blob-heading strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
134 .blob-heading span { color: var(--muted); font-size: 12px; } 158 .blob-heading span { color: var(--muted); font-size: 12px; }
135 .blob-actions { margin-left: auto; display: flex; gap: 6px; } 159 .blob-actions { margin-left: auto; display: flex; gap: 6px; }
136 .blob-actions a, .blob-actions button { min-height: 28px; padding: 3px 10px; cursor: pointer; } 160 .blob-actions a, .blob-actions button { min-height: 28px; padding: 3px 10px; }
137 .blob-actions button .icon { width: 14px; height: 14px; } 161 .blob-actions button.is-copied { color: var(--green); background: #dafbe1; }
138 .code-view { overflow: auto; font: 12px/1.5 ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; } 162 .code-view { overflow: auto; font: 12px/1.5 ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; }
139 .code-view .chroma { margin: 0; padding: 0; background: white; } 163 .code-view .chroma { margin: 0; padding: 0; background: white; }
140 .code-view .chroma table { width: 100%; border-spacing: 0; } 164 .code-view .chroma table { width: 100%; border-spacing: 0; }
@@ -147,7 +171,8 @@ button, input { font: inherit; }
147 .binary-notice > span .icon { width: 44px; height: 44px; } 171 .binary-notice > span .icon { width: 44px; height: 44px; }
148 .binary-notice strong { color: var(--text); font-size: 16px; } 172 .binary-notice strong { color: var(--text); font-size: 16px; }
149 .binary-notice p { margin: 5px 0 14px; } 173 .binary-notice p { margin: 5px 0 14px; }
150 .history-toolbar { justify-content: space-between; padding-bottom: 14px; border-bottom: 1px solid var(--border-muted); } 174
175 .history-toolbar { padding-bottom: 14px; border-bottom: 1px solid var(--border-muted); }
151 .history-total { display: inline-flex; align-items: center; gap: 7px; color: var(--muted); } 176 .history-total { display: inline-flex; align-items: center; gap: 7px; color: var(--muted); }
152 .history-heading { margin: 22px 0 24px; } 177 .history-heading { margin: 22px 0 24px; }
153 .history-heading h1 { margin: 0; font-size: 24px; } 178 .history-heading h1 { margin: 0; font-size: 24px; }
@@ -167,7 +192,21 @@ button, input { font: inherit; }
167 .commit-list-body > span strong { color: var(--text); } 192 .commit-list-body > span strong { color: var(--text); }
168 .commit-sha { padding: 4px 8px; border: 1px solid var(--border); border-radius: 6px; color: var(--text); background: var(--canvas); font: 12px ui-monospace, SFMono-Regular, Consolas, monospace; } 193 .commit-sha { padding: 4px 8px; border: 1px solid var(--border); border-radius: 6px; color: var(--text); background: var(--canvas); font: 12px ui-monospace, SFMono-Regular, Consolas, monospace; }
169 .commit-sha:hover { color: var(--blue); text-decoration: none; } 194 .commit-sha:hover { color: var(--blue); text-decoration: none; }
170 footer { min-height: 90px; max-width: 1232px; margin: 0 auto; border-top: 1px solid var(--border-muted); color: var(--muted); display: flex; justify-content: center; gap: 28px; align-items: center; font-size: 12px; } 195
196 .dialog-backdrop { position: fixed; z-index: 100; inset: 0; display: grid; align-items: start; justify-items: center; padding: 12vh 20px 20px; background: rgba(31,35,40,.45); }
197 .file-dialog { width: min(680px, 100%); max-height: 70vh; display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--border); border-radius: 12px; background: var(--bg); box-shadow: 0 16px 48px rgba(31,35,40,.3); }
198 .file-dialog > header { min-height: 48px; padding: 8px 10px 8px 16px; display: flex; align-items: center; border-bottom: 1px solid var(--border-muted); }
199 .file-dialog h2 { margin: 0; font-size: 16px; }
200 .icon-button { width: 32px; height: 32px; margin-left: auto; display: grid; place-items: center; border: 0; border-radius: 6px; color: var(--muted); background: transparent; cursor: pointer; }
201 .icon-button:hover { color: var(--text); background: var(--canvas); }
202 .file-search { margin: 12px 16px; padding: 0 10px; display: flex; align-items: center; gap: 8px; border: 1px solid #818b98; border-radius: 6px; }
203 .file-search:focus-within { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(9,105,218,.2); }
204 .file-search .icon { color: var(--muted); }
205 .file-search input { width: 100%; height: 36px; border: 0; outline: 0; background: transparent; }
206 .file-results { min-height: 110px; overflow-y: auto; border-top: 1px solid var(--border-muted); }
207 .file-result { padding: 8px 16px; display: block; color: var(--text); font: 13px ui-monospace, SFMono-Regular, Consolas, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
208 .file-result:hover, .file-result:focus { background: var(--canvas); color: var(--blue); text-decoration: none; outline: 0; }
209
171 @media (max-width: 900px) { 210 @media (max-width: 900px) {
172 .repo-grid { grid-template-columns: 1fr; } 211 .repo-grid { grid-template-columns: 1fr; }
173 .sidebar { padding-top: 0; display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 0 24px; } 212 .sidebar { padding-top: 0; display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 0 24px; }
@@ -176,23 +215,26 @@ footer { min-height: 90px; max-width: 1232px; margin: 0 auto; border-top: 1px so
176 .commit-hash, .commit-row time { display: none; } 215 .commit-hash, .commit-row time { display: none; }
177 } 216 }
178 @media (max-width: 650px) { 217 @media (max-width: 650px) {
179 .topbar { padding: 0 16px; } 218 .topbar { min-height: 56px; padding: 12px 16px; }
180 .top-search, .top-link { display: none; } 219 .tabs { padding: 0 12px; }
181 .repo-head { padding-left: 16px; padding-right: 16px; } 220 .content-shell { padding: 16px 12px; margin-bottom: 40px; }
221 .repo-overview-head { align-items: flex-start; }
182 .repo-title { font-size: 17px; } 222 .repo-title { font-size: 17px; }
183 .content-shell { padding: 0 12px; margin-top: 16px; } 223 .fork-button { padding-left: 9px; padding-right: 9px; }
184 .sidebar { display: block; } 224 .sidebar { display: block; }
225 .repo-toolbar { flex-wrap: wrap; }
226 .toolbar-path { align-items: flex-start; flex-wrap: wrap; }
227 .toolbar-path .breadcrumbs { order: 5; width: 100%; }
228 .go-file-button { padding-left: 9px; padding-right: 9px; }
185 .commit-row { grid-template-columns: 32px minmax(0,1fr) auto; } 229 .commit-row { grid-template-columns: 32px minmax(0,1fr) auto; }
186 .commit-count-link { border-left: 0; padding-right: 0; } 230 .commit-count-link { border-left: 0; padding-right: 0; }
187 .commit-count-link strong { display: none; } 231 .commit-count-link strong, .latest-commit strong { display: none; }
188 .latest-commit strong { display: none; }
189 .tree-row { grid-template-columns: 22px minmax(100px,1fr) 70px; } 232 .tree-row { grid-template-columns: 22px minmax(100px,1fr) 70px; }
190 .row-message { display: none; } 233 .row-message { display: none; }
191 .markdown-body { padding: 22px 18px; } 234 .markdown-body { padding: 22px 18px; }
192 .toolbar-path { align-items: flex-start; flex-direction: column; } 235 .branch-popover, .clone-popover { position: fixed; top: 74px; left: 12px; right: 12px; width: auto; }
193 .clone-popover { position: fixed; top: 74px; left: 12px; right: 12px; width: auto; }
194 .clone-popover:before { display: none; }
195 .commit-list-row { grid-template-columns: 34px minmax(0,1fr); } 236 .commit-list-row { grid-template-columns: 34px minmax(0,1fr); }
196 .commit-sha { display: none; } 237 .commit-sha { display: none; }
197 footer { flex-direction: column; gap: 4px; } 238 .dialog-backdrop { padding: 7vh 10px 10px; }
239 .file-dialog { max-height: 82vh; }
198 }` 240 }`
Modifiedinternal/site/template.go +214−55
@@ -9,34 +9,33 @@ const pageTemplate = `<!doctype html>
9 <title>{{.Title}}</title> 9 <title>{{.Title}}</title>
10 <link rel="stylesheet" href="{{.BaseURL}}/.dumbforge/assets/site.css"> 10 <link rel="stylesheet" href="{{.BaseURL}}/.dumbforge/assets/site.css">
11 </head> 11 </head>
12 <body> 12 <body data-base-url="{{.BaseURL}}" data-route-kind="{{.Kind}}" data-route-path="{{.Path}}" data-current-branch="{{.Branch}}" data-default-branch="{{.DefaultBranch}}" data-branches-url="{{.BranchesURL}}" data-files-url="{{.FilesURL}}">
13 <header class="topbar"> 13 <header class="topbar">
14 <a class="brand" href="{{.BaseURL}}" aria-label="dumbforge home"> 14 <a class="brand" href="{{.BaseURL}}" aria-label="Dumbforge repository home"><span class="brand-mark">D</span></a>
15 <span class="brand-mark">D</span><span>dumbforge</span> 15 <nav class="header-crumbs" aria-label="Repository breadcrumb">
16 </a> 16 <a href="{{.BaseURL}}">{{.Owner}}</a><span>/</span><a class="header-repo" href="{{.BaseURL}}">{{.RepoName}}</a>
17 <div class="top-search">Search this repository</div> 17 </nav>
18 <a class="top-link" href="https://git-scm.com/docs/git-clone">Docs</a>
19 </header> 18 </header>
19 <section class="repo-nav">
20 <nav class="tabs" aria-label="Repository">
21 <a class="tab {{if ne .Kind "commits"}}active{{end}}" href="{{.BaseURL}}">{{icon "code"}} Code</a>
22 <a class="tab {{if eq .Kind "commits"}}active{{end}}" href="{{.CommitsURL}}">{{icon "history"}} Commits <span class="counter">{{.CommitCount}}</span></a>
23 </nav>
24 </section>
20 25
21 <main> 26 <main>
22 <section class="repo-head">
23 <div class="repo-title">
24 <span class="repo-icon">{{icon "repo"}}</span>
25 <a href="{{.BaseURL}}">{{.Owner}}</a><span class="slash">/</span><a class="repo-name" href="{{.BaseURL}}">{{.RepoName}}</a>
26 <span class="visibility">Public</span>
27 </div>
28 <nav class="tabs" aria-label="Repository">
29 <a class="tab {{if ne .Kind "commits"}}active{{end}}" href="{{.BaseURL}}">{{icon "code"}} Code</a>
30 <a class="tab {{if eq .Kind "commits"}}active{{end}}" href="{{.CommitsURL}}">{{icon "history"}} Commits <span class="counter">{{.CommitCount}}</span></a>
31 </nav>
32 </section>
33
34 <div class="content-shell"> 27 <div class="content-shell">
35 {{if eq .Kind "repo"}} 28 {{if eq .Kind "repo"}}
29 <div class="repo-overview-head">
30 <div class="repo-title"><span class="repo-icon">{{icon "repo"}}</span><a href="{{.BaseURL}}">{{.RepoName}}</a><span class="visibility">Public</span></div>
31 <a class="secondary-button fork-button" href="{{.GitHubImportURL}}" target="_blank" rel="noopener" title="Import this repository and its complete history into a new GitHub repository">{{icon "fork"}} Fork to GitHub</a>
32 </div>
36 <div class="repo-grid"> 33 <div class="repo-grid">
37 <section class="primary"> 34 <section class="primary">
38 <div class="toolbar"> 35 <div class="toolbar repo-toolbar">
39 <span class="branch-button">{{icon "branch"}}{{.Branch}}{{icon "chevron"}}</span> 36 {{template "branch-menu" .}}
37 <span class="toolbar-spacer"></span>
38 {{template "go-file-button" .}}
40 {{template "clone-menu" .}} 39 {{template "clone-menu" .}}
41 </div> 40 </div>
42 {{template "tree" .}} 41 {{template "tree" .}}
@@ -82,15 +81,20 @@ const pageTemplate = `<!doctype html>
82 {{else if eq .Kind "tree"}} 81 {{else if eq .Kind "tree"}}
83 <section class="primary full-width"> 82 <section class="primary full-width">
84 <div class="toolbar toolbar-path"> 83 <div class="toolbar toolbar-path">
85 <span class="branch-button">{{icon "branch"}}{{.Branch}}{{icon "chevron"}}</span> 84 {{template "branch-menu" .}}
86 <div class="breadcrumbs">{{range $i, $crumb := .Breadcrumbs}}{{if $i}}<span>/</span>{{end}}<a href="{{$crumb.URL}}">{{$crumb.Name}}</a>{{end}}</div> 85 <div class="breadcrumbs">{{range $i, $crumb := .Breadcrumbs}}{{if $i}}<span>/</span>{{end}}<a href="{{$crumb.URL}}">{{$crumb.Name}}</a>{{end}}</div>
86 <span class="toolbar-spacer"></span>
87 {{template "go-file-button" .}}
88 {{template "clone-menu" .}}
87 </div> 89 </div>
88 {{template "tree" .}} 90 {{template "tree" .}}
89 </section> 91 </section>
90 {{else if eq .Kind "commits"}} 92 {{else if eq .Kind "commits"}}
91 <section class="primary full-width commit-history"> 93 <section class="primary full-width commit-history">
92 <div class="toolbar history-toolbar"> 94 <div class="toolbar history-toolbar">
93 <span class="branch-button">{{icon "branch"}}{{.Branch}}{{icon "chevron"}}</span> 95 {{template "branch-menu" .}}
96 <span class="toolbar-spacer"></span>
97 {{template "go-file-button" .}}
94 <span class="history-total">{{icon "history"}} {{.CommitCount}} commits</span> 98 <span class="history-total">{{icon "history"}} {{.CommitCount}} commits</span>
95 </div> 99 </div>
96 <div class="history-heading"><h1>Commit history</h1><p>Browse every commit reachable from <strong>{{.Branch}}</strong>.</p></div> 100 <div class="history-heading"><h1>Commit history</h1><p>Browse every commit reachable from <strong>{{.Branch}}</strong>.</p></div>
@@ -111,13 +115,16 @@ const pageTemplate = `<!doctype html>
111 {{else}} 115 {{else}}
112 <section class="primary full-width"> 116 <section class="primary full-width">
113 <div class="toolbar toolbar-path"> 117 <div class="toolbar toolbar-path">
114 <span class="branch-button">{{icon "branch"}}{{.Branch}}{{icon "chevron"}}</span> 118 {{template "branch-menu" .}}
115 <div class="breadcrumbs">{{range $i, $crumb := .Breadcrumbs}}{{if $i}}<span>/</span>{{end}}<a href="{{$crumb.URL}}">{{$crumb.Name}}</a>{{end}}</div> 119 <div class="breadcrumbs">{{range $i, $crumb := .Breadcrumbs}}{{if $i}}<span>/</span>{{end}}<a href="{{$crumb.URL}}">{{$crumb.Name}}</a>{{end}}</div>
120 <span class="toolbar-spacer"></span>
121 {{template "go-file-button" .}}
122 {{template "clone-menu" .}}
116 </div> 123 </div>
117 <div class="blob-card"> 124 <div class="blob-card">
118 <div class="blob-heading"> 125 <div class="blob-heading">
119 <div><strong>{{.Path}}</strong><span>{{humanSize .FileSize}} · {{.Language}}</span></div> 126 <div><strong>{{.Path}}</strong><span>{{humanSize .FileSize}} · {{.Language}}</span></div>
120 <div class="blob-actions"><a href="{{.RawURL}}">Raw</a><button type="button" data-copy="{{.RawURL}}">{{icon "copy"}}<span data-copy-label>Copy link</span></button></div> 127 <div class="blob-actions"><a href="{{.RawURL}}">Raw</a><button type="button" data-copy="{{.RawURL}}"><span data-copy-icon>{{icon "copy"}}</span><span data-copied-icon hidden>{{icon "check"}}</span><span data-copy-label>Copy link</span></button></div>
121 </div> 128 </div>
122 {{if .IsImage}}<div class="image-preview"><img src="{{.RawURL}}" alt="{{.Path}}"></div> 129 {{if .IsImage}}<div class="image-preview"><img src="{{.RawURL}}" alt="{{.Path}}"></div>
123 {{else if .Code}}<div class="code-view">{{.Code}}</div> 130 {{else if .Code}}<div class="code-view">{{.Code}}</div>
@@ -127,21 +134,22 @@ const pageTemplate = `<!doctype html>
127 {{end}} 134 {{end}}
128 </div> 135 </div>
129 </main> 136 </main>
130 <footer><span>Forged from a static bucket.</span><span>Git data and pages share one URL.</span></footer> 137
138 <div class="dialog-backdrop" data-file-dialog hidden>
139 <section class="file-dialog" role="dialog" aria-modal="true" aria-labelledby="go-file-title">
140 <header><h2 id="go-file-title">Go to file</h2><button class="icon-button" type="button" data-file-close aria-label="Close">{{icon "close"}}</button></header>
141 <label class="file-search">{{icon "search"}}<input type="search" placeholder="Search files" autocomplete="off" data-file-query></label>
142 <div class="file-results" data-file-results><p class="menu-status">Loading files</p></div>
143 </section>
144 </div>
145
131 <script> 146 <script>
132 (function () { 147 (function () {
133 function copyText(value, button) { 148 var body = document.body;
134 var done = function () { 149 var all = function (selector, root) { return Array.prototype.slice.call((root || document).querySelectorAll(selector)); };
135 var label = button.querySelector('[data-copy-label]'); 150 var one = function (selector, root) { return (root || document).querySelector(selector); };
136 if (!label) return; 151
137 var old = label.textContent; 152 function legacyCopy(value) {
138 label.textContent = 'Copied!';
139 setTimeout(function () { label.textContent = old; }, 1200);
140 };
141 if (navigator.clipboard && window.isSecureContext) {
142 navigator.clipboard.writeText(value).then(done);
143 return;
144 }
145 var input = document.createElement('textarea'); 153 var input = document.createElement('textarea');
146 input.value = value; 154 input.value = value;
147 input.style.position = 'fixed'; 155 input.style.position = 'fixed';
@@ -150,38 +158,189 @@ const pageTemplate = `<!doctype html>
150 input.select(); 158 input.select();
151 document.execCommand('copy'); 159 document.execCommand('copy');
152 input.remove(); 160 input.remove();
153 done();
154 } 161 }
155 document.querySelectorAll('[data-copy]').forEach(function (button) { 162 function showCopied(button) {
163 var label = one('[data-copy-label]', button);
164 var copyIcon = one('[data-copy-icon]', button);
165 var copiedIcon = one('[data-copied-icon]', button);
166 if (label && !label.dataset.original) label.dataset.original = label.textContent;
167 if (label) label.textContent = 'Copied!';
168 if (copyIcon) copyIcon.hidden = true;
169 if (copiedIcon) copiedIcon.hidden = false;
170 button.classList.add('is-copied');
171 button.setAttribute('aria-label', 'Copied to clipboard');
172 clearTimeout(button._copyTimer);
173 button._copyTimer = setTimeout(function () {
174 if (label) label.textContent = label.dataset.original || 'Copy';
175 if (copyIcon) copyIcon.hidden = false;
176 if (copiedIcon) copiedIcon.hidden = true;
177 button.classList.remove('is-copied');
178 button.setAttribute('aria-label', 'Copy to clipboard');
179 }, 1600);
180 }
181 function copyText(value, button) {
182 if (navigator.clipboard && window.isSecureContext) {
183 navigator.clipboard.writeText(value).then(function () { showCopied(button); }).catch(function () { legacyCopy(value); showCopied(button); });
184 } else {
185 legacyCopy(value);
186 showCopied(button);
187 }
188 }
189 all('[data-copy]').forEach(function (button) {
156 button.addEventListener('click', function () { copyText(button.getAttribute('data-copy'), button); }); 190 button.addEventListener('click', function () { copyText(button.getAttribute('data-copy'), button); });
157 }); 191 });
158 var menu = document.querySelector('[data-clone-menu]'); 192
159 if (!menu) return; 193 function closeMenu(menu) {
160 var toggle = menu.querySelector('[data-clone-toggle]'); 194 if (!menu) return;
161 var popover = menu.querySelector('[data-clone-popover]'); 195 var panel = one('[data-popover]', menu);
162 var close = function () { popover.hidden = true; toggle.setAttribute('aria-expanded', 'false'); }; 196 var toggle = one('[aria-expanded]', menu);
163 toggle.addEventListener('click', function () { 197 if (panel) panel.hidden = true;
164 var opening = !popover.hidden; 198 if (toggle) toggle.setAttribute('aria-expanded', 'false');
165 popover.hidden = opening; 199 }
166 toggle.setAttribute('aria-expanded', opening ? 'false' : 'true'); 200 function wireMenu(menu, onOpen) {
167 if (!opening) popover.querySelector('input').select(); 201 if (!menu) return;
202 var toggle = one('[aria-expanded]', menu);
203 var panel = one('[data-popover]', menu);
204 toggle.addEventListener('click', function () {
205 var shouldOpen = panel.hidden;
206 all('[data-menu]').forEach(function (other) { if (other !== menu) closeMenu(other); });
207 panel.hidden = !shouldOpen;
208 toggle.setAttribute('aria-expanded', shouldOpen ? 'true' : 'false');
209 if (shouldOpen && onOpen) onOpen(panel);
210 });
211 }
212 var cloneMenu = one('[data-clone-menu]');
213 wireMenu(cloneMenu, function (panel) { one('input', panel).select(); });
214
215 function encodedRoute(parts) {
216 var segments = [];
217 parts.forEach(function (part) {
218 String(part || '').split('/').forEach(function (segment) { if (segment) segments.push(encodeURIComponent(segment)); });
219 });
220 return body.dataset.baseUrl.replace(/\/$/, '') + (segments.length ? '/' + segments.join('/') : '');
221 }
222 function branchTarget(branch, defaultBranch) {
223 var kind = body.dataset.routeKind;
224 var filePath = body.dataset.routePath;
225 if (kind === 'repo') return branch === defaultBranch ? body.dataset.baseUrl : encodedRoute(['tree', branch]);
226 if (kind === 'tree') {
227 if (!filePath && branch === defaultBranch) return body.dataset.baseUrl;
228 return encodedRoute(['tree', branch, filePath]);
229 }
230 if (kind === 'blob') return encodedRoute(['blob', branch, filePath]);
231 if (kind === 'commits') return encodedRoute(['commits', branch]);
232 return body.dataset.baseUrl;
233 }
234 var branchMenu = one('[data-branch-menu]');
235 var branchesLoaded = false;
236 wireMenu(branchMenu, function (panel) {
237 if (branchesLoaded) return;
238 fetch(body.dataset.branchesUrl, {cache: 'no-cache'}).then(function (response) {
239 if (!response.ok) throw new Error('HTTP ' + response.status);
240 return response.json();
241 }).then(function (manifest) {
242 var list = one('[data-branch-list]', panel);
243 list.textContent = '';
244 manifest.branches.forEach(function (branch) {
245 var link = document.createElement('a');
246 link.className = 'branch-option';
247 link.href = branchTarget(branch, manifest.defaultBranch);
248 link.textContent = branch;
249 if (branch === body.dataset.currentBranch) link.classList.add('selected');
250 list.appendChild(link);
251 });
252 if (!manifest.branches.length) list.textContent = 'No branches published.';
253 branchesLoaded = true;
254 }).catch(function () { one('[data-branch-list]', panel).textContent = 'Could not load branches.'; });
255 });
256
257 document.addEventListener('click', function (event) {
258 all('[data-menu]').forEach(function (menu) { if (!menu.contains(event.target)) closeMenu(menu); });
259 });
260
261 var fileDialog = one('[data-file-dialog]');
262 var fileQuery = one('[data-file-query]');
263 var fileResults = one('[data-file-results]');
264 var files = null;
265 function renderFiles() {
266 if (!files) return;
267 var query = fileQuery.value.trim().toLowerCase();
268 var matches = files.filter(function (file) { return !query || file.path.toLowerCase().indexOf(query) !== -1; });
269 matches.sort(function (a, b) {
270 var ai = query ? a.path.toLowerCase().indexOf(query) : 0;
271 var bi = query ? b.path.toLowerCase().indexOf(query) : 0;
272 return ai - bi || a.path.length - b.path.length || a.path.localeCompare(b.path);
273 });
274 fileResults.textContent = '';
275 matches.slice(0, 100).forEach(function (file) {
276 var link = document.createElement('a');
277 link.href = file.url;
278 link.className = 'file-result';
279 link.textContent = file.path;
280 fileResults.appendChild(link);
281 });
282 if (!matches.length) fileResults.innerHTML = '<p class="menu-status">No matching files.</p>';
283 else if (matches.length > 100) {
284 var more = document.createElement('p');
285 more.className = 'menu-status';
286 more.textContent = 'Keep typing to narrow ' + matches.length + ' results.';
287 fileResults.appendChild(more);
288 }
289 }
290 function openFiles() {
291 fileDialog.hidden = false;
292 document.body.classList.add('dialog-open');
293 fileQuery.value = '';
294 fileQuery.focus();
295 if (files) { renderFiles(); return; }
296 fetch(body.dataset.filesUrl, {cache: 'no-cache'}).then(function (response) {
297 if (!response.ok) throw new Error('HTTP ' + response.status);
298 return response.json();
299 }).then(function (manifest) { files = manifest.files || []; renderFiles(); })
300 .catch(function () { fileResults.innerHTML = '<p class="menu-status">Could not load the file list.</p>'; });
301 }
302 function closeFiles() {
303 fileDialog.hidden = true;
304 document.body.classList.remove('dialog-open');
305 }
306 all('[data-file-toggle]').forEach(function (button) { button.addEventListener('click', openFiles); });
307 one('[data-file-close]').addEventListener('click', closeFiles);
308 fileDialog.addEventListener('click', function (event) { if (event.target === fileDialog) closeFiles(); });
309 fileQuery.addEventListener('input', renderFiles);
310 fileQuery.addEventListener('keydown', function (event) {
311 if (event.key === 'Enter') { var first = one('.file-result', fileResults); if (first) window.location.href = first.href; }
312 });
313 document.addEventListener('keydown', function (event) {
314 if (event.key !== 'Escape') return;
315 if (!fileDialog.hidden) { closeFiles(); return; }
316 all('[data-menu]').forEach(closeMenu);
168 }); 317 });
169 document.addEventListener('click', function (event) { if (!menu.contains(event.target)) close(); });
170 document.addEventListener('keydown', function (event) { if (event.key === 'Escape') { close(); toggle.focus(); } });
171 }()); 318 }());
172 </script> 319 </script>
173 </body> 320 </body>
174 </html> 321 </html>
175 322
323 {{define "branch-menu"}}
324 <div class="branch-menu" data-branch-menu data-menu>
325 <button class="branch-button" type="button" aria-expanded="false">{{icon "branch"}}<span>{{.Branch}}</span>{{icon "chevron"}}</button>
326 <div class="branch-popover" data-popover hidden>
327 <h3>Switch branches</h3>
328 <div class="branch-list" data-branch-list><p class="menu-status">Loading branches</p></div>
329 </div>
330 </div>
331 {{end}}
332
333 {{define "go-file-button"}}<button class="secondary-button go-file-button" type="button" data-file-toggle>{{icon "search"}} Go to file</button>{{end}}
334
176 {{define "clone-menu"}} 335 {{define "clone-menu"}}
177 <div class="clone-menu" data-clone-menu> 336 <div class="clone-menu" data-clone-menu data-menu>
178 <button class="clone-button" type="button" data-clone-toggle aria-expanded="false">{{icon "download"}} Clone {{icon "chevron"}}</button> 337 <button class="clone-button" type="button" aria-expanded="false">{{icon "code"}} Code {{icon "chevron"}}</button>
179 <div class="clone-popover" data-clone-popover hidden> 338 <div class="clone-popover" data-popover hidden>
180 <h3>Clone with HTTPS</h3> 339 <h3>Clone with HTTPS</h3>
181 <p>Use Git without installing Dumbforge.</p> 340 <p>Use Git without installing Dumbforge.</p>
182 <div class="clone-input-group"> 341 <div class="clone-input-group">
183 <input type="text" value="{{.CloneURL}}" readonly aria-label="Clone URL"> 342 <input type="text" value="{{.CloneURL}}" readonly aria-label="Clone URL">
184 <button type="button" data-copy="{{.CloneURL}}" aria-label="Copy clone URL">{{icon "copy"}}<span class="sr-only" data-copy-label>Copy</span></button> 343 <button type="button" data-copy="{{.CloneURL}}" aria-label="Copy to clipboard"><span data-copy-icon>{{icon "copy"}}</span><span data-copied-icon hidden>{{icon "check"}}</span><span class="sr-only" data-copy-label>Copy</span></button>
185 </div> 344 </div>
186 <code>git clone {{.CloneURL}}</code> 345 <code>git clone {{.CloneURL}}</code>
187 </div> 346 </div>
Modifiedpackage.json +2−1
@@ -17,7 +17,8 @@
17 "files": [ 17 "files": [
18 "npm/launcher", 18 "npm/launcher",
19 "README.md", 19 "README.md",
20 "LICENSE" 20 "LICENSE",
21 "THIRD_PARTY_NOTICES.md"
21 ], 22 ],
22 "bin": { 23 "bin": {
23 "dumbforge": "npm/launcher/dumbforge.cjs", 24 "dumbforge": "npm/launcher/dumbforge.cjs",