rin
Public Open-source lightweight git hosting.

feat: in-browser file/folder creation, floating sidebar, minimal repo/profile UI

e277fd0 Browse files
- add New file / New folder pages committing directly to a branch (git plumbing, no checkout)
- percent-encode flash cookie values so localized messages survive redirects
- floating sidebar detached from viewport edges; compact clone card on repo page
- drop repo stat chips and About card; hide fork/PR actions for anonymous visitors
- redesign profile page; light/dark palettes fixed to #fff/#111 inverses
R rin · 2026-10-16 16:05 UTC
12 files changed +525 -244
README.md
+1 -1
... ... @@ -8,7 +8,7 @@ Polar is a small, fast alternative to heavyweight Git servers: push over HTTP, b
8 8
9 9 - **One binary, zero dependencies** — `CGO_ENABLED=0` build, pure-Go SQLite (`modernc.org/sqlite`) in WAL mode, bare repositories stored on disk.
10 10 - **Git Smart HTTP** — anonymous read access for public repositories, token-authenticated push, per-user personal access tokens, and browser-assisted sign-in via the OAuth 2.0 Device Authorization Grant (RFC 8628).
11 -- **Code browsing** — file tree, blob viewer with server-side syntax highlighting (Chroma), commit history, diffs, branches, tags, and zip archives.
11 +- **Code browsing** — file tree, blob viewer with server-side syntax highlighting (Chroma), commit history, diffs, branches, tags, and zip archives. The **New file** / **New folder** buttons let you create content right in the browser — changes are committed straight to the selected branch, no local checkout required.
12 12 - **Collaboration** — issues with comments and open/close state; pull requests with comments, approvals and change requests; forks with cross-fork pull requests; per-repository collaborators with read/write roles; and releases with changelogs.
13 13 - **Polished UI** — `html/template` + htmx, no SPA build step; Markdown rendered with Goldmark and sanitized with bluemonday; Hugeicons outline sprite; self-hosted Inter and JetBrains Mono fonts (Cyrillic subsets included).
14 14 - **Command palette** — press `Ctrl+K` / `Cmd+K` for quick navigation and actions.
assets/input.css
+19 -48
... ... @@ -188,34 +188,37 @@ a {
188 188 }
189 189
190 190 .app-sidebar {
191 - width: 236px;
192 - min-width: 236px;
193 - max-width: 236px;
194 - flex: 0 0 236px;
195 - border-right: 1px solid hsl(var(--border));
191 + width: 232px;
192 + min-width: 232px;
193 + max-width: 232px;
194 + flex: 0 0 232px;
195 + border: 1px solid hsl(var(--border));
196 + border-radius: calc(var(--radius) * 1.5);
196 197 background-color: hsl(var(--card));
197 198 display: none;
198 199 flex-direction: column;
199 200 justify-content: space-between;
200 201 position: sticky;
201 - top: 0;
202 - height: 100vh;
202 + top: 0.75rem;
203 + height: calc(100vh - 1.5rem);
204 + margin: 0.75rem 0 0.75rem 0.75rem;
203 205 overflow-y: auto;
204 206 overflow-x: hidden;
205 - padding: 0.85rem 0.75rem;
207 + padding: 0.85rem 0.7rem;
206 208 user-select: none;
207 209 transition:
208 210 width 200ms cubic-bezier(0.16, 1, 0.3, 1),
209 211 min-width 200ms cubic-bezier(0.16, 1, 0.3, 1),
210 212 max-width 200ms cubic-bezier(0.16, 1, 0.3, 1),
211 213 flex-basis 200ms cubic-bezier(0.16, 1, 0.3, 1),
214 + margin 200ms cubic-bezier(0.16, 1, 0.3, 1),
212 215 padding 200ms cubic-bezier(0.16, 1, 0.3, 1),
213 216 border-color 200ms cubic-bezier(0.16, 1, 0.3, 1);
214 217 }
215 218
216 219 .sidebar-inner {
217 - width: 218px;
218 - min-width: 218px;
220 + width: 100%;
221 + min-width: 0;
219 222 height: 100%;
220 223 display: flex;
221 224 flex-direction: column;
... ... @@ -230,9 +233,10 @@ html[data-sidebar="collapsed"] .app-sidebar {
230 233 min-width: 0;
231 234 max-width: 0;
232 235 flex-basis: 0;
236 + margin: 0.75rem 0;
233 237 padding-left: 0;
234 238 padding-right: 0;
235 - border-right-color: transparent;
239 + border-color: transparent;
236 240 }
237 241
238 242 html[data-sidebar="collapsed"] .sidebar-inner {
... ... @@ -1225,13 +1229,7 @@ kbd, .kbd {
1225 1229 border-radius: calc(var(--radius) * 1.6);
1226 1230 overflow: hidden;
1227 1231 }
1228 -.repo-banner::before {
1229 - content: "";
1230 - position: absolute;
1231 - inset: 0 0 auto 0;
1232 - height: 2px;
1233 - background: linear-gradient(90deg, hsl(var(--primary)), hsl(var(--primary) / 0) 65%);
1234 -}
1232 +
1235 1233 .repo-banner-main {
1236 1234 display: flex;
1237 1235 align-items: center;
... ... @@ -1246,39 +1244,12 @@ kbd, .kbd {
1246 1244 flex-wrap: wrap;
1247 1245 }
1248 1246
1249 -.repo-stats {
1250 - display: flex;
1251 - align-items: center;
1252 - gap: 0.4rem;
1253 - flex-wrap: wrap;
1254 -}
1255 -.stat-chip {
1256 - display: inline-flex;
1257 - align-items: center;
1258 - gap: 0.4rem;
1259 - height: 1.85rem;
1260 - padding: 0 0.7rem;
1261 - border: 1px solid hsl(var(--border));
1262 - border-radius: 999px;
1263 - background: hsl(var(--card));
1264 - font-size: 0.78rem;
1265 - color: hsl(var(--muted-foreground));
1266 - white-space: nowrap;
1267 - transition: border-color 120ms ease, color 120ms ease;
1268 -}
1269 -.stat-chip b {
1270 - color: hsl(var(--foreground));
1271 - font-weight: 600;
1272 -}
1273 -.stat-chip:hover {
1274 - color: hsl(var(--foreground));
1275 - border-color: hsl(var(--primary) / 0.55);
1276 -}
1247 +
1277 1248
1278 1249 .repo-layout {
1279 1250 display: grid;
1280 - grid-template-columns: minmax(0, 1fr) 305px;
1281 - gap: 1.1rem;
1251 + grid-template-columns: minmax(0, 1fr) 280px;
1252 + gap: 1rem;
1282 1253 align-items: start;
1283 1254 }
1284 1255 .repo-main {
internal/git/git.go
+74 -0
... ... @@ -117,6 +117,10 @@ func CleanSubPath(p string) (string, error) {
117 117 }
118 118
119 119 func runGit(ctx context.Context, repoPath string, timeout time.Duration, args ...string) ([]byte, error) {
120 + return runGitFull(ctx, repoPath, timeout, nil, nil, args...)
121 +}
122 +
123 +func runGitFull(ctx context.Context, repoPath string, timeout time.Duration, extraEnv []string, input []byte, args ...string) ([]byte, error) {
120 124 cctx, cancel := context.WithTimeout(ctx, timeout)
121 125 defer cancel()
122 126
... ... @@ -131,6 +135,10 @@ func runGit(ctx context.Context, repoPath string, timeout time.Duration, args ..
131 135 "GIT_TERMINAL_PROMPT=0",
132 136 "LC_ALL=C",
133 137 )
138 + cmd.Env = append(cmd.Env, extraEnv...)
139 + if input != nil {
140 + cmd.Stdin = bytes.NewReader(input)
141 + }
134 142 var stdout, stderr bytes.Buffer
135 143 cmd.Stdout = &stdout
136 144 cmd.Stderr = &stderr
... ... @@ -1038,3 +1046,69 @@ func mergeRefs(ctx context.Context, repoPath, baseRef, headRef, commitMsg, autho
1038 1046 }
1039 1047 return mergeHash, nil
1040 1048 }
1049 +
1050 +// WriteFileCommit creates or replaces filePath on branch with content and
1051 +// commits the change. It works on a bare repository directly through git
1052 +// plumbing and a temporary index file, so no working tree is required.
1053 +func WriteFileCommit(ctx context.Context, repoPath, branch, filePath string, content []byte, commitMsg, authorName, authorEmail string) (string, error) {
1054 + if err := ValidateRef(branch); err != nil {
1055 + return "", err
1056 + }
1057 + clean, err := CleanSubPath(filePath)
1058 + if err != nil || clean == "" {
1059 + return "", ErrInvalidPath
1060 + }
1061 + for _, segment := range strings.Split(clean, "/") {
1062 + if segment == ".git" {
1063 + return "", ErrInvalidPath
1064 + }
1065 + }
1066 +
1067 + baseOut, err := runGit(ctx, repoPath, 5*time.Second, "rev-parse", "refs/heads/"+branch)
1068 + if err != nil {
1069 + return "", fmt.Errorf("rev-parse %s: %w", branch, err)
1070 + }
1071 + baseHash := strings.TrimSpace(string(baseOut))
1072 +
1073 + blobOut, err := runGitFull(ctx, repoPath, 10*time.Second, nil, content, "hash-object", "-w", "--stdin")
1074 + if err != nil {
1075 + return "", fmt.Errorf("hash-object: %w", err)
1076 + }
1077 + blobHash := strings.TrimSpace(string(blobOut))
1078 +
1079 + indexPath := filepath.Join(os.TempDir(), fmt.Sprintf("polar-index-%d-%d", time.Now().UnixNano(), os.Getpid()))
1080 + defer os.Remove(indexPath)
1081 + indexEnv := []string{"GIT_INDEX_FILE=" + indexPath}
1082 +
1083 + if _, err := runGitFull(ctx, repoPath, 10*time.Second, indexEnv, nil, "read-tree", baseHash); err != nil {
1084 + return "", fmt.Errorf("read-tree: %w", err)
1085 + }
1086 + if _, err := runGitFull(ctx, repoPath, 10*time.Second, indexEnv, nil, "update-index", "--add", "--cacheinfo", "100644,"+blobHash+","+clean); err != nil {
1087 + return "", fmt.Errorf("update-index: %w", err)
1088 + }
1089 + treeOut, err := runGitFull(ctx, repoPath, 10*time.Second, indexEnv, nil, "write-tree")
1090 + if err != nil {
1091 + return "", fmt.Errorf("write-tree: %w", err)
1092 + }
1093 + treeHash := strings.TrimSpace(string(treeOut))
1094 +
1095 + if commitMsg == "" {
1096 + commitMsg = "Add " + clean
1097 + }
1098 + authorEnv := []string{
1099 + "GIT_AUTHOR_NAME=" + authorName,
1100 + "GIT_AUTHOR_EMAIL=" + authorEmail,
1101 + "GIT_COMMITTER_NAME=" + authorName,
1102 + "GIT_COMMITTER_EMAIL=" + authorEmail,
1103 + }
1104 + commitOut, err := runGitFull(ctx, repoPath, 10*time.Second, authorEnv, nil, "commit-tree", treeHash, "-p", baseHash, "-m", commitMsg)
1105 + if err != nil {
1106 + return "", fmt.Errorf("commit-tree: %w", err)
1107 + }
1108 + commitHash := strings.TrimSpace(string(commitOut))
1109 +
1110 + if _, err := runGit(ctx, repoPath, 5*time.Second, "update-ref", "refs/heads/"+branch, commitHash); err != nil {
1111 + return "", fmt.Errorf("update-ref: %w", err)
1112 + }
1113 + return commitHash, nil
1114 +}
internal/git/write_file_test.go
+95 -0
... ... @@ -0,0 +1,95 @@
1 +package git
2 +
3 +import (
4 + "context"
5 + "os"
6 + "os/exec"
7 + "path/filepath"
8 + "strings"
9 + "testing"
10 +)
11 +
12 +// TestWriteFileCommit creates files (including nested paths) directly on a
13 +// bare repository through git plumbing, without a working tree.
14 +func TestWriteFileCommit(t *testing.T) {
15 + ctx := context.Background()
16 + tmp := t.TempDir()
17 +
18 + repoPath := filepath.Join(tmp, "repo.git")
19 + if err := InitBare(ctx, repoPath, "main"); err != nil {
20 + t.Fatalf("InitBare: %v", err)
21 + }
22 +
23 + work := filepath.Join(tmp, "work")
24 + if err := os.MkdirAll(work, 0o755); err != nil {
25 + t.Fatalf("mkdir: %v", err)
26 + }
27 + run := func(dir string, args ...string) {
28 + t.Helper()
29 + cmd := exec.CommandContext(ctx, "git", args...)
30 + cmd.Dir = dir
31 + if out, err := cmd.CombinedOutput(); err != nil {
32 + t.Fatalf("git %v: %v (%s)", args, err, string(out))
33 + }
34 + }
35 + run(work, "init", "-b", "main")
36 + run(work, "config", "user.name", "Alice")
37 + run(work, "config", "user.email", "alice@example.com")
38 + if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("# Repo\n"), 0o644); err != nil {
39 + t.Fatalf("write: %v", err)
40 + }
41 + run(work, "add", ".")
42 + run(work, "commit", "-m", "initial")
43 + run(work, "push", repoPath, "main")
44 +
45 + hash, err := WriteFileCommit(ctx, repoPath, "main", "docs/guide.md", []byte("hello\n"), "Add docs/guide.md", "Alice", "alice@example.com")
46 + if err != nil {
47 + t.Fatalf("WriteFileCommit: %v", err)
48 + }
49 + if hash == "" {
50 + t.Fatalf("expected commit hash")
51 + }
52 +
53 + blob, err := ReadBlob(ctx, repoPath, "main", "docs/guide.md")
54 + if err != nil {
55 + t.Fatalf("ReadBlob: %v", err)
56 + }
57 + if string(blob.Data) != "hello\n" {
58 + t.Fatalf("unexpected blob content: %q", string(blob.Data))
59 + }
60 +
61 + out, err := runGit(ctx, repoPath, 10_000_000_000, "log", "-1", "--format=%s|%an|%ae", "main")
62 + if err != nil {
63 + t.Fatalf("log: %v", err)
64 + }
65 + line := strings.TrimSpace(string(out))
66 + if line != "Add docs/guide.md|Alice|alice@example.com" {
67 + t.Fatalf("unexpected commit metadata: %s", line)
68 + }
69 +
70 + // A second write replaces the file and keeps history.
71 + if _, err := WriteFileCommit(ctx, repoPath, "main", "docs/guide.md", []byte("hello v2\n"), "Update docs/guide.md", "Bob", "bob@example.com"); err != nil {
72 + t.Fatalf("second WriteFileCommit: %v", err)
73 + }
74 + if n := CountCommits(ctx, repoPath, "main"); n != 3 {
75 + t.Fatalf("expected 3 commits, got %d", n)
76 + }
77 + blob, err = ReadBlob(ctx, repoPath, "main", "docs/guide.md")
78 + if err != nil {
79 + t.Fatalf("ReadBlob v2: %v", err)
80 + }
81 + if string(blob.Data) != "hello v2\n" {
82 + t.Fatalf("unexpected blob content: %q", string(blob.Data))
83 + }
84 +
85 + // Invalid paths and refs must be rejected.
86 + if _, err := WriteFileCommit(ctx, repoPath, "main", "../evil.txt", []byte("x"), "", "Alice", "alice@example.com"); err == nil {
87 + t.Fatalf("expected invalid path to be rejected")
88 + }
89 + if _, err := WriteFileCommit(ctx, repoPath, "main", ".git/config", []byte("x"), "", "Alice", "alice@example.com"); err == nil {
90 + t.Fatalf("expected .git path to be rejected")
91 + }
92 + if _, err := WriteFileCommit(ctx, repoPath, "no-such-branch", "file.txt", []byte("x"), "", "Alice", "alice@example.com"); err == nil {
93 + t.Fatalf("expected missing branch to be rejected")
94 + }
95 +}
internal/i18n/i18n.go
+36 -0
... ... @@ -350,6 +350,24 @@ var translations = map[Lang]map[string]string{
350 350 "flash.release_published": "Релиз %s опубликован.",
351 351 "flash.release_delete_failed": "Не удалось удалить релиз: %s",
352 352 "flash.release_deleted": "Релиз удалён.",
353 + "file.new_file": "Новый файл",
354 + "file.new_folder": "Новая папка",
355 + "file.path": "Путь",
356 + "file.path_ph": "напр. src/main.go",
357 + "file.folder_ph": "напр. docs",
358 + "file.branch": "Ветка",
359 + "file.content": "Содержимое",
360 + "file.content_ph": "Текст файла…",
361 + "file.message": "Сообщение коммита",
362 + "file.create_file": "Создать файл",
363 + "file.create_folder": "Создать папку",
364 + "file.folder_hint": "Git не хранит пустые папки — Polar добавит туда .gitkeep.",
365 + "flash.file_created": "Файл создан.",
366 + "flash.folder_created": "Папка создана.",
367 + "flash.file_exists": "Файл с таким путём уже существует.",
368 + "flash.path_invalid": "Некорректный путь.",
369 + "flash.file_create_failed": "Не удалось создать файл.",
370 + "flash.folder_create_failed": "Не удалось создать папку.",
353 371 "flash.repo_settings_saved": "Настройки репозитория сохранены.",
354 372 "flash.repo_rename_failed": "Не удалось переименовать: %s",
355 373 "flash.repo_renamed": "Репозиторий переименован в %s.",
... ... @@ -815,6 +833,24 @@ var translations = map[Lang]map[string]string{
815 833 "flash.release_published": "Release %s published.",
816 834 "flash.release_delete_failed": "Could not delete the release: %s",
817 835 "flash.release_deleted": "Release deleted.",
836 + "file.new_file": "New file",
837 + "file.new_folder": "New folder",
838 + "file.path": "Path",
839 + "file.path_ph": "e.g. src/main.go",
840 + "file.folder_ph": "e.g. docs",
841 + "file.branch": "Branch",
842 + "file.content": "Content",
843 + "file.content_ph": "File contents…",
844 + "file.message": "Commit message",
845 + "file.create_file": "Create file",
846 + "file.create_folder": "Create folder",
847 + "file.folder_hint": "Git does not track empty folders — Polar adds a .gitkeep placeholder.",
848 + "flash.file_created": "File created.",
849 + "flash.folder_created": "Folder created.",
850 + "flash.file_exists": "A file with that path already exists.",
851 + "flash.path_invalid": "Invalid path.",
852 + "flash.file_create_failed": "Could not create the file.",
853 + "flash.folder_create_failed": "Could not create the folder.",
818 854 "flash.repo_settings_saved": "Repository settings saved.",
819 855 "flash.repo_rename_failed": "Could not rename: %s",
820 856 "flash.repo_renamed": "Repository renamed to %s.",
internal/web/handlers/handlers.go
+9 -1
... ... @@ -5,6 +5,7 @@ import (
5 5 "fmt"
6 6 "log/slog"
7 7 "net/http"
8 + "net/url"
8 9 "strings"
9 10 "time"
10 11
... ... @@ -118,6 +119,10 @@ func (h *Handler) RegisterRoutes(app *fiber.App) {
118 119 app.Post("/:owner/:repo/tags", middleware.RequireAuth(), h.RepoCreateTagSubmit)
119 120 app.Post("/:owner/:repo/tags/delete", middleware.RequireAuth(), h.RepoDeleteTagSubmit)
120 121 app.Get("/:owner/:repo/archive/*", h.RepoArchiveZip)
122 + app.Get("/:owner/:repo/new-file", middleware.RequireAuth(), h.RepoNewFilePage)
123 + app.Post("/:owner/:repo/new-file", middleware.RequireAuth(), h.RepoCreateFileSubmit)
124 + app.Get("/:owner/:repo/new-folder", middleware.RequireAuth(), h.RepoNewFolderPage)
125 + app.Post("/:owner/:repo/new-folder", middleware.RequireAuth(), h.RepoCreateFolderSubmit)
121 126
122 127 app.Post("/:owner/:repo/fork", middleware.RequireAuth(), h.RepoForkSubmit)
123 128 app.Get("/:owner/:repo/forks", h.RepoForksPage)
... ... @@ -184,6 +189,9 @@ func (h *Handler) baseData(c *fiber.Ctx, title string) map[string]any {
184 189
185 190 flash := c.Cookies("polar_flash")
186 191 if flash != "" {
192 + if decoded, err := url.QueryUnescape(flash); err == nil {
193 + flash = decoded
194 + }
187 195 c.ClearCookie("polar_flash")
188 196 }
189 197
... ... @@ -242,7 +250,7 @@ func (h *Handler) repoNavData(c *fiber.Ctx) map[string]any {
242 250 func (h *Handler) setFlash(c *fiber.Ctx, msg string) {
243 251 c.Cookie(&fiber.Cookie{
244 252 Name: "polar_flash",
245 - Value: msg,
253 + Value: url.QueryEscape(msg),
246 254 Path: "/",
247 255 MaxAge: 30,
248 256 HTTPOnly: true,
internal/web/handlers/repo_files.go
+131 -0
... ... @@ -0,0 +1,131 @@
1 +package handlers
2 +
3 +import (
4 + "fmt"
5 + "net/url"
6 + "strings"
7 +
8 + "polar/internal/git"
9 + "polar/internal/web/middleware"
10 +
11 + "github.com/gofiber/fiber/v2"
12 +)
13 +
14 +// RepoNewFilePage renders the web form for creating a file.
15 +// GET /:owner/:repo/new-file
16 +func (h *Handler) RepoNewFilePage(c *fiber.Ctx) error {
17 + return h.renderFileForm(c, "file")
18 +}
19 +
20 +// RepoNewFolderPage renders the form for creating a folder. Git cannot store
21 +// empty directories, so the folder is tracked through a .gitkeep placeholder.
22 +// GET /:owner/:repo/new-folder
23 +func (h *Handler) RepoNewFolderPage(c *fiber.Ctx) error {
24 + return h.renderFileForm(c, "folder")
25 +}
26 +
27 +func (h *Handler) renderFileForm(c *fiber.Ctx, mode string) error {
28 + r, diskPath, err := h.loadAuthorizedRepo(c, true)
29 + if err != nil {
30 + return nil
31 + }
32 +
33 + titleKey := "file.new_file"
34 + if mode == "folder" {
35 + titleKey = "file.new_folder"
36 + }
37 +
38 + ref := strings.TrimSpace(c.Query("ref"))
39 + if ref == "" {
40 + ref, _ = git.EnsureDefaultBranch(c.UserContext(), diskPath, r.DefaultBranch)
41 + }
42 + subPath := strings.Trim(strings.ReplaceAll(c.Query("path"), "\\", "/"), "/")
43 +
44 + data := h.baseData(c, h.tr(c, titleKey))
45 + data["Repo"] = r
46 + data["Ref"] = ref
47 + data["SubPath"] = subPath
48 + data["Mode"] = mode
49 + data["ActiveTab"] = "code"
50 + data["CanWrite"] = true
51 + data["Branches"], _ = git.ListBranches(c.UserContext(), diskPath)
52 + return h.render(c, fiber.StatusOK, "file_new", data)
53 +}
54 +
55 +// RepoCreateFileSubmit commits a new file to the selected branch.
56 +// POST /:owner/:repo/new-file
57 +func (h *Handler) RepoCreateFileSubmit(c *fiber.Ctx) error {
58 + r, diskPath, err := h.loadAuthorizedRepo(c, true)
59 + if err != nil {
60 + return nil
61 + }
62 + u := middleware.CurrentUser(c)
63 +
64 + ref := strings.TrimSpace(c.FormValue("ref"))
65 + if ref == "" || !git.BranchExists(c.UserContext(), diskPath, ref) {
66 + ref, _ = git.EnsureDefaultBranch(c.UserContext(), diskPath, r.DefaultBranch)
67 + }
68 +
69 + filePath := strings.TrimSpace(c.FormValue("path"))
70 + message := strings.TrimSpace(c.FormValue("message"))
71 + content := c.FormValue("content")
72 +
73 + backTo := fmt.Sprintf("/%s/%s/new-file?ref=%s", r.OwnerUsername, r.Name, url.QueryEscape(ref))
74 +
75 + clean, cerr := git.CleanSubPath(filePath)
76 + if cerr != nil || clean == "" {
77 + h.setFlashT(c, "flash.path_invalid")
78 + return c.Redirect(backTo, fiber.StatusSeeOther)
79 + }
80 + if blob, rerr := git.ReadBlob(c.UserContext(), diskPath, ref, clean); rerr == nil && blob != nil {
81 + h.setFlashT(c, "flash.file_exists")
82 + return c.Redirect(backTo+"&path="+url.QueryEscape(clean), fiber.StatusSeeOther)
83 + }
84 + if message == "" {
85 + message = "Add " + clean
86 + }
87 + if _, werr := git.WriteFileCommit(c.UserContext(), diskPath, ref, clean, []byte(content), message, u.Username, u.Email); werr != nil {
88 + h.setFlashT(c, "flash.file_create_failed")
89 + return c.Redirect(backTo+"&path="+url.QueryEscape(clean), fiber.StatusSeeOther)
90 + }
91 +
92 + h.setFlashT(c, "flash.file_created")
93 + return c.Redirect(fmt.Sprintf("/%s/%s/blob/%s/%s", r.OwnerUsername, r.Name, ref, clean), fiber.StatusSeeOther)
94 +}
95 +
96 +// RepoCreateFolderSubmit commits a .gitkeep placeholder so the new folder is
97 +// tracked by git.
98 +// POST /:owner/:repo/new-folder
99 +func (h *Handler) RepoCreateFolderSubmit(c *fiber.Ctx) error {
100 + r, diskPath, err := h.loadAuthorizedRepo(c, true)
101 + if err != nil {
102 + return nil
103 + }
104 + u := middleware.CurrentUser(c)
105 +
106 + ref := strings.TrimSpace(c.FormValue("ref"))
107 + if ref == "" || !git.BranchExists(c.UserContext(), diskPath, ref) {
108 + ref, _ = git.EnsureDefaultBranch(c.UserContext(), diskPath, r.DefaultBranch)
109 + }
110 +
111 + folder := strings.TrimSpace(c.FormValue("path"))
112 + message := strings.TrimSpace(c.FormValue("message"))
113 +
114 + backTo := fmt.Sprintf("/%s/%s/new-folder?ref=%s", r.OwnerUsername, r.Name, url.QueryEscape(ref))
115 +
116 + clean, cerr := git.CleanSubPath(folder)
117 + if cerr != nil || clean == "" {
118 + h.setFlashT(c, "flash.path_invalid")
119 + return c.Redirect(backTo, fiber.StatusSeeOther)
120 + }
121 + if message == "" {
122 + message = "Create folder " + clean
123 + }
124 + if _, werr := git.WriteFileCommit(c.UserContext(), diskPath, ref, clean+"/.gitkeep", nil, message, u.Username, u.Email); werr != nil {
125 + h.setFlashT(c, "flash.folder_create_failed")
126 + return c.Redirect(backTo, fiber.StatusSeeOther)
127 + }
128 +
129 + h.setFlashT(c, "flash.folder_created")
130 + return c.Redirect(fmt.Sprintf("/%s/%s/tree/%s/%s", r.OwnerUsername, r.Name, ref, clean), fiber.StatusSeeOther)
131 +}
internal/web/static/css/app.css
+19 -48
... ... @@ -188,34 +188,37 @@ a {
188 188 }
189 189
190 190 .app-sidebar {
191 - width: 236px;
192 - min-width: 236px;
193 - max-width: 236px;
194 - flex: 0 0 236px;
195 - border-right: 1px solid hsl(var(--border));
191 + width: 232px;
192 + min-width: 232px;
193 + max-width: 232px;
194 + flex: 0 0 232px;
195 + border: 1px solid hsl(var(--border));
196 + border-radius: calc(var(--radius) * 1.5);
196 197 background-color: hsl(var(--card));
197 198 display: none;
198 199 flex-direction: column;
199 200 justify-content: space-between;
200 201 position: sticky;
201 - top: 0;
202 - height: 100vh;
202 + top: 0.75rem;
203 + height: calc(100vh - 1.5rem);
204 + margin: 0.75rem 0 0.75rem 0.75rem;
203 205 overflow-y: auto;
204 206 overflow-x: hidden;
205 - padding: 0.85rem 0.75rem;
207 + padding: 0.85rem 0.7rem;
206 208 user-select: none;
207 209 transition:
208 210 width 200ms cubic-bezier(0.16, 1, 0.3, 1),
209 211 min-width 200ms cubic-bezier(0.16, 1, 0.3, 1),
210 212 max-width 200ms cubic-bezier(0.16, 1, 0.3, 1),
211 213 flex-basis 200ms cubic-bezier(0.16, 1, 0.3, 1),
214 + margin 200ms cubic-bezier(0.16, 1, 0.3, 1),
212 215 padding 200ms cubic-bezier(0.16, 1, 0.3, 1),
213 216 border-color 200ms cubic-bezier(0.16, 1, 0.3, 1);
214 217 }
215 218
216 219 .sidebar-inner {
217 - width: 218px;
218 - min-width: 218px;
220 + width: 100%;
221 + min-width: 0;
219 222 height: 100%;
220 223 display: flex;
221 224 flex-direction: column;
... ... @@ -230,9 +233,10 @@ html[data-sidebar="collapsed"] .app-sidebar {
230 233 min-width: 0;
231 234 max-width: 0;
232 235 flex-basis: 0;
236 + margin: 0.75rem 0;
233 237 padding-left: 0;
234 238 padding-right: 0;
235 - border-right-color: transparent;
239 + border-color: transparent;
236 240 }
237 241
238 242 html[data-sidebar="collapsed"] .sidebar-inner {
... ... @@ -1238,13 +1242,7 @@ kbd, .kbd {
1238 1242 border-radius: calc(var(--radius) * 1.6);
1239 1243 overflow: hidden;
1240 1244 }
1241 -.repo-banner::before {
1242 - content: "";
1243 - position: absolute;
1244 - inset: 0 0 auto 0;
1245 - height: 2px;
1246 - background: linear-gradient(90deg, hsl(var(--primary)), hsl(var(--primary) / 0) 65%);
1247 -}
1245 +
1248 1246 .repo-banner-main {
1249 1247 display: flex;
1250 1248 align-items: center;
... ... @@ -1259,39 +1257,12 @@ kbd, .kbd {
1259 1257 flex-wrap: wrap;
1260 1258 }
1261 1259
1262 -.repo-stats {
1263 - display: flex;
1264 - align-items: center;
1265 - gap: 0.4rem;
1266 - flex-wrap: wrap;
1267 -}
1268 -.stat-chip {
1269 - display: inline-flex;
1270 - align-items: center;
1271 - gap: 0.4rem;
1272 - height: 1.85rem;
1273 - padding: 0 0.7rem;
1274 - border: 1px solid hsl(var(--border));
1275 - border-radius: 999px;
1276 - background: hsl(var(--card));
1277 - font-size: 0.78rem;
1278 - color: hsl(var(--muted-foreground));
1279 - white-space: nowrap;
1280 - transition: border-color 120ms ease, color 120ms ease;
1281 -}
1282 -.stat-chip b {
1283 - color: hsl(var(--foreground));
1284 - font-weight: 600;
1285 -}
1286 -.stat-chip:hover {
1287 - color: hsl(var(--foreground));
1288 - border-color: hsl(var(--primary) / 0.55);
1289 -}
1260 +
1290 1261
1291 1262 .repo-layout {
1292 1263 display: grid;
1293 - grid-template-columns: minmax(0, 1fr) 305px;
1294 - gap: 1.1rem;
1264 + grid-template-columns: minmax(0, 1fr) 280px;
1265 + gap: 1rem;
1295 1266 align-items: start;
1296 1267 }
1297 1268 .repo-main {
internal/web/views/pages/file_new.html
+53 -0
... ... @@ -0,0 +1,53 @@
1 +{{define "content"}}
2 +<div class="container">
3 + {{template "repo_header" .}}
4 +
5 + <div class="card" style="max-width:680px;">
6 + <div class="card-header" style="padding:0.6rem 0.9rem;">
7 + <span class="font-semibold text-sm flex items-center gap-2">
8 + {{if eq .Mode "folder"}}{{icon "folder" "size-4"}}{{else}}{{icon "file" "size-4"}}{{end}}
9 + {{if eq .Mode "folder"}}{{t .Lang "file.new_folder"}}{{else}}{{t .Lang "file.new_file"}}{{end}}
10 + </span>
11 + <span class="font-mono text-xs text-muted">{{.Repo.OwnerUsername}}/{{.Repo.Name}}</span>
12 + </div>
13 + <form method="post" action="{{if eq .Mode "folder"}}/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/new-folder{{else}}/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/new-file{{end}}" class="card-body flex flex-col gap-3">
14 + <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
15 +
16 + <div class="flex flex-col gap-1">
17 + <label class="text-xs font-semibold" for="path">{{t .Lang "file.path"}}</label>
18 + <input id="path" name="path" class="input font-mono" required autofocus value="{{if .SubPath}}{{.SubPath}}/{{end}}" placeholder="{{if eq .Mode "folder"}}{{t .Lang "file.folder_ph"}}{{else}}{{t .Lang "file.path_ph"}}{{end}}">
19 + {{if eq .Mode "folder"}}<span class="text-xs text-muted">{{t .Lang "file.folder_hint"}}</span>{{end}}
20 + </div>
21 +
22 + <div class="flex flex-col gap-1" style="max-width:240px;">
23 + <label class="text-xs font-semibold" for="ref">{{t .Lang "file.branch"}}</label>
24 + <select id="ref" name="ref" class="input font-mono">
25 + {{range .Branches}}<option value="{{.}}" {{if eq . $.Ref}}selected{{end}}>{{.}}</option>{{end}}
26 + </select>
27 + </div>
28 +
29 + {{if eq .Mode "file"}}
30 + <div class="flex flex-col gap-1">
31 + <label class="text-xs font-semibold" for="content">{{t .Lang "file.content"}}</label>
32 + <textarea id="content" name="content" class="input font-mono" rows="12" placeholder="{{t .Lang "file.content_ph"}}" style="resize:vertical;min-height:12rem;line-height:1.5;"></textarea>
33 + </div>
34 + {{end}}
35 +
36 + <div class="flex flex-col gap-1">
37 + <label class="text-xs font-semibold" for="message">{{t .Lang "file.message"}}</label>
38 + <input id="message" name="message" class="input font-mono" placeholder="{{if eq .Mode "folder"}}Create folder{{else}}Add file{{end}}">
39 + </div>
40 +
41 + <div class="flex items-center gap-2 pt-1">
42 + <button type="submit" class="btn btn-primary btn-sm">
43 + {{icon "check" "size-3.5"}}
44 + <span>{{if eq .Mode "folder"}}{{t .Lang "file.create_folder"}}{{else}}{{t .Lang "file.create_file"}}{{end}}</span>
45 + </button>
46 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/tree/{{.Ref}}{{if .SubPath}}/{{.SubPath}}{{end}}" class="btn btn-ghost btn-sm">
47 + <span>{{t .Lang "common.cancel"}}</span>
48 + </a>
49 + </div>
50 + </form>
51 + </div>
52 +</div>
53 +{{end}}
internal/web/views/pages/profile.html
+50 -80
... ... @@ -1,45 +1,39 @@
1 1 {{define "content"}}
2 2 <div class="container">
3 - <!-- Profile Card -->
3 + <!-- Profile header -->
4 4 <div class="card p-4 mb-4">
5 - <div class="flex items-start justify-between flex-wrap gap-4">
6 - <div class="flex items-start gap-4 min-w-0">
7 - {{avatar .ProfileUser.Username .ProfileUser.AvatarURL "avatar-xl"}}
8 - <div class="flex flex-col gap-1 min-w-0">
9 - <div class="flex items-center gap-2 flex-wrap">
10 - {{if .ProfileUser.FullName}}
11 - <h1 class="text-xl font-bold" style="margin:0;line-height:1.2;">{{.ProfileUser.FullName}}</h1>
12 - <span class="font-mono text-sm text-muted">@{{.ProfileUser.Username}}</span>
13 - {{else}}
14 - <h1 class="text-xl font-bold" style="margin:0;line-height:1.2;">{{.ProfileUser.Username}}</h1>
15 - {{end}}
16 - {{if .ProfileUser.IsAdmin}}
17 - <span class="badge badge-primary">{{t .Lang "admin.role.admin"}}</span>
18 - {{end}}
19 - </div>
20 -
21 - {{if .ProfileUser.Bio}}
22 - <p class="text-sm" style="margin:0.15rem 0 0.25rem;max-width:38rem;">{{.ProfileUser.Bio}}</p>
5 + <div class="flex items-start gap-4 flex-wrap">
6 + {{avatar .ProfileUser.Username .ProfileUser.AvatarURL "avatar-xl"}}
7 + <div class="min-w-0 flex-1">
8 + <div class="flex items-center gap-2 flex-wrap">
9 + <h1 class="text-xl font-bold" style="margin:0;line-height:1.2;">{{if .ProfileUser.FullName}}{{.ProfileUser.FullName}}{{else}}{{.ProfileUser.Username}}{{end}}</h1>
10 + {{if .ProfileUser.IsAdmin}}
11 + <span class="badge badge-primary">{{t .Lang "admin.role.admin"}}</span>
23 12 {{end}}
13 + </div>
14 + <div class="text-sm text-muted font-mono">@{{.ProfileUser.Username}}</div>
15 +
16 + {{if .ProfileUser.Bio}}
17 + <p class="text-sm" style="margin:0.45rem 0 0;max-width:38rem;">{{.ProfileUser.Bio}}</p>
18 + {{end}}
24 19
25 - <div class="flex items-center flex-wrap gap-3 text-xs text-muted mt-1">
26 - {{if .ProfileUser.Location}}
27 - <span class="flex items-center gap-1">
28 - {{icon "map-pin" "size-3.5"}}
29 - <span>{{.ProfileUser.Location}}</span>
30 - </span>
31 - {{end}}
32 - {{if .ProfileUser.Website}}
33 - <a href="{{.ProfileUser.Website}}" target="_blank" rel="noopener noreferrer" class="flex items-center gap-1" style="color:hsl(var(--foreground));text-decoration:underline;text-underline-offset:3px;">
34 - {{icon "link" "size-3.5"}}
35 - <span>{{.ProfileUser.Website}}</span>
36 - </a>
37 - {{end}}
20 + <div class="flex items-center flex-wrap gap-3 text-xs text-muted mt-2">
21 + {{if .ProfileUser.Location}}
38 22 <span class="flex items-center gap-1">
39 - {{icon "calendar" "size-3.5"}}
40 - <span>{{t .Lang "profile.joined"}}: {{formatShortDate .ProfileUser.CreatedAt}}</span>
23 + {{icon "map-pin" "size-3.5"}}
24 + <span>{{.ProfileUser.Location}}</span>
41 25 </span>
42 - </div>
26 + {{end}}
27 + {{if .ProfileUser.Website}}
28 + <a href="{{.ProfileUser.Website}}" target="_blank" rel="noopener noreferrer" class="flex items-center gap-1" style="color:hsl(var(--foreground));">
29 + {{icon "link" "size-3.5"}}
30 + <span>{{.ProfileUser.Website}}</span>
31 + </a>
32 + {{end}}
33 + <span class="flex items-center gap-1">
34 + {{icon "calendar" "size-3.5"}}
35 + <span>{{t .Lang "profile.joined"}}: {{formatShortDate .ProfileUser.CreatedAt}}</span>
36 + </span>
43 37 </div>
44 38 </div>
45 39
... ... @@ -54,32 +48,14 @@
54 48 {{end}}
55 49 </div>
56 50 </div>
57 -
58 - <!-- Stats Row -->
59 - <div class="flex items-center gap-4 flex-wrap mt-4 pt-3" style="border-top:1px solid hsl(var(--border));">
60 - <div class="flex items-center gap-1.5 text-xs">
61 - {{icon "git-branch" "size-3.5"}}
62 - <span class="text-muted">{{t .Lang "profile.total_repos"}}:</span>
63 - <strong class="font-mono">{{len .Repos}}</strong>
64 - </div>
65 - <div class="flex items-center gap-1.5 text-xs">
66 - {{icon "globe" "size-3.5"}}
67 - <span class="text-muted">{{t .Lang "profile.public_repos"}}:</span>
68 - <strong class="font-mono">{{.PublicRepoCount}}</strong>
69 - </div>
70 - {{if .ShowPrivateCount}}
71 - <div class="flex items-center gap-1.5 text-xs">
72 - {{icon "lock" "size-3.5"}}
73 - <span class="text-muted">{{t .Lang "profile.private_repos"}}:</span>
74 - <strong class="font-mono">{{.PrivateRepoCount}}</strong>
75 - </div>
76 - {{end}}
77 - </div>
78 51 </div>
79 52
80 - <!-- Repositories Section -->
81 - <div class="flex items-center justify-between flex-wrap gap-3 mb-3">
82 - <h2 class="text-base font-semibold" style="margin:0;">{{t .Lang "nav.repositories"}} ({{len .Repos}})</h2>
53 + <!-- Repositories -->
54 + <div class="flex items-center justify-between flex-wrap gap-2 mb-3">
55 + <h2 class="text-base font-semibold" style="margin:0;">
56 + {{t .Lang "nav.repositories"}}
57 + <span class="text-muted font-mono text-sm" style="font-weight:400;">{{len .Repos}}</span>
58 + </h2>
83 59 <div class="flex items-center gap-2">
84 60 <div class="flex items-center" style="position:relative;">
85 61 <input type="text" class="input" style="height:1.85rem;width:14rem;padding-right:1.8rem;" placeholder="{{t .Lang "repo.filter_ph"}}" data-search-input data-file-filter>
... ... @@ -99,30 +75,24 @@
99 75 {{if .Repos}}
100 76 <div class="grid-2">
101 77 {{range .Repos}}
102 - <div class="card p-3 flex flex-col justify-between gap-2" data-tree-row data-tree-name="{{.Name}} {{.Description}}">
103 - <div>
104 - <div class="flex items-center justify-between gap-2 mb-1">
105 - <a href="/{{.OwnerUsername}}/{{.Name}}" class="font-semibold text-sm flex items-center gap-1.5 truncate">
106 - {{if .IsPrivate}}
107 - {{icon "lock" "size-3.5"}}
108 - {{else}}
109 - {{icon "git-branch" "size-3.5"}}
110 - {{end}}
111 - <span class="truncate">{{.Name}}</span>
112 - </a>
78 + <div class="card p-3 flex flex-col gap-2" data-tree-row data-tree-name="{{.Name}} {{.Description}}">
79 + <div class="flex items-center justify-between gap-2">
80 + <a href="/{{.OwnerUsername}}/{{.Name}}" class="font-semibold text-sm flex items-center gap-1.5 truncate">
113 81 {{if .IsPrivate}}
114 - <span class="badge badge-outline">{{t $.Lang "repo.private"}}</span>
82 + {{icon "lock" "size-3.5"}}
115 83 {{else}}
116 - <span class="badge badge-outline">{{t $.Lang "repo.public"}}</span>
84 + {{icon "git-branch" "size-3.5"}}
117 85 {{end}}
118 - </div>
119 - {{if .Description}}
120 - <p class="text-xs text-muted" style="margin:0.2rem 0 0;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;">{{.Description}}</p>
121 - {{end}}
86 + <span class="truncate">{{.Name}}</span>
87 + </a>
88 + <span class="text-xs text-muted flex-shrink-0">{{timeAgo .UpdatedAt $.Lang}}</span>
122 89 </div>
123 - <div class="flex items-center justify-between text-xs text-muted pt-2" style="border-top:1px solid hsl(var(--border)/0.6);">
124 - <span class="font-mono flex items-center gap-1">{{icon "git-branch" "size-3.5"}} {{.DefaultBranch}}</span>
125 - <span>{{timeAgo .UpdatedAt $.Lang}}</span>
90 + {{if .Description}}
91 + <p class="text-xs text-muted" style="margin:0;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;">{{.Description}}</p>
92 + {{end}}
93 + <div class="text-xs text-muted font-mono flex items-center gap-1.5">
94 + {{icon "git-branch" "size-3.5"}}
95 + <span>{{.DefaultBranch}}</span>
126 96 </div>
127 97 </div>
128 98 {{end}}
... ... @@ -135,4 +105,4 @@
135 105 {{end}}
136 106 {{end}}
137 107 </div>
138 -{{end}}
108 +{{end}}
internal/web/views/pages/repo.html
+25 -38
... ... @@ -92,9 +92,21 @@
92 92 {{end}}
93 93 </div>
94 94
95 - <div class="flex items-center" style="position:relative;">
96 - <input type="text" class="input" style="height:1.75rem;width:13rem;padding-right:1.75rem;font-size:0.78rem;" placeholder="{{t .Lang "repo.find_file"}}" data-file-filter>
97 - <kbd class="kbd" style="position:absolute;right:0.35rem;pointer-events:none;">t</kbd>
95 + <div class="flex items-center gap-2 flex-wrap">
96 + <div class="flex items-center" style="position:relative;">
97 + <input type="text" class="input" style="height:1.75rem;width:13rem;padding-right:1.75rem;font-size:0.78rem;" placeholder="{{t .Lang "repo.find_file"}}" data-file-filter>
98 + <kbd class="kbd" style="position:absolute;right:0.35rem;pointer-events:none;">t</kbd>
99 + </div>
100 + {{if .CanWrite}}
101 + <a class="btn btn-outline btn-sm" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/new-file?ref={{.Ref}}{{if .SubPath}}&path={{.SubPath}}{{end}}" title="{{t .Lang "file.new_file"}}">
102 + {{icon "plus" "size-3.5"}}
103 + <span class="hidden md-flex">{{t .Lang "file.new_file"}}</span>
104 + </a>
105 + <a class="btn btn-ghost btn-sm" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/new-folder?ref={{.Ref}}{{if .SubPath}}&path={{.SubPath}}{{end}}" title="{{t .Lang "file.new_folder"}}">
106 + {{icon "folder" "size-3.5"}}
107 + <span class="hidden md-flex">{{t .Lang "file.new_folder"}}</span>
108 + </a>
109 + {{end}}
98 110 </div>
99 111 </div>
100 112
... ... @@ -165,37 +177,6 @@
165 177 </div>
166 178
167 179 <aside class="repo-side">
168 - <div class="side-card">
169 - <div class="side-card-header">{{icon "info" "size-3.5"}} {{t .Lang "repo.about"}}</div>
170 - <div class="side-card-body">
171 - {{if .Repo.Description}}
172 - <p style="margin:0;">{{.Repo.Description}}</p>
173 - {{end}}
174 - {{if .Repo.ForkedFromRepoID}}
175 - <div class="meta-row">
176 - <span>{{t .Lang "fork.forked_from"}}</span>
177 - <a href="/{{.Repo.ForkedFromOwner}}/{{.Repo.ForkedFromName}}" class="font-medium font-mono">{{.Repo.ForkedFromOwner}}/{{.Repo.ForkedFromName}}</a>
178 - </div>
179 - {{end}}
180 - <div class="meta-row">
181 - <span>{{t .Lang "repo.default_branch"}}</span>
182 - <span class="badge badge-outline font-mono">{{.Repo.DefaultBranch}}</span>
183 - </div>
184 - <div class="meta-row">
185 - <span>{{t .Lang "fork.forks"}}</span>
186 - <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/forks" class="font-mono">{{.Repo.ForkCount}}</a>
187 - </div>
188 - <div class="meta-row">
189 - <span>{{t .Lang "repo.created"}}</span>
190 - <span class="font-mono">{{formatDate .Repo.CreatedAt}}</span>
191 - </div>
192 - <div class="meta-row">
193 - <span>{{t .Lang "repo.updated"}}</span>
194 - <span class="font-mono">{{formatDate .Repo.UpdatedAt}}</span>
195 - </div>
196 - </div>
197 - </div>
198 -
199 180 <div class="side-card">
200 181 <div class="side-card-header">{{icon "terminal" "size-3.5"}} {{t .Lang "repo.clone_title"}}</div>
201 182 <div class="side-card-body">
... ... @@ -205,10 +186,16 @@
205 186 {{icon "copy" "size-3.5"}}
206 187 </button>
207 188 </div>
208 - <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/archive/{{.Ref}}.zip" class="btn btn-outline btn-sm w-full justify-center">
209 - {{icon "download" "size-3.5"}}
210 - <span>{{t .Lang "repo.download_zip"}}</span>
211 - </a>
189 + <div class="flex items-center justify-between">
190 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/archive/{{.Ref}}.zip" class="text-xs text-muted flex items-center gap-1.5">
191 + {{icon "download" "size-3.5"}}
192 + <span>{{t .Lang "repo.download_zip"}}</span>
193 + </a>
194 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/commits/{{.Ref}}" class="text-xs text-muted flex items-center gap-1.5">
195 + {{icon "history" "size-3.5"}}
196 + <span>{{t .Lang "repo.tab.commits"}}</span>
197 + </a>
198 + </div>
212 199 </div>
213 200 </div>
214 201 </aside>
internal/web/views/partials/components.html
+13 -28
... ... @@ -73,44 +73,29 @@
73 73 </div>
74 74 </div>
75 75
76 - <div class="repo-banner-actions">
77 - {{if .CurrentUser}}
76 + {{if .CurrentUser}}
77 + <div class="repo-banner-actions">
78 78 {{if .IsRepoOwner}}
79 - <a class="btn btn-outline btn-sm" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/forks">{{icon "git-branch" "size-3.5"}}<span>{{if gt .Repo.ForkCount 0}}{{.Repo.ForkCount}} {{end}}{{t .Lang "fork.forks_btn"}}</span></a>
79 + <a class="btn btn-ghost btn-sm" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/forks" title="{{t .Lang "fork.forks_btn"}}">{{icon "git-branch" "size-3.5"}}<span class="hidden md-flex">{{if gt .Repo.ForkCount 0}}{{.Repo.ForkCount}} {{end}}{{t .Lang "fork.forks_btn"}}</span></a>
80 80 {{else if .ViewerFork}}
81 - <a class="btn btn-outline btn-sm" href="/{{.ViewerFork.OwnerUsername}}/{{.ViewerFork.Name}}" title="{{t .Lang "fork.your_fork_desc"}}">{{icon "git-branch" "size-3.5"}}<span>{{t .Lang "fork.your_fork"}}</span></a>
81 + <a class="btn btn-ghost btn-sm" href="/{{.ViewerFork.OwnerUsername}}/{{.ViewerFork.Name}}" title="{{t .Lang "fork.your_fork_desc"}}">{{icon "git-branch" "size-3.5"}}<span class="hidden md-flex">{{t .Lang "fork.your_fork"}}</span></a>
82 82 {{else}}
83 83 <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/fork" style="margin:0;">
84 84 <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
85 - <button class="btn btn-outline btn-sm" type="submit" title="{{t .Lang "fork.fork_btn_desc"}}">{{icon "git-branch" "size-3.5"}}<span>{{t .Lang "fork.fork_btn"}}</span></button>
85 + <button class="btn btn-ghost btn-sm" type="submit" title="{{t .Lang "fork.fork_btn_desc"}}">{{icon "git-branch" "size-3.5"}}<span class="hidden md-flex">{{t .Lang "fork.fork_btn"}}</span></button>
86 86 </form>
87 87 {{end}}
88 - {{else}}
89 - <a class="btn btn-outline btn-sm" href="/login?next=/{{.Repo.OwnerUsername}}/{{.Repo.Name}}">{{icon "git-branch" "size-3.5"}}<span>{{t .Lang "fork.fork_btn"}}</span></a>
90 - {{end}}
91 - {{if .Repo.EnableIssues}}
92 - <a class="btn btn-ghost btn-sm" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/issues/new">{{icon "issue" "size-3.5"}}<span class="hidden md-flex">{{t .Lang "issues.new"}}</span></a>
93 - {{end}}
94 - {{if .Repo.EnablePulls}}
95 - <a class="btn btn-ghost btn-sm" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls/new">{{icon "git-pull-request" "size-3.5"}}<span class="hidden md-flex">{{t .Lang "pulls.new"}}</span></a>
96 - {{end}}
97 - </div>
98 - </div>
99 -
100 - <div class="repo-stats mb-3">
101 - {{if .Repo.EnableIssues}}
102 - <a class="stat-chip" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/issues">{{icon "issue" "size-3.5"}}<span>{{t .Lang "repo.tab.issues"}}</span> <b>{{.Repo.OpenIssueCount}}</b></a>
103 - {{end}}
104 - {{if .Repo.EnablePulls}}
105 - <a class="stat-chip" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls">{{icon "git-pull-request" "size-3.5"}}<span>{{t .Lang "repo.tab.pulls"}}</span> <b>{{.Repo.OpenPRCount}}</b></a>
106 - {{end}}
107 - {{if .Repo.EnableReleases}}
108 - <a class="stat-chip" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/releases">{{icon "tag" "size-3.5"}}<span>{{t .Lang "repo.tab.releases"}}</span> <b>{{.Repo.ReleaseCount}}</b></a>
88 + {{if .Repo.EnableIssues}}
89 + <a class="btn btn-ghost btn-sm" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/issues/new" title="{{t .Lang "issues.new"}}">{{icon "issue" "size-3.5"}}<span class="hidden md-flex">{{t .Lang "issues.new"}}</span></a>
90 + {{end}}
91 + {{if .Repo.EnablePulls}}
92 + <a class="btn btn-ghost btn-sm" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls/new" title="{{t .Lang "pulls.new"}}">{{icon "git-pull-request" "size-3.5"}}<span class="hidden md-flex">{{t .Lang "pulls.new"}}</span></a>
93 + {{end}}
94 + </div>
109 95 {{end}}
110 - <a class="stat-chip" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/forks">{{icon "git-branch" "size-3.5"}}<span>{{t .Lang "fork.forks"}}</span> <b>{{.Repo.ForkCount}}</b></a>
111 - <a class="stat-chip" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/branches">{{icon "history" "size-3.5"}}<span>{{t .Lang "repo.tab.branches"}}</span></a>
112 96 </div>
113 97
98 +
114 99 <div class="tabs-nav" role="tablist">
115 100 <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}" class="tab-link {{if eq .ActiveTab "code"}}active{{end}}" role="tab">
116 101 {{icon "code" "size-4"}}