Initial commit: Polar - lightweight self-hosted Git server
99db92d
Файлы на момент коммита
R
rin
·
2026-10-16 15:30 UTC
95 файлов изменено
+17314
-0
.gitattributes
+1
-0
| ... | ... | @@ -0,0 +1 @@ |
| 1 | +* text=auto eol=lf |
.gitignore
+23
-0
| ... | ... | @@ -0,0 +1,23 @@ |
| 1 | +# Binaries and build output | |
| 2 | +/dist/ | |
| 3 | +/polar | |
| 4 | +/polar.exe | |
| 5 | +*.exe | |
| 6 | +*.test | |
| 7 | + | |
| 8 | +# Runtime data and local configuration | |
| 9 | +/data/ | |
| 10 | +/polar.toml | |
| 11 | +*.local.toml | |
| 12 | + | |
| 13 | +# Development scratch | |
| 14 | +/.shots/ | |
| 15 | +/tmpdbg/ | |
| 16 | +/.tmp_* | |
| 17 | + | |
| 18 | +# Editors and OS | |
| 19 | +.DS_Store | |
| 20 | +Thumbs.db | |
| 21 | +.idea/ | |
| 22 | +.vscode/ | |
| 23 | +*.swp |
Dockerfile
+24
-0
| ... | ... | @@ -0,0 +1,24 @@ |
| 1 | +# Stage 1: Build static Go binary | |
| 2 | +FROM golang:1.23-alpine AS builder | |
| 3 | +WORKDIR /src | |
| 4 | +COPY go.mod go.sum ./ | |
| 5 | +RUN go mod download | |
| 6 | +COPY . . | |
| 7 | +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/polar ./cmd/polar | |
| 8 | + | |
| 9 | +# Stage 2: Minimal runtime image with git | |
| 10 | +FROM alpine:3.20 | |
| 11 | +RUN apk add --no-cache git ca-certificates tzdata && \ | |
| 12 | + adduser -D -u 10001 polar | |
| 13 | +WORKDIR /app | |
| 14 | +COPY --from=builder /out/polar /app/polar | |
| 15 | +RUN mkdir -p /app/data && chown -R polar:polar /app | |
| 16 | + | |
| 17 | +USER polar | |
| 18 | +EXPOSE 3000 | |
| 19 | +VOLUME ["/app/data"] | |
| 20 | + | |
| 21 | +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ | |
| 22 | + CMD wget -qO- http://127.0.0.1:3000/healthz || exit 1 | |
| 23 | + | |
| 24 | +ENTRYPOINT ["/app/polar"] |
Makefile
+32
-0
| ... | ... | @@ -0,0 +1,32 @@ |
| 1 | +.PHONY: build run dev css test lint build-all clean | |
| 2 | + | |
| 3 | +BINARY ?= polar | |
| 4 | +TAILWIND_CLI ?= tailwindcss | |
| 5 | + | |
| 6 | +build: | |
| 7 | + CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o $(BINARY) ./cmd/polar | |
| 8 | + | |
| 9 | +run: build | |
| 10 | + ./$(BINARY) | |
| 11 | + | |
| 12 | +dev: | |
| 13 | + CGO_ENABLED=0 go run ./cmd/polar | |
| 14 | + | |
| 15 | +css: | |
| 16 | + $(TAILWIND_CLI) -i ./assets/input.css -o ./internal/web/static/css/app.css --minify | |
| 17 | + | |
| 18 | +test: | |
| 19 | + CGO_ENABLED=0 go test -v -race=false ./... | |
| 20 | + | |
| 21 | +lint: | |
| 22 | + go vet ./... | |
| 23 | + | |
| 24 | +build-all: | |
| 25 | + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o dist/polar-linux-amd64 ./cmd/polar | |
| 26 | + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w" -o dist/polar-linux-arm64 ./cmd/polar | |
| 27 | + CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o dist/polar-darwin-amd64 ./cmd/polar | |
| 28 | + CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags="-s -w" -o dist/polar-darwin-arm64 ./cmd/polar | |
| 29 | + CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o dist/polar-windows-amd64.exe ./cmd/polar | |
| 30 | + | |
| 31 | +clean: | |
| 32 | + rm -rf $(BINARY) $(BINARY).exe dist/ |
README.md
+220
-0
| ... | ... | @@ -0,0 +1,220 @@ |
| 1 | +# Polar | |
| 2 | + | |
| 3 | +**Lightweight self-hosted Git hosting in a single Go binary.** | |
| 4 | + | |
| 5 | +Polar is a small, fast alternative to heavyweight Git servers: push over HTTP, browse code, file issues, open pull requests, and publish releases — all served from one static binary with an embedded SQLite database and no external runtime dependencies. | |
| 6 | + | |
| 7 | +## Highlights | |
| 8 | + | |
| 9 | +- **One binary, zero dependencies** — `CGO_ENABLED=0` build, pure-Go SQLite (`modernc.org/sqlite`) in WAL mode, bare repositories stored on disk. | |
| 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. | |
| 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 | +- **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 | +- **Command palette** — press `Ctrl+K` / `Cmd+K` for quick navigation and actions. | |
| 15 | +- **Three themes** — Light (`#fff` background, `#111` foreground), Dark (`#111` background, `#fff` foreground), and System (follows the OS `prefers-color-scheme`). The choice is persisted in a `polar_theme` cookie so the server renders the correct `data-theme` on first paint — no flash of the wrong theme. | |
| 16 | +- **Bilingual interface** — Russian and English, with a configurable default language. | |
| 17 | +- **Practical security** — Argon2id password hashing, CSRF protection, rate-limited login and registration, session cookies, and revocable access tokens. | |
| 18 | + | |
| 19 | +## Quick Start | |
| 20 | + | |
| 21 | +### Build from source | |
| 22 | + | |
| 23 | +Requirements: Go (version per `go.mod`) and `git` available on the host. | |
| 24 | + | |
| 25 | +```bash | |
| 26 | +CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o polar ./cmd/polar | |
| 27 | +./polar | |
| 28 | +``` | |
| 29 | + | |
| 30 | +Polar starts on `0.0.0.0:3000` and keeps its data in `./data`. Open http://localhost:3000 and register — **the first registered account becomes the administrator**. After that you can disable public registration or manage users from the admin panel. | |
| 31 | + | |
| 32 | +### Docker | |
| 33 | + | |
| 34 | +```bash | |
| 35 | +docker compose up -d | |
| 36 | +``` | |
| 37 | + | |
| 38 | +Or without Compose: | |
| 39 | + | |
| 40 | +```bash | |
| 41 | +docker build -t polar . | |
| 42 | +docker run -d --name polar -p 3000:3000 -v "$PWD/data:/app/data" polar | |
| 43 | +``` | |
| 44 | + | |
| 45 | +### systemd | |
| 46 | + | |
| 47 | +```ini | |
| 48 | +# /etc/systemd/system/polar.service | |
| 49 | +[Unit] | |
| 50 | +Description=Polar Self-Hosted Git Server | |
| 51 | +After=network.target | |
| 52 | + | |
| 53 | +[Service] | |
| 54 | +Type=simple | |
| 55 | +User=polar | |
| 56 | +Group=polar | |
| 57 | +WorkingDirectory=/var/lib/polar | |
| 58 | +ExecStart=/usr/local/bin/polar -config /etc/polar/polar.toml | |
| 59 | +Environment=POLAR_STORAGE_DATA_DIR=/var/lib/polar/data | |
| 60 | +Restart=on-failure | |
| 61 | + | |
| 62 | +[Install] | |
| 63 | +WantedBy=multi-user.target | |
| 64 | +``` | |
| 65 | + | |
| 66 | +## Configuration | |
| 67 | + | |
| 68 | +Polar runs with sensible defaults and no config file. To customize, copy `polar.example.toml` to `polar.toml` (auto-detected in the working directory) or pass `-config /path/to/polar.toml`. Every setting can also be provided through environment variables, which take precedence over the file. | |
| 69 | + | |
| 70 | +```toml | |
| 71 | +[server] | |
| 72 | +host = "0.0.0.0" | |
| 73 | +port = 3000 | |
| 74 | +base_url = "http://localhost:3000" | |
| 75 | + | |
| 76 | +[storage] | |
| 77 | +data_dir = "./data" | |
| 78 | + | |
| 79 | +[auth] | |
| 80 | +registration_enabled = true | |
| 81 | + | |
| 82 | +[landing] | |
| 83 | +enabled = true | |
| 84 | +default_lang = "en" # "en" or "ru" | |
| 85 | +``` | |
| 86 | + | |
| 87 | +| Environment variable | Description | Default | | |
| 88 | +|---|---|---| | |
| 89 | +| `POLAR_SERVER_HOST` | HTTP listen host | `0.0.0.0` | | |
| 90 | +| `POLAR_SERVER_PORT` | HTTP listen port | `3000` | | |
| 91 | +| `POLAR_SERVER_BASE_URL` | Public URL used for clone links | `http://localhost:3000` | | |
| 92 | +| `POLAR_STORAGE_DATA_DIR` | Directory for the database and bare repositories | `./data` | | |
| 93 | +| `POLAR_AUTH_REGISTRATION_ENABLED` | Allow public registration | `true` | | |
| 94 | +| `POLAR_LANDING_ENABLED` | Serve the landing page at `/` | `true` | | |
| 95 | +| `POLAR_LANDING_DEFAULT_LANG` | Default UI language (`ru` or `en`) | `ru` | | |
| 96 | +| `POLAR_CONFIG` | Path to the configuration file | `polar.toml` if present | | |
| 97 | + | |
| 98 | +Short aliases are also accepted: `POLAR_HOST`, `POLAR_PORT`, `POLAR_BASE_URL`, `POLAR_DATA_DIR`. | |
| 99 | + | |
| 100 | +## Git over HTTP | |
| 101 | + | |
| 102 | +Clone a public repository: | |
| 103 | + | |
| 104 | +```bash | |
| 105 | +git clone http://localhost:3000/rin/demo.git | |
| 106 | +``` | |
| 107 | + | |
| 108 | +Push using a personal access token (create one in **Settings → Access tokens**; it is shown once and looks like `polar_...`): | |
| 109 | + | |
| 110 | +```bash | |
| 111 | +git clone http://localhost:3000/rin/demo.git | |
| 112 | +cd demo | |
| 113 | +git remote set-url origin http://rin:polar_XXXXXXXXXX@localhost:3000/rin/demo.git | |
| 114 | +git push | |
| 115 | +``` | |
| 116 | + | |
| 117 | +When git asks for credentials, use your username as the login and the token as the password. Tokens can be revoked at any time from the settings page. | |
| 118 | + | |
| 119 | +### Browser-assisted sign-in (device flow) | |
| 120 | + | |
| 121 | +Instead of pasting a token, let the bundled credential helper obtain one through your browser. Polar implements the OAuth 2.0 Device Authorization Grant (RFC 8628): | |
| 122 | + | |
| 123 | +1. `git push` asks for credentials; the helper requests a device code from `POST /api/v1/device/code`. | |
| 124 | +2. It opens `{base_url}/login/device?user_code=XXXX-XXXX` in your browser — sign in and approve (or deny) the request. | |
| 125 | +3. The helper polls `POST /api/v1/device/token`, receives a personal access token, and caches it in `~/.polar/credentials.json`. | |
| 126 | + | |
| 127 | +Register the helper once, pointing the path at your Polar checkout: | |
| 128 | + | |
| 129 | +```bash | |
| 130 | +# Linux / macOS | |
| 131 | +git config --global credential.helper '!sh /path/to/polar/scripts/git-credential-polar.sh' | |
| 132 | + | |
| 133 | +# Windows (PowerShell) | |
| 134 | +git config --global credential.helper "!powershell -NoProfile -ExecutionPolicy Bypass -File C:/path/to/polar/scripts/git-credential-polar.ps1" | |
| 135 | +``` | |
| 136 | + | |
| 137 | +Device codes expire after 15 minutes, are single-use, and the issued token can be revoked like any other. Set `POLAR_URL` to override the server address detected from the git remote. | |
| 138 | + | |
| 139 | +## Forks, Collaborators and Reviews | |
| 140 | + | |
| 141 | +**Forks.** The **Fork** button on any repository page copies the project to your account. Forking a fork re-roots the copy to the upstream repository, and every fork records its origin — the repository page shows a `forked from` line linking back. If you already have a fork, the button takes you to it instead of creating a duplicate. | |
| 142 | + | |
| 143 | +**Cross-fork pull requests.** Open a pull request from any branch of your fork into the base repository. The PR page marks the head repository, and merging a cross-fork pull request first fetches the head commits into the base repository, so no commits are lost. | |
| 144 | + | |
| 145 | +**Collaborators.** Repository owners and instance administrators manage access in **Settings → Collaborators**: add any registered account with a `read` or `write` role, change roles, or remove access at any time. | |
| 146 | + | |
| 147 | +**Reviews.** On an open pull request, reviewers submit an **Approve** or **Request changes** verdict — reviewing your own pull request is not allowed. Each reviewer has one current verdict, and the review panel lists them all. | |
| 148 | + | |
| 149 | +## Themes | |
| 150 | + | |
| 151 | +Polar ships exactly three themes, selectable from the theme switcher (or the command palette): | |
| 152 | + | |
| 153 | +| Theme | Background | Foreground | | |
| 154 | +|---|---|---| | |
| 155 | +| Light | `#fff` | `#111` | | |
| 156 | +| Dark | `#111` | `#fff` | | |
| 157 | +| System | follows the OS `prefers-color-scheme` | — | | |
| 158 | + | |
| 159 | +The active theme is saved to `localStorage` and a `polar_theme` cookie, so the server renders `<html data-theme="...">` immediately and there is no flash of the wrong theme on load. | |
| 160 | + | |
| 161 | +## Project Layout | |
| 162 | + | |
| 163 | +``` | |
| 164 | +polar/ | |
| 165 | +├── cmd/polar/main.go # Entry point, dependency wiring, graceful shutdown | |
| 166 | +├── internal/ | |
| 167 | +│ ├── config/ # polar.toml + POLAR_* environment overrides | |
| 168 | +│ ├── db/ # SQLite connection and migrations | |
| 169 | +│ ├── auth/ # Sessions, CSRF, Argon2id, access tokens | |
| 170 | +│ ├── user/ # User accounts and profiles | |
| 171 | +│ ├── repo/ # Repositories, issues, pull requests, releases | |
| 172 | +│ ├── git/ # Git plumbing (bare repos, commits, diffs) | |
| 173 | +│ ├── i18n/ # Russian and English message catalogs | |
| 174 | +│ └── web/ | |
| 175 | +│ ├── handlers/ # HTTP and Git Smart HTTP handlers | |
| 176 | +│ ├── middleware/ # Auth, CSRF, rate limiting, i18n | |
| 177 | +│ ├── views/ # html/template views | |
| 178 | +│ └── static/ # go:embed assets (CSS, JS, fonts, icons) | |
| 179 | +├── assets/input.css # Tailwind source for the generated stylesheet | |
| 180 | +├── scripts/ # git credential helpers (device-flow sign-in) | |
| 181 | +├── polar.example.toml | |
| 182 | +├── Makefile | |
| 183 | +├── Dockerfile | |
| 184 | +└── docker-compose.yml | |
| 185 | +``` | |
| 186 | + | |
| 187 | +## Development | |
| 188 | + | |
| 189 | +```bash | |
| 190 | +make dev # run locally | |
| 191 | +make test # go test ./... | |
| 192 | +make lint # go vet ./... | |
| 193 | +make build # static binary with -trimpath -ldflags="-s -w" | |
| 194 | +make build-all # cross-compile Linux/macOS/Windows binaries into dist/ | |
| 195 | +make css # rebuild internal/web/static/css/app.css (requires the Tailwind CLI) | |
| 196 | +``` | |
| 197 | + | |
| 198 | +All runtime assets are embedded with `go:embed` — the binary needs no external CDN or asset directory. See `THIRD_PARTY.md` for licenses of bundled assets (Hugeicons icons, Inter and JetBrains Mono fonts, htmx) and Go dependencies. | |
| 199 | + | |
| 200 | +## Data and Backups | |
| 201 | + | |
| 202 | +``` | |
| 203 | +data/ | |
| 204 | +├── polar.db # SQLite database (WAL mode) | |
| 205 | +└── repos/ # Bare Git repositories, e.g. repos/rin/demo.git | |
| 206 | +``` | |
| 207 | + | |
| 208 | +Safe backup while Polar is running: | |
| 209 | + | |
| 210 | +```bash | |
| 211 | +sqlite3 data/polar.db "VACUUM INTO 'data/polar-backup.db';" | |
| 212 | +tar -czf polar-backup-$(date +%F).tar.gz data/polar-backup.db data/repos/ | |
| 213 | +rm data/polar-backup.db | |
| 214 | +``` | |
| 215 | + | |
| 216 | +To restore, stop Polar, replace `data/polar.db` and `data/repos/` with the backup contents, and start it again. | |
| 217 | + | |
| 218 | +## Health Check | |
| 219 | + | |
| 220 | +`GET /healthz` returns JSON with the database status — handy for Docker health checks and load balancers. |
THIRD_PARTY.md
+56
-0
| ... | ... | @@ -0,0 +1,56 @@ |
| 1 | +# Third-Party Licenses & Attributions | |
| 2 | + | |
| 3 | +Polar embeds all runtime assets into a single binary without external CDN requests. Below is the license information for all bundled third-party assets and libraries. | |
| 4 | + | |
| 5 | +--- | |
| 6 | + | |
| 7 | +## 1. Hugeicons Free Icons (`@hugeicons/core-free-icons`) | |
| 8 | + | |
| 9 | +- **Source:** https://github.com/hugeicons/hugeicons-react / `@hugeicons/core-free-icons` (https://hugeicons.com) | |
| 10 | +- **Usage in Polar:** Selected stroke icons exported as an inline SVG `<symbol>` sprite in `internal/web/static/icons/hugeicons-sprite.svg`. | |
| 11 | +- **License:** MIT License | |
| 12 | + | |
| 13 | +```text | |
| 14 | +MIT License | |
| 15 | + | |
| 16 | +Copyright (c) Hugeicons (https://hugeicons.com) | |
| 17 | + | |
| 18 | +Permission is hereby granted, free of charge, to any person obtaining a copy | |
| 19 | +of this software and associated documentation files (the "Software"), to deal | |
| 20 | +in the Software without restriction, including without limitation the rights | |
| 21 | +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| 22 | +copies of the Software, and to permit persons to whom the Software is | |
| 23 | +furnished to do so, subject to the following conditions: | |
| 24 | + | |
| 25 | +The above copyright notice and this permission notice shall be included in all | |
| 26 | +copies or substantial portions of the Software. | |
| 27 | + | |
| 28 | +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| 29 | +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| 30 | +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| 31 | +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| 32 | +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| 33 | +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
| 34 | +SOFTWARE. | |
| 35 | +``` | |
| 36 | + | |
| 37 | +--- | |
| 38 | + | |
| 39 | +## 2. Fonts (Inter & JetBrains Mono) | |
| 40 | + | |
| 41 | +- **Inter** — Copyright (c) The Inter Project Authors (Rasmus Andersson), licensed under the **SIL Open Font License 1.1 (OFL-1.1)**. | |
| 42 | +- **JetBrains Mono** — Copyright (c) JetBrains s.r.o., licensed under the **SIL Open Font License 1.1 (OFL-1.1)**. | |
| 43 | + | |
| 44 | +--- | |
| 45 | + | |
| 46 | +## 3. Go Dependencies | |
| 47 | + | |
| 48 | +| Package | License | Purpose | | |
| 49 | +|---|---|---| | |
| 50 | +| `github.com/gofiber/fiber/v2` | MIT | HTTP router, middleware, static server | | |
| 51 | +| `modernc.org/sqlite` | BSD-3-Clause | Pure-Go SQLite driver (no CGO) | | |
| 52 | +| `github.com/BurntSushi/toml` | MIT | Configuration file (`polar.toml`) parser | | |
| 53 | +| `golang.org/x/crypto` | BSD-3-Clause | Argon2id password hashing | | |
| 54 | +| `github.com/yuin/goldmark` | MIT | CommonMark / GFM Markdown parser | | |
| 55 | +| `github.com/microcosm-cc/bluemonday` | BSD-3-Clause | HTML sanitizer for rendered Markdown | | |
| 56 | +| `github.com/alecthomas/chroma/v2` | MIT | Server-side syntax highlighting | |
assets/input.css
+1354
-0
| ... | ... | @@ -0,0 +1,1354 @@ |
| 1 | +/* Polar Design System — Minimalist Sidebar Layout, 3 Themes (light, dark, system) */ | |
| 2 | + | |
| 3 | +@font-face { | |
| 4 | + font-family: 'Inter'; | |
| 5 | + font-style: normal; | |
| 6 | + font-weight: 400 600; | |
| 7 | + font-display: swap; | |
| 8 | + src: url('/static/fonts/Inter-Regular.woff2') format('woff2'); | |
| 9 | +} | |
| 10 | + | |
| 11 | +@font-face { | |
| 12 | + font-family: 'Inter'; | |
| 13 | + font-style: normal; | |
| 14 | + font-weight: 400 600; | |
| 15 | + font-display: swap; | |
| 16 | + src: url('/static/fonts/Inter-Cyrillic.woff2') format('woff2'); | |
| 17 | + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; | |
| 18 | +} | |
| 19 | + | |
| 20 | +@font-face { | |
| 21 | + font-family: 'JetBrains Mono'; | |
| 22 | + font-style: normal; | |
| 23 | + font-weight: 400 600; | |
| 24 | + font-display: swap; | |
| 25 | + src: url('/static/fonts/JetBrainsMono-Regular.woff2') format('woff2'); | |
| 26 | +} | |
| 27 | + | |
| 28 | +:root, | |
| 29 | +html[data-theme="light"] { | |
| 30 | + --background: 0 0% 100%; | |
| 31 | + --foreground: 0 0% 6.7%; | |
| 32 | + --card: 0 0% 98%; | |
| 33 | + --card-foreground: 0 0% 6.7%; | |
| 34 | + --popover: 0 0% 100%; | |
| 35 | + --popover-foreground: 0 0% 6.7%; | |
| 36 | + --primary: 0 0% 6.7%; | |
| 37 | + --primary-foreground: 0 0% 100%; | |
| 38 | + --secondary: 0 0% 95%; | |
| 39 | + --secondary-foreground: 0 0% 6.7%; | |
| 40 | + --muted: 0 0% 95%; | |
| 41 | + --muted-foreground: 0 0% 45%; | |
| 42 | + --accent: 0 0% 93%; | |
| 43 | + --accent-foreground: 0 0% 6.7%; | |
| 44 | + --destructive: 0 72% 46%; | |
| 45 | + --destructive-foreground: 0 0% 100%; | |
| 46 | + --border: 0 0% 90%; | |
| 47 | + --input: 0 0% 89%; | |
| 48 | + --ring: 0 0% 6.7%; | |
| 49 | + --radius: 0.45rem; | |
| 50 | + | |
| 51 | + --diff-add-bg: 142 70% 94%; | |
| 52 | + --diff-add-fg: 142 76% 22%; | |
| 53 | + --diff-del-bg: 0 86% 95%; | |
| 54 | + --diff-del-fg: 0 72% 35%; | |
| 55 | + | |
| 56 | + --syntax-kw: 262 72% 42%; | |
| 57 | + --syntax-str: 142 68% 28%; | |
| 58 | + --syntax-cmt: 0 0% 46%; | |
| 59 | + --syntax-fn: 215 80% 42%; | |
| 60 | + --syntax-num: 24 85% 38%; | |
| 61 | + --syntax-type: 190 78% 32%; | |
| 62 | + --syntax-op: 0 0% 25%; | |
| 63 | + color-scheme: light; | |
| 64 | +} | |
| 65 | + | |
| 66 | +html[data-theme="dark"] { | |
| 67 | + --background: 0 0% 6.7%; | |
| 68 | + --foreground: 0 0% 100%; | |
| 69 | + --card: 0 0% 9%; | |
| 70 | + --card-foreground: 0 0% 100%; | |
| 71 | + --popover: 0 0% 9.5%; | |
| 72 | + --popover-foreground: 0 0% 100%; | |
| 73 | + --primary: 0 0% 100%; | |
| 74 | + --primary-foreground: 0 0% 6.7%; | |
| 75 | + --secondary: 0 0% 13%; | |
| 76 | + --secondary-foreground: 0 0% 100%; | |
| 77 | + --muted: 0 0% 12%; | |
| 78 | + --muted-foreground: 0 0% 65%; | |
| 79 | + --accent: 0 0% 15%; | |
| 80 | + --accent-foreground: 0 0% 100%; | |
| 81 | + --destructive: 0 72% 58%; | |
| 82 | + --destructive-foreground: 0 0% 98%; | |
| 83 | + --border: 0 0% 16%; | |
| 84 | + --input: 0 0% 17%; | |
| 85 | + --ring: 0 0% 85%; | |
| 86 | + | |
| 87 | + --diff-add-bg: 142 50% 12%; | |
| 88 | + --diff-add-fg: 142 70% 75%; | |
| 89 | + --diff-del-bg: 0 55% 14%; | |
| 90 | + --diff-del-fg: 0 80% 78%; | |
| 91 | + | |
| 92 | + --syntax-kw: 265 85% 78%; | |
| 93 | + --syntax-str: 142 65% 70%; | |
| 94 | + --syntax-cmt: 0 0% 54%; | |
| 95 | + --syntax-fn: 210 90% 74%; | |
| 96 | + --syntax-num: 32 90% 72%; | |
| 97 | + --syntax-type: 188 80% 68%; | |
| 98 | + --syntax-op: 0 0% 78%; | |
| 99 | + color-scheme: dark; | |
| 100 | +} | |
| 101 | + | |
| 102 | +@media (prefers-color-scheme: dark) { | |
| 103 | + html[data-theme="system"] { | |
| 104 | + --background: 0 0% 6.7%; | |
| 105 | + --foreground: 0 0% 100%; | |
| 106 | + --card: 0 0% 9%; | |
| 107 | + --card-foreground: 0 0% 100%; | |
| 108 | + --popover: 0 0% 9.5%; | |
| 109 | + --popover-foreground: 0 0% 100%; | |
| 110 | + --primary: 0 0% 100%; | |
| 111 | + --primary-foreground: 0 0% 6.7%; | |
| 112 | + --secondary: 0 0% 13%; | |
| 113 | + --secondary-foreground: 0 0% 100%; | |
| 114 | + --muted: 0 0% 12%; | |
| 115 | + --muted-foreground: 0 0% 65%; | |
| 116 | + --accent: 0 0% 15%; | |
| 117 | + --accent-foreground: 0 0% 100%; | |
| 118 | + --destructive: 0 72% 58%; | |
| 119 | + --destructive-foreground: 0 0% 98%; | |
| 120 | + --border: 0 0% 16%; | |
| 121 | + --input: 0 0% 17%; | |
| 122 | + --ring: 0 0% 85%; | |
| 123 | + --diff-add-bg: 142 50% 12%; | |
| 124 | + --diff-add-fg: 142 70% 75%; | |
| 125 | + --diff-del-bg: 0 55% 14%; | |
| 126 | + --diff-del-fg: 0 80% 78%; | |
| 127 | + --syntax-kw: 265 85% 78%; | |
| 128 | + --syntax-str: 142 65% 70%; | |
| 129 | + --syntax-cmt: 0 0% 54%; | |
| 130 | + --syntax-fn: 210 90% 74%; | |
| 131 | + --syntax-num: 32 90% 72%; | |
| 132 | + --syntax-type: 188 80% 68%; | |
| 133 | + --syntax-op: 0 0% 78%; | |
| 134 | + color-scheme: dark; | |
| 135 | + } | |
| 136 | +} | |
| 137 | + | |
| 138 | +*, *::before, *::after { | |
| 139 | + box-sizing: border-box; | |
| 140 | + border-color: hsl(var(--border)); | |
| 141 | +} | |
| 142 | + | |
| 143 | +html { | |
| 144 | + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | |
| 145 | + font-size: 13.5px; | |
| 146 | + line-height: 1.5; | |
| 147 | + -webkit-font-smoothing: antialiased; | |
| 148 | + background-color: hsl(var(--background)); | |
| 149 | + color: hsl(var(--foreground)); | |
| 150 | + overflow-y: scroll; | |
| 151 | +} | |
| 152 | + | |
| 153 | +body { | |
| 154 | + margin: 0; | |
| 155 | + min-height: 100vh; | |
| 156 | + background-color: hsl(var(--background)); | |
| 157 | + color: hsl(var(--foreground)); | |
| 158 | +} | |
| 159 | + | |
| 160 | +code, pre, kbd, .font-mono { | |
| 161 | + font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; | |
| 162 | +} | |
| 163 | + | |
| 164 | +a { | |
| 165 | + color: inherit; | |
| 166 | + text-decoration: none; | |
| 167 | +} | |
| 168 | + | |
| 169 | +@media (prefers-reduced-motion: reduce) { | |
| 170 | + *, *::before, *::after { | |
| 171 | + animation-duration: 0.01ms !important; | |
| 172 | + transition-duration: 0.01ms !important; | |
| 173 | + } | |
| 174 | +} | |
| 175 | + | |
| 176 | +:focus-visible { | |
| 177 | + outline: 2px solid hsl(var(--ring)); | |
| 178 | + outline-offset: 2px; | |
| 179 | +} | |
| 180 | + | |
| 181 | +/* App Shell: Left Sidebar + Right Main Content */ | |
| 182 | +.app-shell { | |
| 183 | + display: flex; | |
| 184 | + flex-direction: row; | |
| 185 | + align-items: stretch; | |
| 186 | + min-height: 100vh; | |
| 187 | + width: 100%; | |
| 188 | +} | |
| 189 | + | |
| 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)); | |
| 196 | + background-color: hsl(var(--card)); | |
| 197 | + display: none; | |
| 198 | + flex-direction: column; | |
| 199 | + justify-content: space-between; | |
| 200 | + position: sticky; | |
| 201 | + top: 0; | |
| 202 | + height: 100vh; | |
| 203 | + overflow-y: auto; | |
| 204 | + overflow-x: hidden; | |
| 205 | + padding: 0.85rem 0.75rem; | |
| 206 | + user-select: none; | |
| 207 | + transition: | |
| 208 | + width 200ms cubic-bezier(0.16, 1, 0.3, 1), | |
| 209 | + min-width 200ms cubic-bezier(0.16, 1, 0.3, 1), | |
| 210 | + max-width 200ms cubic-bezier(0.16, 1, 0.3, 1), | |
| 211 | + flex-basis 200ms cubic-bezier(0.16, 1, 0.3, 1), | |
| 212 | + padding 200ms cubic-bezier(0.16, 1, 0.3, 1), | |
| 213 | + border-color 200ms cubic-bezier(0.16, 1, 0.3, 1); | |
| 214 | +} | |
| 215 | + | |
| 216 | +.sidebar-inner { | |
| 217 | + width: 218px; | |
| 218 | + min-width: 218px; | |
| 219 | + height: 100%; | |
| 220 | + display: flex; | |
| 221 | + flex-direction: column; | |
| 222 | + justify-content: space-between; | |
| 223 | + transition: | |
| 224 | + opacity 150ms cubic-bezier(0.16, 1, 0.3, 1), | |
| 225 | + transform 200ms cubic-bezier(0.16, 1, 0.3, 1); | |
| 226 | +} | |
| 227 | + | |
| 228 | +html[data-sidebar="collapsed"] .app-sidebar { | |
| 229 | + width: 0; | |
| 230 | + min-width: 0; | |
| 231 | + max-width: 0; | |
| 232 | + flex-basis: 0; | |
| 233 | + padding-left: 0; | |
| 234 | + padding-right: 0; | |
| 235 | + border-right-color: transparent; | |
| 236 | +} | |
| 237 | + | |
| 238 | +html[data-sidebar="collapsed"] .sidebar-inner { | |
| 239 | + opacity: 0; | |
| 240 | + transform: translateX(-8px); | |
| 241 | + pointer-events: none; | |
| 242 | +} | |
| 243 | + | |
| 244 | +.sidebar-expand-btn { | |
| 245 | + position: fixed; | |
| 246 | + top: 0.85rem; | |
| 247 | + left: 0.85rem; | |
| 248 | + z-index: 50; | |
| 249 | + display: none; | |
| 250 | + align-items: center; | |
| 251 | + justify-content: center; | |
| 252 | + width: 2rem; | |
| 253 | + height: 2rem; | |
| 254 | + border-radius: var(--radius); | |
| 255 | + border: 1px solid hsl(var(--border)); | |
| 256 | + background-color: hsl(var(--card)); | |
| 257 | + color: hsl(var(--muted-foreground)); | |
| 258 | + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); | |
| 259 | + cursor: pointer; | |
| 260 | + opacity: 0; | |
| 261 | + transform: translateX(-6px) scale(0.95); | |
| 262 | + pointer-events: none; | |
| 263 | + transition: | |
| 264 | + opacity 160ms cubic-bezier(0.16, 1, 0.3, 1), | |
| 265 | + transform 160ms cubic-bezier(0.16, 1, 0.3, 1), | |
| 266 | + background-color 120ms ease, | |
| 267 | + color 120ms ease; | |
| 268 | +} | |
| 269 | + | |
| 270 | +.sidebar-expand-btn:hover { | |
| 271 | + background-color: hsl(var(--accent)); | |
| 272 | + color: hsl(var(--foreground)); | |
| 273 | +} | |
| 274 | + | |
| 275 | +.mobile-topbar { | |
| 276 | + display: flex; | |
| 277 | + align-items: center; | |
| 278 | + justify-content: space-between; | |
| 279 | + height: 2.75rem; | |
| 280 | + padding: 0 0.85rem; | |
| 281 | + border-bottom: 1px solid hsl(var(--border)); | |
| 282 | + background-color: hsl(var(--card)); | |
| 283 | +} | |
| 284 | + | |
| 285 | +.app-col { | |
| 286 | + flex: 1 1 0%; | |
| 287 | + min-width: 0; | |
| 288 | + width: 100%; | |
| 289 | + display: flex; | |
| 290 | + flex-direction: column; | |
| 291 | + min-height: 100vh; | |
| 292 | +} | |
| 293 | + | |
| 294 | +.app-main { | |
| 295 | + flex: 1 1 auto; | |
| 296 | + width: 100%; | |
| 297 | + padding: 1.5rem 1.25rem 3rem; | |
| 298 | +} | |
| 299 | + | |
| 300 | +@keyframes pageFadeIn { | |
| 301 | + from { | |
| 302 | + opacity: 0; | |
| 303 | + transform: translateY(4px); | |
| 304 | + } | |
| 305 | + to { | |
| 306 | + opacity: 1; | |
| 307 | + transform: translateY(0); | |
| 308 | + } | |
| 309 | +} | |
| 310 | + | |
| 311 | +.app-main > .container, | |
| 312 | +.app-main > .container-narrow { | |
| 313 | + animation: pageFadeIn 160ms cubic-bezier(0.16, 1, 0.3, 1); | |
| 314 | +} | |
| 315 | + | |
| 316 | +@media (min-width: 640px) { | |
| 317 | + .app-sidebar { | |
| 318 | + display: flex; | |
| 319 | + } | |
| 320 | + .sidebar-expand-btn { | |
| 321 | + display: inline-flex; | |
| 322 | + } | |
| 323 | + html[data-sidebar="collapsed"] .sidebar-expand-btn { | |
| 324 | + opacity: 1; | |
| 325 | + transform: translateX(0) scale(1); | |
| 326 | + pointer-events: auto; | |
| 327 | + } | |
| 328 | + .mobile-topbar { | |
| 329 | + display: none; | |
| 330 | + } | |
| 331 | + .app-main { | |
| 332 | + padding: 2rem 2.5rem 3.5rem; | |
| 333 | + } | |
| 334 | +} | |
| 335 | + | |
| 336 | +/* Unified Container Width — identical across Code, Commits, and Settings */ | |
| 337 | +.container, | |
| 338 | +.container-narrow { | |
| 339 | + width: 100%; | |
| 340 | + max-width: 960px; | |
| 341 | + margin-left: auto; | |
| 342 | + margin-right: auto; | |
| 343 | +} | |
| 344 | + | |
| 345 | +/* Sidebar Navigation */ | |
| 346 | +.nav-item { | |
| 347 | + display: flex; | |
| 348 | + align-items: center; | |
| 349 | + justify-content: space-between; | |
| 350 | + gap: 0.5rem; | |
| 351 | + height: 1.95rem; | |
| 352 | + padding: 0 0.55rem; | |
| 353 | + border-radius: var(--radius); | |
| 354 | + font-size: 0.85rem; | |
| 355 | + color: hsl(var(--muted-foreground)); | |
| 356 | + transition: background-color 140ms ease, color 140ms ease, transform 120ms cubic-bezier(0.16, 1, 0.3, 1); | |
| 357 | + cursor: pointer; | |
| 358 | + border: 0; | |
| 359 | + background: transparent; | |
| 360 | + width: 100%; | |
| 361 | + text-align: left; | |
| 362 | + overflow: hidden; | |
| 363 | +} | |
| 364 | +.nav-item:hover, | |
| 365 | +.nav-item.active { | |
| 366 | + background-color: hsl(var(--accent)); | |
| 367 | + color: hsl(var(--foreground)); | |
| 368 | +} | |
| 369 | +.nav-item:active { | |
| 370 | + transform: scale(0.985); | |
| 371 | +} | |
| 372 | +.nav-section-label { | |
| 373 | + display: flex; | |
| 374 | + align-items: center; | |
| 375 | + justify-content: space-between; | |
| 376 | + padding: 0.45rem 0.55rem 0.25rem; | |
| 377 | + font-size: 0.7rem; | |
| 378 | + font-weight: 600; | |
| 379 | + text-transform: uppercase; | |
| 380 | + letter-spacing: 0.04em; | |
| 381 | + color: hsl(var(--muted-foreground)); | |
| 382 | +} | |
| 383 | + | |
| 384 | +/* Segmented Theme Control in Sidebar */ | |
| 385 | +.segmented { | |
| 386 | + display: grid; | |
| 387 | + grid-template-columns: repeat(3, minmax(0, 1fr)); | |
| 388 | + gap: 2px; | |
| 389 | + padding: 2px; | |
| 390 | + width: 100%; | |
| 391 | + background-color: hsl(var(--muted)); | |
| 392 | + border: 1px solid hsl(var(--border)); | |
| 393 | + border-radius: var(--radius); | |
| 394 | +} | |
| 395 | +.segmented-btn { | |
| 396 | + display: inline-flex; | |
| 397 | + align-items: center; | |
| 398 | + justify-content: center; | |
| 399 | + gap: 0.25rem; | |
| 400 | + height: 1.65rem; | |
| 401 | + padding: 0 0.2rem; | |
| 402 | + border: 0; | |
| 403 | + border-radius: calc(var(--radius) - 2px); | |
| 404 | + background: transparent; | |
| 405 | + color: hsl(var(--muted-foreground)); | |
| 406 | + font-size: 0.72rem; | |
| 407 | + font-weight: 500; | |
| 408 | + cursor: pointer; | |
| 409 | + overflow: hidden; | |
| 410 | + text-overflow: ellipsis; | |
| 411 | + white-space: nowrap; | |
| 412 | + transition: background-color 150ms cubic-bezier(0.16, 1, 0.3, 1), color 150ms ease, box-shadow 150ms ease, transform 120ms ease; | |
| 413 | +} | |
| 414 | +.segmented-btn:hover { | |
| 415 | + color: hsl(var(--foreground)); | |
| 416 | +} | |
| 417 | +.segmented-btn:active { | |
| 418 | + transform: scale(0.96); | |
| 419 | +} | |
| 420 | +.segmented-btn.active { | |
| 421 | + background-color: hsl(var(--background)); | |
| 422 | + color: hsl(var(--foreground)); | |
| 423 | + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); | |
| 424 | +} | |
| 425 | + | |
| 426 | +/* Icons */ | |
| 427 | +.icon { | |
| 428 | + display: inline-block; | |
| 429 | + vertical-align: middle; | |
| 430 | + flex-shrink: 0; | |
| 431 | + stroke: currentColor; | |
| 432 | +} | |
| 433 | +.size-3 { width: 0.75rem; height: 0.75rem; } | |
| 434 | +.size-3\.5 { width: 0.875rem; height: 0.875rem; } | |
| 435 | +.size-4 { width: 1rem; height: 1rem; } | |
| 436 | +.size-5 { width: 1.25rem; height: 1.25rem; } | |
| 437 | +.size-6 { width: 1.5rem; height: 1.5rem; } | |
| 438 | + | |
| 439 | +/* Buttons */ | |
| 440 | +.btn { | |
| 441 | + display: inline-flex; | |
| 442 | + align-items: center; | |
| 443 | + justify-content: center; | |
| 444 | + gap: 0.4rem; | |
| 445 | + white-space: nowrap; | |
| 446 | + border-radius: var(--radius); | |
| 447 | + font-size: 0.85rem; | |
| 448 | + font-weight: 500; | |
| 449 | + line-height: 1; | |
| 450 | + height: 1.95rem; | |
| 451 | + padding: 0 0.7rem; | |
| 452 | + border: 1px solid transparent; | |
| 453 | + cursor: pointer; | |
| 454 | + transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease, opacity 140ms ease, transform 120ms cubic-bezier(0.16, 1, 0.3, 1); | |
| 455 | + user-select: none; | |
| 456 | +} | |
| 457 | +.btn:active:not(:disabled) { | |
| 458 | + transform: scale(0.97); | |
| 459 | +} | |
| 460 | +.btn:disabled { | |
| 461 | + opacity: 0.5; | |
| 462 | + pointer-events: none; | |
| 463 | +} | |
| 464 | +.btn-sm { | |
| 465 | + height: 1.7rem; | |
| 466 | + padding: 0 0.5rem; | |
| 467 | + font-size: 0.78rem; | |
| 468 | +} | |
| 469 | +.btn-lg { | |
| 470 | + height: 2.3rem; | |
| 471 | + padding: 0 1rem; | |
| 472 | + font-size: 0.92rem; | |
| 473 | +} | |
| 474 | +.btn-icon { | |
| 475 | + width: 1.9rem; | |
| 476 | + height: 1.9rem; | |
| 477 | + padding: 0; | |
| 478 | +} | |
| 479 | +.btn-default, .btn-primary { | |
| 480 | + background-color: hsl(var(--primary)); | |
| 481 | + color: hsl(var(--primary-foreground)); | |
| 482 | +} | |
| 483 | +.btn-default:hover, .btn-primary:hover { | |
| 484 | + opacity: 0.9; | |
| 485 | +} | |
| 486 | +.btn-secondary { | |
| 487 | + background-color: hsl(var(--secondary)); | |
| 488 | + color: hsl(var(--secondary-foreground)); | |
| 489 | + border-color: hsl(var(--border)); | |
| 490 | +} | |
| 491 | +.btn-secondary:hover { | |
| 492 | + background-color: hsl(var(--accent)); | |
| 493 | +} | |
| 494 | +.btn-outline { | |
| 495 | + background-color: transparent; | |
| 496 | + color: hsl(var(--foreground)); | |
| 497 | + border-color: hsl(var(--border)); | |
| 498 | +} | |
| 499 | +.btn-outline:hover { | |
| 500 | + background-color: hsl(var(--accent)); | |
| 501 | + color: hsl(var(--accent-foreground)); | |
| 502 | +} | |
| 503 | +.btn-ghost { | |
| 504 | + background-color: transparent; | |
| 505 | + color: hsl(var(--muted-foreground)); | |
| 506 | +} | |
| 507 | +.btn-ghost:hover { | |
| 508 | + background-color: hsl(var(--accent)); | |
| 509 | + color: hsl(var(--foreground)); | |
| 510 | +} | |
| 511 | +.btn-destructive { | |
| 512 | + background-color: hsl(var(--destructive)); | |
| 513 | + color: hsl(var(--destructive-foreground)); | |
| 514 | +} | |
| 515 | +.btn-destructive:hover { | |
| 516 | + opacity: 0.9; | |
| 517 | +} | |
| 518 | + | |
| 519 | +/* Inputs, Selects, Textareas */ | |
| 520 | +.input, .select, .textarea { | |
| 521 | + display: block; | |
| 522 | + width: 100%; | |
| 523 | + border-radius: var(--radius); | |
| 524 | + border: 1px solid hsl(var(--input)); | |
| 525 | + background-color: hsl(var(--background)); | |
| 526 | + color: hsl(var(--foreground)); | |
| 527 | + font-size: 0.85rem; | |
| 528 | + padding: 0.35rem 0.6rem; | |
| 529 | + transition: border-color 100ms ease; | |
| 530 | +} | |
| 531 | +.input, .select { | |
| 532 | + height: 2.05rem; | |
| 533 | +} | |
| 534 | +.textarea { | |
| 535 | + min-height: 4rem; | |
| 536 | + resize: vertical; | |
| 537 | +} | |
| 538 | +.input:focus, .select:focus, .textarea:focus { | |
| 539 | + outline: none; | |
| 540 | + border-color: hsl(var(--ring)); | |
| 541 | +} | |
| 542 | +.input::placeholder, .textarea::placeholder { | |
| 543 | + color: hsl(var(--muted-foreground)); | |
| 544 | +} | |
| 545 | +.label { | |
| 546 | + display: block; | |
| 547 | + font-size: 0.8rem; | |
| 548 | + font-weight: 500; | |
| 549 | + margin-bottom: 0.3rem; | |
| 550 | + color: hsl(var(--foreground)); | |
| 551 | +} | |
| 552 | +.field-hint { | |
| 553 | + font-size: 0.74rem; | |
| 554 | + color: hsl(var(--muted-foreground)); | |
| 555 | + margin-top: 0.25rem; | |
| 556 | +} | |
| 557 | + | |
| 558 | +/* Switch */ | |
| 559 | +.switch { | |
| 560 | + position: relative; | |
| 561 | + display: inline-flex; | |
| 562 | + width: 2.1rem; | |
| 563 | + height: 1.2rem; | |
| 564 | + flex-shrink: 0; | |
| 565 | + cursor: pointer; | |
| 566 | + align-items: center; | |
| 567 | + border-radius: 9999px; | |
| 568 | + border: 1px solid hsl(var(--border)); | |
| 569 | + background-color: hsl(var(--muted)); | |
| 570 | +} | |
| 571 | +.switch input { | |
| 572 | + opacity: 0; | |
| 573 | + width: 0; | |
| 574 | + height: 0; | |
| 575 | + position: absolute; | |
| 576 | +} | |
| 577 | +.switch-thumb { | |
| 578 | + pointer-events: none; | |
| 579 | + display: block; | |
| 580 | + width: 0.85rem; | |
| 581 | + height: 0.85rem; | |
| 582 | + border-radius: 9999px; | |
| 583 | + background-color: hsl(var(--foreground)); | |
| 584 | + transform: translateX(0.15rem); | |
| 585 | + transition: transform 100ms ease; | |
| 586 | +} | |
| 587 | +.switch input:checked ~ .switch-thumb { | |
| 588 | + transform: translateX(0.95rem); | |
| 589 | +} | |
| 590 | + | |
| 591 | +/* Cards */ | |
| 592 | +.card { | |
| 593 | + border-radius: var(--radius); | |
| 594 | + border: 1px solid hsl(var(--border)); | |
| 595 | + background-color: hsl(var(--card)); | |
| 596 | + color: hsl(var(--card-foreground)); | |
| 597 | +} | |
| 598 | +.card-header { | |
| 599 | + padding: 0.7rem 0.9rem; | |
| 600 | + border-bottom: 1px solid hsl(var(--border)); | |
| 601 | + display: flex; | |
| 602 | + align-items: center; | |
| 603 | + justify-content: space-between; | |
| 604 | + gap: 0.75rem; | |
| 605 | +} | |
| 606 | +.card-body { | |
| 607 | + padding: 0.9rem; | |
| 608 | +} | |
| 609 | +.card-footer { | |
| 610 | + padding: 0.65rem 0.9rem; | |
| 611 | + border-top: 1px solid hsl(var(--border)); | |
| 612 | + background-color: hsl(var(--muted) / 0.3); | |
| 613 | +} | |
| 614 | + | |
| 615 | +/* Badges */ | |
| 616 | +.badge { | |
| 617 | + display: inline-flex; | |
| 618 | + align-items: center; | |
| 619 | + gap: 0.25rem; | |
| 620 | + border-radius: 9999px; | |
| 621 | + border: 1px solid hsl(var(--border)); | |
| 622 | + padding: 0.08rem 0.45rem; | |
| 623 | + font-size: 0.72rem; | |
| 624 | + font-weight: 500; | |
| 625 | + line-height: 1.3; | |
| 626 | + color: hsl(var(--foreground)); | |
| 627 | + background-color: hsl(var(--secondary)); | |
| 628 | +} | |
| 629 | +.badge-outline { | |
| 630 | + background-color: transparent; | |
| 631 | + color: hsl(var(--muted-foreground)); | |
| 632 | +} | |
| 633 | +.badge-primary { | |
| 634 | + background-color: hsl(var(--primary)); | |
| 635 | + color: hsl(var(--primary-foreground)); | |
| 636 | + border-color: transparent; | |
| 637 | +} | |
| 638 | +.badge-destructive { | |
| 639 | + background-color: hsl(var(--destructive) / 0.12); | |
| 640 | + color: hsl(var(--destructive)); | |
| 641 | + border-color: hsl(var(--destructive) / 0.3); | |
| 642 | +} | |
| 643 | + | |
| 644 | +/* Kbd */ | |
| 645 | +kbd, .kbd { | |
| 646 | + display: inline-flex; | |
| 647 | + align-items: center; | |
| 648 | + justify-content: center; | |
| 649 | + min-width: 1.25rem; | |
| 650 | + height: 1.15rem; | |
| 651 | + padding: 0 0.28rem; | |
| 652 | + font-size: 0.68rem; | |
| 653 | + font-weight: 500; | |
| 654 | + line-height: 1; | |
| 655 | + color: hsl(var(--muted-foreground)); | |
| 656 | + background-color: hsl(var(--muted)); | |
| 657 | + border: 1px solid hsl(var(--border)); | |
| 658 | + border-radius: 0.25rem; | |
| 659 | +} | |
| 660 | + | |
| 661 | +/* Avatar */ | |
| 662 | +.avatar { | |
| 663 | + display: inline-flex; | |
| 664 | + align-items: center; | |
| 665 | + justify-content: center; | |
| 666 | + width: 1.45rem; | |
| 667 | + height: 1.45rem; | |
| 668 | + border-radius: 9999px; | |
| 669 | + background-color: hsl(var(--secondary)); | |
| 670 | + border: 1px solid hsl(var(--border)); | |
| 671 | + font-size: 0.7rem; | |
| 672 | + font-weight: 600; | |
| 673 | + color: hsl(var(--foreground)); | |
| 674 | + text-transform: uppercase; | |
| 675 | + flex-shrink: 0; | |
| 676 | +} | |
| 677 | +.avatar-lg { | |
| 678 | + width: 2.2rem; | |
| 679 | + height: 2.2rem; | |
| 680 | + font-size: 0.9rem; | |
| 681 | +} | |
| 682 | + | |
| 683 | +/* Separator */ | |
| 684 | +.separator { | |
| 685 | + height: 1px; | |
| 686 | + width: 100%; | |
| 687 | + background-color: hsl(var(--border)); | |
| 688 | + margin: 0.85rem 0; | |
| 689 | + border: 0; | |
| 690 | +} | |
| 691 | + | |
| 692 | +/* Tabs */ | |
| 693 | +.tabs-nav { | |
| 694 | + display: flex; | |
| 695 | + align-items: center; | |
| 696 | + gap: 0.15rem; | |
| 697 | + padding: 0.2rem; | |
| 698 | + border: 1px solid hsl(var(--border)); | |
| 699 | + border-radius: 999px; | |
| 700 | + background: hsl(var(--muted) / 0.25); | |
| 701 | + margin-bottom: 1rem; | |
| 702 | + overflow-x: auto; | |
| 703 | +} | |
| 704 | +.tab-link { | |
| 705 | + display: inline-flex; | |
| 706 | + align-items: center; | |
| 707 | + gap: 0.35rem; | |
| 708 | + padding: 0.38rem 0.8rem; | |
| 709 | + font-size: 0.82rem; | |
| 710 | + font-weight: 500; | |
| 711 | + color: hsl(var(--muted-foreground)); | |
| 712 | + border-radius: 999px; | |
| 713 | + white-space: nowrap; | |
| 714 | + transition: color 100ms ease, background-color 100ms ease; | |
| 715 | +} | |
| 716 | +.tab-link:hover { | |
| 717 | + color: hsl(var(--foreground)); | |
| 718 | +} | |
| 719 | +.tab-link.active { | |
| 720 | + color: hsl(var(--foreground)); | |
| 721 | + background: hsl(var(--card)); | |
| 722 | + box-shadow: 0 1px 2px hsl(0 0% 0% / 0.08); | |
| 723 | +} | |
| 724 | + | |
| 725 | +/* Dense File List & Tables */ | |
| 726 | +.dense-list { | |
| 727 | + width: 100%; | |
| 728 | +} | |
| 729 | +.dense-row { | |
| 730 | + display: flex; | |
| 731 | + align-items: center; | |
| 732 | + justify-content: space-between; | |
| 733 | + gap: 0.75rem; | |
| 734 | + min-height: 2.05rem; | |
| 735 | + padding: 0.25rem 0.75rem; | |
| 736 | + border-bottom: 1px solid hsl(var(--border)); | |
| 737 | + font-size: 0.85rem; | |
| 738 | + transition: background-color 80ms ease; | |
| 739 | +} | |
| 740 | +.dense-row:last-child { | |
| 741 | + border-bottom: none; | |
| 742 | +} | |
| 743 | +.dense-row:hover { | |
| 744 | + background-color: hsl(var(--accent) / 0.5); | |
| 745 | +} | |
| 746 | + | |
| 747 | +.table-wrap { | |
| 748 | + width: 100%; | |
| 749 | + overflow-x: auto; | |
| 750 | + border: 1px solid hsl(var(--border)); | |
| 751 | + border-radius: var(--radius); | |
| 752 | + background-color: hsl(var(--card)); | |
| 753 | +} | |
| 754 | +.table { | |
| 755 | + width: 100%; | |
| 756 | + border-collapse: collapse; | |
| 757 | + font-size: 0.85rem; | |
| 758 | +} | |
| 759 | +.table th { | |
| 760 | + text-align: left; | |
| 761 | + font-weight: 500; | |
| 762 | + color: hsl(var(--muted-foreground)); | |
| 763 | + padding: 0.5rem 0.75rem; | |
| 764 | + border-bottom: 1px solid hsl(var(--border)); | |
| 765 | + background-color: hsl(var(--muted) / 0.35); | |
| 766 | + font-size: 0.75rem; | |
| 767 | +} | |
| 768 | +.table td { | |
| 769 | + padding: 0.5rem 0.75rem; | |
| 770 | + border-bottom: 1px solid hsl(var(--border)); | |
| 771 | + vertical-align: middle; | |
| 772 | +} | |
| 773 | +.table tr:last-child td { | |
| 774 | + border-bottom: none; | |
| 775 | +} | |
| 776 | + | |
| 777 | +/* Breadcrumb */ | |
| 778 | +.breadcrumb { | |
| 779 | + display: flex; | |
| 780 | + align-items: center; | |
| 781 | + flex-wrap: wrap; | |
| 782 | + gap: 0.25rem; | |
| 783 | + font-size: 0.85rem; | |
| 784 | +} | |
| 785 | +.breadcrumb-sep { | |
| 786 | + color: hsl(var(--muted-foreground)); | |
| 787 | +} | |
| 788 | + | |
| 789 | +/* Dropdown Menu */ | |
| 790 | +@keyframes dropdownIn { | |
| 791 | + from { | |
| 792 | + opacity: 0; | |
| 793 | + transform: translateY(-4px) scale(0.98); | |
| 794 | + } | |
| 795 | + to { | |
| 796 | + opacity: 1; | |
| 797 | + transform: translateY(0) scale(1); | |
| 798 | + } | |
| 799 | +} | |
| 800 | +.dropdown { | |
| 801 | + position: relative; | |
| 802 | + display: inline-block; | |
| 803 | +} | |
| 804 | +.dropdown-menu { | |
| 805 | + position: absolute; | |
| 806 | + left: 0; | |
| 807 | + top: calc(100% + 0.3rem); | |
| 808 | + min-width: 11rem; | |
| 809 | + background-color: hsl(var(--popover)); | |
| 810 | + color: hsl(var(--popover-foreground)); | |
| 811 | + border: 1px solid hsl(var(--border)); | |
| 812 | + border-radius: var(--radius); | |
| 813 | + padding: 0.25rem; | |
| 814 | + box-shadow: 0 8px 24px -4px rgba(0, 0, 0, 0.22); | |
| 815 | + z-index: 60; | |
| 816 | + display: none; | |
| 817 | + transform-origin: top left; | |
| 818 | +} | |
| 819 | +.dropdown-menu.open { | |
| 820 | + display: block; | |
| 821 | + animation: dropdownIn 140ms cubic-bezier(0.16, 1, 0.3, 1); | |
| 822 | +} | |
| 823 | +.dropdown-item { | |
| 824 | + display: flex; | |
| 825 | + width: 100%; | |
| 826 | + align-items: center; | |
| 827 | + justify-content: space-between; | |
| 828 | + gap: 0.5rem; | |
| 829 | + padding: 0.35rem 0.55rem; | |
| 830 | + font-size: 0.8rem; | |
| 831 | + border-radius: calc(var(--radius) - 2px); | |
| 832 | + color: hsl(var(--popover-foreground)); | |
| 833 | + background: transparent; | |
| 834 | + border: 0; | |
| 835 | + cursor: pointer; | |
| 836 | + text-align: left; | |
| 837 | + transition: background-color 120ms ease, color 120ms ease; | |
| 838 | +} | |
| 839 | +.dropdown-item:hover, .dropdown-item.active { | |
| 840 | + background-color: hsl(var(--accent)); | |
| 841 | + color: hsl(var(--accent-foreground)); | |
| 842 | +} | |
| 843 | + | |
| 844 | +/* Modal / Dialog & Command Palette */ | |
| 845 | +@keyframes backdropIn { | |
| 846 | + from { opacity: 0; } | |
| 847 | + to { opacity: 1; } | |
| 848 | +} | |
| 849 | +@keyframes dialogIn { | |
| 850 | + from { | |
| 851 | + opacity: 0; | |
| 852 | + transform: translateY(-6px) scale(0.97); | |
| 853 | + } | |
| 854 | + to { | |
| 855 | + opacity: 1; | |
| 856 | + transform: translateY(0) scale(1); | |
| 857 | + } | |
| 858 | +} | |
| 859 | +.backdrop { | |
| 860 | + position: fixed; | |
| 861 | + inset: 0; | |
| 862 | + background-color: rgba(0, 0, 0, 0.55); | |
| 863 | + backdrop-filter: blur(2px); | |
| 864 | + z-index: 80; | |
| 865 | + display: none; | |
| 866 | + align-items: flex-start; | |
| 867 | + justify-content: center; | |
| 868 | + padding: 10vh 1rem 2rem; | |
| 869 | +} | |
| 870 | +.backdrop.open { | |
| 871 | + display: flex; | |
| 872 | + animation: backdropIn 140ms ease; | |
| 873 | +} | |
| 874 | +.dialog-box { | |
| 875 | + width: 100%; | |
| 876 | + max-width: 28rem; | |
| 877 | + background-color: hsl(var(--popover)); | |
| 878 | + color: hsl(var(--popover-foreground)); | |
| 879 | + border: 1px solid hsl(var(--border)); | |
| 880 | + border-radius: var(--radius); | |
| 881 | + box-shadow: 0 16px 40px -8px rgba(0, 0, 0, 0.35); | |
| 882 | + overflow: hidden; | |
| 883 | + animation: dialogIn 170ms cubic-bezier(0.16, 1, 0.3, 1); | |
| 884 | +} | |
| 885 | +.palette-box { | |
| 886 | + width: 100%; | |
| 887 | + max-width: 34rem; | |
| 888 | + background-color: hsl(var(--popover)); | |
| 889 | + color: hsl(var(--popover-foreground)); | |
| 890 | + border: 1px solid hsl(var(--border)); | |
| 891 | + border-radius: var(--radius); | |
| 892 | + box-shadow: 0 16px 48px -8px rgba(0, 0, 0, 0.4); | |
| 893 | + overflow: hidden; | |
| 894 | + animation: dialogIn 170ms cubic-bezier(0.16, 1, 0.3, 1); | |
| 895 | +} | |
| 896 | +.palette-input-wrap { | |
| 897 | + display: flex; | |
| 898 | + align-items: center; | |
| 899 | + gap: 0.6rem; | |
| 900 | + padding: 0.6rem 0.8rem; | |
| 901 | + border-bottom: 1px solid hsl(var(--border)); | |
| 902 | +} | |
| 903 | +.palette-input { | |
| 904 | + width: 100%; | |
| 905 | + border: 0; | |
| 906 | + background: transparent; | |
| 907 | + color: hsl(var(--foreground)); | |
| 908 | + font-size: 0.9rem; | |
| 909 | + outline: none; | |
| 910 | +} | |
| 911 | +.palette-list { | |
| 912 | + max-height: 20rem; | |
| 913 | + overflow-y: auto; | |
| 914 | + padding: 0.3rem; | |
| 915 | +} | |
| 916 | +.palette-group-title { | |
| 917 | + padding: 0.3rem 0.5rem 0.15rem; | |
| 918 | + font-size: 0.68rem; | |
| 919 | + font-weight: 600; | |
| 920 | + text-transform: uppercase; | |
| 921 | + letter-spacing: 0.04em; | |
| 922 | + color: hsl(var(--muted-foreground)); | |
| 923 | +} | |
| 924 | +.palette-item { | |
| 925 | + display: flex; | |
| 926 | + width: 100%; | |
| 927 | + align-items: center; | |
| 928 | + justify-content: space-between; | |
| 929 | + gap: 0.5rem; | |
| 930 | + padding: 0.4rem 0.55rem; | |
| 931 | + border-radius: calc(var(--radius) - 2px); | |
| 932 | + font-size: 0.85rem; | |
| 933 | + color: hsl(var(--foreground)); | |
| 934 | + cursor: pointer; | |
| 935 | + border: 0; | |
| 936 | + background: transparent; | |
| 937 | + text-align: left; | |
| 938 | + transition: background-color 100ms ease, color 100ms ease; | |
| 939 | +} | |
| 940 | +.palette-item:hover, .palette-item.selected { | |
| 941 | + background-color: hsl(var(--accent)); | |
| 942 | + color: hsl(var(--accent-foreground)); | |
| 943 | +} | |
| 944 | + | |
| 945 | +/* Toast */ | |
| 946 | +@keyframes toastIn { | |
| 947 | + from { | |
| 948 | + opacity: 0; | |
| 949 | + transform: translateY(8px) scale(0.97); | |
| 950 | + } | |
| 951 | + to { | |
| 952 | + opacity: 1; | |
| 953 | + transform: translateY(0) scale(1); | |
| 954 | + } | |
| 955 | +} | |
| 956 | +.toast-container { | |
| 957 | + position: fixed; | |
| 958 | + bottom: 1rem; | |
| 959 | + right: 1rem; | |
| 960 | + z-index: 90; | |
| 961 | + display: flex; | |
| 962 | + flex-direction: column; | |
| 963 | + gap: 0.4rem; | |
| 964 | + max-width: 20rem; | |
| 965 | +} | |
| 966 | +.toast { | |
| 967 | + display: flex; | |
| 968 | + align-items: center; | |
| 969 | + justify-content: space-between; | |
| 970 | + gap: 0.65rem; | |
| 971 | + padding: 0.5rem 0.75rem; | |
| 972 | + border-radius: var(--radius); | |
| 973 | + border: 1px solid hsl(var(--border)); | |
| 974 | + background-color: hsl(var(--popover)); | |
| 975 | + color: hsl(var(--popover-foreground)); | |
| 976 | + box-shadow: 0 8px 24px -4px rgba(0, 0, 0, 0.25); | |
| 977 | + font-size: 0.8rem; | |
| 978 | + animation: toastIn 180ms cubic-bezier(0.16, 1, 0.3, 1); | |
| 979 | +} | |
| 980 | + | |
| 981 | +/* Skeleton */ | |
| 982 | +@keyframes pulse { | |
| 983 | + 0%, 100% { opacity: 1; } | |
| 984 | + 50% { opacity: 0.45; } | |
| 985 | +} | |
| 986 | +.skeleton { | |
| 987 | + background-color: hsl(var(--muted)); | |
| 988 | + border-radius: calc(var(--radius) - 2px); | |
| 989 | + animation: pulse 1.6s ease-in-out infinite; | |
| 990 | +} | |
| 991 | + | |
| 992 | +/* Empty State */ | |
| 993 | +.empty-state { | |
| 994 | + text-align: center; | |
| 995 | + padding: 2rem 1rem; | |
| 996 | + border: 1px dashed hsl(var(--border)); | |
| 997 | + border-radius: var(--radius); | |
| 998 | + color: hsl(var(--muted-foreground)); | |
| 999 | +} | |
| 1000 | + | |
| 1001 | +/* Code Viewer & Chroma */ | |
| 1002 | +.code-table { | |
| 1003 | + width: 100%; | |
| 1004 | + border-collapse: collapse; | |
| 1005 | + font-family: 'JetBrains Mono', monospace; | |
| 1006 | + font-size: 0.8rem; | |
| 1007 | + line-height: 1.5; | |
| 1008 | +} | |
| 1009 | +.code-ln { | |
| 1010 | + width: 1%; | |
| 1011 | + min-width: 2.75rem; | |
| 1012 | + padding: 0 0.65rem; | |
| 1013 | + text-align: right; | |
| 1014 | + color: hsl(var(--muted-foreground) / 0.6); | |
| 1015 | + user-select: none; | |
| 1016 | + vertical-align: top; | |
| 1017 | + border-right: 1px solid hsl(var(--border)); | |
| 1018 | +} | |
| 1019 | +.code-line { | |
| 1020 | + padding: 0 0.75rem; | |
| 1021 | + white-space: pre; | |
| 1022 | + overflow-x: auto; | |
| 1023 | +} | |
| 1024 | +.chroma .k, .chroma .kc, .chroma .kd, .chroma .kn, .chroma .kp, .chroma .kr { color: hsl(var(--syntax-kw)); font-weight: 500; } | |
| 1025 | +.chroma .kt { color: hsl(var(--syntax-type)); } | |
| 1026 | +.chroma .s, .chroma .sa, .chroma .sb, .chroma .sc, .chroma .dl, .chroma .sd, .chroma .s2, .chroma .se, .chroma .sh, .chroma .si, .chroma .sx, .chroma .sr, .chroma .s1, .chroma .ss { color: hsl(var(--syntax-str)); } | |
| 1027 | +.chroma .c, .chroma .ch, .chroma .cm, .chroma .c1, .chroma .cs, .chroma .cp, .chroma .cpf { color: hsl(var(--syntax-cmt)); font-style: italic; } | |
| 1028 | +.chroma .nf, .chroma .fm, .chroma .nx { color: hsl(var(--syntax-fn)); } | |
| 1029 | +.chroma .m, .chroma .mb, .chroma .mf, .chroma .mh, .chroma .mi, .chroma .il, .chroma .mo { color: hsl(var(--syntax-num)); } | |
| 1030 | +.chroma .o, .chroma .ow { color: hsl(var(--syntax-op)); } | |
| 1031 | + | |
| 1032 | +/* Diff Viewer */ | |
| 1033 | +.diff-table { | |
| 1034 | + width: 100%; | |
| 1035 | + border-collapse: collapse; | |
| 1036 | + font-family: 'JetBrains Mono', monospace; | |
| 1037 | + font-size: 0.78rem; | |
| 1038 | + line-height: 1.5; | |
| 1039 | +} | |
| 1040 | +.diff-row-add { | |
| 1041 | + background-color: hsl(var(--diff-add-bg)); | |
| 1042 | + color: hsl(var(--diff-add-fg)); | |
| 1043 | +} | |
| 1044 | +.diff-row-del { | |
| 1045 | + background-color: hsl(var(--diff-del-bg)); | |
| 1046 | + color: hsl(var(--diff-del-fg)); | |
| 1047 | +} | |
| 1048 | +.diff-row-hunk { | |
| 1049 | + background-color: hsl(var(--muted)); | |
| 1050 | + color: hsl(var(--muted-foreground)); | |
| 1051 | +} | |
| 1052 | +.diff-num { | |
| 1053 | + width: 2.75rem; | |
| 1054 | + padding: 0 0.45rem; | |
| 1055 | + text-align: right; | |
| 1056 | + user-select: none; | |
| 1057 | + opacity: 0.65; | |
| 1058 | + border-right: 1px solid hsl(var(--border)); | |
| 1059 | +} | |
| 1060 | +.diff-code { | |
| 1061 | + padding: 0 0.65rem; | |
| 1062 | + white-space: pre-wrap; | |
| 1063 | + word-break: break-all; | |
| 1064 | +} | |
| 1065 | + | |
| 1066 | +/* Markdown Body */ | |
| 1067 | +.markdown-body { | |
| 1068 | + font-size: 0.9rem; | |
| 1069 | + line-height: 1.6; | |
| 1070 | + color: hsl(var(--foreground)); | |
| 1071 | +} | |
| 1072 | +.markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4 { | |
| 1073 | + margin-top: 1.25em; | |
| 1074 | + margin-bottom: 0.45em; | |
| 1075 | + font-weight: 600; | |
| 1076 | + line-height: 1.25; | |
| 1077 | +} | |
| 1078 | +.markdown-body h1 { font-size: 1.35rem; padding-bottom: 0.25rem; border-bottom: 1px solid hsl(var(--border)); } | |
| 1079 | +.markdown-body h2 { font-size: 1.15rem; padding-bottom: 0.2rem; border-bottom: 1px solid hsl(var(--border)); } | |
| 1080 | +.markdown-body h3 { font-size: 1rem; } | |
| 1081 | +.markdown-body h1:first-child, .markdown-body h2:first-child { margin-top: 0; } | |
| 1082 | +.markdown-body p, .markdown-body ul, .markdown-body ol, .markdown-body pre, .markdown-body blockquote { | |
| 1083 | + margin-top: 0; | |
| 1084 | + margin-bottom: 0.8rem; | |
| 1085 | +} | |
| 1086 | +.markdown-body code { | |
| 1087 | + background-color: hsl(var(--muted)); | |
| 1088 | + padding: 0.12rem 0.3rem; | |
| 1089 | + border-radius: 0.25rem; | |
| 1090 | + font-size: 0.84em; | |
| 1091 | +} | |
| 1092 | +.markdown-body pre { | |
| 1093 | + background-color: hsl(var(--muted) / 0.6); | |
| 1094 | + border: 1px solid hsl(var(--border)); | |
| 1095 | + border-radius: var(--radius); | |
| 1096 | + padding: 0.75rem 0.9rem; | |
| 1097 | + overflow-x: auto; | |
| 1098 | +} | |
| 1099 | +.markdown-body pre code { | |
| 1100 | + background: transparent; | |
| 1101 | + padding: 0; | |
| 1102 | +} | |
| 1103 | +.markdown-body a { | |
| 1104 | + text-decoration: underline; | |
| 1105 | + text-underline-offset: 3px; | |
| 1106 | +} | |
| 1107 | + | |
| 1108 | +/* Utility Helpers */ | |
| 1109 | +.flex { display: flex; } | |
| 1110 | +.block { display: block; } | |
| 1111 | +.inline-flex { display: inline-flex; } | |
| 1112 | +.grid { display: grid; } | |
| 1113 | +.hidden { display: none; } | |
| 1114 | +.items-center { align-items: center; } | |
| 1115 | +.items-start { align-items: flex-start; } | |
| 1116 | +.items-baseline { align-items: baseline; } | |
| 1117 | +.justify-between { justify-content: space-between; } | |
| 1118 | +.justify-center { justify-content: center; } | |
| 1119 | +.justify-end { justify-content: flex-end; } | |
| 1120 | +.flex-col { flex-direction: column; } | |
| 1121 | +.flex-wrap { flex-wrap: wrap; } | |
| 1122 | +.flex-1 { flex: 1 1 0%; } | |
| 1123 | +.flex-shrink-0 { flex-shrink: 0; } | |
| 1124 | +.min-w-0 { min-width: 0; } | |
| 1125 | +.gap-0\.5 { gap: 0.125rem; } | |
| 1126 | +.gap-1 { gap: 0.25rem; } | |
| 1127 | +.gap-1\.5 { gap: 0.375rem; } | |
| 1128 | +.gap-2 { gap: 0.5rem; } | |
| 1129 | +.gap-2\.5 { gap: 0.625rem; } | |
| 1130 | +.gap-3 { gap: 0.75rem; } | |
| 1131 | +.gap-4 { gap: 1rem; } | |
| 1132 | +.gap-6 { gap: 1.5rem; } | |
| 1133 | +.w-full { width: 100%; } | |
| 1134 | +.text-xs { font-size: 0.75rem; } | |
| 1135 | +.text-sm { font-size: 0.85rem; } | |
| 1136 | +.text-base { font-size: 0.96rem; } | |
| 1137 | +.text-lg { font-size: 1.1rem; } | |
| 1138 | +.text-xl { font-size: 1.25rem; } | |
| 1139 | +.text-2xl { font-size: 1.5rem; } | |
| 1140 | +.font-medium { font-weight: 500; } | |
| 1141 | +.font-semibold { font-weight: 600; } | |
| 1142 | +.font-bold { font-weight: 700; } | |
| 1143 | +.uppercase { text-transform: uppercase; } | |
| 1144 | +.tracking-tight { letter-spacing: -0.015em; } | |
| 1145 | +.text-muted { color: hsl(var(--muted-foreground)); } | |
| 1146 | +.text-destructive { color: hsl(var(--destructive)); } | |
| 1147 | +.text-foreground { color: hsl(var(--foreground)); } | |
| 1148 | +.text-center { text-align: center; } | |
| 1149 | +.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 1150 | +.hover\:underline:hover { text-decoration: underline; } | |
| 1151 | +.select-all { user-select: all; } | |
| 1152 | +.cursor-pointer { cursor: pointer; } | |
| 1153 | +.mt-0\.5 { margin-top: 0.125rem; } | |
| 1154 | +.mt-1 { margin-top: 0.25rem; } | |
| 1155 | +.mt-2 { margin-top: 0.5rem; } | |
| 1156 | +.mt-3 { margin-top: 0.75rem; } | |
| 1157 | +.mt-4 { margin-top: 1rem; } | |
| 1158 | +.mt-6 { margin-top: 1.5rem; } | |
| 1159 | +.mr-2 { margin-right: 0.5rem; } | |
| 1160 | +.mb-1 { margin-bottom: 0.25rem; } | |
| 1161 | +.mb-1\.5 { margin-bottom: 0.375rem; } | |
| 1162 | +.mb-2 { margin-bottom: 0.5rem; } | |
| 1163 | +.mb-3 { margin-bottom: 0.75rem; } | |
| 1164 | +.mb-4 { margin-bottom: 1rem; } | |
| 1165 | +.mb-6 { margin-bottom: 1.5rem; } | |
| 1166 | +.p-2 { padding: 0.5rem; } | |
| 1167 | +.p-3 { padding: 0.75rem; } | |
| 1168 | +.p-4 { padding: 1rem; } | |
| 1169 | +.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; } | |
| 1170 | +.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; } | |
| 1171 | +.pt-2 { padding-top: 0.5rem; } | |
| 1172 | +.pt-3 { padding-top: 0.75rem; } | |
| 1173 | +.pb-3 { padding-bottom: 0.75rem; } | |
| 1174 | + | |
| 1175 | +.grid-2 { display: grid; grid-template-columns: repeat(1, minmax(0, 1fr)); gap: 0.85rem; } | |
| 1176 | +.grid-3 { display: grid; grid-template-columns: repeat(1, minmax(0, 1fr)); gap: 0.85rem; } | |
| 1177 | +.grid-4 { display: grid; grid-template-columns: repeat(1, minmax(0, 1fr)); gap: 0.85rem; } | |
| 1178 | + | |
| 1179 | +@media (min-width: 768px) { | |
| 1180 | + .grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } | |
| 1181 | + .grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } | |
| 1182 | + .grid-4 { grid-template-columns: repeat(2, minmax(0, 1fr)); } | |
| 1183 | + .md-flex { display: flex !important; } | |
| 1184 | + .md-hidden { display: none !important; } | |
| 1185 | +} | |
| 1186 | + | |
| 1187 | +/* Mobile Drawer */ | |
| 1188 | +.mobile-drawer { | |
| 1189 | + position: fixed; | |
| 1190 | + inset: 0; | |
| 1191 | + z-index: 75; | |
| 1192 | + background-color: rgba(0, 0, 0, 0.5); | |
| 1193 | + display: none; | |
| 1194 | +} | |
| 1195 | +.mobile-drawer.open { | |
| 1196 | + display: block; | |
| 1197 | +} | |
| 1198 | +.mobile-drawer-panel { | |
| 1199 | + width: 224px; | |
| 1200 | + max-width: 85vw; | |
| 1201 | + height: 100%; | |
| 1202 | + background-color: hsl(var(--card)); | |
| 1203 | + border-right: 1px solid hsl(var(--border)); | |
| 1204 | + padding: 0.75rem 0.65rem; | |
| 1205 | + display: flex; | |
| 1206 | + flex-direction: column; | |
| 1207 | + justify-content: space-between; | |
| 1208 | +} | |
| 1209 | + | |
| 1210 | +@media (min-width: 1024px) { | |
| 1211 | + .grid-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } | |
| 1212 | +} | |
| 1213 | + | |
| 1214 | +/* ── Polar repository shell ─────────────────────────────────────────── */ | |
| 1215 | +.repo-banner { | |
| 1216 | + position: relative; | |
| 1217 | + display: flex; | |
| 1218 | + align-items: flex-start; | |
| 1219 | + justify-content: space-between; | |
| 1220 | + gap: 1rem; | |
| 1221 | + flex-wrap: wrap; | |
| 1222 | + padding: 0.9rem 1rem 0.95rem; | |
| 1223 | + background: hsl(var(--card)); | |
| 1224 | + border: 1px solid hsl(var(--border)); | |
| 1225 | + border-radius: calc(var(--radius) * 1.6); | |
| 1226 | + overflow: hidden; | |
| 1227 | +} | |
| 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 | +} | |
| 1235 | +.repo-banner-main { | |
| 1236 | + display: flex; | |
| 1237 | + align-items: center; | |
| 1238 | + gap: 0.7rem; | |
| 1239 | + min-width: 0; | |
| 1240 | + flex: 1 1 20rem; | |
| 1241 | +} | |
| 1242 | +.repo-banner-actions { | |
| 1243 | + display: flex; | |
| 1244 | + align-items: center; | |
| 1245 | + gap: 0.4rem; | |
| 1246 | + flex-wrap: wrap; | |
| 1247 | +} | |
| 1248 | + | |
| 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 | +} | |
| 1277 | + | |
| 1278 | +.repo-layout { | |
| 1279 | + display: grid; | |
| 1280 | + grid-template-columns: minmax(0, 1fr) 305px; | |
| 1281 | + gap: 1.1rem; | |
| 1282 | + align-items: start; | |
| 1283 | +} | |
| 1284 | +.repo-main { | |
| 1285 | + min-width: 0; | |
| 1286 | +} | |
| 1287 | +.repo-side { | |
| 1288 | + display: flex; | |
| 1289 | + flex-direction: column; | |
| 1290 | + gap: 0.9rem; | |
| 1291 | + position: sticky; | |
| 1292 | + top: 0.75rem; | |
| 1293 | +} | |
| 1294 | +.side-card { | |
| 1295 | + border: 1px solid hsl(var(--border)); | |
| 1296 | + border-radius: calc(var(--radius) * 1.3); | |
| 1297 | + background: hsl(var(--card)); | |
| 1298 | + overflow: hidden; | |
| 1299 | +} | |
| 1300 | +.side-card-header { | |
| 1301 | + display: flex; | |
| 1302 | + align-items: center; | |
| 1303 | + gap: 0.4rem; | |
| 1304 | + padding: 0.5rem 0.8rem; | |
| 1305 | + font-size: 0.78rem; | |
| 1306 | + font-weight: 600; | |
| 1307 | + border-bottom: 1px solid hsl(var(--border)); | |
| 1308 | + background: hsl(var(--muted) / 0.3); | |
| 1309 | +} | |
| 1310 | +.side-card-body { | |
| 1311 | + display: flex; | |
| 1312 | + flex-direction: column; | |
| 1313 | + gap: 0.55rem; | |
| 1314 | + padding: 0.7rem 0.8rem; | |
| 1315 | + font-size: 0.8rem; | |
| 1316 | +} | |
| 1317 | +.meta-row { | |
| 1318 | + display: flex; | |
| 1319 | + align-items: center; | |
| 1320 | + justify-content: space-between; | |
| 1321 | + gap: 0.5rem; | |
| 1322 | + color: hsl(var(--muted-foreground)); | |
| 1323 | +} | |
| 1324 | +.meta-row a, | |
| 1325 | +.meta-row b { | |
| 1326 | + color: hsl(var(--foreground)); | |
| 1327 | +} | |
| 1328 | +.clone-box { | |
| 1329 | + display: flex; | |
| 1330 | + align-items: center; | |
| 1331 | + gap: 0.35rem; | |
| 1332 | + border: 1px solid hsl(var(--border)); | |
| 1333 | + border-radius: var(--radius); | |
| 1334 | + background: hsl(var(--muted) / 0.25); | |
| 1335 | + padding: 0.15rem 0.25rem 0.15rem 0.6rem; | |
| 1336 | + min-width: 0; | |
| 1337 | +} | |
| 1338 | +.clone-box code { | |
| 1339 | + flex: 1; | |
| 1340 | + min-width: 0; | |
| 1341 | + overflow: hidden; | |
| 1342 | + text-overflow: ellipsis; | |
| 1343 | + white-space: nowrap; | |
| 1344 | + font-size: 0.72rem; | |
| 1345 | +} | |
| 1346 | +@media (max-width: 1000px) { | |
| 1347 | + .repo-layout { | |
| 1348 | + grid-template-columns: 1fr; | |
| 1349 | + } | |
| 1350 | + .repo-side { | |
| 1351 | + position: static; | |
| 1352 | + order: -1; | |
| 1353 | + } | |
| 1354 | +} |
cmd/polar/main.go
+97
-0
| ... | ... | @@ -0,0 +1,97 @@ |
| 1 | +package main | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "flag" | |
| 6 | + "log/slog" | |
| 7 | + "os" | |
| 8 | + "os/signal" | |
| 9 | + "runtime/debug" | |
| 10 | + "syscall" | |
| 11 | + "time" | |
| 12 | + | |
| 13 | + "polar/internal/auth" | |
| 14 | + "polar/internal/config" | |
| 15 | + "polar/internal/db" | |
| 16 | + "polar/internal/repo" | |
| 17 | + "polar/internal/user" | |
| 18 | + "polar/internal/web/handlers" | |
| 19 | + "polar/internal/web/views" | |
| 20 | + | |
| 21 | + "github.com/gofiber/fiber/v2" | |
| 22 | +) | |
| 23 | + | |
| 24 | +func main() { | |
| 25 | + configPath := flag.String("config", "", "path to polar.toml configuration file") | |
| 26 | + flag.Parse() | |
| 27 | + | |
| 28 | + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ | |
| 29 | + Level: slog.LevelInfo, | |
| 30 | + })) | |
| 31 | + slog.SetDefault(logger) | |
| 32 | + | |
| 33 | + cfg, err := config.Load(*configPath) | |
| 34 | + if err != nil { | |
| 35 | + logger.Error("failed to load configuration", "err", err) | |
| 36 | + os.Exit(1) | |
| 37 | + } | |
| 38 | + | |
| 39 | + if err := os.MkdirAll(cfg.ReposDir(), 0o755); err != nil { | |
| 40 | + logger.Error("failed to create repositories directory", "path", cfg.ReposDir(), "err", err) | |
| 41 | + os.Exit(1) | |
| 42 | + } | |
| 43 | + | |
| 44 | + ctx := context.Background() | |
| 45 | + database, err := db.Open(ctx, cfg.DBPath(), cfg.Auth.RegistrationEnabled) | |
| 46 | + if err != nil { | |
| 47 | + logger.Error("failed to initialize database", "path", cfg.DBPath(), "err", err) | |
| 48 | + os.Exit(1) | |
| 49 | + } | |
| 50 | + defer func() { _ = database.Close() }() | |
| 51 | + | |
| 52 | + authSvc := auth.NewService(database) | |
| 53 | + userSvc := user.NewService(database) | |
| 54 | + repoSvc := repo.NewService(database, cfg.ReposDir()) | |
| 55 | + | |
| 56 | + viewsEngine, err := views.NewEngine() | |
| 57 | + if err != nil { | |
| 58 | + logger.Error("failed to initialize template engine", "err", err) | |
| 59 | + os.Exit(1) | |
| 60 | + } | |
| 61 | + | |
| 62 | + app := fiber.New(fiber.Config{ | |
| 63 | + AppName: "Polar Git Server", | |
| 64 | + DisableStartupMessage: true, | |
| 65 | + BodyLimit: 100 * 1024 * 1024, // 100 MB for git pack pushes | |
| 66 | + ReadTimeout: 60 * time.Second, | |
| 67 | + WriteTimeout: 120 * time.Second, | |
| 68 | + }) | |
| 69 | + | |
| 70 | + h := handlers.New(cfg, database, authSvc, userSvc, repoSvc, viewsEngine, logger) | |
| 71 | + h.RegisterRoutes(app) | |
| 72 | + debug.FreeOSMemory() | |
| 73 | + | |
| 74 | + errCh := make(chan error, 1) | |
| 75 | + go func() { | |
| 76 | + logger.Info("Polar server starting", | |
| 77 | + "addr", cfg.Addr(), | |
| 78 | + "base_url", cfg.Server.BaseURL, | |
| 79 | + "data_dir", cfg.Storage.DataDir, | |
| 80 | + ) | |
| 81 | + if err := app.Listen(cfg.Addr()); err != nil { | |
| 82 | + errCh <- err | |
| 83 | + } | |
| 84 | + }() | |
| 85 | + | |
| 86 | + sigCh := make(chan os.Signal, 1) | |
| 87 | + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) | |
| 88 | + | |
| 89 | + select { | |
| 90 | + case sig := <-sigCh: | |
| 91 | + logger.Info("shutting down gracefully", "signal", sig.String()) | |
| 92 | + _ = app.ShutdownWithTimeout(10 * time.Second) | |
| 93 | + case err := <-errCh: | |
| 94 | + logger.Error("server exited with error", "err", err) | |
| 95 | + os.Exit(1) | |
| 96 | + } | |
| 97 | +} |
docker-compose.yml
+17
-0
| ... | ... | @@ -0,0 +1,17 @@ |
| 1 | +services: | |
| 2 | + polar: | |
| 3 | + build: . | |
| 4 | + container_name: polar | |
| 5 | + restart: unless-stopped | |
| 6 | + ports: | |
| 7 | + - "3000:3000" | |
| 8 | + environment: | |
| 9 | + POLAR_SERVER_HOST: "0.0.0.0" | |
| 10 | + POLAR_SERVER_PORT: "3000" | |
| 11 | + POLAR_SERVER_BASE_URL: "http://localhost:3000" | |
| 12 | + POLAR_STORAGE_DATA_DIR: "/app/data" | |
| 13 | + POLAR_AUTH_REGISTRATION_ENABLED: "true" | |
| 14 | + POLAR_LANDING_ENABLED: "true" | |
| 15 | + POLAR_LANDING_DEFAULT_LANG: "ru" | |
| 16 | + volumes: | |
| 17 | + - ./data:/app/data |
go.mod
+37
-0
| ... | ... | @@ -0,0 +1,37 @@ |
| 1 | +module polar | |
| 2 | + | |
| 3 | +go 1.27.1 | |
| 4 | + | |
| 5 | +require ( | |
| 6 | + github.com/BurntSushi/toml v1.6.0 | |
| 7 | + github.com/alecthomas/chroma/v2 v2.27.0 | |
| 8 | + github.com/gofiber/fiber/v2 v2.52.15 | |
| 9 | + github.com/microcosm-cc/bluemonday v1.0.27 | |
| 10 | + github.com/yuin/goldmark v1.8.6 | |
| 11 | + golang.org/x/crypto v0.58.0 | |
| 12 | + modernc.org/sqlite v1.60.1 | |
| 13 | +) | |
| 14 | + | |
| 15 | +require ( | |
| 16 | + github.com/andybalholm/brotli v1.1.0 // indirect | |
| 17 | + github.com/aymerick/douceur v0.2.0 // indirect | |
| 18 | + github.com/dlclark/regexp2/v2 v2.2.1 // indirect | |
| 19 | + github.com/dustin/go-humanize v1.0.1 // indirect | |
| 20 | + github.com/google/uuid v1.6.0 // indirect | |
| 21 | + github.com/gorilla/css v1.0.1 // indirect | |
| 22 | + github.com/klauspost/compress v1.17.9 // indirect | |
| 23 | + github.com/mattn/go-colorable v0.1.13 // indirect | |
| 24 | + github.com/mattn/go-isatty v0.0.24 // indirect | |
| 25 | + github.com/mattn/go-runewidth v0.0.16 // indirect | |
| 26 | + github.com/ncruces/go-strftime v1.0.0 // indirect | |
| 27 | + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect | |
| 28 | + github.com/rivo/uniseg v0.2.0 // indirect | |
| 29 | + github.com/valyala/bytebufferpool v1.0.0 // indirect | |
| 30 | + github.com/valyala/fasthttp v1.51.0 // indirect | |
| 31 | + github.com/valyala/tcplisten v1.0.0 // indirect | |
| 32 | + golang.org/x/net v0.60.0 // indirect | |
| 33 | + golang.org/x/sys v0.49.0 // indirect | |
| 34 | + modernc.org/libc v1.77.1 // indirect | |
| 35 | + modernc.org/mathutil v1.7.1 // indirect | |
| 36 | + modernc.org/memory v1.12.1 // indirect | |
| 37 | +) |
go.sum
+94
-0
| ... | ... | @@ -0,0 +1,94 @@ |
| 1 | +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= | |
| 2 | +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= | |
| 3 | +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= | |
| 4 | +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= | |
| 5 | +github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs= | |
| 6 | +github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8= | |
| 7 | +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= | |
| 8 | +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= | |
| 9 | +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= | |
| 10 | +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= | |
| 11 | +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= | |
| 12 | +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= | |
| 13 | +github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0= | |
| 14 | +github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= | |
| 15 | +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= | |
| 16 | +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= | |
| 17 | +github.com/gofiber/fiber/v2 v2.52.15 h1:Cov1uKeVPyu9q0jSrN60W+A8XNX+/WK8J7cy5osHLIk= | |
| 18 | +github.com/gofiber/fiber/v2 v2.52.15/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= | |
| 19 | +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= | |
| 20 | +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= | |
| 21 | +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= | |
| 22 | +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | |
| 23 | +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= | |
| 24 | +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= | |
| 25 | +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= | |
| 26 | +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= | |
| 27 | +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= | |
| 28 | +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= | |
| 29 | +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= | |
| 30 | +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= | |
| 31 | +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= | |
| 32 | +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= | |
| 33 | +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= | |
| 34 | +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= | |
| 35 | +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= | |
| 36 | +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= | |
| 37 | +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= | |
| 38 | +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= | |
| 39 | +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= | |
| 40 | +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= | |
| 41 | +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= | |
| 42 | +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= | |
| 43 | +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= | |
| 44 | +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= | |
| 45 | +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= | |
| 46 | +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= | |
| 47 | +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= | |
| 48 | +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= | |
| 49 | +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= | |
| 50 | +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= | |
| 51 | +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= | |
| 52 | +github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4= | |
| 53 | +github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= | |
| 54 | +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= | |
| 55 | +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= | |
| 56 | +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= | |
| 57 | +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= | |
| 58 | +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= | |
| 59 | +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= | |
| 60 | +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= | |
| 61 | +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= | |
| 62 | +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | |
| 63 | +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= | |
| 64 | +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= | |
| 65 | +golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= | |
| 66 | +golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= | |
| 67 | +modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg= | |
| 68 | +modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= | |
| 69 | +modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE= | |
| 70 | +modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA= | |
| 71 | +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= | |
| 72 | +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= | |
| 73 | +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= | |
| 74 | +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= | |
| 75 | +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= | |
| 76 | +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= | |
| 77 | +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= | |
| 78 | +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= | |
| 79 | +modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs= | |
| 80 | +modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0= | |
| 81 | +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= | |
| 82 | +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= | |
| 83 | +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= | |
| 84 | +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= | |
| 85 | +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= | |
| 86 | +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= | |
| 87 | +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= | |
| 88 | +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= | |
| 89 | +modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw= | |
| 90 | +modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0= | |
| 91 | +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= | |
| 92 | +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= | |
| 93 | +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= | |
| 94 | +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= |
internal/auth/auth.go
+280
-0
| ... | ... | @@ -0,0 +1,280 @@ |
| 1 | +package auth | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "crypto/rand" | |
| 6 | + "crypto/sha256" | |
| 7 | + "crypto/subtle" | |
| 8 | + "database/sql" | |
| 9 | + "encoding/base64" | |
| 10 | + "encoding/hex" | |
| 11 | + "errors" | |
| 12 | + "fmt" | |
| 13 | + "strings" | |
| 14 | + "time" | |
| 15 | + | |
| 16 | + "polar/internal/db" | |
| 17 | + | |
| 18 | + "golang.org/x/crypto/argon2" | |
| 19 | +) | |
| 20 | + | |
| 21 | +var ( | |
| 22 | + ErrInvalidCredentials = errors.New("invalid credentials") | |
| 23 | + ErrSessionExpired = errors.New("session expired or not found") | |
| 24 | + ErrInvalidToken = errors.New("invalid access token") | |
| 25 | +) | |
| 26 | + | |
| 27 | +const ( | |
| 28 | + argonTime uint32 = 1 | |
| 29 | + argonMemory uint32 = 64 * 1024 | |
| 30 | + argonThreads uint8 = 4 | |
| 31 | + argonKeyLen uint32 = 32 | |
| 32 | + saltLen = 16 | |
| 33 | + SessionTTL = 30 * 24 * time.Hour | |
| 34 | +) | |
| 35 | + | |
| 36 | +type Session struct { | |
| 37 | + ID string | |
| 38 | + UserID int64 | |
| 39 | + CSRFToken string | |
| 40 | + ExpiresAt time.Time | |
| 41 | + CreatedAt time.Time | |
| 42 | +} | |
| 43 | + | |
| 44 | +type AccessToken struct { | |
| 45 | + ID int64 | |
| 46 | + UserID int64 | |
| 47 | + Name string | |
| 48 | + TokenPrefix string | |
| 49 | + LastUsedAt *time.Time | |
| 50 | + CreatedAt time.Time | |
| 51 | +} | |
| 52 | + | |
| 53 | +type Service struct { | |
| 54 | + db *db.DB | |
| 55 | +} | |
| 56 | + | |
| 57 | +func NewService(database *db.DB) *Service { | |
| 58 | + return &Service{db: database} | |
| 59 | +} | |
| 60 | + | |
| 61 | +func HashPassword(password string) (string, error) { | |
| 62 | + if len(password) < 8 { | |
| 63 | + return "", errors.New("password must be at least 8 characters") | |
| 64 | + } | |
| 65 | + salt := make([]byte, saltLen) | |
| 66 | + if _, err := rand.Read(salt); err != nil { | |
| 67 | + return "", fmt.Errorf("generate salt: %w", err) | |
| 68 | + } | |
| 69 | + hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) | |
| 70 | + b64Salt := base64.RawStdEncoding.EncodeToString(salt) | |
| 71 | + b64Hash := base64.RawStdEncoding.EncodeToString(hash) | |
| 72 | + encoded := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", | |
| 73 | + argon2.Version, argonMemory, argonTime, argonThreads, b64Salt, b64Hash) | |
| 74 | + return encoded, nil | |
| 75 | +} | |
| 76 | + | |
| 77 | +func VerifyPassword(password, encodedHash string) (bool, error) { | |
| 78 | + parts := strings.Split(encodedHash, "$") | |
| 79 | + if len(parts) != 6 || parts[1] != "argon2id" { | |
| 80 | + return false, errors.New("invalid argon2id hash format") | |
| 81 | + } | |
| 82 | + var memory, timeCost uint32 | |
| 83 | + var threads uint8 | |
| 84 | + _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &timeCost, &threads) | |
| 85 | + if err != nil { | |
| 86 | + return false, fmt.Errorf("parse argon2 params: %w", err) | |
| 87 | + } | |
| 88 | + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) | |
| 89 | + if err != nil { | |
| 90 | + return false, fmt.Errorf("decode salt: %w", err) | |
| 91 | + } | |
| 92 | + expectedHash, err := base64.RawStdEncoding.DecodeString(parts[5]) | |
| 93 | + if err != nil { | |
| 94 | + return false, fmt.Errorf("decode hash: %w", err) | |
| 95 | + } | |
| 96 | + computed := argon2.IDKey([]byte(password), salt, timeCost, memory, threads, uint32(len(expectedHash))) | |
| 97 | + return subtle.ConstantTimeCompare(computed, expectedHash) == 1, nil | |
| 98 | +} | |
| 99 | + | |
| 100 | +func randomHex(bytesLen int) (string, error) { | |
| 101 | + b := make([]byte, bytesLen) | |
| 102 | + if _, err := rand.Read(b); err != nil { | |
| 103 | + return "", err | |
| 104 | + } | |
| 105 | + return hex.EncodeToString(b), nil | |
| 106 | +} | |
| 107 | + | |
| 108 | +func (s *Service) CreateSession(ctx context.Context, userID int64) (*Session, error) { | |
| 109 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 110 | + defer cancel() | |
| 111 | + | |
| 112 | + id, err := randomHex(32) | |
| 113 | + if err != nil { | |
| 114 | + return nil, fmt.Errorf("generate session id: %w", err) | |
| 115 | + } | |
| 116 | + csrf, err := randomHex(24) | |
| 117 | + if err != nil { | |
| 118 | + return nil, fmt.Errorf("generate csrf token: %w", err) | |
| 119 | + } | |
| 120 | + now := time.Now().UTC() | |
| 121 | + expires := now.Add(SessionTTL) | |
| 122 | + | |
| 123 | + _, err = s.db.ExecContext(cctx, ` | |
| 124 | + INSERT INTO sessions (id, user_id, csrf_token, expires_at, created_at) | |
| 125 | + VALUES (?, ?, ?, ?, ?) | |
| 126 | + `, id, userID, csrf, expires, now) | |
| 127 | + if err != nil { | |
| 128 | + return nil, fmt.Errorf("insert session: %w", err) | |
| 129 | + } | |
| 130 | + | |
| 131 | + return &Session{ | |
| 132 | + ID: id, | |
| 133 | + UserID: userID, | |
| 134 | + CSRFToken: csrf, | |
| 135 | + ExpiresAt: expires, | |
| 136 | + CreatedAt: now, | |
| 137 | + }, nil | |
| 138 | +} | |
| 139 | + | |
| 140 | +func (s *Service) GetSession(ctx context.Context, sessionID string) (*Session, error) { | |
| 141 | + if sessionID == "" { | |
| 142 | + return nil, ErrSessionExpired | |
| 143 | + } | |
| 144 | + cctx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 145 | + defer cancel() | |
| 146 | + | |
| 147 | + var sess Session | |
| 148 | + err := s.db.QueryRowContext(cctx, ` | |
| 149 | + SELECT id, user_id, csrf_token, expires_at, created_at | |
| 150 | + FROM sessions WHERE id = ? | |
| 151 | + `, sessionID).Scan(&sess.ID, &sess.UserID, &sess.CSRFToken, &sess.ExpiresAt, &sess.CreatedAt) | |
| 152 | + if err != nil { | |
| 153 | + if errors.Is(err, sql.ErrNoRows) { | |
| 154 | + return nil, ErrSessionExpired | |
| 155 | + } | |
| 156 | + return nil, fmt.Errorf("query session: %w", err) | |
| 157 | + } | |
| 158 | + | |
| 159 | + if time.Now().UTC().After(sess.ExpiresAt) { | |
| 160 | + _ = s.DeleteSession(ctx, sessionID) | |
| 161 | + return nil, ErrSessionExpired | |
| 162 | + } | |
| 163 | + return &sess, nil | |
| 164 | +} | |
| 165 | + | |
| 166 | +func (s *Service) DeleteSession(ctx context.Context, sessionID string) error { | |
| 167 | + cctx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 168 | + defer cancel() | |
| 169 | + _, err := s.db.ExecContext(cctx, `DELETE FROM sessions WHERE id = ?`, sessionID) | |
| 170 | + return err | |
| 171 | +} | |
| 172 | + | |
| 173 | +func HashToken(rawToken string) string { | |
| 174 | + sum := sha256.Sum256([]byte(rawToken)) | |
| 175 | + return hex.EncodeToString(sum[:]) | |
| 176 | +} | |
| 177 | + | |
| 178 | +func (s *Service) CreateToken(ctx context.Context, userID int64, name string) (string, *AccessToken, error) { | |
| 179 | + name = strings.TrimSpace(name) | |
| 180 | + if name == "" || len(name) > 64 { | |
| 181 | + return "", nil, errors.New("token name must be between 1 and 64 characters") | |
| 182 | + } | |
| 183 | + | |
| 184 | + secret, err := randomHex(20) | |
| 185 | + if err != nil { | |
| 186 | + return "", nil, fmt.Errorf("generate token secret: %w", err) | |
| 187 | + } | |
| 188 | + rawToken := "polar_" + secret | |
| 189 | + tokenHash := HashToken(rawToken) | |
| 190 | + prefix := rawToken[:10] | |
| 191 | + now := time.Now().UTC() | |
| 192 | + | |
| 193 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 194 | + defer cancel() | |
| 195 | + | |
| 196 | + res, err := s.db.ExecContext(cctx, ` | |
| 197 | + INSERT INTO access_tokens (user_id, name, token_prefix, token_hash, created_at) | |
| 198 | + VALUES (?, ?, ?, ?, ?) | |
| 199 | + `, userID, name, prefix, tokenHash, now) | |
| 200 | + if err != nil { | |
| 201 | + return "", nil, fmt.Errorf("insert access token: %w", err) | |
| 202 | + } | |
| 203 | + id, _ := res.LastInsertId() | |
| 204 | + | |
| 205 | + return rawToken, &AccessToken{ | |
| 206 | + ID: id, | |
| 207 | + UserID: userID, | |
| 208 | + Name: name, | |
| 209 | + TokenPrefix: prefix, | |
| 210 | + CreatedAt: now, | |
| 211 | + }, nil | |
| 212 | +} | |
| 213 | + | |
| 214 | +func (s *Service) VerifyToken(ctx context.Context, rawToken string) (int64, error) { | |
| 215 | + rawToken = strings.TrimSpace(rawToken) | |
| 216 | + if !strings.HasPrefix(rawToken, "polar_") { | |
| 217 | + return 0, ErrInvalidToken | |
| 218 | + } | |
| 219 | + tokenHash := HashToken(rawToken) | |
| 220 | + | |
| 221 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 222 | + defer cancel() | |
| 223 | + | |
| 224 | + var tokenID, userID int64 | |
| 225 | + err := s.db.QueryRowContext(cctx, ` | |
| 226 | + SELECT id, user_id FROM access_tokens WHERE token_hash = ? | |
| 227 | + `, tokenHash).Scan(&tokenID, &userID) | |
| 228 | + if err != nil { | |
| 229 | + if errors.Is(err, sql.ErrNoRows) { | |
| 230 | + return 0, ErrInvalidToken | |
| 231 | + } | |
| 232 | + return 0, fmt.Errorf("lookup token: %w", err) | |
| 233 | + } | |
| 234 | + | |
| 235 | + now := time.Now().UTC() | |
| 236 | + _, _ = s.db.ExecContext(cctx, `UPDATE access_tokens SET last_used_at = ? WHERE id = ?`, now, tokenID) | |
| 237 | + return userID, nil | |
| 238 | +} | |
| 239 | + | |
| 240 | +func (s *Service) ListTokens(ctx context.Context, userID int64) ([]AccessToken, error) { | |
| 241 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 242 | + defer cancel() | |
| 243 | + | |
| 244 | + rows, err := s.db.QueryContext(cctx, ` | |
| 245 | + SELECT id, user_id, name, token_prefix, last_used_at, created_at | |
| 246 | + FROM access_tokens | |
| 247 | + WHERE user_id = ? | |
| 248 | + ORDER BY created_at DESC | |
| 249 | + `, userID) | |
| 250 | + if err != nil { | |
| 251 | + return nil, fmt.Errorf("list access tokens: %w", err) | |
| 252 | + } | |
| 253 | + defer rows.Close() | |
| 254 | + | |
| 255 | + var list []AccessToken | |
| 256 | + for rows.Next() { | |
| 257 | + var t AccessToken | |
| 258 | + var lastUsed sql.NullTime | |
| 259 | + if err := rows.Scan(&t.ID, &t.UserID, &t.Name, &t.TokenPrefix, &lastUsed, &t.CreatedAt); err != nil { | |
| 260 | + return nil, fmt.Errorf("scan access token: %w", err) | |
| 261 | + } | |
| 262 | + if lastUsed.Valid { | |
| 263 | + tm := lastUsed.Time | |
| 264 | + t.LastUsedAt = &tm | |
| 265 | + } | |
| 266 | + list = append(list, t) | |
| 267 | + } | |
| 268 | + return list, rows.Err() | |
| 269 | +} | |
| 270 | + | |
| 271 | +func (s *Service) RevokeToken(ctx context.Context, userID, tokenID int64) error { | |
| 272 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 273 | + defer cancel() | |
| 274 | + | |
| 275 | + _, err := s.db.ExecContext(cctx, `DELETE FROM access_tokens WHERE id = ? AND user_id = ?`, tokenID, userID) | |
| 276 | + if err != nil { | |
| 277 | + return fmt.Errorf("revoke token: %w", err) | |
| 278 | + } | |
| 279 | + return nil | |
| 280 | +} |
internal/auth/auth_test.go
+101
-0
| ... | ... | @@ -0,0 +1,101 @@ |
| 1 | +package auth | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "path/filepath" | |
| 6 | + "strings" | |
| 7 | + "testing" | |
| 8 | + | |
| 9 | + "polar/internal/db" | |
| 10 | +) | |
| 11 | + | |
| 12 | +func TestPasswordHashAndVerify(t *testing.T) { | |
| 13 | + if _, err := HashPassword("short"); err == nil { | |
| 14 | + t.Fatalf("expected error for short password") | |
| 15 | + } | |
| 16 | + | |
| 17 | + hash, err := HashPassword("correct-horse-battery-staple") | |
| 18 | + if err != nil { | |
| 19 | + t.Fatalf("HashPassword failed: %v", err) | |
| 20 | + } | |
| 21 | + if !strings.HasPrefix(hash, "$argon2id$") { | |
| 22 | + t.Fatalf("expected argon2id PHC format, got %q", hash) | |
| 23 | + } | |
| 24 | + | |
| 25 | + ok, err := VerifyPassword("correct-horse-battery-staple", hash) | |
| 26 | + if err != nil || !ok { | |
| 27 | + t.Fatalf("VerifyPassword failed for valid password: %v", err) | |
| 28 | + } | |
| 29 | + | |
| 30 | + ok, err = VerifyPassword("wrong-password", hash) | |
| 31 | + if err != nil || ok { | |
| 32 | + t.Fatalf("VerifyPassword should return false for wrong password") | |
| 33 | + } | |
| 34 | +} | |
| 35 | + | |
| 36 | +func TestSessionsAndTokens(t *testing.T) { | |
| 37 | + ctx := context.Background() | |
| 38 | + database, err := db.Open(ctx, filepath.Join(t.TempDir(), "auth.db"), true) | |
| 39 | + if err != nil { | |
| 40 | + t.Fatalf("open db: %v", err) | |
| 41 | + } | |
| 42 | + defer database.Close() | |
| 43 | + | |
| 44 | + _, err = database.ExecContext(ctx, `INSERT INTO users (id, username, email, password_hash, is_admin) VALUES (1, 'alice', 'alice@example.com', 'hash', 1)`) | |
| 45 | + if err != nil { | |
| 46 | + t.Fatalf("insert test user: %v", err) | |
| 47 | + } | |
| 48 | + | |
| 49 | + svc := NewService(database) | |
| 50 | + | |
| 51 | + sess, err := svc.CreateSession(ctx, 1) | |
| 52 | + if err != nil { | |
| 53 | + t.Fatalf("CreateSession failed: %v", err) | |
| 54 | + } | |
| 55 | + if len(sess.ID) != 64 || len(sess.CSRFToken) < 32 { | |
| 56 | + t.Fatalf("unexpected session ID or CSRF token length") | |
| 57 | + } | |
| 58 | + | |
| 59 | + loaded, err := svc.GetSession(ctx, sess.ID) | |
| 60 | + if err != nil || loaded.UserID != 1 { | |
| 61 | + t.Fatalf("GetSession failed: %v", err) | |
| 62 | + } | |
| 63 | + | |
| 64 | + if err := svc.DeleteSession(ctx, sess.ID); err != nil { | |
| 65 | + t.Fatalf("DeleteSession failed: %v", err) | |
| 66 | + } | |
| 67 | + if _, err := svc.GetSession(ctx, sess.ID); err == nil { | |
| 68 | + t.Fatalf("expected error after session deletion") | |
| 69 | + } | |
| 70 | + | |
| 71 | + rawToken, tok, err := svc.CreateToken(ctx, 1, "Laptop CLI") | |
| 72 | + if err != nil { | |
| 73 | + t.Fatalf("CreateToken failed: %v", err) | |
| 74 | + } | |
| 75 | + if !strings.HasPrefix(rawToken, "polar_") { | |
| 76 | + t.Fatalf("expected polar_ prefix, got %q", rawToken) | |
| 77 | + } | |
| 78 | + if tok.Name != "Laptop CLI" { | |
| 79 | + t.Fatalf("unexpected token name: %q", tok.Name) | |
| 80 | + } | |
| 81 | + | |
| 82 | + uid, err := svc.VerifyToken(ctx, rawToken) | |
| 83 | + if err != nil || uid != 1 { | |
| 84 | + t.Fatalf("VerifyToken failed: uid=%d err=%v", uid, err) | |
| 85 | + } | |
| 86 | + | |
| 87 | + tokens, err := svc.ListTokens(ctx, 1) | |
| 88 | + if err != nil || len(tokens) != 1 { | |
| 89 | + t.Fatalf("ListTokens failed: len=%d err=%v", len(tokens), err) | |
| 90 | + } | |
| 91 | + if tokens[0].LastUsedAt == nil { | |
| 92 | + t.Fatalf("expected LastUsedAt to be updated after VerifyToken") | |
| 93 | + } | |
| 94 | + | |
| 95 | + if err := svc.RevokeToken(ctx, 1, tok.ID); err != nil { | |
| 96 | + t.Fatalf("RevokeToken failed: %v", err) | |
| 97 | + } | |
| 98 | + if _, err := svc.VerifyToken(ctx, rawToken); err == nil { | |
| 99 | + t.Fatalf("expected error for revoked token") | |
| 100 | + } | |
| 101 | +} |
internal/auth/device.go
+195
-0
| ... | ... | @@ -0,0 +1,195 @@ |
| 1 | +package auth | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "crypto/rand" | |
| 6 | + "database/sql" | |
| 7 | + "errors" | |
| 8 | + "fmt" | |
| 9 | + "math/big" | |
| 10 | + "strings" | |
| 11 | + "time" | |
| 12 | +) | |
| 13 | + | |
| 14 | +const ( | |
| 15 | + DeviceCodeTTL = 15 * time.Minute | |
| 16 | + DeviceCodeInterval = 5 // seconds the client should wait between polls | |
| 17 | +) | |
| 18 | + | |
| 19 | +// Status values for a device authorization grant. | |
| 20 | +const ( | |
| 21 | + DeviceStatusPending = "pending" | |
| 22 | + DeviceStatusApproved = "approved" | |
| 23 | + DeviceStatusDenied = "denied" | |
| 24 | +) | |
| 25 | + | |
| 26 | +var ErrDeviceCodeNotFound = errors.New("device code not found") | |
| 27 | + | |
| 28 | +// userCodeAlphabet avoids characters that are easy to confuse (0/O, 1/I/L). | |
| 29 | +const userCodeAlphabet = "BCDFGHJKLMNPQRSTVWXZ23456789" | |
| 30 | + | |
| 31 | +type DeviceCode struct { | |
| 32 | + Code string | |
| 33 | + UserCode string | |
| 34 | + ClientID string | |
| 35 | + UserID *int64 | |
| 36 | + Status string | |
| 37 | + ExpiresAt time.Time | |
| 38 | +} | |
| 39 | + | |
| 40 | +func generateUserCode() (string, error) { | |
| 41 | + letters := make([]byte, 8) | |
| 42 | + for i := range letters { | |
| 43 | + n, err := rand.Int(rand.Reader, big.NewInt(int64(len(userCodeAlphabet)))) | |
| 44 | + if err != nil { | |
| 45 | + return "", err | |
| 46 | + } | |
| 47 | + letters[i] = userCodeAlphabet[n.Int64()] | |
| 48 | + } | |
| 49 | + return string(letters[:4]) + "-" + string(letters[4:]), nil | |
| 50 | +} | |
| 51 | + | |
| 52 | +// NormalizeUserCode uppercases the entered code and restores the XXXX-XXXX shape. | |
| 53 | +func NormalizeUserCode(code string) string { | |
| 54 | + code = strings.ToUpper(strings.TrimSpace(code)) | |
| 55 | + code = strings.ReplaceAll(code, " ", "") | |
| 56 | + code = strings.ReplaceAll(code, "-", "") | |
| 57 | + if len(code) != 8 { | |
| 58 | + return code | |
| 59 | + } | |
| 60 | + return code[:4] + "-" + code[4:] | |
| 61 | +} | |
| 62 | + | |
| 63 | +// CreateDeviceCode starts a device authorization grant (RFC 8628). | |
| 64 | +func (s *Service) CreateDeviceCode(ctx context.Context, clientID string) (*DeviceCode, error) { | |
| 65 | + clientID = strings.TrimSpace(clientID) | |
| 66 | + if len(clientID) > 120 { | |
| 67 | + clientID = clientID[:120] | |
| 68 | + } | |
| 69 | + | |
| 70 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 71 | + defer cancel() | |
| 72 | + | |
| 73 | + for attempt := 0; attempt < 5; attempt++ { | |
| 74 | + deviceCode, err := randomHex(24) | |
| 75 | + if err != nil { | |
| 76 | + return nil, fmt.Errorf("generate device code: %w", err) | |
| 77 | + } | |
| 78 | + userCode, err := generateUserCode() | |
| 79 | + if err != nil { | |
| 80 | + return nil, fmt.Errorf("generate user code: %w", err) | |
| 81 | + } | |
| 82 | + | |
| 83 | + expiresAt := time.Now().UTC().Add(DeviceCodeTTL) | |
| 84 | + _, err = s.db.ExecContext(cctx, ` | |
| 85 | + INSERT INTO device_codes (device_code, user_code, client_id, status, created_at, expires_at) | |
| 86 | + VALUES (?, ?, ?, ?, ?, ?) | |
| 87 | + `, deviceCode, userCode, clientID, DeviceStatusPending, time.Now().UTC(), expiresAt) | |
| 88 | + if err != nil { | |
| 89 | + if strings.Contains(strings.ToLower(err.Error()), "unique") { | |
| 90 | + continue | |
| 91 | + } | |
| 92 | + return nil, fmt.Errorf("insert device code: %w", err) | |
| 93 | + } | |
| 94 | + return &DeviceCode{Code: deviceCode, UserCode: userCode, ClientID: clientID, Status: DeviceStatusPending, ExpiresAt: expiresAt}, nil | |
| 95 | + } | |
| 96 | + return nil, errors.New("could not allocate a unique user code") | |
| 97 | +} | |
| 98 | + | |
| 99 | +// GetDeviceCodeByUserCode looks up a grant by the human-friendly code. | |
| 100 | +func (s *Service) GetDeviceCodeByUserCode(ctx context.Context, userCode string) (*DeviceCode, error) { | |
| 101 | + userCode = NormalizeUserCode(userCode) | |
| 102 | + if userCode == "" { | |
| 103 | + return nil, ErrDeviceCodeNotFound | |
| 104 | + } | |
| 105 | + | |
| 106 | + cctx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 107 | + defer cancel() | |
| 108 | + | |
| 109 | + var dc DeviceCode | |
| 110 | + var userID sql.NullInt64 | |
| 111 | + var expiresAt time.Time | |
| 112 | + err := s.db.QueryRowContext(cctx, ` | |
| 113 | + SELECT device_code, user_code, client_id, user_id, status, expires_at | |
| 114 | + FROM device_codes WHERE user_code = ? | |
| 115 | + `, userCode).Scan(&dc.Code, &dc.UserCode, &dc.ClientID, &userID, &dc.Status, &expiresAt) | |
| 116 | + if err != nil { | |
| 117 | + if errors.Is(err, sql.ErrNoRows) { | |
| 118 | + return nil, ErrDeviceCodeNotFound | |
| 119 | + } | |
| 120 | + return nil, fmt.Errorf("get device code: %w", err) | |
| 121 | + } | |
| 122 | + if userID.Valid { | |
| 123 | + dc.UserID = &userID.Int64 | |
| 124 | + } | |
| 125 | + dc.ExpiresAt = expiresAt | |
| 126 | + return &dc, nil | |
| 127 | +} | |
| 128 | + | |
| 129 | +// SetDeviceCodeStatus approves or denies a pending grant. | |
| 130 | +func (s *Service) SetDeviceCodeStatus(ctx context.Context, userCode string, userID int64, status string) error { | |
| 131 | + if status != DeviceStatusApproved && status != DeviceStatusDenied { | |
| 132 | + return errors.New("invalid device code status") | |
| 133 | + } | |
| 134 | + | |
| 135 | + cctx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 136 | + defer cancel() | |
| 137 | + | |
| 138 | + res, err := s.db.ExecContext(cctx, ` | |
| 139 | + UPDATE device_codes | |
| 140 | + SET status = ?, user_id = ? | |
| 141 | + WHERE user_code = ? AND status = ? AND expires_at > ? | |
| 142 | + `, status, userID, NormalizeUserCode(userCode), DeviceStatusPending, time.Now().UTC()) | |
| 143 | + if err != nil { | |
| 144 | + return fmt.Errorf("update device code: %w", err) | |
| 145 | + } | |
| 146 | + if n, _ := res.RowsAffected(); n == 0 { | |
| 147 | + return ErrDeviceCodeNotFound | |
| 148 | + } | |
| 149 | + return nil | |
| 150 | +} | |
| 151 | + | |
| 152 | +// ConsumeDeviceCode marks an approved grant as exchanged so the device code | |
| 153 | +// cannot be traded for a second token. | |
| 154 | +func (s *Service) ConsumeDeviceCode(ctx context.Context, deviceCode string) { | |
| 155 | + cctx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 156 | + defer cancel() | |
| 157 | + | |
| 158 | + _, _ = s.db.ExecContext(cctx, `UPDATE device_codes SET status = 'exchanged' WHERE device_code = ?`, deviceCode) | |
| 159 | +} | |
| 160 | + | |
| 161 | +// PollDeviceCode returns the current grant status and marks the poll time. | |
| 162 | +func (s *Service) PollDeviceCode(ctx context.Context, deviceCode string) (*DeviceCode, error) { | |
| 163 | + deviceCode = strings.TrimSpace(deviceCode) | |
| 164 | + if deviceCode == "" { | |
| 165 | + return nil, ErrDeviceCodeNotFound | |
| 166 | + } | |
| 167 | + | |
| 168 | + cctx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 169 | + defer cancel() | |
| 170 | + | |
| 171 | + var dc DeviceCode | |
| 172 | + var userID sql.NullInt64 | |
| 173 | + var expiresAt time.Time | |
| 174 | + err := s.db.QueryRowContext(cctx, ` | |
| 175 | + SELECT device_code, user_code, client_id, user_id, status, expires_at | |
| 176 | + FROM device_codes WHERE device_code = ? | |
| 177 | + `, deviceCode).Scan(&dc.Code, &dc.UserCode, &dc.ClientID, &userID, &dc.Status, &expiresAt) | |
| 178 | + if err != nil { | |
| 179 | + if errors.Is(err, sql.ErrNoRows) { | |
| 180 | + return nil, ErrDeviceCodeNotFound | |
| 181 | + } | |
| 182 | + return nil, fmt.Errorf("poll device code: %w", err) | |
| 183 | + } | |
| 184 | + if userID.Valid { | |
| 185 | + dc.UserID = &userID.Int64 | |
| 186 | + } | |
| 187 | + dc.ExpiresAt = expiresAt | |
| 188 | + | |
| 189 | + _, _ = s.db.ExecContext(cctx, `UPDATE device_codes SET last_polled_at = ? WHERE device_code = ?`, time.Now().UTC(), deviceCode) | |
| 190 | + | |
| 191 | + if time.Now().UTC().After(expiresAt) && dc.Status == DeviceStatusPending { | |
| 192 | + dc.Status = "expired" | |
| 193 | + } | |
| 194 | + return &dc, nil | |
| 195 | +} |
internal/config/config.go
+154
-0
| ... | ... | @@ -0,0 +1,154 @@ |
| 1 | +package config | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "fmt" | |
| 5 | + "os" | |
| 6 | + "path/filepath" | |
| 7 | + "strconv" | |
| 8 | + "strings" | |
| 9 | + | |
| 10 | + "github.com/BurntSushi/toml" | |
| 11 | +) | |
| 12 | + | |
| 13 | +type Config struct { | |
| 14 | + Server ServerConfig `toml:"server"` | |
| 15 | + Storage StorageConfig `toml:"storage"` | |
| 16 | + Auth AuthConfig `toml:"auth"` | |
| 17 | + Landing LandingConfig `toml:"landing"` | |
| 18 | +} | |
| 19 | + | |
| 20 | +type ServerConfig struct { | |
| 21 | + Host string `toml:"host"` | |
| 22 | + Port int `toml:"port"` | |
| 23 | + BaseURL string `toml:"base_url"` | |
| 24 | +} | |
| 25 | + | |
| 26 | +type StorageConfig struct { | |
| 27 | + DataDir string `toml:"data_dir"` | |
| 28 | +} | |
| 29 | + | |
| 30 | +type AuthConfig struct { | |
| 31 | + RegistrationEnabled bool `toml:"registration_enabled"` | |
| 32 | +} | |
| 33 | + | |
| 34 | +type LandingConfig struct { | |
| 35 | + Enabled bool `toml:"enabled"` | |
| 36 | + DefaultLang string `toml:"default_lang"` | |
| 37 | +} | |
| 38 | + | |
| 39 | +func Default() Config { | |
| 40 | + return Config{ | |
| 41 | + Server: ServerConfig{ | |
| 42 | + Host: "0.0.0.0", | |
| 43 | + Port: 3000, | |
| 44 | + BaseURL: "http://localhost:3000", | |
| 45 | + }, | |
| 46 | + Storage: StorageConfig{ | |
| 47 | + DataDir: "./data", | |
| 48 | + }, | |
| 49 | + Auth: AuthConfig{ | |
| 50 | + RegistrationEnabled: true, | |
| 51 | + }, | |
| 52 | + Landing: LandingConfig{ | |
| 53 | + Enabled: true, | |
| 54 | + DefaultLang: "ru", | |
| 55 | + }, | |
| 56 | + } | |
| 57 | +} | |
| 58 | + | |
| 59 | +func Load(configPath string) (Config, error) { | |
| 60 | + cfg := Default() | |
| 61 | + | |
| 62 | + if envPath := os.Getenv("POLAR_CONFIG"); envPath != "" && configPath == "" { | |
| 63 | + configPath = envPath | |
| 64 | + } | |
| 65 | + if configPath == "" { | |
| 66 | + if _, err := os.Stat("polar.toml"); err == nil { | |
| 67 | + configPath = "polar.toml" | |
| 68 | + } | |
| 69 | + } | |
| 70 | + | |
| 71 | + if configPath != "" { | |
| 72 | + data, err := os.ReadFile(configPath) | |
| 73 | + if err != nil { | |
| 74 | + return cfg, fmt.Errorf("read config file %s: %w", configPath, err) | |
| 75 | + } | |
| 76 | + if err := toml.Unmarshal(data, &cfg); err != nil { | |
| 77 | + return cfg, fmt.Errorf("parse config file %s: %w", configPath, err) | |
| 78 | + } | |
| 79 | + } | |
| 80 | + | |
| 81 | + applyEnv(&cfg) | |
| 82 | + | |
| 83 | + cfg.Server.BaseURL = strings.TrimRight(cfg.Server.BaseURL, "/") | |
| 84 | + if cfg.Server.BaseURL == "" { | |
| 85 | + host := cfg.Server.Host | |
| 86 | + if host == "0.0.0.0" || host == "" { | |
| 87 | + host = "localhost" | |
| 88 | + } | |
| 89 | + cfg.Server.BaseURL = fmt.Sprintf("http://%s:%d", host, cfg.Server.Port) | |
| 90 | + } | |
| 91 | + if cfg.Landing.DefaultLang != "en" && cfg.Landing.DefaultLang != "ru" { | |
| 92 | + cfg.Landing.DefaultLang = "ru" | |
| 93 | + } | |
| 94 | + | |
| 95 | + return cfg, nil | |
| 96 | +} | |
| 97 | + | |
| 98 | +func applyEnv(cfg *Config) { | |
| 99 | + getEnv := os.Getenv | |
| 100 | + | |
| 101 | + if v := getEnv("POLAR_SERVER_HOST"); v != "" { | |
| 102 | + cfg.Server.Host = v | |
| 103 | + } | |
| 104 | + if v := getEnv("POLAR_HOST"); v != "" { | |
| 105 | + cfg.Server.Host = v | |
| 106 | + } | |
| 107 | + if v := getEnv("POLAR_SERVER_PORT"); v != "" { | |
| 108 | + if p, err := strconv.Atoi(v); err == nil && p > 0 { | |
| 109 | + cfg.Server.Port = p | |
| 110 | + } | |
| 111 | + } | |
| 112 | + if v := getEnv("POLAR_PORT"); v != "" { | |
| 113 | + if p, err := strconv.Atoi(v); err == nil && p > 0 { | |
| 114 | + cfg.Server.Port = p | |
| 115 | + } | |
| 116 | + } | |
| 117 | + if v := getEnv("POLAR_SERVER_BASE_URL"); v != "" { | |
| 118 | + cfg.Server.BaseURL = v | |
| 119 | + } | |
| 120 | + if v := getEnv("POLAR_BASE_URL"); v != "" { | |
| 121 | + cfg.Server.BaseURL = v | |
| 122 | + } | |
| 123 | + if v := getEnv("POLAR_STORAGE_DATA_DIR"); v != "" { | |
| 124 | + cfg.Storage.DataDir = v | |
| 125 | + } | |
| 126 | + if v := getEnv("POLAR_DATA_DIR"); v != "" { | |
| 127 | + cfg.Storage.DataDir = v | |
| 128 | + } | |
| 129 | + if v := getEnv("POLAR_AUTH_REGISTRATION_ENABLED"); v != "" { | |
| 130 | + if b, err := strconv.ParseBool(v); err == nil { | |
| 131 | + cfg.Auth.RegistrationEnabled = b | |
| 132 | + } | |
| 133 | + } | |
| 134 | + if v := getEnv("POLAR_LANDING_ENABLED"); v != "" { | |
| 135 | + if b, err := strconv.ParseBool(v); err == nil { | |
| 136 | + cfg.Landing.Enabled = b | |
| 137 | + } | |
| 138 | + } | |
| 139 | + if v := getEnv("POLAR_LANDING_DEFAULT_LANG"); v != "" { | |
| 140 | + cfg.Landing.DefaultLang = strings.ToLower(strings.TrimSpace(v)) | |
| 141 | + } | |
| 142 | +} | |
| 143 | + | |
| 144 | +func (c Config) Addr() string { | |
| 145 | + return fmt.Sprintf("%s:%d", c.Server.Host, c.Server.Port) | |
| 146 | +} | |
| 147 | + | |
| 148 | +func (c Config) DBPath() string { | |
| 149 | + return filepath.Join(c.Storage.DataDir, "polar.db") | |
| 150 | +} | |
| 151 | + | |
| 152 | +func (c Config) ReposDir() string { | |
| 153 | + return filepath.Join(c.Storage.DataDir, "repos") | |
| 154 | +} |
internal/config/config_test.go
+59
-0
| ... | ... | @@ -0,0 +1,59 @@ |
| 1 | +package config | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "os" | |
| 5 | + "path/filepath" | |
| 6 | + "testing" | |
| 7 | +) | |
| 8 | + | |
| 9 | +func TestLoadDefaultsAndOverrides(t *testing.T) { | |
| 10 | + cfg, err := Load("") | |
| 11 | + if err != nil { | |
| 12 | + t.Fatalf("Load default failed: %v", err) | |
| 13 | + } | |
| 14 | + if cfg.Server.Port != 3000 || cfg.Landing.DefaultLang != "ru" { | |
| 15 | + t.Fatalf("unexpected default config: %+v", cfg) | |
| 16 | + } | |
| 17 | + | |
| 18 | + tmpFile := filepath.Join(t.TempDir(), "polar.toml") | |
| 19 | + tomlData := ` | |
| 20 | +[server] | |
| 21 | +host = "127.0.0.1" | |
| 22 | +port = 4000 | |
| 23 | +base_url = "https://git.example.com/" | |
| 24 | + | |
| 25 | +[storage] | |
| 26 | +data_dir = "/var/lib/polar" | |
| 27 | + | |
| 28 | +[auth] | |
| 29 | +registration_enabled = false | |
| 30 | + | |
| 31 | +[landing] | |
| 32 | +enabled = false | |
| 33 | +default_lang = "en" | |
| 34 | +` | |
| 35 | + if err := os.WriteFile(tmpFile, []byte(tomlData), 0o644); err != nil { | |
| 36 | + t.Fatalf("write temp toml: %v", err) | |
| 37 | + } | |
| 38 | + | |
| 39 | + t.Setenv("POLAR_SERVER_PORT", "8080") | |
| 40 | + loaded, err := Load(tmpFile) | |
| 41 | + if err != nil { | |
| 42 | + t.Fatalf("Load from file failed: %v", err) | |
| 43 | + } | |
| 44 | + if loaded.Server.Port != 8080 { | |
| 45 | + t.Fatalf("expected env POLAR_SERVER_PORT=8080 to override file port 4000, got %d", loaded.Server.Port) | |
| 46 | + } | |
| 47 | + if loaded.Server.BaseURL != "https://git.example.com" { | |
| 48 | + t.Fatalf("expected trailing slash trimmed from base_url, got %q", loaded.Server.BaseURL) | |
| 49 | + } | |
| 50 | + if loaded.Auth.RegistrationEnabled { | |
| 51 | + t.Fatalf("expected registration_enabled = false") | |
| 52 | + } | |
| 53 | + if loaded.Landing.DefaultLang != "en" { | |
| 54 | + t.Fatalf("expected default_lang = en, got %q", loaded.Landing.DefaultLang) | |
| 55 | + } | |
| 56 | + if loaded.DBPath() != filepath.Join(loaded.Storage.DataDir, "polar.db") { | |
| 57 | + t.Fatalf("expected DBPath to default to polar.db, got %q", loaded.DBPath()) | |
| 58 | + } | |
| 59 | +} |
internal/db/db.go
+206
-0
| ... | ... | @@ -0,0 +1,206 @@ |
| 1 | +package db | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "database/sql" | |
| 6 | + "embed" | |
| 7 | + "errors" | |
| 8 | + "fmt" | |
| 9 | + "io/fs" | |
| 10 | + "os" | |
| 11 | + "path/filepath" | |
| 12 | + "sort" | |
| 13 | + "strconv" | |
| 14 | + "strings" | |
| 15 | + "time" | |
| 16 | + | |
| 17 | + _ "modernc.org/sqlite" | |
| 18 | +) | |
| 19 | + | |
| 20 | +//go:embed migrations/*.sql | |
| 21 | +var migrationsFS embed.FS | |
| 22 | + | |
| 23 | +type DB struct { | |
| 24 | + *sql.DB | |
| 25 | +} | |
| 26 | + | |
| 27 | +func Open(ctx context.Context, dbPath string, defaultRegEnabled bool) (*DB, error) { | |
| 28 | + if dbPath != ":memory:" && !strings.HasPrefix(dbPath, "file::memory:") { | |
| 29 | + if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil { | |
| 30 | + return nil, fmt.Errorf("create database directory: %w", err) | |
| 31 | + } | |
| 32 | + } | |
| 33 | + | |
| 34 | + dsn := dbPath | |
| 35 | + if !strings.Contains(dsn, "?") { | |
| 36 | + dsn += "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)" | |
| 37 | + } | |
| 38 | + | |
| 39 | + sqlDB, err := sql.Open("sqlite", dsn) | |
| 40 | + if err != nil { | |
| 41 | + return nil, fmt.Errorf("open sqlite %s: %w", dbPath, err) | |
| 42 | + } | |
| 43 | + | |
| 44 | + sqlDB.SetMaxOpenConns(1) | |
| 45 | + sqlDB.SetMaxIdleConns(1) | |
| 46 | + sqlDB.SetConnMaxLifetime(0) | |
| 47 | + | |
| 48 | + pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 49 | + defer cancel() | |
| 50 | + if err := sqlDB.PingContext(pingCtx); err != nil { | |
| 51 | + _ = sqlDB.Close() | |
| 52 | + return nil, fmt.Errorf("ping sqlite: %w", err) | |
| 53 | + } | |
| 54 | + | |
| 55 | + pragmas := []string{ | |
| 56 | + "PRAGMA journal_mode = WAL;", | |
| 57 | + "PRAGMA foreign_keys = ON;", | |
| 58 | + "PRAGMA busy_timeout = 5000;", | |
| 59 | + "PRAGMA synchronous = NORMAL;", | |
| 60 | + } | |
| 61 | + for _, p := range pragmas { | |
| 62 | + if _, err := sqlDB.ExecContext(pingCtx, p); err != nil { | |
| 63 | + _ = sqlDB.Close() | |
| 64 | + return nil, fmt.Errorf("exec pragma %q: %w", p, err) | |
| 65 | + } | |
| 66 | + } | |
| 67 | + | |
| 68 | + d := &DB{DB: sqlDB} | |
| 69 | + if err := d.Migrate(pingCtx); err != nil { | |
| 70 | + _ = sqlDB.Close() | |
| 71 | + return nil, fmt.Errorf("run migrations: %w", err) | |
| 72 | + } | |
| 73 | + | |
| 74 | + if _, err := d.GetSetting(pingCtx, "registration_enabled"); errors.Is(err, sql.ErrNoRows) { | |
| 75 | + if err := d.SetSetting(pingCtx, "registration_enabled", strconv.FormatBool(defaultRegEnabled)); err != nil { | |
| 76 | + _ = sqlDB.Close() | |
| 77 | + return nil, fmt.Errorf("seed registration_enabled setting: %w", err) | |
| 78 | + } | |
| 79 | + } | |
| 80 | + | |
| 81 | + return d, nil | |
| 82 | +} | |
| 83 | + | |
| 84 | +func (d *DB) Migrate(ctx context.Context) error { | |
| 85 | + _, err := d.ExecContext(ctx, ` | |
| 86 | + CREATE TABLE IF NOT EXISTS schema_migrations ( | |
| 87 | + version TEXT PRIMARY KEY, | |
| 88 | + applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | |
| 89 | + ); | |
| 90 | + `) | |
| 91 | + if err != nil { | |
| 92 | + return fmt.Errorf("create schema_migrations table: %w", err) | |
| 93 | + } | |
| 94 | + | |
| 95 | + entries, err := fs.ReadDir(migrationsFS, "migrations") | |
| 96 | + if err != nil { | |
| 97 | + return fmt.Errorf("read embedded migrations: %w", err) | |
| 98 | + } | |
| 99 | + | |
| 100 | + var files []string | |
| 101 | + for _, entry := range entries { | |
| 102 | + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") { | |
| 103 | + files = append(files, entry.Name()) | |
| 104 | + } | |
| 105 | + } | |
| 106 | + sort.Strings(files) | |
| 107 | + | |
| 108 | + for _, name := range files { | |
| 109 | + var exists int | |
| 110 | + err := d.QueryRowContext(ctx, `SELECT 1 FROM schema_migrations WHERE version = ?`, name).Scan(&exists) | |
| 111 | + if err == nil { | |
| 112 | + continue | |
| 113 | + } | |
| 114 | + if !errors.Is(err, sql.ErrNoRows) { | |
| 115 | + return fmt.Errorf("check migration %s: %w", name, err) | |
| 116 | + } | |
| 117 | + | |
| 118 | + content, err := migrationsFS.ReadFile("migrations/" + name) | |
| 119 | + if err != nil { | |
| 120 | + return fmt.Errorf("read migration %s: %w", name, err) | |
| 121 | + } | |
| 122 | + | |
| 123 | + tx, err := d.BeginTx(ctx, nil) | |
| 124 | + if err != nil { | |
| 125 | + return fmt.Errorf("begin migration tx %s: %w", name, err) | |
| 126 | + } | |
| 127 | + | |
| 128 | + if _, err := tx.ExecContext(ctx, string(content)); err != nil { | |
| 129 | + _ = tx.Rollback() | |
| 130 | + return fmt.Errorf("execute migration %s: %w", name, err) | |
| 131 | + } | |
| 132 | + if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations (version) VALUES (?)`, name); err != nil { | |
| 133 | + _ = tx.Rollback() | |
| 134 | + return fmt.Errorf("record migration %s: %w", name, err) | |
| 135 | + } | |
| 136 | + if err := tx.Commit(); err != nil { | |
| 137 | + return fmt.Errorf("commit migration %s: %w", name, err) | |
| 138 | + } | |
| 139 | + } | |
| 140 | + | |
| 141 | + return nil | |
| 142 | +} | |
| 143 | + | |
| 144 | +func (d *DB) GetSetting(ctx context.Context, key string) (string, error) { | |
| 145 | + cctx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 146 | + defer cancel() | |
| 147 | + var val string | |
| 148 | + err := d.QueryRowContext(cctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&val) | |
| 149 | + if err != nil { | |
| 150 | + return "", fmt.Errorf("get setting %s: %w", key, err) | |
| 151 | + } | |
| 152 | + return val, nil | |
| 153 | +} | |
| 154 | + | |
| 155 | +func (d *DB) SetSetting(ctx context.Context, key, value string) error { | |
| 156 | + cctx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 157 | + defer cancel() | |
| 158 | + _, err := d.ExecContext(cctx, ` | |
| 159 | + INSERT INTO settings (key, value) VALUES (?, ?) | |
| 160 | + ON CONFLICT(key) DO UPDATE SET value = excluded.value | |
| 161 | + `, key, value) | |
| 162 | + if err != nil { | |
| 163 | + return fmt.Errorf("set setting %s: %w", key, err) | |
| 164 | + } | |
| 165 | + return nil | |
| 166 | +} | |
| 167 | + | |
| 168 | +func (d *DB) GetBoolSetting(ctx context.Context, key string, fallback bool) bool { | |
| 169 | + val, err := d.GetSetting(ctx, key) | |
| 170 | + if err != nil { | |
| 171 | + return fallback | |
| 172 | + } | |
| 173 | + b, err := strconv.ParseBool(val) | |
| 174 | + if err != nil { | |
| 175 | + return fallback | |
| 176 | + } | |
| 177 | + return b | |
| 178 | +} | |
| 179 | + | |
| 180 | +func (d *DB) SetBoolSetting(ctx context.Context, key string, enabled bool) error { | |
| 181 | + return d.SetSetting(ctx, key, strconv.FormatBool(enabled)) | |
| 182 | +} | |
| 183 | + | |
| 184 | +func (d *DB) IsRegistrationEnabled(ctx context.Context, fallback bool) bool { | |
| 185 | + return d.GetBoolSetting(ctx, "registration_enabled", fallback) | |
| 186 | +} | |
| 187 | + | |
| 188 | +func (d *DB) SetRegistrationEnabled(ctx context.Context, enabled bool) error { | |
| 189 | + return d.SetBoolSetting(ctx, "registration_enabled", enabled) | |
| 190 | +} | |
| 191 | + | |
| 192 | +func (d *DB) IsExploreEnabled(ctx context.Context) bool { | |
| 193 | + return d.GetBoolSetting(ctx, "explore_enabled", true) | |
| 194 | +} | |
| 195 | + | |
| 196 | +func (d *DB) IsLandingEnabled(ctx context.Context, fallback bool) bool { | |
| 197 | + return d.GetBoolSetting(ctx, "landing_enabled", fallback) | |
| 198 | +} | |
| 199 | + | |
| 200 | +func (d *DB) IsAnonymousReadEnabled(ctx context.Context) bool { | |
| 201 | + return d.GetBoolSetting(ctx, "anonymous_read_enabled", true) | |
| 202 | +} | |
| 203 | + | |
| 204 | +func (d *DB) IsIssuesEnabled(ctx context.Context) bool { | |
| 205 | + return d.GetBoolSetting(ctx, "issues_enabled", true) | |
| 206 | +} |
internal/db/db_test.go
+42
-0
| ... | ... | @@ -0,0 +1,42 @@ |
| 1 | +package db | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "path/filepath" | |
| 6 | + "testing" | |
| 7 | +) | |
| 8 | + | |
| 9 | +func TestOpenAndMigrate(t *testing.T) { | |
| 10 | + ctx := context.Background() | |
| 11 | + dbPath := filepath.Join(t.TempDir(), "test_ada.db") | |
| 12 | + | |
| 13 | + database, err := Open(ctx, dbPath, true) | |
| 14 | + if err != nil { | |
| 15 | + t.Fatalf("Open failed: %v", err) | |
| 16 | + } | |
| 17 | + defer database.Close() | |
| 18 | + | |
| 19 | + // Re-running Migrate must be idempotent | |
| 20 | + if err := database.Migrate(ctx); err != nil { | |
| 21 | + t.Fatalf("second Migrate failed: %v", err) | |
| 22 | + } | |
| 23 | + | |
| 24 | + if !database.IsRegistrationEnabled(ctx, false) { | |
| 25 | + t.Fatalf("expected registration_enabled to be seeded as true") | |
| 26 | + } | |
| 27 | + | |
| 28 | + if err := database.SetRegistrationEnabled(ctx, false); err != nil { | |
| 29 | + t.Fatalf("SetRegistrationEnabled failed: %v", err) | |
| 30 | + } | |
| 31 | + if database.IsRegistrationEnabled(ctx, true) { | |
| 32 | + t.Fatalf("expected registration_enabled to be false after update") | |
| 33 | + } | |
| 34 | + | |
| 35 | + var fk int | |
| 36 | + if err := database.QueryRowContext(ctx, "PRAGMA foreign_keys;").Scan(&fk); err != nil { | |
| 37 | + t.Fatalf("query foreign_keys pragma: %v", err) | |
| 38 | + } | |
| 39 | + if fk != 1 { | |
| 40 | + t.Fatalf("expected foreign_keys = 1, got %d", fk) | |
| 41 | + } | |
| 42 | +} |
internal/db/migrations/001_init.sql
+54
-0
| ... | ... | @@ -0,0 +1,54 @@ |
| 1 | +CREATE TABLE IF NOT EXISTS users ( | |
| 2 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 3 | + username TEXT NOT NULL UNIQUE COLLATE NOCASE, | |
| 4 | + email TEXT NOT NULL UNIQUE COLLATE NOCASE, | |
| 5 | + password_hash TEXT NOT NULL, | |
| 6 | + bio TEXT NOT NULL DEFAULT '', | |
| 7 | + is_admin INTEGER NOT NULL DEFAULT 0, | |
| 8 | + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | |
| 9 | +); | |
| 10 | + | |
| 11 | +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); | |
| 12 | + | |
| 13 | +CREATE TABLE IF NOT EXISTS sessions ( | |
| 14 | + id TEXT PRIMARY KEY, | |
| 15 | + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 16 | + csrf_token TEXT NOT NULL, | |
| 17 | + expires_at DATETIME NOT NULL, | |
| 18 | + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | |
| 19 | +); | |
| 20 | + | |
| 21 | +CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); | |
| 22 | +CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); | |
| 23 | + | |
| 24 | +CREATE TABLE IF NOT EXISTS access_tokens ( | |
| 25 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 26 | + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 27 | + name TEXT NOT NULL, | |
| 28 | + token_prefix TEXT NOT NULL DEFAULT '', | |
| 29 | + token_hash TEXT NOT NULL UNIQUE, | |
| 30 | + last_used_at DATETIME, | |
| 31 | + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | |
| 32 | +); | |
| 33 | + | |
| 34 | +CREATE INDEX IF NOT EXISTS idx_access_tokens_user_id ON access_tokens(user_id); | |
| 35 | +CREATE INDEX IF NOT EXISTS idx_access_tokens_hash ON access_tokens(token_hash); | |
| 36 | + | |
| 37 | +CREATE TABLE IF NOT EXISTS repositories ( | |
| 38 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 39 | + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 40 | + name TEXT NOT NULL COLLATE NOCASE, | |
| 41 | + description TEXT NOT NULL DEFAULT '', | |
| 42 | + is_private INTEGER NOT NULL DEFAULT 0, | |
| 43 | + default_branch TEXT NOT NULL DEFAULT 'main', | |
| 44 | + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 45 | + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 46 | + UNIQUE(owner_id, name) | |
| 47 | +); | |
| 48 | + | |
| 49 | +CREATE INDEX IF NOT EXISTS idx_repositories_owner_id ON repositories(owner_id); | |
| 50 | + | |
| 51 | +CREATE TABLE IF NOT EXISTS settings ( | |
| 52 | + key TEXT PRIMARY KEY, | |
| 53 | + value TEXT NOT NULL | |
| 54 | +); |
internal/db/migrations/002_features.sql
+29
-0
| ... | ... | @@ -0,0 +1,29 @@ |
| 1 | +ALTER TABLE users ADD COLUMN full_name TEXT NOT NULL DEFAULT ''; | |
| 2 | +ALTER TABLE users ADD COLUMN avatar_url TEXT NOT NULL DEFAULT ''; | |
| 3 | +ALTER TABLE users ADD COLUMN location TEXT NOT NULL DEFAULT ''; | |
| 4 | +ALTER TABLE users ADD COLUMN website TEXT NOT NULL DEFAULT ''; | |
| 5 | + | |
| 6 | +CREATE TABLE IF NOT EXISTS issues ( | |
| 7 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 8 | + repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, | |
| 9 | + number INTEGER NOT NULL, | |
| 10 | + author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 11 | + title TEXT NOT NULL, | |
| 12 | + body TEXT NOT NULL DEFAULT '', | |
| 13 | + is_closed INTEGER NOT NULL DEFAULT 0, | |
| 14 | + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 15 | + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 16 | + UNIQUE(repo_id, number) | |
| 17 | +); | |
| 18 | + | |
| 19 | +CREATE INDEX IF NOT EXISTS idx_issues_repo_id ON issues(repo_id, is_closed, number DESC); | |
| 20 | + | |
| 21 | +CREATE TABLE IF NOT EXISTS issue_comments ( | |
| 22 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 23 | + issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE, | |
| 24 | + author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 25 | + body TEXT NOT NULL, | |
| 26 | + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | |
| 27 | +); | |
| 28 | + | |
| 29 | +CREATE INDEX IF NOT EXISTS idx_issue_comments_issue_id ON issue_comments(issue_id, created_at ASC); |
internal/db/migrations/003_pulls_releases_repo_settings.sql
+49
-0
| ... | ... | @@ -0,0 +1,49 @@ |
| 1 | +ALTER TABLE repositories ADD COLUMN enable_issues INTEGER NOT NULL DEFAULT 1; | |
| 2 | +ALTER TABLE repositories ADD COLUMN enable_pulls INTEGER NOT NULL DEFAULT 1; | |
| 3 | +ALTER TABLE repositories ADD COLUMN enable_releases INTEGER NOT NULL DEFAULT 1; | |
| 4 | +ALTER TABLE repositories ADD COLUMN enable_activity INTEGER NOT NULL DEFAULT 1; | |
| 5 | + | |
| 6 | +CREATE TABLE IF NOT EXISTS pull_requests ( | |
| 7 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 8 | + repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, | |
| 9 | + number INTEGER NOT NULL, | |
| 10 | + author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 11 | + title TEXT NOT NULL, | |
| 12 | + body TEXT NOT NULL DEFAULT '', | |
| 13 | + base_branch TEXT NOT NULL, | |
| 14 | + head_branch TEXT NOT NULL, | |
| 15 | + status TEXT NOT NULL DEFAULT 'open', | |
| 16 | + merged_by_id INTEGER REFERENCES users(id), | |
| 17 | + merged_at DATETIME, | |
| 18 | + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 19 | + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 20 | + UNIQUE(repo_id, number) | |
| 21 | +); | |
| 22 | + | |
| 23 | +CREATE INDEX IF NOT EXISTS idx_pulls_repo_id ON pull_requests(repo_id, status, number DESC); | |
| 24 | + | |
| 25 | +CREATE TABLE IF NOT EXISTS pull_request_comments ( | |
| 26 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 27 | + pull_id INTEGER NOT NULL REFERENCES pull_requests(id) ON DELETE CASCADE, | |
| 28 | + author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 29 | + body TEXT NOT NULL, | |
| 30 | + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | |
| 31 | +); | |
| 32 | + | |
| 33 | +CREATE INDEX IF NOT EXISTS idx_pull_comments_pull_id ON pull_request_comments(pull_id, created_at ASC); | |
| 34 | + | |
| 35 | +CREATE TABLE IF NOT EXISTS releases ( | |
| 36 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 37 | + repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, | |
| 38 | + author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 39 | + tag_name TEXT NOT NULL, | |
| 40 | + title TEXT NOT NULL, | |
| 41 | + body TEXT NOT NULL DEFAULT '', | |
| 42 | + is_draft INTEGER NOT NULL DEFAULT 0, | |
| 43 | + is_prerelease INTEGER NOT NULL DEFAULT 0, | |
| 44 | + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 45 | + published_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 46 | + UNIQUE(repo_id, tag_name) | |
| 47 | +); | |
| 48 | + | |
| 49 | +CREATE INDEX IF NOT EXISTS idx_releases_repo_id ON releases(repo_id, published_at DESC); |
internal/db/migrations/004_social.sql
+41
-0
| ... | ... | @@ -0,0 +1,41 @@ |
| 1 | +-- Collaborators: per-repository access grants for other users. | |
| 2 | +CREATE TABLE IF NOT EXISTS collaborators ( | |
| 3 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 4 | + repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, | |
| 5 | + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 6 | + role TEXT NOT NULL DEFAULT 'write', | |
| 7 | + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 8 | + UNIQUE(repo_id, user_id) | |
| 9 | +); | |
| 10 | +CREATE INDEX IF NOT EXISTS idx_collaborators_user ON collaborators(user_id); | |
| 11 | + | |
| 12 | +-- Forks. | |
| 13 | +ALTER TABLE repositories ADD COLUMN forked_from_repo_id INTEGER REFERENCES repositories(id) ON DELETE SET NULL; | |
| 14 | + | |
| 15 | +-- Fork-based (cross-repository) pull requests. | |
| 16 | +ALTER TABLE pull_requests ADD COLUMN head_repo_id INTEGER REFERENCES repositories(id) ON DELETE CASCADE; | |
| 17 | + | |
| 18 | +-- Pull request reviews (approve / request changes). | |
| 19 | +CREATE TABLE IF NOT EXISTS pull_request_reviews ( | |
| 20 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 21 | + pull_id INTEGER NOT NULL REFERENCES pull_requests(id) ON DELETE CASCADE, | |
| 22 | + reviewer_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 23 | + state TEXT NOT NULL, | |
| 24 | + body TEXT NOT NULL DEFAULT '', | |
| 25 | + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 26 | + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 27 | + UNIQUE(pull_id, reviewer_id) | |
| 28 | +); | |
| 29 | + | |
| 30 | +-- Device authorization grants (RFC 8628) used by the git credential helper. | |
| 31 | +CREATE TABLE IF NOT EXISTS device_codes ( | |
| 32 | + device_code TEXT PRIMARY KEY, | |
| 33 | + user_code TEXT NOT NULL UNIQUE, | |
| 34 | + client_id TEXT NOT NULL DEFAULT '', | |
| 35 | + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, | |
| 36 | + status TEXT NOT NULL DEFAULT 'pending', | |
| 37 | + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 38 | + expires_at DATETIME NOT NULL, | |
| 39 | + last_polled_at DATETIME | |
| 40 | +); | |
| 41 | +CREATE INDEX IF NOT EXISTS idx_device_codes_expires ON device_codes(expires_at); |
internal/git/git.go
+1040
-0
| ... | ... | @@ -0,0 +1,1040 @@ |
| 1 | +package git | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "bytes" | |
| 5 | + "context" | |
| 6 | + "encoding/binary" | |
| 7 | + "errors" | |
| 8 | + "fmt" | |
| 9 | + "os" | |
| 10 | + "os/exec" | |
| 11 | + "path" | |
| 12 | + "path/filepath" | |
| 13 | + "sort" | |
| 14 | + "strconv" | |
| 15 | + "strings" | |
| 16 | + "time" | |
| 17 | + "unicode" | |
| 18 | + "unicode/utf16" | |
| 19 | + "unicode/utf8" | |
| 20 | +) | |
| 21 | + | |
| 22 | +var ( | |
| 23 | + ErrInvalidRef = errors.New("invalid git reference") | |
| 24 | + ErrInvalidPath = errors.New("invalid repository file path") | |
| 25 | + ErrNotFound = errors.New("git object not found") | |
| 26 | + ErrEmptyRepo = errors.New("repository is empty") | |
| 27 | +) | |
| 28 | + | |
| 29 | +type TreeEntry struct { | |
| 30 | + Mode string | |
| 31 | + Type string // "tree" or "blob" | |
| 32 | + Hash string | |
| 33 | + Size int64 | |
| 34 | + Name string | |
| 35 | + Path string | |
| 36 | + CommitHash string | |
| 37 | + CommitMsg string | |
| 38 | + CommitTime time.Time | |
| 39 | +} | |
| 40 | + | |
| 41 | +type Blob struct { | |
| 42 | + Name string | |
| 43 | + Path string | |
| 44 | + Size int64 | |
| 45 | + Data []byte | |
| 46 | + IsBinary bool | |
| 47 | + LineCount int | |
| 48 | +} | |
| 49 | + | |
| 50 | +type Commit struct { | |
| 51 | + Hash string | |
| 52 | + ShortHash string | |
| 53 | + AuthorName string | |
| 54 | + AuthorEmail string | |
| 55 | + AuthoredAt time.Time | |
| 56 | + Subject string | |
| 57 | + Body string | |
| 58 | + Parents []string | |
| 59 | +} | |
| 60 | + | |
| 61 | +type DiffLine struct { | |
| 62 | + Type string // "add", "del", "ctx", "hunk" | |
| 63 | + Content string | |
| 64 | + OldNum int | |
| 65 | + NewNum int | |
| 66 | +} | |
| 67 | + | |
| 68 | +type FileDiff struct { | |
| 69 | + OldPath string | |
| 70 | + NewPath string | |
| 71 | + Status string // "added", "deleted", "modified", "renamed" | |
| 72 | + Additions int | |
| 73 | + Deletions int | |
| 74 | + IsBinary bool | |
| 75 | + Lines []DiffLine | |
| 76 | +} | |
| 77 | + | |
| 78 | +type CommitDetail struct { | |
| 79 | + Commit Commit | |
| 80 | + FilesChanged int | |
| 81 | + TotalAdditions int | |
| 82 | + TotalDeletions int | |
| 83 | + Files []FileDiff | |
| 84 | +} | |
| 85 | + | |
| 86 | +func ValidateRef(ref string) error { | |
| 87 | + ref = strings.TrimSpace(ref) | |
| 88 | + if ref == "" || len(ref) > 255 { | |
| 89 | + return ErrInvalidRef | |
| 90 | + } | |
| 91 | + if strings.HasPrefix(ref, "-") || strings.Contains(ref, "..") || | |
| 92 | + strings.ContainsAny(ref, " \t\n\r~^:?*[\\`\x00") || | |
| 93 | + strings.HasSuffix(ref, ".lock") || strings.HasSuffix(ref, "/") { | |
| 94 | + return ErrInvalidRef | |
| 95 | + } | |
| 96 | + return nil | |
| 97 | +} | |
| 98 | + | |
| 99 | +func CleanSubPath(p string) (string, error) { | |
| 100 | + p = strings.TrimSpace(strings.ReplaceAll(p, "\\", "/")) | |
| 101 | + p = strings.TrimPrefix(p, "/") | |
| 102 | + p = strings.TrimSuffix(p, "/") | |
| 103 | + if p == "" || p == "." { | |
| 104 | + return "", nil | |
| 105 | + } | |
| 106 | + if strings.Contains(p, "\x00") || strings.HasPrefix(p, "-") { | |
| 107 | + return "", ErrInvalidPath | |
| 108 | + } | |
| 109 | + cleaned := path.Clean(p) | |
| 110 | + if cleaned == "." { | |
| 111 | + return "", nil | |
| 112 | + } | |
| 113 | + if strings.HasPrefix(cleaned, "..") || strings.Contains(cleaned, "/../") { | |
| 114 | + return "", ErrInvalidPath | |
| 115 | + } | |
| 116 | + return cleaned, nil | |
| 117 | +} | |
| 118 | + | |
| 119 | +func runGit(ctx context.Context, repoPath string, timeout time.Duration, args ...string) ([]byte, error) { | |
| 120 | + cctx, cancel := context.WithTimeout(ctx, timeout) | |
| 121 | + defer cancel() | |
| 122 | + | |
| 123 | + fullArgs := make([]string, 0, len(args)+2) | |
| 124 | + if repoPath != "" { | |
| 125 | + fullArgs = append(fullArgs, "-C", repoPath) | |
| 126 | + } | |
| 127 | + fullArgs = append(fullArgs, args...) | |
| 128 | + | |
| 129 | + cmd := exec.CommandContext(cctx, "git", fullArgs...) | |
| 130 | + cmd.Env = append(os.Environ(), | |
| 131 | + "GIT_TERMINAL_PROMPT=0", | |
| 132 | + "LC_ALL=C", | |
| 133 | + ) | |
| 134 | + var stdout, stderr bytes.Buffer | |
| 135 | + cmd.Stdout = &stdout | |
| 136 | + cmd.Stderr = &stderr | |
| 137 | + | |
| 138 | + if err := cmd.Run(); err != nil { | |
| 139 | + return nil, fmt.Errorf("git %v: %w (%s)", args, err, strings.TrimSpace(stderr.String())) | |
| 140 | + } | |
| 141 | + return stdout.Bytes(), nil | |
| 142 | +} | |
| 143 | + | |
| 144 | +func InitBare(ctx context.Context, repoPath, defaultBranch string) error { | |
| 145 | + if defaultBranch == "" { | |
| 146 | + defaultBranch = "main" | |
| 147 | + } | |
| 148 | + if err := ValidateRef(defaultBranch); err != nil { | |
| 149 | + defaultBranch = "main" | |
| 150 | + } | |
| 151 | + if err := os.MkdirAll(filepath.Dir(repoPath), 0o755); err != nil { | |
| 152 | + return fmt.Errorf("mkdir repo parent: %w", err) | |
| 153 | + } | |
| 154 | + | |
| 155 | + _, err := runGit(ctx, "", 10*time.Second, "init", "--bare", "--initial-branch="+defaultBranch, repoPath) | |
| 156 | + if err != nil { | |
| 157 | + if _, err2 := runGit(ctx, "", 10*time.Second, "init", "--bare", repoPath); err2 != nil { | |
| 158 | + return fmt.Errorf("init bare repo: %w", err2) | |
| 159 | + } | |
| 160 | + } | |
| 161 | + _, _ = runGit(ctx, repoPath, 5*time.Second, "symbolic-ref", "HEAD", "refs/heads/"+defaultBranch) | |
| 162 | + _, _ = runGit(ctx, repoPath, 5*time.Second, "config", "http.receivepack", "true") | |
| 163 | + _, _ = runGit(ctx, repoPath, 5*time.Second, "config", "http.uploadpack", "true") | |
| 164 | + return nil | |
| 165 | +} | |
| 166 | + | |
| 167 | +func EnsureDefaultBranch(ctx context.Context, repoPath, preferred string) (string, bool) { | |
| 168 | + if preferred == "" { | |
| 169 | + preferred = "main" | |
| 170 | + } | |
| 171 | + branches, err := ListBranches(ctx, repoPath) | |
| 172 | + if err != nil || len(branches) == 0 { | |
| 173 | + return preferred, true | |
| 174 | + } | |
| 175 | + | |
| 176 | + out, err := runGit(ctx, repoPath, 3*time.Second, "symbolic-ref", "--short", "HEAD") | |
| 177 | + headBranch := strings.TrimSpace(string(out)) | |
| 178 | + if err == nil && headBranch != "" { | |
| 179 | + for _, b := range branches { | |
| 180 | + if b == headBranch { | |
| 181 | + return headBranch, false | |
| 182 | + } | |
| 183 | + } | |
| 184 | + } | |
| 185 | + | |
| 186 | + chosen := branches[0] | |
| 187 | + for _, b := range branches { | |
| 188 | + if b == preferred { | |
| 189 | + chosen = preferred | |
| 190 | + break | |
| 191 | + } | |
| 192 | + } | |
| 193 | + _, _ = runGit(ctx, repoPath, 3*time.Second, "symbolic-ref", "HEAD", "refs/heads/"+chosen) | |
| 194 | + return chosen, false | |
| 195 | +} | |
| 196 | + | |
| 197 | +func ListBranches(ctx context.Context, repoPath string) ([]string, error) { | |
| 198 | + out, err := runGit(ctx, repoPath, 5*time.Second, "for-each-ref", "--format=%(refname:short)", "refs/heads/") | |
| 199 | + if err != nil { | |
| 200 | + return nil, err | |
| 201 | + } | |
| 202 | + return splitNonEmptyLines(string(out)), nil | |
| 203 | +} | |
| 204 | + | |
| 205 | +func ListTags(ctx context.Context, repoPath string) ([]string, error) { | |
| 206 | + out, err := runGit(ctx, repoPath, 5*time.Second, "for-each-ref", "--sort=-creatordate", "--format=%(refname:short)", "refs/tags/") | |
| 207 | + if err != nil { | |
| 208 | + return nil, err | |
| 209 | + } | |
| 210 | + return splitNonEmptyLines(string(out)), nil | |
| 211 | +} | |
| 212 | + | |
| 213 | +func ListTree(ctx context.Context, repoPath, ref, subPath string) ([]TreeEntry, error) { | |
| 214 | + if err := ValidateRef(ref); err != nil { | |
| 215 | + return nil, err | |
| 216 | + } | |
| 217 | + cleanPath, err := CleanSubPath(subPath) | |
| 218 | + if err != nil { | |
| 219 | + return nil, err | |
| 220 | + } | |
| 221 | + | |
| 222 | + target := ref | |
| 223 | + if cleanPath != "" { | |
| 224 | + target = ref + ":" + cleanPath | |
| 225 | + } | |
| 226 | + | |
| 227 | + out, err := runGit(ctx, repoPath, 10*time.Second, "ls-tree", "-l", target) | |
| 228 | + if err != nil { | |
| 229 | + if strings.Contains(err.Error(), "Not a valid object name") || strings.Contains(err.Error(), "bad file") { | |
| 230 | + return nil, ErrNotFound | |
| 231 | + } | |
| 232 | + return nil, err | |
| 233 | + } | |
| 234 | + | |
| 235 | + lines := splitNonEmptyLines(string(out)) | |
| 236 | + entries := make([]TreeEntry, 0, len(lines)) | |
| 237 | + for _, line := range lines { | |
| 238 | + // Format: <mode> <type> <object> <size>\t<file> | |
| 239 | + tabIdx := strings.IndexByte(line, '\t') | |
| 240 | + if tabIdx == -1 { | |
| 241 | + continue | |
| 242 | + } | |
| 243 | + meta := strings.Fields(line[:tabIdx]) | |
| 244 | + name := line[tabIdx+1:] | |
| 245 | + if len(meta) < 4 { | |
| 246 | + continue | |
| 247 | + } | |
| 248 | + var size int64 | |
| 249 | + if meta[3] != "-" { | |
| 250 | + size, _ = strconv.ParseInt(meta[3], 10, 64) | |
| 251 | + } | |
| 252 | + entryPath := name | |
| 253 | + if cleanPath != "" { | |
| 254 | + entryPath = cleanPath + "/" + name | |
| 255 | + } | |
| 256 | + entries = append(entries, TreeEntry{ | |
| 257 | + Mode: meta[0], | |
| 258 | + Type: meta[1], | |
| 259 | + Hash: meta[2], | |
| 260 | + Size: size, | |
| 261 | + Name: name, | |
| 262 | + Path: entryPath, | |
| 263 | + }) | |
| 264 | + } | |
| 265 | + | |
| 266 | + sort.Slice(entries, func(i, j int) bool { | |
| 267 | + if entries[i].Type != entries[j].Type { | |
| 268 | + return entries[i].Type == "tree" | |
| 269 | + } | |
| 270 | + return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name) | |
| 271 | + }) | |
| 272 | + | |
| 273 | + // Populate last commit summary for up to 50 entries | |
| 274 | + if len(entries) <= 50 { | |
| 275 | + for i := range entries { | |
| 276 | + if c, err := GetLatestCommit(ctx, repoPath, ref, entries[i].Path); err == nil && c != nil { | |
| 277 | + entries[i].CommitHash = c.ShortHash | |
| 278 | + entries[i].CommitMsg = c.Subject | |
| 279 | + entries[i].CommitTime = c.AuthoredAt | |
| 280 | + } | |
| 281 | + } | |
| 282 | + } | |
| 283 | + | |
| 284 | + return entries, nil | |
| 285 | +} | |
| 286 | + | |
| 287 | +func ListAllFiles(ctx context.Context, repoPath, ref string) ([]string, error) { | |
| 288 | + if err := ValidateRef(ref); err != nil { | |
| 289 | + return nil, err | |
| 290 | + } | |
| 291 | + out, err := runGit(ctx, repoPath, 10*time.Second, "ls-tree", "-r", "--name-only", ref) | |
| 292 | + if err != nil { | |
| 293 | + return nil, err | |
| 294 | + } | |
| 295 | + files := splitNonEmptyLines(string(out)) | |
| 296 | + if len(files) > 1000 { | |
| 297 | + files = files[:1000] | |
| 298 | + } | |
| 299 | + return files, nil | |
| 300 | +} | |
| 301 | + | |
| 302 | +func ReadBlob(ctx context.Context, repoPath, ref, filePath string) (*Blob, error) { | |
| 303 | + if err := ValidateRef(ref); err != nil { | |
| 304 | + return nil, err | |
| 305 | + } | |
| 306 | + cleanPath, err := CleanSubPath(filePath) | |
| 307 | + if err != nil || cleanPath == "" { | |
| 308 | + return nil, ErrInvalidPath | |
| 309 | + } | |
| 310 | + | |
| 311 | + spec := ref + ":" + cleanPath | |
| 312 | + typeOut, err := runGit(ctx, repoPath, 5*time.Second, "cat-file", "-t", spec) | |
| 313 | + if err != nil { | |
| 314 | + return nil, ErrNotFound | |
| 315 | + } | |
| 316 | + if strings.TrimSpace(string(typeOut)) != "blob" { | |
| 317 | + return nil, ErrNotFound | |
| 318 | + } | |
| 319 | + | |
| 320 | + data, err := runGit(ctx, repoPath, 10*time.Second, "cat-file", "-p", spec) | |
| 321 | + if err != nil { | |
| 322 | + return nil, ErrNotFound | |
| 323 | + } | |
| 324 | + | |
| 325 | + data = normalizeTextEncoding(data) | |
| 326 | + | |
| 327 | + checkLen := len(data) | |
| 328 | + if checkLen > 8000 { | |
| 329 | + checkLen = 8000 | |
| 330 | + } | |
| 331 | + isBinary := bytes.IndexByte(data[:checkLen], 0) != -1 || !utf8.Valid(data[:checkLen]) | |
| 332 | + | |
| 333 | + lineCount := 0 | |
| 334 | + if !isBinary && len(data) > 0 { | |
| 335 | + lineCount = bytes.Count(data, []byte{'\n'}) | |
| 336 | + if data[len(data)-1] != '\n' { | |
| 337 | + lineCount++ | |
| 338 | + } | |
| 339 | + } | |
| 340 | + | |
| 341 | + return &Blob{ | |
| 342 | + Name: path.Base(cleanPath), | |
| 343 | + Path: cleanPath, | |
| 344 | + Size: int64(len(data)), | |
| 345 | + Data: data, | |
| 346 | + IsBinary: isBinary, | |
| 347 | + LineCount: lineCount, | |
| 348 | + }, nil | |
| 349 | +} | |
| 350 | + | |
| 351 | +func FindReadme(ctx context.Context, repoPath, ref, subPath string) (*Blob, error) { | |
| 352 | + candidates := []string{"README.md", "readme.md", "Readme.md", "README.markdown", "README.txt", "README"} | |
| 353 | + cleanSub, err := CleanSubPath(subPath) | |
| 354 | + if err != nil { | |
| 355 | + return nil, err | |
| 356 | + } | |
| 357 | + for _, name := range candidates { | |
| 358 | + p := name | |
| 359 | + if cleanSub != "" { | |
| 360 | + p = cleanSub + "/" + name | |
| 361 | + } | |
| 362 | + if blob, err := ReadBlob(ctx, repoPath, ref, p); err == nil && !blob.IsBinary { | |
| 363 | + return blob, nil | |
| 364 | + } | |
| 365 | + } | |
| 366 | + return nil, ErrNotFound | |
| 367 | +} | |
| 368 | + | |
| 369 | +// normalizeTextEncoding converts BOM-marked text (UTF-8/UTF-16) to UTF-8 so | |
| 370 | +// files written by Windows tooling (e.g. PowerShell) render as text instead | |
| 371 | +// of being misdetected as binary. | |
| 372 | +func normalizeTextEncoding(data []byte) []byte { | |
| 373 | + if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF { | |
| 374 | + return data[3:] | |
| 375 | + } | |
| 376 | + if len(data) >= 2 && data[0] == 0xFF && data[1] == 0xFE { | |
| 377 | + if len(data) >= 4 && data[2] == 0x00 && data[3] == 0x00 { | |
| 378 | + return data // UTF-32LE is left untouched | |
| 379 | + } | |
| 380 | + return utf16ToUTF8(data[2:], binary.LittleEndian) | |
| 381 | + } | |
| 382 | + if len(data) >= 2 && data[0] == 0xFE && data[1] == 0xFF { | |
| 383 | + return utf16ToUTF8(data[2:], binary.BigEndian) | |
| 384 | + } | |
| 385 | + return data | |
| 386 | +} | |
| 387 | + | |
| 388 | +func utf16ToUTF8(data []byte, order binary.ByteOrder) []byte { | |
| 389 | + runes := make([]rune, 0, len(data)/2) | |
| 390 | + for i := 0; i+1 < len(data); i += 2 { | |
| 391 | + unit := order.Uint16(data[i : i+2]) | |
| 392 | + if unit == 0 { | |
| 393 | + break | |
| 394 | + } | |
| 395 | + if utf16.IsSurrogate(rune(unit)) && i+3 < len(data) { | |
| 396 | + next := order.Uint16(data[i+2 : i+4]) | |
| 397 | + if combined := utf16.DecodeRune(rune(unit), rune(next)); combined != unicode.ReplacementChar { | |
| 398 | + runes = append(runes, combined) | |
| 399 | + i += 2 | |
| 400 | + continue | |
| 401 | + } | |
| 402 | + } | |
| 403 | + runes = append(runes, rune(unit)) | |
| 404 | + } | |
| 405 | + return []byte(string(runes)) | |
| 406 | +} | |
| 407 | + | |
| 408 | +const commitFormat = "%H%x1f%h%x1f%an%x1f%ae%x1f%at%x1f%s%x1f%b%x1f%P%x1e" | |
| 409 | + | |
| 410 | +func GetLatestCommit(ctx context.Context, repoPath, ref, subPath string) (*Commit, error) { | |
| 411 | + if err := ValidateRef(ref); err != nil { | |
| 412 | + return nil, err | |
| 413 | + } | |
| 414 | + args := []string{"log", "-n", "1", "--format=" + commitFormat, ref} | |
| 415 | + if subPath != "" { | |
| 416 | + cleanPath, err := CleanSubPath(subPath) | |
| 417 | + if err != nil { | |
| 418 | + return nil, err | |
| 419 | + } | |
| 420 | + if cleanPath != "" { | |
| 421 | + args = append(args, "--", cleanPath) | |
| 422 | + } | |
| 423 | + } | |
| 424 | + out, err := runGit(ctx, repoPath, 5*time.Second, args...) | |
| 425 | + if err != nil { | |
| 426 | + return nil, err | |
| 427 | + } | |
| 428 | + commits := parseCommits(string(out)) | |
| 429 | + if len(commits) == 0 { | |
| 430 | + return nil, ErrNotFound | |
| 431 | + } | |
| 432 | + return &commits[0], nil | |
| 433 | +} | |
| 434 | + | |
| 435 | +func ListCommits(ctx context.Context, repoPath, ref string, limit, offset int) ([]Commit, error) { | |
| 436 | + if err := ValidateRef(ref); err != nil { | |
| 437 | + return nil, err | |
| 438 | + } | |
| 439 | + if limit <= 0 || limit > 100 { | |
| 440 | + limit = 30 | |
| 441 | + } | |
| 442 | + if offset < 0 { | |
| 443 | + offset = 0 | |
| 444 | + } | |
| 445 | + out, err := runGit(ctx, repoPath, 10*time.Second, | |
| 446 | + "log", | |
| 447 | + "--max-count="+strconv.Itoa(limit), | |
| 448 | + "--skip="+strconv.Itoa(offset), | |
| 449 | + "--format="+commitFormat, | |
| 450 | + ref, | |
| 451 | + "--", | |
| 452 | + ) | |
| 453 | + if err != nil { | |
| 454 | + return nil, err | |
| 455 | + } | |
| 456 | + return parseCommits(string(out)), nil | |
| 457 | +} | |
| 458 | + | |
| 459 | +func GetCommitDiff(ctx context.Context, repoPath, commitHash string) (*CommitDetail, error) { | |
| 460 | + if err := ValidateRef(commitHash); err != nil { | |
| 461 | + return nil, err | |
| 462 | + } | |
| 463 | + | |
| 464 | + metaOut, err := runGit(ctx, repoPath, 5*time.Second, "show", "-s", "--format="+commitFormat, commitHash) | |
| 465 | + if err != nil { | |
| 466 | + return nil, ErrNotFound | |
| 467 | + } | |
| 468 | + commits := parseCommits(string(metaOut)) | |
| 469 | + if len(commits) == 0 { | |
| 470 | + return nil, ErrNotFound | |
| 471 | + } | |
| 472 | + commit := commits[0] | |
| 473 | + | |
| 474 | + diffOut, err := runGit(ctx, repoPath, 15*time.Second, "show", "--patch", "--find-renames", "--format=", commitHash) | |
| 475 | + if err != nil { | |
| 476 | + return nil, err | |
| 477 | + } | |
| 478 | + | |
| 479 | + files, adds, dels := parseUnifiedDiff(string(diffOut)) | |
| 480 | + return &CommitDetail{ | |
| 481 | + Commit: commit, | |
| 482 | + FilesChanged: len(files), | |
| 483 | + TotalAdditions: adds, | |
| 484 | + TotalDeletions: dels, | |
| 485 | + Files: files, | |
| 486 | + }, nil | |
| 487 | +} | |
| 488 | + | |
| 489 | +func parseCommits(raw string) []Commit { | |
| 490 | + records := strings.Split(raw, "\x1e") | |
| 491 | + var commits []Commit | |
| 492 | + for _, rec := range records { | |
| 493 | + rec = strings.TrimSpace(rec) | |
| 494 | + if rec == "" { | |
| 495 | + continue | |
| 496 | + } | |
| 497 | + fields := strings.Split(rec, "\x1f") | |
| 498 | + if len(fields) < 7 { | |
| 499 | + continue | |
| 500 | + } | |
| 501 | + ts, _ := strconv.ParseInt(strings.TrimSpace(fields[4]), 10, 64) | |
| 502 | + var parents []string | |
| 503 | + if len(fields) >= 8 && strings.TrimSpace(fields[7]) != "" { | |
| 504 | + parents = strings.Fields(fields[7]) | |
| 505 | + } | |
| 506 | + commits = append(commits, Commit{ | |
| 507 | + Hash: strings.TrimSpace(fields[0]), | |
| 508 | + ShortHash: strings.TrimSpace(fields[1]), | |
| 509 | + AuthorName: strings.TrimSpace(fields[2]), | |
| 510 | + AuthorEmail: strings.TrimSpace(fields[3]), | |
| 511 | + AuthoredAt: time.Unix(ts, 0).UTC(), | |
| 512 | + Subject: strings.TrimSpace(fields[5]), | |
| 513 | + Body: strings.TrimSpace(fields[6]), | |
| 514 | + Parents: parents, | |
| 515 | + }) | |
| 516 | + } | |
| 517 | + return commits | |
| 518 | +} | |
| 519 | + | |
| 520 | +func parseUnifiedDiff(raw string) ([]FileDiff, int, int) { | |
| 521 | + var files []FileDiff | |
| 522 | + var current *FileDiff | |
| 523 | + totalAdds := 0 | |
| 524 | + totalDels := 0 | |
| 525 | + | |
| 526 | + oldLine := 0 | |
| 527 | + newLine := 0 | |
| 528 | + | |
| 529 | + lines := strings.Split(raw, "\n") | |
| 530 | + for _, line := range lines { | |
| 531 | + line = strings.TrimSuffix(line, "\r") | |
| 532 | + if strings.HasPrefix(line, "diff --git ") { | |
| 533 | + if current != nil { | |
| 534 | + files = append(files, *current) | |
| 535 | + } | |
| 536 | + oldP, newP := parseDiffGitHeader(line) | |
| 537 | + current = &FileDiff{ | |
| 538 | + OldPath: oldP, | |
| 539 | + NewPath: newP, | |
| 540 | + Status: "modified", | |
| 541 | + } | |
| 542 | + continue | |
| 543 | + } | |
| 544 | + if current == nil { | |
| 545 | + continue | |
| 546 | + } | |
| 547 | + | |
| 548 | + if strings.HasPrefix(line, "new file mode ") { | |
| 549 | + current.Status = "added" | |
| 550 | + continue | |
| 551 | + } | |
| 552 | + if strings.HasPrefix(line, "deleted file mode ") { | |
| 553 | + current.Status = "deleted" | |
| 554 | + continue | |
| 555 | + } | |
| 556 | + if strings.HasPrefix(line, "rename from ") { | |
| 557 | + current.Status = "renamed" | |
| 558 | + current.OldPath = strings.TrimPrefix(line, "rename from ") | |
| 559 | + continue | |
| 560 | + } | |
| 561 | + if strings.HasPrefix(line, "rename to ") { | |
| 562 | + current.Status = "renamed" | |
| 563 | + current.NewPath = strings.TrimPrefix(line, "rename to ") | |
| 564 | + continue | |
| 565 | + } | |
| 566 | + if strings.HasPrefix(line, "Binary files ") { | |
| 567 | + current.IsBinary = true | |
| 568 | + continue | |
| 569 | + } | |
| 570 | + if strings.HasPrefix(line, "index ") || strings.HasPrefix(line, "--- ") || strings.HasPrefix(line, "+++ ") { | |
| 571 | + continue | |
| 572 | + } | |
| 573 | + | |
| 574 | + if strings.HasPrefix(line, "@@ ") { | |
| 575 | + oldLine, newLine = parseHunkHeader(line) | |
| 576 | + current.Lines = append(current.Lines, DiffLine{ | |
| 577 | + Type: "hunk", | |
| 578 | + Content: line, | |
| 579 | + }) | |
| 580 | + continue | |
| 581 | + } | |
| 582 | + | |
| 583 | + if len(current.Lines) == 0 { | |
| 584 | + continue | |
| 585 | + } | |
| 586 | + | |
| 587 | + if strings.HasPrefix(line, "+") { | |
| 588 | + current.Additions++ | |
| 589 | + totalAdds++ | |
| 590 | + current.Lines = append(current.Lines, DiffLine{ | |
| 591 | + Type: "add", | |
| 592 | + Content: line[1:], | |
| 593 | + NewNum: newLine, | |
| 594 | + }) | |
| 595 | + newLine++ | |
| 596 | + } else if strings.HasPrefix(line, "-") { | |
| 597 | + current.Deletions++ | |
| 598 | + totalDels++ | |
| 599 | + current.Lines = append(current.Lines, DiffLine{ | |
| 600 | + Type: "del", | |
| 601 | + Content: line[1:], | |
| 602 | + OldNum: oldLine, | |
| 603 | + }) | |
| 604 | + oldLine++ | |
| 605 | + } else if strings.HasPrefix(line, " ") { | |
| 606 | + current.Lines = append(current.Lines, DiffLine{ | |
| 607 | + Type: "ctx", | |
| 608 | + Content: line[1:], | |
| 609 | + OldNum: oldLine, | |
| 610 | + NewNum: newLine, | |
| 611 | + }) | |
| 612 | + oldLine++ | |
| 613 | + newLine++ | |
| 614 | + } | |
| 615 | + } | |
| 616 | + | |
| 617 | + if current != nil { | |
| 618 | + files = append(files, *current) | |
| 619 | + } | |
| 620 | + return files, totalAdds, totalDels | |
| 621 | +} | |
| 622 | + | |
| 623 | +func parseDiffGitHeader(line string) (string, string) { | |
| 624 | + // diff --git a/foo b/foo | |
| 625 | + rest := strings.TrimPrefix(line, "diff --git ") | |
| 626 | + parts := strings.SplitN(rest, " b/", 2) | |
| 627 | + if len(parts) == 2 { | |
| 628 | + return strings.TrimPrefix(parts[0], "a/"), parts[1] | |
| 629 | + } | |
| 630 | + return rest, rest | |
| 631 | +} | |
| 632 | + | |
| 633 | +func parseHunkHeader(line string) (int, int) { | |
| 634 | + // @@ -oldStart,oldCount +newStart,newCount @@ | |
| 635 | + oldStart, newStart := 1, 1 | |
| 636 | + endIdx := strings.Index(line[3:], "@@") | |
| 637 | + if endIdx == -1 { | |
| 638 | + return oldStart, newStart | |
| 639 | + } | |
| 640 | + header := strings.TrimSpace(line[3 : 3+endIdx]) | |
| 641 | + fields := strings.Fields(header) | |
| 642 | + for _, f := range fields { | |
| 643 | + if strings.HasPrefix(f, "-") { | |
| 644 | + nums := strings.SplitN(f[1:], ",", 2) | |
| 645 | + if v, err := strconv.Atoi(nums[0]); err == nil { | |
| 646 | + oldStart = v | |
| 647 | + } | |
| 648 | + } else if strings.HasPrefix(f, "+") { | |
| 649 | + nums := strings.SplitN(f[1:], ",", 2) | |
| 650 | + if v, err := strconv.Atoi(nums[0]); err == nil { | |
| 651 | + newStart = v | |
| 652 | + } | |
| 653 | + } | |
| 654 | + } | |
| 655 | + return oldStart, newStart | |
| 656 | +} | |
| 657 | + | |
| 658 | +func splitNonEmptyLines(s string) []string { | |
| 659 | + raw := strings.Split(s, "\n") | |
| 660 | + out := make([]string, 0, len(raw)) | |
| 661 | + for _, l := range raw { | |
| 662 | + l = strings.TrimSpace(l) | |
| 663 | + if l != "" { | |
| 664 | + out = append(out, l) | |
| 665 | + } | |
| 666 | + } | |
| 667 | + return out | |
| 668 | +} | |
| 669 | + | |
| 670 | +type BranchInfo struct { | |
| 671 | + Name string | |
| 672 | + Hash string | |
| 673 | + ShortHash string | |
| 674 | + Subject string | |
| 675 | + AuthorName string | |
| 676 | + UpdatedAt time.Time | |
| 677 | + IsDefault bool | |
| 678 | +} | |
| 679 | + | |
| 680 | +type TagInfo struct { | |
| 681 | + Name string | |
| 682 | + Hash string | |
| 683 | + ShortHash string | |
| 684 | + Subject string | |
| 685 | + CreatedAt time.Time | |
| 686 | +} | |
| 687 | + | |
| 688 | +func ListBranchDetails(ctx context.Context, repoPath, defaultBranch string) ([]BranchInfo, error) { | |
| 689 | + out, err := runGit(ctx, repoPath, 5*time.Second, | |
| 690 | + "for-each-ref", | |
| 691 | + "--sort=-committerdate", | |
| 692 | + "--format=%(refname:short)%00%(objectname)%00%(objectname:short)%00%(contents:subject)%00%(authorname)%00%(committerdate:unix)", | |
| 693 | + "refs/heads/", | |
| 694 | + ) | |
| 695 | + if err != nil { | |
| 696 | + return nil, err | |
| 697 | + } | |
| 698 | + lines := splitNonEmptyLines(string(out)) | |
| 699 | + res := make([]BranchInfo, 0, len(lines)) | |
| 700 | + for _, l := range lines { | |
| 701 | + parts := strings.Split(l, "\x00") | |
| 702 | + if len(parts) < 6 { | |
| 703 | + continue | |
| 704 | + } | |
| 705 | + ts, _ := strconv.ParseInt(parts[5], 10, 64) | |
| 706 | + var tm time.Time | |
| 707 | + if ts > 0 { | |
| 708 | + tm = time.Unix(ts, 0).UTC() | |
| 709 | + } | |
| 710 | + res = append(res, BranchInfo{ | |
| 711 | + Name: parts[0], | |
| 712 | + Hash: parts[1], | |
| 713 | + ShortHash: parts[2], | |
| 714 | + Subject: parts[3], | |
| 715 | + AuthorName: parts[4], | |
| 716 | + UpdatedAt: tm, | |
| 717 | + IsDefault: parts[0] == defaultBranch, | |
| 718 | + }) | |
| 719 | + } | |
| 720 | + sort.SliceStable(res, func(i, j int) bool { | |
| 721 | + if res[i].IsDefault != res[j].IsDefault { | |
| 722 | + return res[i].IsDefault | |
| 723 | + } | |
| 724 | + return res[i].UpdatedAt.After(res[j].UpdatedAt) | |
| 725 | + }) | |
| 726 | + return res, nil | |
| 727 | +} | |
| 728 | + | |
| 729 | +func ListTagDetails(ctx context.Context, repoPath string) ([]TagInfo, error) { | |
| 730 | + out, err := runGit(ctx, repoPath, 5*time.Second, | |
| 731 | + "for-each-ref", | |
| 732 | + "--sort=-creatordate", | |
| 733 | + "--format=%(refname:short)%00%(objectname)%00%(objectname:short)%00%(contents:subject)%00%(creatordate:unix)", | |
| 734 | + "refs/tags/", | |
| 735 | + ) | |
| 736 | + if err != nil { | |
| 737 | + return nil, err | |
| 738 | + } | |
| 739 | + lines := splitNonEmptyLines(string(out)) | |
| 740 | + res := make([]TagInfo, 0, len(lines)) | |
| 741 | + for _, l := range lines { | |
| 742 | + parts := strings.Split(l, "\x00") | |
| 743 | + if len(parts) < 5 { | |
| 744 | + continue | |
| 745 | + } | |
| 746 | + ts, _ := strconv.ParseInt(parts[4], 10, 64) | |
| 747 | + var tm time.Time | |
| 748 | + if ts > 0 { | |
| 749 | + tm = time.Unix(ts, 0).UTC() | |
| 750 | + } | |
| 751 | + res = append(res, TagInfo{ | |
| 752 | + Name: parts[0], | |
| 753 | + Hash: parts[1], | |
| 754 | + ShortHash: parts[2], | |
| 755 | + Subject: parts[3], | |
| 756 | + CreatedAt: tm, | |
| 757 | + }) | |
| 758 | + } | |
| 759 | + return res, nil | |
| 760 | +} | |
| 761 | + | |
| 762 | +func CreateBranch(ctx context.Context, repoPath, branchName, fromRef string) error { | |
| 763 | + branchName = strings.TrimSpace(branchName) | |
| 764 | + fromRef = strings.TrimSpace(fromRef) | |
| 765 | + if err := ValidateRef(branchName); err != nil { | |
| 766 | + return err | |
| 767 | + } | |
| 768 | + if err := ValidateRef(fromRef); err != nil { | |
| 769 | + return err | |
| 770 | + } | |
| 771 | + _, err := runGit(ctx, repoPath, 5*time.Second, "branch", branchName, fromRef) | |
| 772 | + return err | |
| 773 | +} | |
| 774 | + | |
| 775 | +func DeleteBranch(ctx context.Context, repoPath, branchName string) error { | |
| 776 | + branchName = strings.TrimSpace(branchName) | |
| 777 | + if err := ValidateRef(branchName); err != nil { | |
| 778 | + return err | |
| 779 | + } | |
| 780 | + _, err := runGit(ctx, repoPath, 5*time.Second, "branch", "-D", branchName) | |
| 781 | + return err | |
| 782 | +} | |
| 783 | + | |
| 784 | +func CreateTag(ctx context.Context, repoPath, tagName, fromRef, message string) error { | |
| 785 | + tagName = strings.TrimSpace(tagName) | |
| 786 | + fromRef = strings.TrimSpace(fromRef) | |
| 787 | + message = strings.TrimSpace(message) | |
| 788 | + if err := ValidateRef(tagName); err != nil { | |
| 789 | + return err | |
| 790 | + } | |
| 791 | + if err := ValidateRef(fromRef); err != nil { | |
| 792 | + return err | |
| 793 | + } | |
| 794 | + if message != "" { | |
| 795 | + _, err := runGit(ctx, repoPath, 5*time.Second, | |
| 796 | + "-c", "user.name=Polar", | |
| 797 | + "-c", "user.email=polar@localhost", | |
| 798 | + "tag", "-a", tagName, "-m", message, fromRef, | |
| 799 | + ) | |
| 800 | + return err | |
| 801 | + } | |
| 802 | + _, err := runGit(ctx, repoPath, 5*time.Second, "tag", tagName, fromRef) | |
| 803 | + return err | |
| 804 | +} | |
| 805 | + | |
| 806 | +func DeleteTag(ctx context.Context, repoPath, tagName string) error { | |
| 807 | + tagName = strings.TrimSpace(tagName) | |
| 808 | + if err := ValidateRef(tagName); err != nil { | |
| 809 | + return err | |
| 810 | + } | |
| 811 | + _, err := runGit(ctx, repoPath, 5*time.Second, "tag", "-d", tagName) | |
| 812 | + return err | |
| 813 | +} | |
| 814 | + | |
| 815 | +func ArchiveZip(ctx context.Context, repoPath, ref, prefix string) ([]byte, error) { | |
| 816 | + ref = strings.TrimSpace(ref) | |
| 817 | + if err := ValidateRef(ref); err != nil { | |
| 818 | + return nil, err | |
| 819 | + } | |
| 820 | + prefix = strings.Trim(prefix, "/") | |
| 821 | + if prefix == "" { | |
| 822 | + prefix = "repo" | |
| 823 | + } | |
| 824 | + return runGit(ctx, repoPath, 30*time.Second, "archive", "--format=zip", "--prefix="+prefix+"/", ref) | |
| 825 | +} | |
| 826 | + | |
| 827 | +func ArchiveTarGz(ctx context.Context, repoPath, ref, prefix string) ([]byte, error) { | |
| 828 | + ref = strings.TrimSpace(ref) | |
| 829 | + if err := ValidateRef(ref); err != nil { | |
| 830 | + return nil, err | |
| 831 | + } | |
| 832 | + prefix = strings.Trim(prefix, "/") | |
| 833 | + if prefix == "" { | |
| 834 | + prefix = "repo" | |
| 835 | + } | |
| 836 | + return runGit(ctx, repoPath, 30*time.Second, "archive", "--format=tar.gz", "--prefix="+prefix+"/", ref) | |
| 837 | +} | |
| 838 | + | |
| 839 | +func CompareBranches(ctx context.Context, repoPath, baseBranch, headBranch string) (*CommitDetail, []Commit, error) { | |
| 840 | + if err := ValidateRef(baseBranch); err != nil { | |
| 841 | + return nil, nil, err | |
| 842 | + } | |
| 843 | + if err := ValidateRef(headBranch); err != nil { | |
| 844 | + return nil, nil, err | |
| 845 | + } | |
| 846 | + out, err := runGit(ctx, repoPath, 10*time.Second, "log", "--format="+commitFormat, baseBranch+".."+headBranch, "--") | |
| 847 | + if err != nil { | |
| 848 | + return nil, nil, fmt.Errorf("git log base..head: %w", err) | |
| 849 | + } | |
| 850 | + commits := parseCommits(string(out)) | |
| 851 | + | |
| 852 | + diffOut, err := runGit(ctx, repoPath, 15*time.Second, "diff", "--patch", "--find-renames", baseBranch+"..."+headBranch) | |
| 853 | + if err != nil { | |
| 854 | + return nil, nil, fmt.Errorf("git diff base...head: %w", err) | |
| 855 | + } | |
| 856 | + files, adds, dels := parseUnifiedDiff(string(diffOut)) | |
| 857 | + | |
| 858 | + detail := &CommitDetail{ | |
| 859 | + FilesChanged: len(files), | |
| 860 | + TotalAdditions: adds, | |
| 861 | + TotalDeletions: dels, | |
| 862 | + Files: files, | |
| 863 | + } | |
| 864 | + return detail, commits, nil | |
| 865 | +} | |
| 866 | + | |
| 867 | +func CanMergeBranches(ctx context.Context, repoPath, baseBranch, headBranch string) (canMerge bool, isFastForward bool, err error) { | |
| 868 | + if err := ValidateRef(baseBranch); err != nil { | |
| 869 | + return false, false, err | |
| 870 | + } | |
| 871 | + if err := ValidateRef(headBranch); err != nil { | |
| 872 | + return false, false, err | |
| 873 | + } | |
| 874 | + | |
| 875 | + _, err = runGit(ctx, repoPath, 5*time.Second, "merge-base", "--is-ancestor", headBranch, baseBranch) | |
| 876 | + if err == nil { | |
| 877 | + return true, true, nil | |
| 878 | + } | |
| 879 | + | |
| 880 | + _, err = runGit(ctx, repoPath, 5*time.Second, "merge-base", "--is-ancestor", baseBranch, headBranch) | |
| 881 | + if err == nil { | |
| 882 | + return true, true, nil | |
| 883 | + } | |
| 884 | + | |
| 885 | + baseCommit, err := runGit(ctx, repoPath, 5*time.Second, "merge-base", baseBranch, headBranch) | |
| 886 | + if err != nil { | |
| 887 | + return false, false, fmt.Errorf("find merge base: %w", err) | |
| 888 | + } | |
| 889 | + baseHash := strings.TrimSpace(string(baseCommit)) | |
| 890 | + if baseHash == "" { | |
| 891 | + return false, false, errors.New("no common ancestor") | |
| 892 | + } | |
| 893 | + | |
| 894 | + treeOut, err := runGit(ctx, repoPath, 5*time.Second, "merge-tree", baseHash, baseBranch, headBranch) | |
| 895 | + if err != nil { | |
| 896 | + return false, false, nil | |
| 897 | + } | |
| 898 | + if strings.Contains(string(treeOut), "+<<<<<<<") { | |
| 899 | + return false, false, nil | |
| 900 | + } | |
| 901 | + return true, false, nil | |
| 902 | +} | |
| 903 | + | |
| 904 | +func MergeBranches(ctx context.Context, repoPath, baseBranch, headBranch, commitMsg, authorName, authorEmail string) (string, error) { | |
| 905 | + if err := ValidateRef(baseBranch); err != nil { | |
| 906 | + return "", err | |
| 907 | + } | |
| 908 | + if err := ValidateRef(headBranch); err != nil { | |
| 909 | + return "", err | |
| 910 | + } | |
| 911 | + return mergeRefs(ctx, repoPath, baseBranch, headBranch, commitMsg, authorName, authorEmail) | |
| 912 | +} | |
| 913 | + | |
| 914 | +// MergeFromPath fetches headBranch from another local repository (a fork) and merges | |
| 915 | +// it into baseBranch of repoPath. Used to merge cross-repository pull requests. | |
| 916 | +func MergeFromPath(ctx context.Context, repoPath, forkPath, baseBranch, headBranch, tempRef, commitMsg, authorName, authorEmail string) (string, error) { | |
| 917 | + if err := ValidateRef(baseBranch); err != nil { | |
| 918 | + return "", err | |
| 919 | + } | |
| 920 | + if err := ValidateRef(headBranch); err != nil { | |
| 921 | + return "", err | |
| 922 | + } | |
| 923 | + if tempRef == "" { | |
| 924 | + tempRef = "refs/polar/tmp-merge" | |
| 925 | + } | |
| 926 | + if err := FetchToTemp(ctx, repoPath, forkPath, headBranch, tempRef); err != nil { | |
| 927 | + return "", err | |
| 928 | + } | |
| 929 | + defer DeleteRef(context.WithoutCancel(ctx), repoPath, tempRef) | |
| 930 | + return mergeRefs(ctx, repoPath, baseBranch, tempRef, commitMsg, authorName, authorEmail) | |
| 931 | +} | |
| 932 | + | |
| 933 | +// FetchToTemp fetches a branch from another local repository into a temporary ref. | |
| 934 | +func FetchToTemp(ctx context.Context, repoPath, srcPath, branch, tempRef string) error { | |
| 935 | + if err := ValidateRef(branch); err != nil { | |
| 936 | + return err | |
| 937 | + } | |
| 938 | + _, err := runGit(ctx, repoPath, 120*time.Second, "fetch", "--no-tags", "--quiet", srcPath, "+refs/heads/"+branch+":"+tempRef) | |
| 939 | + if err != nil { | |
| 940 | + return fmt.Errorf("fetch branch %s: %w", branch, err) | |
| 941 | + } | |
| 942 | + return nil | |
| 943 | +} | |
| 944 | + | |
| 945 | +// DeleteRef removes a ref; missing refs are ignored. | |
| 946 | +func DeleteRef(ctx context.Context, repoPath, ref string) { | |
| 947 | + _, _ = runGit(ctx, repoPath, 10*time.Second, "update-ref", "-d", ref) | |
| 948 | +} | |
| 949 | + | |
| 950 | +// CloneBare copies a bare repository to a new location (used when creating forks). | |
| 951 | +func CloneBare(ctx context.Context, srcPath, dstPath string) error { | |
| 952 | + if _, err := runGit(ctx, "", 180*time.Second, "clone", "--bare", "--quiet", srcPath, dstPath); err != nil { | |
| 953 | + return fmt.Errorf("clone bare repository: %w", err) | |
| 954 | + } | |
| 955 | + _, _ = runGit(ctx, dstPath, 10*time.Second, "remote", "remove", "origin") | |
| 956 | + return nil | |
| 957 | +} | |
| 958 | + | |
| 959 | +// BranchExists reports whether a local branch exists in a bare repository. | |
| 960 | +func BranchExists(ctx context.Context, repoPath, branch string) bool { | |
| 961 | + if err := ValidateRef(branch); err != nil { | |
| 962 | + return false | |
| 963 | + } | |
| 964 | + _, err := runGit(ctx, repoPath, 5*time.Second, "show-ref", "--verify", "--quiet", "refs/heads/"+branch) | |
| 965 | + return err == nil | |
| 966 | +} | |
| 967 | + | |
| 968 | +// CountCommits returns the number of commits reachable from ref (0 on error). | |
| 969 | +func CountCommits(ctx context.Context, repoPath, ref string) int { | |
| 970 | + if err := ValidateRef(ref); err != nil { | |
| 971 | + return 0 | |
| 972 | + } | |
| 973 | + out, err := runGit(ctx, repoPath, 10*time.Second, "rev-list", "--count", ref) | |
| 974 | + if err != nil { | |
| 975 | + return 0 | |
| 976 | + } | |
| 977 | + n, _ := strconv.Atoi(strings.TrimSpace(string(out))) | |
| 978 | + return n | |
| 979 | +} | |
| 980 | + | |
| 981 | +func mergeRefs(ctx context.Context, repoPath, baseRef, headRef, commitMsg, authorName, authorEmail string) (string, error) { | |
| 982 | + canMerge, isFF, err := CanMergeBranches(ctx, repoPath, baseRef, headRef) | |
| 983 | + if err != nil { | |
| 984 | + return "", err | |
| 985 | + } | |
| 986 | + if !canMerge { | |
| 987 | + return "", errors.New("cannot merge: conflicting changes detected") | |
| 988 | + } | |
| 989 | + | |
| 990 | + headCommit, err := runGit(ctx, repoPath, 5*time.Second, "rev-parse", headRef) | |
| 991 | + if err != nil { | |
| 992 | + return "", fmt.Errorf("rev-parse head: %w", err) | |
| 993 | + } | |
| 994 | + headHash := strings.TrimSpace(string(headCommit)) | |
| 995 | + | |
| 996 | + if isFF { | |
| 997 | + _, err = runGit(ctx, repoPath, 5*time.Second, "update-ref", "refs/heads/"+baseRef, headHash) | |
| 998 | + if err != nil { | |
| 999 | + return "", fmt.Errorf("update-ref fast-forward: %w", err) | |
| 1000 | + } | |
| 1001 | + return headHash, nil | |
| 1002 | + } | |
| 1003 | + | |
| 1004 | + wtOut, err := runGit(ctx, repoPath, 5*time.Second, "merge-tree", "--write-tree", baseRef, headRef) | |
| 1005 | + var treeHash string | |
| 1006 | + if err == nil && len(strings.TrimSpace(string(wtOut))) == 40 { | |
| 1007 | + treeHash = strings.TrimSpace(string(wtOut)) | |
| 1008 | + } else { | |
| 1009 | + headTree, _ := runGit(ctx, repoPath, 5*time.Second, "rev-parse", headRef+"^{tree}") | |
| 1010 | + treeHash = strings.TrimSpace(string(headTree)) | |
| 1011 | + } | |
| 1012 | + | |
| 1013 | + baseHeadHash, _ := runGit(ctx, repoPath, 5*time.Second, "rev-parse", baseRef) | |
| 1014 | + baseHashStr := strings.TrimSpace(string(baseHeadHash)) | |
| 1015 | + | |
| 1016 | + if commitMsg == "" { | |
| 1017 | + commitMsg = fmt.Sprintf("Merge branch '%s' into %s", headRef, baseRef) | |
| 1018 | + } | |
| 1019 | + | |
| 1020 | + commitArgs := []string{"commit-tree", treeHash, "-p", baseHashStr, "-p", headHash, "-m", commitMsg} | |
| 1021 | + cmd := exec.CommandContext(ctx, "git", commitArgs...) | |
| 1022 | + cmd.Dir = repoPath | |
| 1023 | + cmd.Env = append(os.Environ(), | |
| 1024 | + "GIT_AUTHOR_NAME="+authorName, | |
| 1025 | + "GIT_AUTHOR_EMAIL="+authorEmail, | |
| 1026 | + "GIT_COMMITTER_NAME="+authorName, | |
| 1027 | + "GIT_COMMITTER_EMAIL="+authorEmail, | |
| 1028 | + ) | |
| 1029 | + out, err := cmd.CombinedOutput() | |
| 1030 | + if err != nil { | |
| 1031 | + return "", fmt.Errorf("commit-tree failed: %s (%w)", string(out), err) | |
| 1032 | + } | |
| 1033 | + mergeHash := strings.TrimSpace(string(out)) | |
| 1034 | + | |
| 1035 | + _, err = runGit(ctx, repoPath, 5*time.Second, "update-ref", "refs/heads/"+baseRef, mergeHash) | |
| 1036 | + if err != nil { | |
| 1037 | + return "", fmt.Errorf("update-ref: %w", err) | |
| 1038 | + } | |
| 1039 | + return mergeHash, nil | |
| 1040 | +} |
internal/git/git_test.go
+105
-0
| ... | ... | @@ -0,0 +1,105 @@ |
| 1 | +package git | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "os" | |
| 6 | + "os/exec" | |
| 7 | + "path/filepath" | |
| 8 | + "testing" | |
| 9 | +) | |
| 10 | + | |
| 11 | +func TestValidateRefAndCleanSubPath(t *testing.T) { | |
| 12 | + if err := ValidateRef("main"); err != nil { | |
| 13 | + t.Fatalf("expected 'main' to be valid ref: %v", err) | |
| 14 | + } | |
| 15 | + if err := ValidateRef("feature/ui-kit"); err != nil { | |
| 16 | + t.Fatalf("expected 'feature/ui-kit' to be valid ref: %v", err) | |
| 17 | + } | |
| 18 | + for _, bad := range []string{"", "-flag", "a..b", "ref.lock", "ref with space"} { | |
| 19 | + if err := ValidateRef(bad); err == nil { | |
| 20 | + t.Fatalf("expected ref %q to be rejected", bad) | |
| 21 | + } | |
| 22 | + } | |
| 23 | + | |
| 24 | + if p, err := CleanSubPath("internal/web/views.go"); err != nil || p != "internal/web/views.go" { | |
| 25 | + t.Fatalf("CleanSubPath failed: p=%q err=%v", p, err) | |
| 26 | + } | |
| 27 | + for _, badPath := range []string{"../secret", "a/../../b", "-flag"} { | |
| 28 | + if _, err := CleanSubPath(badPath); err == nil { | |
| 29 | + t.Fatalf("expected path %q to be rejected", badPath) | |
| 30 | + } | |
| 31 | + } | |
| 32 | +} | |
| 33 | + | |
| 34 | +func TestBareRepoReadAndDiff(t *testing.T) { | |
| 35 | + ctx := context.Background() | |
| 36 | + tmp := t.TempDir() | |
| 37 | + barePath := filepath.Join(tmp, "repo.git") | |
| 38 | + | |
| 39 | + if err := InitBare(ctx, barePath, "main"); err != nil { | |
| 40 | + t.Fatalf("InitBare failed: %v", err) | |
| 41 | + } | |
| 42 | + | |
| 43 | + _, isEmpty := EnsureDefaultBranch(ctx, barePath, "main") | |
| 44 | + if !isEmpty { | |
| 45 | + t.Fatalf("expected newly initialized bare repo to be empty") | |
| 46 | + } | |
| 47 | + | |
| 48 | + // Create a working repo and push a commit into barePath | |
| 49 | + work := filepath.Join(tmp, "work") | |
| 50 | + if err := os.MkdirAll(filepath.Join(work, "pkg"), 0o755); err != nil { | |
| 51 | + t.Fatalf("mkdir work: %v", err) | |
| 52 | + } | |
| 53 | + | |
| 54 | + run := func(args ...string) { | |
| 55 | + t.Helper() | |
| 56 | + cmd := exec.CommandContext(ctx, "git", args...) | |
| 57 | + cmd.Dir = work | |
| 58 | + if out, err := cmd.CombinedOutput(); err != nil { | |
| 59 | + t.Fatalf("git %v: %v (%s)", args, err, string(out)) | |
| 60 | + } | |
| 61 | + } | |
| 62 | + | |
| 63 | + run("init", "-b", "main") | |
| 64 | + run("config", "user.name", "Alice") | |
| 65 | + run("config", "user.email", "alice@example.com") | |
| 66 | + _ = os.WriteFile(filepath.Join(work, "README.md"), []byte("# Test Repo\n"), 0o644) | |
| 67 | + _ = os.WriteFile(filepath.Join(work, "pkg", "hello.go"), []byte("package pkg\n\nfunc Hello() string { return \"hi\" }\n"), 0o644) | |
| 68 | + run("add", ".") | |
| 69 | + run("commit", "-m", "Add README and hello.go") | |
| 70 | + run("push", barePath, "main") | |
| 71 | + | |
| 72 | + branch, isEmpty := EnsureDefaultBranch(ctx, barePath, "main") | |
| 73 | + if isEmpty || branch != "main" { | |
| 74 | + t.Fatalf("expected non-empty repo on branch main, got branch=%q isEmpty=%v", branch, isEmpty) | |
| 75 | + } | |
| 76 | + | |
| 77 | + entries, err := ListTree(ctx, barePath, "main", "") | |
| 78 | + if err != nil || len(entries) != 2 { | |
| 79 | + t.Fatalf("ListTree failed: len=%d err=%v", len(entries), err) | |
| 80 | + } | |
| 81 | + // Directory "pkg" should be sorted before file "README.md" | |
| 82 | + if entries[0].Type != "tree" || entries[0].Name != "pkg" { | |
| 83 | + t.Fatalf("expected directory 'pkg' first, got %+v", entries[0]) | |
| 84 | + } | |
| 85 | + | |
| 86 | + readme, err := FindReadme(ctx, barePath, "main", "") | |
| 87 | + if err != nil || readme.Name != "README.md" { | |
| 88 | + t.Fatalf("FindReadme failed: %v", err) | |
| 89 | + } | |
| 90 | + | |
| 91 | + blob, err := ReadBlob(ctx, barePath, "main", "pkg/hello.go") | |
| 92 | + if err != nil || blob.IsBinary || blob.LineCount != 3 { | |
| 93 | + t.Fatalf("ReadBlob failed: %+v err=%v", blob, err) | |
| 94 | + } | |
| 95 | + | |
| 96 | + commits, err := ListCommits(ctx, barePath, "main", 10, 0) | |
| 97 | + if err != nil || len(commits) != 1 { | |
| 98 | + t.Fatalf("ListCommits failed: len=%d err=%v", len(commits), err) | |
| 99 | + } | |
| 100 | + | |
| 101 | + detail, err := GetCommitDiff(ctx, barePath, commits[0].Hash) | |
| 102 | + if err != nil || detail.FilesChanged != 2 || detail.TotalAdditions < 4 { | |
| 103 | + t.Fatalf("GetCommitDiff failed: %+v err=%v", detail, err) | |
| 104 | + } | |
| 105 | +} |
internal/git/http.go
+85
-0
| ... | ... | @@ -0,0 +1,85 @@ |
| 1 | +package git | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "bytes" | |
| 5 | + "context" | |
| 6 | + "errors" | |
| 7 | + "fmt" | |
| 8 | + "io" | |
| 9 | + "os" | |
| 10 | + "os/exec" | |
| 11 | + "strings" | |
| 12 | + "time" | |
| 13 | +) | |
| 14 | + | |
| 15 | +var ErrUnsupportedService = errors.New("unsupported git service") | |
| 16 | + | |
| 17 | +func subcommandForService(service string) (string, error) { | |
| 18 | + switch service { | |
| 19 | + case "git-upload-pack": | |
| 20 | + return "upload-pack", nil | |
| 21 | + case "git-receive-pack": | |
| 22 | + return "receive-pack", nil | |
| 23 | + default: | |
| 24 | + return "", ErrUnsupportedService | |
| 25 | + } | |
| 26 | +} | |
| 27 | + | |
| 28 | +func pktLine(payload string) string { | |
| 29 | + return fmt.Sprintf("%04x%s", len(payload)+4, payload) | |
| 30 | +} | |
| 31 | + | |
| 32 | +func AdvertiseRefs(ctx context.Context, repoPath, service string, out io.Writer) error { | |
| 33 | + subcmd, err := subcommandForService(service) | |
| 34 | + if err != nil { | |
| 35 | + return err | |
| 36 | + } | |
| 37 | + | |
| 38 | + cctx, cancel := context.WithTimeout(ctx, 30*time.Second) | |
| 39 | + defer cancel() | |
| 40 | + | |
| 41 | + var stdout, stderr bytes.Buffer | |
| 42 | + cmd := exec.CommandContext(cctx, "git", subcmd, "--stateless-rpc", "--advertise-refs", repoPath) | |
| 43 | + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "LC_ALL=C") | |
| 44 | + cmd.Stdout = &stdout | |
| 45 | + cmd.Stderr = &stderr | |
| 46 | + | |
| 47 | + if err := cmd.Run(); err != nil { | |
| 48 | + return fmt.Errorf("git %s advertise-refs: %w (%s)", subcmd, err, strings.TrimSpace(stderr.String())) | |
| 49 | + } | |
| 50 | + | |
| 51 | + header := pktLine(fmt.Sprintf("# service=%s\n", service)) + "0000" | |
| 52 | + if _, err := io.WriteString(out, header); err != nil { | |
| 53 | + return err | |
| 54 | + } | |
| 55 | + _, err = out.Write(stdout.Bytes()) | |
| 56 | + return err | |
| 57 | +} | |
| 58 | + | |
| 59 | +func ServiceRPC(ctx context.Context, repoPath, service string, in io.Reader, out io.Writer) error { | |
| 60 | + subcmd, err := subcommandForService(service) | |
| 61 | + if err != nil { | |
| 62 | + return err | |
| 63 | + } | |
| 64 | + | |
| 65 | + cctx, cancel := context.WithTimeout(ctx, 2*time.Minute) | |
| 66 | + defer cancel() | |
| 67 | + | |
| 68 | + var stderr bytes.Buffer | |
| 69 | + cmd := exec.CommandContext(cctx, "git", subcmd, "--stateless-rpc", repoPath) | |
| 70 | + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "LC_ALL=C") | |
| 71 | + cmd.Stdin = in | |
| 72 | + cmd.Stdout = out | |
| 73 | + cmd.Stderr = &stderr | |
| 74 | + | |
| 75 | + if err := cmd.Run(); err != nil { | |
| 76 | + return fmt.Errorf("git %s stateless-rpc: %w (%s)", subcmd, err, strings.TrimSpace(stderr.String())) | |
| 77 | + } | |
| 78 | + | |
| 79 | + if service == "git-receive-pack" { | |
| 80 | + _, _ = EnsureDefaultBranch(ctx, repoPath, "main") | |
| 81 | + _, _ = runGit(ctx, repoPath, 5*time.Second, "update-server-info") | |
| 82 | + } | |
| 83 | + | |
| 84 | + return nil | |
| 85 | +} |
internal/git/merge_test.go
+80
-0
| ... | ... | @@ -0,0 +1,80 @@ |
| 1 | +package git | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "os" | |
| 6 | + "os/exec" | |
| 7 | + "path/filepath" | |
| 8 | + "strings" | |
| 9 | + "testing" | |
| 10 | +) | |
| 11 | + | |
| 12 | +// TestMergeFromPath verifies cross-repository (fork) merging through a | |
| 13 | +// temporary ref fetched from another local repository. | |
| 14 | +func TestMergeFromPath(t *testing.T) { | |
| 15 | + ctx := context.Background() | |
| 16 | + tmp := t.TempDir() | |
| 17 | + | |
| 18 | + basePath := filepath.Join(tmp, "base.git") | |
| 19 | + if err := InitBare(ctx, basePath, "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 | + | |
| 36 | + run(work, "init", "-b", "main") | |
| 37 | + run(work, "config", "user.name", "Alice") | |
| 38 | + run(work, "config", "user.email", "alice@example.com") | |
| 39 | + if err := os.WriteFile(filepath.Join(work, "file.txt"), []byte("base\n"), 0o644); err != nil { | |
| 40 | + t.Fatalf("write: %v", err) | |
| 41 | + } | |
| 42 | + run(work, "add", ".") | |
| 43 | + run(work, "commit", "-m", "base commit") | |
| 44 | + run(work, "push", basePath, "main") | |
| 45 | + | |
| 46 | + forkPath := filepath.Join(tmp, "fork.git") | |
| 47 | + if err := CloneBare(ctx, basePath, forkPath); err != nil { | |
| 48 | + t.Fatalf("CloneBare: %v", err) | |
| 49 | + } | |
| 50 | + | |
| 51 | + // Add a feature branch on the fork. | |
| 52 | + run(work, "checkout", "-b", "feature") | |
| 53 | + if err := os.WriteFile(filepath.Join(work, "feature.txt"), []byte("feature\n"), 0o644); err != nil { | |
| 54 | + t.Fatalf("write: %v", err) | |
| 55 | + } | |
| 56 | + run(work, "add", ".") | |
| 57 | + run(work, "commit", "-m", "add feature") | |
| 58 | + run(work, "push", forkPath, "feature") | |
| 59 | + | |
| 60 | + hash, err := MergeFromPath(ctx, basePath, forkPath, "main", "feature", "refs/polar/tmp-merge-test", "Merge PR #1", "Alice", "alice@example.com") | |
| 61 | + if err != nil { | |
| 62 | + t.Fatalf("MergeFromPath: %v", err) | |
| 63 | + } | |
| 64 | + if hash == "" { | |
| 65 | + t.Fatalf("expected merge commit hash") | |
| 66 | + } | |
| 67 | + | |
| 68 | + out, err := runGit(ctx, basePath, 10_000_000_000, "ls-tree", "-r", "--name-only", "main") | |
| 69 | + if err != nil { | |
| 70 | + t.Fatalf("ls-tree: %v", err) | |
| 71 | + } | |
| 72 | + if !strings.Contains(string(out), "feature.txt") { | |
| 73 | + t.Fatalf("expected feature.txt on merged main, got: %s", string(out)) | |
| 74 | + } | |
| 75 | + | |
| 76 | + // The temporary ref must be gone after the merge. | |
| 77 | + if out, err := runGit(ctx, basePath, 10_000_000_000, "show-ref", "--verify", "--quiet", "refs/polar/tmp-merge-test"); err == nil { | |
| 78 | + t.Fatalf("expected temp ref to be deleted, got %s", string(out)) | |
| 79 | + } | |
| 80 | +} |
internal/git/text_encoding_test.go
+100
-0
| ... | ... | @@ -0,0 +1,100 @@ |
| 1 | +package git | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "encoding/binary" | |
| 6 | + "os" | |
| 7 | + "os/exec" | |
| 8 | + "path/filepath" | |
| 9 | + "testing" | |
| 10 | + "unicode/utf16" | |
| 11 | +) | |
| 12 | + | |
| 13 | +func utf16Bytes(s string, order binary.ByteOrder) []byte { | |
| 14 | + units := utf16.Encode([]rune(s)) | |
| 15 | + out := make([]byte, 0, len(units)*2) | |
| 16 | + for _, u := range units { | |
| 17 | + var pair [2]byte | |
| 18 | + order.PutUint16(pair[:], u) | |
| 19 | + out = append(out, pair[:]...) | |
| 20 | + } | |
| 21 | + return out | |
| 22 | +} | |
| 23 | + | |
| 24 | +func TestNormalizeTextEncoding(t *testing.T) { | |
| 25 | + utf8BOM := append([]byte{0xEF, 0xBB, 0xBF}, []byte("# readme")...) | |
| 26 | + if got := string(normalizeTextEncoding(utf8BOM)); got != "# readme" { | |
| 27 | + t.Fatalf("utf-8 BOM: got %q", got) | |
| 28 | + } | |
| 29 | + | |
| 30 | + le := append([]byte{0xFF, 0xFE}, utf16Bytes("# readme \u2713", binary.LittleEndian)...) | |
| 31 | + if got := string(normalizeTextEncoding(le)); got != "# readme \u2713" { | |
| 32 | + t.Fatalf("utf-16le: got %q", got) | |
| 33 | + } | |
| 34 | + | |
| 35 | + be := append([]byte{0xFE, 0xFF}, utf16Bytes("# readme \u2713", binary.BigEndian)...) | |
| 36 | + if got := string(normalizeTextEncoding(be)); got != "# readme \u2713" { | |
| 37 | + t.Fatalf("utf-16be: got %q", got) | |
| 38 | + } | |
| 39 | + | |
| 40 | + plain := []byte("# plain readme") | |
| 41 | + if got := string(normalizeTextEncoding(plain)); got != "# plain readme" { | |
| 42 | + t.Fatalf("plain text must stay untouched, got %q", got) | |
| 43 | + } | |
| 44 | + | |
| 45 | + binaryData := []byte{0x00, 0x01, 0x02, 0x03} | |
| 46 | + if got := normalizeTextEncoding(binaryData); len(got) != len(binaryData) { | |
| 47 | + t.Fatalf("binary data must stay untouched") | |
| 48 | + } | |
| 49 | +} | |
| 50 | + | |
| 51 | +// TestReadBlobDecodesUTF16 mirrors README files written by Windows tooling | |
| 52 | +// (PowerShell writes UTF-16LE by default) and expects them to render as text. | |
| 53 | +func TestReadBlobDecodesUTF16(t *testing.T) { | |
| 54 | + ctx := context.Background() | |
| 55 | + tmp := t.TempDir() | |
| 56 | + barePath := filepath.Join(tmp, "repo.git") | |
| 57 | + if err := InitBare(ctx, barePath, "main"); err != nil { | |
| 58 | + t.Fatalf("InitBare: %v", err) | |
| 59 | + } | |
| 60 | + | |
| 61 | + work := filepath.Join(tmp, "work") | |
| 62 | + if err := os.MkdirAll(work, 0o755); err != nil { | |
| 63 | + t.Fatalf("mkdir: %v", err) | |
| 64 | + } | |
| 65 | + run := func(args ...string) { | |
| 66 | + t.Helper() | |
| 67 | + cmd := exec.CommandContext(ctx, "git", args...) | |
| 68 | + cmd.Dir = work | |
| 69 | + if out, err := cmd.CombinedOutput(); err != nil { | |
| 70 | + t.Fatalf("git %v: %v (%s)", args, err, string(out)) | |
| 71 | + } | |
| 72 | + } | |
| 73 | + run("init", "-b", "main") | |
| 74 | + run("config", "user.name", "Tester") | |
| 75 | + run("config", "user.email", "tester@example.com") | |
| 76 | + | |
| 77 | + content := append([]byte{0xFF, 0xFE}, utf16Bytes("# Windows README\n", binary.LittleEndian)...) | |
| 78 | + if err := os.WriteFile(filepath.Join(work, "README.md"), content, 0o644); err != nil { | |
| 79 | + t.Fatalf("write README: %v", err) | |
| 80 | + } | |
| 81 | + run("add", ".") | |
| 82 | + run("commit", "-m", "utf-16 readme") | |
| 83 | + run("push", barePath, "main") | |
| 84 | + | |
| 85 | + blob, err := ReadBlob(ctx, barePath, "main", "README.md") | |
| 86 | + if err != nil { | |
| 87 | + t.Fatalf("ReadBlob: %v", err) | |
| 88 | + } | |
| 89 | + if blob.IsBinary { | |
| 90 | + t.Fatalf("BOM-marked UTF-16 file must not be treated as binary") | |
| 91 | + } | |
| 92 | + if string(blob.Data) != "# Windows README\n" { | |
| 93 | + t.Fatalf("unexpected decoded content %q", string(blob.Data)) | |
| 94 | + } | |
| 95 | + | |
| 96 | + readme, err := FindReadme(ctx, barePath, "main", "") | |
| 97 | + if err != nil || readme == nil { | |
| 98 | + t.Fatalf("FindReadme should find the UTF-16 README, err=%v", err) | |
| 99 | + } | |
| 100 | +} |
internal/i18n/i18n.go
+963
-0
| ... | ... | @@ -0,0 +1,963 @@ |
| 1 | +package i18n | |
| 2 | + | |
| 3 | +type Lang string | |
| 4 | + | |
| 5 | +const ( | |
| 6 | + RU Lang = "ru" | |
| 7 | + EN Lang = "en" | |
| 8 | +) | |
| 9 | + | |
| 10 | +func Normalize(code string) Lang { | |
| 11 | + switch code { | |
| 12 | + case "en", "EN": | |
| 13 | + return EN | |
| 14 | + default: | |
| 15 | + return RU | |
| 16 | + } | |
| 17 | +} | |
| 18 | + | |
| 19 | +var translations = map[Lang]map[string]string{ | |
| 20 | + RU: { | |
| 21 | + // Навигация и общее | |
| 22 | + "app.tagline": "Лёгкий self-hosted Git-хостинг", | |
| 23 | + "nav.home": "Главная", | |
| 24 | + "nav.repositories": "Репозитории", | |
| 25 | + "nav.explore": "Обзор", | |
| 26 | + "nav.settings": "Настройки", | |
| 27 | + "nav.admin": "Админка", | |
| 28 | + "nav.login": "Войти", | |
| 29 | + "nav.register": "Регистрация", | |
| 30 | + "nav.logout": "Выйти", | |
| 31 | + "nav.new_repo": "Создать", | |
| 32 | + "nav.command_palette": "Поиск и команды", | |
| 33 | + "nav.search_placeholder": "Поиск...", | |
| 34 | + "nav.theme": "Тема", | |
| 35 | + "nav.lang": "Язык", | |
| 36 | + "common.copy": "Копировать", | |
| 37 | + "common.copied": "Скопировано", | |
| 38 | + "common.cancel": "Отмена", | |
| 39 | + "common.save": "Сохранить", | |
| 40 | + "common.delete": "Удалить", | |
| 41 | + "common.close": "Закрыть", | |
| 42 | + "common.never": "Никогда", | |
| 43 | + "common.newer": "Новее", | |
| 44 | + "common.older": "Старее", | |
| 45 | + "common.up": "Наверх", | |
| 46 | + | |
| 47 | + // Темы (только 3) | |
| 48 | + "theme.light": "Светлая", | |
| 49 | + "theme.dark": "Тёмная", | |
| 50 | + "theme.system": "Авто", | |
| 51 | + | |
| 52 | + // Лендинг | |
| 53 | + "landing.badge": "Один бинарник · SQLite · Ноль зависимостей", | |
| 54 | + "landing.hero.title": "Лёгкий self-hosted Git-хостинг", | |
| 55 | + "landing.hero.subtitle": "Один бинарник на Go со встроенным SQLite. Поднимается за минуту, потребляет минимум памяти и не перегружен лишним.", | |
| 56 | + "landing.hero.cta_start": "Начать", | |
| 57 | + "landing.features.title": "Принципы", | |
| 58 | + "landing.feat1.title": "Один бинарник", | |
| 59 | + "landing.feat1.desc": "Шаблоны, стили, шрифты, миграции и иконки вшиты через go:embed.", | |
| 60 | + "landing.feat2.title": "SQLite (WAL)", | |
| 61 | + "landing.feat2.desc": "Вся база — один файл data/polar.db без CGO. Бэкап простым копированием.", | |
| 62 | + "landing.feat3.title": "Быстрый отклик", | |
| 63 | + "landing.feat3.desc": "Мгновенный старт, серверный рендеринг и < 30 МБ памяти в простое.", | |
| 64 | + "landing.feat4.title": "Приватность", | |
| 65 | + "landing.feat4.desc": "Публичные и приватные репозитории, Argon2id и токены доступа для Git HTTP.", | |
| 66 | + // Авторизация | |
| 67 | + "auth.login.title": "Вход", | |
| 68 | + "auth.register.title": "Регистрация", | |
| 69 | + "auth.register.disabled": "Публичная регистрация отключена администратором.", | |
| 70 | + "auth.username": "Имя пользователя", | |
| 71 | + "auth.username_hint": "2–32 символа: латиница, цифры, дефис, подчёркивание", | |
| 72 | + "auth.username_or_email": "Логин или Email", | |
| 73 | + "auth.email": "Email", | |
| 74 | + "auth.password": "Пароль", | |
| 75 | + "auth.password_hint": "Минимум 8 символов", | |
| 76 | + "auth.submit_login": "Войти", | |
| 77 | + "auth.submit_register": "Создать аккаунт", | |
| 78 | + "auth.no_account": "Нет аккаунта?", | |
| 79 | + "auth.have_account": "Уже есть аккаунт?", | |
| 80 | + "auth.invalid_creds": "Неверный логин или пароль", | |
| 81 | + | |
| 82 | + // Репозитории | |
| 83 | + "repo.new.title": "Новый репозиторий", | |
| 84 | + "repo.name": "Название", | |
| 85 | + "repo.name_hint": "Латинские буквы, цифры, точка, дефис, подчёркивание", | |
| 86 | + "repo.description": "Описание", | |
| 87 | + "repo.description_ph": "Краткое описание проекта (необязательно)", | |
| 88 | + "repo.visibility": "Доступ", | |
| 89 | + "repo.public": "Публичный", | |
| 90 | + "repo.public_desc": "Виден всем, клонирование без авторизации", | |
| 91 | + "repo.private": "Приватный", | |
| 92 | + "repo.private_desc": "Доступен только владельцу", | |
| 93 | + "repo.create_btn": "Создать репозиторий", | |
| 94 | + "repo.filter_ph": "Фильтр репозиториев...", | |
| 95 | + "repo.empty_list": "Репозиториев пока нет", | |
| 96 | + "repo.empty_list_sub": "Создайте первый репозиторий, чтобы отправить код.", | |
| 97 | + "repo.empty.title": "Репозиторий пуст", | |
| 98 | + "repo.empty.new_cmd": "Создать новый репозиторий в командной строке", | |
| 99 | + "repo.empty.push_cmd": "Отправить существующий репозиторий", | |
| 100 | + "repo.tab.code": "Код", | |
| 101 | + "repo.tab.commits": "Коммиты", | |
| 102 | + "repo.tab.branches": "Ветки и теги", | |
| 103 | + "repo.tab.issues": "Задачи", | |
| 104 | + "repo.tab.pulls": "Пул-реквесты", | |
| 105 | + "repo.tab.releases": "Релизы", | |
| 106 | + "repo.tab.activity": "Активность", | |
| 107 | + "repo.tab.settings": "Настройки", | |
| 108 | + "repo.features": "Возможности репозитория", | |
| 109 | + "repo.clone": "Клонировать", | |
| 110 | + "repo.download_zip": "Скачать ZIP", | |
| 111 | + "repo.branches": "Ветки", | |
| 112 | + "repo.tags": "Теги", | |
| 113 | + "repo.default_branch": "Ветка по умолчанию", | |
| 114 | + "repo.find_file": "Поиск файла...", | |
| 115 | + "repo.files": "Файлы", | |
| 116 | + "repo.readme": "README", | |
| 117 | + "repo.raw": "Raw", | |
| 118 | + "repo.history": "История", | |
| 119 | + "repo.lines": "строк", | |
| 120 | + "repo.lines_one": "строка", | |
| 121 | + "repo.lines_few": "строки", | |
| 122 | + "repo.lines_many": "строк", | |
| 123 | + "repo.binary_notice": "Бинарный файл — предпросмотр недоступен", | |
| 124 | + "repo.download_raw": "Скачать файл", | |
| 125 | + "repo.browse_at_commit": "Файлы на момент коммита", | |
| 126 | + "repo.files_changed": "файлов изменено", | |
| 127 | + "repo.files_changed_one": "файл изменён", | |
| 128 | + "repo.files_changed_few": "файла изменено", | |
| 129 | + "repo.files_changed_many": "файлов изменено", | |
| 130 | + "repo.no_commits": "Коммитов пока нет", | |
| 131 | + "repo.danger_zone": "Опасная зона", | |
| 132 | + "repo.delete": "Удалить репозиторий", | |
| 133 | + "repo.delete_desc": "Удаление необратимо: все файлы и история git будут стёрты.", | |
| 134 | + "repo.delete_confirm": "Введите название репозитория для подтверждения", | |
| 135 | + "repo.rename": "Переименовать", | |
| 136 | + "repo.save": "Сохранить", | |
| 137 | + "repo.branch.new": "Новая ветка", | |
| 138 | + "repo.branch.name_ph": "Имя ветки (напр. feat/ui)", | |
| 139 | + "repo.branch.from": "От ветки / коммита", | |
| 140 | + "repo.branch.create": "Создать ветку", | |
| 141 | + "repo.branch.default": "по умолчанию", | |
| 142 | + "repo.tag.new": "Новый тег / релиз", | |
| 143 | + "repo.tag.name_ph": "Тег (напр. v1.0.0)", | |
| 144 | + "repo.tag.msg_ph": "Описание тега (необязательно)", | |
| 145 | + "repo.tag.create": "Создать тег", | |
| 146 | + "repo.tags.empty": "Тегов пока нет", | |
| 147 | + | |
| 148 | + // Pull Requests | |
| 149 | + "pulls.title": "Пул-реквесты", | |
| 150 | + "pulls.new": "Новый пул-реквест", | |
| 151 | + "pulls.empty": "Пул-реквестов пока нет", | |
| 152 | + "pulls.open": "Открыт", | |
| 153 | + "pulls.closed": "Закрыт", | |
| 154 | + "pulls.merged": "Слит", | |
| 155 | + "pulls.compare": "Сравнение веток", | |
| 156 | + "pulls.base": "Базовая ветка", | |
| 157 | + "pulls.head": "Ветка изменений", | |
| 158 | + "pulls.title_ph": "Заголовок пул-реквеста...", | |
| 159 | + "pulls.desc_ph": "Опишите предлагаемые изменения (Markdown)...", | |
| 160 | + "pulls.create_btn": "Создать пул-реквест", | |
| 161 | + "pulls.merge_btn": "Слить пул-реквест", | |
| 162 | + "pulls.can_merge": "Ветки можно объединить автоматически.", | |
| 163 | + "pulls.cannot_merge": "Невозможно объединить автоматически: обнаружены конфликты.", | |
| 164 | + "pulls.already_merged": "Ветка изменений уже объединена с базовой.", | |
| 165 | + "pulls.close_btn": "Закрыть PR", | |
| 166 | + "pulls.reopen_btn": "Открыть заново", | |
| 167 | + "pulls.comment_btn": "Прокомментировать", | |
| 168 | + "pulls.conversation": "Обсуждение", | |
| 169 | + "pulls.files_changed": "Изменения", | |
| 170 | + "pulls.commits": "Коммиты", | |
| 171 | + "pulls.able_to_merge": "Можно слить автоматически", | |
| 172 | + "pulls.body": "Описание", | |
| 173 | + "pulls.body_ph": "Опишите предлагаемые изменения (Markdown)...", | |
| 174 | + "pulls.branches_identical": "Ветки идентичны — сравнивать нечего", | |
| 175 | + "pulls.cannot_merge_warning": "Обнаружены конфликты — автоматическое слияние невозможно", | |
| 176 | + "pulls.closed_desc": "Ветка изменений не была объединена с базовой.", | |
| 177 | + "pulls.closed_message": "Пул-реквест закрыт", | |
| 178 | + "pulls.comment_ph": "Напишите комментарий (поддерживается Markdown)...", | |
| 179 | + "pulls.compare_branches": "Сравнение веток", | |
| 180 | + "pulls.conflicts_desc": "Разрешите конфликты локально и запушьте изменения в ветку.", | |
| 181 | + "pulls.conflicts_title": "Есть конфликты слияния", | |
| 182 | + "pulls.into": "в", | |
| 183 | + "pulls.merge_clean": "Конфликтов нет — изменения можно объединить.", | |
| 184 | + "pulls.merged_message": "Пул-реквест слит", | |
| 185 | + "pulls.no_changes": "Файлы не изменены", | |
| 186 | + "pulls.no_commits": "В этом пул-реквесте пока нет коммитов", | |
| 187 | + "pulls.only_write_merge": "Сливать пул-реквесты могут только пользователи с правом записи.", | |
| 188 | + "pulls.status.closed": "Закрыт", | |
| 189 | + "pulls.status.merged": "Слит", | |
| 190 | + "pulls.status.open": "Открыт", | |
| 191 | + "pulls.wants_to_merge": "хочет слить", | |
| 192 | + | |
| 193 | + // Releases | |
| 194 | + "releases.title": "Релизы", | |
| 195 | + "releases.new": "Новый релиз", | |
| 196 | + "releases.empty": "Релизов пока нет", | |
| 197 | + "releases.tag_select": "Тег", | |
| 198 | + "releases.title_ph": "Название релиза", | |
| 199 | + "releases.desc_ph": "Описание релиза, список изменений (Markdown)...", | |
| 200 | + "releases.prerelease": "Пре-релиз (нестабильная сборка)", | |
| 201 | + "releases.publish": "Опубликовать релиз", | |
| 202 | + "releases.delete": "Удалить релиз", | |
| 203 | + "releases.assets": "Файлы", | |
| 204 | + "releases.source_zip": "Исходный код (zip)", | |
| 205 | + "releases.source_tar": "Исходный код (tar.gz)", | |
| 206 | + "releases.body": "Описание", | |
| 207 | + "releases.body_ph": "Опишите релиз, список изменений (Markdown)...", | |
| 208 | + "releases.count": "релизов", | |
| 209 | + "releases.count_one": "релиз", | |
| 210 | + "releases.count_few": "релиза", | |
| 211 | + "releases.count_many": "релизов", | |
| 212 | + "releases.delete_confirm": "Удалить этот релиз? Действие необратимо.", | |
| 213 | + "releases.is_prerelease": "Пре-релиз (нестабильная сборка)", | |
| 214 | + "releases.publish_btn": "Опубликовать релиз", | |
| 215 | + "releases.tag_desc": "Существующий тег или новый — он будет создан автоматически.", | |
| 216 | + "releases.tag_name": "Тег", | |
| 217 | + "releases.tags": "тегов", | |
| 218 | + "releases.tags_one": "тег", | |
| 219 | + "releases.tags_few": "тега", | |
| 220 | + "releases.tags_many": "тегов", | |
| 221 | + "releases.target": "Ветка", | |
| 222 | + "releases.target_desc": "Ветка или коммит, на который будет указывать тег.", | |
| 223 | + | |
| 224 | + // Activity | |
| 225 | + "activity.title": "Активность", | |
| 226 | + "activity.pulse": "Сводка активности", | |
| 227 | + "activity.empty": "Активности пока нет", | |
| 228 | + "activity.commits": "коммитов", | |
| 229 | + "activity.open_prs": "открытых PR", | |
| 230 | + "activity.merged_prs": "слитых PR", | |
| 231 | + "activity.open_issues": "открытых задач", | |
| 232 | + "activity.releases": "релизов", | |
| 233 | + "activity.issues": "открыто и закрыто", | |
| 234 | + "activity.on_default_branch": "в ветке по умолчанию", | |
| 235 | + "activity.published_releases": "опубликованные релизы", | |
| 236 | + "activity.pull_requests": "открыто и слито", | |
| 237 | + "activity.recent_feed": "Лента активности", | |
| 238 | + "activity.badge_commit": "коммит", | |
| 239 | + "activity.badge_release": "релиз", | |
| 240 | + | |
| 241 | + // Задачи (Issues) | |
| 242 | + "issue.new": "Новая задача", | |
| 243 | + "issue.title": "Заголовок", | |
| 244 | + "issue.title_ph": "Кратко опишите задачу или проблему", | |
| 245 | + "issue.body": "Описание", | |
| 246 | + "issue.body_ph": "Подробное описание (поддерживается Markdown)...", | |
| 247 | + "issue.create": "Создать задачу", | |
| 248 | + "issue.open": "Открытые", | |
| 249 | + "issue.closed": "Закрытые", | |
| 250 | + "issue.status.open": "Открыта", | |
| 251 | + "issue.status.closed": "Закрыта", | |
| 252 | + "issue.close_btn": "Закрыть задачу", | |
| 253 | + "issue.reopen_btn": "Переоткрыть", | |
| 254 | + "issue.comment_ph": "Напишите комментарий (поддерживается Markdown)...", | |
| 255 | + "issue.comment_btn": "Отправить", | |
| 256 | + "issue.empty": "Задач в этом списке нет", | |
| 257 | + | |
| 258 | + // Настройки пользователя, профиль и токены | |
| 259 | + "settings.title": "Настройки", | |
| 260 | + "settings.profile": "Профиль", | |
| 261 | + "settings.avatar": "Аватар", | |
| 262 | + "settings.avatar_upload": "Выбрать изображение", | |
| 263 | + "settings.avatar_remove": "Удалить аватар", | |
| 264 | + "settings.avatar_hint": "PNG, JPG, WebP или GIF — автоматически кадрируется до 256×256", | |
| 265 | + "settings.full_name": "Отображаемое имя", | |
| 266 | + "settings.full_name_ph": "Ваше имя или ник", | |
| 267 | + "settings.bio": "О себе", | |
| 268 | + "settings.bio_ph": "Короткая подпись в профиле", | |
| 269 | + "settings.location": "Локация", | |
| 270 | + "settings.location_ph": "Город, страна", | |
| 271 | + "settings.website": "Сайт", | |
| 272 | + "settings.website_ph": "https://example.com", | |
| 273 | + "profile.edit": "Редактировать профиль", | |
| 274 | + "profile.joined": "Регистрация", | |
| 275 | + "profile.public_repos": "Публичные", | |
| 276 | + "profile.private_repos": "Приватные", | |
| 277 | + "profile.total_repos": "Репозитории", | |
| 278 | + "settings.tokens": "Токены доступа", | |
| 279 | + "settings.tokens.desc": "Используйте токен вместо пароля для git clone и git push по HTTP.", | |
| 280 | + "settings.token.name": "Название токена (например, Ноутбук)", | |
| 281 | + "settings.token.create": "Создать", | |
| 282 | + "settings.token.revoke": "Отозвать", | |
| 283 | + "settings.token.created_once": "Скопируйте токен сейчас — он показывается только один раз:", | |
| 284 | + "settings.token.col_name": "Название", | |
| 285 | + "settings.token.col_prefix": "Префикс", | |
| 286 | + "settings.token.col_created": "Создан", | |
| 287 | + "settings.token.col_used": "Использован", | |
| 288 | + "settings.token.empty": "Токенов доступа пока нет", | |
| 289 | + "settings.password.current": "Текущий пароль", | |
| 290 | + "settings.password.new": "Новый пароль", | |
| 291 | + "settings.password.update": "Обновить пароль", | |
| 292 | + | |
| 293 | + // Админка | |
| 294 | + "admin.title": "Администрирование", | |
| 295 | + "admin.features": "Модули и доступ", | |
| 296 | + "admin.users": "Пользователи", | |
| 297 | + "admin.registration": "Публичная регистрация", | |
| 298 | + "admin.registration.desc": "Разрешить самостоятельную регистрацию новых аккаунтов", | |
| 299 | + "admin.explore": "Раздел «Обзор» (Explore)", | |
| 300 | + "admin.explore.desc": "Показывать каталог публичных репозиториев в сайдбаре и по адресу /explore", | |
| 301 | + "admin.landing": "Приветственная страница", | |
| 302 | + "admin.landing.desc": "Показывать лендинг на главной странице (если отключено — переход сразу к репозиториям или входу)", | |
| 303 | + "admin.anon_read": "Гостевой просмотр", | |
| 304 | + "admin.anon_read.desc": "Разрешить неавторизованным пользователям просматривать публичные репозитории и профили", | |
| 305 | + "admin.issues": "Трекер задач (Issues)", | |
| 306 | + "admin.issues.desc": "Включить вкладку «Задачи» в репозиториях", | |
| 307 | + "admin.enabled": "Включено", | |
| 308 | + "admin.disabled": "Отключено", | |
| 309 | + "admin.enable_btn": "Включить", | |
| 310 | + "admin.disable_btn": "Отключить", | |
| 311 | + "admin.col.user": "Пользователь", | |
| 312 | + "admin.col.repos": "Репозитории", | |
| 313 | + "admin.col.role": "Роль", | |
| 314 | + "admin.col.joined": "Дата", | |
| 315 | + "admin.role.admin": "Админ", | |
| 316 | + "admin.role.user": "Участник", | |
| 317 | + | |
| 318 | + // Командная палитра и хоткеи | |
| 319 | + "palette.placeholder": "Поиск репозитория, файла или команды...", | |
| 320 | + "palette.group.nav": "Навигация", | |
| 321 | + "palette.group.themes": "Тема оформления", | |
| 322 | + "palette.group.repos": "Репозитории", | |
| 323 | + "shortcuts.title": "Горячие клавиши", | |
| 324 | + "shortcuts.palette": "Командная палитра", | |
| 325 | + "shortcuts.search": "Фокус на поиск", | |
| 326 | + "shortcuts.home": "На главную", | |
| 327 | + "shortcuts.repos": "Мои репозитории", | |
| 328 | + "shortcuts.files": "Фильтр файлов", | |
| 329 | + "shortcuts.sidebar": "Скрыть / показать сайдбар", | |
| 330 | + "nav.toggle_sidebar": "Сайдбар (Ctrl+B)", | |
| 331 | + | |
| 332 | + // Флеш-сообщения | |
| 333 | + "flash.welcome": "Добро пожаловать в Polar, @%s!", | |
| 334 | + "flash.profile_updated": "Профиль обновлён.", | |
| 335 | + "flash.password_updated": "Пароль успешно изменён.", | |
| 336 | + "flash.token_revoked": "Токен доступа отозван.", | |
| 337 | + "flash.settings_updated": "Настройки сохранены.", | |
| 338 | + "flash.error": "Ошибка: %s", | |
| 339 | + "flash.comment_error": "Не удалось отправить комментарий: %s", | |
| 340 | + "flash.pr_created": "Пул-реквест #%d создан.", | |
| 341 | + "flash.pr_closed": "Пул-реквест #%d закрыт.", | |
| 342 | + "flash.pr_reopened": "Пул-реквест #%d открыт заново.", | |
| 343 | + "flash.pr_merged": "Пул-реквест #%d слит.", | |
| 344 | + "flash.pr_merged_state": "Нельзя изменить состояние слитого пул-реквеста.", | |
| 345 | + "flash.pr_merge_open_only": "Слить можно только открытый пул-реквест.", | |
| 346 | + "flash.pr_merge_failed": "Не удалось слить: %s", | |
| 347 | + "flash.release_tag_empty": "Имя тега не может быть пустым.", | |
| 348 | + "flash.release_tag_failed": "Не удалось создать тег: %s", | |
| 349 | + "flash.release_create_failed": "Не удалось создать релиз: %s", | |
| 350 | + "flash.release_published": "Релиз %s опубликован.", | |
| 351 | + "flash.release_delete_failed": "Не удалось удалить релиз: %s", | |
| 352 | + "flash.release_deleted": "Релиз удалён.", | |
| 353 | + "flash.repo_settings_saved": "Настройки репозитория сохранены.", | |
| 354 | + "flash.repo_rename_failed": "Не удалось переименовать: %s", | |
| 355 | + "flash.repo_renamed": "Репозиторий переименован в %s.", | |
| 356 | + "flash.repo_confirm_mismatch": "Введённое имя не совпадает с названием репозитория.", | |
| 357 | + "flash.repo_deleted": "Репозиторий %s удалён.", | |
| 358 | + "flash.branch_created": "Ветка %s создана.", | |
| 359 | + "flash.branch_create_failed": "Не удалось создать ветку: %s", | |
| 360 | + "flash.branch_default_delete": "Нельзя удалить ветку по умолчанию.", | |
| 361 | + "flash.branch_deleted": "Ветка %s удалена.", | |
| 362 | + "flash.branch_delete_failed": "Не удалось удалить ветку: %s", | |
| 363 | + "flash.tag_created": "Тег %s создан.", | |
| 364 | + "flash.tag_create_failed": "Не удалось создать тег: %s", | |
| 365 | + "flash.tag_deleted": "Тег %s удалён.", | |
| 366 | + "flash.tag_delete_failed": "Не удалось удалить тег: %s", | |
| 367 | + "flash.issue_closed": "Задача #%d закрыта.", | |
| 368 | + "flash.issue_reopened": "Задача #%d открыта заново.", | |
| 369 | + | |
| 370 | + // Ошибочные страницы | |
| 371 | + "err.not_found": "Не найдено", | |
| 372 | + "err.forbidden": "Доступ запрещён", | |
| 373 | + "err.session_error": "Ошибка сессии", | |
| 374 | + "err.session_create": "Не удалось создать сессию.", | |
| 375 | + "err.settings_error": "Ошибка настроек", | |
| 376 | + "err.settings_save": "Не удалось сохранить настройку.", | |
| 377 | + "err.explore_disabled": "Раздел «Обзор» отключён на этом сервере.", | |
| 378 | + "err.activity_disabled": "Активность отключена для этого репозитория.", | |
| 379 | + "err.pulls_disabled": "Пул-реквесты отключены для этого репозитория.", | |
| 380 | + "err.releases_disabled": "Релизы отключены для этого репозитория.", | |
| 381 | + "err.issues_disabled": "Задачи отключены для этого репозитория.", | |
| 382 | + "err.repo_not_found": "Репозиторий не найден", | |
| 383 | + "err.repo_not_found_desc": "Репозиторий не существует или у вас нет к нему доступа.", | |
| 384 | + "err.repo_private_desc": "Репозиторий не существует или является приватным.", | |
| 385 | + "err.access_denied": "Доступ запрещён", | |
| 386 | + "err.repo_write_denied": "У вас нет прав на изменение этого репозитория.", | |
| 387 | + "err.invalid_repo_path": "Некорректный путь репозитория", | |
| 388 | + "err.path_not_found": "Путь не найден", | |
| 389 | + "err.path_not_found_desc": "Не удалось найти путь в репозитории для ссылки %s", | |
| 390 | + "err.file_not_found": "Файл не найден", | |
| 391 | + "err.file_not_found_desc": "Запрошенный файл не найден в %s", | |
| 392 | + "err.commit_not_found": "Коммит не найден", | |
| 393 | + "err.commit_not_found_desc": "Не удалось найти коммит %s", | |
| 394 | + "err.pr_not_found": "Пул-реквест не найден", | |
| 395 | + "err.pr_not_found_desc": "Не удалось найти пул-реквест.", | |
| 396 | + "err.pr_state_forbidden": "Вы не можете изменить состояние этого пул-реквеста.", | |
| 397 | + "err.pr_merge_forbidden": "У вас нет прав на слияние пул-реквестов.", | |
| 398 | + "err.issue_not_found": "Задача не найдена", | |
| 399 | + "err.issue_not_found_desc": "Не удалось найти задачу.", | |
| 400 | + "err.issue_state_forbidden": "Вы не можете изменить состояние этой задачи.", | |
| 401 | + "err.delete_error": "Ошибка удаления", | |
| 402 | + "err.repo_delete_failed": "Не удалось удалить репозиторий.", | |
| 403 | + "err.user_not_found": "Пользователь @%s не существует.", | |
| 404 | + "err.archive_error": "Ошибка архива", | |
| 405 | + "err.archive_tar_failed": "Не удалось создать tar.gz архив для %s", | |
| 406 | + "err.archive_zip_failed": "Не удалось создать zip архив для %s", | |
| 407 | + | |
| 408 | + // Форки | |
| 409 | + "fork.forked_from": "форк от", | |
| 410 | + "fork.forks": "Форки", | |
| 411 | + "fork.forks_btn": "Форки", | |
| 412 | + "fork.fork_btn": "Форк", | |
| 413 | + "fork.fork_btn_desc": "Создать форк этого репозитория", | |
| 414 | + "fork.your_fork": "Ваш форк", | |
| 415 | + "fork.your_fork_desc": "Перейти к вашему форку", | |
| 416 | + "forks.title": "Форки", | |
| 417 | + "forks.empty": "Пока никто не форкал этот репозиторий.", | |
| 418 | + | |
| 419 | + // Коллабораторы | |
| 420 | + "collab.title": "Коллабораторы", | |
| 421 | + "collab.desc": "Люди с доступом к репозиторию", | |
| 422 | + "collab.owner": "Владелец", | |
| 423 | + "collab.role": "Роль", | |
| 424 | + "collab.role_read": "Чтение", | |
| 425 | + "collab.role_write": "Запись", | |
| 426 | + "collab.username": "Пользователь", | |
| 427 | + "collab.username_ph": "username", | |
| 428 | + "collab.add": "Добавить", | |
| 429 | + "collab.remove": "Убрать доступ", | |
| 430 | + "collab.empty": "Коллабораторов пока нет.", | |
| 431 | + | |
| 432 | + // Ревью | |
| 433 | + "reviews.title": "Ревью", | |
| 434 | + "reviews.approved": "Одобрено", | |
| 435 | + "reviews.changes_requested": "Запрошены изменения", | |
| 436 | + "reviews.body_ph": "Комментарий к ревью (необязательно)", | |
| 437 | + "reviews.approve": "Одобрить", | |
| 438 | + "reviews.request_changes": "Запросить изменения", | |
| 439 | + | |
| 440 | + // Device flow (RFC 8628) | |
| 441 | + "device.title": "Подключение устройства", | |
| 442 | + "device.badge": "Git", | |
| 443 | + "device.desc": "Введите код, который показал git или скрипт-помощник, чтобы выдать устройству доступ к вашему аккаунту.", | |
| 444 | + "device.check": "Проверить", | |
| 445 | + "device.authorize_for": "Выдать доступ по коду", | |
| 446 | + "device.scope_hint": "Доступ даётся на чтение и запись репозиториев, к которым у вас есть права, и действует только для этого устройства.", | |
| 447 | + "device.approve": "Разрешить", | |
| 448 | + "device.deny": "Отклонить", | |
| 449 | + "device.signin_to_approve": "Войдите, чтобы подтвердить доступ", | |
| 450 | + "device.helper_hint": "Подсказка: настройте credential helper, чтобы код запрашивался автоматически — см. README.", | |
| 451 | + "device.code_unknown": "Код не найден. Проверьте, что он введён верно.", | |
| 452 | + "device.code_used": "Этот код уже использован или истёк.", | |
| 453 | + "device.approved_now": "Готово! Устройство подключено — вернитесь в терминал.", | |
| 454 | + "device.denied_now": "Запрос отклонён. Устройство не получит доступ.", | |
| 455 | + | |
| 456 | + // Flash: форки, коллабораторы, ревью, device flow | |
| 457 | + "flash.fork_self": "Это ваш репозиторий — форк не нужен.", | |
| 458 | + "flash.fork_exists": "У вас уже есть форк этого репозитория.", | |
| 459 | + "flash.fork_created": "Форк создан.", | |
| 460 | + "flash.fork_failed": "Не удалось создать форк: %s", | |
| 461 | + "flash.collab_added": "@%s добавлен в коллабораторы.", | |
| 462 | + "flash.collab_removed": "Доступ коллаборатора отозван.", | |
| 463 | + "flash.collab_failed": "Не удалось изменить коллаборатора: %s", | |
| 464 | + "flash.review_self": "Нельзя ревьюить собственный пул-реквест.", | |
| 465 | + "flash.review_closed": "Ревью доступно только для открытых пул-реквестов.", | |
| 466 | + "flash.review_failed": "Не удалось сохранить ревью: %s", | |
| 467 | + "flash.review_approved": "Ревью сохранено: одобрено.", | |
| 468 | + "flash.review_changes_requested": "Ревью сохранено: запрошены изменения.", | |
| 469 | + "flash.device_code_required": "Введите код устройства.", | |
| 470 | + "flash.device_code_invalid": "Код не найден, истёк или уже использован.", | |
| 471 | + "flash.device_approved": "Устройство авторизовано — можно возвращаться к git.", | |
| 472 | + "flash.device_denied": "Запрос отклонён.", | |
| 473 | + | |
| 474 | + // Прочее | |
| 475 | + "issues.new": "Новая задача", | |
| 476 | + "pulls.head_repo": "Из репозитория", | |
| 477 | + "repo.about": "О репозитории", | |
| 478 | + "repo.clone_title": "Клонирование", | |
| 479 | + "repo.created": "Создан", | |
| 480 | + "repo.updated": "Обновлён", | |
| 481 | + "repo.view_file": "Открыть", | |
| 482 | + "err.collab_forbidden": "Управлять коллабораторами может только владелец или администратор.", | |
| 483 | + "err.pr_head_repo_invalid": "Выбран недопустимый репозиторий-источник."}, | |
| 484 | + EN: { | |
| 485 | + // Navigation & Common | |
| 486 | + "app.tagline": "Lightweight self-hosted Git hosting", | |
| 487 | + "nav.home": "Home", | |
| 488 | + "nav.repositories": "Repositories", | |
| 489 | + "nav.explore": "Explore", | |
| 490 | + "nav.settings": "Settings", | |
| 491 | + "nav.admin": "Admin", | |
| 492 | + "nav.login": "Sign in", | |
| 493 | + "nav.register": "Sign up", | |
| 494 | + "nav.logout": "Sign out", | |
| 495 | + "nav.new_repo": "New", | |
| 496 | + "nav.command_palette": "Search & commands", | |
| 497 | + "nav.search_placeholder": "Search...", | |
| 498 | + "nav.theme": "Theme", | |
| 499 | + "nav.lang": "Language", | |
| 500 | + "common.copy": "Copy", | |
| 501 | + "common.copied": "Copied", | |
| 502 | + "common.cancel": "Cancel", | |
| 503 | + "common.save": "Save", | |
| 504 | + "common.delete": "Delete", | |
| 505 | + "common.close": "Close", | |
| 506 | + "common.never": "Never", | |
| 507 | + "common.newer": "Newer", | |
| 508 | + "common.older": "Older", | |
| 509 | + "common.up": "Up", | |
| 510 | + | |
| 511 | + // Themes (only 3) | |
| 512 | + "theme.light": "Light", | |
| 513 | + "theme.dark": "Dark", | |
| 514 | + "theme.system": "System", | |
| 515 | + | |
| 516 | + // Landing | |
| 517 | + "landing.badge": "Single Binary · SQLite · Zero Dependencies", | |
| 518 | + "landing.hero.title": "Lightweight self-hosted Git hosting", | |
| 519 | + "landing.hero.subtitle": "A single Go binary backed by SQLite. Deploys in a minute, uses minimal memory, and stays out of your way.", | |
| 520 | + "landing.hero.cta_start": "Get Started", | |
| 521 | + "landing.features.title": "Principles", | |
| 522 | + "landing.feat1.title": "Single Binary", | |
| 523 | + "landing.feat1.desc": "Templates, styles, fonts, migrations, and icons are compiled in via go:embed.", | |
| 524 | + "landing.feat2.title": "SQLite (WAL)", | |
| 525 | + "landing.feat2.desc": "Your entire database is a single data/polar.db file without CGO. Back up by copying.", | |
| 526 | + "landing.feat3.title": "Fast & Quiet", | |
| 527 | + "landing.feat3.desc": "Instant cold start, server-side rendering, and < 30 MB idle memory.", | |
| 528 | + "landing.feat4.title": "Private by Default", | |
| 529 | + "landing.feat4.desc": "Public and private repositories, Argon2id passwords, and Git HTTP access tokens.", | |
| 530 | + | |
| 531 | + // Auth | |
| 532 | + "auth.login.title": "Sign in", | |
| 533 | + "auth.register.title": "Create account", | |
| 534 | + "auth.register.disabled": "Public registration is disabled by the administrator.", | |
| 535 | + "auth.username": "Username", | |
| 536 | + "auth.username_hint": "2–32 characters: letters, digits, hyphen, underscore", | |
| 537 | + "auth.username_or_email": "Username or Email", | |
| 538 | + "auth.email": "Email", | |
| 539 | + "auth.password": "Password", | |
| 540 | + "auth.password_hint": "At least 8 characters", | |
| 541 | + "auth.submit_login": "Sign in", | |
| 542 | + "auth.submit_register": "Create account", | |
| 543 | + "auth.no_account": "No account?", | |
| 544 | + "auth.have_account": "Have an account?", | |
| 545 | + "auth.invalid_creds": "Invalid username/email or password", | |
| 546 | + | |
| 547 | + // Repositories | |
| 548 | + "repo.new.title": "New repository", | |
| 549 | + "repo.name": "Name", | |
| 550 | + "repo.name_hint": "Letters, digits, '.', '-', '_'", | |
| 551 | + "repo.description": "Description", | |
| 552 | + "repo.description_ph": "Short description (optional)", | |
| 553 | + "repo.visibility": "Visibility", | |
| 554 | + "repo.public": "Public", | |
| 555 | + "repo.public_desc": "Anyone can view and clone this repository", | |
| 556 | + "repo.private": "Private", | |
| 557 | + "repo.private_desc": "Accessible only to you", | |
| 558 | + "repo.create_btn": "Create repository", | |
| 559 | + "repo.filter_ph": "Filter repositories...", | |
| 560 | + "repo.empty_list": "No repositories yet", | |
| 561 | + "repo.empty_list_sub": "Create your first repository to push code.", | |
| 562 | + "repo.empty.title": "Repository is empty", | |
| 563 | + "repo.empty.new_cmd": "Create a new repository on the command line", | |
| 564 | + "repo.empty.push_cmd": "Push an existing repository", | |
| 565 | + "repo.tab.code": "Code", | |
| 566 | + "repo.tab.commits": "Commits", | |
| 567 | + "repo.tab.branches": "Branches & Tags", | |
| 568 | + "repo.tab.issues": "Issues", | |
| 569 | + "repo.tab.pulls": "Pull requests", | |
| 570 | + "repo.tab.releases": "Releases", | |
| 571 | + "repo.tab.activity": "Activity", | |
| 572 | + "repo.tab.settings": "Settings", | |
| 573 | + "repo.features": "Repository Features", | |
| 574 | + "repo.clone": "Clone", | |
| 575 | + "repo.download_zip": "Download ZIP", | |
| 576 | + "repo.branches": "Branches", | |
| 577 | + "repo.tags": "Tags", | |
| 578 | + "repo.default_branch": "Default branch", | |
| 579 | + "repo.find_file": "Filter files...", | |
| 580 | + "repo.files": "Files", | |
| 581 | + "repo.readme": "README", | |
| 582 | + "repo.raw": "Raw", | |
| 583 | + "repo.history": "History", | |
| 584 | + "repo.lines": "lines", | |
| 585 | + "repo.lines_one": "line", | |
| 586 | + "repo.lines_few": "lines", | |
| 587 | + "repo.lines_many": "lines", | |
| 588 | + "repo.binary_notice": "Binary file — inline preview unavailable", | |
| 589 | + "repo.download_raw": "Download file", | |
| 590 | + "repo.browse_at_commit": "Browse files", | |
| 591 | + "repo.files_changed": "files changed", | |
| 592 | + "repo.files_changed_one": "file changed", | |
| 593 | + "repo.files_changed_few": "files changed", | |
| 594 | + "repo.files_changed_many": "files changed", | |
| 595 | + "repo.no_commits": "No commits yet", | |
| 596 | + "repo.danger_zone": "Danger Zone", | |
| 597 | + "repo.delete": "Delete repository", | |
| 598 | + "repo.delete_desc": "Once deleted, the repository and its git history cannot be recovered.", | |
| 599 | + "repo.delete_confirm": "Type the repository name to confirm", | |
| 600 | + "repo.rename": "Rename", | |
| 601 | + "repo.save": "Save", | |
| 602 | + "repo.branch.new": "New branch", | |
| 603 | + "repo.branch.name_ph": "Branch name (e.g. feat/ui)", | |
| 604 | + "repo.branch.from": "From branch / ref", | |
| 605 | + "repo.branch.create": "Create branch", | |
| 606 | + "repo.branch.default": "default", | |
| 607 | + "repo.tag.new": "New tag / release", | |
| 608 | + "repo.tag.name_ph": "Tag (e.g. v1.0.0)", | |
| 609 | + "repo.tag.msg_ph": "Tag note (optional)", | |
| 610 | + "repo.tag.create": "Create tag", | |
| 611 | + "repo.tags.empty": "No tags yet", | |
| 612 | + | |
| 613 | + // Pull Requests | |
| 614 | + "pulls.title": "Pull requests", | |
| 615 | + "pulls.new": "New pull request", | |
| 616 | + "pulls.empty": "No pull requests yet", | |
| 617 | + "pulls.open": "Open", | |
| 618 | + "pulls.closed": "Closed", | |
| 619 | + "pulls.merged": "Merged", | |
| 620 | + "pulls.compare": "Comparing changes", | |
| 621 | + "pulls.base": "base", | |
| 622 | + "pulls.head": "compare", | |
| 623 | + "pulls.title_ph": "Pull request title...", | |
| 624 | + "pulls.desc_ph": "Describe your changes (Markdown supported)...", | |
| 625 | + "pulls.create_btn": "Create pull request", | |
| 626 | + "pulls.merge_btn": "Merge pull request", | |
| 627 | + "pulls.can_merge": "Able to merge automatically.", | |
| 628 | + "pulls.cannot_merge": "Can't automatically merge: conflicts detected.", | |
| 629 | + "pulls.already_merged": "The head branch is already merged into base.", | |
| 630 | + "pulls.close_btn": "Close PR", | |
| 631 | + "pulls.reopen_btn": "Reopen PR", | |
| 632 | + "pulls.comment_btn": "Comment", | |
| 633 | + "pulls.conversation": "Conversation", | |
| 634 | + "pulls.files_changed": "Files changed", | |
| 635 | + "pulls.commits": "Commits", | |
| 636 | + "pulls.able_to_merge": "Able to merge automatically", | |
| 637 | + "pulls.body": "Description", | |
| 638 | + "pulls.body_ph": "Describe your changes (Markdown supported)...", | |
| 639 | + "pulls.branches_identical": "Branches are identical — nothing to compare", | |
| 640 | + "pulls.cannot_merge_warning": "Conflicts detected — automatic merge is not possible", | |
| 641 | + "pulls.closed_desc": "The head branch was not merged into base.", | |
| 642 | + "pulls.closed_message": "Pull request closed", | |
| 643 | + "pulls.comment_ph": "Write a comment (Markdown supported)...", | |
| 644 | + "pulls.compare_branches": "Comparing branches", | |
| 645 | + "pulls.conflicts_desc": "Resolve the conflicts locally and push to the head branch.", | |
| 646 | + "pulls.conflicts_title": "Merge conflicts detected", | |
| 647 | + "pulls.into": "into", | |
| 648 | + "pulls.merge_clean": "No conflicts — changes can be merged.", | |
| 649 | + "pulls.merged_message": "Pull request merged", | |
| 650 | + "pulls.no_changes": "No files changed", | |
| 651 | + "pulls.no_commits": "No commits in this pull request yet", | |
| 652 | + "pulls.only_write_merge": "Only users with write access can merge pull requests.", | |
| 653 | + "pulls.status.closed": "Closed", | |
| 654 | + "pulls.status.merged": "Merged", | |
| 655 | + "pulls.status.open": "Open", | |
| 656 | + "pulls.wants_to_merge": "wants to merge", | |
| 657 | + | |
| 658 | + // Releases | |
| 659 | + "releases.title": "Releases", | |
| 660 | + "releases.new": "Draft a new release", | |
| 661 | + "releases.empty": "No releases yet", | |
| 662 | + "releases.tag_select": "Tag", | |
| 663 | + "releases.title_ph": "Release title", | |
| 664 | + "releases.desc_ph": "Describe this release, release notes (Markdown)...", | |
| 665 | + "releases.prerelease": "This is a pre-release", | |
| 666 | + "releases.publish": "Publish release", | |
| 667 | + "releases.delete": "Delete release", | |
| 668 | + "releases.assets": "Assets", | |
| 669 | + "releases.source_zip": "Source code (zip)", | |
| 670 | + "releases.source_tar": "Source code (tar.gz)", | |
| 671 | + "releases.body": "Description", | |
| 672 | + "releases.body_ph": "Describe this release, release notes (Markdown)...", | |
| 673 | + "releases.count": "releases", | |
| 674 | + "releases.count_one": "release", | |
| 675 | + "releases.count_few": "releases", | |
| 676 | + "releases.count_many": "releases", | |
| 677 | + "releases.delete_confirm": "Delete this release? This cannot be undone.", | |
| 678 | + "releases.is_prerelease": "This is a pre-release", | |
| 679 | + "releases.publish_btn": "Publish release", | |
| 680 | + "releases.tag_desc": "An existing tag or a new one — it will be created automatically.", | |
| 681 | + "releases.tag_name": "Tag", | |
| 682 | + "releases.tags": "tags", | |
| 683 | + "releases.tags_one": "tag", | |
| 684 | + "releases.tags_few": "tags", | |
| 685 | + "releases.tags_many": "tags", | |
| 686 | + "releases.target": "Branch", | |
| 687 | + "releases.target_desc": "Branch or commit the tag will point to.", | |
| 688 | + | |
| 689 | + // Activity | |
| 690 | + "activity.title": "Activity", | |
| 691 | + "activity.pulse": "Activity overview", | |
| 692 | + "activity.empty": "No recent activity recorded.", | |
| 693 | + "activity.commits": "commits", | |
| 694 | + "activity.open_prs": "open PRs", | |
| 695 | + "activity.merged_prs": "merged PRs", | |
| 696 | + "activity.open_issues": "open issues", | |
| 697 | + "activity.releases": "releases", | |
| 698 | + "activity.issues": "opened and closed", | |
| 699 | + "activity.on_default_branch": "on the default branch", | |
| 700 | + "activity.published_releases": "published releases", | |
| 701 | + "activity.pull_requests": "opened and merged", | |
| 702 | + "activity.recent_feed": "Activity feed", | |
| 703 | + "activity.badge_commit": "commit", | |
| 704 | + "activity.badge_release": "release", | |
| 705 | + | |
| 706 | + // Issues | |
| 707 | + "issue.new": "New issue", | |
| 708 | + "issue.title": "Title", | |
| 709 | + "issue.title_ph": "Brief summary of the issue", | |
| 710 | + "issue.body": "Description", | |
| 711 | + "issue.body_ph": "Detailed description (Markdown supported)...", | |
| 712 | + "issue.create": "Create issue", | |
| 713 | + "issue.open": "Open", | |
| 714 | + "issue.closed": "Closed", | |
| 715 | + "issue.status.open": "Open", | |
| 716 | + "issue.status.closed": "Closed", | |
| 717 | + "issue.close_btn": "Close issue", | |
| 718 | + "issue.reopen_btn": "Reopen", | |
| 719 | + "issue.comment_ph": "Write a comment (Markdown supported)...", | |
| 720 | + "issue.comment_btn": "Comment", | |
| 721 | + "issue.empty": "No issues in this list", | |
| 722 | + | |
| 723 | + // Settings, Profile & Tokens | |
| 724 | + "settings.title": "Settings", | |
| 725 | + "settings.profile": "Profile", | |
| 726 | + "settings.avatar": "Avatar", | |
| 727 | + "settings.avatar_upload": "Choose image", | |
| 728 | + "settings.avatar_remove": "Remove avatar", | |
| 729 | + "settings.avatar_hint": "PNG, JPG, WebP, or GIF — automatically cropped to 256×256", | |
| 730 | + "settings.full_name": "Display name", | |
| 731 | + "settings.full_name_ph": "Your name", | |
| 732 | + "settings.bio": "Bio", | |
| 733 | + "settings.bio_ph": "Short profile bio", | |
| 734 | + "settings.location": "Location", | |
| 735 | + "settings.location_ph": "City, Country", | |
| 736 | + "settings.website": "Website", | |
| 737 | + "settings.website_ph": "https://example.com", | |
| 738 | + "profile.edit": "Edit profile", | |
| 739 | + "profile.joined": "Joined", | |
| 740 | + "profile.public_repos": "Public", | |
| 741 | + "profile.private_repos": "Private", | |
| 742 | + "profile.total_repos": "Repositories", | |
| 743 | + "settings.tokens": "Access Tokens", | |
| 744 | + "settings.tokens.desc": "Use a token instead of your password for git clone and git push over HTTP.", | |
| 745 | + "settings.token.name": "Token name (e.g., Laptop)", | |
| 746 | + "settings.token.create": "Create", | |
| 747 | + "settings.token.revoke": "Revoke", | |
| 748 | + "settings.token.created_once": "Copy this token now — it will only be shown once:", | |
| 749 | + "settings.token.col_name": "Name", | |
| 750 | + "settings.token.col_prefix": "Prefix", | |
| 751 | + "settings.token.col_created": "Created", | |
| 752 | + "settings.token.col_used": "Last used", | |
| 753 | + "settings.token.empty": "No access tokens yet", | |
| 754 | + "settings.password.current": "Current password", | |
| 755 | + "settings.password.new": "New password", | |
| 756 | + "settings.password.update": "Update password", | |
| 757 | + | |
| 758 | + // Admin | |
| 759 | + "admin.title": "Administration", | |
| 760 | + "admin.features": "Features & Access", | |
| 761 | + "admin.users": "Users", | |
| 762 | + "admin.registration": "Public registration", | |
| 763 | + "admin.registration.desc": "Allow new users to create accounts", | |
| 764 | + "admin.explore": "Explore page", | |
| 765 | + "admin.explore.desc": "Show public repository directory in sidebar and at /explore", | |
| 766 | + "admin.landing": "Landing page", | |
| 767 | + "admin.landing.desc": "Show landing page on root URL (if disabled, redirects to repos or sign in)", | |
| 768 | + "admin.anon_read": "Anonymous read access", | |
| 769 | + "admin.anon_read.desc": "Allow unauthenticated guests to browse public repositories and profiles", | |
| 770 | + "admin.issues": "Issue tracker", | |
| 771 | + "admin.issues.desc": "Enable the Issues tab inside repositories", | |
| 772 | + "admin.enabled": "Enabled", | |
| 773 | + "admin.disabled": "Disabled", | |
| 774 | + "admin.enable_btn": "Enable", | |
| 775 | + "admin.disable_btn": "Disable", | |
| 776 | + "admin.col.user": "User", | |
| 777 | + "admin.col.repos": "Repos", | |
| 778 | + "admin.col.role": "Role", | |
| 779 | + "admin.col.joined": "Joined", | |
| 780 | + "admin.role.admin": "Admin", | |
| 781 | + "admin.role.user": "User", | |
| 782 | + | |
| 783 | + // Command Palette & Shortcuts | |
| 784 | + "palette.placeholder": "Search repositories, files, or commands...", | |
| 785 | + "palette.group.nav": "Navigation", | |
| 786 | + "palette.group.themes": "Theme", | |
| 787 | + "palette.group.repos": "Repositories", | |
| 788 | + "shortcuts.title": "Keyboard shortcuts", | |
| 789 | + "shortcuts.palette": "Command palette", | |
| 790 | + "shortcuts.search": "Focus search", | |
| 791 | + "shortcuts.home": "Go home", | |
| 792 | + "shortcuts.repos": "Repositories", | |
| 793 | + "shortcuts.files": "Filter files", | |
| 794 | + "shortcuts.sidebar": "Toggle sidebar", | |
| 795 | + "nav.toggle_sidebar": "Sidebar (Ctrl+B)", | |
| 796 | + | |
| 797 | + // Flash messages | |
| 798 | + "flash.welcome": "Welcome to Polar, @%s!", | |
| 799 | + "flash.profile_updated": "Profile updated.", | |
| 800 | + "flash.password_updated": "Password changed successfully.", | |
| 801 | + "flash.token_revoked": "Access token revoked.", | |
| 802 | + "flash.settings_updated": "Settings saved.", | |
| 803 | + "flash.error": "Error: %s", | |
| 804 | + "flash.comment_error": "Could not post the comment: %s", | |
| 805 | + "flash.pr_created": "Pull request #%d created.", | |
| 806 | + "flash.pr_closed": "Pull request #%d closed.", | |
| 807 | + "flash.pr_reopened": "Pull request #%d reopened.", | |
| 808 | + "flash.pr_merged": "Pull request #%d merged.", | |
| 809 | + "flash.pr_merged_state": "Cannot change the state of a merged pull request.", | |
| 810 | + "flash.pr_merge_open_only": "Only an open pull request can be merged.", | |
| 811 | + "flash.pr_merge_failed": "Could not merge: %s", | |
| 812 | + "flash.release_tag_empty": "Tag name cannot be empty.", | |
| 813 | + "flash.release_tag_failed": "Could not create the tag: %s", | |
| 814 | + "flash.release_create_failed": "Could not create the release: %s", | |
| 815 | + "flash.release_published": "Release %s published.", | |
| 816 | + "flash.release_delete_failed": "Could not delete the release: %s", | |
| 817 | + "flash.release_deleted": "Release deleted.", | |
| 818 | + "flash.repo_settings_saved": "Repository settings saved.", | |
| 819 | + "flash.repo_rename_failed": "Could not rename: %s", | |
| 820 | + "flash.repo_renamed": "Repository renamed to %s.", | |
| 821 | + "flash.repo_confirm_mismatch": "The name you typed does not match the repository name.", | |
| 822 | + "flash.repo_deleted": "Repository %s deleted.", | |
| 823 | + "flash.branch_created": "Branch %s created.", | |
| 824 | + "flash.branch_create_failed": "Could not create the branch: %s", | |
| 825 | + "flash.branch_default_delete": "The default branch cannot be deleted.", | |
| 826 | + "flash.branch_deleted": "Branch %s deleted.", | |
| 827 | + "flash.branch_delete_failed": "Could not delete the branch: %s", | |
| 828 | + "flash.tag_created": "Tag %s created.", | |
| 829 | + "flash.tag_create_failed": "Could not create the tag: %s", | |
| 830 | + "flash.tag_deleted": "Tag %s deleted.", | |
| 831 | + "flash.tag_delete_failed": "Could not delete the tag: %s", | |
| 832 | + "flash.issue_closed": "Issue #%d closed.", | |
| 833 | + "flash.issue_reopened": "Issue #%d reopened.", | |
| 834 | + | |
| 835 | + // Error pages | |
| 836 | + "err.not_found": "Not found", | |
| 837 | + "err.forbidden": "Access denied", | |
| 838 | + "err.session_error": "Session error", | |
| 839 | + "err.session_create": "Could not create a session.", | |
| 840 | + "err.settings_error": "Settings error", | |
| 841 | + "err.settings_save": "Could not save the setting.", | |
| 842 | + "err.explore_disabled": "Explore is disabled on this server.", | |
| 843 | + "err.activity_disabled": "Activity is disabled for this repository.", | |
| 844 | + "err.pulls_disabled": "Pull requests are disabled for this repository.", | |
| 845 | + "err.releases_disabled": "Releases are disabled for this repository.", | |
| 846 | + "err.issues_disabled": "Issues are disabled for this repository.", | |
| 847 | + "err.repo_not_found": "Repository not found", | |
| 848 | + "err.repo_not_found_desc": "The repository does not exist or you do not have access to it.", | |
| 849 | + "err.repo_private_desc": "The repository does not exist or is private.", | |
| 850 | + "err.access_denied": "Access denied", | |
| 851 | + "err.repo_write_denied": "You do not have permission to modify this repository.", | |
| 852 | + "err.invalid_repo_path": "Invalid repository path", | |
| 853 | + "err.path_not_found": "Path not found", | |
| 854 | + "err.path_not_found_desc": "Could not find the path in the repository for ref %s", | |
| 855 | + "err.file_not_found": "File not found", | |
| 856 | + "err.file_not_found_desc": "The requested file was not found in %s", | |
| 857 | + "err.commit_not_found": "Commit not found", | |
| 858 | + "err.commit_not_found_desc": "Could not find commit %s", | |
| 859 | + "err.pr_not_found": "Pull request not found", | |
| 860 | + "err.pr_not_found_desc": "Could not find the pull request.", | |
| 861 | + "err.pr_state_forbidden": "You cannot change the state of this pull request.", | |
| 862 | + "err.pr_merge_forbidden": "You do not have permission to merge pull requests.", | |
| 863 | + "err.issue_not_found": "Issue not found", | |
| 864 | + "err.issue_not_found_desc": "Could not find the issue.", | |
| 865 | + "err.issue_state_forbidden": "You cannot change the state of this issue.", | |
| 866 | + "err.delete_error": "Delete error", | |
| 867 | + "err.repo_delete_failed": "Could not delete the repository.", | |
| 868 | + "err.user_not_found": "User @%s does not exist.", | |
| 869 | + "err.archive_error": "Archive error", | |
| 870 | + "err.archive_tar_failed": "Could not create a tar.gz archive for %s", | |
| 871 | + "err.archive_zip_failed": "Could not create a zip archive for %s", | |
| 872 | + | |
| 873 | + // Forks | |
| 874 | + "fork.forked_from": "forked from", | |
| 875 | + "fork.forks": "Forks", | |
| 876 | + "fork.forks_btn": "Forks", | |
| 877 | + "fork.fork_btn": "Fork", | |
| 878 | + "fork.fork_btn_desc": "Create a fork of this repository", | |
| 879 | + "fork.your_fork": "Your fork", | |
| 880 | + "fork.your_fork_desc": "Go to your fork", | |
| 881 | + "forks.title": "Forks", | |
| 882 | + "forks.empty": "Nobody has forked this repository yet.", | |
| 883 | + | |
| 884 | + // Collaborators | |
| 885 | + "collab.title": "Collaborators", | |
| 886 | + "collab.desc": "People with access to this repository", | |
| 887 | + "collab.owner": "Owner", | |
| 888 | + "collab.role": "Role", | |
| 889 | + "collab.role_read": "Read", | |
| 890 | + "collab.role_write": "Write", | |
| 891 | + "collab.username": "User", | |
| 892 | + "collab.username_ph": "username", | |
| 893 | + "collab.add": "Add", | |
| 894 | + "collab.remove": "Revoke access", | |
| 895 | + "collab.empty": "No collaborators yet.", | |
| 896 | + | |
| 897 | + // Reviews | |
| 898 | + "reviews.title": "Reviews", | |
| 899 | + "reviews.approved": "Approved", | |
| 900 | + "reviews.changes_requested": "Changes requested", | |
| 901 | + "reviews.body_ph": "Review comment (optional)", | |
| 902 | + "reviews.approve": "Approve", | |
| 903 | + "reviews.request_changes": "Request changes", | |
| 904 | + | |
| 905 | + // Device flow (RFC 8628) | |
| 906 | + "device.title": "Device authorization", | |
| 907 | + "device.badge": "Git", | |
| 908 | + "device.desc": "Enter the code shown by git or the credential helper to grant this device access to your account.", | |
| 909 | + "device.check": "Check", | |
| 910 | + "device.authorize_for": "Grant access to code", | |
| 911 | + "device.scope_hint": "Access covers repositories you can already reach and is bound to this device only.", | |
| 912 | + "device.approve": "Approve", | |
| 913 | + "device.deny": "Deny", | |
| 914 | + "device.signin_to_approve": "Sign in to approve access", | |
| 915 | + "device.helper_hint": "Tip: configure the credential helper so the code is requested automatically — see the README.", | |
| 916 | + "device.code_unknown": "Code not found. Double-check the code you entered.", | |
| 917 | + "device.code_used": "This code has already been used or expired.", | |
| 918 | + "device.approved_now": "All set! The device is authorized — return to your terminal.", | |
| 919 | + "device.denied_now": "Request denied. The device will not get access.", | |
| 920 | + | |
| 921 | + // Flash: forks, collaborators, reviews, device flow | |
| 922 | + "flash.fork_self": "This repository is yours — no fork needed.", | |
| 923 | + "flash.fork_exists": "You already have a fork of this repository.", | |
| 924 | + "flash.fork_created": "Fork created.", | |
| 925 | + "flash.fork_failed": "Could not create fork: %s", | |
| 926 | + "flash.collab_added": "@%s added as a collaborator.", | |
| 927 | + "flash.collab_removed": "Collaborator access revoked.", | |
| 928 | + "flash.collab_failed": "Could not update collaborator: %s", | |
| 929 | + "flash.review_self": "You cannot review your own pull request.", | |
| 930 | + "flash.review_closed": "Reviews are only available on open pull requests.", | |
| 931 | + "flash.review_failed": "Could not save the review: %s", | |
| 932 | + "flash.review_approved": "Review saved: approved.", | |
| 933 | + "flash.review_changes_requested": "Review saved: changes requested.", | |
| 934 | + "flash.device_code_required": "Enter the device code.", | |
| 935 | + "flash.device_code_invalid": "Code not found, expired, or already used.", | |
| 936 | + "flash.device_approved": "Device authorized — you can return to git.", | |
| 937 | + "flash.device_denied": "Request denied.", | |
| 938 | + | |
| 939 | + // Misc | |
| 940 | + "issues.new": "New issue", | |
| 941 | + "pulls.head_repo": "From repository", | |
| 942 | + "repo.about": "About", | |
| 943 | + "repo.clone_title": "Clone", | |
| 944 | + "repo.created": "Created", | |
| 945 | + "repo.updated": "Updated", | |
| 946 | + "repo.view_file": "Open", | |
| 947 | + "err.collab_forbidden": "Only the owner or an administrator can manage collaborators.", | |
| 948 | + "err.pr_head_repo_invalid": "The selected source repository is not allowed."}, | |
| 949 | +} | |
| 950 | + | |
| 951 | +func T(lang Lang, key string) string { | |
| 952 | + dict, ok := translations[Normalize(string(lang))] | |
| 953 | + if !ok { | |
| 954 | + dict = translations[RU] | |
| 955 | + } | |
| 956 | + if val, ok := dict[key]; ok { | |
| 957 | + return val | |
| 958 | + } | |
| 959 | + if val, ok := translations[RU][key]; ok { | |
| 960 | + return val | |
| 961 | + } | |
| 962 | + return key | |
| 963 | +} |
internal/i18n/i18n_test.go
+21
-0
| ... | ... | @@ -0,0 +1,21 @@ |
| 1 | +package i18n | |
| 2 | + | |
| 3 | +import "testing" | |
| 4 | + | |
| 5 | +// TestTranslationParity ensures both dictionaries expose the same keys. | |
| 6 | +func TestTranslationParity(t *testing.T) { | |
| 7 | + ru, en := translations[RU], translations[EN] | |
| 8 | + for k := range ru { | |
| 9 | + if _, ok := en[k]; !ok { | |
| 10 | + t.Errorf("EN is missing key %q", k) | |
| 11 | + } | |
| 12 | + } | |
| 13 | + for k := range en { | |
| 14 | + if _, ok := ru[k]; !ok { | |
| 15 | + t.Errorf("RU is missing key %q", k) | |
| 16 | + } | |
| 17 | + } | |
| 18 | + if len(ru) == 0 { | |
| 19 | + t.Fatal("empty dictionary") | |
| 20 | + } | |
| 21 | +} |
internal/repo/activity.go
+136
-0
| ... | ... | @@ -0,0 +1,136 @@ |
| 1 | +package repo | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "fmt" | |
| 6 | + "sort" | |
| 7 | + "time" | |
| 8 | + | |
| 9 | + "polar/internal/git" | |
| 10 | +) | |
| 11 | + | |
| 12 | +type ActivityItem struct { | |
| 13 | + Type string // "commit", "pull", "issue", "release" | |
| 14 | + Title string | |
| 15 | + Subtitle string | |
| 16 | + AuthorUsername string | |
| 17 | + AuthorAvatar string | |
| 18 | + Link string | |
| 19 | + Icon string | |
| 20 | + Badge string | |
| 21 | + CreatedAt time.Time | |
| 22 | +} | |
| 23 | + | |
| 24 | +type ActivityStats struct { | |
| 25 | + CommitCount int | |
| 26 | + OpenIssues int | |
| 27 | + ClosedIssues int | |
| 28 | + OpenPRs int | |
| 29 | + MergedPRs int | |
| 30 | + ReleaseCount int | |
| 31 | +} | |
| 32 | + | |
| 33 | +func (s *Service) GetRepoActivity(ctx context.Context, r *Repository, diskPath string) ([]ActivityItem, *ActivityStats, error) { | |
| 34 | + var items []ActivityItem | |
| 35 | + | |
| 36 | + // 1. Recent commits from default branch | |
| 37 | + commits, _ := git.ListCommits(ctx, diskPath, r.DefaultBranch, 25, 0) | |
| 38 | + for _, c := range commits { | |
| 39 | + items = append(items, ActivityItem{ | |
| 40 | + Type: "commit", | |
| 41 | + Title: c.Subject, | |
| 42 | + Subtitle: c.ShortHash, | |
| 43 | + AuthorUsername: c.AuthorName, | |
| 44 | + AuthorAvatar: "", | |
| 45 | + Link: fmt.Sprintf("/%s/%s/commit/%s", r.OwnerUsername, r.Name, c.Hash), | |
| 46 | + Icon: "git-commit", | |
| 47 | + Badge: "commit", | |
| 48 | + CreatedAt: c.AuthoredAt, | |
| 49 | + }) | |
| 50 | + } | |
| 51 | + | |
| 52 | + // 2. Recent Pull Requests | |
| 53 | + pulls, _ := s.ListPullRequests(ctx, r.ID, "all") | |
| 54 | + var openPRs, mergedPRs int | |
| 55 | + for _, pr := range pulls { | |
| 56 | + switch pr.Status { | |
| 57 | + case "open": | |
| 58 | + openPRs++ | |
| 59 | + case "merged": | |
| 60 | + mergedPRs++ | |
| 61 | + } | |
| 62 | + badge := pr.Status | |
| 63 | + icon := "git-pull-request" | |
| 64 | + items = append(items, ActivityItem{ | |
| 65 | + Type: "pull", | |
| 66 | + Title: fmt.Sprintf("#%d %s", pr.Number, pr.Title), | |
| 67 | + Subtitle: fmt.Sprintf("%s → %s", pr.HeadBranch, pr.BaseBranch), | |
| 68 | + AuthorUsername: pr.AuthorUsername, | |
| 69 | + AuthorAvatar: pr.AuthorAvatar, | |
| 70 | + Link: fmt.Sprintf("/%s/%s/pulls/%d", r.OwnerUsername, r.Name, pr.Number), | |
| 71 | + Icon: icon, | |
| 72 | + Badge: badge, | |
| 73 | + CreatedAt: pr.CreatedAt, | |
| 74 | + }) | |
| 75 | + } | |
| 76 | + | |
| 77 | + // 3. Recent Issues | |
| 78 | + openIssList, _ := s.ListIssues(ctx, r.ID, false) | |
| 79 | + closedIssList, _ := s.ListIssues(ctx, r.ID, true) | |
| 80 | + openIssues := len(openIssList) | |
| 81 | + closedIssues := len(closedIssList) | |
| 82 | + allIssues := append(openIssList, closedIssList...) | |
| 83 | + for _, iss := range allIssues { | |
| 84 | + badge := "open" | |
| 85 | + if iss.IsClosed { | |
| 86 | + badge = "closed" | |
| 87 | + } | |
| 88 | + items = append(items, ActivityItem{ | |
| 89 | + Type: "issue", | |
| 90 | + Title: fmt.Sprintf("#%d %s", iss.Number, iss.Title), | |
| 91 | + Subtitle: "", | |
| 92 | + AuthorUsername: iss.AuthorUsername, | |
| 93 | + AuthorAvatar: iss.AuthorAvatar, | |
| 94 | + Link: fmt.Sprintf("/%s/%s/issues/%d", r.OwnerUsername, r.Name, iss.Number), | |
| 95 | + Icon: "issue", | |
| 96 | + Badge: badge, | |
| 97 | + CreatedAt: iss.CreatedAt, | |
| 98 | + }) | |
| 99 | + } | |
| 100 | + | |
| 101 | + // 4. Recent Releases | |
| 102 | + releases, _ := s.ListReleases(ctx, r.ID) | |
| 103 | + for _, rel := range releases { | |
| 104 | + items = append(items, ActivityItem{ | |
| 105 | + Type: "release", | |
| 106 | + Title: fmt.Sprintf("%s (%s)", rel.Title, rel.TagName), | |
| 107 | + Subtitle: "", | |
| 108 | + AuthorUsername: rel.AuthorUsername, | |
| 109 | + AuthorAvatar: rel.AuthorAvatar, | |
| 110 | + Link: fmt.Sprintf("/%s/%s/releases#%s", r.OwnerUsername, r.Name, rel.TagName), | |
| 111 | + Icon: "tag", | |
| 112 | + Badge: "release", | |
| 113 | + CreatedAt: rel.PublishedAt, | |
| 114 | + }) | |
| 115 | + } | |
| 116 | + | |
| 117 | + // Sort combined timeline in reverse chronological order | |
| 118 | + sort.Slice(items, func(i, j int) bool { | |
| 119 | + return items[i].CreatedAt.After(items[j].CreatedAt) | |
| 120 | + }) | |
| 121 | + | |
| 122 | + if len(items) > 50 { | |
| 123 | + items = items[:50] | |
| 124 | + } | |
| 125 | + | |
| 126 | + stats := &ActivityStats{ | |
| 127 | + CommitCount: len(commits), | |
| 128 | + OpenIssues: openIssues, | |
| 129 | + ClosedIssues: closedIssues, | |
| 130 | + OpenPRs: openPRs, | |
| 131 | + MergedPRs: mergedPRs, | |
| 132 | + ReleaseCount: len(releases), | |
| 133 | + } | |
| 134 | + | |
| 135 | + return items, stats, nil | |
| 136 | +} |
internal/repo/collab.go
+152
-0
| ... | ... | @@ -0,0 +1,152 @@ |
| 1 | +package repo | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "database/sql" | |
| 6 | + "errors" | |
| 7 | + "fmt" | |
| 8 | + "strings" | |
| 9 | + "time" | |
| 10 | + | |
| 11 | + "polar/internal/user" | |
| 12 | +) | |
| 13 | + | |
| 14 | +const ( | |
| 15 | + RoleRead = "read" | |
| 16 | + RoleWrite = "write" | |
| 17 | +) | |
| 18 | + | |
| 19 | +var ( | |
| 20 | + ErrCollabUserNotFound = errors.New("user not found") | |
| 21 | + ErrCannotAddOwner = errors.New("repository owner cannot be added as a collaborator") | |
| 22 | +) | |
| 23 | + | |
| 24 | +type Collaborator struct { | |
| 25 | + UserID int64 | |
| 26 | + Username string | |
| 27 | + FullName string | |
| 28 | + AvatarURL string | |
| 29 | + Role string | |
| 30 | + CreatedAt time.Time | |
| 31 | +} | |
| 32 | + | |
| 33 | +func normalizeRole(role string) string { | |
| 34 | + if strings.TrimSpace(role) == RoleRead { | |
| 35 | + return RoleRead | |
| 36 | + } | |
| 37 | + return RoleWrite | |
| 38 | +} | |
| 39 | + | |
| 40 | +func (s *Service) AddCollaborator(ctx context.Context, r *Repository, username, role string) error { | |
| 41 | + username = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(username), "@")) | |
| 42 | + if username == "" { | |
| 43 | + return ErrCollabUserNotFound | |
| 44 | + } | |
| 45 | + | |
| 46 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 47 | + defer cancel() | |
| 48 | + | |
| 49 | + var userID int64 | |
| 50 | + err := s.db.QueryRowContext(cctx, `SELECT id FROM users WHERE LOWER(username) = LOWER(?)`, username).Scan(&userID) | |
| 51 | + if err != nil { | |
| 52 | + if errors.Is(err, sql.ErrNoRows) { | |
| 53 | + return ErrCollabUserNotFound | |
| 54 | + } | |
| 55 | + return fmt.Errorf("lookup collaborator user: %w", err) | |
| 56 | + } | |
| 57 | + if userID == r.OwnerID { | |
| 58 | + return ErrCannotAddOwner | |
| 59 | + } | |
| 60 | + | |
| 61 | + now := time.Now().UTC() | |
| 62 | + _, err = s.db.ExecContext(cctx, ` | |
| 63 | + INSERT INTO collaborators (repo_id, user_id, role, created_at) | |
| 64 | + VALUES (?, ?, ?, ?) | |
| 65 | + ON CONFLICT(repo_id, user_id) DO UPDATE SET role = excluded.role | |
| 66 | + `, r.ID, userID, normalizeRole(role), now) | |
| 67 | + if err != nil { | |
| 68 | + return fmt.Errorf("upsert collaborator: %w", err) | |
| 69 | + } | |
| 70 | + return nil | |
| 71 | +} | |
| 72 | + | |
| 73 | +func (s *Service) RemoveCollaborator(ctx context.Context, repoID, userID int64) error { | |
| 74 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 75 | + defer cancel() | |
| 76 | + | |
| 77 | + _, err := s.db.ExecContext(cctx, `DELETE FROM collaborators WHERE repo_id = ? AND user_id = ?`, repoID, userID) | |
| 78 | + if err != nil { | |
| 79 | + return fmt.Errorf("remove collaborator: %w", err) | |
| 80 | + } | |
| 81 | + return nil | |
| 82 | +} | |
| 83 | + | |
| 84 | +func (s *Service) ListCollaborators(ctx context.Context, repoID int64) ([]Collaborator, error) { | |
| 85 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 86 | + defer cancel() | |
| 87 | + | |
| 88 | + rows, err := s.db.QueryContext(cctx, ` | |
| 89 | + SELECT c.user_id, u.username, u.full_name, u.avatar_url, c.role, c.created_at | |
| 90 | + FROM collaborators c | |
| 91 | + JOIN users u ON u.id = c.user_id | |
| 92 | + WHERE c.repo_id = ? | |
| 93 | + ORDER BY c.created_at ASC | |
| 94 | + `, repoID) | |
| 95 | + if err != nil { | |
| 96 | + return nil, fmt.Errorf("list collaborators: %w", err) | |
| 97 | + } | |
| 98 | + defer rows.Close() | |
| 99 | + | |
| 100 | + var list []Collaborator | |
| 101 | + for rows.Next() { | |
| 102 | + var c Collaborator | |
| 103 | + if err := rows.Scan(&c.UserID, &c.Username, &c.FullName, &c.AvatarURL, &c.Role, &c.CreatedAt); err != nil { | |
| 104 | + return nil, fmt.Errorf("scan collaborator: %w", err) | |
| 105 | + } | |
| 106 | + list = append(list, c) | |
| 107 | + } | |
| 108 | + return list, rows.Err() | |
| 109 | +} | |
| 110 | + | |
| 111 | +func (s *Service) GetCollaboratorRole(ctx context.Context, repoID, userID int64) string { | |
| 112 | + cctx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 113 | + defer cancel() | |
| 114 | + | |
| 115 | + var role string | |
| 116 | + err := s.db.QueryRowContext(cctx, `SELECT role FROM collaborators WHERE repo_id = ? AND user_id = ?`, repoID, userID).Scan(&role) | |
| 117 | + if err != nil { | |
| 118 | + return "" | |
| 119 | + } | |
| 120 | + return role | |
| 121 | +} | |
| 122 | + | |
| 123 | +// CanRead reports whether the user may read the repository (owner, admin, | |
| 124 | +// collaborator of any role, or anyone for public repositories). | |
| 125 | +func (s *Service) CanRead(ctx context.Context, r *Repository, u *user.User) bool { | |
| 126 | + if r == nil { | |
| 127 | + return false | |
| 128 | + } | |
| 129 | + if !r.IsPrivate { | |
| 130 | + return true | |
| 131 | + } | |
| 132 | + if u == nil { | |
| 133 | + return false | |
| 134 | + } | |
| 135 | + if u.ID == r.OwnerID || u.IsAdmin { | |
| 136 | + return true | |
| 137 | + } | |
| 138 | + role := s.GetCollaboratorRole(ctx, r.ID, u.ID) | |
| 139 | + return role == RoleRead || role == RoleWrite | |
| 140 | +} | |
| 141 | + | |
| 142 | +// CanWrite reports whether the user may push to the repository (owner, admin | |
| 143 | +// or a collaborator with the write role). | |
| 144 | +func (s *Service) CanWrite(ctx context.Context, r *Repository, u *user.User) bool { | |
| 145 | + if r == nil || u == nil { | |
| 146 | + return false | |
| 147 | + } | |
| 148 | + if u.ID == r.OwnerID || u.IsAdmin { | |
| 149 | + return true | |
| 150 | + } | |
| 151 | + return s.GetCollaboratorRole(ctx, r.ID, u.ID) == RoleWrite | |
| 152 | +} |
internal/repo/issues.go
+234
-0
| ... | ... | @@ -0,0 +1,234 @@ |
| 1 | +package repo | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "database/sql" | |
| 6 | + "errors" | |
| 7 | + "fmt" | |
| 8 | + "strings" | |
| 9 | + "time" | |
| 10 | +) | |
| 11 | + | |
| 12 | +var ( | |
| 13 | + ErrIssueNotFound = errors.New("issue not found") | |
| 14 | + ErrEmptyTitle = errors.New("issue title cannot be empty") | |
| 15 | + ErrEmptyComment = errors.New("comment cannot be empty") | |
| 16 | +) | |
| 17 | + | |
| 18 | +type Issue struct { | |
| 19 | + ID int64 | |
| 20 | + RepoID int64 | |
| 21 | + Number int | |
| 22 | + AuthorID int64 | |
| 23 | + AuthorUsername string | |
| 24 | + AuthorAvatar string | |
| 25 | + Title string | |
| 26 | + Body string | |
| 27 | + IsClosed bool | |
| 28 | + CommentCount int | |
| 29 | + CreatedAt time.Time | |
| 30 | + UpdatedAt time.Time | |
| 31 | +} | |
| 32 | + | |
| 33 | +type IssueComment struct { | |
| 34 | + ID int64 | |
| 35 | + IssueID int64 | |
| 36 | + AuthorID int64 | |
| 37 | + AuthorUsername string | |
| 38 | + AuthorAvatar string | |
| 39 | + Body string | |
| 40 | + CreatedAt time.Time | |
| 41 | +} | |
| 42 | + | |
| 43 | +func (s *Service) CreateIssue(ctx context.Context, repoID, authorID int64, title, body string) (*Issue, error) { | |
| 44 | + title = strings.TrimSpace(title) | |
| 45 | + body = strings.TrimSpace(body) | |
| 46 | + if title == "" { | |
| 47 | + return nil, ErrEmptyTitle | |
| 48 | + } | |
| 49 | + if len(title) > 200 { | |
| 50 | + title = title[:200] | |
| 51 | + } | |
| 52 | + if len(body) > 20000 { | |
| 53 | + body = body[:20000] | |
| 54 | + } | |
| 55 | + | |
| 56 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 57 | + defer cancel() | |
| 58 | + | |
| 59 | + tx, err := s.db.BeginTx(cctx, nil) | |
| 60 | + if err != nil { | |
| 61 | + return nil, fmt.Errorf("begin create issue tx: %w", err) | |
| 62 | + } | |
| 63 | + defer func() { _ = tx.Rollback() }() | |
| 64 | + | |
| 65 | + var nextNum int | |
| 66 | + err = tx.QueryRowContext(cctx, `SELECT COALESCE(MAX(number), 0) + 1 FROM issues WHERE repo_id = ?`, repoID).Scan(&nextNum) | |
| 67 | + if err != nil { | |
| 68 | + return nil, fmt.Errorf("next issue number: %w", err) | |
| 69 | + } | |
| 70 | + | |
| 71 | + now := time.Now().UTC() | |
| 72 | + res, err := tx.ExecContext(cctx, ` | |
| 73 | + INSERT INTO issues (repo_id, number, author_id, title, body, is_closed, created_at, updated_at) | |
| 74 | + VALUES (?, ?, ?, ?, ?, 0, ?, ?) | |
| 75 | + `, repoID, nextNum, authorID, title, body, now, now) | |
| 76 | + if err != nil { | |
| 77 | + return nil, fmt.Errorf("insert issue: %w", err) | |
| 78 | + } | |
| 79 | + | |
| 80 | + id, _ := res.LastInsertId() | |
| 81 | + if err := tx.Commit(); err != nil { | |
| 82 | + return nil, fmt.Errorf("commit issue tx: %w", err) | |
| 83 | + } | |
| 84 | + | |
| 85 | + return &Issue{ | |
| 86 | + ID: id, | |
| 87 | + RepoID: repoID, | |
| 88 | + Number: nextNum, | |
| 89 | + AuthorID: authorID, | |
| 90 | + Title: title, | |
| 91 | + Body: body, | |
| 92 | + IsClosed: false, | |
| 93 | + CreatedAt: now, | |
| 94 | + UpdatedAt: now, | |
| 95 | + }, nil | |
| 96 | +} | |
| 97 | + | |
| 98 | +func (s *Service) ListIssues(ctx context.Context, repoID int64, isClosed bool) ([]Issue, error) { | |
| 99 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 100 | + defer cancel() | |
| 101 | + | |
| 102 | + closedInt := 0 | |
| 103 | + if isClosed { | |
| 104 | + closedInt = 1 | |
| 105 | + } | |
| 106 | + | |
| 107 | + rows, err := s.db.QueryContext(cctx, ` | |
| 108 | + SELECT i.id, i.repo_id, i.number, i.author_id, u.username, u.avatar_url, | |
| 109 | + i.title, i.body, i.is_closed, | |
| 110 | + (SELECT COUNT(*) FROM issue_comments ic WHERE ic.issue_id = i.id) AS comment_count, | |
| 111 | + i.created_at, i.updated_at | |
| 112 | + FROM issues i | |
| 113 | + JOIN users u ON u.id = i.author_id | |
| 114 | + WHERE i.repo_id = ? AND i.is_closed = ? | |
| 115 | + ORDER BY i.number DESC | |
| 116 | + `, repoID, closedInt) | |
| 117 | + if err != nil { | |
| 118 | + return nil, fmt.Errorf("list issues: %w", err) | |
| 119 | + } | |
| 120 | + defer rows.Close() | |
| 121 | + | |
| 122 | + var issues []Issue | |
| 123 | + for rows.Next() { | |
| 124 | + var iss Issue | |
| 125 | + var closed int | |
| 126 | + if err := rows.Scan(&iss.ID, &iss.RepoID, &iss.Number, &iss.AuthorID, &iss.AuthorUsername, &iss.AuthorAvatar, | |
| 127 | + &iss.Title, &iss.Body, &closed, &iss.CommentCount, &iss.CreatedAt, &iss.UpdatedAt); err != nil { | |
| 128 | + return nil, fmt.Errorf("scan issue: %w", err) | |
| 129 | + } | |
| 130 | + iss.IsClosed = closed == 1 | |
| 131 | + issues = append(issues, iss) | |
| 132 | + } | |
| 133 | + return issues, rows.Err() | |
| 134 | +} | |
| 135 | + | |
| 136 | +func (s *Service) CountIssues(ctx context.Context, repoID int64) (openCount int, closedCount int, err error) { | |
| 137 | + cctx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 138 | + defer cancel() | |
| 139 | + | |
| 140 | + _ = s.db.QueryRowContext(cctx, `SELECT COUNT(*) FROM issues WHERE repo_id = ? AND is_closed = 0`, repoID).Scan(&openCount) | |
| 141 | + _ = s.db.QueryRowContext(cctx, `SELECT COUNT(*) FROM issues WHERE repo_id = ? AND is_closed = 1`, repoID).Scan(&closedCount) | |
| 142 | + return openCount, closedCount, nil | |
| 143 | +} | |
| 144 | + | |
| 145 | +func (s *Service) GetIssue(ctx context.Context, repoID int64, number int) (*Issue, error) { | |
| 146 | + cctx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 147 | + defer cancel() | |
| 148 | + | |
| 149 | + var iss Issue | |
| 150 | + var closed int | |
| 151 | + err := s.db.QueryRowContext(cctx, ` | |
| 152 | + SELECT i.id, i.repo_id, i.number, i.author_id, u.username, u.avatar_url, | |
| 153 | + i.title, i.body, i.is_closed, | |
| 154 | + (SELECT COUNT(*) FROM issue_comments ic WHERE ic.issue_id = i.id) AS comment_count, | |
| 155 | + i.created_at, i.updated_at | |
| 156 | + FROM issues i | |
| 157 | + JOIN users u ON u.id = i.author_id | |
| 158 | + WHERE i.repo_id = ? AND i.number = ? | |
| 159 | + `, repoID, number).Scan(&iss.ID, &iss.RepoID, &iss.Number, &iss.AuthorID, &iss.AuthorUsername, &iss.AuthorAvatar, | |
| 160 | + &iss.Title, &iss.Body, &closed, &iss.CommentCount, &iss.CreatedAt, &iss.UpdatedAt) | |
| 161 | + if err != nil { | |
| 162 | + if errors.Is(err, sql.ErrNoRows) { | |
| 163 | + return nil, ErrIssueNotFound | |
| 164 | + } | |
| 165 | + return nil, fmt.Errorf("get issue: %w", err) | |
| 166 | + } | |
| 167 | + iss.IsClosed = closed == 1 | |
| 168 | + return &iss, nil | |
| 169 | +} | |
| 170 | + | |
| 171 | +func (s *Service) SetIssueClosed(ctx context.Context, issueID int64, closed bool) error { | |
| 172 | + cctx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 173 | + defer cancel() | |
| 174 | + | |
| 175 | + val := 0 | |
| 176 | + if closed { | |
| 177 | + val = 1 | |
| 178 | + } | |
| 179 | + _, err := s.db.ExecContext(cctx, ` | |
| 180 | + UPDATE issues SET is_closed = ?, updated_at = ? WHERE id = ? | |
| 181 | + `, val, time.Now().UTC(), issueID) | |
| 182 | + return err | |
| 183 | +} | |
| 184 | + | |
| 185 | +func (s *Service) CreateIssueComment(ctx context.Context, issueID, authorID int64, body string) error { | |
| 186 | + body = strings.TrimSpace(body) | |
| 187 | + if body == "" { | |
| 188 | + return ErrEmptyComment | |
| 189 | + } | |
| 190 | + if len(body) > 20000 { | |
| 191 | + body = body[:20000] | |
| 192 | + } | |
| 193 | + | |
| 194 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 195 | + defer cancel() | |
| 196 | + | |
| 197 | + now := time.Now().UTC() | |
| 198 | + _, err := s.db.ExecContext(cctx, ` | |
| 199 | + INSERT INTO issue_comments (issue_id, author_id, body, created_at) | |
| 200 | + VALUES (?, ?, ?, ?) | |
| 201 | + `, issueID, authorID, body, now) | |
| 202 | + if err != nil { | |
| 203 | + return fmt.Errorf("insert comment: %w", err) | |
| 204 | + } | |
| 205 | + _, _ = s.db.ExecContext(cctx, `UPDATE issues SET updated_at = ? WHERE id = ?`, now, issueID) | |
| 206 | + return nil | |
| 207 | +} | |
| 208 | + | |
| 209 | +func (s *Service) ListIssueComments(ctx context.Context, issueID int64) ([]IssueComment, error) { | |
| 210 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 211 | + defer cancel() | |
| 212 | + | |
| 213 | + rows, err := s.db.QueryContext(cctx, ` | |
| 214 | + SELECT ic.id, ic.issue_id, ic.author_id, u.username, u.avatar_url, ic.body, ic.created_at | |
| 215 | + FROM issue_comments ic | |
| 216 | + JOIN users u ON u.id = ic.author_id | |
| 217 | + WHERE ic.issue_id = ? | |
| 218 | + ORDER BY ic.created_at ASC | |
| 219 | + `, issueID) | |
| 220 | + if err != nil { | |
| 221 | + return nil, fmt.Errorf("list issue comments: %w", err) | |
| 222 | + } | |
| 223 | + defer rows.Close() | |
| 224 | + | |
| 225 | + var list []IssueComment | |
| 226 | + for rows.Next() { | |
| 227 | + var c IssueComment | |
| 228 | + if err := rows.Scan(&c.ID, &c.IssueID, &c.AuthorID, &c.AuthorUsername, &c.AuthorAvatar, &c.Body, &c.CreatedAt); err != nil { | |
| 229 | + return nil, fmt.Errorf("scan issue comment: %w", err) | |
| 230 | + } | |
| 231 | + list = append(list, c) | |
| 232 | + } | |
| 233 | + return list, rows.Err() | |
| 234 | +} |
internal/repo/merge.go
+60
-0
| ... | ... | @@ -0,0 +1,60 @@ |
| 1 | +package repo | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "errors" | |
| 6 | + "fmt" | |
| 7 | + | |
| 8 | + "polar/internal/git" | |
| 9 | +) | |
| 10 | + | |
| 11 | +var ErrForkNotFound = errors.New("fork repository not found") | |
| 12 | + | |
| 13 | +// MergePullRequest merges an open pull request into its base branch and | |
| 14 | +// records the merge. Same-repository pull requests merge local branches; | |
| 15 | +// cross-repository (fork) pull requests are merged through a temporary ref | |
| 16 | +// fetched from the fork. | |
| 17 | +func (s *Service) MergePullRequest(ctx context.Context, base *Repository, pr *PullRequest, authorName, authorEmail string, mergedByID int64) error { | |
| 18 | + if pr == nil || base == nil { | |
| 19 | + return errors.New("pull request and repository are required") | |
| 20 | + } | |
| 21 | + if pr.Status != "open" { | |
| 22 | + return errors.New("only open pull requests can be merged") | |
| 23 | + } | |
| 24 | + | |
| 25 | + baseDisk, err := s.DiskPath(base.OwnerUsername, base.Name) | |
| 26 | + if err != nil { | |
| 27 | + return err | |
| 28 | + } | |
| 29 | + | |
| 30 | + headLabel := pr.HeadBranch | |
| 31 | + if pr.HeadRepoID != nil && *pr.HeadRepoID != base.ID { | |
| 32 | + headLabel = fmt.Sprintf("%s/%s:%s", pr.HeadRepoOwner, pr.HeadRepoName, pr.HeadBranch) | |
| 33 | + } | |
| 34 | + msg := fmt.Sprintf("Merge pull request #%d from %s\n\n%s", pr.Number, headLabel, pr.Title) | |
| 35 | + | |
| 36 | + if pr.HeadRepoID != nil && *pr.HeadRepoID != base.ID { | |
| 37 | + head, err := s.GetByID(ctx, *pr.HeadRepoID) | |
| 38 | + if err != nil || head == nil { | |
| 39 | + return ErrForkNotFound | |
| 40 | + } | |
| 41 | + forkDisk, err := s.DiskPath(head.OwnerUsername, head.Name) | |
| 42 | + if err != nil { | |
| 43 | + return err | |
| 44 | + } | |
| 45 | + tmpRef := fmt.Sprintf("refs/polar/tmp-merge-%d", pr.ID) | |
| 46 | + if _, err := git.MergeFromPath(ctx, baseDisk, forkDisk, pr.BaseBranch, pr.HeadBranch, tmpRef, msg, authorName, authorEmail); err != nil { | |
| 47 | + return err | |
| 48 | + } | |
| 49 | + } else { | |
| 50 | + if _, err := git.MergeBranches(ctx, baseDisk, pr.BaseBranch, pr.HeadBranch, msg, authorName, authorEmail); err != nil { | |
| 51 | + return err | |
| 52 | + } | |
| 53 | + } | |
| 54 | + | |
| 55 | + if err := s.SetPullRequestStatus(ctx, pr.ID, "merged", &mergedByID); err != nil { | |
| 56 | + return err | |
| 57 | + } | |
| 58 | + s.TouchUpdatedAt(ctx, base.ID) | |
| 59 | + return nil | |
| 60 | +} |
internal/repo/pulls.go
+331
-0
| ... | ... | @@ -0,0 +1,331 @@ |
| 1 | +package repo | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "database/sql" | |
| 6 | + "errors" | |
| 7 | + "fmt" | |
| 8 | + "strings" | |
| 9 | + "time" | |
| 10 | +) | |
| 11 | + | |
| 12 | +var ( | |
| 13 | + ErrPullNotFound = errors.New("pull request not found") | |
| 14 | + ErrEmptyPullTitle = errors.New("pull request title cannot be empty") | |
| 15 | + ErrSameBranch = errors.New("base and head branches cannot be the same") | |
| 16 | + ErrEmptyBranch = errors.New("branches must be specified") | |
| 17 | + ErrPullAlreadyOpen = errors.New("a pull request for these branches is already open") | |
| 18 | +) | |
| 19 | + | |
| 20 | +type PullRequest struct { | |
| 21 | + ID int64 | |
| 22 | + RepoID int64 | |
| 23 | + Number int | |
| 24 | + AuthorID int64 | |
| 25 | + AuthorUsername string | |
| 26 | + AuthorAvatar string | |
| 27 | + Title string | |
| 28 | + Body string | |
| 29 | + BaseBranch string | |
| 30 | + HeadBranch string | |
| 31 | + Status string // "open", "closed", "merged" | |
| 32 | + MergedByID *int64 | |
| 33 | + HeadRepoID *int64 | |
| 34 | + HeadRepoOwner string | |
| 35 | + HeadRepoName string | |
| 36 | + MergedByUsername string | |
| 37 | + MergedAt *time.Time | |
| 38 | + CommentCount int | |
| 39 | + CreatedAt time.Time | |
| 40 | + UpdatedAt time.Time | |
| 41 | +} | |
| 42 | + | |
| 43 | +type PullRequestComment struct { | |
| 44 | + ID int64 | |
| 45 | + PullID int64 | |
| 46 | + AuthorID int64 | |
| 47 | + AuthorUsername string | |
| 48 | + AuthorAvatar string | |
| 49 | + Body string | |
| 50 | + CreatedAt time.Time | |
| 51 | +} | |
| 52 | + | |
| 53 | +func (s *Service) CreatePullRequest(ctx context.Context, repoID, authorID int64, title, body, baseBranch, headBranch string, headRepoID *int64) (*PullRequest, error) { | |
| 54 | + title = strings.TrimSpace(title) | |
| 55 | + body = strings.TrimSpace(body) | |
| 56 | + baseBranch = strings.TrimSpace(baseBranch) | |
| 57 | + headBranch = strings.TrimSpace(headBranch) | |
| 58 | + | |
| 59 | + if title == "" { | |
| 60 | + return nil, ErrEmptyPullTitle | |
| 61 | + } | |
| 62 | + if len(title) > 200 { | |
| 63 | + title = title[:200] | |
| 64 | + } | |
| 65 | + if len(body) > 20000 { | |
| 66 | + body = body[:20000] | |
| 67 | + } | |
| 68 | + if baseBranch == "" || headBranch == "" { | |
| 69 | + return nil, ErrEmptyBranch | |
| 70 | + } | |
| 71 | + if baseBranch == headBranch { | |
| 72 | + return nil, ErrSameBranch | |
| 73 | + } | |
| 74 | + | |
| 75 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 76 | + defer cancel() | |
| 77 | + | |
| 78 | + tx, err := s.db.BeginTx(cctx, nil) | |
| 79 | + if err != nil { | |
| 80 | + return nil, fmt.Errorf("begin create pr tx: %w", err) | |
| 81 | + } | |
| 82 | + defer func() { _ = tx.Rollback() }() | |
| 83 | + | |
| 84 | + var existingID int64 | |
| 85 | + err = tx.QueryRowContext(cctx, ` | |
| 86 | + SELECT id FROM pull_requests | |
| 87 | + WHERE repo_id = ? AND base_branch = ? AND head_branch = ? AND status = 'open' AND head_repo_id IS ? | |
| 88 | + `, repoID, baseBranch, headBranch, headRepoID).Scan(&existingID) | |
| 89 | + if err == nil { | |
| 90 | + return nil, ErrPullAlreadyOpen | |
| 91 | + } | |
| 92 | + | |
| 93 | + var nextNum int | |
| 94 | + err = tx.QueryRowContext(cctx, `SELECT COALESCE(MAX(number), 0) + 1 FROM pull_requests WHERE repo_id = ?`, repoID).Scan(&nextNum) | |
| 95 | + if err != nil { | |
| 96 | + return nil, fmt.Errorf("next pr number: %w", err) | |
| 97 | + } | |
| 98 | + | |
| 99 | + now := time.Now().UTC() | |
| 100 | + res, err := tx.ExecContext(cctx, ` | |
| 101 | + INSERT INTO pull_requests (repo_id, number, author_id, title, body, base_branch, head_branch, head_repo_id, status, created_at, updated_at) | |
| 102 | + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?) | |
| 103 | + `, repoID, nextNum, authorID, title, body, baseBranch, headBranch, headRepoID, now, now) | |
| 104 | + if err != nil { | |
| 105 | + return nil, fmt.Errorf("insert pull request: %w", err) | |
| 106 | + } | |
| 107 | + | |
| 108 | + _ = res | |
| 109 | + if err := tx.Commit(); err != nil { | |
| 110 | + return nil, fmt.Errorf("commit pull request tx: %w", err) | |
| 111 | + } | |
| 112 | + | |
| 113 | + return s.GetPullRequest(ctx, repoID, nextNum) | |
| 114 | +} | |
| 115 | + | |
| 116 | +func (s *Service) GetPullRequest(ctx context.Context, repoID int64, number int) (*PullRequest, error) { | |
| 117 | + cctx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 118 | + defer cancel() | |
| 119 | + | |
| 120 | + var pr PullRequest | |
| 121 | + var mergedByID sql.NullInt64 | |
| 122 | + var mergedByUsername sql.NullString | |
| 123 | + var mergedAt sql.NullTime | |
| 124 | + var headRepoID sql.NullInt64 | |
| 125 | + var headRepoOwner, headRepoName sql.NullString | |
| 126 | + | |
| 127 | + err := s.db.QueryRowContext(cctx, ` | |
| 128 | + SELECT pr.id, pr.repo_id, pr.number, pr.author_id, u.username, u.avatar_url, | |
| 129 | + pr.title, pr.body, pr.base_branch, pr.head_branch, pr.status, | |
| 130 | + pr.merged_by_id, mu.username, pr.merged_at, | |
| 131 | + pr.head_repo_id, hu.username, hr.name, | |
| 132 | + (SELECT COUNT(*) FROM pull_request_comments prc WHERE prc.pull_id = pr.id) AS comment_count, | |
| 133 | + pr.created_at, pr.updated_at | |
| 134 | + FROM pull_requests pr | |
| 135 | + JOIN users u ON u.id = pr.author_id | |
| 136 | + LEFT JOIN users mu ON mu.id = pr.merged_by_id | |
| 137 | + LEFT JOIN repositories hr ON hr.id = pr.head_repo_id | |
| 138 | + LEFT JOIN users hu ON hu.id = hr.owner_id | |
| 139 | + WHERE pr.repo_id = ? AND pr.number = ? | |
| 140 | + `, repoID, number).Scan( | |
| 141 | + &pr.ID, &pr.RepoID, &pr.Number, &pr.AuthorID, &pr.AuthorUsername, &pr.AuthorAvatar, | |
| 142 | + &pr.Title, &pr.Body, &pr.BaseBranch, &pr.HeadBranch, &pr.Status, | |
| 143 | + &mergedByID, &mergedByUsername, &mergedAt, | |
| 144 | + &headRepoID, &headRepoOwner, &headRepoName, | |
| 145 | + &pr.CommentCount, &pr.CreatedAt, &pr.UpdatedAt, | |
| 146 | + ) | |
| 147 | + if err != nil { | |
| 148 | + if errors.Is(err, sql.ErrNoRows) { | |
| 149 | + return nil, ErrPullNotFound | |
| 150 | + } | |
| 151 | + return nil, fmt.Errorf("get pull request: %w", err) | |
| 152 | + } | |
| 153 | + | |
| 154 | + if mergedByID.Valid { | |
| 155 | + pr.MergedByID = &mergedByID.Int64 | |
| 156 | + } | |
| 157 | + if mergedByUsername.Valid { | |
| 158 | + pr.MergedByUsername = mergedByUsername.String | |
| 159 | + } | |
| 160 | + if mergedAt.Valid { | |
| 161 | + t := mergedAt.Time | |
| 162 | + pr.MergedAt = &t | |
| 163 | + } | |
| 164 | + if headRepoID.Valid { | |
| 165 | + id := headRepoID.Int64 | |
| 166 | + pr.HeadRepoID = &id | |
| 167 | + } | |
| 168 | + pr.HeadRepoOwner = headRepoOwner.String | |
| 169 | + pr.HeadRepoName = headRepoName.String | |
| 170 | + | |
| 171 | + return &pr, nil | |
| 172 | +} | |
| 173 | + | |
| 174 | +func (s *Service) ListPullRequests(ctx context.Context, repoID int64, statusFilter string) ([]PullRequest, error) { | |
| 175 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 176 | + defer cancel() | |
| 177 | + | |
| 178 | + query := ` | |
| 179 | + SELECT pr.id, pr.repo_id, pr.number, pr.author_id, u.username, u.avatar_url, | |
| 180 | + pr.title, pr.body, pr.base_branch, pr.head_branch, pr.status, | |
| 181 | + pr.merged_by_id, mu.username, pr.merged_at, | |
| 182 | + pr.head_repo_id, hu.username, hr.name, | |
| 183 | + (SELECT COUNT(*) FROM pull_request_comments prc WHERE prc.pull_id = pr.id) AS comment_count, | |
| 184 | + pr.created_at, pr.updated_at | |
| 185 | + FROM pull_requests pr | |
| 186 | + JOIN users u ON u.id = pr.author_id | |
| 187 | + LEFT JOIN users mu ON mu.id = pr.merged_by_id | |
| 188 | + LEFT JOIN repositories hr ON hr.id = pr.head_repo_id | |
| 189 | + LEFT JOIN users hu ON hu.id = hr.owner_id | |
| 190 | + WHERE pr.repo_id = ? | |
| 191 | + ` | |
| 192 | + var args []any | |
| 193 | + args = append(args, repoID) | |
| 194 | + | |
| 195 | + switch statusFilter { | |
| 196 | + case "open": | |
| 197 | + query += ` AND pr.status = 'open'` | |
| 198 | + case "closed": | |
| 199 | + query += ` AND pr.status = 'closed'` | |
| 200 | + case "merged": | |
| 201 | + query += ` AND pr.status = 'merged'` | |
| 202 | + } | |
| 203 | + query += ` ORDER BY pr.created_at DESC` | |
| 204 | + | |
| 205 | + rows, err := s.db.QueryContext(cctx, query, args...) | |
| 206 | + if err != nil { | |
| 207 | + return nil, fmt.Errorf("list pull requests: %w", err) | |
| 208 | + } | |
| 209 | + defer rows.Close() | |
| 210 | + | |
| 211 | + var list []PullRequest | |
| 212 | + for rows.Next() { | |
| 213 | + var pr PullRequest | |
| 214 | + var mergedByID sql.NullInt64 | |
| 215 | + var mergedByUsername sql.NullString | |
| 216 | + var mergedAt sql.NullTime | |
| 217 | + var headRepoID sql.NullInt64 | |
| 218 | + var headRepoOwner, headRepoName sql.NullString | |
| 219 | + | |
| 220 | + if err := rows.Scan( | |
| 221 | + &pr.ID, &pr.RepoID, &pr.Number, &pr.AuthorID, &pr.AuthorUsername, &pr.AuthorAvatar, | |
| 222 | + &pr.Title, &pr.Body, &pr.BaseBranch, &pr.HeadBranch, &pr.Status, | |
| 223 | + &mergedByID, &mergedByUsername, &mergedAt, | |
| 224 | + &headRepoID, &headRepoOwner, &headRepoName, | |
| 225 | + &pr.CommentCount, &pr.CreatedAt, &pr.UpdatedAt, | |
| 226 | + ); err != nil { | |
| 227 | + return nil, fmt.Errorf("scan pull request: %w", err) | |
| 228 | + } | |
| 229 | + if mergedByID.Valid { | |
| 230 | + pr.MergedByID = &mergedByID.Int64 | |
| 231 | + } | |
| 232 | + if mergedByUsername.Valid { | |
| 233 | + pr.MergedByUsername = mergedByUsername.String | |
| 234 | + } | |
| 235 | + if mergedAt.Valid { | |
| 236 | + t := mergedAt.Time | |
| 237 | + pr.MergedAt = &t | |
| 238 | + } | |
| 239 | + if headRepoID.Valid { | |
| 240 | + id := headRepoID.Int64 | |
| 241 | + pr.HeadRepoID = &id | |
| 242 | + } | |
| 243 | + pr.HeadRepoOwner = headRepoOwner.String | |
| 244 | + pr.HeadRepoName = headRepoName.String | |
| 245 | + list = append(list, pr) | |
| 246 | + } | |
| 247 | + return list, rows.Err() | |
| 248 | +} | |
| 249 | + | |
| 250 | +func (s *Service) CountPullRequests(ctx context.Context, repoID int64) (openCount int, closedCount int, mergedCount int, err error) { | |
| 251 | + cctx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 252 | + defer cancel() | |
| 253 | + | |
| 254 | + _ = s.db.QueryRowContext(cctx, `SELECT COUNT(*) FROM pull_requests WHERE repo_id = ? AND status = 'open'`, repoID).Scan(&openCount) | |
| 255 | + _ = s.db.QueryRowContext(cctx, `SELECT COUNT(*) FROM pull_requests WHERE repo_id = ? AND status = 'closed'`, repoID).Scan(&closedCount) | |
| 256 | + _ = s.db.QueryRowContext(cctx, `SELECT COUNT(*) FROM pull_requests WHERE repo_id = ? AND status = 'merged'`, repoID).Scan(&mergedCount) | |
| 257 | + return openCount, closedCount, mergedCount, nil | |
| 258 | +} | |
| 259 | + | |
| 260 | +func (s *Service) SetPullRequestStatus(ctx context.Context, pullID int64, status string, mergedByID *int64) error { | |
| 261 | + cctx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 262 | + defer cancel() | |
| 263 | + | |
| 264 | + now := time.Now().UTC() | |
| 265 | + if status == "merged" { | |
| 266 | + _, err := s.db.ExecContext(cctx, ` | |
| 267 | + UPDATE pull_requests | |
| 268 | + SET status = 'merged', merged_by_id = ?, merged_at = ?, updated_at = ? | |
| 269 | + WHERE id = ? | |
| 270 | + `, mergedByID, now, now, pullID) | |
| 271 | + return err | |
| 272 | + } | |
| 273 | + | |
| 274 | + _, err := s.db.ExecContext(cctx, ` | |
| 275 | + UPDATE pull_requests | |
| 276 | + SET status = ?, merged_by_id = NULL, merged_at = NULL, updated_at = ? | |
| 277 | + WHERE id = ? | |
| 278 | + `, status, now, pullID) | |
| 279 | + return err | |
| 280 | +} | |
| 281 | + | |
| 282 | +func (s *Service) CreatePullRequestComment(ctx context.Context, pullID, authorID int64, body string) error { | |
| 283 | + body = strings.TrimSpace(body) | |
| 284 | + if body == "" { | |
| 285 | + return ErrEmptyComment | |
| 286 | + } | |
| 287 | + if len(body) > 20000 { | |
| 288 | + body = body[:20000] | |
| 289 | + } | |
| 290 | + | |
| 291 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 292 | + defer cancel() | |
| 293 | + | |
| 294 | + now := time.Now().UTC() | |
| 295 | + _, err := s.db.ExecContext(cctx, ` | |
| 296 | + INSERT INTO pull_request_comments (pull_id, author_id, body, created_at) | |
| 297 | + VALUES (?, ?, ?, ?) | |
| 298 | + `, pullID, authorID, body, now) | |
| 299 | + if err != nil { | |
| 300 | + return fmt.Errorf("insert pr comment: %w", err) | |
| 301 | + } | |
| 302 | + _, _ = s.db.ExecContext(cctx, `UPDATE pull_requests SET updated_at = ? WHERE id = ?`, now, pullID) | |
| 303 | + return nil | |
| 304 | +} | |
| 305 | + | |
| 306 | +func (s *Service) ListPullRequestComments(ctx context.Context, pullID int64) ([]PullRequestComment, error) { | |
| 307 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 308 | + defer cancel() | |
| 309 | + | |
| 310 | + rows, err := s.db.QueryContext(cctx, ` | |
| 311 | + SELECT prc.id, prc.pull_id, prc.author_id, u.username, u.avatar_url, prc.body, prc.created_at | |
| 312 | + FROM pull_request_comments prc | |
| 313 | + JOIN users u ON u.id = prc.author_id | |
| 314 | + WHERE prc.pull_id = ? | |
| 315 | + ORDER BY prc.created_at ASC | |
| 316 | + `, pullID) | |
| 317 | + if err != nil { | |
| 318 | + return nil, fmt.Errorf("list pr comments: %w", err) | |
| 319 | + } | |
| 320 | + defer rows.Close() | |
| 321 | + | |
| 322 | + var list []PullRequestComment | |
| 323 | + for rows.Next() { | |
| 324 | + var c PullRequestComment | |
| 325 | + if err := rows.Scan(&c.ID, &c.PullID, &c.AuthorID, &c.AuthorUsername, &c.AuthorAvatar, &c.Body, &c.CreatedAt); err != nil { | |
| 326 | + return nil, fmt.Errorf("scan pr comment: %w", err) | |
| 327 | + } | |
| 328 | + list = append(list, c) | |
| 329 | + } | |
| 330 | + return list, rows.Err() | |
| 331 | +} |
internal/repo/releases.go
+198
-0
| ... | ... | @@ -0,0 +1,198 @@ |
| 1 | +package repo | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "database/sql" | |
| 6 | + "errors" | |
| 7 | + "fmt" | |
| 8 | + "strings" | |
| 9 | + "time" | |
| 10 | +) | |
| 11 | + | |
| 12 | +var ( | |
| 13 | + ErrReleaseNotFound = errors.New("release not found") | |
| 14 | + ErrEmptyReleaseTag = errors.New("release tag cannot be empty") | |
| 15 | + ErrEmptyReleaseTitle = errors.New("release title cannot be empty") | |
| 16 | + ErrReleaseAlreadyExist = errors.New("a release for this tag already exists") | |
| 17 | +) | |
| 18 | + | |
| 19 | +type Release struct { | |
| 20 | + ID int64 | |
| 21 | + RepoID int64 | |
| 22 | + AuthorID int64 | |
| 23 | + AuthorUsername string | |
| 24 | + AuthorAvatar string | |
| 25 | + TagName string | |
| 26 | + Title string | |
| 27 | + Body string | |
| 28 | + IsDraft bool | |
| 29 | + IsPrerelease bool | |
| 30 | + CreatedAt time.Time | |
| 31 | + PublishedAt time.Time | |
| 32 | +} | |
| 33 | + | |
| 34 | +func (s *Service) CreateRelease(ctx context.Context, repoID, authorID int64, tagName, title, body string, isDraft, isPrerelease bool) (*Release, error) { | |
| 35 | + tagName = strings.TrimSpace(tagName) | |
| 36 | + title = strings.TrimSpace(title) | |
| 37 | + body = strings.TrimSpace(body) | |
| 38 | + | |
| 39 | + if tagName == "" { | |
| 40 | + return nil, ErrEmptyReleaseTag | |
| 41 | + } | |
| 42 | + if title == "" { | |
| 43 | + title = tagName | |
| 44 | + } | |
| 45 | + if len(title) > 200 { | |
| 46 | + title = title[:200] | |
| 47 | + } | |
| 48 | + if len(body) > 50000 { | |
| 49 | + body = body[:50000] | |
| 50 | + } | |
| 51 | + | |
| 52 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 53 | + defer cancel() | |
| 54 | + | |
| 55 | + b2i := func(b bool) int { | |
| 56 | + if b { | |
| 57 | + return 1 | |
| 58 | + } | |
| 59 | + return 0 | |
| 60 | + } | |
| 61 | + | |
| 62 | + now := time.Now().UTC() | |
| 63 | + res, err := s.db.ExecContext(cctx, ` | |
| 64 | + INSERT INTO releases (repo_id, author_id, tag_name, title, body, is_draft, is_prerelease, created_at, published_at) | |
| 65 | + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 66 | + `, repoID, authorID, tagName, title, body, b2i(isDraft), b2i(isPrerelease), now, now) | |
| 67 | + if err != nil { | |
| 68 | + if strings.Contains(strings.ToLower(err.Error()), "unique") { | |
| 69 | + return nil, ErrReleaseAlreadyExist | |
| 70 | + } | |
| 71 | + return nil, fmt.Errorf("insert release: %w", err) | |
| 72 | + } | |
| 73 | + | |
| 74 | + id, _ := res.LastInsertId() | |
| 75 | + return s.GetReleaseByID(ctx, repoID, id) | |
| 76 | +} | |
| 77 | + | |
| 78 | +func (s *Service) GetRelease(ctx context.Context, repoID int64, tagName string) (*Release, error) { | |
| 79 | + cctx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 80 | + defer cancel() | |
| 81 | + | |
| 82 | + var rel Release | |
| 83 | + var draft, prerelease int | |
| 84 | + err := s.db.QueryRowContext(cctx, ` | |
| 85 | + SELECT r.id, r.repo_id, r.author_id, u.username, u.avatar_url, | |
| 86 | + r.tag_name, r.title, r.body, r.is_draft, r.is_prerelease, r.created_at, r.published_at | |
| 87 | + FROM releases r | |
| 88 | + JOIN users u ON u.id = r.author_id | |
| 89 | + WHERE r.repo_id = ? AND r.tag_name = ? | |
| 90 | + `, repoID, tagName).Scan( | |
| 91 | + &rel.ID, &rel.RepoID, &rel.AuthorID, &rel.AuthorUsername, &rel.AuthorAvatar, | |
| 92 | + &rel.TagName, &rel.Title, &rel.Body, &draft, &prerelease, &rel.CreatedAt, &rel.PublishedAt, | |
| 93 | + ) | |
| 94 | + if err != nil { | |
| 95 | + if errors.Is(err, sql.ErrNoRows) { | |
| 96 | + return nil, ErrReleaseNotFound | |
| 97 | + } | |
| 98 | + return nil, fmt.Errorf("get release: %w", err) | |
| 99 | + } | |
| 100 | + rel.IsDraft = draft == 1 | |
| 101 | + rel.IsPrerelease = prerelease == 1 | |
| 102 | + return &rel, nil | |
| 103 | +} | |
| 104 | + | |
| 105 | +func (s *Service) GetReleaseByID(ctx context.Context, repoID, releaseID int64) (*Release, error) { | |
| 106 | + cctx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 107 | + defer cancel() | |
| 108 | + | |
| 109 | + var rel Release | |
| 110 | + var draft, prerelease int | |
| 111 | + err := s.db.QueryRowContext(cctx, ` | |
| 112 | + SELECT r.id, r.repo_id, r.author_id, u.username, u.avatar_url, | |
| 113 | + r.tag_name, r.title, r.body, r.is_draft, r.is_prerelease, r.created_at, r.published_at | |
| 114 | + FROM releases r | |
| 115 | + JOIN users u ON u.id = r.author_id | |
| 116 | + WHERE r.repo_id = ? AND r.id = ? | |
| 117 | + `, repoID, releaseID).Scan( | |
| 118 | + &rel.ID, &rel.RepoID, &rel.AuthorID, &rel.AuthorUsername, &rel.AuthorAvatar, | |
| 119 | + &rel.TagName, &rel.Title, &rel.Body, &draft, &prerelease, &rel.CreatedAt, &rel.PublishedAt, | |
| 120 | + ) | |
| 121 | + if err != nil { | |
| 122 | + if errors.Is(err, sql.ErrNoRows) { | |
| 123 | + return nil, ErrReleaseNotFound | |
| 124 | + } | |
| 125 | + return nil, fmt.Errorf("get release: %w", err) | |
| 126 | + } | |
| 127 | + rel.IsDraft = draft == 1 | |
| 128 | + rel.IsPrerelease = prerelease == 1 | |
| 129 | + return &rel, nil | |
| 130 | +} | |
| 131 | + | |
| 132 | +func (s *Service) ListReleases(ctx context.Context, repoID int64) ([]Release, error) { | |
| 133 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 134 | + defer cancel() | |
| 135 | + | |
| 136 | + rows, err := s.db.QueryContext(cctx, ` | |
| 137 | + SELECT r.id, r.repo_id, r.author_id, u.username, u.avatar_url, | |
| 138 | + r.tag_name, r.title, r.body, r.is_draft, r.is_prerelease, r.created_at, r.published_at | |
| 139 | + FROM releases r | |
| 140 | + JOIN users u ON u.id = r.author_id | |
| 141 | + WHERE r.repo_id = ? | |
| 142 | + ORDER BY r.published_at DESC | |
| 143 | + `, repoID) | |
| 144 | + if err != nil { | |
| 145 | + return nil, fmt.Errorf("list releases: %w", err) | |
| 146 | + } | |
| 147 | + defer rows.Close() | |
| 148 | + | |
| 149 | + var list []Release | |
| 150 | + for rows.Next() { | |
| 151 | + var rel Release | |
| 152 | + var draft, prerelease int | |
| 153 | + if err := rows.Scan( | |
| 154 | + &rel.ID, &rel.RepoID, &rel.AuthorID, &rel.AuthorUsername, &rel.AuthorAvatar, | |
| 155 | + &rel.TagName, &rel.Title, &rel.Body, &draft, &prerelease, &rel.CreatedAt, &rel.PublishedAt, | |
| 156 | + ); err != nil { | |
| 157 | + return nil, fmt.Errorf("scan release: %w", err) | |
| 158 | + } | |
| 159 | + rel.IsDraft = draft == 1 | |
| 160 | + rel.IsPrerelease = prerelease == 1 | |
| 161 | + list = append(list, rel) | |
| 162 | + } | |
| 163 | + return list, rows.Err() | |
| 164 | +} | |
| 165 | + | |
| 166 | +func (s *Service) UpdateRelease(ctx context.Context, releaseID int64, title, body string, isPrerelease bool) error { | |
| 167 | + cctx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 168 | + defer cancel() | |
| 169 | + | |
| 170 | + title = strings.TrimSpace(title) | |
| 171 | + body = strings.TrimSpace(body) | |
| 172 | + if len(title) > 200 { | |
| 173 | + title = title[:200] | |
| 174 | + } | |
| 175 | + if len(body) > 50000 { | |
| 176 | + body = body[:50000] | |
| 177 | + } | |
| 178 | + | |
| 179 | + preInt := 0 | |
| 180 | + if isPrerelease { | |
| 181 | + preInt = 1 | |
| 182 | + } | |
| 183 | + | |
| 184 | + _, err := s.db.ExecContext(cctx, ` | |
| 185 | + UPDATE releases | |
| 186 | + SET title = ?, body = ?, is_prerelease = ? | |
| 187 | + WHERE id = ? | |
| 188 | + `, title, body, preInt, releaseID) | |
| 189 | + return err | |
| 190 | +} | |
| 191 | + | |
| 192 | +func (s *Service) DeleteRelease(ctx context.Context, releaseID int64) error { | |
| 193 | + cctx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 194 | + defer cancel() | |
| 195 | + | |
| 196 | + _, err := s.db.ExecContext(cctx, `DELETE FROM releases WHERE id = ?`, releaseID) | |
| 197 | + return err | |
| 198 | +} |
internal/repo/repo.go
+591
-0
| ... | ... | @@ -0,0 +1,591 @@ |
| 1 | +package repo | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "database/sql" | |
| 6 | + "errors" | |
| 7 | + "fmt" | |
| 8 | + "os" | |
| 9 | + "path/filepath" | |
| 10 | + "regexp" | |
| 11 | + "strings" | |
| 12 | + "time" | |
| 13 | + | |
| 14 | + "polar/internal/db" | |
| 15 | + "polar/internal/git" | |
| 16 | + "polar/internal/user" | |
| 17 | +) | |
| 18 | + | |
| 19 | +var ( | |
| 20 | + ErrRepoNotFound = errors.New("repository not found") | |
| 21 | + ErrRepoAlreadyExists = errors.New("repository with this name already exists") | |
| 22 | + ErrInvalidRepoName = errors.New("repository name must be 1–64 characters (letters, digits, '.', '-', '_')") | |
| 23 | + ErrPathTraversal = errors.New("unsafe repository path") | |
| 24 | +) | |
| 25 | + | |
| 26 | +var repoNameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`) | |
| 27 | + | |
| 28 | +var reservedRepoNames = map[string]struct{}{ | |
| 29 | + "settings": {}, "commits": {}, "commit": {}, "tree": {}, "blob": {}, | |
| 30 | + "raw": {}, "info": {}, "git-upload-pack": {}, "git-receive-pack": {}, | |
| 31 | + "new": {}, "delete": {}, "rename": {}, "branches": {}, "tags": {}, | |
| 32 | + "issues": {}, "archive": {}, "pulls": {}, "releases": {}, "activity": {}, | |
| 33 | +} | |
| 34 | + | |
| 35 | +type Repository struct { | |
| 36 | + ID int64 | |
| 37 | + OwnerID int64 | |
| 38 | + OwnerUsername string | |
| 39 | + OwnerAvatarURL string | |
| 40 | + Name string | |
| 41 | + Description string | |
| 42 | + IsPrivate bool | |
| 43 | + DefaultBranch string | |
| 44 | + EnableIssues bool | |
| 45 | + EnablePulls bool | |
| 46 | + EnableReleases bool | |
| 47 | + EnableActivity bool | |
| 48 | + | |
| 49 | + ForkedFromRepoID *int64 | |
| 50 | + ForkedFromOwner string | |
| 51 | + ForkedFromName string | |
| 52 | + ForkCount int | |
| 53 | + | |
| 54 | + OpenIssueCount int | |
| 55 | + OpenPRCount int | |
| 56 | + ReleaseCount int | |
| 57 | + CreatedAt time.Time | |
| 58 | + UpdatedAt time.Time | |
| 59 | +} | |
| 60 | + | |
| 61 | +type Service struct { | |
| 62 | + db *db.DB | |
| 63 | + reposDir string | |
| 64 | +} | |
| 65 | + | |
| 66 | +func NewService(database *db.DB, reposDir string) *Service { | |
| 67 | + return &Service{ | |
| 68 | + db: database, | |
| 69 | + reposDir: reposDir, | |
| 70 | + } | |
| 71 | +} | |
| 72 | + | |
| 73 | +func ValidateRepoName(name string) error { | |
| 74 | + name = strings.TrimSpace(name) | |
| 75 | + if !repoNameRegex.MatchString(name) { | |
| 76 | + return ErrInvalidRepoName | |
| 77 | + } | |
| 78 | + lower := strings.ToLower(name) | |
| 79 | + if _, reserved := reservedRepoNames[lower]; reserved { | |
| 80 | + return ErrInvalidRepoName | |
| 81 | + } | |
| 82 | + if strings.Contains(lower, "..") || | |
| 83 | + strings.HasSuffix(lower, ".git") || | |
| 84 | + strings.HasSuffix(lower, ".lock") || | |
| 85 | + strings.HasSuffix(lower, ".") { | |
| 86 | + return ErrInvalidRepoName | |
| 87 | + } | |
| 88 | + return nil | |
| 89 | +} | |
| 90 | + | |
| 91 | +func (s *Service) DiskPath(ownerUsername, repoName string) (string, error) { | |
| 92 | + if err := user.ValidateUsername(ownerUsername); err != nil { | |
| 93 | + return "", err | |
| 94 | + } | |
| 95 | + if err := ValidateRepoName(repoName); err != nil { | |
| 96 | + return "", err | |
| 97 | + } | |
| 98 | + | |
| 99 | + baseAbs, err := filepath.Abs(s.reposDir) | |
| 100 | + if err != nil { | |
| 101 | + return "", fmt.Errorf("resolve repos dir: %w", err) | |
| 102 | + } | |
| 103 | + target := filepath.Join(baseAbs, strings.ToLower(ownerUsername), strings.ToLower(repoName)+".git") | |
| 104 | + rel, err := filepath.Rel(baseAbs, target) | |
| 105 | + if err != nil || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) { | |
| 106 | + return "", ErrPathTraversal | |
| 107 | + } | |
| 108 | + return target, nil | |
| 109 | +} | |
| 110 | + | |
| 111 | +func (s *Service) Create(ctx context.Context, owner *user.User, name, description string, isPrivate bool) (*Repository, error) { | |
| 112 | + name = strings.TrimSpace(name) | |
| 113 | + description = strings.TrimSpace(description) | |
| 114 | + if len(description) > 500 { | |
| 115 | + description = description[:500] | |
| 116 | + } | |
| 117 | + | |
| 118 | + if err := ValidateRepoName(name); err != nil { | |
| 119 | + return nil, err | |
| 120 | + } | |
| 121 | + | |
| 122 | + diskPath, err := s.DiskPath(owner.Username, name) | |
| 123 | + if err != nil { | |
| 124 | + return nil, err | |
| 125 | + } | |
| 126 | + | |
| 127 | + cctx, cancel := context.WithTimeout(ctx, 10*time.Second) | |
| 128 | + defer cancel() | |
| 129 | + | |
| 130 | + var exists int | |
| 131 | + err = s.db.QueryRowContext(cctx, ` | |
| 132 | + SELECT 1 FROM repositories WHERE owner_id = ? AND LOWER(name) = LOWER(?) | |
| 133 | + `, owner.ID, name).Scan(&exists) | |
| 134 | + if err == nil { | |
| 135 | + return nil, ErrRepoAlreadyExists | |
| 136 | + } else if !errors.Is(err, sql.ErrNoRows) { | |
| 137 | + return nil, fmt.Errorf("check repo exists: %w", err) | |
| 138 | + } | |
| 139 | + | |
| 140 | + defaultBranch := "main" | |
| 141 | + if err := git.InitBare(cctx, diskPath, defaultBranch); err != nil { | |
| 142 | + return nil, fmt.Errorf("init bare git repo: %w", err) | |
| 143 | + } | |
| 144 | + | |
| 145 | + privInt := 0 | |
| 146 | + if isPrivate { | |
| 147 | + privInt = 1 | |
| 148 | + } | |
| 149 | + now := time.Now().UTC() | |
| 150 | + | |
| 151 | + res, err := s.db.ExecContext(cctx, ` | |
| 152 | + INSERT INTO repositories (owner_id, name, description, is_private, default_branch, created_at, updated_at) | |
| 153 | + VALUES (?, ?, ?, ?, ?, ?, ?) | |
| 154 | + `, owner.ID, name, description, privInt, defaultBranch, now, now) | |
| 155 | + if err != nil { | |
| 156 | + _ = os.RemoveAll(diskPath) | |
| 157 | + if strings.Contains(strings.ToLower(err.Error()), "unique") { | |
| 158 | + return nil, ErrRepoAlreadyExists | |
| 159 | + } | |
| 160 | + return nil, fmt.Errorf("insert repository: %w", err) | |
| 161 | + } | |
| 162 | + | |
| 163 | + id, _ := res.LastInsertId() | |
| 164 | + return &Repository{ | |
| 165 | + ID: id, | |
| 166 | + OwnerID: owner.ID, | |
| 167 | + OwnerUsername: owner.Username, | |
| 168 | + OwnerAvatarURL: owner.AvatarURL, | |
| 169 | + Name: name, | |
| 170 | + Description: description, | |
| 171 | + IsPrivate: isPrivate, | |
| 172 | + DefaultBranch: defaultBranch, | |
| 173 | + EnableIssues: true, | |
| 174 | + EnablePulls: true, | |
| 175 | + EnableReleases: true, | |
| 176 | + EnableActivity: true, | |
| 177 | + CreatedAt: now, | |
| 178 | + UpdatedAt: now, | |
| 179 | + }, nil | |
| 180 | +} | |
| 181 | + | |
| 182 | +const repoSelectFields = ` | |
| 183 | + SELECT r.id, r.owner_id, u.username, u.avatar_url, r.name, r.description, r.is_private, r.default_branch, | |
| 184 | + r.enable_issues, r.enable_pulls, r.enable_releases, r.enable_activity, | |
| 185 | + r.forked_from_repo_id, fu.username AS forked_from_owner, fr.name AS forked_from_name, | |
| 186 | + (SELECT COUNT(*) FROM repositories f WHERE f.forked_from_repo_id = r.id) AS fork_count, | |
| 187 | + (SELECT COUNT(*) FROM issues i WHERE i.repo_id = r.id AND i.is_closed = 0) AS open_issues, | |
| 188 | + (SELECT COUNT(*) FROM pull_requests pr WHERE pr.repo_id = r.id AND pr.status = 'open') AS open_prs, | |
| 189 | + (SELECT COUNT(*) FROM releases rel WHERE rel.repo_id = r.id AND rel.is_draft = 0) AS release_count, | |
| 190 | + r.created_at, r.updated_at | |
| 191 | + FROM repositories r | |
| 192 | + JOIN users u ON u.id = r.owner_id | |
| 193 | + LEFT JOIN repositories fr ON fr.id = r.forked_from_repo_id | |
| 194 | + LEFT JOIN users fu ON fu.id = fr.owner_id | |
| 195 | +` | |
| 196 | + | |
| 197 | +func (s *Service) GetByOwnerAndName(ctx context.Context, ownerUsername, repoName string) (*Repository, error) { | |
| 198 | + ownerUsername = strings.TrimSpace(ownerUsername) | |
| 199 | + repoName = strings.TrimSuffix(strings.TrimSpace(repoName), ".git") | |
| 200 | + | |
| 201 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 202 | + defer cancel() | |
| 203 | + | |
| 204 | + row := s.db.QueryRowContext(cctx, repoSelectFields+` | |
| 205 | + WHERE LOWER(u.username) = LOWER(?) AND LOWER(r.name) = LOWER(?) | |
| 206 | + `, ownerUsername, repoName) | |
| 207 | + | |
| 208 | + return scanRepo(row) | |
| 209 | +} | |
| 210 | + | |
| 211 | +// GetByID loads a repository by primary key. | |
| 212 | +func (s *Service) GetByID(ctx context.Context, id int64) (*Repository, error) { | |
| 213 | + cctx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 214 | + defer cancel() | |
| 215 | + | |
| 216 | + row := s.db.QueryRowContext(cctx, repoSelectFields+` WHERE r.id = ? `, id) | |
| 217 | + return scanRepo(row) | |
| 218 | +} | |
| 219 | +func (s *Service) ListByOwner(ctx context.Context, ownerID int64, includePrivate bool) ([]Repository, error) { | |
| 220 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 221 | + defer cancel() | |
| 222 | + | |
| 223 | + query := repoSelectFields + ` WHERE r.owner_id = ?` | |
| 224 | + if !includePrivate { | |
| 225 | + query += ` AND r.is_private = 0` | |
| 226 | + } | |
| 227 | + query += ` ORDER BY r.updated_at DESC, r.created_at DESC` | |
| 228 | + | |
| 229 | + rows, err := s.db.QueryContext(cctx, query, ownerID) | |
| 230 | + if err != nil { | |
| 231 | + return nil, fmt.Errorf("list repos by owner: %w", err) | |
| 232 | + } | |
| 233 | + defer rows.Close() | |
| 234 | + return scanRepos(rows) | |
| 235 | +} | |
| 236 | + | |
| 237 | +func (s *Service) ListPublic(ctx context.Context, limit int) ([]Repository, error) { | |
| 238 | + if limit <= 0 || limit > 200 { | |
| 239 | + limit = 50 | |
| 240 | + } | |
| 241 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 242 | + defer cancel() | |
| 243 | + | |
| 244 | + rows, err := s.db.QueryContext(cctx, repoSelectFields+` | |
| 245 | + WHERE r.is_private = 0 | |
| 246 | + ORDER BY r.updated_at DESC, r.created_at DESC | |
| 247 | + LIMIT ? | |
| 248 | + `, limit) | |
| 249 | + if err != nil { | |
| 250 | + return nil, fmt.Errorf("list public repos: %w", err) | |
| 251 | + } | |
| 252 | + defer rows.Close() | |
| 253 | + return scanRepos(rows) | |
| 254 | +} | |
| 255 | + | |
| 256 | +func (s *Service) ListAccessible(ctx context.Context, viewer *user.User, limit int) ([]Repository, error) { | |
| 257 | + if viewer == nil { | |
| 258 | + return s.ListPublic(ctx, limit) | |
| 259 | + } | |
| 260 | + if limit <= 0 || limit > 200 { | |
| 261 | + limit = 100 | |
| 262 | + } | |
| 263 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 264 | + defer cancel() | |
| 265 | + | |
| 266 | + var ( | |
| 267 | + rows *sql.Rows | |
| 268 | + err error | |
| 269 | + ) | |
| 270 | + if viewer.IsAdmin { | |
| 271 | + rows, err = s.db.QueryContext(cctx, repoSelectFields+` | |
| 272 | + ORDER BY (r.owner_id = ?) DESC, r.updated_at DESC | |
| 273 | + LIMIT ? | |
| 274 | + `, viewer.ID, limit) | |
| 275 | + } else { | |
| 276 | + rows, err = s.db.QueryContext(cctx, repoSelectFields+` | |
| 277 | + WHERE r.owner_id = ? OR r.is_private = 0 | |
| 278 | + ORDER BY (r.owner_id = ?) DESC, r.updated_at DESC | |
| 279 | + LIMIT ? | |
| 280 | + `, viewer.ID, viewer.ID, limit) | |
| 281 | + } | |
| 282 | + if err != nil { | |
| 283 | + return nil, fmt.Errorf("list accessible repos: %w", err) | |
| 284 | + } | |
| 285 | + defer rows.Close() | |
| 286 | + return scanRepos(rows) | |
| 287 | +} | |
| 288 | + | |
| 289 | +func (s *Service) Update(ctx context.Context, r *Repository, description string, isPrivate bool, defaultBranch string, enableIssues, enablePulls, enableReleases, enableActivity bool) error { | |
| 290 | + description = strings.TrimSpace(description) | |
| 291 | + if len(description) > 500 { | |
| 292 | + description = description[:500] | |
| 293 | + } | |
| 294 | + if defaultBranch == "" { | |
| 295 | + defaultBranch = r.DefaultBranch | |
| 296 | + } | |
| 297 | + if err := git.ValidateRef(defaultBranch); err != nil { | |
| 298 | + return err | |
| 299 | + } | |
| 300 | + | |
| 301 | + privInt := 0 | |
| 302 | + if isPrivate { | |
| 303 | + privInt = 1 | |
| 304 | + } | |
| 305 | + b2i := func(b bool) int { | |
| 306 | + if b { | |
| 307 | + return 1 | |
| 308 | + } | |
| 309 | + return 0 | |
| 310 | + } | |
| 311 | + now := time.Now().UTC() | |
| 312 | + | |
| 313 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 314 | + defer cancel() | |
| 315 | + | |
| 316 | + _, err := s.db.ExecContext(cctx, ` | |
| 317 | + UPDATE repositories | |
| 318 | + SET description = ?, is_private = ?, default_branch = ?, | |
| 319 | + enable_issues = ?, enable_pulls = ?, enable_releases = ?, enable_activity = ?, | |
| 320 | + updated_at = ? | |
| 321 | + WHERE id = ? | |
| 322 | + `, description, privInt, defaultBranch, b2i(enableIssues), b2i(enablePulls), b2i(enableReleases), b2i(enableActivity), now, r.ID) | |
| 323 | + if err != nil { | |
| 324 | + return fmt.Errorf("update repository: %w", err) | |
| 325 | + } | |
| 326 | + r.Description = description | |
| 327 | + r.IsPrivate = isPrivate | |
| 328 | + r.DefaultBranch = defaultBranch | |
| 329 | + r.EnableIssues = enableIssues | |
| 330 | + r.EnablePulls = enablePulls | |
| 331 | + r.EnableReleases = enableReleases | |
| 332 | + r.EnableActivity = enableActivity | |
| 333 | + r.UpdatedAt = now | |
| 334 | + return nil | |
| 335 | +} | |
| 336 | + | |
| 337 | +func (s *Service) TouchUpdatedAt(ctx context.Context, repoID int64) { | |
| 338 | + cctx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 339 | + defer cancel() | |
| 340 | + _, _ = s.db.ExecContext(cctx, `UPDATE repositories SET updated_at = ? WHERE id = ?`, time.Now().UTC(), repoID) | |
| 341 | +} | |
| 342 | + | |
| 343 | +func (s *Service) Rename(ctx context.Context, r *Repository, newName string) error { | |
| 344 | + newName = strings.TrimSpace(newName) | |
| 345 | + if err := ValidateRepoName(newName); err != nil { | |
| 346 | + return err | |
| 347 | + } | |
| 348 | + if strings.EqualFold(r.Name, newName) && r.Name == newName { | |
| 349 | + return nil | |
| 350 | + } | |
| 351 | + | |
| 352 | + oldPath, err := s.DiskPath(r.OwnerUsername, r.Name) | |
| 353 | + if err != nil { | |
| 354 | + return err | |
| 355 | + } | |
| 356 | + newPath, err := s.DiskPath(r.OwnerUsername, newName) | |
| 357 | + if err != nil { | |
| 358 | + return err | |
| 359 | + } | |
| 360 | + | |
| 361 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 362 | + defer cancel() | |
| 363 | + | |
| 364 | + if !strings.EqualFold(r.Name, newName) { | |
| 365 | + var exists int | |
| 366 | + err := s.db.QueryRowContext(cctx, ` | |
| 367 | + SELECT 1 FROM repositories WHERE owner_id = ? AND LOWER(name) = LOWER(?) AND id != ? | |
| 368 | + `, r.OwnerID, newName, r.ID).Scan(&exists) | |
| 369 | + if err == nil { | |
| 370 | + return ErrRepoAlreadyExists | |
| 371 | + } | |
| 372 | + } | |
| 373 | + | |
| 374 | + if oldPath != newPath { | |
| 375 | + if err := os.Rename(oldPath, newPath); err != nil { | |
| 376 | + return fmt.Errorf("rename repository directory: %w", err) | |
| 377 | + } | |
| 378 | + } | |
| 379 | + | |
| 380 | + now := time.Now().UTC() | |
| 381 | + _, err = s.db.ExecContext(cctx, ` | |
| 382 | + UPDATE repositories SET name = ?, updated_at = ? WHERE id = ? | |
| 383 | + `, newName, now, r.ID) | |
| 384 | + if err != nil { | |
| 385 | + if oldPath != newPath { | |
| 386 | + _ = os.Rename(newPath, oldPath) | |
| 387 | + } | |
| 388 | + return fmt.Errorf("update repository name: %w", err) | |
| 389 | + } | |
| 390 | + | |
| 391 | + r.Name = newName | |
| 392 | + r.UpdatedAt = now | |
| 393 | + return nil | |
| 394 | +} | |
| 395 | + | |
| 396 | +func (s *Service) Delete(ctx context.Context, r *Repository) error { | |
| 397 | + diskPath, err := s.DiskPath(r.OwnerUsername, r.Name) | |
| 398 | + if err != nil { | |
| 399 | + return err | |
| 400 | + } | |
| 401 | + | |
| 402 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 403 | + defer cancel() | |
| 404 | + | |
| 405 | + if _, err := s.db.ExecContext(cctx, `DELETE FROM repositories WHERE id = ?`, r.ID); err != nil { | |
| 406 | + return fmt.Errorf("delete repository record: %w", err) | |
| 407 | + } | |
| 408 | + if err := os.RemoveAll(diskPath); err != nil { | |
| 409 | + return fmt.Errorf("remove repository directory: %w", err) | |
| 410 | + } | |
| 411 | + return nil | |
| 412 | +} | |
| 413 | + | |
| 414 | +func boolInt(b bool) int { | |
| 415 | + if b { | |
| 416 | + return 1 | |
| 417 | + } | |
| 418 | + return 0 | |
| 419 | +} | |
| 420 | + | |
| 421 | +// FindForkByOwner returns the owner's fork of the given repository, if any. | |
| 422 | +func (s *Service) FindForkByOwner(ctx context.Context, srcRepoID, ownerID int64) (*Repository, error) { | |
| 423 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 424 | + defer cancel() | |
| 425 | + | |
| 426 | + row := s.db.QueryRowContext(cctx, repoSelectFields+` | |
| 427 | + WHERE r.forked_from_repo_id = ? AND r.owner_id = ? | |
| 428 | + ORDER BY r.created_at ASC | |
| 429 | + LIMIT 1 | |
| 430 | + `, srcRepoID, ownerID) | |
| 431 | + r, err := scanRepo(row) | |
| 432 | + if errors.Is(err, ErrRepoNotFound) { | |
| 433 | + return nil, nil | |
| 434 | + } | |
| 435 | + return r, err | |
| 436 | +} | |
| 437 | + | |
| 438 | +// Fork copies a repository into the owner's namespace. | |
| 439 | +func (s *Service) Fork(ctx context.Context, src *Repository, owner *user.User, name string) (*Repository, error) { | |
| 440 | + name = strings.TrimSpace(name) | |
| 441 | + if name == "" { | |
| 442 | + name = src.Name | |
| 443 | + } | |
| 444 | + if err := ValidateRepoName(name); err != nil { | |
| 445 | + return nil, err | |
| 446 | + } | |
| 447 | + | |
| 448 | + srcPath, err := s.DiskPath(src.OwnerUsername, src.Name) | |
| 449 | + if err != nil { | |
| 450 | + return nil, err | |
| 451 | + } | |
| 452 | + dstPath, err := s.DiskPath(owner.Username, name) | |
| 453 | + if err != nil { | |
| 454 | + return nil, err | |
| 455 | + } | |
| 456 | + | |
| 457 | + cctx, cancel := context.WithTimeout(ctx, 120*time.Second) | |
| 458 | + defer cancel() | |
| 459 | + | |
| 460 | + var exists int | |
| 461 | + err = s.db.QueryRowContext(cctx, ` | |
| 462 | + SELECT 1 FROM repositories WHERE owner_id = ? AND LOWER(name) = LOWER(?) | |
| 463 | + `, owner.ID, name).Scan(&exists) | |
| 464 | + if err == nil { | |
| 465 | + return nil, ErrRepoAlreadyExists | |
| 466 | + } else if !errors.Is(err, sql.ErrNoRows) { | |
| 467 | + return nil, fmt.Errorf("check fork name: %w", err) | |
| 468 | + } | |
| 469 | + | |
| 470 | + if err := git.CloneBare(cctx, srcPath, dstPath); err != nil { | |
| 471 | + return nil, fmt.Errorf("copy repository: %w", err) | |
| 472 | + } | |
| 473 | + | |
| 474 | + now := time.Now().UTC() | |
| 475 | + srcID := src.ID | |
| 476 | + res, err := s.db.ExecContext(cctx, ` | |
| 477 | + INSERT INTO repositories (owner_id, name, description, is_private, default_branch, | |
| 478 | + enable_issues, enable_pulls, enable_releases, enable_activity, forked_from_repo_id, created_at, updated_at) | |
| 479 | + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 480 | + `, owner.ID, name, src.Description, boolInt(src.IsPrivate), src.DefaultBranch, | |
| 481 | + boolInt(src.EnableIssues), boolInt(src.EnablePulls), boolInt(src.EnableReleases), boolInt(src.EnableActivity), | |
| 482 | + srcID, now, now) | |
| 483 | + if err != nil { | |
| 484 | + _ = os.RemoveAll(dstPath) | |
| 485 | + if strings.Contains(strings.ToLower(err.Error()), "unique") { | |
| 486 | + return nil, ErrRepoAlreadyExists | |
| 487 | + } | |
| 488 | + return nil, fmt.Errorf("insert fork: %w", err) | |
| 489 | + } | |
| 490 | + | |
| 491 | + id, _ := res.LastInsertId() | |
| 492 | + return &Repository{ | |
| 493 | + ID: id, | |
| 494 | + OwnerID: owner.ID, | |
| 495 | + OwnerUsername: owner.Username, | |
| 496 | + OwnerAvatarURL: owner.AvatarURL, | |
| 497 | + Name: name, | |
| 498 | + Description: src.Description, | |
| 499 | + IsPrivate: src.IsPrivate, | |
| 500 | + DefaultBranch: src.DefaultBranch, | |
| 501 | + EnableIssues: src.EnableIssues, | |
| 502 | + EnablePulls: src.EnablePulls, | |
| 503 | + EnableReleases: src.EnableReleases, | |
| 504 | + EnableActivity: src.EnableActivity, | |
| 505 | + ForkedFromRepoID: &srcID, | |
| 506 | + ForkedFromOwner: src.OwnerUsername, | |
| 507 | + ForkedFromName: src.Name, | |
| 508 | + CreatedAt: now, | |
| 509 | + UpdatedAt: now, | |
| 510 | + }, nil | |
| 511 | +} | |
| 512 | + | |
| 513 | +// ListForks returns repositories forked from the given repository. | |
| 514 | +func (s *Service) ListForks(ctx context.Context, srcRepoID int64, limit int) ([]Repository, error) { | |
| 515 | + if limit <= 0 || limit > 100 { | |
| 516 | + limit = 20 | |
| 517 | + } | |
| 518 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 519 | + defer cancel() | |
| 520 | + | |
| 521 | + rows, err := s.db.QueryContext(cctx, repoSelectFields+` | |
| 522 | + WHERE r.forked_from_repo_id = ? | |
| 523 | + ORDER BY r.updated_at DESC | |
| 524 | + LIMIT ? | |
| 525 | + `, srcRepoID, limit) | |
| 526 | + if err != nil { | |
| 527 | + return nil, fmt.Errorf("list forks: %w", err) | |
| 528 | + } | |
| 529 | + defer rows.Close() | |
| 530 | + return scanRepos(rows) | |
| 531 | +} | |
| 532 | + | |
| 533 | +func scanRepo(row *sql.Row) (*Repository, error) { | |
| 534 | + var r Repository | |
| 535 | + var isPriv, enableIssues, enablePulls, enableReleases, enableActivity int | |
| 536 | + var forkedFromID sql.NullInt64 | |
| 537 | + var forkedFromOwner, forkedFromName sql.NullString | |
| 538 | + err := row.Scan(&r.ID, &r.OwnerID, &r.OwnerUsername, &r.OwnerAvatarURL, &r.Name, &r.Description, &isPriv, &r.DefaultBranch, | |
| 539 | + &enableIssues, &enablePulls, &enableReleases, &enableActivity, | |
| 540 | + &forkedFromID, &forkedFromOwner, &forkedFromName, &r.ForkCount, | |
| 541 | + &r.OpenIssueCount, &r.OpenPRCount, &r.ReleaseCount, | |
| 542 | + &r.CreatedAt, &r.UpdatedAt) | |
| 543 | + if err != nil { | |
| 544 | + if errors.Is(err, sql.ErrNoRows) { | |
| 545 | + return nil, ErrRepoNotFound | |
| 546 | + } | |
| 547 | + return nil, fmt.Errorf("scan repository: %w", err) | |
| 548 | + } | |
| 549 | + r.IsPrivate = isPriv == 1 | |
| 550 | + r.EnableIssues = enableIssues == 1 | |
| 551 | + r.EnablePulls = enablePulls == 1 | |
| 552 | + r.EnableReleases = enableReleases == 1 | |
| 553 | + r.EnableActivity = enableActivity == 1 | |
| 554 | + if forkedFromID.Valid { | |
| 555 | + id := forkedFromID.Int64 | |
| 556 | + r.ForkedFromRepoID = &id | |
| 557 | + } | |
| 558 | + r.ForkedFromOwner = forkedFromOwner.String | |
| 559 | + r.ForkedFromName = forkedFromName.String | |
| 560 | + return &r, nil | |
| 561 | +} | |
| 562 | + | |
| 563 | +func scanRepos(rows *sql.Rows) ([]Repository, error) { | |
| 564 | + var list []Repository | |
| 565 | + for rows.Next() { | |
| 566 | + var r Repository | |
| 567 | + var isPriv, enableIssues, enablePulls, enableReleases, enableActivity int | |
| 568 | + var forkedFromID sql.NullInt64 | |
| 569 | + var forkedFromOwner, forkedFromName sql.NullString | |
| 570 | + if err := rows.Scan(&r.ID, &r.OwnerID, &r.OwnerUsername, &r.OwnerAvatarURL, &r.Name, &r.Description, &isPriv, &r.DefaultBranch, | |
| 571 | + &enableIssues, &enablePulls, &enableReleases, &enableActivity, | |
| 572 | + &forkedFromID, &forkedFromOwner, &forkedFromName, &r.ForkCount, | |
| 573 | + &r.OpenIssueCount, &r.OpenPRCount, &r.ReleaseCount, | |
| 574 | + &r.CreatedAt, &r.UpdatedAt); err != nil { | |
| 575 | + return nil, fmt.Errorf("scan repository row: %w", err) | |
| 576 | + } | |
| 577 | + r.IsPrivate = isPriv == 1 | |
| 578 | + r.EnableIssues = enableIssues == 1 | |
| 579 | + r.EnablePulls = enablePulls == 1 | |
| 580 | + r.EnableReleases = enableReleases == 1 | |
| 581 | + r.EnableActivity = enableActivity == 1 | |
| 582 | + if forkedFromID.Valid { | |
| 583 | + id := forkedFromID.Int64 | |
| 584 | + r.ForkedFromRepoID = &id | |
| 585 | + } | |
| 586 | + r.ForkedFromOwner = forkedFromOwner.String | |
| 587 | + r.ForkedFromName = forkedFromName.String | |
| 588 | + list = append(list, r) | |
| 589 | + } | |
| 590 | + return list, rows.Err() | |
| 591 | +} |
internal/repo/repo_test.go
+98
-0
| ... | ... | @@ -0,0 +1,98 @@ |
| 1 | +package repo | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "os" | |
| 6 | + "path/filepath" | |
| 7 | + "testing" | |
| 8 | + | |
| 9 | + "polar/internal/db" | |
| 10 | + "polar/internal/user" | |
| 11 | +) | |
| 12 | + | |
| 13 | +func TestValidateRepoName(t *testing.T) { | |
| 14 | + valid := []string{"alice", "my-repo_1", "v1.0.0", "x", "core.go"} | |
| 15 | + for _, n := range valid { | |
| 16 | + if err := ValidateRepoName(n); err != nil { | |
| 17 | + t.Errorf("expected repo name %q to be valid, got %v", n, err) | |
| 18 | + } | |
| 19 | + } | |
| 20 | + | |
| 21 | + invalid := []string{ | |
| 22 | + "", ".", "..", ".hidden", "-dash", "repo.git", "repo.lock", | |
| 23 | + "repo.", "a..b", "foo/bar", `foo\bar`, "settings", "commits", | |
| 24 | + "commit", "tree", "blob", "raw", "info", "git-upload-pack", | |
| 25 | + } | |
| 26 | + for _, n := range invalid { | |
| 27 | + if err := ValidateRepoName(n); err == nil { | |
| 28 | + t.Errorf("expected repo name %q to be rejected", n) | |
| 29 | + } | |
| 30 | + } | |
| 31 | +} | |
| 32 | + | |
| 33 | +func TestRepoCRUDAndAccessControl(t *testing.T) { | |
| 34 | + ctx := context.Background() | |
| 35 | + tmp := t.TempDir() | |
| 36 | + database, err := db.Open(ctx, filepath.Join(tmp, "repo.db"), true) | |
| 37 | + if err != nil { | |
| 38 | + t.Fatalf("open db: %v", err) | |
| 39 | + } | |
| 40 | + defer database.Close() | |
| 41 | + | |
| 42 | + userSvc := user.NewService(database) | |
| 43 | + owner, err := userSvc.Register(ctx, "alice", "alice@example.com", "password123", true) | |
| 44 | + if err != nil { | |
| 45 | + t.Fatalf("register owner: %v", err) | |
| 46 | + } | |
| 47 | + stranger, err := userSvc.Register(ctx, "bob", "bob@example.com", "password123", true) | |
| 48 | + if err != nil { | |
| 49 | + t.Fatalf("register stranger: %v", err) | |
| 50 | + } | |
| 51 | + | |
| 52 | + repoSvc := NewService(database, filepath.Join(tmp, "repos")) | |
| 53 | + | |
| 54 | + privRepo, err := repoSvc.Create(ctx, owner, "secret-notes", "Private repo", true) | |
| 55 | + if err != nil { | |
| 56 | + t.Fatalf("Create private repo failed: %v", err) | |
| 57 | + } | |
| 58 | + | |
| 59 | + // Verify bare git repository exists on disk | |
| 60 | + diskPath, err := repoSvc.DiskPath(owner.Username, privRepo.Name) | |
| 61 | + if err != nil { | |
| 62 | + t.Fatalf("DiskPath failed: %v", err) | |
| 63 | + } | |
| 64 | + if _, err := os.Stat(filepath.Join(diskPath, "HEAD")); err != nil { | |
| 65 | + t.Fatalf("expected bare repo HEAD on disk: %v", err) | |
| 66 | + } | |
| 67 | + | |
| 68 | + // Private repo access checks | |
| 69 | + if repoSvc.CanRead(ctx, privRepo, nil) { | |
| 70 | + t.Fatalf("anonymous user must NOT read private repo") | |
| 71 | + } | |
| 72 | + if repoSvc.CanRead(ctx, privRepo, stranger) { | |
| 73 | + t.Fatalf("other regular user must NOT read private repo") | |
| 74 | + } | |
| 75 | + if !repoSvc.CanRead(ctx, privRepo, owner) { | |
| 76 | + t.Fatalf("owner must read private repo") | |
| 77 | + } | |
| 78 | + if !repoSvc.CanWrite(ctx, privRepo, owner) || repoSvc.CanWrite(ctx, privRepo, stranger) { | |
| 79 | + t.Fatalf("unexpected CanWrite result") | |
| 80 | + } | |
| 81 | + | |
| 82 | + // Rename repository | |
| 83 | + if err := repoSvc.Rename(ctx, privRepo, "renamed-notes"); err != nil { | |
| 84 | + t.Fatalf("Rename failed: %v", err) | |
| 85 | + } | |
| 86 | + newDiskPath, _ := repoSvc.DiskPath(owner.Username, "renamed-notes") | |
| 87 | + if _, err := os.Stat(filepath.Join(newDiskPath, "HEAD")); err != nil { | |
| 88 | + t.Fatalf("expected renamed bare repo on disk: %v", err) | |
| 89 | + } | |
| 90 | + | |
| 91 | + // Delete repository | |
| 92 | + if err := repoSvc.Delete(ctx, privRepo); err != nil { | |
| 93 | + t.Fatalf("Delete failed: %v", err) | |
| 94 | + } | |
| 95 | + if _, err := os.Stat(newDiskPath); !os.IsNotExist(err) { | |
| 96 | + t.Fatalf("expected bare repo directory to be deleted from disk") | |
| 97 | + } | |
| 98 | +} |
internal/repo/reviews.go
+117
-0
| ... | ... | @@ -0,0 +1,117 @@ |
| 1 | +package repo | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "errors" | |
| 6 | + "fmt" | |
| 7 | + "strings" | |
| 8 | + "time" | |
| 9 | +) | |
| 10 | + | |
| 11 | +const ( | |
| 12 | + ReviewApproved = "approved" | |
| 13 | + ReviewChangesRequested = "changes_requested" | |
| 14 | +) | |
| 15 | + | |
| 16 | +var ErrInvalidReviewState = errors.New("invalid review state") | |
| 17 | + | |
| 18 | +type Review struct { | |
| 19 | + ID int64 | |
| 20 | + PullID int64 | |
| 21 | + ReviewerID int64 | |
| 22 | + ReviewerUsername string | |
| 23 | + ReviewerAvatar string | |
| 24 | + State string | |
| 25 | + Body string | |
| 26 | + CreatedAt time.Time | |
| 27 | + UpdatedAt time.Time | |
| 28 | +} | |
| 29 | + | |
| 30 | +// UpsertReview records or updates the reviewer's verdict on a pull request. | |
| 31 | +func (s *Service) UpsertReview(ctx context.Context, pullID, reviewerID int64, state, body string) error { | |
| 32 | + state = strings.TrimSpace(state) | |
| 33 | + if state != ReviewApproved && state != ReviewChangesRequested { | |
| 34 | + return ErrInvalidReviewState | |
| 35 | + } | |
| 36 | + body = strings.TrimSpace(body) | |
| 37 | + if len(body) > 10000 { | |
| 38 | + body = body[:10000] | |
| 39 | + } | |
| 40 | + | |
| 41 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 42 | + defer cancel() | |
| 43 | + | |
| 44 | + now := time.Now().UTC() | |
| 45 | + _, err := s.db.ExecContext(cctx, ` | |
| 46 | + INSERT INTO pull_request_reviews (pull_id, reviewer_id, state, body, created_at, updated_at) | |
| 47 | + VALUES (?, ?, ?, ?, ?, ?) | |
| 48 | + ON CONFLICT(pull_id, reviewer_id) DO UPDATE SET | |
| 49 | + state = excluded.state, | |
| 50 | + body = excluded.body, | |
| 51 | + updated_at = excluded.updated_at | |
| 52 | + `, pullID, reviewerID, state, body, now, now) | |
| 53 | + if err != nil { | |
| 54 | + return fmt.Errorf("upsert review: %w", err) | |
| 55 | + } | |
| 56 | + return nil | |
| 57 | +} | |
| 58 | + | |
| 59 | +// ListReviews returns all reviews left on a pull request, oldest first. | |
| 60 | +func (s *Service) ListReviews(ctx context.Context, pullID int64) ([]Review, error) { | |
| 61 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 62 | + defer cancel() | |
| 63 | + | |
| 64 | + rows, err := s.db.QueryContext(cctx, ` | |
| 65 | + SELECT rv.id, rv.pull_id, rv.reviewer_id, u.username, u.avatar_url, rv.state, rv.body, rv.created_at, rv.updated_at | |
| 66 | + FROM pull_request_reviews rv | |
| 67 | + JOIN users u ON u.id = rv.reviewer_id | |
| 68 | + WHERE rv.pull_id = ? | |
| 69 | + ORDER BY rv.updated_at ASC | |
| 70 | + `, pullID) | |
| 71 | + if err != nil { | |
| 72 | + return nil, fmt.Errorf("list reviews: %w", err) | |
| 73 | + } | |
| 74 | + defer rows.Close() | |
| 75 | + | |
| 76 | + var list []Review | |
| 77 | + for rows.Next() { | |
| 78 | + var rv Review | |
| 79 | + if err := rows.Scan(&rv.ID, &rv.PullID, &rv.ReviewerID, &rv.ReviewerUsername, &rv.ReviewerAvatar, | |
| 80 | + &rv.State, &rv.Body, &rv.CreatedAt, &rv.UpdatedAt); err != nil { | |
| 81 | + return nil, fmt.Errorf("scan review: %w", err) | |
| 82 | + } | |
| 83 | + list = append(list, rv) | |
| 84 | + } | |
| 85 | + return list, rows.Err() | |
| 86 | +} | |
| 87 | + | |
| 88 | +// GetReview returns the review left by a specific user, if any. | |
| 89 | +func (s *Service) GetReview(ctx context.Context, pullID, reviewerID int64) (*Review, error) { | |
| 90 | + cctx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 91 | + defer cancel() | |
| 92 | + | |
| 93 | + var rv Review | |
| 94 | + err := s.db.QueryRowContext(cctx, ` | |
| 95 | + SELECT rv.id, rv.pull_id, rv.reviewer_id, u.username, u.avatar_url, rv.state, rv.body, rv.created_at, rv.updated_at | |
| 96 | + FROM pull_request_reviews rv | |
| 97 | + JOIN users u ON u.id = rv.reviewer_id | |
| 98 | + WHERE rv.pull_id = ? AND rv.reviewer_id = ? | |
| 99 | + `, pullID, reviewerID).Scan(&rv.ID, &rv.PullID, &rv.ReviewerID, &rv.ReviewerUsername, &rv.ReviewerAvatar, | |
| 100 | + &rv.State, &rv.Body, &rv.CreatedAt, &rv.UpdatedAt) | |
| 101 | + if err != nil { | |
| 102 | + return nil, err | |
| 103 | + } | |
| 104 | + return &rv, nil | |
| 105 | +} | |
| 106 | + | |
| 107 | +// ReviewCounts returns how many reviewers approved and how many requested changes. | |
| 108 | +func (s *Service) ReviewCounts(ctx context.Context, pullID int64) (approved int, changesRequested int) { | |
| 109 | + cctx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 110 | + defer cancel() | |
| 111 | + | |
| 112 | + _ = s.db.QueryRowContext(cctx, `SELECT COUNT(*) FROM pull_request_reviews WHERE pull_id = ? AND state = ?`, | |
| 113 | + pullID, ReviewApproved).Scan(&approved) | |
| 114 | + _ = s.db.QueryRowContext(cctx, `SELECT COUNT(*) FROM pull_request_reviews WHERE pull_id = ? AND state = ?`, | |
| 115 | + pullID, ReviewChangesRequested).Scan(&changesRequested) | |
| 116 | + return approved, changesRequested | |
| 117 | +} |
internal/user/user.go
+370
-0
| ... | ... | @@ -0,0 +1,370 @@ |
| 1 | +package user | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "database/sql" | |
| 6 | + "errors" | |
| 7 | + "fmt" | |
| 8 | + "net/mail" | |
| 9 | + "regexp" | |
| 10 | + "strings" | |
| 11 | + "time" | |
| 12 | + | |
| 13 | + "polar/internal/auth" | |
| 14 | + "polar/internal/db" | |
| 15 | +) | |
| 16 | + | |
| 17 | +var ( | |
| 18 | + ErrUserNotFound = errors.New("user not found") | |
| 19 | + ErrUserAlreadyExists = errors.New("username or email already taken") | |
| 20 | + ErrRegistrationDisabled = errors.New("public registration is disabled") | |
| 21 | + ErrInvalidUsername = errors.New("username must be 2–32 alphanumeric characters or single hyphens/underscores") | |
| 22 | + ErrReservedUsername = errors.New("this username is reserved by the system") | |
| 23 | + ErrInvalidEmail = errors.New("invalid email address") | |
| 24 | + ErrWeakPassword = errors.New("password must be at least 8 characters") | |
| 25 | +) | |
| 26 | + | |
| 27 | +var usernameRegex = regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9]|[-_][a-zA-Z0-9]){1,31}$`) | |
| 28 | + | |
| 29 | +var reservedUsernames = map[string]struct{}{ | |
| 30 | + "login": {}, "register": {}, "logout": {}, "settings": {}, "admin": {}, | |
| 31 | + "new": {}, "explore": {}, "repos": {}, "api": {}, "static": {}, | |
| 32 | + "assets": {}, "healthz": {}, "_ui": {}, "git": {}, "raw": {}, | |
| 33 | + "tree": {}, "blob": {}, "commits": {}, "commit": {}, "branches": {}, | |
| 34 | + "tags": {}, "user": {}, "users": {}, "org": {}, "orgs": {}, | |
| 35 | + "help": {}, "about": {}, "pricing": {}, "security": {}, "terms": {}, | |
| 36 | + "privacy": {}, "favicon.ico": {}, "robots.txt": {}, "search": {}, | |
| 37 | + "avatars": {}, "issues": {}, "archive": {}, | |
| 38 | +} | |
| 39 | + | |
| 40 | +type User struct { | |
| 41 | + ID int64 | |
| 42 | + Username string | |
| 43 | + Email string | |
| 44 | + PasswordHash string | |
| 45 | + FullName string | |
| 46 | + Bio string | |
| 47 | + AvatarURL string | |
| 48 | + Location string | |
| 49 | + Website string | |
| 50 | + IsAdmin bool | |
| 51 | + RepoCount int | |
| 52 | + CreatedAt time.Time | |
| 53 | +} | |
| 54 | + | |
| 55 | +type Service struct { | |
| 56 | + db *db.DB | |
| 57 | +} | |
| 58 | + | |
| 59 | +func NewService(database *db.DB) *Service { | |
| 60 | + return &Service{db: database} | |
| 61 | +} | |
| 62 | + | |
| 63 | +func ValidateUsername(username string) error { | |
| 64 | + username = strings.TrimSpace(username) | |
| 65 | + if !usernameRegex.MatchString(username) { | |
| 66 | + return ErrInvalidUsername | |
| 67 | + } | |
| 68 | + lower := strings.ToLower(username) | |
| 69 | + if _, reserved := reservedUsernames[lower]; reserved { | |
| 70 | + return ErrReservedUsername | |
| 71 | + } | |
| 72 | + if strings.HasSuffix(lower, ".git") || strings.Contains(lower, "..") { | |
| 73 | + return ErrInvalidUsername | |
| 74 | + } | |
| 75 | + return nil | |
| 76 | +} | |
| 77 | + | |
| 78 | +func ValidateEmail(email string) error { | |
| 79 | + email = strings.TrimSpace(email) | |
| 80 | + if len(email) < 3 || len(email) > 254 { | |
| 81 | + return ErrInvalidEmail | |
| 82 | + } | |
| 83 | + addr, err := mail.ParseAddress(email) | |
| 84 | + if err != nil || addr.Address != email || !strings.Contains(email, "@") { | |
| 85 | + return ErrInvalidEmail | |
| 86 | + } | |
| 87 | + return nil | |
| 88 | +} | |
| 89 | + | |
| 90 | +func (s *Service) Count(ctx context.Context) (int, error) { | |
| 91 | + cctx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 92 | + defer cancel() | |
| 93 | + var count int | |
| 94 | + if err := s.db.QueryRowContext(cctx, `SELECT COUNT(*) FROM users`).Scan(&count); err != nil { | |
| 95 | + return 0, fmt.Errorf("count users: %w", err) | |
| 96 | + } | |
| 97 | + return count, nil | |
| 98 | +} | |
| 99 | + | |
| 100 | +func (s *Service) Register(ctx context.Context, username, email, password string, regEnabled bool) (*User, error) { | |
| 101 | + username = strings.TrimSpace(username) | |
| 102 | + email = strings.ToLower(strings.TrimSpace(email)) | |
| 103 | + | |
| 104 | + if err := ValidateUsername(username); err != nil { | |
| 105 | + return nil, err | |
| 106 | + } | |
| 107 | + if err := ValidateEmail(email); err != nil { | |
| 108 | + return nil, err | |
| 109 | + } | |
| 110 | + if len(password) < 8 { | |
| 111 | + return nil, ErrWeakPassword | |
| 112 | + } | |
| 113 | + | |
| 114 | + hash, err := auth.HashPassword(password) | |
| 115 | + if err != nil { | |
| 116 | + return nil, fmt.Errorf("hash password: %w", err) | |
| 117 | + } | |
| 118 | + | |
| 119 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 120 | + defer cancel() | |
| 121 | + | |
| 122 | + tx, err := s.db.BeginTx(cctx, nil) | |
| 123 | + if err != nil { | |
| 124 | + return nil, fmt.Errorf("begin register tx: %w", err) | |
| 125 | + } | |
| 126 | + defer func() { _ = tx.Rollback() }() | |
| 127 | + | |
| 128 | + var userCount int | |
| 129 | + if err := tx.QueryRowContext(cctx, `SELECT COUNT(*) FROM users`).Scan(&userCount); err != nil { | |
| 130 | + return nil, fmt.Errorf("count existing users: %w", err) | |
| 131 | + } | |
| 132 | + | |
| 133 | + if userCount > 0 && !regEnabled { | |
| 134 | + return nil, ErrRegistrationDisabled | |
| 135 | + } | |
| 136 | + | |
| 137 | + var existing int | |
| 138 | + err = tx.QueryRowContext(cctx, ` | |
| 139 | + SELECT 1 FROM users WHERE LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?) | |
| 140 | + `, username, email).Scan(&existing) | |
| 141 | + if err == nil { | |
| 142 | + return nil, ErrUserAlreadyExists | |
| 143 | + } else if !errors.Is(err, sql.ErrNoRows) { | |
| 144 | + return nil, fmt.Errorf("check existing user: %w", err) | |
| 145 | + } | |
| 146 | + | |
| 147 | + isAdmin := userCount == 0 | |
| 148 | + isAdminInt := 0 | |
| 149 | + if isAdmin { | |
| 150 | + isAdminInt = 1 | |
| 151 | + } | |
| 152 | + now := time.Now().UTC() | |
| 153 | + | |
| 154 | + res, err := tx.ExecContext(cctx, ` | |
| 155 | + INSERT INTO users (username, email, password_hash, bio, is_admin, created_at) | |
| 156 | + VALUES (?, ?, ?, '', ?, ?) | |
| 157 | + `, username, email, hash, isAdminInt, now) | |
| 158 | + if err != nil { | |
| 159 | + if strings.Contains(strings.ToLower(err.Error()), "unique") { | |
| 160 | + return nil, ErrUserAlreadyExists | |
| 161 | + } | |
| 162 | + return nil, fmt.Errorf("insert user: %w", err) | |
| 163 | + } | |
| 164 | + | |
| 165 | + id, _ := res.LastInsertId() | |
| 166 | + if err := tx.Commit(); err != nil { | |
| 167 | + return nil, fmt.Errorf("commit register tx: %w", err) | |
| 168 | + } | |
| 169 | + | |
| 170 | + return &User{ | |
| 171 | + ID: id, | |
| 172 | + Username: username, | |
| 173 | + Email: email, | |
| 174 | + PasswordHash: hash, | |
| 175 | + IsAdmin: isAdmin, | |
| 176 | + CreatedAt: now, | |
| 177 | + }, nil | |
| 178 | +} | |
| 179 | + | |
| 180 | +func (s *Service) Authenticate(ctx context.Context, login, password string) (*User, error) { | |
| 181 | + login = strings.TrimSpace(login) | |
| 182 | + if login == "" || password == "" { | |
| 183 | + return nil, auth.ErrInvalidCredentials | |
| 184 | + } | |
| 185 | + | |
| 186 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 187 | + defer cancel() | |
| 188 | + | |
| 189 | + u, err := s.scanUser(s.db.QueryRowContext(cctx, ` | |
| 190 | + SELECT id, username, email, password_hash, full_name, bio, avatar_url, location, website, is_admin, created_at | |
| 191 | + FROM users | |
| 192 | + WHERE LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?) | |
| 193 | + `, login, login)) | |
| 194 | + if err != nil { | |
| 195 | + if errors.Is(err, ErrUserNotFound) { | |
| 196 | + return nil, auth.ErrInvalidCredentials | |
| 197 | + } | |
| 198 | + return nil, err | |
| 199 | + } | |
| 200 | + | |
| 201 | + ok, err := auth.VerifyPassword(password, u.PasswordHash) | |
| 202 | + if err != nil || !ok { | |
| 203 | + return nil, auth.ErrInvalidCredentials | |
| 204 | + } | |
| 205 | + return u, nil | |
| 206 | +} | |
| 207 | + | |
| 208 | +func (s *Service) GetByID(ctx context.Context, id int64) (*User, error) { | |
| 209 | + cctx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 210 | + defer cancel() | |
| 211 | + | |
| 212 | + return s.scanUser(s.db.QueryRowContext(cctx, ` | |
| 213 | + SELECT id, username, email, password_hash, full_name, bio, avatar_url, location, website, is_admin, created_at | |
| 214 | + FROM users WHERE id = ? | |
| 215 | + `, id)) | |
| 216 | +} | |
| 217 | + | |
| 218 | +func (s *Service) GetByUsername(ctx context.Context, username string) (*User, error) { | |
| 219 | + cctx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 220 | + defer cancel() | |
| 221 | + | |
| 222 | + return s.scanUser(s.db.QueryRowContext(cctx, ` | |
| 223 | + SELECT id, username, email, password_hash, full_name, bio, avatar_url, location, website, is_admin, created_at | |
| 224 | + FROM users WHERE LOWER(username) = LOWER(?) | |
| 225 | + `, strings.TrimSpace(username))) | |
| 226 | +} | |
| 227 | + | |
| 228 | +func (s *Service) UpdateProfile(ctx context.Context, userID int64, email, bio string) error { | |
| 229 | + u, err := s.GetByID(ctx, userID) | |
| 230 | + if err != nil { | |
| 231 | + return err | |
| 232 | + } | |
| 233 | + return s.UpdateProfileDetails(ctx, userID, email, u.FullName, bio, u.Location, u.Website) | |
| 234 | +} | |
| 235 | + | |
| 236 | +func (s *Service) UpdateProfileDetails(ctx context.Context, userID int64, email, fullName, bio, location, website string) error { | |
| 237 | + email = strings.ToLower(strings.TrimSpace(email)) | |
| 238 | + fullName = strings.TrimSpace(fullName) | |
| 239 | + bio = strings.TrimSpace(bio) | |
| 240 | + location = strings.TrimSpace(location) | |
| 241 | + website = strings.TrimSpace(website) | |
| 242 | + | |
| 243 | + if len(fullName) > 80 { | |
| 244 | + fullName = fullName[:80] | |
| 245 | + } | |
| 246 | + if len(bio) > 280 { | |
| 247 | + bio = bio[:280] | |
| 248 | + } | |
| 249 | + if len(location) > 80 { | |
| 250 | + location = location[:80] | |
| 251 | + } | |
| 252 | + if len(website) > 200 { | |
| 253 | + website = website[:200] | |
| 254 | + } | |
| 255 | + if website != "" && !strings.HasPrefix(website, "http://") && !strings.HasPrefix(website, "https://") { | |
| 256 | + website = "https://" + website | |
| 257 | + } | |
| 258 | + if err := ValidateEmail(email); err != nil { | |
| 259 | + return err | |
| 260 | + } | |
| 261 | + | |
| 262 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 263 | + defer cancel() | |
| 264 | + | |
| 265 | + _, err := s.db.ExecContext(cctx, ` | |
| 266 | + UPDATE users SET email = ?, full_name = ?, bio = ?, location = ?, website = ? WHERE id = ? | |
| 267 | + `, email, fullName, bio, location, website, userID) | |
| 268 | + if err != nil { | |
| 269 | + if strings.Contains(strings.ToLower(err.Error()), "unique") { | |
| 270 | + return ErrUserAlreadyExists | |
| 271 | + } | |
| 272 | + return fmt.Errorf("update profile: %w", err) | |
| 273 | + } | |
| 274 | + return nil | |
| 275 | +} | |
| 276 | + | |
| 277 | +func (s *Service) UpdateAvatar(ctx context.Context, userID int64, avatarURL string) error { | |
| 278 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 279 | + defer cancel() | |
| 280 | + | |
| 281 | + _, err := s.db.ExecContext(cctx, ` | |
| 282 | + UPDATE users SET avatar_url = ? WHERE id = ? | |
| 283 | + `, strings.TrimSpace(avatarURL), userID) | |
| 284 | + if err != nil { | |
| 285 | + return fmt.Errorf("update avatar: %w", err) | |
| 286 | + } | |
| 287 | + return nil | |
| 288 | +} | |
| 289 | + | |
| 290 | +func (s *Service) UpdatePassword(ctx context.Context, userID int64, currentPassword, newPassword string) error { | |
| 291 | + if len(newPassword) < 8 { | |
| 292 | + return ErrWeakPassword | |
| 293 | + } | |
| 294 | + u, err := s.GetByID(ctx, userID) | |
| 295 | + if err != nil { | |
| 296 | + return err | |
| 297 | + } | |
| 298 | + ok, err := auth.VerifyPassword(currentPassword, u.PasswordHash) | |
| 299 | + if err != nil || !ok { | |
| 300 | + return auth.ErrInvalidCredentials | |
| 301 | + } | |
| 302 | + newHash, err := auth.HashPassword(newPassword) | |
| 303 | + if err != nil { | |
| 304 | + return fmt.Errorf("hash new password: %w", err) | |
| 305 | + } | |
| 306 | + | |
| 307 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 308 | + defer cancel() | |
| 309 | + | |
| 310 | + _, err = s.db.ExecContext(cctx, `UPDATE users SET password_hash = ? WHERE id = ?`, newHash, userID) | |
| 311 | + if err != nil { | |
| 312 | + return fmt.Errorf("update password: %w", err) | |
| 313 | + } | |
| 314 | + return nil | |
| 315 | +} | |
| 316 | + | |
| 317 | +func (s *Service) ListAll(ctx context.Context) ([]User, error) { | |
| 318 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 319 | + defer cancel() | |
| 320 | + | |
| 321 | + rows, err := s.db.QueryContext(cctx, ` | |
| 322 | + SELECT u.id, u.username, u.email, u.password_hash, u.full_name, u.bio, u.avatar_url, u.location, u.website, u.is_admin, u.created_at, | |
| 323 | + COUNT(r.id) AS repo_count | |
| 324 | + FROM users u | |
| 325 | + LEFT JOIN repositories r ON r.owner_id = u.id | |
| 326 | + GROUP BY u.id | |
| 327 | + ORDER BY u.created_at ASC | |
| 328 | + `) | |
| 329 | + if err != nil { | |
| 330 | + return nil, fmt.Errorf("list users: %w", err) | |
| 331 | + } | |
| 332 | + defer rows.Close() | |
| 333 | + | |
| 334 | + var users []User | |
| 335 | + for rows.Next() { | |
| 336 | + var u User | |
| 337 | + var isAdmin int | |
| 338 | + if err := rows.Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &u.FullName, &u.Bio, &u.AvatarURL, &u.Location, &u.Website, &isAdmin, &u.CreatedAt, &u.RepoCount); err != nil { | |
| 339 | + return nil, fmt.Errorf("scan user: %w", err) | |
| 340 | + } | |
| 341 | + u.IsAdmin = isAdmin == 1 | |
| 342 | + users = append(users, u) | |
| 343 | + } | |
| 344 | + return users, rows.Err() | |
| 345 | +} | |
| 346 | + | |
| 347 | +func (s *Service) SetAdmin(ctx context.Context, userID int64, isAdmin bool) error { | |
| 348 | + cctx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 349 | + defer cancel() | |
| 350 | + val := 0 | |
| 351 | + if isAdmin { | |
| 352 | + val = 1 | |
| 353 | + } | |
| 354 | + _, err := s.db.ExecContext(cctx, `UPDATE users SET is_admin = ? WHERE id = ?`, val, userID) | |
| 355 | + return err | |
| 356 | +} | |
| 357 | + | |
| 358 | +func (s *Service) scanUser(row *sql.Row) (*User, error) { | |
| 359 | + var u User | |
| 360 | + var isAdmin int | |
| 361 | + err := row.Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &u.FullName, &u.Bio, &u.AvatarURL, &u.Location, &u.Website, &isAdmin, &u.CreatedAt) | |
| 362 | + if err != nil { | |
| 363 | + if errors.Is(err, sql.ErrNoRows) { | |
| 364 | + return nil, ErrUserNotFound | |
| 365 | + } | |
| 366 | + return nil, fmt.Errorf("scan user: %w", err) | |
| 367 | + } | |
| 368 | + u.IsAdmin = isAdmin == 1 | |
| 369 | + return &u, nil | |
| 370 | +} |
internal/user/user_test.go
+76
-0
| ... | ... | @@ -0,0 +1,76 @@ |
| 1 | +package user | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "errors" | |
| 6 | + "path/filepath" | |
| 7 | + "testing" | |
| 8 | + | |
| 9 | + "polar/internal/db" | |
| 10 | +) | |
| 11 | + | |
| 12 | +func TestValidateUsername(t *testing.T) { | |
| 13 | + valid := []string{"alice", "rin", "dev_01", "git-user-99", "ab"} | |
| 14 | + for _, u := range valid { | |
| 15 | + if err := ValidateUsername(u); err != nil { | |
| 16 | + t.Errorf("expected username %q to be valid, got %v", u, err) | |
| 17 | + } | |
| 18 | + } | |
| 19 | + | |
| 20 | + invalid := []string{ | |
| 21 | + "", "a", "-alice", "alice-", "alice..dev", "../etc", "foo/bar", | |
| 22 | + `foo\bar`, "repo.git", "admin", "login", "register", "settings", | |
| 23 | + "_ui", "healthz", "explore", "alice--user", | |
| 24 | + } | |
| 25 | + for _, u := range invalid { | |
| 26 | + if err := ValidateUsername(u); err == nil { | |
| 27 | + t.Errorf("expected username %q to be rejected", u) | |
| 28 | + } | |
| 29 | + } | |
| 30 | +} | |
| 31 | + | |
| 32 | +func TestFirstUserAdminAndRegistrationToggle(t *testing.T) { | |
| 33 | + ctx := context.Background() | |
| 34 | + database, err := db.Open(ctx, filepath.Join(t.TempDir(), "users.db"), true) | |
| 35 | + if err != nil { | |
| 36 | + t.Fatalf("open db: %v", err) | |
| 37 | + } | |
| 38 | + defer database.Close() | |
| 39 | + | |
| 40 | + svc := NewService(database) | |
| 41 | + | |
| 42 | + // First user becomes admin even if regEnabled is false | |
| 43 | + first, err := svc.Register(ctx, "alice", "alice@example.com", "supersecret123", false) | |
| 44 | + if err != nil { | |
| 45 | + t.Fatalf("Register first user failed: %v", err) | |
| 46 | + } | |
| 47 | + if !first.IsAdmin { | |
| 48 | + t.Fatalf("expected first registered user to be admin") | |
| 49 | + } | |
| 50 | + | |
| 51 | + // Second user when regEnabled is false must fail | |
| 52 | + _, err = svc.Register(ctx, "bob", "bob@example.com", "supersecret123", false) | |
| 53 | + if !errors.Is(err, ErrRegistrationDisabled) { | |
| 54 | + t.Fatalf("expected ErrRegistrationDisabled, got %v", err) | |
| 55 | + } | |
| 56 | + | |
| 57 | + // Second user when regEnabled is true succeeds and is NOT admin | |
| 58 | + second, err := svc.Register(ctx, "bob", "bob@example.com", "supersecret123", true) | |
| 59 | + if err != nil { | |
| 60 | + t.Fatalf("Register second user failed: %v", err) | |
| 61 | + } | |
| 62 | + if second.IsAdmin { | |
| 63 | + t.Fatalf("expected second user NOT to be admin") | |
| 64 | + } | |
| 65 | + | |
| 66 | + // Authenticate by username and email | |
| 67 | + if _, err := svc.Authenticate(ctx, "alice", "supersecret123"); err != nil { | |
| 68 | + t.Fatalf("Authenticate by username failed: %v", err) | |
| 69 | + } | |
| 70 | + if _, err := svc.Authenticate(ctx, "ALICE@example.com", "supersecret123"); err != nil { | |
| 71 | + t.Fatalf("Authenticate by email failed: %v", err) | |
| 72 | + } | |
| 73 | + if _, err := svc.Authenticate(ctx, "alice", "wrongpassword"); err == nil { | |
| 74 | + t.Fatalf("expected error for wrong password") | |
| 75 | + } | |
| 76 | +} |
internal/web/handlers/activity.go
+31
-0
| ... | ... | @@ -0,0 +1,31 @@ |
| 1 | +package handlers | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "github.com/gofiber/fiber/v2" | |
| 5 | + | |
| 6 | + "polar/internal/web/middleware" | |
| 7 | +) | |
| 8 | + | |
| 9 | +func (h *Handler) RepoActivityPage(c *fiber.Ctx) error { | |
| 10 | + r, diskPath, err := h.loadAuthorizedRepo(c, false) | |
| 11 | + if err != nil { | |
| 12 | + return nil | |
| 13 | + } | |
| 14 | + if !r.EnableActivity { | |
| 15 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.activity_disabled") | |
| 16 | + } | |
| 17 | + | |
| 18 | + items, stats, _ := h.repoSvc.GetRepoActivity(c.UserContext(), r, diskPath) | |
| 19 | + | |
| 20 | + viewer := middleware.CurrentUser(c) | |
| 21 | + data := h.baseData(c, h.tr(c, "activity.title")+" · "+r.OwnerUsername+"/"+r.Name) | |
| 22 | + data["Repo"] = r | |
| 23 | + data["Ref"] = r.DefaultBranch | |
| 24 | + data["ActiveTab"] = "activity" | |
| 25 | + data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer) | |
| 26 | + data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name) | |
| 27 | + data["Items"] = items | |
| 28 | + data["Stats"] = stats | |
| 29 | + | |
| 30 | + return h.render(c, fiber.StatusOK, "activity", data) | |
| 31 | +} |
internal/web/handlers/auth.go
+273
-0
| ... | ... | @@ -0,0 +1,273 @@ |
| 1 | +package handlers | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "encoding/base64" | |
| 5 | + "strconv" | |
| 6 | + "strings" | |
| 7 | + | |
| 8 | + "polar/internal/auth" | |
| 9 | + "polar/internal/i18n" | |
| 10 | + "polar/internal/web/middleware" | |
| 11 | + | |
| 12 | + "github.com/gofiber/fiber/v2" | |
| 13 | +) | |
| 14 | + | |
| 15 | +func (h *Handler) LoginPage(c *fiber.Ctx) error { | |
| 16 | + if middleware.CurrentUser(c) != nil { | |
| 17 | + return c.Redirect("/repos", fiber.StatusSeeOther) | |
| 18 | + } | |
| 19 | + lang, _ := c.Locals("lang").(i18n.Lang) | |
| 20 | + data := h.baseData(c, i18n.T(lang, "nav.login")) | |
| 21 | + data["Next"] = safeNextPath(c.Query("next")) | |
| 22 | + return h.render(c, fiber.StatusOK, "login", data) | |
| 23 | +} | |
| 24 | + | |
| 25 | +func (h *Handler) LoginSubmit(c *fiber.Ctx) error { | |
| 26 | + lang, _ := c.Locals("lang").(i18n.Lang) | |
| 27 | + login := strings.TrimSpace(c.FormValue("login")) | |
| 28 | + password := c.FormValue("password") | |
| 29 | + | |
| 30 | + u, err := h.userSvc.Authenticate(c.UserContext(), login, password) | |
| 31 | + if err != nil { | |
| 32 | + data := h.baseData(c, i18n.T(lang, "nav.login")) | |
| 33 | + data["FormLogin"] = login | |
| 34 | + data["Next"] = safeNextPath(c.FormValue("next")) | |
| 35 | + data["Error"] = i18n.T(lang, "auth.invalid_creds") | |
| 36 | + return h.render(c, fiber.StatusUnauthorized, "login", data) | |
| 37 | + } | |
| 38 | + | |
| 39 | + sess, err := h.authSvc.CreateSession(c.UserContext(), u.ID) | |
| 40 | + if err != nil { | |
| 41 | + h.logger.Error("create session failed", "err", err) | |
| 42 | + return h.renderErrorT(c, fiber.StatusInternalServerError, "err.session_error", "err.session_create") | |
| 43 | + } | |
| 44 | + | |
| 45 | + c.Cookie(&fiber.Cookie{ | |
| 46 | + Name: middleware.SessionCookieName, | |
| 47 | + Value: sess.ID, | |
| 48 | + Path: "/", | |
| 49 | + Expires: sess.ExpiresAt, | |
| 50 | + HTTPOnly: true, | |
| 51 | + SameSite: "Lax", | |
| 52 | + }) | |
| 53 | + | |
| 54 | + if next := safeNextPath(c.FormValue("next")); next != "" { | |
| 55 | + return c.Redirect(next, fiber.StatusSeeOther) | |
| 56 | + } | |
| 57 | + return c.Redirect("/repos", fiber.StatusSeeOther) | |
| 58 | +} | |
| 59 | + | |
| 60 | +// safeNextPath returns a same-site path for post-login redirects. | |
| 61 | +func safeNextPath(next string) string { | |
| 62 | + next = strings.TrimSpace(next) | |
| 63 | + if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.HasPrefix(next, "/\\") { | |
| 64 | + return "" | |
| 65 | + } | |
| 66 | + return next | |
| 67 | +} | |
| 68 | + | |
| 69 | +func (h *Handler) RegisterPage(c *fiber.Ctx) error { | |
| 70 | + if middleware.CurrentUser(c) != nil { | |
| 71 | + return c.Redirect("/repos", fiber.StatusSeeOther) | |
| 72 | + } | |
| 73 | + lang, _ := c.Locals("lang").(i18n.Lang) | |
| 74 | + data := h.baseData(c, i18n.T(lang, "nav.register")) | |
| 75 | + return h.render(c, fiber.StatusOK, "register", data) | |
| 76 | +} | |
| 77 | + | |
| 78 | +func (h *Handler) RegisterSubmit(c *fiber.Ctx) error { | |
| 79 | + lang, _ := c.Locals("lang").(i18n.Lang) | |
| 80 | + username := strings.TrimSpace(c.FormValue("username")) | |
| 81 | + email := strings.TrimSpace(c.FormValue("email")) | |
| 82 | + password := c.FormValue("password") | |
| 83 | + | |
| 84 | + regAllowed := h.isRegistrationAllowed(c) | |
| 85 | + u, err := h.userSvc.Register(c.UserContext(), username, email, password, regAllowed) | |
| 86 | + if err != nil { | |
| 87 | + data := h.baseData(c, i18n.T(lang, "nav.register")) | |
| 88 | + data["FormUsername"] = username | |
| 89 | + data["FormEmail"] = email | |
| 90 | + data["Error"] = err.Error() | |
| 91 | + return h.render(c, fiber.StatusBadRequest, "register", data) | |
| 92 | + } | |
| 93 | + | |
| 94 | + sess, err := h.authSvc.CreateSession(c.UserContext(), u.ID) | |
| 95 | + if err != nil { | |
| 96 | + h.logger.Error("create session after register failed", "err", err) | |
| 97 | + return c.Redirect("/login", fiber.StatusSeeOther) | |
| 98 | + } | |
| 99 | + | |
| 100 | + c.Cookie(&fiber.Cookie{ | |
| 101 | + Name: middleware.SessionCookieName, | |
| 102 | + Value: sess.ID, | |
| 103 | + Path: "/", | |
| 104 | + Expires: sess.ExpiresAt, | |
| 105 | + HTTPOnly: true, | |
| 106 | + SameSite: "Lax", | |
| 107 | + }) | |
| 108 | + | |
| 109 | + h.setFlashT(c, "flash.welcome", u.Username) | |
| 110 | + return c.Redirect("/repos", fiber.StatusSeeOther) | |
| 111 | +} | |
| 112 | + | |
| 113 | +func (h *Handler) LogoutSubmit(c *fiber.Ctx) error { | |
| 114 | + sessID := c.Cookies(middleware.SessionCookieName) | |
| 115 | + if sessID != "" { | |
| 116 | + _ = h.authSvc.DeleteSession(c.UserContext(), sessID) | |
| 117 | + } | |
| 118 | + c.ClearCookie(middleware.SessionCookieName) | |
| 119 | + return c.Redirect("/", fiber.StatusSeeOther) | |
| 120 | +} | |
| 121 | + | |
| 122 | +func (h *Handler) SettingsPage(c *fiber.Ctx) error { | |
| 123 | + u := middleware.CurrentUser(c) | |
| 124 | + lang, _ := c.Locals("lang").(i18n.Lang) | |
| 125 | + data := h.baseData(c, i18n.T(lang, "settings.title")) | |
| 126 | + | |
| 127 | + tokens, err := h.authSvc.ListTokens(c.UserContext(), u.ID) | |
| 128 | + if err != nil { | |
| 129 | + h.logger.Error("list tokens failed", "err", err) | |
| 130 | + } | |
| 131 | + data["Tokens"] = tokens | |
| 132 | + | |
| 133 | + newToken := c.Cookies("polar_new_token") | |
| 134 | + if newToken != "" { | |
| 135 | + c.ClearCookie("polar_new_token") | |
| 136 | + data["NewRawToken"] = newToken | |
| 137 | + } | |
| 138 | + | |
| 139 | + return h.render(c, fiber.StatusOK, "settings", data) | |
| 140 | +} | |
| 141 | + | |
| 142 | +func (h *Handler) UpdateProfileSubmit(c *fiber.Ctx) error { | |
| 143 | + u := middleware.CurrentUser(c) | |
| 144 | + email := c.FormValue("email") | |
| 145 | + fullName := c.FormValue("full_name") | |
| 146 | + bio := c.FormValue("bio") | |
| 147 | + location := c.FormValue("location") | |
| 148 | + website := c.FormValue("website") | |
| 149 | + | |
| 150 | + if err := h.userSvc.UpdateProfileDetails(c.UserContext(), u.ID, email, fullName, bio, location, website); err != nil { | |
| 151 | + lang, _ := c.Locals("lang").(i18n.Lang) | |
| 152 | + data := h.baseData(c, i18n.T(lang, "settings.title")) | |
| 153 | + tokens, _ := h.authSvc.ListTokens(c.UserContext(), u.ID) | |
| 154 | + data["Tokens"] = tokens | |
| 155 | + data["Error"] = err.Error() | |
| 156 | + return h.render(c, fiber.StatusBadRequest, "settings", data) | |
| 157 | + } | |
| 158 | + | |
| 159 | + if c.FormValue("remove_avatar") == "true" { | |
| 160 | + _ = h.userSvc.UpdateAvatar(c.UserContext(), u.ID, "") | |
| 161 | + } else if avatarData := strings.TrimSpace(c.FormValue("avatar_data")); avatarData != "" && strings.HasPrefix(avatarData, "data:image/") { | |
| 162 | + _ = h.userSvc.UpdateAvatar(c.UserContext(), u.ID, avatarData) | |
| 163 | + } else if file, err := c.FormFile("avatar_file"); err == nil && file != nil && file.Size > 0 && file.Size <= 2*1024*1024 { | |
| 164 | + if f, err := file.Open(); err == nil { | |
| 165 | + defer f.Close() | |
| 166 | + buf := make([]byte, file.Size) | |
| 167 | + if _, err := f.Read(buf); err == nil { | |
| 168 | + mime := file.Header.Get("Content-Type") | |
| 169 | + if mime == "" { | |
| 170 | + mime = "image/png" | |
| 171 | + } | |
| 172 | + dataURI := "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(buf) | |
| 173 | + _ = h.userSvc.UpdateAvatar(c.UserContext(), u.ID, dataURI) | |
| 174 | + } | |
| 175 | + } | |
| 176 | + } | |
| 177 | + | |
| 178 | + h.setFlashT(c, "flash.profile_updated") | |
| 179 | + return c.Redirect("/settings", fiber.StatusSeeOther) | |
| 180 | +} | |
| 181 | + | |
| 182 | +func (h *Handler) UpdatePasswordSubmit(c *fiber.Ctx) error { | |
| 183 | + u := middleware.CurrentUser(c) | |
| 184 | + currentPwd := c.FormValue("current_password") | |
| 185 | + newPwd := c.FormValue("new_password") | |
| 186 | + | |
| 187 | + if err := h.userSvc.UpdatePassword(c.UserContext(), u.ID, currentPwd, newPwd); err != nil { | |
| 188 | + lang, _ := c.Locals("lang").(i18n.Lang) | |
| 189 | + data := h.baseData(c, i18n.T(lang, "settings.title")) | |
| 190 | + tokens, _ := h.authSvc.ListTokens(c.UserContext(), u.ID) | |
| 191 | + data["Tokens"] = tokens | |
| 192 | + data["Error"] = err.Error() | |
| 193 | + return h.render(c, fiber.StatusBadRequest, "settings", data) | |
| 194 | + } | |
| 195 | + | |
| 196 | + h.setFlashT(c, "flash.password_updated") | |
| 197 | + return c.Redirect("/settings", fiber.StatusSeeOther) | |
| 198 | +} | |
| 199 | + | |
| 200 | +func (h *Handler) CreateTokenSubmit(c *fiber.Ctx) error { | |
| 201 | + u := middleware.CurrentUser(c) | |
| 202 | + name := c.FormValue("name") | |
| 203 | + | |
| 204 | + rawToken, _, err := h.authSvc.CreateToken(c.UserContext(), u.ID, name) | |
| 205 | + if err != nil { | |
| 206 | + lang, _ := c.Locals("lang").(i18n.Lang) | |
| 207 | + data := h.baseData(c, i18n.T(lang, "settings.title")) | |
| 208 | + tokens, _ := h.authSvc.ListTokens(c.UserContext(), u.ID) | |
| 209 | + data["Tokens"] = tokens | |
| 210 | + data["Error"] = err.Error() | |
| 211 | + return h.render(c, fiber.StatusBadRequest, "settings", data) | |
| 212 | + } | |
| 213 | + | |
| 214 | + c.Cookie(&fiber.Cookie{ | |
| 215 | + Name: "polar_new_token", | |
| 216 | + Value: rawToken, | |
| 217 | + Path: "/settings", | |
| 218 | + MaxAge: 60, | |
| 219 | + HTTPOnly: true, | |
| 220 | + SameSite: "Lax", | |
| 221 | + }) | |
| 222 | + return c.Redirect("/settings#tokens", fiber.StatusSeeOther) | |
| 223 | +} | |
| 224 | + | |
| 225 | +func (h *Handler) RevokeTokenSubmit(c *fiber.Ctx) error { | |
| 226 | + u := middleware.CurrentUser(c) | |
| 227 | + tokenID, err := strconv.ParseInt(c.Params("id"), 10, 64) | |
| 228 | + if err != nil { | |
| 229 | + return c.Redirect("/settings#tokens", fiber.StatusSeeOther) | |
| 230 | + } | |
| 231 | + _ = h.authSvc.RevokeToken(c.UserContext(), u.ID, tokenID) | |
| 232 | + h.setFlashT(c, "flash.token_revoked") | |
| 233 | + return c.Redirect("/settings#tokens", fiber.StatusSeeOther) | |
| 234 | +} | |
| 235 | + | |
| 236 | +func (h *Handler) AdminPage(c *fiber.Ctx) error { | |
| 237 | + lang, _ := c.Locals("lang").(i18n.Lang) | |
| 238 | + data := h.baseData(c, i18n.T(lang, "admin.title")) | |
| 239 | + users, err := h.userSvc.ListAll(c.UserContext()) | |
| 240 | + if err != nil { | |
| 241 | + h.logger.Error("list users failed", "err", err) | |
| 242 | + } | |
| 243 | + data["Users"] = users | |
| 244 | + return h.render(c, fiber.StatusOK, "admin", data) | |
| 245 | +} | |
| 246 | + | |
| 247 | +func (h *Handler) AdminSettingsSubmit(c *fiber.Ctx) error { | |
| 248 | + key := c.FormValue("setting_key") | |
| 249 | + val := c.FormValue("setting_val") | |
| 250 | + | |
| 251 | + // Fallback for legacy registration_enabled direct form | |
| 252 | + if key == "" { | |
| 253 | + if rVal := c.FormValue("registration_enabled"); rVal != "" { | |
| 254 | + key = "registration_enabled" | |
| 255 | + val = rVal | |
| 256 | + } | |
| 257 | + } | |
| 258 | + | |
| 259 | + if key == "" { | |
| 260 | + return c.Redirect("/admin", fiber.StatusSeeOther) | |
| 261 | + } | |
| 262 | + | |
| 263 | + enabled := val == "true" || val == "1" || val == "on" | |
| 264 | + if err := h.db.SetBoolSetting(c.UserContext(), key, enabled); err != nil { | |
| 265 | + h.logger.Error("update admin setting failed", "key", key, "err", err) | |
| 266 | + return h.renderErrorT(c, fiber.StatusInternalServerError, "err.settings_error", "err.settings_save") | |
| 267 | + } | |
| 268 | + | |
| 269 | + h.setFlashT(c, "flash.settings_updated") | |
| 270 | + return c.Redirect("/admin", fiber.StatusSeeOther) | |
| 271 | +} | |
| 272 | + | |
| 273 | +var _ = auth.ErrInvalidCredentials |
internal/web/handlers/collaborators.go
+55
-0
| ... | ... | @@ -0,0 +1,55 @@ |
| 1 | +package handlers | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "fmt" | |
| 5 | + "strconv" | |
| 6 | + "strings" | |
| 7 | + | |
| 8 | + "github.com/gofiber/fiber/v2" | |
| 9 | + | |
| 10 | + "polar/internal/web/middleware" | |
| 11 | +) | |
| 12 | + | |
| 13 | +// RepoCollaboratorAddSubmit grants a user access to the repository. | |
| 14 | +func (h *Handler) RepoCollaboratorAddSubmit(c *fiber.Ctx) error { | |
| 15 | + r, _, err := h.loadAuthorizedRepo(c, true) | |
| 16 | + if err != nil { | |
| 17 | + return nil | |
| 18 | + } | |
| 19 | + viewer := middleware.CurrentUser(c) | |
| 20 | + if viewer == nil || (viewer.ID != r.OwnerID && !viewer.IsAdmin) { | |
| 21 | + return h.renderErrorT(c, fiber.StatusForbidden, "err.forbidden", "err.collab_forbidden") | |
| 22 | + } | |
| 23 | + | |
| 24 | + username := strings.TrimSpace(c.FormValue("username")) | |
| 25 | + role := strings.TrimSpace(c.FormValue("role")) | |
| 26 | + | |
| 27 | + if err := h.repoSvc.AddCollaborator(c.UserContext(), r, username, role); err != nil { | |
| 28 | + h.setFlashT(c, "flash.collab_failed", err.Error()) | |
| 29 | + } else { | |
| 30 | + h.setFlashT(c, "flash.collab_added", username) | |
| 31 | + } | |
| 32 | + return c.Redirect(fmt.Sprintf("/%s/%s/settings#collaborators", r.OwnerUsername, r.Name), fiber.StatusSeeOther) | |
| 33 | +} | |
| 34 | + | |
| 35 | +// RepoCollaboratorRemoveSubmit revokes a user's access to the repository. | |
| 36 | +func (h *Handler) RepoCollaboratorRemoveSubmit(c *fiber.Ctx) error { | |
| 37 | + r, _, err := h.loadAuthorizedRepo(c, true) | |
| 38 | + if err != nil { | |
| 39 | + return nil | |
| 40 | + } | |
| 41 | + viewer := middleware.CurrentUser(c) | |
| 42 | + if viewer == nil || (viewer.ID != r.OwnerID && !viewer.IsAdmin) { | |
| 43 | + return h.renderErrorT(c, fiber.StatusForbidden, "err.forbidden", "err.collab_forbidden") | |
| 44 | + } | |
| 45 | + | |
| 46 | + userID, _ := strconv.ParseInt(strings.TrimSpace(c.FormValue("user_id")), 10, 64) | |
| 47 | + if userID > 0 { | |
| 48 | + if err := h.repoSvc.RemoveCollaborator(c.UserContext(), r.ID, userID); err != nil { | |
| 49 | + h.setFlashT(c, "flash.collab_failed", err.Error()) | |
| 50 | + } else { | |
| 51 | + h.setFlashT(c, "flash.collab_removed") | |
| 52 | + } | |
| 53 | + } | |
| 54 | + return c.Redirect(fmt.Sprintf("/%s/%s/settings#collaborators", r.OwnerUsername, r.Name), fiber.StatusSeeOther) | |
| 55 | +} |
internal/web/handlers/device.go
+171
-0
| ... | ... | @@ -0,0 +1,171 @@ |
| 1 | +package handlers | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "strings" | |
| 5 | + "time" | |
| 6 | + | |
| 7 | + "github.com/gofiber/fiber/v2" | |
| 8 | + | |
| 9 | + "polar/internal/auth" | |
| 10 | + "polar/internal/web/middleware" | |
| 11 | +) | |
| 12 | + | |
| 13 | +func (h *Handler) siteURL(c *fiber.Ctx) string { | |
| 14 | + base := strings.TrimRight(h.cfg.Server.BaseURL, "/") | |
| 15 | + if base == "" || strings.Contains(base, "localhost:3000") { | |
| 16 | + if reqBase := c.BaseURL(); reqBase != "" { | |
| 17 | + base = strings.TrimRight(reqBase, "/") | |
| 18 | + } | |
| 19 | + } | |
| 20 | + return base | |
| 21 | +} | |
| 22 | + | |
| 23 | +// DeviceCodeAPI starts a device authorization grant (RFC 8628). | |
| 24 | +// POST /api/v1/device/code | |
| 25 | +func (h *Handler) DeviceCodeAPI(c *fiber.Ctx) error { | |
| 26 | + clientID := strings.TrimSpace(c.FormValue("client_id")) | |
| 27 | + if clientID == "" { | |
| 28 | + var payload struct { | |
| 29 | + ClientID string `json:"client_id"` | |
| 30 | + } | |
| 31 | + if err := c.BodyParser(&payload); err == nil { | |
| 32 | + clientID = strings.TrimSpace(payload.ClientID) | |
| 33 | + } | |
| 34 | + } | |
| 35 | + if clientID == "" { | |
| 36 | + clientID = "polar-git" | |
| 37 | + } | |
| 38 | + | |
| 39 | + dc, err := h.authSvc.CreateDeviceCode(c.UserContext(), clientID) | |
| 40 | + if err != nil { | |
| 41 | + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "server_error"}) | |
| 42 | + } | |
| 43 | + | |
| 44 | + base := h.siteURL(c) | |
| 45 | + return c.JSON(fiber.Map{ | |
| 46 | + "device_code": dc.Code, | |
| 47 | + "user_code": dc.UserCode, | |
| 48 | + "verification_uri": base + "/login/device", | |
| 49 | + "verification_uri_complete": base + "/login/device?user_code=" + dc.UserCode, | |
| 50 | + "expires_in": int(auth.DeviceCodeTTL.Seconds()), | |
| 51 | + "interval": auth.DeviceCodeInterval, | |
| 52 | + }) | |
| 53 | +} | |
| 54 | + | |
| 55 | +// DeviceTokenAPI exchanges an approved device code for an access token. | |
| 56 | +// POST /api/v1/device/token | |
| 57 | +func (h *Handler) DeviceTokenAPI(c *fiber.Ctx) error { | |
| 58 | + deviceCode := strings.TrimSpace(c.FormValue("device_code")) | |
| 59 | + if deviceCode == "" { | |
| 60 | + var payload struct { | |
| 61 | + DeviceCode string `json:"device_code"` | |
| 62 | + } | |
| 63 | + if err := c.BodyParser(&payload); err == nil { | |
| 64 | + deviceCode = strings.TrimSpace(payload.DeviceCode) | |
| 65 | + } | |
| 66 | + } | |
| 67 | + | |
| 68 | + dc, err := h.authSvc.PollDeviceCode(c.UserContext(), deviceCode) | |
| 69 | + if err != nil { | |
| 70 | + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid_grant"}) | |
| 71 | + } | |
| 72 | + | |
| 73 | + switch dc.Status { | |
| 74 | + case auth.DeviceStatusPending: | |
| 75 | + return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{"error": "authorization_pending"}) | |
| 76 | + case auth.DeviceStatusDenied: | |
| 77 | + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "access_denied"}) | |
| 78 | + case auth.DeviceStatusApproved: | |
| 79 | + if dc.UserID == nil { | |
| 80 | + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid_grant"}) | |
| 81 | + } | |
| 82 | + name := "Device flow" | |
| 83 | + if dc.ClientID != "" { | |
| 84 | + name = "Device flow (" + dc.ClientID + ")" | |
| 85 | + } | |
| 86 | + raw, _, err := h.authSvc.CreateToken(c.UserContext(), *dc.UserID, name) | |
| 87 | + if err != nil { | |
| 88 | + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "server_error"}) | |
| 89 | + } | |
| 90 | + h.authSvc.ConsumeDeviceCode(c.UserContext(), dc.Code) | |
| 91 | + username := "" | |
| 92 | + if u, err := h.userSvc.GetByID(c.UserContext(), *dc.UserID); err == nil && u != nil { | |
| 93 | + username = u.Username | |
| 94 | + } | |
| 95 | + return c.JSON(fiber.Map{ | |
| 96 | + "access_token": raw, | |
| 97 | + "token_type": "bearer", | |
| 98 | + "username": username, | |
| 99 | + "scope": "git", | |
| 100 | + }) | |
| 101 | + default: | |
| 102 | + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "expired_token"}) | |
| 103 | + } | |
| 104 | +} | |
| 105 | + | |
| 106 | +// DeviceVerifyPage renders the device approval page. | |
| 107 | +// GET /login/device | |
| 108 | +func (h *Handler) DeviceVerifyPage(c *fiber.Ctx) error { | |
| 109 | + viewer := middleware.CurrentUser(c) | |
| 110 | + userCode := auth.NormalizeUserCode(c.Query("user_code")) | |
| 111 | + | |
| 112 | + data := h.baseData(c, h.tr(c, "device.title")) | |
| 113 | + data["UserCode"] = userCode | |
| 114 | + data["SignedIn"] = viewer != nil | |
| 115 | + | |
| 116 | + if userCode != "" && len(userCode) == 9 { | |
| 117 | + dc, err := h.authSvc.GetDeviceCodeByUserCode(c.UserContext(), userCode) | |
| 118 | + switch { | |
| 119 | + case err != nil: | |
| 120 | + data["CodeError"] = h.tr(c, "device.code_unknown") | |
| 121 | + case dc.Status == auth.DeviceStatusApproved: | |
| 122 | + data["CodeApproved"] = true | |
| 123 | + case dc.Status == auth.DeviceStatusDenied: | |
| 124 | + data["CodeDenied"] = true | |
| 125 | + case dc.Status != auth.DeviceStatusPending || time.Now().UTC().After(dc.ExpiresAt): | |
| 126 | + data["CodeError"] = h.tr(c, "device.code_used") | |
| 127 | + default: | |
| 128 | + data["CodePending"] = true | |
| 129 | + if dc.ClientID != "" { | |
| 130 | + data["ClientName"] = dc.ClientID | |
| 131 | + } | |
| 132 | + } | |
| 133 | + } | |
| 134 | + | |
| 135 | + return h.render(c, fiber.StatusOK, "device", data) | |
| 136 | +} | |
| 137 | + | |
| 138 | +// DeviceVerifySubmit approves or denies a pending grant. | |
| 139 | +// POST /login/device | |
| 140 | +func (h *Handler) DeviceVerifySubmit(c *fiber.Ctx) error { | |
| 141 | + viewer := middleware.CurrentUser(c) | |
| 142 | + if viewer == nil { | |
| 143 | + return c.Redirect("/login?next=/login/device", fiber.StatusSeeOther) | |
| 144 | + } | |
| 145 | + | |
| 146 | + userCode := auth.NormalizeUserCode(c.FormValue("user_code")) | |
| 147 | + action := strings.TrimSpace(c.FormValue("action")) | |
| 148 | + redirect := "/login/device?user_code=" + userCode | |
| 149 | + | |
| 150 | + if userCode == "" { | |
| 151 | + h.setFlashT(c, "flash.device_code_required") | |
| 152 | + return c.Redirect("/login/device", fiber.StatusSeeOther) | |
| 153 | + } | |
| 154 | + | |
| 155 | + status := auth.DeviceStatusApproved | |
| 156 | + if action == "deny" { | |
| 157 | + status = auth.DeviceStatusDenied | |
| 158 | + } | |
| 159 | + | |
| 160 | + if err := h.authSvc.SetDeviceCodeStatus(c.UserContext(), userCode, viewer.ID, status); err != nil { | |
| 161 | + h.setFlashT(c, "flash.device_code_invalid") | |
| 162 | + return c.Redirect("/login/device", fiber.StatusSeeOther) | |
| 163 | + } | |
| 164 | + | |
| 165 | + if status == auth.DeviceStatusApproved { | |
| 166 | + h.setFlashT(c, "flash.device_approved") | |
| 167 | + } else { | |
| 168 | + h.setFlashT(c, "flash.device_denied") | |
| 169 | + } | |
| 170 | + return c.Redirect(redirect, fiber.StatusSeeOther) | |
| 171 | +} |
internal/web/handlers/forks.go
+77
-0
| ... | ... | @@ -0,0 +1,77 @@ |
| 1 | +package handlers | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "fmt" | |
| 5 | + "strings" | |
| 6 | + | |
| 7 | + "github.com/gofiber/fiber/v2" | |
| 8 | + | |
| 9 | + "polar/internal/web/middleware" | |
| 10 | +) | |
| 11 | + | |
| 12 | +// RepoForkSubmit creates a fork of the repository for the current user. | |
| 13 | +func (h *Handler) RepoForkSubmit(c *fiber.Ctx) error { | |
| 14 | + r, _, err := h.loadAuthorizedRepo(c, false) | |
| 15 | + if err != nil { | |
| 16 | + return nil | |
| 17 | + } | |
| 18 | + | |
| 19 | + u := middleware.CurrentUser(c) | |
| 20 | + if u == nil { | |
| 21 | + return c.Redirect("/login", fiber.StatusSeeOther) | |
| 22 | + } | |
| 23 | + | |
| 24 | + if u.ID == r.OwnerID { | |
| 25 | + h.setFlashT(c, "flash.fork_self") | |
| 26 | + return c.Redirect(fmt.Sprintf("/%s/%s", r.OwnerUsername, r.Name), fiber.StatusSeeOther) | |
| 27 | + } | |
| 28 | + | |
| 29 | + if existing, _ := h.repoSvc.FindForkByOwner(c.UserContext(), r.ID, u.ID); existing != nil { | |
| 30 | + h.setFlashT(c, "flash.fork_exists") | |
| 31 | + return c.Redirect(fmt.Sprintf("/%s/%s", existing.OwnerUsername, existing.Name), fiber.StatusSeeOther) | |
| 32 | + } | |
| 33 | + | |
| 34 | + name := strings.TrimSpace(c.FormValue("name")) | |
| 35 | + newFork := r | |
| 36 | + if r.ForkedFromRepoID != nil { | |
| 37 | + if parent, parentErr := h.repoSvc.GetByID(c.UserContext(), *r.ForkedFromRepoID); parentErr == nil && parent != nil { | |
| 38 | + newFork = parent | |
| 39 | + } | |
| 40 | + } | |
| 41 | + | |
| 42 | + fork, err := h.repoSvc.Fork(c.UserContext(), newFork, u, name) | |
| 43 | + if err != nil { | |
| 44 | + h.setFlashT(c, "flash.fork_failed", err.Error()) | |
| 45 | + return c.Redirect(fmt.Sprintf("/%s/%s", r.OwnerUsername, r.Name), fiber.StatusSeeOther) | |
| 46 | + } | |
| 47 | + | |
| 48 | + h.setFlashT(c, "flash.fork_created") | |
| 49 | + return c.Redirect(fmt.Sprintf("/%s/%s", fork.OwnerUsername, fork.Name), fiber.StatusSeeOther) | |
| 50 | +} | |
| 51 | + | |
| 52 | +// RepoForksPage lists all forks of the repository. | |
| 53 | +func (h *Handler) RepoForksPage(c *fiber.Ctx) error { | |
| 54 | + r, _, err := h.loadAuthorizedRepo(c, false) | |
| 55 | + if err != nil { | |
| 56 | + return nil | |
| 57 | + } | |
| 58 | + | |
| 59 | + forks, _ := h.repoSvc.ListForks(c.UserContext(), r.ID, 100) | |
| 60 | + | |
| 61 | + var upstream map[string]string | |
| 62 | + if r.ForkedFromRepoID != nil { | |
| 63 | + upstream = map[string]string{"Owner": r.ForkedFromOwner, "Name": r.ForkedFromName} | |
| 64 | + } | |
| 65 | + | |
| 66 | + viewer := middleware.CurrentUser(c) | |
| 67 | + data := h.baseData(c, h.tr(c, "forks.title")+" · "+r.OwnerUsername+"/"+r.Name) | |
| 68 | + data["Repo"] = r | |
| 69 | + data["Ref"] = r.DefaultBranch | |
| 70 | + data["ActiveTab"] = "code" | |
| 71 | + data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer) | |
| 72 | + data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name) | |
| 73 | + data["Forks"] = forks | |
| 74 | + data["Upstream"] = upstream | |
| 75 | + | |
| 76 | + return h.render(c, fiber.StatusOK, "forks", data) | |
| 77 | +} |
internal/web/handlers/git_http.go
+170
-0
| ... | ... | @@ -0,0 +1,170 @@ |
| 1 | +package handlers | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "bytes" | |
| 5 | + "compress/gzip" | |
| 6 | + "encoding/base64" | |
| 7 | + "io" | |
| 8 | + "strings" | |
| 9 | + | |
| 10 | + "polar/internal/git" | |
| 11 | + "polar/internal/repo" | |
| 12 | + "polar/internal/user" | |
| 13 | + | |
| 14 | + "github.com/gofiber/fiber/v2" | |
| 15 | +) | |
| 16 | + | |
| 17 | +func (h *Handler) authenticateGitBasic(c *fiber.Ctx) *user.User { | |
| 18 | + authHeader := c.Get(fiber.HeaderAuthorization) | |
| 19 | + if !strings.HasPrefix(authHeader, "Basic ") { | |
| 20 | + return nil | |
| 21 | + } | |
| 22 | + decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authHeader, "Basic ")) | |
| 23 | + if err != nil { | |
| 24 | + return nil | |
| 25 | + } | |
| 26 | + parts := strings.SplitN(string(decoded), ":", 2) | |
| 27 | + if len(parts) != 2 { | |
| 28 | + return nil | |
| 29 | + } | |
| 30 | + username := strings.TrimSpace(parts[0]) | |
| 31 | + secret := strings.TrimSpace(parts[1]) | |
| 32 | + if secret == "" { | |
| 33 | + return nil | |
| 34 | + } | |
| 35 | + | |
| 36 | + // 1. Personal Access Token | |
| 37 | + if strings.HasPrefix(secret, "polar_") { | |
| 38 | + userID, err := h.authSvc.VerifyToken(c.UserContext(), secret) | |
| 39 | + if err != nil { | |
| 40 | + return nil | |
| 41 | + } | |
| 42 | + u, err := h.userSvc.GetByID(c.UserContext(), userID) | |
| 43 | + if err != nil { | |
| 44 | + return nil | |
| 45 | + } | |
| 46 | + if username != "" && !strings.EqualFold(u.Username, username) && !strings.EqualFold(u.Email, username) { | |
| 47 | + return nil | |
| 48 | + } | |
| 49 | + return u | |
| 50 | + } | |
| 51 | + | |
| 52 | + // 2. Fallback to username + password if provided | |
| 53 | + if username != "" { | |
| 54 | + if u, err := h.userSvc.Authenticate(c.UserContext(), username, secret); err == nil { | |
| 55 | + return u | |
| 56 | + } | |
| 57 | + } | |
| 58 | + return nil | |
| 59 | +} | |
| 60 | + | |
| 61 | +func requestBasicAuth(c *fiber.Ctx) error { | |
| 62 | + c.Set(fiber.HeaderWWWAuthenticate, `Basic realm="Polar Git"`) | |
| 63 | + return c.Status(fiber.StatusUnauthorized).SendString("Authentication required") | |
| 64 | +} | |
| 65 | + | |
| 66 | +func (h *Handler) authorizeGitRequest(c *fiber.Ctx, service string) (*repo.Repository, string, error) { | |
| 67 | + owner := c.Params("owner") | |
| 68 | + repoName := strings.TrimSuffix(c.Params("repo"), ".git") | |
| 69 | + | |
| 70 | + r, err := h.repoSvc.GetByOwnerAndName(c.UserContext(), owner, repoName) | |
| 71 | + if err != nil { | |
| 72 | + // If anonymous request to non-existent or private repo, prompt Basic Auth first so git clients send credentials | |
| 73 | + if c.Get(fiber.HeaderAuthorization) == "" { | |
| 74 | + return nil, "", requestBasicAuth(c) | |
| 75 | + } | |
| 76 | + return nil, "", c.Status(fiber.StatusNotFound).SendString("Repository not found") | |
| 77 | + } | |
| 78 | + | |
| 79 | + authedUser := h.authenticateGitBasic(c) | |
| 80 | + | |
| 81 | + switch service { | |
| 82 | + case "git-upload-pack": | |
| 83 | + if r.IsPrivate { | |
| 84 | + if authedUser == nil { | |
| 85 | + return nil, "", requestBasicAuth(c) | |
| 86 | + } | |
| 87 | + if !h.repoSvc.CanRead(c.UserContext(), r, authedUser) { | |
| 88 | + return nil, "", c.Status(fiber.StatusForbidden).SendString("Forbidden") | |
| 89 | + } | |
| 90 | + } | |
| 91 | + case "git-receive-pack": | |
| 92 | + if authedUser == nil { | |
| 93 | + return nil, "", requestBasicAuth(c) | |
| 94 | + } | |
| 95 | + if !h.repoSvc.CanWrite(c.UserContext(), r, authedUser) { | |
| 96 | + return nil, "", c.Status(fiber.StatusForbidden).SendString("Forbidden") | |
| 97 | + } | |
| 98 | + default: | |
| 99 | + return nil, "", c.Status(fiber.StatusBadRequest).SendString("Unsupported git service") | |
| 100 | + } | |
| 101 | + | |
| 102 | + diskPath, err := h.repoSvc.DiskPath(r.OwnerUsername, r.Name) | |
| 103 | + if err != nil { | |
| 104 | + return nil, "", c.Status(fiber.StatusBadRequest).SendString("Invalid repository path") | |
| 105 | + } | |
| 106 | + | |
| 107 | + return r, diskPath, nil | |
| 108 | +} | |
| 109 | + | |
| 110 | +func (h *Handler) GitInfoRefs(c *fiber.Ctx) error { | |
| 111 | + service := c.Query("service") | |
| 112 | + if service != "git-upload-pack" && service != "git-receive-pack" { | |
| 113 | + return c.Status(fiber.StatusBadRequest).SendString("Smart HTTP service parameter required") | |
| 114 | + } | |
| 115 | + | |
| 116 | + _, diskPath, err := h.authorizeGitRequest(c, service) | |
| 117 | + if err != nil || diskPath == "" { | |
| 118 | + return err | |
| 119 | + } | |
| 120 | + | |
| 121 | + var buf bytes.Buffer | |
| 122 | + if err := git.AdvertiseRefs(c.UserContext(), diskPath, service, &buf); err != nil { | |
| 123 | + h.logger.Error("git advertise-refs failed", "service", service, "err", err) | |
| 124 | + return c.Status(fiber.StatusInternalServerError).SendString("Git advertise-refs error") | |
| 125 | + } | |
| 126 | + | |
| 127 | + c.Set(fiber.HeaderContentType, "application/x-"+service+"-advertisement") | |
| 128 | + c.Set(fiber.HeaderCacheControl, "no-cache, max-age=0, must-revalidate") | |
| 129 | + c.Set("Pragma", "no-cache") | |
| 130 | + return c.Status(fiber.StatusOK).Send(buf.Bytes()) | |
| 131 | +} | |
| 132 | + | |
| 133 | +func (h *Handler) GitUploadPack(c *fiber.Ctx) error { | |
| 134 | + return h.handleGitServiceRPC(c, "git-upload-pack") | |
| 135 | +} | |
| 136 | + | |
| 137 | +func (h *Handler) GitReceivePack(c *fiber.Ctx) error { | |
| 138 | + return h.handleGitServiceRPC(c, "git-receive-pack") | |
| 139 | +} | |
| 140 | + | |
| 141 | +func (h *Handler) handleGitServiceRPC(c *fiber.Ctx, service string) error { | |
| 142 | + r, diskPath, err := h.authorizeGitRequest(c, service) | |
| 143 | + if err != nil || diskPath == "" { | |
| 144 | + return err | |
| 145 | + } | |
| 146 | + | |
| 147 | + var bodyReader io.Reader = bytes.NewReader(c.Body()) | |
| 148 | + if strings.EqualFold(c.Get(fiber.HeaderContentEncoding), "gzip") { | |
| 149 | + gz, err := gzip.NewReader(bodyReader) | |
| 150 | + if err != nil { | |
| 151 | + return c.Status(fiber.StatusBadRequest).SendString("Invalid gzip body") | |
| 152 | + } | |
| 153 | + defer gz.Close() | |
| 154 | + bodyReader = gz | |
| 155 | + } | |
| 156 | + | |
| 157 | + var out bytes.Buffer | |
| 158 | + if err := git.ServiceRPC(c.UserContext(), diskPath, service, bodyReader, &out); err != nil { | |
| 159 | + h.logger.Error("git service rpc failed", "service", service, "err", err) | |
| 160 | + return c.Status(fiber.StatusInternalServerError).SendString("Git RPC error") | |
| 161 | + } | |
| 162 | + | |
| 163 | + if service == "git-receive-pack" { | |
| 164 | + h.repoSvc.TouchUpdatedAt(c.UserContext(), r.ID) | |
| 165 | + } | |
| 166 | + | |
| 167 | + c.Set(fiber.HeaderContentType, "application/x-"+service+"-result") | |
| 168 | + c.Set(fiber.HeaderCacheControl, "no-cache, max-age=0, must-revalidate") | |
| 169 | + return c.Status(fiber.StatusOK).Send(out.Bytes()) | |
| 170 | +} |
internal/web/handlers/handlers.go
+351
-0
| ... | ... | @@ -0,0 +1,351 @@ |
| 1 | +package handlers | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "bytes" | |
| 5 | + "fmt" | |
| 6 | + "log/slog" | |
| 7 | + "net/http" | |
| 8 | + "strings" | |
| 9 | + "time" | |
| 10 | + | |
| 11 | + "polar/internal/auth" | |
| 12 | + "polar/internal/config" | |
| 13 | + "polar/internal/db" | |
| 14 | + "polar/internal/i18n" | |
| 15 | + "polar/internal/repo" | |
| 16 | + "polar/internal/user" | |
| 17 | + "polar/internal/web/middleware" | |
| 18 | + "polar/internal/web/static" | |
| 19 | + "polar/internal/web/views" | |
| 20 | + | |
| 21 | + "github.com/gofiber/fiber/v2" | |
| 22 | + "github.com/gofiber/fiber/v2/middleware/filesystem" | |
| 23 | +) | |
| 24 | + | |
| 25 | +type Handler struct { | |
| 26 | + cfg config.Config | |
| 27 | + db *db.DB | |
| 28 | + authSvc *auth.Service | |
| 29 | + userSvc *user.Service | |
| 30 | + repoSvc *repo.Service | |
| 31 | + views *views.Engine | |
| 32 | + logger *slog.Logger | |
| 33 | +} | |
| 34 | + | |
| 35 | +func New( | |
| 36 | + cfg config.Config, | |
| 37 | + database *db.DB, | |
| 38 | + authSvc *auth.Service, | |
| 39 | + userSvc *user.Service, | |
| 40 | + repoSvc *repo.Service, | |
| 41 | + viewsEngine *views.Engine, | |
| 42 | + logger *slog.Logger, | |
| 43 | +) *Handler { | |
| 44 | + return &Handler{ | |
| 45 | + cfg: cfg, | |
| 46 | + db: database, | |
| 47 | + authSvc: authSvc, | |
| 48 | + userSvc: userSvc, | |
| 49 | + repoSvc: repoSvc, | |
| 50 | + views: viewsEngine, | |
| 51 | + logger: logger, | |
| 52 | + } | |
| 53 | +} | |
| 54 | + | |
| 55 | +func (h *Handler) RegisterRoutes(app *fiber.App) { | |
| 56 | + app.Use(middleware.RecoverAndLog(h.logger)) | |
| 57 | + app.Use(middleware.SecurityHeaders()) | |
| 58 | + | |
| 59 | + app.Get("/healthz", h.Healthz) | |
| 60 | + | |
| 61 | + app.Use("/static", filesystem.New(filesystem.Config{ | |
| 62 | + Root: http.FS(static.FS), | |
| 63 | + Browse: false, | |
| 64 | + MaxAge: 0, | |
| 65 | + })) | |
| 66 | + | |
| 67 | + app.Use(middleware.LocaleAndTheme(h.cfg.Landing.DefaultLang)) | |
| 68 | + app.Use(middleware.SessionAuth(h.authSvc, h.userSvc)) | |
| 69 | + app.Use(middleware.CSRFProtection()) | |
| 70 | + | |
| 71 | + authLimiter := middleware.NewRateLimiter(20, time.Minute) | |
| 72 | + | |
| 73 | + // Public & Root routes | |
| 74 | + app.Get("/", h.Home) | |
| 75 | + app.Get("/explore", h.Explore) | |
| 76 | + | |
| 77 | + // Auth routes | |
| 78 | + app.Get("/login", h.LoginPage) | |
| 79 | + app.Post("/login", authLimiter.Handler(), h.LoginSubmit) | |
| 80 | + app.Get("/register", h.RegisterPage) | |
| 81 | + app.Post("/register", authLimiter.Handler(), h.RegisterSubmit) | |
| 82 | + app.Post("/logout", h.LogoutSubmit) | |
| 83 | + app.Get("/login/device", h.DeviceVerifyPage) | |
| 84 | + app.Post("/login/device", h.DeviceVerifySubmit) | |
| 85 | + app.Post("/api/v1/device/code", h.DeviceCodeAPI) | |
| 86 | + app.Post("/api/v1/device/token", h.DeviceTokenAPI) | |
| 87 | + | |
| 88 | + // Authenticated user routes | |
| 89 | + app.Get("/repos", middleware.RequireAuth(), h.MyRepos) | |
| 90 | + app.Get("/new", middleware.RequireAuth(), h.NewRepoPage) | |
| 91 | + app.Post("/new", middleware.RequireAuth(), h.NewRepoSubmit) | |
| 92 | + app.Get("/settings", middleware.RequireAuth(), h.SettingsPage) | |
| 93 | + app.Post("/settings/profile", middleware.RequireAuth(), h.UpdateProfileSubmit) | |
| 94 | + app.Post("/settings/password", middleware.RequireAuth(), h.UpdatePasswordSubmit) | |
| 95 | + app.Post("/settings/tokens", middleware.RequireAuth(), h.CreateTokenSubmit) | |
| 96 | + app.Post("/settings/tokens/:id/revoke", middleware.RequireAuth(), h.RevokeTokenSubmit) | |
| 97 | + | |
| 98 | + // Admin routes | |
| 99 | + app.Get("/admin", middleware.RequireAdmin(), h.AdminPage) | |
| 100 | + app.Post("/admin/settings", middleware.RequireAdmin(), h.AdminSettingsSubmit) | |
| 101 | + | |
| 102 | + // Git Smart HTTP endpoints (supports both /:owner/:repo.git/* and /:owner/:repo/*) | |
| 103 | + app.Get("/:owner/:repo/info/refs", h.GitInfoRefs) | |
| 104 | + app.Post("/:owner/:repo/git-upload-pack", h.GitUploadPack) | |
| 105 | + app.Post("/:owner/:repo/git-receive-pack", h.GitReceivePack) | |
| 106 | + | |
| 107 | + // Repository Web UI routes | |
| 108 | + app.Get("/:owner/:repo/settings", middleware.RequireAuth(), h.RepoSettingsPage) | |
| 109 | + app.Post("/:owner/:repo/settings", middleware.RequireAuth(), h.RepoSettingsSubmit) | |
| 110 | + app.Post("/:owner/:repo/settings/rename", middleware.RequireAuth(), h.RepoRenameSubmit) | |
| 111 | + app.Post("/:owner/:repo/settings/delete", middleware.RequireAuth(), h.RepoDeleteSubmit) | |
| 112 | + app.Post("/:owner/:repo/settings/collaborators/add", middleware.RequireAuth(), h.RepoCollaboratorAddSubmit) | |
| 113 | + app.Post("/:owner/:repo/settings/collaborators/remove", middleware.RequireAuth(), h.RepoCollaboratorRemoveSubmit) | |
| 114 | + | |
| 115 | + app.Get("/:owner/:repo/branches", h.RepoBranchesPage) | |
| 116 | + app.Post("/:owner/:repo/branches", middleware.RequireAuth(), h.RepoCreateBranchSubmit) | |
| 117 | + app.Post("/:owner/:repo/branches/delete", middleware.RequireAuth(), h.RepoDeleteBranchSubmit) | |
| 118 | + app.Post("/:owner/:repo/tags", middleware.RequireAuth(), h.RepoCreateTagSubmit) | |
| 119 | + app.Post("/:owner/:repo/tags/delete", middleware.RequireAuth(), h.RepoDeleteTagSubmit) | |
| 120 | + app.Get("/:owner/:repo/archive/*", h.RepoArchiveZip) | |
| 121 | + | |
| 122 | + app.Post("/:owner/:repo/fork", middleware.RequireAuth(), h.RepoForkSubmit) | |
| 123 | + app.Get("/:owner/:repo/forks", h.RepoForksPage) | |
| 124 | + | |
| 125 | + app.Get("/:owner/:repo/issues", h.RepoIssuesPage) | |
| 126 | + app.Get("/:owner/:repo/issues/new", middleware.RequireAuth(), h.RepoNewIssuePage) | |
| 127 | + app.Post("/:owner/:repo/issues/new", middleware.RequireAuth(), h.RepoNewIssueSubmit) | |
| 128 | + app.Get("/:owner/:repo/issues/:number", h.RepoIssueDetailPage) | |
| 129 | + app.Post("/:owner/:repo/issues/:number/comments", middleware.RequireAuth(), h.RepoIssueCommentSubmit) | |
| 130 | + app.Post("/:owner/:repo/issues/:number/state", middleware.RequireAuth(), h.RepoIssueStateSubmit) | |
| 131 | + | |
| 132 | + // Pull Requests | |
| 133 | + app.Get("/:owner/:repo/pulls", h.RepoPullsPage) | |
| 134 | + app.Get("/:owner/:repo/pulls/new", middleware.RequireAuth(), h.RepoNewPullPage) | |
| 135 | + app.Post("/:owner/:repo/pulls/new", middleware.RequireAuth(), h.RepoNewPullSubmit) | |
| 136 | + app.Get("/:owner/:repo/pulls/:number", h.RepoPullDetailPage) | |
| 137 | + app.Post("/:owner/:repo/pulls/:number/comments", middleware.RequireAuth(), h.RepoPullCommentSubmit) | |
| 138 | + app.Post("/:owner/:repo/pulls/:number/state", middleware.RequireAuth(), h.RepoPullStateSubmit) | |
| 139 | + app.Post("/:owner/:repo/pulls/:number/merge", middleware.RequireAuth(), h.RepoPullMergeSubmit) | |
| 140 | + app.Post("/:owner/:repo/pulls/:number/review", middleware.RequireAuth(), h.RepoPullReviewSubmit) | |
| 141 | + | |
| 142 | + // Releases | |
| 143 | + app.Get("/:owner/:repo/releases", h.RepoReleasesPage) | |
| 144 | + app.Get("/:owner/:repo/releases/new", middleware.RequireAuth(), h.RepoNewReleasePage) | |
| 145 | + app.Post("/:owner/:repo/releases/new", middleware.RequireAuth(), h.RepoNewReleaseSubmit) | |
| 146 | + app.Post("/:owner/:repo/releases/:id/delete", middleware.RequireAuth(), h.RepoDeleteReleaseSubmit) | |
| 147 | + | |
| 148 | + // Activity | |
| 149 | + app.Get("/:owner/:repo/activity", h.RepoActivityPage) | |
| 150 | + | |
| 151 | + app.Get("/:owner/:repo/commits/:ref", h.RepoCommitsPage) | |
| 152 | + app.Get("/:owner/:repo/commit/:hash", h.RepoCommitDetailPage) | |
| 153 | + app.Get("/:owner/:repo/tree/:ref/*", h.RepoTreePage) | |
| 154 | + app.Get("/:owner/:repo/tree/:ref", h.RepoTreePage) | |
| 155 | + app.Get("/:owner/:repo/blob/:ref/*", h.RepoBlobPage) | |
| 156 | + app.Get("/:owner/:repo/raw/:ref/*", h.RepoRawBlob) | |
| 157 | + app.Get("/:owner/:repo", h.RepoHomePage) | |
| 158 | + | |
| 159 | + // User profile page | |
| 160 | + app.Get("/:username", h.UserProfilePage) | |
| 161 | +} | |
| 162 | + | |
| 163 | +func (h *Handler) Healthz(c *fiber.Ctx) error { | |
| 164 | + if err := h.db.PingContext(c.UserContext()); err != nil { | |
| 165 | + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ | |
| 166 | + "status": "error", | |
| 167 | + "db": "unreachable", | |
| 168 | + }) | |
| 169 | + } | |
| 170 | + return c.Status(fiber.StatusOK).JSON(fiber.Map{ | |
| 171 | + "status": "ok", | |
| 172 | + "service": "polar", | |
| 173 | + }) | |
| 174 | +} | |
| 175 | + | |
| 176 | +func (h *Handler) baseData(c *fiber.Ctx, title string) map[string]any { | |
| 177 | + u := middleware.CurrentUser(c) | |
| 178 | + lang := h.langOf(c) | |
| 179 | + theme, _ := c.Locals("theme").(string) | |
| 180 | + if theme == "" { | |
| 181 | + theme = "system" | |
| 182 | + } | |
| 183 | + csrf, _ := c.Locals("csrf_token").(string) | |
| 184 | + | |
| 185 | + flash := c.Cookies("polar_flash") | |
| 186 | + if flash != "" { | |
| 187 | + c.ClearCookie("polar_flash") | |
| 188 | + } | |
| 189 | + | |
| 190 | + ctx := c.UserContext() | |
| 191 | + anonRead := h.db.IsAnonymousReadEnabled(ctx) | |
| 192 | + var paletteRepos []repo.Repository | |
| 193 | + if u != nil || anonRead { | |
| 194 | + paletteRepos, _ = h.repoSvc.ListAccessible(ctx, u, 25) | |
| 195 | + } | |
| 196 | + | |
| 197 | + data := map[string]any{ | |
| 198 | + "Title": title, | |
| 199 | + "Lang": lang, | |
| 200 | + "Theme": theme, | |
| 201 | + "CSRFToken": csrf, | |
| 202 | + "CurrentUser": u, | |
| 203 | + "Flash": flash, | |
| 204 | + "RegistrationEnabled": h.isRegistrationAllowed(c), | |
| 205 | + "ExploreEnabled": h.db.IsExploreEnabled(ctx), | |
| 206 | + "LandingEnabled": h.db.IsLandingEnabled(ctx, h.cfg.Landing.Enabled), | |
| 207 | + "AnonymousReadEnabled": anonRead, | |
| 208 | + "IssuesEnabled": h.db.IsIssuesEnabled(ctx), | |
| 209 | + "PaletteRepos": paletteRepos, | |
| 210 | + } | |
| 211 | + if extra := h.repoNavData(c); extra != nil { | |
| 212 | + for k, v := range extra { | |
| 213 | + data[k] = v | |
| 214 | + } | |
| 215 | + } | |
| 216 | + return data | |
| 217 | +} | |
| 218 | + | |
| 219 | +// repoNavData enriches template data for /:owner/:repo routes with fork | |
| 220 | +// navigation state used by the repository header. | |
| 221 | +func (h *Handler) repoNavData(c *fiber.Ctx) map[string]any { | |
| 222 | + owner, name := c.Params("owner"), strings.TrimSuffix(c.Params("repo"), ".git") | |
| 223 | + if owner == "" || name == "" { | |
| 224 | + return nil | |
| 225 | + } | |
| 226 | + ctx := c.UserContext() | |
| 227 | + nr, err := h.repoSvc.GetByOwnerAndName(ctx, owner, name) | |
| 228 | + if err != nil || nr == nil { | |
| 229 | + return nil | |
| 230 | + } | |
| 231 | + u := middleware.CurrentUser(c) | |
| 232 | + data := map[string]any{"NavRepo": nr} | |
| 233 | + data["IsRepoOwner"] = u != nil && u.ID == nr.OwnerID | |
| 234 | + if u != nil && u.ID != nr.OwnerID { | |
| 235 | + if fork, ferr := h.repoSvc.FindForkByOwner(ctx, nr.ID, u.ID); ferr == nil && fork != nil { | |
| 236 | + data["ViewerFork"] = fork | |
| 237 | + } | |
| 238 | + } | |
| 239 | + return data | |
| 240 | +} | |
| 241 | + | |
| 242 | +func (h *Handler) setFlash(c *fiber.Ctx, msg string) { | |
| 243 | + c.Cookie(&fiber.Cookie{ | |
| 244 | + Name: "polar_flash", | |
| 245 | + Value: msg, | |
| 246 | + Path: "/", | |
| 247 | + MaxAge: 30, | |
| 248 | + HTTPOnly: true, | |
| 249 | + SameSite: "Lax", | |
| 250 | + }) | |
| 251 | +} | |
| 252 | + | |
| 253 | +// langOf resolves the active UI language for the current request. | |
| 254 | +func (h *Handler) langOf(c *fiber.Ctx) i18n.Lang { | |
| 255 | + lang, _ := c.Locals("lang").(i18n.Lang) | |
| 256 | + if lang == "" { | |
| 257 | + lang = i18n.Normalize(h.cfg.Landing.DefaultLang) | |
| 258 | + } | |
| 259 | + return lang | |
| 260 | +} | |
| 261 | + | |
| 262 | +// tr translates key for the current request and formats it with args. | |
| 263 | +func (h *Handler) tr(c *fiber.Ctx, key string, args ...any) string { | |
| 264 | + return fmt.Sprintf(i18n.T(h.langOf(c), key), args...) | |
| 265 | +} | |
| 266 | + | |
| 267 | +// setFlashT stores a localized flash message. | |
| 268 | +func (h *Handler) setFlashT(c *fiber.Ctx, key string, args ...any) { | |
| 269 | + h.setFlash(c, h.tr(c, key, args...)) | |
| 270 | +} | |
| 271 | + | |
| 272 | +// renderErrorT renders an error page with localized title and message. | |
| 273 | +func (h *Handler) renderErrorT(c *fiber.Ctx, status int, titleKey, msgKey string, args ...any) error { | |
| 274 | + return h.renderError(c, status, i18n.T(h.langOf(c), titleKey), h.tr(c, msgKey, args...)) | |
| 275 | +} | |
| 276 | + | |
| 277 | +func (h *Handler) isRegistrationAllowed(c *fiber.Ctx) bool { | |
| 278 | + if cnt, err := h.userSvc.Count(c.UserContext()); err == nil && cnt == 0 { | |
| 279 | + return true | |
| 280 | + } | |
| 281 | + return h.db.IsRegistrationEnabled(c.UserContext(), h.cfg.Auth.RegistrationEnabled) | |
| 282 | +} | |
| 283 | + | |
| 284 | +func (h *Handler) render(c *fiber.Ctx, status int, page string, data map[string]any) error { | |
| 285 | + var buf bytes.Buffer | |
| 286 | + if err := h.views.Render(&buf, page, data); err != nil { | |
| 287 | + h.logger.Error("render template failed", "page", page, "err", err) | |
| 288 | + return c.Status(fiber.StatusInternalServerError).SendString("Template render error") | |
| 289 | + } | |
| 290 | + c.Status(status) | |
| 291 | + c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) | |
| 292 | + return c.Send(buf.Bytes()) | |
| 293 | +} | |
| 294 | + | |
| 295 | +func (h *Handler) renderError(c *fiber.Ctx, status int, title, message string) error { | |
| 296 | + data := h.baseData(c, title) | |
| 297 | + data["StatusCode"] = status | |
| 298 | + data["Title"] = title | |
| 299 | + data["Message"] = message | |
| 300 | + return h.render(c, status, "error", data) | |
| 301 | +} | |
| 302 | + | |
| 303 | +func (h *Handler) Home(c *fiber.Ctx) error { | |
| 304 | + if !h.db.IsLandingEnabled(c.UserContext(), h.cfg.Landing.Enabled) { | |
| 305 | + if middleware.CurrentUser(c) != nil { | |
| 306 | + return c.Redirect("/repos", fiber.StatusSeeOther) | |
| 307 | + } | |
| 308 | + return c.Redirect("/login", fiber.StatusSeeOther) | |
| 309 | + } | |
| 310 | + data := h.baseData(c, "") | |
| 311 | + if h.db.IsExploreEnabled(c.UserContext()) && (middleware.CurrentUser(c) != nil || h.db.IsAnonymousReadEnabled(c.UserContext())) { | |
| 312 | + repos, _ := h.repoSvc.ListPublic(c.UserContext(), 10) | |
| 313 | + data["Repos"] = repos | |
| 314 | + } | |
| 315 | + return h.render(c, fiber.StatusOK, "landing", data) | |
| 316 | +} | |
| 317 | + | |
| 318 | +func (h *Handler) Explore(c *fiber.Ctx) error { | |
| 319 | + if !h.db.IsExploreEnabled(c.UserContext()) { | |
| 320 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.explore_disabled") | |
| 321 | + } | |
| 322 | + if middleware.CurrentUser(c) == nil && !h.db.IsAnonymousReadEnabled(c.UserContext()) { | |
| 323 | + return c.Redirect("/login", fiber.StatusSeeOther) | |
| 324 | + } | |
| 325 | + lang, _ := c.Locals("lang").(i18n.Lang) | |
| 326 | + data := h.baseData(c, i18n.T(lang, "nav.explore")) | |
| 327 | + repos, _ := h.repoSvc.ListPublic(c.UserContext(), 100) | |
| 328 | + data["PageHeading"] = i18n.T(lang, "nav.explore") | |
| 329 | + data["Repos"] = repos | |
| 330 | + return h.render(c, fiber.StatusOK, "repos", data) | |
| 331 | +} | |
| 332 | + | |
| 333 | +func (h *Handler) MyRepos(c *fiber.Ctx) error { | |
| 334 | + u := middleware.CurrentUser(c) | |
| 335 | + lang, _ := c.Locals("lang").(i18n.Lang) | |
| 336 | + data := h.baseData(c, i18n.T(lang, "nav.repositories")) | |
| 337 | + repos, _ := h.repoSvc.ListByOwner(c.UserContext(), u.ID, true) | |
| 338 | + data["PageHeading"] = i18n.T(lang, "nav.repositories") | |
| 339 | + data["Repos"] = repos | |
| 340 | + return h.render(c, fiber.StatusOK, "repos", data) | |
| 341 | +} | |
| 342 | + | |
| 343 | +func (h *Handler) cloneURL(c *fiber.Ctx, ownerUsername, repoName string) string { | |
| 344 | + base := strings.TrimRight(h.cfg.Server.BaseURL, "/") | |
| 345 | + if base == "" || strings.Contains(base, "localhost:3000") { | |
| 346 | + if reqBase := c.BaseURL(); reqBase != "" { | |
| 347 | + base = strings.TrimRight(reqBase, "/") | |
| 348 | + } | |
| 349 | + } | |
| 350 | + return base + "/" + ownerUsername + "/" + repoName + ".git" | |
| 351 | +} |
internal/web/handlers/handlers_test.go
+328
-0
| ... | ... | @@ -0,0 +1,328 @@ |
| 1 | +package handlers | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "fmt" | |
| 6 | + "io" | |
| 7 | + "log/slog" | |
| 8 | + "net" | |
| 9 | + "net/http" | |
| 10 | + "net/http/httptest" | |
| 11 | + "os" | |
| 12 | + "os/exec" | |
| 13 | + "path/filepath" | |
| 14 | + "strings" | |
| 15 | + "testing" | |
| 16 | + "time" | |
| 17 | + | |
| 18 | + "polar/internal/auth" | |
| 19 | + "polar/internal/config" | |
| 20 | + "polar/internal/db" | |
| 21 | + "polar/internal/repo" | |
| 22 | + "polar/internal/user" | |
| 23 | + "polar/internal/web/views" | |
| 24 | + | |
| 25 | + "github.com/gofiber/fiber/v2" | |
| 26 | +) | |
| 27 | + | |
| 28 | +func setupTestApp(t *testing.T) (*fiber.App, *auth.Service, *user.Service, *repo.Service) { | |
| 29 | + t.Helper() | |
| 30 | + tmp := t.TempDir() | |
| 31 | + | |
| 32 | + cfg := config.Default() | |
| 33 | + cfg.Storage.DataDir = tmp | |
| 34 | + | |
| 35 | + ctx := context.Background() | |
| 36 | + database, err := db.Open(ctx, cfg.DBPath(), true) | |
| 37 | + if err != nil { | |
| 38 | + t.Fatalf("open test db: %v", err) | |
| 39 | + } | |
| 40 | + t.Cleanup(func() { _ = database.Close() }) | |
| 41 | + | |
| 42 | + authSvc := auth.NewService(database) | |
| 43 | + userSvc := user.NewService(database) | |
| 44 | + repoSvc := repo.NewService(database, cfg.ReposDir()) | |
| 45 | + | |
| 46 | + viewsEngine, err := views.NewEngine() | |
| 47 | + if err != nil { | |
| 48 | + t.Fatalf("new views engine: %v", err) | |
| 49 | + } | |
| 50 | + | |
| 51 | + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) | |
| 52 | + app := fiber.New(fiber.Config{ | |
| 53 | + DisableStartupMessage: true, | |
| 54 | + BodyLimit: 50 * 1024 * 1024, | |
| 55 | + }) | |
| 56 | + | |
| 57 | + h := New(cfg, database, authSvc, userSvc, repoSvc, viewsEngine, logger) | |
| 58 | + h.RegisterRoutes(app) | |
| 59 | + | |
| 60 | + return app, authSvc, userSvc, repoSvc | |
| 61 | +} | |
| 62 | + | |
| 63 | +func TestHealthzLandingAndStatic(t *testing.T) { | |
| 64 | + app, _, _, _ := setupTestApp(t) | |
| 65 | + | |
| 66 | + // 1. /healthz | |
| 67 | + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) | |
| 68 | + resp, err := app.Test(req, 5000) | |
| 69 | + if err != nil { | |
| 70 | + t.Fatalf("healthz request failed: %v", err) | |
| 71 | + } | |
| 72 | + if resp.StatusCode != http.StatusOK { | |
| 73 | + t.Fatalf("expected 200 on /healthz, got %d", resp.StatusCode) | |
| 74 | + } | |
| 75 | + if resp.Header.Get("X-Content-Type-Options") != "nosniff" { | |
| 76 | + t.Fatalf("missing security header X-Content-Type-Options") | |
| 77 | + } | |
| 78 | + | |
| 79 | + // 2. Landing page (RU & EN) + verify zero external asset requests | |
| 80 | + reqRU := httptest.NewRequest(http.MethodGet, "/?lang=ru", nil) | |
| 81 | + respRU, err := app.Test(reqRU, 5000) | |
| 82 | + if err != nil || respRU.StatusCode != http.StatusOK { | |
| 83 | + t.Fatalf("landing RU failed: %v", err) | |
| 84 | + } | |
| 85 | + bodyRU, _ := io.ReadAll(respRU.Body) | |
| 86 | + htmlRU := string(bodyRU) | |
| 87 | + if !strings.Contains(htmlRU, "Polar") || !strings.Contains(htmlRU, "hi-git-branch") { | |
| 88 | + t.Fatalf("landing RU missing expected content or Hugeicons sprite") | |
| 89 | + } | |
| 90 | + if strings.Contains(htmlRU, "https://fonts.googleapis.com") || strings.Contains(htmlRU, "https://unpkg.com") || strings.Contains(htmlRU, "https://cdn.") { | |
| 91 | + t.Fatalf("landing page must not make external CDN requests") | |
| 92 | + } | |
| 93 | + | |
| 94 | + reqEN := httptest.NewRequest(http.MethodGet, "/?lang=en", nil) | |
| 95 | + respEN, err := app.Test(reqEN, 5000) | |
| 96 | + if err != nil || respEN.StatusCode != http.StatusOK { | |
| 97 | + t.Fatalf("landing EN failed: %v", err) | |
| 98 | + } | |
| 99 | + bodyEN, _ := io.ReadAll(respEN.Body) | |
| 100 | + if !strings.Contains(string(bodyEN), "Lightweight self-hosted Git hosting") { | |
| 101 | + t.Fatalf("landing EN missing translated hero title") | |
| 102 | + } | |
| 103 | + | |
| 104 | + // 3. Static embedded CSS & JS | |
| 105 | + for _, asset := range []string{"/static/css/app.css", "/static/js/app.js", "/static/js/htmx.min.js"} { | |
| 106 | + r := httptest.NewRequest(http.MethodGet, asset, nil) | |
| 107 | + res, err := app.Test(r, 5000) | |
| 108 | + if err != nil || res.StatusCode != http.StatusOK { | |
| 109 | + t.Fatalf("embedded static asset %s failed: status=%d err=%v", asset, res.StatusCode, err) | |
| 110 | + } | |
| 111 | + } | |
| 112 | +} | |
| 113 | + | |
| 114 | +func TestEndToEndGitSmartHTTPAndPrivateRepos(t *testing.T) { | |
| 115 | + app, authSvc, userSvc, repoSvc := setupTestApp(t) | |
| 116 | + ctx := context.Background() | |
| 117 | + | |
| 118 | + // Start real TCP listener for system git CLI | |
| 119 | + ln, err := net.Listen("tcp", "127.0.0.1:0") | |
| 120 | + if err != nil { | |
| 121 | + t.Fatalf("listen tcp: %v", err) | |
| 122 | + } | |
| 123 | + defer ln.Close() | |
| 124 | + | |
| 125 | + go func() { | |
| 126 | + _ = app.Listener(ln) | |
| 127 | + }() | |
| 128 | + | |
| 129 | + baseURL := "http://" + ln.Addr().String() | |
| 130 | + | |
| 131 | + // Create owner user + personal access token | |
| 132 | + owner, err := userSvc.Register(ctx, "alice", "alice@example.com", "strongpassword", true) | |
| 133 | + if err != nil { | |
| 134 | + t.Fatalf("register owner: %v", err) | |
| 135 | + } | |
| 136 | + rawToken, _, err := authSvc.CreateToken(ctx, owner.ID, "E2E Token") | |
| 137 | + if err != nil { | |
| 138 | + t.Fatalf("create token: %v", err) | |
| 139 | + } | |
| 140 | + | |
| 141 | + // Create a public repo and a private repo | |
| 142 | + pubRepo, err := repoSvc.Create(ctx, owner, "engine", "Analytical Engine", false) | |
| 143 | + if err != nil { | |
| 144 | + t.Fatalf("create public repo: %v", err) | |
| 145 | + } | |
| 146 | + privRepo, err := repoSvc.Create(ctx, owner, "vault", "Secret Notes", true) | |
| 147 | + if err != nil { | |
| 148 | + t.Fatalf("create private repo: %v", err) | |
| 149 | + } | |
| 150 | + | |
| 151 | + // 1. Verify private repo is blocked on Web UI and Git HTTP without credentials | |
| 152 | + webPrivResp, err := http.Get(baseURL + "/alice/vault") | |
| 153 | + if err != nil { | |
| 154 | + t.Fatalf("GET /alice/vault: %v", err) | |
| 155 | + } | |
| 156 | + _ = webPrivResp.Body.Close() | |
| 157 | + if webPrivResp.StatusCode != http.StatusNotFound { | |
| 158 | + t.Fatalf("expected 404 for unauthenticated private repo web request, got %d", webPrivResp.StatusCode) | |
| 159 | + } | |
| 160 | + | |
| 161 | + gitPrivResp, err := http.Get(baseURL + "/alice/vault.git/info/refs?service=git-upload-pack") | |
| 162 | + if err != nil { | |
| 163 | + t.Fatalf("GET private info/refs: %v", err) | |
| 164 | + } | |
| 165 | + _ = gitPrivResp.Body.Close() | |
| 166 | + if gitPrivResp.StatusCode != http.StatusUnauthorized { | |
| 167 | + t.Fatalf("expected 401 for unauthenticated private git info/refs, got %d", gitPrivResp.StatusCode) | |
| 168 | + } | |
| 169 | + | |
| 170 | + // 2. Push commits to public repo over HTTP using Personal Access Token | |
| 171 | + workDir := filepath.Join(t.TempDir(), "work") | |
| 172 | + if err := os.MkdirAll(workDir, 0o755); err != nil { | |
| 173 | + t.Fatalf("mkdir work: %v", err) | |
| 174 | + } | |
| 175 | + | |
| 176 | + runGitCmd := func(dir string, args ...string) { | |
| 177 | + t.Helper() | |
| 178 | + cctx, cancel := context.WithTimeout(ctx, 20*time.Second) | |
| 179 | + defer cancel() | |
| 180 | + cmd := exec.CommandContext(cctx, "git", args...) | |
| 181 | + cmd.Dir = dir | |
| 182 | + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") | |
| 183 | + out, err := cmd.CombinedOutput() | |
| 184 | + if err != nil { | |
| 185 | + t.Fatalf("git %v failed: %v\nOutput: %s", args, err, string(out)) | |
| 186 | + } | |
| 187 | + } | |
| 188 | + | |
| 189 | + runGitCmd(workDir, "init", "-b", "main") | |
| 190 | + runGitCmd(workDir, "config", "user.name", "Alice Lovelace") | |
| 191 | + runGitCmd(workDir, "config", "user.email", "alice@example.com") | |
| 192 | + | |
| 193 | + if err := os.WriteFile(filepath.Join(workDir, "README.md"), []byte("# Analytical Engine\n\nHello from **Alice**!\n"), 0o644); err != nil { | |
| 194 | + t.Fatalf("write README.md: %v", err) | |
| 195 | + } | |
| 196 | + if err := os.WriteFile(filepath.Join(workDir, "main.go"), []byte("package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"Alice\")\n}\n"), 0o644); err != nil { | |
| 197 | + t.Fatalf("write main.go: %v", err) | |
| 198 | + } | |
| 199 | + | |
| 200 | + runGitCmd(workDir, "add", "README.md", "main.go") | |
| 201 | + runGitCmd(workDir, "commit", "-m", "Initial commit with README and main.go") | |
| 202 | + | |
| 203 | + authedPubURL := fmt.Sprintf("http://%s:%s@%s/alice/%s.git", owner.Username, rawToken, ln.Addr().String(), pubRepo.Name) | |
| 204 | + runGitCmd(workDir, "push", "-u", authedPubURL, "main") | |
| 205 | + | |
| 206 | + // Also push to private repo with token | |
| 207 | + authedPrivURL := fmt.Sprintf("http://%s:%s@%s/alice/%s.git", owner.Username, rawToken, ln.Addr().String(), privRepo.Name) | |
| 208 | + runGitCmd(workDir, "push", authedPrivURL, "main") | |
| 209 | + | |
| 210 | + // 3. Clone public repo anonymously and private repo with token | |
| 211 | + clonePubDir := filepath.Join(t.TempDir(), "clone-pub") | |
| 212 | + runGitCmd(t.TempDir(), "clone", baseURL+"/alice/engine.git", clonePubDir) | |
| 213 | + if _, err := os.Stat(filepath.Join(clonePubDir, "main.go")); err != nil { | |
| 214 | + t.Fatalf("cloned public repo missing main.go: %v", err) | |
| 215 | + } | |
| 216 | + | |
| 217 | + clonePrivDir := filepath.Join(t.TempDir(), "clone-priv") | |
| 218 | + runGitCmd(t.TempDir(), "clone", authedPrivURL, clonePrivDir) | |
| 219 | + if _, err := os.Stat(filepath.Join(clonePrivDir, "README.md")); err != nil { | |
| 220 | + t.Fatalf("cloned private repo missing README.md: %v", err) | |
| 221 | + } | |
| 222 | + | |
| 223 | + // 4. Verify Web UI renders repository tree, README Markdown, syntax-highlighted blob, commits, and diff | |
| 224 | + checkEndpoint := func(urlPath, expectedSubstring string) { | |
| 225 | + t.Helper() | |
| 226 | + resp, err := http.Get(baseURL + urlPath) | |
| 227 | + if err != nil { | |
| 228 | + t.Fatalf("GET %s failed: %v", urlPath, err) | |
| 229 | + } | |
| 230 | + defer resp.Body.Close() | |
| 231 | + if resp.StatusCode != http.StatusOK { | |
| 232 | + t.Fatalf("GET %s returned status %d", urlPath, resp.StatusCode) | |
| 233 | + } | |
| 234 | + b, _ := io.ReadAll(resp.Body) | |
| 235 | + if !strings.Contains(string(b), expectedSubstring) { | |
| 236 | + t.Fatalf("GET %s missing expected %q", urlPath, expectedSubstring) | |
| 237 | + } | |
| 238 | + } | |
| 239 | + | |
| 240 | + checkEndpoint("/alice/engine", "Analytical Engine") | |
| 241 | + checkEndpoint("/alice/engine/blob/main/main.go", "Println") | |
| 242 | + checkEndpoint("/alice/engine/raw/main/main.go", "package main") | |
| 243 | + checkEndpoint("/alice/engine/commits/main", "Initial commit with README and main.go") | |
| 244 | + checkEndpoint("/alice/engine/branches", "main") | |
| 245 | + checkEndpoint("/alice", "alice") | |
| 246 | + | |
| 247 | + // 5. Verify ZIP archive download | |
| 248 | + zipResp, err := http.Get(baseURL + "/alice/engine/archive/main.zip") | |
| 249 | + if err != nil { | |
| 250 | + t.Fatalf("GET archive zip failed: %v", err) | |
| 251 | + } | |
| 252 | + defer zipResp.Body.Close() | |
| 253 | + if zipResp.StatusCode != http.StatusOK { | |
| 254 | + t.Fatalf("GET archive zip status %d", zipResp.StatusCode) | |
| 255 | + } | |
| 256 | + zipBytes, _ := io.ReadAll(zipResp.Body) | |
| 257 | + if len(zipBytes) < 4 || zipBytes[0] != 'P' || zipBytes[1] != 'K' { | |
| 258 | + t.Fatalf("expected valid ZIP magic bytes, got len=%d", len(zipBytes)) | |
| 259 | + } | |
| 260 | +} | |
| 261 | + | |
| 262 | +func TestFeaturesTogglesAndProfile(t *testing.T) { | |
| 263 | + app, _, userSvc, repoSvc := setupTestApp(t) | |
| 264 | + ctx := context.Background() | |
| 265 | + | |
| 266 | + // 1. Create a user | |
| 267 | + u, err := userSvc.Register(ctx, "rin", "rin@example.com", "pass12345", true) | |
| 268 | + if err != nil { | |
| 269 | + t.Fatalf("register: %v", err) | |
| 270 | + } | |
| 271 | + | |
| 272 | + // 2. Update user profile with avatar and details | |
| 273 | + avatarData := "data:image/webp;base64,UklGRkAAAABXRUJQVlA4IDQAAADwAQCdASoBAAEAAQAcJaACdLoAAP7/2wAA" | |
| 274 | + if err := userSvc.UpdateProfileDetails(ctx, u.ID, "rin@example.com", "Rin Tester", "Developer", "Tokyo", "https://example.com"); err != nil { | |
| 275 | + t.Fatalf("update profile details: %v", err) | |
| 276 | + } | |
| 277 | + if err := userSvc.UpdateAvatar(ctx, u.ID, avatarData); err != nil { | |
| 278 | + t.Fatalf("update avatar: %v", err) | |
| 279 | + } | |
| 280 | + | |
| 281 | + // Check profile page | |
| 282 | + reqProfile := httptest.NewRequest(http.MethodGet, "/rin", nil) | |
| 283 | + respProfile, err := app.Test(reqProfile, 5000) | |
| 284 | + if err != nil || respProfile.StatusCode != http.StatusOK { | |
| 285 | + t.Fatalf("profile page failed: status=%d err=%v", respProfile.StatusCode, err) | |
| 286 | + } | |
| 287 | + profBody, _ := io.ReadAll(respProfile.Body) | |
| 288 | + profHTML := string(profBody) | |
| 289 | + if !strings.Contains(profHTML, "Rin Tester") || !strings.Contains(profHTML, "data:image/webp") { | |
| 290 | + t.Fatalf("profile page missing updated name or avatar") | |
| 291 | + } | |
| 292 | + | |
| 293 | + // 3. Create a repo and an issue | |
| 294 | + r, err := repoSvc.Create(ctx, u, "test-repo", "A test repo", false) | |
| 295 | + if err != nil { | |
| 296 | + t.Fatalf("create repo: %v", err) | |
| 297 | + } | |
| 298 | + issue, err := repoSvc.CreateIssue(ctx, r.ID, u.ID, "First Bug", "Something went wrong") | |
| 299 | + if err != nil { | |
| 300 | + t.Fatalf("create issue: %v", err) | |
| 301 | + } | |
| 302 | + if issue.Number != 1 { | |
| 303 | + t.Fatalf("expected issue number 1, got %d", issue.Number) | |
| 304 | + } | |
| 305 | + if err := repoSvc.CreateIssueComment(ctx, issue.ID, u.ID, "Fixed in commit 123"); err != nil { | |
| 306 | + t.Fatalf("add issue comment: %v", err) | |
| 307 | + } | |
| 308 | + | |
| 309 | + reqIssues := httptest.NewRequest(http.MethodGet, "/rin/test-repo/issues", nil) | |
| 310 | + respIssues, err := app.Test(reqIssues, 5000) | |
| 311 | + if err != nil || respIssues.StatusCode != http.StatusOK { | |
| 312 | + t.Fatalf("issues list failed: status=%d err=%v", respIssues.StatusCode, err) | |
| 313 | + } | |
| 314 | + issBody, _ := io.ReadAll(respIssues.Body) | |
| 315 | + if !strings.Contains(string(issBody), "First Bug") { | |
| 316 | + t.Fatalf("issues list missing issue title") | |
| 317 | + } | |
| 318 | + | |
| 319 | + reqIssueDetail := httptest.NewRequest(http.MethodGet, "/rin/test-repo/issues/1", nil) | |
| 320 | + respIssueDetail, err := app.Test(reqIssueDetail, 5000) | |
| 321 | + if err != nil || respIssueDetail.StatusCode != http.StatusOK { | |
| 322 | + t.Fatalf("issue detail failed: status=%d err=%v", respIssueDetail.StatusCode, err) | |
| 323 | + } | |
| 324 | + issDetailBody, _ := io.ReadAll(respIssueDetail.Body) | |
| 325 | + if !strings.Contains(string(issDetailBody), "Fixed in commit 123") { | |
| 326 | + t.Fatalf("issue detail missing comment") | |
| 327 | + } | |
| 328 | +} |
internal/web/handlers/pulls.go
+393
-0
| ... | ... | @@ -0,0 +1,393 @@ |
| 1 | +package handlers | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "fmt" | |
| 6 | + "strconv" | |
| 7 | + "strings" | |
| 8 | + | |
| 9 | + "github.com/gofiber/fiber/v2" | |
| 10 | + | |
| 11 | + "polar/internal/git" | |
| 12 | + "polar/internal/repo" | |
| 13 | + "polar/internal/user" | |
| 14 | + "polar/internal/web/middleware" | |
| 15 | + "polar/internal/web/views" | |
| 16 | +) | |
| 17 | + | |
| 18 | +// headRepoCandidates lists repositories a viewer may open a pull request from: | |
| 19 | +// the base repository itself plus the viewer's forks of it. | |
| 20 | +func (h *Handler) headRepoCandidates(c *fiber.Ctx, r *repo.Repository, viewer *user.User) []*repo.Repository { | |
| 21 | + candidates := []*repo.Repository{r} | |
| 22 | + if viewer == nil { | |
| 23 | + return candidates | |
| 24 | + } | |
| 25 | + forks, _ := h.repoSvc.ListForks(c.UserContext(), r.ID, 100) | |
| 26 | + for _, f := range forks { | |
| 27 | + if f.OwnerID == viewer.ID { | |
| 28 | + fork := f | |
| 29 | + candidates = append(candidates, &fork) | |
| 30 | + } | |
| 31 | + } | |
| 32 | + return candidates | |
| 33 | +} | |
| 34 | + | |
| 35 | +func (h *Handler) findHeadRepo(candidates []*repo.Repository, id int64) *repo.Repository { | |
| 36 | + for _, cand := range candidates { | |
| 37 | + if cand.ID == id { | |
| 38 | + return cand | |
| 39 | + } | |
| 40 | + } | |
| 41 | + return nil | |
| 42 | +} | |
| 43 | + | |
| 44 | +// resolveHeadRef makes headBranch of headRepo visible inside baseDisk. | |
| 45 | +// For same-repository PRs the branch name is returned unchanged. For | |
| 46 | +// cross-repository (fork) PRs the branch is fetched into a temporary ref and | |
| 47 | +// the returned cleanup removes it again. | |
| 48 | +func (h *Handler) resolveHeadRef(c *fiber.Ctx, r *repo.Repository, baseDisk string, headRepoID int64, headBranch string) (ref string, headRepo *repo.Repository, cleanup func(), err error) { | |
| 49 | + noop := func() {} | |
| 50 | + if headRepoID == 0 || headRepoID == r.ID { | |
| 51 | + return headBranch, r, noop, nil | |
| 52 | + } | |
| 53 | + hr, err := h.repoSvc.GetByID(c.UserContext(), headRepoID) | |
| 54 | + if err != nil || hr == nil { | |
| 55 | + return "", nil, noop, fmt.Errorf("head repository not found") | |
| 56 | + } | |
| 57 | + if hr.ForkedFromRepoID == nil || *hr.ForkedFromRepoID != r.ID { | |
| 58 | + return "", nil, noop, fmt.Errorf("head repository is not a fork of this repository") | |
| 59 | + } | |
| 60 | + forkDisk, err := h.repoSvc.DiskPath(hr.OwnerUsername, hr.Name) | |
| 61 | + if err != nil { | |
| 62 | + return "", nil, noop, err | |
| 63 | + } | |
| 64 | + tmpRef := fmt.Sprintf("refs/polar/tmp-pr-%d", hr.ID) | |
| 65 | + if err := git.FetchToTemp(c.UserContext(), baseDisk, forkDisk, headBranch, tmpRef); err != nil { | |
| 66 | + return "", nil, noop, err | |
| 67 | + } | |
| 68 | + cleanup = func() { git.DeleteRef(context.WithoutCancel(c.UserContext()), baseDisk, tmpRef) } | |
| 69 | + return tmpRef, hr, cleanup, nil | |
| 70 | +} | |
| 71 | + | |
| 72 | +func (h *Handler) RepoPullsPage(c *fiber.Ctx) error { | |
| 73 | + r, _, err := h.loadAuthorizedRepo(c, false) | |
| 74 | + if err != nil { | |
| 75 | + return nil | |
| 76 | + } | |
| 77 | + if !r.EnablePulls { | |
| 78 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.pulls_disabled") | |
| 79 | + } | |
| 80 | + | |
| 81 | + state := c.Query("state", "open") | |
| 82 | + if state != "open" && state != "closed" && state != "merged" { | |
| 83 | + state = "open" | |
| 84 | + } | |
| 85 | + | |
| 86 | + pulls, _ := h.repoSvc.ListPullRequests(c.UserContext(), r.ID, state) | |
| 87 | + openCount, closedCount, mergedCount, _ := h.repoSvc.CountPullRequests(c.UserContext(), r.ID) | |
| 88 | + | |
| 89 | + viewer := middleware.CurrentUser(c) | |
| 90 | + data := h.baseData(c, h.tr(c, "pulls.title")+" · "+r.OwnerUsername+"/"+r.Name) | |
| 91 | + data["Repo"] = r | |
| 92 | + data["Ref"] = r.DefaultBranch | |
| 93 | + data["ActiveTab"] = "pulls" | |
| 94 | + data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer) | |
| 95 | + data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name) | |
| 96 | + data["Pulls"] = pulls | |
| 97 | + data["CurrentState"] = state | |
| 98 | + data["OpenCount"] = openCount | |
| 99 | + data["ClosedCount"] = closedCount | |
| 100 | + data["MergedCount"] = mergedCount | |
| 101 | + | |
| 102 | + return h.render(c, fiber.StatusOK, "pulls", data) | |
| 103 | +} | |
| 104 | + | |
| 105 | +func (h *Handler) RepoNewPullPage(c *fiber.Ctx) error { | |
| 106 | + r, diskPath, err := h.loadAuthorizedRepo(c, false) | |
| 107 | + if err != nil { | |
| 108 | + return nil | |
| 109 | + } | |
| 110 | + if !r.EnablePulls { | |
| 111 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.pulls_disabled") | |
| 112 | + } | |
| 113 | + | |
| 114 | + viewer := middleware.CurrentUser(c) | |
| 115 | + candidates := h.headRepoCandidates(c, r, viewer) | |
| 116 | + | |
| 117 | + headRepo := r | |
| 118 | + if v := strings.TrimSpace(c.Query("head_repo")); v != "" { | |
| 119 | + if id, convErr := strconv.ParseInt(v, 10, 64); convErr == nil { | |
| 120 | + if found := h.findHeadRepo(candidates, id); found != nil { | |
| 121 | + headRepo = found | |
| 122 | + } | |
| 123 | + } | |
| 124 | + } | |
| 125 | + | |
| 126 | + headDisk, err := h.repoSvc.DiskPath(headRepo.OwnerUsername, headRepo.Name) | |
| 127 | + if err != nil { | |
| 128 | + headDisk = diskPath | |
| 129 | + } | |
| 130 | + | |
| 131 | + branches, _ := git.ListBranches(c.UserContext(), headDisk) | |
| 132 | + baseBranches, _ := git.ListBranches(c.UserContext(), diskPath) | |
| 133 | + baseBranch := strings.TrimSpace(c.Query("base")) | |
| 134 | + if baseBranch == "" { | |
| 135 | + baseBranch = r.DefaultBranch | |
| 136 | + } | |
| 137 | + headBranch := strings.TrimSpace(c.Query("head")) | |
| 138 | + if headBranch == "" && len(branches) > 1 { | |
| 139 | + for _, b := range branches { | |
| 140 | + if b != baseBranch || headRepo.ID != r.ID { | |
| 141 | + headBranch = b | |
| 142 | + break | |
| 143 | + } | |
| 144 | + } | |
| 145 | + } | |
| 146 | + | |
| 147 | + data := h.baseData(c, h.tr(c, "pulls.new")+" · "+r.OwnerUsername+"/"+r.Name) | |
| 148 | + data["Repo"] = r | |
| 149 | + data["Ref"] = r.DefaultBranch | |
| 150 | + data["ActiveTab"] = "pulls" | |
| 151 | + data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer) | |
| 152 | + data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name) | |
| 153 | + data["Branches"] = branches | |
| 154 | + data["BaseBranches"] = baseBranches | |
| 155 | + data["BaseBranch"] = baseBranch | |
| 156 | + data["HeadBranch"] = headBranch | |
| 157 | + data["HeadRepo"] = headRepo | |
| 158 | + data["HeadRepos"] = candidates | |
| 159 | + | |
| 160 | + if baseBranch != "" && headBranch != "" && (baseBranch != headBranch || headRepo.ID != r.ID) { | |
| 161 | + headRef, _, cleanup, refErr := h.resolveHeadRef(c, r, diskPath, headRepo.ID, headBranch) | |
| 162 | + if refErr == nil { | |
| 163 | + defer cleanup() | |
| 164 | + diffDetail, cmpCommits, cmpErr := git.CompareBranches(c.UserContext(), diskPath, baseBranch, headRef) | |
| 165 | + if cmpErr == nil { | |
| 166 | + data["Compare"] = diffDetail | |
| 167 | + data["CompareCommits"] = cmpCommits | |
| 168 | + } | |
| 169 | + canMerge, _, _ := git.CanMergeBranches(c.UserContext(), diskPath, baseBranch, headRef) | |
| 170 | + data["CanMerge"] = canMerge | |
| 171 | + } else { | |
| 172 | + data["CompareError"] = refErr.Error() | |
| 173 | + } | |
| 174 | + } | |
| 175 | + | |
| 176 | + return h.render(c, fiber.StatusOK, "pull_new", data) | |
| 177 | +} | |
| 178 | + | |
| 179 | +func (h *Handler) RepoNewPullSubmit(c *fiber.Ctx) error { | |
| 180 | + r, _, err := h.loadAuthorizedRepo(c, false) | |
| 181 | + if err != nil { | |
| 182 | + return nil | |
| 183 | + } | |
| 184 | + if !r.EnablePulls { | |
| 185 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.pulls_disabled") | |
| 186 | + } | |
| 187 | + | |
| 188 | + u := middleware.CurrentUser(c) | |
| 189 | + title := strings.TrimSpace(c.FormValue("title")) | |
| 190 | + body := strings.TrimSpace(c.FormValue("body")) | |
| 191 | + baseBranch := strings.TrimSpace(c.FormValue("base_branch")) | |
| 192 | + headBranch := strings.TrimSpace(c.FormValue("head_branch")) | |
| 193 | + headRepoRaw := strings.TrimSpace(c.FormValue("head_repo")) | |
| 194 | + | |
| 195 | + var headRepoID *int64 | |
| 196 | + if headRepoRaw != "" { | |
| 197 | + if id, convErr := strconv.ParseInt(headRepoRaw, 10, 64); convErr == nil && id != r.ID { | |
| 198 | + candidates := h.headRepoCandidates(c, r, u) | |
| 199 | + if h.findHeadRepo(candidates, id) == nil { | |
| 200 | + h.setFlashT(c, "flash.error", "err.pr_head_repo_invalid") | |
| 201 | + return c.Redirect(fmt.Sprintf("/%s/%s/pulls/new", r.OwnerUsername, r.Name), fiber.StatusSeeOther) | |
| 202 | + } | |
| 203 | + headRepoID = &id | |
| 204 | + } | |
| 205 | + } | |
| 206 | + | |
| 207 | + pr, err := h.repoSvc.CreatePullRequest(c.UserContext(), r.ID, u.ID, title, body, baseBranch, headBranch, headRepoID) | |
| 208 | + if err != nil { | |
| 209 | + h.setFlashT(c, "flash.error", err.Error()) | |
| 210 | + redirect := fmt.Sprintf("/%s/%s/pulls/new?base=%s&head=%s", r.OwnerUsername, r.Name, baseBranch, headBranch) | |
| 211 | + if headRepoID != nil { | |
| 212 | + redirect += fmt.Sprintf("&head_repo=%d", *headRepoID) | |
| 213 | + } | |
| 214 | + return c.Redirect(redirect, fiber.StatusSeeOther) | |
| 215 | + } | |
| 216 | + | |
| 217 | + h.setFlashT(c, "flash.pr_created", pr.Number) | |
| 218 | + return c.Redirect(fmt.Sprintf("/%s/%s/pulls/%d", r.OwnerUsername, r.Name, pr.Number), fiber.StatusSeeOther) | |
| 219 | +} | |
| 220 | + | |
| 221 | +func (h *Handler) RepoPullDetailPage(c *fiber.Ctx) error { | |
| 222 | + r, diskPath, err := h.loadAuthorizedRepo(c, false) | |
| 223 | + if err != nil { | |
| 224 | + return nil | |
| 225 | + } | |
| 226 | + if !r.EnablePulls { | |
| 227 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.pulls_disabled") | |
| 228 | + } | |
| 229 | + | |
| 230 | + num, _ := strconv.Atoi(c.Params("number")) | |
| 231 | + pr, err := h.repoSvc.GetPullRequest(c.UserContext(), r.ID, num) | |
| 232 | + if err != nil { | |
| 233 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.pr_not_found", "err.pr_not_found_desc") | |
| 234 | + } | |
| 235 | + | |
| 236 | + comments, _ := h.repoSvc.ListPullRequestComments(c.UserContext(), pr.ID) | |
| 237 | + reviews, _ := h.repoSvc.ListReviews(c.UserContext(), pr.ID) | |
| 238 | + approved, changesRequested := h.repoSvc.ReviewCounts(c.UserContext(), pr.ID) | |
| 239 | + | |
| 240 | + var headRef string | |
| 241 | + var cleanup func() | |
| 242 | + if pr.HeadRepoID != nil && *pr.HeadRepoID != r.ID { | |
| 243 | + ref, _, cl, refErr := h.resolveHeadRef(c, r, diskPath, *pr.HeadRepoID, pr.HeadBranch) | |
| 244 | + if refErr == nil { | |
| 245 | + headRef = ref | |
| 246 | + cleanup = cl | |
| 247 | + defer cleanup() | |
| 248 | + } | |
| 249 | + } else { | |
| 250 | + headRef = pr.HeadBranch | |
| 251 | + } | |
| 252 | + | |
| 253 | + var diffDetail *git.CommitDetail | |
| 254 | + var cmpCommits []git.Commit | |
| 255 | + canMerge := false | |
| 256 | + if headRef != "" { | |
| 257 | + diffDetail, cmpCommits, _ = git.CompareBranches(c.UserContext(), diskPath, pr.BaseBranch, headRef) | |
| 258 | + canMerge, _, _ = git.CanMergeBranches(c.UserContext(), diskPath, pr.BaseBranch, headRef) | |
| 259 | + } | |
| 260 | + | |
| 261 | + viewer := middleware.CurrentUser(c) | |
| 262 | + canWrite := h.repoSvc.CanWrite(c.UserContext(), r, viewer) | |
| 263 | + canToggle := viewer != nil && (viewer.ID == pr.AuthorID || canWrite) | |
| 264 | + canMergeAction := viewer != nil && canWrite && pr.Status == "open" && canMerge | |
| 265 | + canReview := viewer != nil && viewer.ID != pr.AuthorID && pr.Status == "open" | |
| 266 | + | |
| 267 | + subTab := c.Query("tab", "conversation") | |
| 268 | + if subTab != "conversation" && subTab != "commits" && subTab != "files" { | |
| 269 | + subTab = "conversation" | |
| 270 | + } | |
| 271 | + | |
| 272 | + data := h.baseData(c, fmt.Sprintf("#%d: %s · %s/%s", pr.Number, pr.Title, r.OwnerUsername, r.Name)) | |
| 273 | + data["Repo"] = r | |
| 274 | + data["Ref"] = r.DefaultBranch | |
| 275 | + data["ActiveTab"] = "pulls" | |
| 276 | + data["CanWrite"] = canWrite | |
| 277 | + data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name) | |
| 278 | + data["PR"] = pr | |
| 279 | + data["Comments"] = comments | |
| 280 | + data["Reviews"] = reviews | |
| 281 | + data["ApprovedCount"] = approved | |
| 282 | + data["ChangesRequestedCount"] = changesRequested | |
| 283 | + data["CanReview"] = canReview | |
| 284 | + data["Compare"] = diffDetail | |
| 285 | + data["CompareCommits"] = cmpCommits | |
| 286 | + data["CanMerge"] = canMerge | |
| 287 | + data["CanToggle"] = canToggle | |
| 288 | + data["CanMergeAction"] = canMergeAction | |
| 289 | + data["SubTab"] = subTab | |
| 290 | + data["BodyHTML"] = views.RenderMarkdown([]byte(pr.Body)) | |
| 291 | + | |
| 292 | + return h.render(c, fiber.StatusOK, "pull_detail", data) | |
| 293 | +} | |
| 294 | + | |
| 295 | +func (h *Handler) RepoPullCommentSubmit(c *fiber.Ctx) error { | |
| 296 | + r, _, err := h.loadAuthorizedRepo(c, false) | |
| 297 | + if err != nil { | |
| 298 | + return nil | |
| 299 | + } | |
| 300 | + if !r.EnablePulls { | |
| 301 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.pulls_disabled") | |
| 302 | + } | |
| 303 | + | |
| 304 | + num, _ := strconv.Atoi(c.Params("number")) | |
| 305 | + pr, err := h.repoSvc.GetPullRequest(c.UserContext(), r.ID, num) | |
| 306 | + if err != nil { | |
| 307 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.pr_not_found", "err.pr_not_found_desc") | |
| 308 | + } | |
| 309 | + | |
| 310 | + u := middleware.CurrentUser(c) | |
| 311 | + body := strings.TrimSpace(c.FormValue("body")) | |
| 312 | + if err := h.repoSvc.CreatePullRequestComment(c.UserContext(), pr.ID, u.ID, body); err != nil { | |
| 313 | + h.setFlashT(c, "flash.comment_error", err.Error()) | |
| 314 | + } | |
| 315 | + | |
| 316 | + return c.Redirect(fmt.Sprintf("/%s/%s/pulls/%d", r.OwnerUsername, r.Name, pr.Number), fiber.StatusSeeOther) | |
| 317 | +} | |
| 318 | + | |
| 319 | +func (h *Handler) RepoPullStateSubmit(c *fiber.Ctx) error { | |
| 320 | + r, _, err := h.loadAuthorizedRepo(c, false) | |
| 321 | + if err != nil { | |
| 322 | + return nil | |
| 323 | + } | |
| 324 | + if !r.EnablePulls { | |
| 325 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.pulls_disabled") | |
| 326 | + } | |
| 327 | + | |
| 328 | + num, _ := strconv.Atoi(c.Params("number")) | |
| 329 | + pr, err := h.repoSvc.GetPullRequest(c.UserContext(), r.ID, num) | |
| 330 | + if err != nil { | |
| 331 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.pr_not_found", "err.pr_not_found_desc") | |
| 332 | + } | |
| 333 | + | |
| 334 | + viewer := middleware.CurrentUser(c) | |
| 335 | + if viewer == nil || (viewer.ID != pr.AuthorID && !h.repoSvc.CanWrite(c.UserContext(), r, viewer)) { | |
| 336 | + return h.renderErrorT(c, fiber.StatusForbidden, "err.forbidden", "err.pr_state_forbidden") | |
| 337 | + } | |
| 338 | + | |
| 339 | + if pr.Status == "merged" { | |
| 340 | + h.setFlashT(c, "flash.pr_merged_state") | |
| 341 | + return c.Redirect(fmt.Sprintf("/%s/%s/pulls/%d", r.OwnerUsername, r.Name, pr.Number), fiber.StatusSeeOther) | |
| 342 | + } | |
| 343 | + | |
| 344 | + targetState := strings.TrimSpace(c.FormValue("state")) | |
| 345 | + if targetState == "closed" { | |
| 346 | + _ = h.repoSvc.SetPullRequestStatus(c.UserContext(), pr.ID, "closed", nil) | |
| 347 | + h.setFlashT(c, "flash.pr_closed", pr.Number) | |
| 348 | + } else if targetState == "open" { | |
| 349 | + _ = h.repoSvc.SetPullRequestStatus(c.UserContext(), pr.ID, "open", nil) | |
| 350 | + h.setFlashT(c, "flash.pr_reopened", pr.Number) | |
| 351 | + } | |
| 352 | + | |
| 353 | + return c.Redirect(fmt.Sprintf("/%s/%s/pulls/%d", r.OwnerUsername, r.Name, pr.Number), fiber.StatusSeeOther) | |
| 354 | +} | |
| 355 | + | |
| 356 | +func (h *Handler) RepoPullMergeSubmit(c *fiber.Ctx) error { | |
| 357 | + r, _, err := h.loadAuthorizedRepo(c, true) | |
| 358 | + if err != nil { | |
| 359 | + return nil | |
| 360 | + } | |
| 361 | + if !r.EnablePulls { | |
| 362 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.pulls_disabled") | |
| 363 | + } | |
| 364 | + | |
| 365 | + num, _ := strconv.Atoi(c.Params("number")) | |
| 366 | + pr, err := h.repoSvc.GetPullRequest(c.UserContext(), r.ID, num) | |
| 367 | + if err != nil { | |
| 368 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.pr_not_found", "err.pr_not_found_desc") | |
| 369 | + } | |
| 370 | + | |
| 371 | + if pr.Status != "open" { | |
| 372 | + h.setFlashT(c, "flash.pr_merge_open_only") | |
| 373 | + return c.Redirect(fmt.Sprintf("/%s/%s/pulls/%d", r.OwnerUsername, r.Name, pr.Number), fiber.StatusSeeOther) | |
| 374 | + } | |
| 375 | + | |
| 376 | + viewer := middleware.CurrentUser(c) | |
| 377 | + if !h.repoSvc.CanWrite(c.UserContext(), r, viewer) { | |
| 378 | + return h.renderErrorT(c, fiber.StatusForbidden, "err.forbidden", "err.pr_merge_forbidden") | |
| 379 | + } | |
| 380 | + | |
| 381 | + authorName := viewer.FullName | |
| 382 | + if authorName == "" { | |
| 383 | + authorName = viewer.Username | |
| 384 | + } | |
| 385 | + | |
| 386 | + if mergeErr := h.repoSvc.MergePullRequest(c.UserContext(), r, pr, authorName, viewer.Email, viewer.ID); mergeErr != nil { | |
| 387 | + h.setFlashT(c, "flash.pr_merge_failed", mergeErr.Error()) | |
| 388 | + return c.Redirect(fmt.Sprintf("/%s/%s/pulls/%d", r.OwnerUsername, r.Name, pr.Number), fiber.StatusSeeOther) | |
| 389 | + } | |
| 390 | + | |
| 391 | + h.setFlashT(c, "flash.pr_merged", pr.Number) | |
| 392 | + return c.Redirect(fmt.Sprintf("/%s/%s/pulls/%d", r.OwnerUsername, r.Name, pr.Number), fiber.StatusSeeOther) | |
| 393 | +} |
internal/web/handlers/releases.go
+150
-0
| ... | ... | @@ -0,0 +1,150 @@ |
| 1 | +package handlers | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "fmt" | |
| 5 | + "html/template" | |
| 6 | + "strconv" | |
| 7 | + "strings" | |
| 8 | + | |
| 9 | + "github.com/gofiber/fiber/v2" | |
| 10 | + | |
| 11 | + "polar/internal/git" | |
| 12 | + "polar/internal/repo" | |
| 13 | + "polar/internal/web/middleware" | |
| 14 | + "polar/internal/web/views" | |
| 15 | +) | |
| 16 | + | |
| 17 | +type ReleaseViewItem struct { | |
| 18 | + repo.Release | |
| 19 | + BodyHTML template.HTML | |
| 20 | +} | |
| 21 | + | |
| 22 | +func (h *Handler) RepoReleasesPage(c *fiber.Ctx) error { | |
| 23 | + r, diskPath, err := h.loadAuthorizedRepo(c, false) | |
| 24 | + if err != nil { | |
| 25 | + return nil | |
| 26 | + } | |
| 27 | + if !r.EnableReleases { | |
| 28 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.releases_disabled") | |
| 29 | + } | |
| 30 | + | |
| 31 | + releases, _ := h.repoSvc.ListReleases(c.UserContext(), r.ID) | |
| 32 | + tags, _ := git.ListTags(c.UserContext(), diskPath) | |
| 33 | + | |
| 34 | + var releaseItems []ReleaseViewItem | |
| 35 | + for _, rel := range releases { | |
| 36 | + rendered := views.RenderMarkdown([]byte(rel.Body)) | |
| 37 | + releaseItems = append(releaseItems, ReleaseViewItem{ | |
| 38 | + Release: rel, | |
| 39 | + BodyHTML: rendered, | |
| 40 | + }) | |
| 41 | + } | |
| 42 | + | |
| 43 | + viewer := middleware.CurrentUser(c) | |
| 44 | + data := h.baseData(c, h.tr(c, "releases.title")+" · "+r.OwnerUsername+"/"+r.Name) | |
| 45 | + data["Repo"] = r | |
| 46 | + data["Ref"] = r.DefaultBranch | |
| 47 | + data["ActiveTab"] = "releases" | |
| 48 | + data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer) | |
| 49 | + data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name) | |
| 50 | + data["Releases"] = releaseItems | |
| 51 | + data["TagCount"] = len(tags) | |
| 52 | + | |
| 53 | + return h.render(c, fiber.StatusOK, "releases", data) | |
| 54 | +} | |
| 55 | + | |
| 56 | +func (h *Handler) RepoNewReleasePage(c *fiber.Ctx) error { | |
| 57 | + r, diskPath, err := h.loadAuthorizedRepo(c, true) | |
| 58 | + if err != nil { | |
| 59 | + return nil | |
| 60 | + } | |
| 61 | + if !r.EnableReleases { | |
| 62 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.releases_disabled") | |
| 63 | + } | |
| 64 | + | |
| 65 | + tags, _ := git.ListTags(c.UserContext(), diskPath) | |
| 66 | + branches, _ := git.ListBranches(c.UserContext(), diskPath) | |
| 67 | + selectedTag := strings.TrimSpace(c.Query("tag")) | |
| 68 | + | |
| 69 | + viewer := middleware.CurrentUser(c) | |
| 70 | + data := h.baseData(c, h.tr(c, "releases.new")+" · "+r.OwnerUsername+"/"+r.Name) | |
| 71 | + data["Repo"] = r | |
| 72 | + data["Ref"] = r.DefaultBranch | |
| 73 | + data["ActiveTab"] = "releases" | |
| 74 | + data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer) | |
| 75 | + data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name) | |
| 76 | + data["Tags"] = tags | |
| 77 | + data["Branches"] = branches | |
| 78 | + data["SelectedTag"] = selectedTag | |
| 79 | + | |
| 80 | + return h.render(c, fiber.StatusOK, "release_new", data) | |
| 81 | +} | |
| 82 | + | |
| 83 | +func (h *Handler) RepoNewReleaseSubmit(c *fiber.Ctx) error { | |
| 84 | + r, diskPath, err := h.loadAuthorizedRepo(c, true) | |
| 85 | + if err != nil { | |
| 86 | + return nil | |
| 87 | + } | |
| 88 | + if !r.EnableReleases { | |
| 89 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.releases_disabled") | |
| 90 | + } | |
| 91 | + | |
| 92 | + u := middleware.CurrentUser(c) | |
| 93 | + tagName := strings.TrimSpace(c.FormValue("tag_name")) | |
| 94 | + title := strings.TrimSpace(c.FormValue("title")) | |
| 95 | + body := strings.TrimSpace(c.FormValue("body")) | |
| 96 | + targetRef := strings.TrimSpace(c.FormValue("target_ref")) | |
| 97 | + if targetRef == "" { | |
| 98 | + targetRef = r.DefaultBranch | |
| 99 | + } | |
| 100 | + isPrerelease := c.FormValue("is_prerelease") == "true" || c.FormValue("is_prerelease") == "on" || c.FormValue("is_prerelease") == "1" | |
| 101 | + | |
| 102 | + if tagName == "" { | |
| 103 | + h.setFlashT(c, "flash.release_tag_empty") | |
| 104 | + return c.Redirect(fmt.Sprintf("/%s/%s/releases/new", r.OwnerUsername, r.Name), fiber.StatusSeeOther) | |
| 105 | + } | |
| 106 | + | |
| 107 | + // Create git tag if it doesn't exist yet | |
| 108 | + existingTags, _ := git.ListTags(c.UserContext(), diskPath) | |
| 109 | + tagExists := false | |
| 110 | + for _, t := range existingTags { | |
| 111 | + if t == tagName { | |
| 112 | + tagExists = true | |
| 113 | + break | |
| 114 | + } | |
| 115 | + } | |
| 116 | + if !tagExists { | |
| 117 | + if err := git.CreateTag(c.UserContext(), diskPath, tagName, targetRef, title); err != nil { | |
| 118 | + h.setFlashT(c, "flash.release_tag_failed", err.Error()) | |
| 119 | + return c.Redirect(fmt.Sprintf("/%s/%s/releases/new", r.OwnerUsername, r.Name), fiber.StatusSeeOther) | |
| 120 | + } | |
| 121 | + } | |
| 122 | + | |
| 123 | + if _, err := h.repoSvc.CreateRelease(c.UserContext(), r.ID, u.ID, tagName, title, body, false, isPrerelease); err != nil { | |
| 124 | + h.setFlashT(c, "flash.release_create_failed", err.Error()) | |
| 125 | + return c.Redirect(fmt.Sprintf("/%s/%s/releases/new", r.OwnerUsername, r.Name), fiber.StatusSeeOther) | |
| 126 | + } | |
| 127 | + | |
| 128 | + h.repoSvc.TouchUpdatedAt(c.UserContext(), r.ID) | |
| 129 | + h.setFlashT(c, "flash.release_published", tagName) | |
| 130 | + return c.Redirect(fmt.Sprintf("/%s/%s/releases", r.OwnerUsername, r.Name), fiber.StatusSeeOther) | |
| 131 | +} | |
| 132 | + | |
| 133 | +func (h *Handler) RepoDeleteReleaseSubmit(c *fiber.Ctx) error { | |
| 134 | + r, _, err := h.loadAuthorizedRepo(c, true) | |
| 135 | + if err != nil { | |
| 136 | + return nil | |
| 137 | + } | |
| 138 | + if !r.EnableReleases { | |
| 139 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.releases_disabled") | |
| 140 | + } | |
| 141 | + | |
| 142 | + relID, _ := strconv.ParseInt(c.Params("id"), 10, 64) | |
| 143 | + if err := h.repoSvc.DeleteRelease(c.UserContext(), relID); err != nil { | |
| 144 | + h.setFlashT(c, "flash.release_delete_failed", err.Error()) | |
| 145 | + } else { | |
| 146 | + h.setFlashT(c, "flash.release_deleted") | |
| 147 | + } | |
| 148 | + | |
| 149 | + return c.Redirect(fmt.Sprintf("/%s/%s/releases", r.OwnerUsername, r.Name), fiber.StatusSeeOther) | |
| 150 | +} |
internal/web/handlers/repo.go
+721
-0
| ... | ... | @@ -0,0 +1,721 @@ |
| 1 | +package handlers | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "fmt" | |
| 5 | + "net/url" | |
| 6 | + "path" | |
| 7 | + "strconv" | |
| 8 | + "strings" | |
| 9 | + | |
| 10 | + "polar/internal/git" | |
| 11 | + "polar/internal/i18n" | |
| 12 | + "polar/internal/repo" | |
| 13 | + "polar/internal/web/middleware" | |
| 14 | + "polar/internal/web/views" | |
| 15 | + | |
| 16 | + "github.com/gofiber/fiber/v2" | |
| 17 | +) | |
| 18 | + | |
| 19 | +type BreadcrumbItem struct { | |
| 20 | + Name string | |
| 21 | + Path string | |
| 22 | + IsLast bool | |
| 23 | +} | |
| 24 | + | |
| 25 | +func buildBreadcrumbs(subPath string) []BreadcrumbItem { | |
| 26 | + subPath = strings.Trim(subPath, "/") | |
| 27 | + if subPath == "" { | |
| 28 | + return nil | |
| 29 | + } | |
| 30 | + parts := strings.Split(subPath, "/") | |
| 31 | + items := make([]BreadcrumbItem, 0, len(parts)) | |
| 32 | + var acc []string | |
| 33 | + for idx, p := range parts { | |
| 34 | + acc = append(acc, p) | |
| 35 | + items = append(items, BreadcrumbItem{ | |
| 36 | + Name: p, | |
| 37 | + Path: strings.Join(acc, "/"), | |
| 38 | + IsLast: idx == len(parts)-1, | |
| 39 | + }) | |
| 40 | + } | |
| 41 | + return items | |
| 42 | +} | |
| 43 | + | |
| 44 | +func (h *Handler) NewRepoPage(c *fiber.Ctx) error { | |
| 45 | + lang, _ := c.Locals("lang").(i18n.Lang) | |
| 46 | + data := h.baseData(c, i18n.T(lang, "repo.new.title")) | |
| 47 | + return h.render(c, fiber.StatusOK, "repo_new", data) | |
| 48 | +} | |
| 49 | + | |
| 50 | +func (h *Handler) NewRepoSubmit(c *fiber.Ctx) error { | |
| 51 | + u := middleware.CurrentUser(c) | |
| 52 | + lang, _ := c.Locals("lang").(i18n.Lang) | |
| 53 | + | |
| 54 | + name := strings.TrimSpace(c.FormValue("name")) | |
| 55 | + desc := strings.TrimSpace(c.FormValue("description")) | |
| 56 | + isPriv := c.FormValue("visibility") == "private" | |
| 57 | + | |
| 58 | + r, err := h.repoSvc.Create(c.UserContext(), u, name, desc, isPriv) | |
| 59 | + if err != nil { | |
| 60 | + data := h.baseData(c, i18n.T(lang, "repo.new.title")) | |
| 61 | + data["FormName"] = name | |
| 62 | + data["FormDescription"] = desc | |
| 63 | + data["Error"] = err.Error() | |
| 64 | + return h.render(c, fiber.StatusBadRequest, "repo_new", data) | |
| 65 | + } | |
| 66 | + | |
| 67 | + return c.Redirect(fmt.Sprintf("/%s/%s", r.OwnerUsername, r.Name), fiber.StatusSeeOther) | |
| 68 | +} | |
| 69 | + | |
| 70 | +func (h *Handler) loadAuthorizedRepo(c *fiber.Ctx, requireWrite bool) (*repo.Repository, string, error) { | |
| 71 | + owner := c.Params("owner") | |
| 72 | + repoName := strings.TrimSuffix(c.Params("repo"), ".git") | |
| 73 | + viewer := middleware.CurrentUser(c) | |
| 74 | + | |
| 75 | + if viewer == nil && !h.db.IsAnonymousReadEnabled(c.UserContext()) { | |
| 76 | + _ = c.Redirect("/login", fiber.StatusSeeOther) | |
| 77 | + return nil, "", fiber.ErrUnauthorized | |
| 78 | + } | |
| 79 | + | |
| 80 | + r, err := h.repoSvc.GetByOwnerAndName(c.UserContext(), owner, repoName) | |
| 81 | + if err != nil { | |
| 82 | + _ = h.renderErrorT(c, fiber.StatusNotFound, "err.repo_not_found", "err.repo_not_found_desc") | |
| 83 | + return nil, "", err | |
| 84 | + } | |
| 85 | + | |
| 86 | + if requireWrite { | |
| 87 | + if !h.repoSvc.CanWrite(c.UserContext(), r, viewer) { | |
| 88 | + _ = h.renderErrorT(c, fiber.StatusForbidden, "err.access_denied", "err.repo_write_denied") | |
| 89 | + return nil, "", fiber.ErrForbidden | |
| 90 | + } | |
| 91 | + } else { | |
| 92 | + if !h.repoSvc.CanRead(c.UserContext(), r, viewer) { | |
| 93 | + _ = h.renderErrorT(c, fiber.StatusNotFound, "err.repo_not_found", "err.repo_private_desc") | |
| 94 | + return nil, "", fiber.ErrNotFound | |
| 95 | + } | |
| 96 | + } | |
| 97 | + | |
| 98 | + diskPath, err := h.repoSvc.DiskPath(r.OwnerUsername, r.Name) | |
| 99 | + if err != nil { | |
| 100 | + _ = h.renderError(c, fiber.StatusBadRequest, i18n.T(h.langOf(c), "err.invalid_repo_path"), err.Error()) | |
| 101 | + return nil, "", err | |
| 102 | + } | |
| 103 | + | |
| 104 | + return r, diskPath, nil | |
| 105 | +} | |
| 106 | + | |
| 107 | +func (h *Handler) RepoHomePage(c *fiber.Ctx) error { | |
| 108 | + return h.renderRepoTree(c, "", "") | |
| 109 | +} | |
| 110 | + | |
| 111 | +func (h *Handler) RepoTreePage(c *fiber.Ctx) error { | |
| 112 | + ref, _ := url.PathUnescape(c.Params("ref")) | |
| 113 | + subPath, _ := url.PathUnescape(c.Params("*")) | |
| 114 | + return h.renderRepoTree(c, ref, subPath) | |
| 115 | +} | |
| 116 | + | |
| 117 | +func (h *Handler) renderRepoTree(c *fiber.Ctx, ref, subPath string) error { | |
| 118 | + r, diskPath, err := h.loadAuthorizedRepo(c, false) | |
| 119 | + if err != nil { | |
| 120 | + return nil | |
| 121 | + } | |
| 122 | + viewer := middleware.CurrentUser(c) | |
| 123 | + | |
| 124 | + headBranch, isEmpty := git.EnsureDefaultBranch(c.UserContext(), diskPath, r.DefaultBranch) | |
| 125 | + if ref == "" { | |
| 126 | + ref = headBranch | |
| 127 | + } | |
| 128 | + | |
| 129 | + cloneURL := h.cloneURL(c, r.OwnerUsername, r.Name) | |
| 130 | + data := h.baseData(c, r.OwnerUsername+"/"+r.Name) | |
| 131 | + data["Repo"] = r | |
| 132 | + data["Ref"] = ref | |
| 133 | + data["SubPath"] = strings.Trim(subPath, "/") | |
| 134 | + data["ActiveTab"] = "code" | |
| 135 | + data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer) | |
| 136 | + data["CloneURL"] = cloneURL | |
| 137 | + data["IsEmpty"] = isEmpty | |
| 138 | + | |
| 139 | + if isEmpty { | |
| 140 | + data["QuickStartNew"] = fmt.Sprintf( | |
| 141 | + "echo \"# %s\" >> README.md\ngit init -b %s\ngit add README.md\ngit commit -m \"Initial commit\"\ngit remote add origin %s\ngit push -u origin %s", | |
| 142 | + r.Name, r.DefaultBranch, cloneURL, r.DefaultBranch, | |
| 143 | + ) | |
| 144 | + data["QuickStartExisting"] = fmt.Sprintf( | |
| 145 | + "git remote add origin %s\ngit branch -M %s\ngit push -u origin %s", | |
| 146 | + cloneURL, r.DefaultBranch, r.DefaultBranch, | |
| 147 | + ) | |
| 148 | + return h.render(c, fiber.StatusOK, "repo", data) | |
| 149 | + } | |
| 150 | + | |
| 151 | + branches, _ := git.ListBranches(c.UserContext(), diskPath) | |
| 152 | + tags, _ := git.ListTags(c.UserContext(), diskPath) | |
| 153 | + entries, err := git.ListTree(c.UserContext(), diskPath, ref, subPath) | |
| 154 | + if err != nil { | |
| 155 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.path_not_found", "err.path_not_found_desc", ref) | |
| 156 | + } | |
| 157 | + | |
| 158 | + latestCommit, _ := git.GetLatestCommit(c.UserContext(), diskPath, ref, subPath) | |
| 159 | + allFiles, _ := git.ListAllFiles(c.UserContext(), diskPath, ref) | |
| 160 | + | |
| 161 | + data["Branches"] = branches | |
| 162 | + data["Tags"] = tags | |
| 163 | + data["Entries"] = entries | |
| 164 | + data["LatestCommit"] = latestCommit | |
| 165 | + data["PaletteFiles"] = allFiles | |
| 166 | + data["Breadcrumbs"] = buildBreadcrumbs(subPath) | |
| 167 | + if strings.Trim(subPath, "/") != "" { | |
| 168 | + data["ParentPath"] = path.Dir(strings.Trim(subPath, "/")) | |
| 169 | + } | |
| 170 | + | |
| 171 | + if readmeBlob, err := git.FindReadme(c.UserContext(), diskPath, ref, subPath); err == nil && readmeBlob != nil { | |
| 172 | + data["ReadmeName"] = readmeBlob.Name | |
| 173 | + data["ReadmeHTML"] = views.RenderMarkdown(readmeBlob.Data) | |
| 174 | + } | |
| 175 | + | |
| 176 | + return h.render(c, fiber.StatusOK, "repo", data) | |
| 177 | +} | |
| 178 | + | |
| 179 | +func (h *Handler) RepoBlobPage(c *fiber.Ctx) error { | |
| 180 | + r, diskPath, err := h.loadAuthorizedRepo(c, false) | |
| 181 | + if err != nil { | |
| 182 | + return nil | |
| 183 | + } | |
| 184 | + viewer := middleware.CurrentUser(c) | |
| 185 | + | |
| 186 | + ref, _ := url.PathUnescape(c.Params("ref")) | |
| 187 | + subPath, _ := url.PathUnescape(c.Params("*")) | |
| 188 | + | |
| 189 | + blob, err := git.ReadBlob(c.UserContext(), diskPath, ref, subPath) | |
| 190 | + if err != nil { | |
| 191 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.file_not_found", "err.file_not_found_desc", ref) | |
| 192 | + } | |
| 193 | + | |
| 194 | + branches, _ := git.ListBranches(c.UserContext(), diskPath) | |
| 195 | + tags, _ := git.ListTags(c.UserContext(), diskPath) | |
| 196 | + allFiles, _ := git.ListAllFiles(c.UserContext(), diskPath, ref) | |
| 197 | + | |
| 198 | + data := h.baseData(c, blob.Name+" · "+r.OwnerUsername+"/"+r.Name) | |
| 199 | + data["Repo"] = r | |
| 200 | + data["Ref"] = ref | |
| 201 | + data["SubPath"] = blob.Path | |
| 202 | + data["ActiveTab"] = "code" | |
| 203 | + data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer) | |
| 204 | + data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name) | |
| 205 | + data["Branches"] = branches | |
| 206 | + data["Tags"] = tags | |
| 207 | + data["Blob"] = blob | |
| 208 | + data["Breadcrumbs"] = buildBreadcrumbs(blob.Path) | |
| 209 | + data["PaletteFiles"] = allFiles | |
| 210 | + | |
| 211 | + if !blob.IsBinary { | |
| 212 | + rawText := string(blob.Data) | |
| 213 | + data["RawText"] = rawText | |
| 214 | + data["HighlightedLines"] = views.HighlightCode(blob.Name, rawText) | |
| 215 | + } | |
| 216 | + | |
| 217 | + return h.render(c, fiber.StatusOK, "blob", data) | |
| 218 | +} | |
| 219 | + | |
| 220 | +func (h *Handler) RepoRawBlob(c *fiber.Ctx) error { | |
| 221 | + _, diskPath, err := h.loadAuthorizedRepo(c, false) | |
| 222 | + if err != nil { | |
| 223 | + return nil | |
| 224 | + } | |
| 225 | + | |
| 226 | + ref, _ := url.PathUnescape(c.Params("ref")) | |
| 227 | + subPath, _ := url.PathUnescape(c.Params("*")) | |
| 228 | + | |
| 229 | + blob, err := git.ReadBlob(c.UserContext(), diskPath, ref, subPath) | |
| 230 | + if err != nil { | |
| 231 | + return c.Status(fiber.StatusNotFound).SendString("File not found") | |
| 232 | + } | |
| 233 | + | |
| 234 | + if blob.IsBinary { | |
| 235 | + c.Set(fiber.HeaderContentType, "application/octet-stream") | |
| 236 | + } else { | |
| 237 | + c.Set(fiber.HeaderContentType, "text/plain; charset=utf-8") | |
| 238 | + } | |
| 239 | + return c.Send(blob.Data) | |
| 240 | +} | |
| 241 | + | |
| 242 | +func (h *Handler) RepoCommitsPage(c *fiber.Ctx) error { | |
| 243 | + r, diskPath, err := h.loadAuthorizedRepo(c, false) | |
| 244 | + if err != nil { | |
| 245 | + return nil | |
| 246 | + } | |
| 247 | + viewer := middleware.CurrentUser(c) | |
| 248 | + | |
| 249 | + ref, _ := url.PathUnescape(c.Params("ref")) | |
| 250 | + if ref == "" { | |
| 251 | + ref, _ = git.EnsureDefaultBranch(c.UserContext(), diskPath, r.DefaultBranch) | |
| 252 | + } | |
| 253 | + | |
| 254 | + page, _ := strconv.Atoi(c.Query("page", "1")) | |
| 255 | + if page < 1 { | |
| 256 | + page = 1 | |
| 257 | + } | |
| 258 | + const perPage = 30 | |
| 259 | + offset := (page - 1) * perPage | |
| 260 | + | |
| 261 | + commits, _ := git.ListCommits(c.UserContext(), diskPath, ref, perPage+1, offset) | |
| 262 | + hasMore := false | |
| 263 | + if len(commits) > perPage { | |
| 264 | + hasMore = true | |
| 265 | + commits = commits[:perPage] | |
| 266 | + } | |
| 267 | + branches, _ := git.ListBranches(c.UserContext(), diskPath) | |
| 268 | + | |
| 269 | + data := h.baseData(c, h.tr(c, "repo.tab.commits")+" · "+r.OwnerUsername+"/"+r.Name) | |
| 270 | + data["Repo"] = r | |
| 271 | + data["Ref"] = ref | |
| 272 | + data["ActiveTab"] = "commits" | |
| 273 | + data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer) | |
| 274 | + data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name) | |
| 275 | + data["Branches"] = branches | |
| 276 | + data["Commits"] = commits | |
| 277 | + data["Page"] = page | |
| 278 | + data["HasMore"] = hasMore | |
| 279 | + | |
| 280 | + return h.render(c, fiber.StatusOK, "commits", data) | |
| 281 | +} | |
| 282 | + | |
| 283 | +func (h *Handler) RepoCommitDetailPage(c *fiber.Ctx) error { | |
| 284 | + r, diskPath, err := h.loadAuthorizedRepo(c, false) | |
| 285 | + if err != nil { | |
| 286 | + return nil | |
| 287 | + } | |
| 288 | + viewer := middleware.CurrentUser(c) | |
| 289 | + hash := c.Params("hash") | |
| 290 | + | |
| 291 | + detail, err := git.GetCommitDiff(c.UserContext(), diskPath, hash) | |
| 292 | + if err != nil { | |
| 293 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.commit_not_found", "err.commit_not_found_desc", hash) | |
| 294 | + } | |
| 295 | + | |
| 296 | + data := h.baseData(c, detail.Commit.ShortHash+" · "+r.OwnerUsername+"/"+r.Name) | |
| 297 | + data["Repo"] = r | |
| 298 | + data["Ref"] = r.DefaultBranch | |
| 299 | + data["ActiveTab"] = "commits" | |
| 300 | + data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer) | |
| 301 | + data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name) | |
| 302 | + data["Detail"] = detail | |
| 303 | + | |
| 304 | + return h.render(c, fiber.StatusOK, "commit", data) | |
| 305 | +} | |
| 306 | + | |
| 307 | +func (h *Handler) RepoSettingsPage(c *fiber.Ctx) error { | |
| 308 | + r, diskPath, err := h.loadAuthorizedRepo(c, true) | |
| 309 | + if err != nil { | |
| 310 | + return nil | |
| 311 | + } | |
| 312 | + branches, _ := git.ListBranches(c.UserContext(), diskPath) | |
| 313 | + | |
| 314 | + data := h.baseData(c, h.tr(c, "repo.tab.settings")+" · "+r.OwnerUsername+"/"+r.Name) | |
| 315 | + data["Repo"] = r | |
| 316 | + data["Ref"] = r.DefaultBranch | |
| 317 | + data["ActiveTab"] = "settings" | |
| 318 | + data["CanWrite"] = true | |
| 319 | + data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name) | |
| 320 | + data["Branches"] = branches | |
| 321 | + | |
| 322 | + viewer := middleware.CurrentUser(c) | |
| 323 | + collaborators, _ := h.repoSvc.ListCollaborators(c.UserContext(), r.ID) | |
| 324 | + data["Collaborators"] = collaborators | |
| 325 | + data["CanManageCollab"] = viewer != nil && (viewer.ID == r.OwnerID || viewer.IsAdmin) | |
| 326 | + | |
| 327 | + return h.render(c, fiber.StatusOK, "repo_settings", data) | |
| 328 | +} | |
| 329 | + | |
| 330 | +func (h *Handler) RepoSettingsSubmit(c *fiber.Ctx) error { | |
| 331 | + r, _, err := h.loadAuthorizedRepo(c, true) | |
| 332 | + if err != nil { | |
| 333 | + return nil | |
| 334 | + } | |
| 335 | + | |
| 336 | + desc := c.FormValue("description") | |
| 337 | + defaultBranch := c.FormValue("default_branch") | |
| 338 | + isPriv := c.FormValue("visibility") == "private" | |
| 339 | + | |
| 340 | + enableIssues := c.FormValue("enable_issues") == "true" || c.FormValue("enable_issues") == "on" || c.FormValue("enable_issues") == "1" | |
| 341 | + enablePulls := c.FormValue("enable_pulls") == "true" || c.FormValue("enable_pulls") == "on" || c.FormValue("enable_pulls") == "1" | |
| 342 | + enableReleases := c.FormValue("enable_releases") == "true" || c.FormValue("enable_releases") == "on" || c.FormValue("enable_releases") == "1" | |
| 343 | + enableActivity := c.FormValue("enable_activity") == "true" || c.FormValue("enable_activity") == "on" || c.FormValue("enable_activity") == "1" | |
| 344 | + | |
| 345 | + if err := h.repoSvc.Update(c.UserContext(), r, desc, isPriv, defaultBranch, enableIssues, enablePulls, enableReleases, enableActivity); err != nil { | |
| 346 | + h.setFlashT(c, "flash.error", err.Error()) | |
| 347 | + return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther) | |
| 348 | + } | |
| 349 | + | |
| 350 | + h.setFlashT(c, "flash.repo_settings_saved") | |
| 351 | + return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther) | |
| 352 | +} | |
| 353 | + | |
| 354 | +func (h *Handler) RepoRenameSubmit(c *fiber.Ctx) error { | |
| 355 | + r, _, err := h.loadAuthorizedRepo(c, true) | |
| 356 | + if err != nil { | |
| 357 | + return nil | |
| 358 | + } | |
| 359 | + | |
| 360 | + newName := strings.TrimSpace(c.FormValue("new_name")) | |
| 361 | + if err := h.repoSvc.Rename(c.UserContext(), r, newName); err != nil { | |
| 362 | + h.setFlashT(c, "flash.repo_rename_failed", err.Error()) | |
| 363 | + return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther) | |
| 364 | + } | |
| 365 | + | |
| 366 | + h.setFlashT(c, "flash.repo_renamed", r.Name) | |
| 367 | + return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther) | |
| 368 | +} | |
| 369 | + | |
| 370 | +func (h *Handler) RepoDeleteSubmit(c *fiber.Ctx) error { | |
| 371 | + r, _, err := h.loadAuthorizedRepo(c, true) | |
| 372 | + if err != nil { | |
| 373 | + return nil | |
| 374 | + } | |
| 375 | + | |
| 376 | + confirmName := strings.TrimSpace(c.FormValue("confirm_name")) | |
| 377 | + if !strings.EqualFold(confirmName, r.Name) { | |
| 378 | + h.setFlashT(c, "flash.repo_confirm_mismatch") | |
| 379 | + return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther) | |
| 380 | + } | |
| 381 | + | |
| 382 | + if err := h.repoSvc.Delete(c.UserContext(), r); err != nil { | |
| 383 | + h.logger.Error("delete repo failed", "err", err) | |
| 384 | + return h.renderErrorT(c, fiber.StatusInternalServerError, "err.delete_error", "err.repo_delete_failed") | |
| 385 | + } | |
| 386 | + | |
| 387 | + h.setFlashT(c, "flash.repo_deleted", r.Name) | |
| 388 | + return c.Redirect("/repos", fiber.StatusSeeOther) | |
| 389 | +} | |
| 390 | + | |
| 391 | +func (h *Handler) UserProfilePage(c *fiber.Ctx) error { | |
| 392 | + username := c.Params("username") | |
| 393 | + profileUser, err := h.userSvc.GetByUsername(c.UserContext(), username) | |
| 394 | + if err != nil { | |
| 395 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.user_not_found", username) | |
| 396 | + } | |
| 397 | + | |
| 398 | + viewer := middleware.CurrentUser(c) | |
| 399 | + if viewer == nil && !h.db.IsAnonymousReadEnabled(c.UserContext()) { | |
| 400 | + return c.Redirect("/login", fiber.StatusSeeOther) | |
| 401 | + } | |
| 402 | + | |
| 403 | + includePrivate := viewer != nil && (viewer.ID == profileUser.ID || viewer.IsAdmin) | |
| 404 | + repos, _ := h.repoSvc.ListByOwner(c.UserContext(), profileUser.ID, includePrivate) | |
| 405 | + | |
| 406 | + publicCount := 0 | |
| 407 | + privateCount := 0 | |
| 408 | + for _, rp := range repos { | |
| 409 | + if rp.IsPrivate { | |
| 410 | + privateCount++ | |
| 411 | + } else { | |
| 412 | + publicCount++ | |
| 413 | + } | |
| 414 | + } | |
| 415 | + | |
| 416 | + lang, _ := c.Locals("lang").(i18n.Lang) | |
| 417 | + title := "@" + profileUser.Username | |
| 418 | + if profileUser.FullName != "" { | |
| 419 | + title = profileUser.FullName + " (@" + profileUser.Username + ")" | |
| 420 | + } | |
| 421 | + data := h.baseData(c, title) | |
| 422 | + data["ProfileUser"] = profileUser | |
| 423 | + data["PageHeading"] = i18n.T(lang, "nav.repositories") | |
| 424 | + data["Repos"] = repos | |
| 425 | + data["PublicRepoCount"] = publicCount | |
| 426 | + data["PrivateRepoCount"] = privateCount | |
| 427 | + data["ShowPrivateCount"] = includePrivate | |
| 428 | + return h.render(c, fiber.StatusOK, "profile", data) | |
| 429 | +} | |
| 430 | + | |
| 431 | +func (h *Handler) RepoBranchesPage(c *fiber.Ctx) error { | |
| 432 | + r, diskPath, err := h.loadAuthorizedRepo(c, false) | |
| 433 | + if err != nil { | |
| 434 | + return nil | |
| 435 | + } | |
| 436 | + viewer := middleware.CurrentUser(c) | |
| 437 | + | |
| 438 | + branches, _ := git.ListBranchDetails(c.UserContext(), diskPath, r.DefaultBranch) | |
| 439 | + tags, _ := git.ListTagDetails(c.UserContext(), diskPath) | |
| 440 | + | |
| 441 | + data := h.baseData(c, h.tr(c, "repo.tab.branches")+" · "+r.OwnerUsername+"/"+r.Name) | |
| 442 | + data["Repo"] = r | |
| 443 | + data["Ref"] = r.DefaultBranch | |
| 444 | + data["ActiveTab"] = "branches" | |
| 445 | + data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer) | |
| 446 | + data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name) | |
| 447 | + data["BranchDetails"] = branches | |
| 448 | + data["TagDetails"] = tags | |
| 449 | + data["IsEmpty"] = len(branches) == 0 | |
| 450 | + | |
| 451 | + return h.render(c, fiber.StatusOK, "branches", data) | |
| 452 | +} | |
| 453 | + | |
| 454 | +func (h *Handler) RepoCreateBranchSubmit(c *fiber.Ctx) error { | |
| 455 | + r, diskPath, err := h.loadAuthorizedRepo(c, true) | |
| 456 | + if err != nil { | |
| 457 | + return nil | |
| 458 | + } | |
| 459 | + branchName := strings.TrimSpace(c.FormValue("branch_name")) | |
| 460 | + fromRef := strings.TrimSpace(c.FormValue("from_ref")) | |
| 461 | + if fromRef == "" { | |
| 462 | + fromRef = r.DefaultBranch | |
| 463 | + } | |
| 464 | + | |
| 465 | + if err := git.CreateBranch(c.UserContext(), diskPath, branchName, fromRef); err != nil { | |
| 466 | + h.setFlashT(c, "flash.branch_create_failed", err.Error()) | |
| 467 | + } else { | |
| 468 | + h.setFlashT(c, "flash.branch_created", branchName) | |
| 469 | + } | |
| 470 | + return c.Redirect(fmt.Sprintf("/%s/%s/branches", r.OwnerUsername, r.Name), fiber.StatusSeeOther) | |
| 471 | +} | |
| 472 | + | |
| 473 | +func (h *Handler) RepoDeleteBranchSubmit(c *fiber.Ctx) error { | |
| 474 | + r, diskPath, err := h.loadAuthorizedRepo(c, true) | |
| 475 | + if err != nil { | |
| 476 | + return nil | |
| 477 | + } | |
| 478 | + branchName := strings.TrimSpace(c.FormValue("branch_name")) | |
| 479 | + if strings.EqualFold(branchName, r.DefaultBranch) { | |
| 480 | + h.setFlashT(c, "flash.branch_default_delete") | |
| 481 | + return c.Redirect(fmt.Sprintf("/%s/%s/branches", r.OwnerUsername, r.Name), fiber.StatusSeeOther) | |
| 482 | + } | |
| 483 | + | |
| 484 | + if err := git.DeleteBranch(c.UserContext(), diskPath, branchName); err != nil { | |
| 485 | + h.setFlashT(c, "flash.branch_delete_failed", err.Error()) | |
| 486 | + } else { | |
| 487 | + h.setFlashT(c, "flash.branch_deleted", branchName) | |
| 488 | + } | |
| 489 | + return c.Redirect(fmt.Sprintf("/%s/%s/branches", r.OwnerUsername, r.Name), fiber.StatusSeeOther) | |
| 490 | +} | |
| 491 | + | |
| 492 | +func (h *Handler) RepoCreateTagSubmit(c *fiber.Ctx) error { | |
| 493 | + r, diskPath, err := h.loadAuthorizedRepo(c, true) | |
| 494 | + if err != nil { | |
| 495 | + return nil | |
| 496 | + } | |
| 497 | + tagName := strings.TrimSpace(c.FormValue("tag_name")) | |
| 498 | + fromRef := strings.TrimSpace(c.FormValue("from_ref")) | |
| 499 | + if fromRef == "" { | |
| 500 | + fromRef = r.DefaultBranch | |
| 501 | + } | |
| 502 | + message := strings.TrimSpace(c.FormValue("message")) | |
| 503 | + | |
| 504 | + if err := git.CreateTag(c.UserContext(), diskPath, tagName, fromRef, message); err != nil { | |
| 505 | + h.setFlashT(c, "flash.tag_create_failed", err.Error()) | |
| 506 | + } else { | |
| 507 | + h.setFlashT(c, "flash.tag_created", tagName) | |
| 508 | + } | |
| 509 | + return c.Redirect(fmt.Sprintf("/%s/%s/branches", r.OwnerUsername, r.Name), fiber.StatusSeeOther) | |
| 510 | +} | |
| 511 | + | |
| 512 | +func (h *Handler) RepoDeleteTagSubmit(c *fiber.Ctx) error { | |
| 513 | + r, diskPath, err := h.loadAuthorizedRepo(c, true) | |
| 514 | + if err != nil { | |
| 515 | + return nil | |
| 516 | + } | |
| 517 | + tagName := strings.TrimSpace(c.FormValue("tag_name")) | |
| 518 | + | |
| 519 | + if err := git.DeleteTag(c.UserContext(), diskPath, tagName); err != nil { | |
| 520 | + h.setFlashT(c, "flash.tag_delete_failed", err.Error()) | |
| 521 | + } else { | |
| 522 | + h.setFlashT(c, "flash.tag_deleted", tagName) | |
| 523 | + } | |
| 524 | + return c.Redirect(fmt.Sprintf("/%s/%s/branches", r.OwnerUsername, r.Name), fiber.StatusSeeOther) | |
| 525 | +} | |
| 526 | + | |
| 527 | +func (h *Handler) RepoArchiveZip(c *fiber.Ctx) error { | |
| 528 | + r, diskPath, err := h.loadAuthorizedRepo(c, false) | |
| 529 | + if err != nil { | |
| 530 | + return nil | |
| 531 | + } | |
| 532 | + | |
| 533 | + filename := c.Params("*") | |
| 534 | + isTarGz := strings.HasSuffix(filename, ".tar.gz") || strings.HasSuffix(filename, ".tgz") | |
| 535 | + ref := strings.TrimSuffix(strings.TrimSuffix(strings.TrimSuffix(filename, ".tar.gz"), ".tgz"), ".zip") | |
| 536 | + ref, _ = url.PathUnescape(ref) | |
| 537 | + if ref == "" { | |
| 538 | + ref = r.DefaultBranch | |
| 539 | + } | |
| 540 | + | |
| 541 | + prefix := r.Name + "-" + strings.ReplaceAll(ref, "/", "-") | |
| 542 | + if isTarGz { | |
| 543 | + tarBytes, err := git.ArchiveTarGz(c.UserContext(), diskPath, ref, prefix) | |
| 544 | + if err != nil { | |
| 545 | + h.logger.Error("archive tar.gz failed", "ref", ref, "err", err) | |
| 546 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.archive_error", "err.archive_tar_failed", ref) | |
| 547 | + } | |
| 548 | + c.Set(fiber.HeaderContentType, "application/gzip") | |
| 549 | + c.Set(fiber.HeaderContentDisposition, fmt.Sprintf(`attachment; filename="%s.tar.gz"`, prefix)) | |
| 550 | + return c.Send(tarBytes) | |
| 551 | + } | |
| 552 | + | |
| 553 | + zipBytes, err := git.ArchiveZip(c.UserContext(), diskPath, ref, prefix) | |
| 554 | + if err != nil { | |
| 555 | + h.logger.Error("archive zip failed", "ref", ref, "err", err) | |
| 556 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.archive_error", "err.archive_zip_failed", ref) | |
| 557 | + } | |
| 558 | + | |
| 559 | + c.Set(fiber.HeaderContentType, "application/zip") | |
| 560 | + c.Set(fiber.HeaderContentDisposition, fmt.Sprintf(`attachment; filename="%s.zip"`, prefix)) | |
| 561 | + return c.Send(zipBytes) | |
| 562 | +} | |
| 563 | + | |
| 564 | +func (h *Handler) RepoIssuesPage(c *fiber.Ctx) error { | |
| 565 | + r, _, err := h.loadAuthorizedRepo(c, false) | |
| 566 | + if err != nil { | |
| 567 | + return nil | |
| 568 | + } | |
| 569 | + if !r.EnableIssues { | |
| 570 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.issues_disabled") | |
| 571 | + } | |
| 572 | + | |
| 573 | + state := c.Query("state", "open") | |
| 574 | + isClosed := state == "closed" | |
| 575 | + | |
| 576 | + issues, _ := h.repoSvc.ListIssues(c.UserContext(), r.ID, isClosed) | |
| 577 | + openCount, closedCount, _ := h.repoSvc.CountIssues(c.UserContext(), r.ID) | |
| 578 | + | |
| 579 | + viewer := middleware.CurrentUser(c) | |
| 580 | + data := h.baseData(c, h.tr(c, "repo.tab.issues")+" · "+r.OwnerUsername+"/"+r.Name) | |
| 581 | + data["Repo"] = r | |
| 582 | + data["Ref"] = r.DefaultBranch | |
| 583 | + data["ActiveTab"] = "issues" | |
| 584 | + data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer) | |
| 585 | + data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name) | |
| 586 | + data["Issues"] = issues | |
| 587 | + data["OpenCount"] = openCount | |
| 588 | + data["ClosedCount"] = closedCount | |
| 589 | + data["ShowClosed"] = isClosed | |
| 590 | + | |
| 591 | + return h.render(c, fiber.StatusOK, "issues", data) | |
| 592 | +} | |
| 593 | + | |
| 594 | +func (h *Handler) RepoNewIssuePage(c *fiber.Ctx) error { | |
| 595 | + r, _, err := h.loadAuthorizedRepo(c, false) | |
| 596 | + if err != nil { | |
| 597 | + return nil | |
| 598 | + } | |
| 599 | + if !r.EnableIssues { | |
| 600 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.issues_disabled") | |
| 601 | + } | |
| 602 | + | |
| 603 | + viewer := middleware.CurrentUser(c) | |
| 604 | + data := h.baseData(c, h.tr(c, "issue.new")+" · "+r.OwnerUsername+"/"+r.Name) | |
| 605 | + data["Repo"] = r | |
| 606 | + data["Ref"] = r.DefaultBranch | |
| 607 | + data["ActiveTab"] = "issues" | |
| 608 | + data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer) | |
| 609 | + data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name) | |
| 610 | + | |
| 611 | + return h.render(c, fiber.StatusOK, "issue_new", data) | |
| 612 | +} | |
| 613 | + | |
| 614 | +func (h *Handler) RepoNewIssueSubmit(c *fiber.Ctx) error { | |
| 615 | + r, _, err := h.loadAuthorizedRepo(c, false) | |
| 616 | + if err != nil { | |
| 617 | + return nil | |
| 618 | + } | |
| 619 | + if !r.EnableIssues { | |
| 620 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.issues_disabled") | |
| 621 | + } | |
| 622 | + | |
| 623 | + u := middleware.CurrentUser(c) | |
| 624 | + title := c.FormValue("title") | |
| 625 | + body := c.FormValue("body") | |
| 626 | + | |
| 627 | + iss, err := h.repoSvc.CreateIssue(c.UserContext(), r.ID, u.ID, title, body) | |
| 628 | + if err != nil { | |
| 629 | + h.setFlashT(c, "flash.error", err.Error()) | |
| 630 | + return c.Redirect(fmt.Sprintf("/%s/%s/issues/new", r.OwnerUsername, r.Name), fiber.StatusSeeOther) | |
| 631 | + } | |
| 632 | + | |
| 633 | + return c.Redirect(fmt.Sprintf("/%s/%s/issues/%d", r.OwnerUsername, r.Name, iss.Number), fiber.StatusSeeOther) | |
| 634 | +} | |
| 635 | + | |
| 636 | +func (h *Handler) RepoIssueDetailPage(c *fiber.Ctx) error { | |
| 637 | + r, _, err := h.loadAuthorizedRepo(c, false) | |
| 638 | + if err != nil { | |
| 639 | + return nil | |
| 640 | + } | |
| 641 | + if !r.EnableIssues { | |
| 642 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.issues_disabled") | |
| 643 | + } | |
| 644 | + | |
| 645 | + num, _ := strconv.Atoi(c.Params("number")) | |
| 646 | + iss, err := h.repoSvc.GetIssue(c.UserContext(), r.ID, num) | |
| 647 | + if err != nil { | |
| 648 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.issue_not_found", "err.issue_not_found_desc") | |
| 649 | + } | |
| 650 | + | |
| 651 | + comments, _ := h.repoSvc.ListIssueComments(c.UserContext(), iss.ID) | |
| 652 | + viewer := middleware.CurrentUser(c) | |
| 653 | + canToggle := viewer != nil && (viewer.ID == iss.AuthorID || h.repoSvc.CanWrite(c.UserContext(), r, viewer)) | |
| 654 | + | |
| 655 | + data := h.baseData(c, fmt.Sprintf("#%d: %s · %s/%s", iss.Number, iss.Title, r.OwnerUsername, r.Name)) | |
| 656 | + data["Repo"] = r | |
| 657 | + data["Ref"] = r.DefaultBranch | |
| 658 | + data["ActiveTab"] = "issues" | |
| 659 | + data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer) | |
| 660 | + data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name) | |
| 661 | + data["Issue"] = iss | |
| 662 | + data["Comments"] = comments | |
| 663 | + data["CanToggleIssue"] = canToggle | |
| 664 | + | |
| 665 | + return h.render(c, fiber.StatusOK, "issue_detail", data) | |
| 666 | +} | |
| 667 | + | |
| 668 | +func (h *Handler) RepoIssueCommentSubmit(c *fiber.Ctx) error { | |
| 669 | + r, _, err := h.loadAuthorizedRepo(c, false) | |
| 670 | + if err != nil { | |
| 671 | + return nil | |
| 672 | + } | |
| 673 | + if !r.EnableIssues { | |
| 674 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.issues_disabled") | |
| 675 | + } | |
| 676 | + | |
| 677 | + num, _ := strconv.Atoi(c.Params("number")) | |
| 678 | + iss, err := h.repoSvc.GetIssue(c.UserContext(), r.ID, num) | |
| 679 | + if err != nil { | |
| 680 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.issue_not_found", "err.issue_not_found_desc") | |
| 681 | + } | |
| 682 | + | |
| 683 | + u := middleware.CurrentUser(c) | |
| 684 | + body := c.FormValue("body") | |
| 685 | + if err := h.repoSvc.CreateIssueComment(c.UserContext(), iss.ID, u.ID, body); err != nil { | |
| 686 | + h.setFlashT(c, "flash.comment_error", err.Error()) | |
| 687 | + } | |
| 688 | + return c.Redirect(fmt.Sprintf("/%s/%s/issues/%d", r.OwnerUsername, r.Name, iss.Number), fiber.StatusSeeOther) | |
| 689 | +} | |
| 690 | + | |
| 691 | +func (h *Handler) RepoIssueStateSubmit(c *fiber.Ctx) error { | |
| 692 | + r, _, err := h.loadAuthorizedRepo(c, false) | |
| 693 | + if err != nil { | |
| 694 | + return nil | |
| 695 | + } | |
| 696 | + if !r.EnableIssues { | |
| 697 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.issues_disabled") | |
| 698 | + } | |
| 699 | + | |
| 700 | + num, _ := strconv.Atoi(c.Params("number")) | |
| 701 | + iss, err := h.repoSvc.GetIssue(c.UserContext(), r.ID, num) | |
| 702 | + if err != nil { | |
| 703 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.issue_not_found", "err.issue_not_found_desc") | |
| 704 | + } | |
| 705 | + | |
| 706 | + viewer := middleware.CurrentUser(c) | |
| 707 | + if viewer == nil || (viewer.ID != iss.AuthorID && !h.repoSvc.CanWrite(c.UserContext(), r, viewer)) { | |
| 708 | + return h.renderErrorT(c, fiber.StatusForbidden, "err.forbidden", "err.issue_state_forbidden") | |
| 709 | + } | |
| 710 | + | |
| 711 | + targetState := c.FormValue("state") | |
| 712 | + isClosed := targetState == "closed" | |
| 713 | + _ = h.repoSvc.SetIssueClosed(c.UserContext(), iss.ID, isClosed) | |
| 714 | + | |
| 715 | + if isClosed { | |
| 716 | + h.setFlashT(c, "flash.issue_closed", iss.Number) | |
| 717 | + } else { | |
| 718 | + h.setFlashT(c, "flash.issue_reopened", iss.Number) | |
| 719 | + } | |
| 720 | + return c.Redirect(fmt.Sprintf("/%s/%s/issues/%d", r.OwnerUsername, r.Name, iss.Number), fiber.StatusSeeOther) | |
| 721 | +} |
internal/web/handlers/reviews.go
+55
-0
| ... | ... | @@ -0,0 +1,55 @@ |
| 1 | +package handlers | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "fmt" | |
| 5 | + "strconv" | |
| 6 | + "strings" | |
| 7 | + | |
| 8 | + "github.com/gofiber/fiber/v2" | |
| 9 | + | |
| 10 | + "polar/internal/web/middleware" | |
| 11 | +) | |
| 12 | + | |
| 13 | +// RepoPullReviewSubmit records the reviewer's verdict (approve / request changes). | |
| 14 | +func (h *Handler) RepoPullReviewSubmit(c *fiber.Ctx) error { | |
| 15 | + r, _, err := h.loadAuthorizedRepo(c, false) | |
| 16 | + if err != nil { | |
| 17 | + return nil | |
| 18 | + } | |
| 19 | + if !r.EnablePulls { | |
| 20 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.pulls_disabled") | |
| 21 | + } | |
| 22 | + | |
| 23 | + num, _ := strconv.Atoi(c.Params("number")) | |
| 24 | + pr, err := h.repoSvc.GetPullRequest(c.UserContext(), r.ID, num) | |
| 25 | + if err != nil { | |
| 26 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.pr_not_found", "err.pr_not_found_desc") | |
| 27 | + } | |
| 28 | + | |
| 29 | + u := middleware.CurrentUser(c) | |
| 30 | + redirect := fmt.Sprintf("/%s/%s/pulls/%d", r.OwnerUsername, r.Name, pr.Number) | |
| 31 | + | |
| 32 | + if u == nil || u.ID == pr.AuthorID { | |
| 33 | + h.setFlashT(c, "flash.review_self") | |
| 34 | + return c.Redirect(redirect, fiber.StatusSeeOther) | |
| 35 | + } | |
| 36 | + if pr.Status != "open" { | |
| 37 | + h.setFlashT(c, "flash.review_closed") | |
| 38 | + return c.Redirect(redirect, fiber.StatusSeeOther) | |
| 39 | + } | |
| 40 | + | |
| 41 | + state := strings.TrimSpace(c.FormValue("state")) | |
| 42 | + body := strings.TrimSpace(c.FormValue("body")) | |
| 43 | + | |
| 44 | + if err := h.repoSvc.UpsertReview(c.UserContext(), pr.ID, u.ID, state, body); err != nil { | |
| 45 | + h.setFlashT(c, "flash.review_failed", err.Error()) | |
| 46 | + return c.Redirect(redirect, fiber.StatusSeeOther) | |
| 47 | + } | |
| 48 | + | |
| 49 | + if state == "approved" { | |
| 50 | + h.setFlashT(c, "flash.review_approved") | |
| 51 | + } else { | |
| 52 | + h.setFlashT(c, "flash.review_changes_requested") | |
| 53 | + } | |
| 54 | + return c.Redirect(redirect, fiber.StatusSeeOther) | |
| 55 | +} |
internal/web/handlers/social_test.go
+322
-0
| ... | ... | @@ -0,0 +1,322 @@ |
| 1 | +package handlers | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "encoding/json" | |
| 6 | + "io" | |
| 7 | + "net/http" | |
| 8 | + "net/http/httptest" | |
| 9 | + "os" | |
| 10 | + "os/exec" | |
| 11 | + "path/filepath" | |
| 12 | + "strings" | |
| 13 | + "testing" | |
| 14 | + | |
| 15 | + "polar/internal/auth" | |
| 16 | + "polar/internal/repo" | |
| 17 | +) | |
| 18 | + | |
| 19 | +func postForm(t *testing.T, app interface { | |
| 20 | + Test(*http.Request, ...int) (*http.Response, error) | |
| 21 | +}, path, body string) *http.Response { | |
| 22 | + t.Helper() | |
| 23 | + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) | |
| 24 | + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") | |
| 25 | + resp, err := app.Test(req, 5000) | |
| 26 | + if err != nil { | |
| 27 | + t.Fatalf("POST %s: %v", path, err) | |
| 28 | + } | |
| 29 | + return resp | |
| 30 | +} | |
| 31 | + | |
| 32 | +func getPage(t *testing.T, app interface { | |
| 33 | + Test(*http.Request, ...int) (*http.Response, error) | |
| 34 | +}, path string) (int, string) { | |
| 35 | + t.Helper() | |
| 36 | + resp, err := app.Test(httptest.NewRequest(http.MethodGet, path, nil), 5000) | |
| 37 | + if err != nil { | |
| 38 | + t.Fatalf("GET %s: %v", path, err) | |
| 39 | + } | |
| 40 | + defer resp.Body.Close() | |
| 41 | + b, _ := io.ReadAll(resp.Body) | |
| 42 | + return resp.StatusCode, string(b) | |
| 43 | +} | |
| 44 | + | |
| 45 | +// TestDeviceFlowAPI walks through the full RFC 8628 exchange. | |
| 46 | +func TestDeviceFlowAPI(t *testing.T) { | |
| 47 | + app, authSvc, userSvc, _ := setupTestApp(t) | |
| 48 | + ctx := context.Background() | |
| 49 | + | |
| 50 | + u, err := userSvc.Register(ctx, "dev", "dev@example.com", "password123", true) | |
| 51 | + if err != nil { | |
| 52 | + t.Fatalf("register: %v", err) | |
| 53 | + } | |
| 54 | + | |
| 55 | + resp := postForm(t, app, "/api/v1/device/code", "client_id=git-credential-polar") | |
| 56 | + if resp.StatusCode != http.StatusOK { | |
| 57 | + t.Fatalf("device/code status %d", resp.StatusCode) | |
| 58 | + } | |
| 59 | + var code struct { | |
| 60 | + DeviceCode string `json:"device_code"` | |
| 61 | + UserCode string `json:"user_code"` | |
| 62 | + VerificationURI string `json:"verification_uri"` | |
| 63 | + Interval int `json:"interval"` | |
| 64 | + ExpiresIn int `json:"expires_in"` | |
| 65 | + } | |
| 66 | + if err := json.NewDecoder(resp.Body).Decode(&code); err != nil { | |
| 67 | + t.Fatalf("decode device/code: %v", err) | |
| 68 | + } | |
| 69 | + if code.DeviceCode == "" || code.UserCode == "" || code.Interval <= 0 || code.ExpiresIn <= 0 { | |
| 70 | + t.Fatalf("incomplete device code response: %+v", code) | |
| 71 | + } | |
| 72 | + if !strings.Contains(code.VerificationURI, "/login/device") { | |
| 73 | + t.Fatalf("unexpected verification uri %q", code.VerificationURI) | |
| 74 | + } | |
| 75 | + | |
| 76 | + // Pending exchange. | |
| 77 | + resp = postForm(t, app, "/api/v1/device/token", "device_code="+code.DeviceCode) | |
| 78 | + if resp.StatusCode != http.StatusTooManyRequests { | |
| 79 | + t.Fatalf("expected 429 while pending, got %d", resp.StatusCode) | |
| 80 | + } | |
| 81 | + pendingBody, _ := io.ReadAll(resp.Body) | |
| 82 | + if !strings.Contains(string(pendingBody), "authorization_pending") { | |
| 83 | + t.Fatalf("expected authorization_pending, got %s", string(pendingBody)) | |
| 84 | + } | |
| 85 | + | |
| 86 | + // The verification page shows the pending grant. | |
| 87 | + status, page := getPage(t, app, "/login/device?user_code="+code.UserCode) | |
| 88 | + if status != http.StatusOK { | |
| 89 | + t.Fatalf("device page status %d", status) | |
| 90 | + } | |
| 91 | + if !strings.Contains(page, code.UserCode) { | |
| 92 | + t.Fatalf("device page missing user code") | |
| 93 | + } | |
| 94 | + | |
| 95 | + // Approve and exchange. | |
| 96 | + if err := authSvc.SetDeviceCodeStatus(ctx, code.UserCode, u.ID, auth.DeviceStatusApproved); err != nil { | |
| 97 | + t.Fatalf("approve: %v", err) | |
| 98 | + } | |
| 99 | + resp = postForm(t, app, "/api/v1/device/token", "device_code="+code.DeviceCode) | |
| 100 | + if resp.StatusCode != http.StatusOK { | |
| 101 | + body, _ := io.ReadAll(resp.Body) | |
| 102 | + t.Fatalf("token exchange status %d: %s", resp.StatusCode, string(body)) | |
| 103 | + } | |
| 104 | + var token struct { | |
| 105 | + AccessToken string `json:"access_token"` | |
| 106 | + TokenType string `json:"token_type"` | |
| 107 | + Username string `json:"username"` | |
| 108 | + } | |
| 109 | + if err := json.NewDecoder(resp.Body).Decode(&token); err != nil { | |
| 110 | + t.Fatalf("decode token: %v", err) | |
| 111 | + } | |
| 112 | + if !strings.HasPrefix(token.AccessToken, "polar_") || token.Username != "dev" { | |
| 113 | + t.Fatalf("unexpected token payload: %+v", token) | |
| 114 | + } | |
| 115 | + | |
| 116 | + userID, err := authSvc.VerifyToken(ctx, token.AccessToken) | |
| 117 | + if err != nil || userID != u.ID { | |
| 118 | + t.Fatalf("issued token does not verify: id=%d err=%v", userID, err) | |
| 119 | + } | |
| 120 | + | |
| 121 | + // The device code is single use. | |
| 122 | + resp = postForm(t, app, "/api/v1/device/token", "device_code="+code.DeviceCode) | |
| 123 | + if resp.StatusCode == http.StatusOK { | |
| 124 | + t.Fatalf("device code must not be exchangeable twice") | |
| 125 | + } | |
| 126 | +} | |
| 127 | + | |
| 128 | +func gitRun(t *testing.T, dir string, args ...string) { | |
| 129 | + t.Helper() | |
| 130 | + cmd := exec.CommandContext(context.Background(), "git", args...) | |
| 131 | + cmd.Dir = dir | |
| 132 | + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") | |
| 133 | + if out, err := cmd.CombinedOutput(); err != nil { | |
| 134 | + t.Fatalf("git %v: %v (%s)", args, err, string(out)) | |
| 135 | + } | |
| 136 | +} | |
| 137 | + | |
| 138 | +// pushCommit seeds the bare repository with a single commit on the given branch. | |
| 139 | +func pushCommit(t *testing.T, barePath, branch, filename, content string) { | |
| 140 | + t.Helper() | |
| 141 | + work := t.TempDir() | |
| 142 | + gitRun(t, work, "init", "-b", "main") | |
| 143 | + gitRun(t, work, "config", "user.name", "Tester") | |
| 144 | + gitRun(t, work, "config", "user.email", "tester@example.com") | |
| 145 | + if err := os.WriteFile(filepath.Join(work, filename), []byte(content), 0o644); err != nil { | |
| 146 | + t.Fatalf("write %s: %v", filename, err) | |
| 147 | + } | |
| 148 | + gitRun(t, work, "add", ".") | |
| 149 | + gitRun(t, work, "commit", "-m", "add "+filename) | |
| 150 | + pushRef := "HEAD:refs/heads/" + branch | |
| 151 | + gitRun(t, work, "push", barePath, pushRef) | |
| 152 | +} | |
| 153 | + | |
| 154 | +// pushBranchFromClone adds a branch with a new commit on top of the cloned | |
| 155 | +// history of the given bare repository. | |
| 156 | +func pushBranchFromClone(t *testing.T, barePath, branch, filename, content string) { | |
| 157 | + t.Helper() | |
| 158 | + work := filepath.Join(t.TempDir(), "clone") | |
| 159 | + gitRun(t, t.TempDir(), "clone", barePath, work) | |
| 160 | + gitRun(t, work, "config", "user.name", "Tester") | |
| 161 | + gitRun(t, work, "config", "user.email", "tester@example.com") | |
| 162 | + gitRun(t, work, "checkout", "-b", branch) | |
| 163 | + if err := os.WriteFile(filepath.Join(work, filename), []byte(content), 0o644); err != nil { | |
| 164 | + t.Fatalf("write %s: %v", filename, err) | |
| 165 | + } | |
| 166 | + gitRun(t, work, "add", ".") | |
| 167 | + gitRun(t, work, "commit", "-m", "add "+filename) | |
| 168 | + gitRun(t, work, "push", "origin", branch) | |
| 169 | +} | |
| 170 | + | |
| 171 | +// TestForksCrossRepoPRAndReviews exercises the social features end to end. | |
| 172 | +func TestForksCrossRepoPRAndReviews(t *testing.T) { | |
| 173 | + app, _, userSvc, repoSvc := setupTestApp(t) | |
| 174 | + ctx := context.Background() | |
| 175 | + | |
| 176 | + alice, err := userSvc.Register(ctx, "alice", "alice@example.com", "strongpassword", true) | |
| 177 | + if err != nil { | |
| 178 | + t.Fatalf("register alice: %v", err) | |
| 179 | + } | |
| 180 | + bob, err := userSvc.Register(ctx, "bob", "bob@example.com", "strongpassword", true) | |
| 181 | + if err != nil { | |
| 182 | + t.Fatalf("register bob: %v", err) | |
| 183 | + } | |
| 184 | + | |
| 185 | + r, err := repoSvc.Create(ctx, alice, "engine", "Analytical Engine", false) | |
| 186 | + if err != nil { | |
| 187 | + t.Fatalf("create repo: %v", err) | |
| 188 | + } | |
| 189 | + basePath, _ := repoSvc.DiskPath("alice", "engine") | |
| 190 | + pushCommit(t, basePath, "main", "file.txt", "base\n") | |
| 191 | + | |
| 192 | + // Fork and verify both the record and the on-disk copy. | |
| 193 | + fork, err := repoSvc.Fork(ctx, r, bob, "") | |
| 194 | + if err != nil { | |
| 195 | + t.Fatalf("fork: %v", err) | |
| 196 | + } | |
| 197 | + if fork.ForkedFromRepoID == nil || *fork.ForkedFromRepoID != r.ID { | |
| 198 | + t.Fatalf("fork missing origin link") | |
| 199 | + } | |
| 200 | + if fork.ForkedFromOwner != "alice" || fork.ForkedFromName != "engine" { | |
| 201 | + t.Fatalf("fork origin metadata wrong: %+v", fork) | |
| 202 | + } | |
| 203 | + forkPath, _ := repoSvc.DiskPath("bob", "engine") | |
| 204 | + if _, err := os.Stat(filepath.Join(forkPath, "HEAD")); err != nil { | |
| 205 | + t.Fatalf("fork bare repository missing on disk: %v", err) | |
| 206 | + } | |
| 207 | + | |
| 208 | + found, err := repoSvc.FindForkByOwner(ctx, r.ID, bob.ID) | |
| 209 | + if err != nil || found == nil || found.ID != fork.ID { | |
| 210 | + t.Fatalf("FindForkByOwner failed: %+v err=%v", found, err) | |
| 211 | + } | |
| 212 | + forks, err := repoSvc.ListForks(ctx, r.ID, 10) | |
| 213 | + if err != nil || len(forks) != 1 { | |
| 214 | + t.Fatalf("ListForks expected 1, got %d err=%v", len(forks), err) | |
| 215 | + } | |
| 216 | + | |
| 217 | + // Repository pages expose the fork relationship. | |
| 218 | + status, page := getPage(t, app, "/bob/engine") | |
| 219 | + if status != http.StatusOK || !strings.Contains(page, "/alice/engine") { | |
| 220 | + t.Fatalf("fork page should link to the origin repository (status %d)", status) | |
| 221 | + } | |
| 222 | + status, page = getPage(t, app, "/alice/engine/forks") | |
| 223 | + if status != http.StatusOK || !strings.Contains(page, "bob/engine") { | |
| 224 | + t.Fatalf("forks page should list bob/engine (status %d)", status) | |
| 225 | + } | |
| 226 | + | |
| 227 | + // Bob pushes a feature branch into his fork and opens a cross-fork PR. | |
| 228 | + pushBranchFromClone(t, forkPath, "feature", "feature.txt", "feature") | |
| 229 | + | |
| 230 | + pr, err := repoSvc.CreatePullRequest(ctx, r.ID, bob.ID, "Add feature", "Implements feature", "main", "feature", &fork.ID) | |
| 231 | + if err != nil { | |
| 232 | + t.Fatalf("create cross-fork PR: %v", err) | |
| 233 | + } | |
| 234 | + if pr.HeadRepoID == nil || *pr.HeadRepoID != fork.ID || pr.HeadRepoOwner != "bob" { | |
| 235 | + t.Fatalf("cross-fork PR metadata wrong: %+v", pr) | |
| 236 | + } | |
| 237 | + | |
| 238 | + status, page = getPage(t, app, "/alice/engine/pulls/1") | |
| 239 | + if status != http.StatusOK { | |
| 240 | + t.Fatalf("pull detail status %d", status) | |
| 241 | + } | |
| 242 | + if !strings.Contains(page, "bob/engine:feature") { | |
| 243 | + t.Fatalf("pull detail missing cross-fork head label") | |
| 244 | + } | |
| 245 | + if !strings.Contains(page, "Add feature") { | |
| 246 | + t.Fatalf("pull detail missing title") | |
| 247 | + } | |
| 248 | + | |
| 249 | + // Reviews: alice approves, the verdict shows up on the page. | |
| 250 | + if err := repoSvc.UpsertReview(ctx, pr.ID, alice.ID, repo.ReviewApproved, "looks good"); err != nil { | |
| 251 | + t.Fatalf("upsert review: %v", err) | |
| 252 | + } | |
| 253 | + reviews, err := repoSvc.ListReviews(ctx, pr.ID) | |
| 254 | + if err != nil || len(reviews) != 1 || reviews[0].State != repo.ReviewApproved { | |
| 255 | + t.Fatalf("ListReviews unexpected: %+v err=%v", reviews, err) | |
| 256 | + } | |
| 257 | + approved, changesRequested := repoSvc.ReviewCounts(ctx, pr.ID) | |
| 258 | + if approved != 1 || changesRequested != 0 { | |
| 259 | + t.Fatalf("ReviewCounts = %d/%d", approved, changesRequested) | |
| 260 | + } | |
| 261 | + // Changing the verdict updates the same review row. | |
| 262 | + if err := repoSvc.UpsertReview(ctx, pr.ID, alice.ID, repo.ReviewChangesRequested, "needs tests"); err != nil { | |
| 263 | + t.Fatalf("update review: %v", err) | |
| 264 | + } | |
| 265 | + reviews, _ = repoSvc.ListReviews(ctx, pr.ID) | |
| 266 | + if len(reviews) != 1 || reviews[0].State != repo.ReviewChangesRequested || reviews[0].Body != "needs tests" { | |
| 267 | + t.Fatalf("review update failed: %+v", reviews) | |
| 268 | + } | |
| 269 | + status, page = getPage(t, app, "/alice/engine/pulls/1") | |
| 270 | + if status != http.StatusOK || !strings.Contains(page, "needs tests") { | |
| 271 | + t.Fatalf("pull page should render reviews") | |
| 272 | + } | |
| 273 | + | |
| 274 | + // Cross-fork merge produces a merge commit on the base repository. | |
| 275 | + if err := repoSvc.MergePullRequest(ctx, r, pr, "Alice", "alice@example.com", alice.ID); err != nil { | |
| 276 | + t.Fatalf("cross-fork merge: %v", err) | |
| 277 | + } | |
| 278 | + merged, err := repoSvc.GetPullRequest(ctx, r.ID, 1) | |
| 279 | + if err != nil || merged.Status != "merged" { | |
| 280 | + t.Fatalf("expected merged PR, got %+v err=%v", merged, err) | |
| 281 | + } | |
| 282 | + | |
| 283 | + // Collaborators on a private repository. | |
| 284 | + priv, err := repoSvc.Create(ctx, alice, "vault", "Secret", true) | |
| 285 | + if err != nil { | |
| 286 | + t.Fatalf("create private repo: %v", err) | |
| 287 | + } | |
| 288 | + if repoSvc.CanRead(ctx, priv, bob) { | |
| 289 | + t.Fatalf("bob must not read the private repo before being added") | |
| 290 | + } | |
| 291 | + if err := repoSvc.AddCollaborator(ctx, priv, "bob", repo.RoleRead); err != nil { | |
| 292 | + t.Fatalf("add collaborator: %v", err) | |
| 293 | + } | |
| 294 | + if !repoSvc.CanRead(ctx, priv, bob) { | |
| 295 | + t.Fatalf("bob should read the private repo with the read role") | |
| 296 | + } | |
| 297 | + if repoSvc.CanWrite(ctx, priv, bob) { | |
| 298 | + t.Fatalf("bob must not write with the read role") | |
| 299 | + } | |
| 300 | + if err := repoSvc.AddCollaborator(ctx, priv, "bob", repo.RoleWrite); err != nil { | |
| 301 | + t.Fatalf("upgrade collaborator: %v", err) | |
| 302 | + } | |
| 303 | + if !repoSvc.CanWrite(ctx, priv, bob) { | |
| 304 | + t.Fatalf("bob should write with the write role") | |
| 305 | + } | |
| 306 | + if err := repoSvc.AddCollaborator(ctx, priv, "alice", repo.RoleWrite); err != repo.ErrCannotAddOwner { | |
| 307 | + t.Fatalf("adding the owner should fail, got %v", err) | |
| 308 | + } | |
| 309 | + if err := repoSvc.AddCollaborator(ctx, priv, "ghost", repo.RoleRead); err != repo.ErrCollabUserNotFound { | |
| 310 | + t.Fatalf("unknown user should fail, got %v", err) | |
| 311 | + } | |
| 312 | + collabs, err := repoSvc.ListCollaborators(ctx, priv.ID) | |
| 313 | + if err != nil || len(collabs) != 1 || collabs[0].Username != "bob" || collabs[0].Role != repo.RoleWrite { | |
| 314 | + t.Fatalf("ListCollaborators unexpected: %+v err=%v", collabs, err) | |
| 315 | + } | |
| 316 | + if err := repoSvc.RemoveCollaborator(ctx, priv.ID, bob.ID); err != nil { | |
| 317 | + t.Fatalf("remove collaborator: %v", err) | |
| 318 | + } | |
| 319 | + if repoSvc.CanRead(ctx, priv, bob) { | |
| 320 | + t.Fatalf("bob must lose access after removal") | |
| 321 | + } | |
| 322 | +} |
internal/web/middleware/middleware.go
+223
-0
| ... | ... | @@ -0,0 +1,223 @@ |
| 1 | +package middleware | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "crypto/rand" | |
| 5 | + "crypto/subtle" | |
| 6 | + "encoding/hex" | |
| 7 | + "log/slog" | |
| 8 | + "strings" | |
| 9 | + "sync" | |
| 10 | + "time" | |
| 11 | + | |
| 12 | + "polar/internal/auth" | |
| 13 | + "polar/internal/i18n" | |
| 14 | + "polar/internal/user" | |
| 15 | + | |
| 16 | + "github.com/gofiber/fiber/v2" | |
| 17 | +) | |
| 18 | + | |
| 19 | +const ( | |
| 20 | + SessionCookieName = "polar_session" | |
| 21 | + CSRFCookieName = "polar_csrf" | |
| 22 | + ThemeCookieName = "polar_theme" | |
| 23 | + LangCookieName = "polar_lang" | |
| 24 | +) | |
| 25 | + | |
| 26 | +func SecurityHeaders() fiber.Handler { | |
| 27 | + return func(c *fiber.Ctx) error { | |
| 28 | + c.Set("X-Content-Type-Options", "nosniff") | |
| 29 | + c.Set("X-Frame-Options", "DENY") | |
| 30 | + c.Set("Referrer-Policy", "strict-origin-when-cross-origin") | |
| 31 | + c.Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'") | |
| 32 | + return c.Next() | |
| 33 | + } | |
| 34 | +} | |
| 35 | + | |
| 36 | +func RecoverAndLog(logger *slog.Logger) fiber.Handler { | |
| 37 | + return func(c *fiber.Ctx) (err error) { | |
| 38 | + start := time.Now() | |
| 39 | + defer func() { | |
| 40 | + if r := recover(); r != nil { | |
| 41 | + logger.Error("panic recovered", "panic", r, "path", c.Path()) | |
| 42 | + _ = c.Status(fiber.StatusInternalServerError).SendString("Internal Server Error") | |
| 43 | + } | |
| 44 | + }() | |
| 45 | + | |
| 46 | + err = c.Next() | |
| 47 | + dur := time.Since(start) | |
| 48 | + if !strings.HasPrefix(c.Path(), "/static/") && c.Path() != "/healthz" { | |
| 49 | + logger.Info("http request", | |
| 50 | + "method", c.Method(), | |
| 51 | + "path", c.Path(), | |
| 52 | + "status", c.Response().StatusCode(), | |
| 53 | + "duration_ms", dur.Milliseconds(), | |
| 54 | + ) | |
| 55 | + } | |
| 56 | + return err | |
| 57 | + } | |
| 58 | +} | |
| 59 | + | |
| 60 | +func LocaleAndTheme(defaultLang string) fiber.Handler { | |
| 61 | + return func(c *fiber.Ctx) error { | |
| 62 | + lang := c.Query("lang") | |
| 63 | + if lang == "ru" || lang == "en" { | |
| 64 | + c.Cookie(&fiber.Cookie{ | |
| 65 | + Name: LangCookieName, | |
| 66 | + Value: lang, | |
| 67 | + Path: "/", | |
| 68 | + MaxAge: 365 * 24 * 3600, | |
| 69 | + HTTPOnly: true, | |
| 70 | + SameSite: "Lax", | |
| 71 | + }) | |
| 72 | + } else { | |
| 73 | + lang = c.Cookies(LangCookieName, defaultLang) | |
| 74 | + } | |
| 75 | + c.Locals("lang", i18n.Normalize(lang)) | |
| 76 | + | |
| 77 | + theme := c.Cookies(ThemeCookieName, "system") | |
| 78 | + switch theme { | |
| 79 | + case "light", "dark", "system": | |
| 80 | + default: | |
| 81 | + theme = "system" | |
| 82 | + } | |
| 83 | + c.Locals("theme", theme) | |
| 84 | + return c.Next() | |
| 85 | + } | |
| 86 | +} | |
| 87 | + | |
| 88 | +func SessionAuth(authSvc *auth.Service, userSvc *user.Service) fiber.Handler { | |
| 89 | + return func(c *fiber.Ctx) error { | |
| 90 | + sessID := c.Cookies(SessionCookieName) | |
| 91 | + if sessID != "" { | |
| 92 | + if sess, err := authSvc.GetSession(c.UserContext(), sessID); err == nil && sess != nil { | |
| 93 | + if u, err := userSvc.GetByID(c.UserContext(), sess.UserID); err == nil && u != nil { | |
| 94 | + c.Locals("user", u) | |
| 95 | + c.Locals("session", sess) | |
| 96 | + c.Locals("csrf_token", sess.CSRFToken) | |
| 97 | + return c.Next() | |
| 98 | + } | |
| 99 | + } | |
| 100 | + } | |
| 101 | + | |
| 102 | + // Anonymous CSRF token via cookie | |
| 103 | + csrfCookie := c.Cookies(CSRFCookieName) | |
| 104 | + if len(csrfCookie) < 32 { | |
| 105 | + b := make([]byte, 24) | |
| 106 | + _, _ = rand.Read(b) | |
| 107 | + csrfCookie = hex.EncodeToString(b) | |
| 108 | + c.Cookie(&fiber.Cookie{ | |
| 109 | + Name: CSRFCookieName, | |
| 110 | + Value: csrfCookie, | |
| 111 | + Path: "/", | |
| 112 | + MaxAge: 86400, | |
| 113 | + HTTPOnly: true, | |
| 114 | + SameSite: "Lax", | |
| 115 | + }) | |
| 116 | + } | |
| 117 | + c.Locals("csrf_token", csrfCookie) | |
| 118 | + return c.Next() | |
| 119 | + } | |
| 120 | +} | |
| 121 | + | |
| 122 | +func CSRFProtection() fiber.Handler { | |
| 123 | + return func(c *fiber.Ctx) error { | |
| 124 | + method := c.Method() | |
| 125 | + if method == fiber.MethodGet || method == fiber.MethodHead || method == fiber.MethodOptions { | |
| 126 | + return c.Next() | |
| 127 | + } | |
| 128 | + | |
| 129 | + // Git Smart HTTP endpoints use Basic Auth + Token | |
| 130 | + p := c.Path() | |
| 131 | + if strings.HasSuffix(p, "/git-upload-pack") || strings.HasSuffix(p, "/git-receive-pack") { | |
| 132 | + return c.Next() | |
| 133 | + } | |
| 134 | + | |
| 135 | + // Device flow endpoints are unauthenticated machine-to-machine calls. | |
| 136 | + if strings.HasPrefix(p, "/api/v1/device/") { | |
| 137 | + return c.Next() | |
| 138 | + } | |
| 139 | + | |
| 140 | + expected, _ := c.Locals("csrf_token").(string) | |
| 141 | + provided := c.FormValue("_csrf") | |
| 142 | + if provided == "" { | |
| 143 | + provided = c.Get("X-CSRF-Token") | |
| 144 | + } | |
| 145 | + | |
| 146 | + if expected == "" || provided == "" || subtle.ConstantTimeCompare([]byte(expected), []byte(provided)) != 1 { | |
| 147 | + return c.Status(fiber.StatusForbidden).SendString("CSRF token mismatch") | |
| 148 | + } | |
| 149 | + return c.Next() | |
| 150 | + } | |
| 151 | +} | |
| 152 | + | |
| 153 | +func RequireAuth() fiber.Handler { | |
| 154 | + return func(c *fiber.Ctx) error { | |
| 155 | + if CurrentUser(c) == nil { | |
| 156 | + return c.Redirect("/login", fiber.StatusSeeOther) | |
| 157 | + } | |
| 158 | + return c.Next() | |
| 159 | + } | |
| 160 | +} | |
| 161 | + | |
| 162 | +func RequireAdmin() fiber.Handler { | |
| 163 | + return func(c *fiber.Ctx) error { | |
| 164 | + u := CurrentUser(c) | |
| 165 | + if u == nil { | |
| 166 | + return c.Redirect("/login", fiber.StatusSeeOther) | |
| 167 | + } | |
| 168 | + if !u.IsAdmin { | |
| 169 | + return c.Status(fiber.StatusForbidden).SendString("Forbidden: administrator access required") | |
| 170 | + } | |
| 171 | + return c.Next() | |
| 172 | + } | |
| 173 | +} | |
| 174 | + | |
| 175 | +func CurrentUser(c *fiber.Ctx) *user.User { | |
| 176 | + u, _ := c.Locals("user").(*user.User) | |
| 177 | + return u | |
| 178 | +} | |
| 179 | + | |
| 180 | +type rateBucket struct { | |
| 181 | + count int | |
| 182 | + resetAt time.Time | |
| 183 | +} | |
| 184 | + | |
| 185 | +type RateLimiter struct { | |
| 186 | + mu sync.Mutex | |
| 187 | + buckets map[string]*rateBucket | |
| 188 | + limit int | |
| 189 | + window time.Duration | |
| 190 | +} | |
| 191 | + | |
| 192 | +func NewRateLimiter(limit int, window time.Duration) *RateLimiter { | |
| 193 | + return &RateLimiter{ | |
| 194 | + buckets: make(map[string]*rateBucket), | |
| 195 | + limit: limit, | |
| 196 | + window: window, | |
| 197 | + } | |
| 198 | +} | |
| 199 | + | |
| 200 | +func (rl *RateLimiter) Handler() fiber.Handler { | |
| 201 | + return func(c *fiber.Ctx) error { | |
| 202 | + ip := c.IP() | |
| 203 | + if ip == "" { | |
| 204 | + ip = "local" | |
| 205 | + } | |
| 206 | + now := time.Now() | |
| 207 | + | |
| 208 | + rl.mu.Lock() | |
| 209 | + b, exists := rl.buckets[ip] | |
| 210 | + if !exists || now.After(b.resetAt) { | |
| 211 | + b = &rateBucket{count: 0, resetAt: now.Add(rl.window)} | |
| 212 | + rl.buckets[ip] = b | |
| 213 | + } | |
| 214 | + b.count++ | |
| 215 | + over := b.count > rl.limit | |
| 216 | + rl.mu.Unlock() | |
| 217 | + | |
| 218 | + if over { | |
| 219 | + return c.Status(fiber.StatusTooManyRequests).SendString("Too many requests, please try again in a minute.") | |
| 220 | + } | |
| 221 | + return c.Next() | |
| 222 | + } | |
| 223 | +} |
internal/web/static/css/app.css
+1367
-0
| ... | ... | @@ -0,0 +1,1367 @@ |
| 1 | +/* Polar Design System — Minimalist Sidebar Layout, 3 Themes (light, dark, system) */ | |
| 2 | + | |
| 3 | +@font-face { | |
| 4 | + font-family: 'Inter'; | |
| 5 | + font-style: normal; | |
| 6 | + font-weight: 400 600; | |
| 7 | + font-display: swap; | |
| 8 | + src: url('/static/fonts/Inter-Regular.woff2') format('woff2'); | |
| 9 | +} | |
| 10 | + | |
| 11 | +@font-face { | |
| 12 | + font-family: 'Inter'; | |
| 13 | + font-style: normal; | |
| 14 | + font-weight: 400 600; | |
| 15 | + font-display: swap; | |
| 16 | + src: url('/static/fonts/Inter-Cyrillic.woff2') format('woff2'); | |
| 17 | + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; | |
| 18 | +} | |
| 19 | + | |
| 20 | +@font-face { | |
| 21 | + font-family: 'JetBrains Mono'; | |
| 22 | + font-style: normal; | |
| 23 | + font-weight: 400 600; | |
| 24 | + font-display: swap; | |
| 25 | + src: url('/static/fonts/JetBrainsMono-Regular.woff2') format('woff2'); | |
| 26 | +} | |
| 27 | + | |
| 28 | +:root, | |
| 29 | +html[data-theme="light"] { | |
| 30 | + --background: 0 0% 100%; | |
| 31 | + --foreground: 0 0% 6.7%; | |
| 32 | + --card: 0 0% 98%; | |
| 33 | + --card-foreground: 0 0% 6.7%; | |
| 34 | + --popover: 0 0% 100%; | |
| 35 | + --popover-foreground: 0 0% 6.7%; | |
| 36 | + --primary: 0 0% 6.7%; | |
| 37 | + --primary-foreground: 0 0% 100%; | |
| 38 | + --secondary: 0 0% 95%; | |
| 39 | + --secondary-foreground: 0 0% 6.7%; | |
| 40 | + --muted: 0 0% 95%; | |
| 41 | + --muted-foreground: 0 0% 45%; | |
| 42 | + --accent: 0 0% 93%; | |
| 43 | + --accent-foreground: 0 0% 6.7%; | |
| 44 | + --destructive: 0 72% 46%; | |
| 45 | + --destructive-foreground: 0 0% 100%; | |
| 46 | + --border: 0 0% 90%; | |
| 47 | + --input: 0 0% 89%; | |
| 48 | + --ring: 0 0% 6.7%; | |
| 49 | + --radius: 0.45rem; | |
| 50 | + | |
| 51 | + --diff-add-bg: 142 70% 94%; | |
| 52 | + --diff-add-fg: 142 76% 22%; | |
| 53 | + --diff-del-bg: 0 86% 95%; | |
| 54 | + --diff-del-fg: 0 72% 35%; | |
| 55 | + | |
| 56 | + --syntax-kw: 262 72% 42%; | |
| 57 | + --syntax-str: 142 68% 28%; | |
| 58 | + --syntax-cmt: 0 0% 46%; | |
| 59 | + --syntax-fn: 215 80% 42%; | |
| 60 | + --syntax-num: 24 85% 38%; | |
| 61 | + --syntax-type: 190 78% 32%; | |
| 62 | + --syntax-op: 0 0% 25%; | |
| 63 | + color-scheme: light; | |
| 64 | +} | |
| 65 | + | |
| 66 | +html[data-theme="dark"] { | |
| 67 | + --background: 0 0% 6.7%; | |
| 68 | + --foreground: 0 0% 100%; | |
| 69 | + --card: 0 0% 9%; | |
| 70 | + --card-foreground: 0 0% 100%; | |
| 71 | + --popover: 0 0% 9.5%; | |
| 72 | + --popover-foreground: 0 0% 100%; | |
| 73 | + --primary: 0 0% 100%; | |
| 74 | + --primary-foreground: 0 0% 6.7%; | |
| 75 | + --secondary: 0 0% 13%; | |
| 76 | + --secondary-foreground: 0 0% 100%; | |
| 77 | + --muted: 0 0% 12%; | |
| 78 | + --muted-foreground: 0 0% 65%; | |
| 79 | + --accent: 0 0% 15%; | |
| 80 | + --accent-foreground: 0 0% 100%; | |
| 81 | + --destructive: 0 72% 58%; | |
| 82 | + --destructive-foreground: 0 0% 98%; | |
| 83 | + --border: 0 0% 16%; | |
| 84 | + --input: 0 0% 17%; | |
| 85 | + --ring: 0 0% 85%; | |
| 86 | + | |
| 87 | + --diff-add-bg: 142 50% 12%; | |
| 88 | + --diff-add-fg: 142 70% 75%; | |
| 89 | + --diff-del-bg: 0 55% 14%; | |
| 90 | + --diff-del-fg: 0 80% 78%; | |
| 91 | + | |
| 92 | + --syntax-kw: 265 85% 78%; | |
| 93 | + --syntax-str: 142 65% 70%; | |
| 94 | + --syntax-cmt: 0 0% 54%; | |
| 95 | + --syntax-fn: 210 90% 74%; | |
| 96 | + --syntax-num: 32 90% 72%; | |
| 97 | + --syntax-type: 188 80% 68%; | |
| 98 | + --syntax-op: 0 0% 78%; | |
| 99 | + color-scheme: dark; | |
| 100 | +} | |
| 101 | + | |
| 102 | +@media (prefers-color-scheme: dark) { | |
| 103 | + html[data-theme="system"] { | |
| 104 | + --background: 0 0% 6.7%; | |
| 105 | + --foreground: 0 0% 100%; | |
| 106 | + --card: 0 0% 9%; | |
| 107 | + --card-foreground: 0 0% 100%; | |
| 108 | + --popover: 0 0% 9.5%; | |
| 109 | + --popover-foreground: 0 0% 100%; | |
| 110 | + --primary: 0 0% 100%; | |
| 111 | + --primary-foreground: 0 0% 6.7%; | |
| 112 | + --secondary: 0 0% 13%; | |
| 113 | + --secondary-foreground: 0 0% 100%; | |
| 114 | + --muted: 0 0% 12%; | |
| 115 | + --muted-foreground: 0 0% 65%; | |
| 116 | + --accent: 0 0% 15%; | |
| 117 | + --accent-foreground: 0 0% 100%; | |
| 118 | + --destructive: 0 72% 58%; | |
| 119 | + --destructive-foreground: 0 0% 98%; | |
| 120 | + --border: 0 0% 16%; | |
| 121 | + --input: 0 0% 17%; | |
| 122 | + --ring: 0 0% 85%; | |
| 123 | + --diff-add-bg: 142 50% 12%; | |
| 124 | + --diff-add-fg: 142 70% 75%; | |
| 125 | + --diff-del-bg: 0 55% 14%; | |
| 126 | + --diff-del-fg: 0 80% 78%; | |
| 127 | + --syntax-kw: 265 85% 78%; | |
| 128 | + --syntax-str: 142 65% 70%; | |
| 129 | + --syntax-cmt: 0 0% 54%; | |
| 130 | + --syntax-fn: 210 90% 74%; | |
| 131 | + --syntax-num: 32 90% 72%; | |
| 132 | + --syntax-type: 188 80% 68%; | |
| 133 | + --syntax-op: 0 0% 78%; | |
| 134 | + color-scheme: dark; | |
| 135 | + } | |
| 136 | +} | |
| 137 | + | |
| 138 | +*, *::before, *::after { | |
| 139 | + box-sizing: border-box; | |
| 140 | + border-color: hsl(var(--border)); | |
| 141 | +} | |
| 142 | + | |
| 143 | +html { | |
| 144 | + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | |
| 145 | + font-size: 13.5px; | |
| 146 | + line-height: 1.5; | |
| 147 | + -webkit-font-smoothing: antialiased; | |
| 148 | + background-color: hsl(var(--background)); | |
| 149 | + color: hsl(var(--foreground)); | |
| 150 | + overflow-y: scroll; | |
| 151 | +} | |
| 152 | + | |
| 153 | +body { | |
| 154 | + margin: 0; | |
| 155 | + min-height: 100vh; | |
| 156 | + background-color: hsl(var(--background)); | |
| 157 | + color: hsl(var(--foreground)); | |
| 158 | +} | |
| 159 | + | |
| 160 | +code, pre, kbd, .font-mono { | |
| 161 | + font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; | |
| 162 | +} | |
| 163 | + | |
| 164 | +a { | |
| 165 | + color: inherit; | |
| 166 | + text-decoration: none; | |
| 167 | +} | |
| 168 | + | |
| 169 | +@media (prefers-reduced-motion: reduce) { | |
| 170 | + *, *::before, *::after { | |
| 171 | + animation-duration: 0.01ms !important; | |
| 172 | + transition-duration: 0.01ms !important; | |
| 173 | + } | |
| 174 | +} | |
| 175 | + | |
| 176 | +:focus-visible { | |
| 177 | + outline: 2px solid hsl(var(--ring)); | |
| 178 | + outline-offset: 2px; | |
| 179 | +} | |
| 180 | + | |
| 181 | +/* App Shell: Left Sidebar + Right Main Content */ | |
| 182 | +.app-shell { | |
| 183 | + display: flex; | |
| 184 | + flex-direction: row; | |
| 185 | + align-items: stretch; | |
| 186 | + min-height: 100vh; | |
| 187 | + width: 100%; | |
| 188 | +} | |
| 189 | + | |
| 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)); | |
| 196 | + background-color: hsl(var(--card)); | |
| 197 | + display: none; | |
| 198 | + flex-direction: column; | |
| 199 | + justify-content: space-between; | |
| 200 | + position: sticky; | |
| 201 | + top: 0; | |
| 202 | + height: 100vh; | |
| 203 | + overflow-y: auto; | |
| 204 | + overflow-x: hidden; | |
| 205 | + padding: 0.85rem 0.75rem; | |
| 206 | + user-select: none; | |
| 207 | + transition: | |
| 208 | + width 200ms cubic-bezier(0.16, 1, 0.3, 1), | |
| 209 | + min-width 200ms cubic-bezier(0.16, 1, 0.3, 1), | |
| 210 | + max-width 200ms cubic-bezier(0.16, 1, 0.3, 1), | |
| 211 | + flex-basis 200ms cubic-bezier(0.16, 1, 0.3, 1), | |
| 212 | + padding 200ms cubic-bezier(0.16, 1, 0.3, 1), | |
| 213 | + border-color 200ms cubic-bezier(0.16, 1, 0.3, 1); | |
| 214 | +} | |
| 215 | + | |
| 216 | +.sidebar-inner { | |
| 217 | + width: 218px; | |
| 218 | + min-width: 218px; | |
| 219 | + height: 100%; | |
| 220 | + display: flex; | |
| 221 | + flex-direction: column; | |
| 222 | + justify-content: space-between; | |
| 223 | + transition: | |
| 224 | + opacity 150ms cubic-bezier(0.16, 1, 0.3, 1), | |
| 225 | + transform 200ms cubic-bezier(0.16, 1, 0.3, 1); | |
| 226 | +} | |
| 227 | + | |
| 228 | +html[data-sidebar="collapsed"] .app-sidebar { | |
| 229 | + width: 0; | |
| 230 | + min-width: 0; | |
| 231 | + max-width: 0; | |
| 232 | + flex-basis: 0; | |
| 233 | + padding-left: 0; | |
| 234 | + padding-right: 0; | |
| 235 | + border-right-color: transparent; | |
| 236 | +} | |
| 237 | + | |
| 238 | +html[data-sidebar="collapsed"] .sidebar-inner { | |
| 239 | + opacity: 0; | |
| 240 | + transform: translateX(-8px); | |
| 241 | + pointer-events: none; | |
| 242 | +} | |
| 243 | + | |
| 244 | +.sidebar-expand-btn { | |
| 245 | + position: fixed; | |
| 246 | + top: 0.85rem; | |
| 247 | + left: 0.85rem; | |
| 248 | + z-index: 50; | |
| 249 | + display: none; | |
| 250 | + align-items: center; | |
| 251 | + justify-content: center; | |
| 252 | + width: 2rem; | |
| 253 | + height: 2rem; | |
| 254 | + border-radius: var(--radius); | |
| 255 | + border: 1px solid hsl(var(--border)); | |
| 256 | + background-color: hsl(var(--card)); | |
| 257 | + color: hsl(var(--muted-foreground)); | |
| 258 | + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); | |
| 259 | + cursor: pointer; | |
| 260 | + opacity: 0; | |
| 261 | + transform: translateX(-6px) scale(0.95); | |
| 262 | + pointer-events: none; | |
| 263 | + transition: | |
| 264 | + opacity 160ms cubic-bezier(0.16, 1, 0.3, 1), | |
| 265 | + transform 160ms cubic-bezier(0.16, 1, 0.3, 1), | |
| 266 | + background-color 120ms ease, | |
| 267 | + color 120ms ease; | |
| 268 | +} | |
| 269 | + | |
| 270 | +.sidebar-expand-btn:hover { | |
| 271 | + background-color: hsl(var(--accent)); | |
| 272 | + color: hsl(var(--foreground)); | |
| 273 | +} | |
| 274 | + | |
| 275 | +.mobile-topbar { | |
| 276 | + display: flex; | |
| 277 | + align-items: center; | |
| 278 | + justify-content: space-between; | |
| 279 | + height: 2.75rem; | |
| 280 | + padding: 0 0.85rem; | |
| 281 | + border-bottom: 1px solid hsl(var(--border)); | |
| 282 | + background-color: hsl(var(--card)); | |
| 283 | +} | |
| 284 | + | |
| 285 | +.app-col { | |
| 286 | + flex: 1 1 0%; | |
| 287 | + min-width: 0; | |
| 288 | + width: 100%; | |
| 289 | + display: flex; | |
| 290 | + flex-direction: column; | |
| 291 | + min-height: 100vh; | |
| 292 | +} | |
| 293 | + | |
| 294 | +.app-main { | |
| 295 | + flex: 1 1 auto; | |
| 296 | + width: 100%; | |
| 297 | + padding: 1.5rem 1.25rem 3rem; | |
| 298 | +} | |
| 299 | + | |
| 300 | +@keyframes pageFadeIn { | |
| 301 | + from { | |
| 302 | + opacity: 0; | |
| 303 | + transform: translateY(4px); | |
| 304 | + } | |
| 305 | + to { | |
| 306 | + opacity: 1; | |
| 307 | + transform: translateY(0); | |
| 308 | + } | |
| 309 | +} | |
| 310 | + | |
| 311 | +.app-main > .container, | |
| 312 | +.app-main > .container-narrow { | |
| 313 | + animation: pageFadeIn 160ms cubic-bezier(0.16, 1, 0.3, 1); | |
| 314 | +} | |
| 315 | + | |
| 316 | +@media (min-width: 640px) { | |
| 317 | + .app-sidebar { | |
| 318 | + display: flex; | |
| 319 | + } | |
| 320 | + .sidebar-expand-btn { | |
| 321 | + display: inline-flex; | |
| 322 | + } | |
| 323 | + html[data-sidebar="collapsed"] .sidebar-expand-btn { | |
| 324 | + opacity: 1; | |
| 325 | + transform: translateX(0) scale(1); | |
| 326 | + pointer-events: auto; | |
| 327 | + } | |
| 328 | + .mobile-topbar { | |
| 329 | + display: none; | |
| 330 | + } | |
| 331 | + .app-main { | |
| 332 | + padding: 2rem 2.5rem 3.5rem; | |
| 333 | + } | |
| 334 | +} | |
| 335 | + | |
| 336 | +/* Unified Container Width — identical across Code, Commits, and Settings */ | |
| 337 | +.container, | |
| 338 | +.container-narrow { | |
| 339 | + width: 100%; | |
| 340 | + max-width: 960px; | |
| 341 | + margin-left: auto; | |
| 342 | + margin-right: auto; | |
| 343 | +} | |
| 344 | + | |
| 345 | +/* Sidebar Navigation */ | |
| 346 | +.nav-item { | |
| 347 | + display: flex; | |
| 348 | + align-items: center; | |
| 349 | + justify-content: space-between; | |
| 350 | + gap: 0.5rem; | |
| 351 | + height: 1.95rem; | |
| 352 | + padding: 0 0.55rem; | |
| 353 | + border-radius: var(--radius); | |
| 354 | + font-size: 0.85rem; | |
| 355 | + color: hsl(var(--muted-foreground)); | |
| 356 | + transition: background-color 140ms ease, color 140ms ease, transform 120ms cubic-bezier(0.16, 1, 0.3, 1); | |
| 357 | + cursor: pointer; | |
| 358 | + border: 0; | |
| 359 | + background: transparent; | |
| 360 | + width: 100%; | |
| 361 | + text-align: left; | |
| 362 | + overflow: hidden; | |
| 363 | +} | |
| 364 | +.nav-item:hover, | |
| 365 | +.nav-item.active { | |
| 366 | + background-color: hsl(var(--accent)); | |
| 367 | + color: hsl(var(--foreground)); | |
| 368 | +} | |
| 369 | +.nav-item:active { | |
| 370 | + transform: scale(0.985); | |
| 371 | +} | |
| 372 | +.nav-section-label { | |
| 373 | + display: flex; | |
| 374 | + align-items: center; | |
| 375 | + justify-content: space-between; | |
| 376 | + padding: 0.45rem 0.55rem 0.25rem; | |
| 377 | + font-size: 0.7rem; | |
| 378 | + font-weight: 600; | |
| 379 | + text-transform: uppercase; | |
| 380 | + letter-spacing: 0.04em; | |
| 381 | + color: hsl(var(--muted-foreground)); | |
| 382 | +} | |
| 383 | + | |
| 384 | +/* Segmented Theme Control in Sidebar */ | |
| 385 | +.segmented { | |
| 386 | + display: grid; | |
| 387 | + grid-template-columns: repeat(3, minmax(0, 1fr)); | |
| 388 | + gap: 2px; | |
| 389 | + padding: 2px; | |
| 390 | + width: 100%; | |
| 391 | + background-color: hsl(var(--muted)); | |
| 392 | + border: 1px solid hsl(var(--border)); | |
| 393 | + border-radius: var(--radius); | |
| 394 | +} | |
| 395 | +.segmented-btn { | |
| 396 | + display: inline-flex; | |
| 397 | + align-items: center; | |
| 398 | + justify-content: center; | |
| 399 | + gap: 0.25rem; | |
| 400 | + height: 1.65rem; | |
| 401 | + padding: 0 0.2rem; | |
| 402 | + border: 0; | |
| 403 | + border-radius: calc(var(--radius) - 2px); | |
| 404 | + background: transparent; | |
| 405 | + color: hsl(var(--muted-foreground)); | |
| 406 | + font-size: 0.72rem; | |
| 407 | + font-weight: 500; | |
| 408 | + cursor: pointer; | |
| 409 | + overflow: hidden; | |
| 410 | + text-overflow: ellipsis; | |
| 411 | + white-space: nowrap; | |
| 412 | + transition: background-color 150ms cubic-bezier(0.16, 1, 0.3, 1), color 150ms ease, box-shadow 150ms ease, transform 120ms ease; | |
| 413 | +} | |
| 414 | +.segmented-btn:hover { | |
| 415 | + color: hsl(var(--foreground)); | |
| 416 | +} | |
| 417 | +.segmented-btn:active { | |
| 418 | + transform: scale(0.96); | |
| 419 | +} | |
| 420 | +.segmented-btn.active { | |
| 421 | + background-color: hsl(var(--background)); | |
| 422 | + color: hsl(var(--foreground)); | |
| 423 | + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); | |
| 424 | +} | |
| 425 | + | |
| 426 | +/* Icons */ | |
| 427 | +.icon { | |
| 428 | + display: inline-block; | |
| 429 | + vertical-align: middle; | |
| 430 | + flex-shrink: 0; | |
| 431 | + stroke: currentColor; | |
| 432 | +} | |
| 433 | +.size-3 { width: 0.75rem; height: 0.75rem; } | |
| 434 | +.size-3\.5 { width: 0.875rem; height: 0.875rem; } | |
| 435 | +.size-4 { width: 1rem; height: 1rem; } | |
| 436 | +.size-5 { width: 1.25rem; height: 1.25rem; } | |
| 437 | +.size-6 { width: 1.5rem; height: 1.5rem; } | |
| 438 | + | |
| 439 | +/* Buttons */ | |
| 440 | +.btn { | |
| 441 | + display: inline-flex; | |
| 442 | + align-items: center; | |
| 443 | + justify-content: center; | |
| 444 | + gap: 0.4rem; | |
| 445 | + white-space: nowrap; | |
| 446 | + border-radius: var(--radius); | |
| 447 | + font-size: 0.85rem; | |
| 448 | + font-weight: 500; | |
| 449 | + line-height: 1; | |
| 450 | + height: 1.95rem; | |
| 451 | + padding: 0 0.7rem; | |
| 452 | + border: 1px solid transparent; | |
| 453 | + cursor: pointer; | |
| 454 | + transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease, opacity 140ms ease, transform 120ms cubic-bezier(0.16, 1, 0.3, 1); | |
| 455 | + user-select: none; | |
| 456 | +} | |
| 457 | +.btn:active:not(:disabled) { | |
| 458 | + transform: scale(0.97); | |
| 459 | +} | |
| 460 | +.btn:disabled { | |
| 461 | + opacity: 0.5; | |
| 462 | + pointer-events: none; | |
| 463 | +} | |
| 464 | +.btn-sm { | |
| 465 | + height: 1.7rem; | |
| 466 | + padding: 0 0.5rem; | |
| 467 | + font-size: 0.78rem; | |
| 468 | +} | |
| 469 | +.btn-lg { | |
| 470 | + height: 2.3rem; | |
| 471 | + padding: 0 1rem; | |
| 472 | + font-size: 0.92rem; | |
| 473 | +} | |
| 474 | +.btn-icon { | |
| 475 | + width: 1.9rem; | |
| 476 | + height: 1.9rem; | |
| 477 | + padding: 0; | |
| 478 | +} | |
| 479 | +.btn-default, .btn-primary { | |
| 480 | + background-color: hsl(var(--primary)); | |
| 481 | + color: hsl(var(--primary-foreground)); | |
| 482 | +} | |
| 483 | +.btn-default:hover, .btn-primary:hover { | |
| 484 | + opacity: 0.9; | |
| 485 | +} | |
| 486 | +.btn-secondary { | |
| 487 | + background-color: hsl(var(--secondary)); | |
| 488 | + color: hsl(var(--secondary-foreground)); | |
| 489 | + border-color: hsl(var(--border)); | |
| 490 | +} | |
| 491 | +.btn-secondary:hover { | |
| 492 | + background-color: hsl(var(--accent)); | |
| 493 | +} | |
| 494 | +.btn-outline { | |
| 495 | + background-color: transparent; | |
| 496 | + color: hsl(var(--foreground)); | |
| 497 | + border-color: hsl(var(--border)); | |
| 498 | +} | |
| 499 | +.btn-outline:hover { | |
| 500 | + background-color: hsl(var(--accent)); | |
| 501 | + color: hsl(var(--accent-foreground)); | |
| 502 | +} | |
| 503 | +.btn-ghost { | |
| 504 | + background-color: transparent; | |
| 505 | + color: hsl(var(--muted-foreground)); | |
| 506 | +} | |
| 507 | +.btn-ghost:hover { | |
| 508 | + background-color: hsl(var(--accent)); | |
| 509 | + color: hsl(var(--foreground)); | |
| 510 | +} | |
| 511 | +.btn-destructive { | |
| 512 | + background-color: hsl(var(--destructive)); | |
| 513 | + color: hsl(var(--destructive-foreground)); | |
| 514 | +} | |
| 515 | +.btn-destructive:hover { | |
| 516 | + opacity: 0.9; | |
| 517 | +} | |
| 518 | + | |
| 519 | +/* Inputs, Selects, Textareas */ | |
| 520 | +.input, .select, .textarea { | |
| 521 | + display: block; | |
| 522 | + width: 100%; | |
| 523 | + border-radius: var(--radius); | |
| 524 | + border: 1px solid hsl(var(--input)); | |
| 525 | + background-color: hsl(var(--background)); | |
| 526 | + color: hsl(var(--foreground)); | |
| 527 | + font-size: 0.85rem; | |
| 528 | + padding: 0.35rem 0.6rem; | |
| 529 | + transition: border-color 100ms ease; | |
| 530 | +} | |
| 531 | +.input, .select { | |
| 532 | + height: 2.05rem; | |
| 533 | +} | |
| 534 | +.textarea { | |
| 535 | + min-height: 4rem; | |
| 536 | + resize: vertical; | |
| 537 | +} | |
| 538 | +.input:focus, .select:focus, .textarea:focus { | |
| 539 | + outline: none; | |
| 540 | + border-color: hsl(var(--ring)); | |
| 541 | +} | |
| 542 | +.input::placeholder, .textarea::placeholder { | |
| 543 | + color: hsl(var(--muted-foreground)); | |
| 544 | +} | |
| 545 | +.label { | |
| 546 | + display: block; | |
| 547 | + font-size: 0.8rem; | |
| 548 | + font-weight: 500; | |
| 549 | + margin-bottom: 0.3rem; | |
| 550 | + color: hsl(var(--foreground)); | |
| 551 | +} | |
| 552 | +.field-hint { | |
| 553 | + font-size: 0.74rem; | |
| 554 | + color: hsl(var(--muted-foreground)); | |
| 555 | + margin-top: 0.25rem; | |
| 556 | +} | |
| 557 | + | |
| 558 | +/* Switch */ | |
| 559 | +.switch { | |
| 560 | + position: relative; | |
| 561 | + display: inline-flex; | |
| 562 | + width: 2.1rem; | |
| 563 | + height: 1.2rem; | |
| 564 | + flex-shrink: 0; | |
| 565 | + cursor: pointer; | |
| 566 | + align-items: center; | |
| 567 | + border-radius: 9999px; | |
| 568 | + border: 1px solid hsl(var(--border)); | |
| 569 | + background-color: hsl(var(--muted)); | |
| 570 | +} | |
| 571 | +.switch input { | |
| 572 | + opacity: 0; | |
| 573 | + width: 0; | |
| 574 | + height: 0; | |
| 575 | + position: absolute; | |
| 576 | +} | |
| 577 | +.switch-thumb { | |
| 578 | + pointer-events: none; | |
| 579 | + display: block; | |
| 580 | + width: 0.85rem; | |
| 581 | + height: 0.85rem; | |
| 582 | + border-radius: 9999px; | |
| 583 | + background-color: hsl(var(--foreground)); | |
| 584 | + transform: translateX(0.15rem); | |
| 585 | + transition: transform 100ms ease; | |
| 586 | +} | |
| 587 | +.switch input:checked ~ .switch-thumb { | |
| 588 | + transform: translateX(0.95rem); | |
| 589 | +} | |
| 590 | + | |
| 591 | +/* Cards */ | |
| 592 | +.card { | |
| 593 | + border-radius: var(--radius); | |
| 594 | + border: 1px solid hsl(var(--border)); | |
| 595 | + background-color: hsl(var(--card)); | |
| 596 | + color: hsl(var(--card-foreground)); | |
| 597 | +} | |
| 598 | +.card-header { | |
| 599 | + padding: 0.7rem 0.9rem; | |
| 600 | + border-bottom: 1px solid hsl(var(--border)); | |
| 601 | + display: flex; | |
| 602 | + align-items: center; | |
| 603 | + justify-content: space-between; | |
| 604 | + gap: 0.75rem; | |
| 605 | +} | |
| 606 | +.card-body { | |
| 607 | + padding: 0.9rem; | |
| 608 | +} | |
| 609 | +.card-footer { | |
| 610 | + padding: 0.65rem 0.9rem; | |
| 611 | + border-top: 1px solid hsl(var(--border)); | |
| 612 | + background-color: hsl(var(--muted) / 0.3); | |
| 613 | +} | |
| 614 | + | |
| 615 | +/* Badges */ | |
| 616 | +.badge { | |
| 617 | + display: inline-flex; | |
| 618 | + align-items: center; | |
| 619 | + gap: 0.25rem; | |
| 620 | + border-radius: 9999px; | |
| 621 | + border: 1px solid hsl(var(--border)); | |
| 622 | + padding: 0.08rem 0.45rem; | |
| 623 | + font-size: 0.72rem; | |
| 624 | + font-weight: 500; | |
| 625 | + line-height: 1.3; | |
| 626 | + color: hsl(var(--foreground)); | |
| 627 | + background-color: hsl(var(--secondary)); | |
| 628 | +} | |
| 629 | +.badge-outline { | |
| 630 | + background-color: transparent; | |
| 631 | + color: hsl(var(--muted-foreground)); | |
| 632 | +} | |
| 633 | +.badge-primary { | |
| 634 | + background-color: hsl(var(--primary)); | |
| 635 | + color: hsl(var(--primary-foreground)); | |
| 636 | + border-color: transparent; | |
| 637 | +} | |
| 638 | +.badge-destructive { | |
| 639 | + background-color: hsl(var(--destructive) / 0.12); | |
| 640 | + color: hsl(var(--destructive)); | |
| 641 | + border-color: hsl(var(--destructive) / 0.3); | |
| 642 | +} | |
| 643 | + | |
| 644 | +/* Kbd */ | |
| 645 | +kbd, .kbd { | |
| 646 | + display: inline-flex; | |
| 647 | + align-items: center; | |
| 648 | + justify-content: center; | |
| 649 | + min-width: 1.25rem; | |
| 650 | + height: 1.15rem; | |
| 651 | + padding: 0 0.28rem; | |
| 652 | + font-size: 0.68rem; | |
| 653 | + font-weight: 500; | |
| 654 | + line-height: 1; | |
| 655 | + color: hsl(var(--muted-foreground)); | |
| 656 | + background-color: hsl(var(--muted)); | |
| 657 | + border: 1px solid hsl(var(--border)); | |
| 658 | + border-radius: 0.25rem; | |
| 659 | +} | |
| 660 | + | |
| 661 | +/* Avatar */ | |
| 662 | +.avatar { | |
| 663 | + display: inline-flex; | |
| 664 | + align-items: center; | |
| 665 | + justify-content: center; | |
| 666 | + width: 1.45rem; | |
| 667 | + height: 1.45rem; | |
| 668 | + border-radius: 9999px; | |
| 669 | + background-color: hsl(var(--secondary)); | |
| 670 | + border: 1px solid hsl(var(--border)); | |
| 671 | + font-size: 0.7rem; | |
| 672 | + font-weight: 600; | |
| 673 | + color: hsl(var(--foreground)); | |
| 674 | + text-transform: uppercase; | |
| 675 | + flex-shrink: 0; | |
| 676 | + overflow: hidden; | |
| 677 | +} | |
| 678 | +.avatar-img { | |
| 679 | + width: 100%; | |
| 680 | + height: 100%; | |
| 681 | + object-fit: cover; | |
| 682 | + display: block; | |
| 683 | +} | |
| 684 | +.avatar-lg { | |
| 685 | + width: 2.3rem; | |
| 686 | + height: 2.3rem; | |
| 687 | + font-size: 0.9rem; | |
| 688 | +} | |
| 689 | +.avatar-xl { | |
| 690 | + width: 4.25rem; | |
| 691 | + height: 4.25rem; | |
| 692 | + font-size: 1.5rem; | |
| 693 | +} | |
| 694 | + | |
| 695 | + | |
| 696 | +/* Separator */ | |
| 697 | +.separator { | |
| 698 | + height: 1px; | |
| 699 | + width: 100%; | |
| 700 | + background-color: hsl(var(--border)); | |
| 701 | + margin: 0.85rem 0; | |
| 702 | + border: 0; | |
| 703 | +} | |
| 704 | + | |
| 705 | +/* Tabs */ | |
| 706 | +.tabs-nav { | |
| 707 | + display: flex; | |
| 708 | + align-items: center; | |
| 709 | + gap: 0.15rem; | |
| 710 | + padding: 0.2rem; | |
| 711 | + border: 1px solid hsl(var(--border)); | |
| 712 | + border-radius: 999px; | |
| 713 | + background: hsl(var(--muted) / 0.25); | |
| 714 | + margin-bottom: 1rem; | |
| 715 | + overflow-x: auto; | |
| 716 | +} | |
| 717 | +.tab-link { | |
| 718 | + display: inline-flex; | |
| 719 | + align-items: center; | |
| 720 | + gap: 0.35rem; | |
| 721 | + padding: 0.38rem 0.8rem; | |
| 722 | + font-size: 0.82rem; | |
| 723 | + font-weight: 500; | |
| 724 | + color: hsl(var(--muted-foreground)); | |
| 725 | + border-radius: 999px; | |
| 726 | + white-space: nowrap; | |
| 727 | + transition: color 100ms ease, background-color 100ms ease; | |
| 728 | +} | |
| 729 | +.tab-link:hover { | |
| 730 | + color: hsl(var(--foreground)); | |
| 731 | +} | |
| 732 | +.tab-link.active { | |
| 733 | + color: hsl(var(--foreground)); | |
| 734 | + background: hsl(var(--card)); | |
| 735 | + box-shadow: 0 1px 2px hsl(0 0% 0% / 0.08); | |
| 736 | +} | |
| 737 | + | |
| 738 | +/* Dense File List & Tables */ | |
| 739 | +.dense-list { | |
| 740 | + width: 100%; | |
| 741 | +} | |
| 742 | +.dense-row { | |
| 743 | + display: flex; | |
| 744 | + align-items: center; | |
| 745 | + justify-content: space-between; | |
| 746 | + gap: 0.75rem; | |
| 747 | + min-height: 2.05rem; | |
| 748 | + padding: 0.25rem 0.75rem; | |
| 749 | + border-bottom: 1px solid hsl(var(--border)); | |
| 750 | + font-size: 0.85rem; | |
| 751 | + transition: background-color 80ms ease; | |
| 752 | +} | |
| 753 | +.dense-row:last-child { | |
| 754 | + border-bottom: none; | |
| 755 | +} | |
| 756 | +.dense-row:hover { | |
| 757 | + background-color: hsl(var(--accent) / 0.5); | |
| 758 | +} | |
| 759 | + | |
| 760 | +.table-wrap { | |
| 761 | + width: 100%; | |
| 762 | + overflow-x: auto; | |
| 763 | + border: 1px solid hsl(var(--border)); | |
| 764 | + border-radius: var(--radius); | |
| 765 | + background-color: hsl(var(--card)); | |
| 766 | +} | |
| 767 | +.table { | |
| 768 | + width: 100%; | |
| 769 | + border-collapse: collapse; | |
| 770 | + font-size: 0.85rem; | |
| 771 | +} | |
| 772 | +.table th { | |
| 773 | + text-align: left; | |
| 774 | + font-weight: 500; | |
| 775 | + color: hsl(var(--muted-foreground)); | |
| 776 | + padding: 0.5rem 0.75rem; | |
| 777 | + border-bottom: 1px solid hsl(var(--border)); | |
| 778 | + background-color: hsl(var(--muted) / 0.35); | |
| 779 | + font-size: 0.75rem; | |
| 780 | +} | |
| 781 | +.table td { | |
| 782 | + padding: 0.5rem 0.75rem; | |
| 783 | + border-bottom: 1px solid hsl(var(--border)); | |
| 784 | + vertical-align: middle; | |
| 785 | +} | |
| 786 | +.table tr:last-child td { | |
| 787 | + border-bottom: none; | |
| 788 | +} | |
| 789 | + | |
| 790 | +/* Breadcrumb */ | |
| 791 | +.breadcrumb { | |
| 792 | + display: flex; | |
| 793 | + align-items: center; | |
| 794 | + flex-wrap: wrap; | |
| 795 | + gap: 0.25rem; | |
| 796 | + font-size: 0.85rem; | |
| 797 | +} | |
| 798 | +.breadcrumb-sep { | |
| 799 | + color: hsl(var(--muted-foreground)); | |
| 800 | +} | |
| 801 | + | |
| 802 | +/* Dropdown Menu */ | |
| 803 | +@keyframes dropdownIn { | |
| 804 | + from { | |
| 805 | + opacity: 0; | |
| 806 | + transform: translateY(-4px) scale(0.98); | |
| 807 | + } | |
| 808 | + to { | |
| 809 | + opacity: 1; | |
| 810 | + transform: translateY(0) scale(1); | |
| 811 | + } | |
| 812 | +} | |
| 813 | +.dropdown { | |
| 814 | + position: relative; | |
| 815 | + display: inline-block; | |
| 816 | +} | |
| 817 | +.dropdown-menu { | |
| 818 | + position: absolute; | |
| 819 | + left: 0; | |
| 820 | + top: calc(100% + 0.3rem); | |
| 821 | + min-width: 11rem; | |
| 822 | + background-color: hsl(var(--popover)); | |
| 823 | + color: hsl(var(--popover-foreground)); | |
| 824 | + border: 1px solid hsl(var(--border)); | |
| 825 | + border-radius: var(--radius); | |
| 826 | + padding: 0.25rem; | |
| 827 | + box-shadow: 0 8px 24px -4px rgba(0, 0, 0, 0.22); | |
| 828 | + z-index: 60; | |
| 829 | + display: none; | |
| 830 | + transform-origin: top left; | |
| 831 | +} | |
| 832 | +.dropdown-menu.open { | |
| 833 | + display: block; | |
| 834 | + animation: dropdownIn 140ms cubic-bezier(0.16, 1, 0.3, 1); | |
| 835 | +} | |
| 836 | +.dropdown-item { | |
| 837 | + display: flex; | |
| 838 | + width: 100%; | |
| 839 | + align-items: center; | |
| 840 | + justify-content: space-between; | |
| 841 | + gap: 0.5rem; | |
| 842 | + padding: 0.35rem 0.55rem; | |
| 843 | + font-size: 0.8rem; | |
| 844 | + border-radius: calc(var(--radius) - 2px); | |
| 845 | + color: hsl(var(--popover-foreground)); | |
| 846 | + background: transparent; | |
| 847 | + border: 0; | |
| 848 | + cursor: pointer; | |
| 849 | + text-align: left; | |
| 850 | + transition: background-color 120ms ease, color 120ms ease; | |
| 851 | +} | |
| 852 | +.dropdown-item:hover, .dropdown-item.active { | |
| 853 | + background-color: hsl(var(--accent)); | |
| 854 | + color: hsl(var(--accent-foreground)); | |
| 855 | +} | |
| 856 | + | |
| 857 | +/* Modal / Dialog & Command Palette */ | |
| 858 | +@keyframes backdropIn { | |
| 859 | + from { opacity: 0; } | |
| 860 | + to { opacity: 1; } | |
| 861 | +} | |
| 862 | +@keyframes dialogIn { | |
| 863 | + from { | |
| 864 | + opacity: 0; | |
| 865 | + transform: translateY(-6px) scale(0.97); | |
| 866 | + } | |
| 867 | + to { | |
| 868 | + opacity: 1; | |
| 869 | + transform: translateY(0) scale(1); | |
| 870 | + } | |
| 871 | +} | |
| 872 | +.backdrop { | |
| 873 | + position: fixed; | |
| 874 | + inset: 0; | |
| 875 | + background-color: rgba(0, 0, 0, 0.55); | |
| 876 | + backdrop-filter: blur(2px); | |
| 877 | + z-index: 80; | |
| 878 | + display: none; | |
| 879 | + align-items: flex-start; | |
| 880 | + justify-content: center; | |
| 881 | + padding: 10vh 1rem 2rem; | |
| 882 | +} | |
| 883 | +.backdrop.open { | |
| 884 | + display: flex; | |
| 885 | + animation: backdropIn 140ms ease; | |
| 886 | +} | |
| 887 | +.dialog-box { | |
| 888 | + width: 100%; | |
| 889 | + max-width: 28rem; | |
| 890 | + background-color: hsl(var(--popover)); | |
| 891 | + color: hsl(var(--popover-foreground)); | |
| 892 | + border: 1px solid hsl(var(--border)); | |
| 893 | + border-radius: var(--radius); | |
| 894 | + box-shadow: 0 16px 40px -8px rgba(0, 0, 0, 0.35); | |
| 895 | + overflow: hidden; | |
| 896 | + animation: dialogIn 170ms cubic-bezier(0.16, 1, 0.3, 1); | |
| 897 | +} | |
| 898 | +.palette-box { | |
| 899 | + width: 100%; | |
| 900 | + max-width: 34rem; | |
| 901 | + background-color: hsl(var(--popover)); | |
| 902 | + color: hsl(var(--popover-foreground)); | |
| 903 | + border: 1px solid hsl(var(--border)); | |
| 904 | + border-radius: var(--radius); | |
| 905 | + box-shadow: 0 16px 48px -8px rgba(0, 0, 0, 0.4); | |
| 906 | + overflow: hidden; | |
| 907 | + animation: dialogIn 170ms cubic-bezier(0.16, 1, 0.3, 1); | |
| 908 | +} | |
| 909 | +.palette-input-wrap { | |
| 910 | + display: flex; | |
| 911 | + align-items: center; | |
| 912 | + gap: 0.6rem; | |
| 913 | + padding: 0.6rem 0.8rem; | |
| 914 | + border-bottom: 1px solid hsl(var(--border)); | |
| 915 | +} | |
| 916 | +.palette-input { | |
| 917 | + width: 100%; | |
| 918 | + border: 0; | |
| 919 | + background: transparent; | |
| 920 | + color: hsl(var(--foreground)); | |
| 921 | + font-size: 0.9rem; | |
| 922 | + outline: none; | |
| 923 | +} | |
| 924 | +.palette-list { | |
| 925 | + max-height: 20rem; | |
| 926 | + overflow-y: auto; | |
| 927 | + padding: 0.3rem; | |
| 928 | +} | |
| 929 | +.palette-group-title { | |
| 930 | + padding: 0.3rem 0.5rem 0.15rem; | |
| 931 | + font-size: 0.68rem; | |
| 932 | + font-weight: 600; | |
| 933 | + text-transform: uppercase; | |
| 934 | + letter-spacing: 0.04em; | |
| 935 | + color: hsl(var(--muted-foreground)); | |
| 936 | +} | |
| 937 | +.palette-item { | |
| 938 | + display: flex; | |
| 939 | + width: 100%; | |
| 940 | + align-items: center; | |
| 941 | + justify-content: space-between; | |
| 942 | + gap: 0.5rem; | |
| 943 | + padding: 0.4rem 0.55rem; | |
| 944 | + border-radius: calc(var(--radius) - 2px); | |
| 945 | + font-size: 0.85rem; | |
| 946 | + color: hsl(var(--foreground)); | |
| 947 | + cursor: pointer; | |
| 948 | + border: 0; | |
| 949 | + background: transparent; | |
| 950 | + text-align: left; | |
| 951 | + transition: background-color 100ms ease, color 100ms ease; | |
| 952 | +} | |
| 953 | +.palette-item:hover, .palette-item.selected { | |
| 954 | + background-color: hsl(var(--accent)); | |
| 955 | + color: hsl(var(--accent-foreground)); | |
| 956 | +} | |
| 957 | + | |
| 958 | +/* Toast */ | |
| 959 | +@keyframes toastIn { | |
| 960 | + from { | |
| 961 | + opacity: 0; | |
| 962 | + transform: translateY(8px) scale(0.97); | |
| 963 | + } | |
| 964 | + to { | |
| 965 | + opacity: 1; | |
| 966 | + transform: translateY(0) scale(1); | |
| 967 | + } | |
| 968 | +} | |
| 969 | +.toast-container { | |
| 970 | + position: fixed; | |
| 971 | + bottom: 1rem; | |
| 972 | + right: 1rem; | |
| 973 | + z-index: 90; | |
| 974 | + display: flex; | |
| 975 | + flex-direction: column; | |
| 976 | + gap: 0.4rem; | |
| 977 | + max-width: 20rem; | |
| 978 | +} | |
| 979 | +.toast { | |
| 980 | + display: flex; | |
| 981 | + align-items: center; | |
| 982 | + justify-content: space-between; | |
| 983 | + gap: 0.65rem; | |
| 984 | + padding: 0.5rem 0.75rem; | |
| 985 | + border-radius: var(--radius); | |
| 986 | + border: 1px solid hsl(var(--border)); | |
| 987 | + background-color: hsl(var(--popover)); | |
| 988 | + color: hsl(var(--popover-foreground)); | |
| 989 | + box-shadow: 0 8px 24px -4px rgba(0, 0, 0, 0.25); | |
| 990 | + font-size: 0.8rem; | |
| 991 | + animation: toastIn 180ms cubic-bezier(0.16, 1, 0.3, 1); | |
| 992 | +} | |
| 993 | + | |
| 994 | +/* Skeleton */ | |
| 995 | +@keyframes pulse { | |
| 996 | + 0%, 100% { opacity: 1; } | |
| 997 | + 50% { opacity: 0.45; } | |
| 998 | +} | |
| 999 | +.skeleton { | |
| 1000 | + background-color: hsl(var(--muted)); | |
| 1001 | + border-radius: calc(var(--radius) - 2px); | |
| 1002 | + animation: pulse 1.6s ease-in-out infinite; | |
| 1003 | +} | |
| 1004 | + | |
| 1005 | +/* Empty State */ | |
| 1006 | +.empty-state { | |
| 1007 | + text-align: center; | |
| 1008 | + padding: 2rem 1rem; | |
| 1009 | + border: 1px dashed hsl(var(--border)); | |
| 1010 | + border-radius: var(--radius); | |
| 1011 | + color: hsl(var(--muted-foreground)); | |
| 1012 | +} | |
| 1013 | + | |
| 1014 | +/* Code Viewer & Chroma */ | |
| 1015 | +.code-table { | |
| 1016 | + width: 100%; | |
| 1017 | + border-collapse: collapse; | |
| 1018 | + font-family: 'JetBrains Mono', monospace; | |
| 1019 | + font-size: 0.8rem; | |
| 1020 | + line-height: 1.5; | |
| 1021 | +} | |
| 1022 | +.code-ln { | |
| 1023 | + width: 1%; | |
| 1024 | + min-width: 2.75rem; | |
| 1025 | + padding: 0 0.65rem; | |
| 1026 | + text-align: right; | |
| 1027 | + color: hsl(var(--muted-foreground) / 0.6); | |
| 1028 | + user-select: none; | |
| 1029 | + vertical-align: top; | |
| 1030 | + border-right: 1px solid hsl(var(--border)); | |
| 1031 | +} | |
| 1032 | +.code-line { | |
| 1033 | + padding: 0 0.75rem; | |
| 1034 | + white-space: pre; | |
| 1035 | + overflow-x: auto; | |
| 1036 | +} | |
| 1037 | +.chroma .k, .chroma .kc, .chroma .kd, .chroma .kn, .chroma .kp, .chroma .kr { color: hsl(var(--syntax-kw)); font-weight: 500; } | |
| 1038 | +.chroma .kt { color: hsl(var(--syntax-type)); } | |
| 1039 | +.chroma .s, .chroma .sa, .chroma .sb, .chroma .sc, .chroma .dl, .chroma .sd, .chroma .s2, .chroma .se, .chroma .sh, .chroma .si, .chroma .sx, .chroma .sr, .chroma .s1, .chroma .ss { color: hsl(var(--syntax-str)); } | |
| 1040 | +.chroma .c, .chroma .ch, .chroma .cm, .chroma .c1, .chroma .cs, .chroma .cp, .chroma .cpf { color: hsl(var(--syntax-cmt)); font-style: italic; } | |
| 1041 | +.chroma .nf, .chroma .fm, .chroma .nx { color: hsl(var(--syntax-fn)); } | |
| 1042 | +.chroma .m, .chroma .mb, .chroma .mf, .chroma .mh, .chroma .mi, .chroma .il, .chroma .mo { color: hsl(var(--syntax-num)); } | |
| 1043 | +.chroma .o, .chroma .ow { color: hsl(var(--syntax-op)); } | |
| 1044 | + | |
| 1045 | +/* Diff Viewer */ | |
| 1046 | +.diff-table { | |
| 1047 | + width: 100%; | |
| 1048 | + border-collapse: collapse; | |
| 1049 | + font-family: 'JetBrains Mono', monospace; | |
| 1050 | + font-size: 0.78rem; | |
| 1051 | + line-height: 1.5; | |
| 1052 | +} | |
| 1053 | +.diff-row-add { | |
| 1054 | + background-color: hsl(var(--diff-add-bg)); | |
| 1055 | + color: hsl(var(--diff-add-fg)); | |
| 1056 | +} | |
| 1057 | +.diff-row-del { | |
| 1058 | + background-color: hsl(var(--diff-del-bg)); | |
| 1059 | + color: hsl(var(--diff-del-fg)); | |
| 1060 | +} | |
| 1061 | +.diff-row-hunk { | |
| 1062 | + background-color: hsl(var(--muted)); | |
| 1063 | + color: hsl(var(--muted-foreground)); | |
| 1064 | +} | |
| 1065 | +.diff-num { | |
| 1066 | + width: 2.75rem; | |
| 1067 | + padding: 0 0.45rem; | |
| 1068 | + text-align: right; | |
| 1069 | + user-select: none; | |
| 1070 | + opacity: 0.65; | |
| 1071 | + border-right: 1px solid hsl(var(--border)); | |
| 1072 | +} | |
| 1073 | +.diff-code { | |
| 1074 | + padding: 0 0.65rem; | |
| 1075 | + white-space: pre-wrap; | |
| 1076 | + word-break: break-all; | |
| 1077 | +} | |
| 1078 | + | |
| 1079 | +/* Markdown Body */ | |
| 1080 | +.markdown-body { | |
| 1081 | + font-size: 0.9rem; | |
| 1082 | + line-height: 1.6; | |
| 1083 | + color: hsl(var(--foreground)); | |
| 1084 | +} | |
| 1085 | +.markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4 { | |
| 1086 | + margin-top: 1.25em; | |
| 1087 | + margin-bottom: 0.45em; | |
| 1088 | + font-weight: 600; | |
| 1089 | + line-height: 1.25; | |
| 1090 | +} | |
| 1091 | +.markdown-body h1 { font-size: 1.35rem; padding-bottom: 0.25rem; border-bottom: 1px solid hsl(var(--border)); } | |
| 1092 | +.markdown-body h2 { font-size: 1.15rem; padding-bottom: 0.2rem; border-bottom: 1px solid hsl(var(--border)); } | |
| 1093 | +.markdown-body h3 { font-size: 1rem; } | |
| 1094 | +.markdown-body h1:first-child, .markdown-body h2:first-child { margin-top: 0; } | |
| 1095 | +.markdown-body p, .markdown-body ul, .markdown-body ol, .markdown-body pre, .markdown-body blockquote { | |
| 1096 | + margin-top: 0; | |
| 1097 | + margin-bottom: 0.8rem; | |
| 1098 | +} | |
| 1099 | +.markdown-body code { | |
| 1100 | + background-color: hsl(var(--muted)); | |
| 1101 | + padding: 0.12rem 0.3rem; | |
| 1102 | + border-radius: 0.25rem; | |
| 1103 | + font-size: 0.84em; | |
| 1104 | +} | |
| 1105 | +.markdown-body pre { | |
| 1106 | + background-color: hsl(var(--muted) / 0.6); | |
| 1107 | + border: 1px solid hsl(var(--border)); | |
| 1108 | + border-radius: var(--radius); | |
| 1109 | + padding: 0.75rem 0.9rem; | |
| 1110 | + overflow-x: auto; | |
| 1111 | +} | |
| 1112 | +.markdown-body pre code { | |
| 1113 | + background: transparent; | |
| 1114 | + padding: 0; | |
| 1115 | +} | |
| 1116 | +.markdown-body a { | |
| 1117 | + text-decoration: underline; | |
| 1118 | + text-underline-offset: 3px; | |
| 1119 | +} | |
| 1120 | + | |
| 1121 | +/* Utility Helpers */ | |
| 1122 | +.flex { display: flex; } | |
| 1123 | +.block { display: block; } | |
| 1124 | +.inline-flex { display: inline-flex; } | |
| 1125 | +.grid { display: grid; } | |
| 1126 | +.hidden { display: none; } | |
| 1127 | +.items-center { align-items: center; } | |
| 1128 | +.items-start { align-items: flex-start; } | |
| 1129 | +.items-baseline { align-items: baseline; } | |
| 1130 | +.justify-between { justify-content: space-between; } | |
| 1131 | +.justify-center { justify-content: center; } | |
| 1132 | +.justify-end { justify-content: flex-end; } | |
| 1133 | +.flex-col { flex-direction: column; } | |
| 1134 | +.flex-wrap { flex-wrap: wrap; } | |
| 1135 | +.flex-1 { flex: 1 1 0%; } | |
| 1136 | +.flex-shrink-0 { flex-shrink: 0; } | |
| 1137 | +.min-w-0 { min-width: 0; } | |
| 1138 | +.gap-0\.5 { gap: 0.125rem; } | |
| 1139 | +.gap-1 { gap: 0.25rem; } | |
| 1140 | +.gap-1\.5 { gap: 0.375rem; } | |
| 1141 | +.gap-2 { gap: 0.5rem; } | |
| 1142 | +.gap-2\.5 { gap: 0.625rem; } | |
| 1143 | +.gap-3 { gap: 0.75rem; } | |
| 1144 | +.gap-4 { gap: 1rem; } | |
| 1145 | +.gap-6 { gap: 1.5rem; } | |
| 1146 | +.w-full { width: 100%; } | |
| 1147 | +.text-xs { font-size: 0.75rem; } | |
| 1148 | +.text-sm { font-size: 0.85rem; } | |
| 1149 | +.text-base { font-size: 0.96rem; } | |
| 1150 | +.text-lg { font-size: 1.1rem; } | |
| 1151 | +.text-xl { font-size: 1.25rem; } | |
| 1152 | +.text-2xl { font-size: 1.5rem; } | |
| 1153 | +.font-medium { font-weight: 500; } | |
| 1154 | +.font-semibold { font-weight: 600; } | |
| 1155 | +.font-bold { font-weight: 700; } | |
| 1156 | +.uppercase { text-transform: uppercase; } | |
| 1157 | +.tracking-tight { letter-spacing: -0.015em; } | |
| 1158 | +.text-muted { color: hsl(var(--muted-foreground)); } | |
| 1159 | +.text-destructive { color: hsl(var(--destructive)); } | |
| 1160 | +.text-foreground { color: hsl(var(--foreground)); } | |
| 1161 | +.text-center { text-align: center; } | |
| 1162 | +.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 1163 | +.hover\:underline:hover { text-decoration: underline; } | |
| 1164 | +.select-all { user-select: all; } | |
| 1165 | +.cursor-pointer { cursor: pointer; } | |
| 1166 | +.mt-0\.5 { margin-top: 0.125rem; } | |
| 1167 | +.mt-1 { margin-top: 0.25rem; } | |
| 1168 | +.mt-2 { margin-top: 0.5rem; } | |
| 1169 | +.mt-3 { margin-top: 0.75rem; } | |
| 1170 | +.mt-4 { margin-top: 1rem; } | |
| 1171 | +.mt-6 { margin-top: 1.5rem; } | |
| 1172 | +.mr-2 { margin-right: 0.5rem; } | |
| 1173 | +.mb-1 { margin-bottom: 0.25rem; } | |
| 1174 | +.mb-1\.5 { margin-bottom: 0.375rem; } | |
| 1175 | +.mb-2 { margin-bottom: 0.5rem; } | |
| 1176 | +.mb-3 { margin-bottom: 0.75rem; } | |
| 1177 | +.mb-4 { margin-bottom: 1rem; } | |
| 1178 | +.mb-6 { margin-bottom: 1.5rem; } | |
| 1179 | +.p-2 { padding: 0.5rem; } | |
| 1180 | +.p-3 { padding: 0.75rem; } | |
| 1181 | +.p-4 { padding: 1rem; } | |
| 1182 | +.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; } | |
| 1183 | +.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; } | |
| 1184 | +.pt-2 { padding-top: 0.5rem; } | |
| 1185 | +.pt-3 { padding-top: 0.75rem; } | |
| 1186 | +.pb-3 { padding-bottom: 0.75rem; } | |
| 1187 | + | |
| 1188 | +.grid-2 { display: grid; grid-template-columns: repeat(1, minmax(0, 1fr)); gap: 0.85rem; } | |
| 1189 | +.grid-3 { display: grid; grid-template-columns: repeat(1, minmax(0, 1fr)); gap: 0.85rem; } | |
| 1190 | +.grid-4 { display: grid; grid-template-columns: repeat(1, minmax(0, 1fr)); gap: 0.85rem; } | |
| 1191 | + | |
| 1192 | +@media (min-width: 768px) { | |
| 1193 | + .grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } | |
| 1194 | + .grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } | |
| 1195 | + .grid-4 { grid-template-columns: repeat(2, minmax(0, 1fr)); } | |
| 1196 | + .md-flex { display: flex !important; } | |
| 1197 | + .md-hidden { display: none !important; } | |
| 1198 | +} | |
| 1199 | + | |
| 1200 | +/* Mobile Drawer */ | |
| 1201 | +.mobile-drawer { | |
| 1202 | + position: fixed; | |
| 1203 | + inset: 0; | |
| 1204 | + z-index: 75; | |
| 1205 | + background-color: rgba(0, 0, 0, 0.5); | |
| 1206 | + display: none; | |
| 1207 | +} | |
| 1208 | +.mobile-drawer.open { | |
| 1209 | + display: block; | |
| 1210 | +} | |
| 1211 | +.mobile-drawer-panel { | |
| 1212 | + width: 224px; | |
| 1213 | + max-width: 85vw; | |
| 1214 | + height: 100%; | |
| 1215 | + background-color: hsl(var(--card)); | |
| 1216 | + border-right: 1px solid hsl(var(--border)); | |
| 1217 | + padding: 0.75rem 0.65rem; | |
| 1218 | + display: flex; | |
| 1219 | + flex-direction: column; | |
| 1220 | + justify-content: space-between; | |
| 1221 | +} | |
| 1222 | + | |
| 1223 | +@media (min-width: 1024px) { | |
| 1224 | + .grid-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } | |
| 1225 | +} | |
| 1226 | + | |
| 1227 | +/* ── Polar repository shell ─────────────────────────────────────────── */ | |
| 1228 | +.repo-banner { | |
| 1229 | + position: relative; | |
| 1230 | + display: flex; | |
| 1231 | + align-items: flex-start; | |
| 1232 | + justify-content: space-between; | |
| 1233 | + gap: 1rem; | |
| 1234 | + flex-wrap: wrap; | |
| 1235 | + padding: 0.9rem 1rem 0.95rem; | |
| 1236 | + background: hsl(var(--card)); | |
| 1237 | + border: 1px solid hsl(var(--border)); | |
| 1238 | + border-radius: calc(var(--radius) * 1.6); | |
| 1239 | + overflow: hidden; | |
| 1240 | +} | |
| 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 | +} | |
| 1248 | +.repo-banner-main { | |
| 1249 | + display: flex; | |
| 1250 | + align-items: center; | |
| 1251 | + gap: 0.7rem; | |
| 1252 | + min-width: 0; | |
| 1253 | + flex: 1 1 20rem; | |
| 1254 | +} | |
| 1255 | +.repo-banner-actions { | |
| 1256 | + display: flex; | |
| 1257 | + align-items: center; | |
| 1258 | + gap: 0.4rem; | |
| 1259 | + flex-wrap: wrap; | |
| 1260 | +} | |
| 1261 | + | |
| 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 | +} | |
| 1290 | + | |
| 1291 | +.repo-layout { | |
| 1292 | + display: grid; | |
| 1293 | + grid-template-columns: minmax(0, 1fr) 305px; | |
| 1294 | + gap: 1.1rem; | |
| 1295 | + align-items: start; | |
| 1296 | +} | |
| 1297 | +.repo-main { | |
| 1298 | + min-width: 0; | |
| 1299 | +} | |
| 1300 | +.repo-side { | |
| 1301 | + display: flex; | |
| 1302 | + flex-direction: column; | |
| 1303 | + gap: 0.9rem; | |
| 1304 | + position: sticky; | |
| 1305 | + top: 0.75rem; | |
| 1306 | +} | |
| 1307 | +.side-card { | |
| 1308 | + border: 1px solid hsl(var(--border)); | |
| 1309 | + border-radius: calc(var(--radius) * 1.3); | |
| 1310 | + background: hsl(var(--card)); | |
| 1311 | + overflow: hidden; | |
| 1312 | +} | |
| 1313 | +.side-card-header { | |
| 1314 | + display: flex; | |
| 1315 | + align-items: center; | |
| 1316 | + gap: 0.4rem; | |
| 1317 | + padding: 0.5rem 0.8rem; | |
| 1318 | + font-size: 0.78rem; | |
| 1319 | + font-weight: 600; | |
| 1320 | + border-bottom: 1px solid hsl(var(--border)); | |
| 1321 | + background: hsl(var(--muted) / 0.3); | |
| 1322 | +} | |
| 1323 | +.side-card-body { | |
| 1324 | + display: flex; | |
| 1325 | + flex-direction: column; | |
| 1326 | + gap: 0.55rem; | |
| 1327 | + padding: 0.7rem 0.8rem; | |
| 1328 | + font-size: 0.8rem; | |
| 1329 | +} | |
| 1330 | +.meta-row { | |
| 1331 | + display: flex; | |
| 1332 | + align-items: center; | |
| 1333 | + justify-content: space-between; | |
| 1334 | + gap: 0.5rem; | |
| 1335 | + color: hsl(var(--muted-foreground)); | |
| 1336 | +} | |
| 1337 | +.meta-row a, | |
| 1338 | +.meta-row b { | |
| 1339 | + color: hsl(var(--foreground)); | |
| 1340 | +} | |
| 1341 | +.clone-box { | |
| 1342 | + display: flex; | |
| 1343 | + align-items: center; | |
| 1344 | + gap: 0.35rem; | |
| 1345 | + border: 1px solid hsl(var(--border)); | |
| 1346 | + border-radius: var(--radius); | |
| 1347 | + background: hsl(var(--muted) / 0.25); | |
| 1348 | + padding: 0.15rem 0.25rem 0.15rem 0.6rem; | |
| 1349 | + min-width: 0; | |
| 1350 | +} | |
| 1351 | +.clone-box code { | |
| 1352 | + flex: 1; | |
| 1353 | + min-width: 0; | |
| 1354 | + overflow: hidden; | |
| 1355 | + text-overflow: ellipsis; | |
| 1356 | + white-space: nowrap; | |
| 1357 | + font-size: 0.72rem; | |
| 1358 | +} | |
| 1359 | +@media (max-width: 1000px) { | |
| 1360 | + .repo-layout { | |
| 1361 | + grid-template-columns: 1fr; | |
| 1362 | + } | |
| 1363 | + .repo-side { | |
| 1364 | + position: static; | |
| 1365 | + order: -1; | |
| 1366 | + } | |
| 1367 | +} |
internal/web/static/embed.go
+26
-0
| ... | ... | @@ -0,0 +1,26 @@ |
| 1 | +package static | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "crypto/sha256" | |
| 5 | + "embed" | |
| 6 | + "encoding/hex" | |
| 7 | +) | |
| 8 | + | |
| 9 | +//go:embed css/* js/* fonts/* icons/* favicon.svg | |
| 10 | +var FS embed.FS | |
| 11 | + | |
| 12 | +//go:embed icons/hugeicons-sprite.svg | |
| 13 | +var SpriteSVG string | |
| 14 | + | |
| 15 | +//go:embed css/app.css | |
| 16 | +var AppCSS string | |
| 17 | + | |
| 18 | +//go:embed js/app.js | |
| 19 | +var AppJS string | |
| 20 | + | |
| 21 | +func AssetVersion() string { | |
| 22 | + h := sha256.New() | |
| 23 | + _, _ = h.Write([]byte(AppCSS)) | |
| 24 | + _, _ = h.Write([]byte(AppJS)) | |
| 25 | + return hex.EncodeToString(h.Sum(nil))[:10] | |
| 26 | +} |
internal/web/static/favicon.svg
+8
-0
| ... | ... | @@ -0,0 +1,8 @@ |
| 1 | +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"> | |
| 2 | + <rect width="64" height="64" rx="14" fill="#0a0a0a"/> | |
| 3 | + <path d="M22 18v24" stroke="#ffffff" stroke-width="6" stroke-linecap="round"/> | |
| 4 | + <path d="M22 30c0 8 6 10 14 10" stroke="#ffffff" stroke-width="6" stroke-linecap="round" fill="none"/> | |
| 5 | + <circle cx="22" cy="14" r="6" fill="#ffffff"/> | |
| 6 | + <circle cx="22" cy="46" r="6" fill="#ffffff"/> | |
| 7 | + <circle cx="42" cy="42" r="6" fill="#ffffff"/> | |
| 8 | +</svg> |
internal/web/static/fonts/Inter-Cyrillic.woff2
+0
-0
Бинарный файл — предпросмотр недоступен
internal/web/static/fonts/Inter-Regular.woff2
+0
-0
Бинарный файл — предпросмотр недоступен
internal/web/static/fonts/JetBrainsMono-Regular.woff2
+0
-0
Бинарный файл — предпросмотр недоступен
internal/web/static/icons/hugeicons-sprite.svg
+221
-0
| ... | ... | @@ -0,0 +1,221 @@ |
| 1 | +<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true"> | |
| 2 | + <!-- Hugeicons (@hugeicons/core-free-icons, MIT License) inline SVG sprite --> | |
| 3 | + <symbol id="hi-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 4 | + <path d="M3 7.8C3 6.12 3 5.28 3.327 4.638a3 3 0 0 1 1.311-1.311C5.28 3 6.12 3 7.8 3h1.33c.653 0 .98 0 1.276.096.262.085.505.222.712.405.234.207.415.479.778 1.023l.408.612c.363.544.544.816.778 1.023.207.183.45.32.712.405.296.096.623.096 1.276.096H16.2c1.68 0 2.52 0 3.162.327a3 3 0 0 1 1.311 1.311C21 8.94 21 9.78 21 11.46v4.74c0 1.68 0 2.52-.327 3.162a3 3 0 0 1-1.311 1.311C18.72 21 17.88 21 16.2 21H7.8c-1.68 0-2.52 0-3.162-.327a3 3 0 0 1-1.311-1.311C3 18.72 3 17.88 3 16.2V7.8Z"/> | |
| 5 | + </symbol> | |
| 6 | + <symbol id="hi-file" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 7 | + <path d="M14 2.5V6c0 1.1.9 2 2 2h3.5"/> | |
| 8 | + <path d="M5 17V7c0-2.21 1.79-4 4-4h5.17a3 3 0 0 1 2.12.88l2.83 2.83A3 3 0 0 1 20 8.83V17c0 2.21-1.79 4-4 4H9c-2.21 0-4-1.79-4-4Z"/> | |
| 9 | + </symbol> | |
| 10 | + <symbol id="hi-file-code" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 11 | + <path d="M14 2.5V6c0 1.1.9 2 2 2h3.5"/> | |
| 12 | + <path d="M5 17V7c0-2.21 1.79-4 4-4h5.17a3 3 0 0 1 2.12.88l2.83 2.83A3 3 0 0 1 20 8.83V17c0 2.21-1.79 4-4 4H9c-2.21 0-4-1.79-4-4Z"/> | |
| 13 | + <path d="m10 12.5-2 2 2 2m4-4 2 2-2 2"/> | |
| 14 | + </symbol> | |
| 15 | + <symbol id="hi-git-branch" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 16 | + <circle cx="6" cy="6" r="2.5"/> | |
| 17 | + <circle cx="18" cy="8" r="2.5"/> | |
| 18 | + <circle cx="6" cy="18" r="2.5"/> | |
| 19 | + <path d="M6 8.5v7M18 10.5c0 3.5-2.5 4.5-6 4.5H6"/> | |
| 20 | + </symbol> | |
| 21 | + <symbol id="hi-git-commit" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 22 | + <circle cx="12" cy="12" r="3.5"/> | |
| 23 | + <path d="M3 12h5.5m7 0H21"/> | |
| 24 | + </symbol> | |
| 25 | + <symbol id="hi-git-merge" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 26 | + <circle cx="6" cy="6" r="2.5"/> | |
| 27 | + <circle cx="6" cy="18" r="2.5"/> | |
| 28 | + <circle cx="18" cy="16" r="2.5"/> | |
| 29 | + <path d="M6 8.5v7m0-7c0 4 4 7.5 9.5 7.5"/> | |
| 30 | + </symbol> | |
| 31 | + <symbol id="hi-tag" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 32 | + <path d="M3.5 9.6V5.8A2.3 2.3 0 0 1 5.8 3.5h3.8c.61 0 1.2.24 1.63.67l8.6 8.6a2.3 2.3 0 0 1 0 3.26l-3.8 3.8a2.3 2.3 0 0 1-3.26 0l-8.6-8.6A2.3 2.3 0 0 1 3.5 9.6Z"/> | |
| 33 | + <circle cx="7.5" cy="7.5" r="1.25" fill="currentColor"/> | |
| 34 | + </symbol> | |
| 35 | + <symbol id="hi-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 36 | + <circle cx="11" cy="11" r="7.5"/> | |
| 37 | + <path d="m16.5 16.5 4 4"/> | |
| 38 | + </symbol> | |
| 39 | + <symbol id="hi-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 40 | + <circle cx="12" cy="12" r="3"/> | |
| 41 | + <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9c.26.604.852.997 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"/> | |
| 42 | + </symbol> | |
| 43 | + <symbol id="hi-user" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 44 | + <circle cx="12" cy="8" r="4"/> | |
| 45 | + <path d="M5 20c0-3.314 3.134-6 7-6s7 2.686 7 6"/> | |
| 46 | + </symbol> | |
| 47 | + <symbol id="hi-users" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 48 | + <circle cx="9" cy="8" r="3.5"/> | |
| 49 | + <path d="M3 19.5c0-2.9 2.686-5.25 6-5.25s6 2.35 6 5.25"/> | |
| 50 | + <path d="M15.5 5a3.2 3.2 0 0 1 0 6.2M17.5 14.5c2.1.45 3.5 2 3.5 4.2"/> | |
| 51 | + </symbol> | |
| 52 | + <symbol id="hi-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 53 | + <rect x="8.5" y="8.5" width="11.5" height="11.5" rx="2.5"/> | |
| 54 | + <path d="M15.5 8.5V6.5A2.5 2.5 0 0 0 13 4H6.5A2.5 2.5 0 0 0 4 6.5V13a2.5 2.5 0 0 0 2.5 2.5h2"/> | |
| 55 | + </symbol> | |
| 56 | + <symbol id="hi-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 57 | + <path d="m4.5 12.5 5 5L19.5 7.5"/> | |
| 58 | + </symbol> | |
| 59 | + <symbol id="hi-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 60 | + <path d="M12 4.5v15m7.5-7.5h-15"/> | |
| 61 | + </symbol> | |
| 62 | + <symbol id="hi-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 63 | + <circle cx="12" cy="12" r="4.2"/> | |
| 64 | + <path d="M12 2.5v2.2m0 14.6v2.2M4.93 4.93l1.56 1.56m11.02 11.02 1.56 1.56M2.5 12h2.2m14.6 0h2.2M4.93 19.07l1.56-1.56m11.02-11.02 1.56-1.56"/> | |
| 65 | + </symbol> | |
| 66 | + <symbol id="hi-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 67 | + <path d="M20.2 14.6A8.5 8.5 0 0 1 9.4 3.8a8.5 8.5 0 1 0 10.8 10.8Z"/> | |
| 68 | + </symbol> | |
| 69 | + <symbol id="hi-laptop" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 70 | + <rect x="4" y="4.5" width="16" height="11" rx="2"/> | |
| 71 | + <path d="M2.5 19.5h19"/> | |
| 72 | + </symbol> | |
| 73 | + <symbol id="hi-sparkles" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 74 | + <path d="m12 3 1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3Zm6.5 12 .8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8.8-2.2Z"/> | |
| 75 | + </symbol> | |
| 76 | + <symbol id="hi-book-open" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 77 | + <path d="M12 6.5c-2-1.7-4.7-2.5-8-2.5v14c3.3 0 6 .8 8 2.5m0-14c2-1.7 4.7-2.5 8-2.5v14c-3.3 0-6 .8-8 2.5m0-14v14"/> | |
| 78 | + </symbol> | |
| 79 | + <symbol id="hi-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 80 | + <path d="M4 6.5h16M4 12h16M4 17.5h16"/> | |
| 81 | + </symbol> | |
| 82 | + <symbol id="hi-close" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 83 | + <path d="m6 6 12 12M18 6 6 18"/> | |
| 84 | + </symbol> | |
| 85 | + <symbol id="hi-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 86 | + <rect x="4.5" y="10.5" width="15" height="10" rx="2.5"/> | |
| 87 | + <path d="M8 10.5V7.5a4 4 0 1 1 8 0v3m-4 4v2"/> | |
| 88 | + </symbol> | |
| 89 | + <symbol id="hi-unlock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 90 | + <rect x="4.5" y="10.5" width="15" height="10" rx="2.5"/> | |
| 91 | + <path d="M8 10.5V7.5a4 4 0 0 1 7.6-1.7M12 14.5v2"/> | |
| 92 | + </symbol> | |
| 93 | + <symbol id="hi-globe" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 94 | + <circle cx="12" cy="12" r="9"/> | |
| 95 | + <path d="M3.5 12h17M12 3c2.5 2.5 3.8 5.6 3.8 9s-1.3 6.5-3.8 9c-2.5-2.5-3.8-5.6-3.8-9S9.5 5.5 12 3Z"/> | |
| 96 | + </symbol> | |
| 97 | + <symbol id="hi-star" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 98 | + <path d="m12 3.5 2.7 5.5 6.1.9-4.4 4.3 1 6.1-5.4-2.9-5.4 2.9 1-6.1-4.4-4.3 6.1-.9L12 3.5Z"/> | |
| 99 | + </symbol> | |
| 100 | + <symbol id="hi-trash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 101 | + <path d="M4 6.5h16m-10 4v6m4-6v6M6 6.5l.8 12.1a2.5 2.5 0 0 0 2.5 2.4h5.4a2.5 2.5 0 0 0 2.5-2.4L18 6.5M9 6.5V4.8A1.8 1.8 0 0 1 10.8 3h2.4A1.8 1.8 0 0 1 15 4.8v1.7"/> | |
| 102 | + </symbol> | |
| 103 | + <symbol id="hi-chevron-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 104 | + <path d="m6 9 6 6 6-6"/> | |
| 105 | + </symbol> | |
| 106 | + <symbol id="hi-chevron-right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 107 | + <path d="m9 6 6 6-6 6"/> | |
| 108 | + </symbol> | |
| 109 | + <symbol id="hi-arrow-left" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 110 | + <path d="M19 12H5m6-6-6 6 6 6"/> | |
| 111 | + </symbol> | |
| 112 | + <symbol id="hi-arrow-right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 113 | + <path d="M5 12h14m-6-6 6 6-6 6"/> | |
| 114 | + </symbol> | |
| 115 | + <symbol id="hi-terminal" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 116 | + <rect x="3" y="4" width="18" height="16" rx="2.5"/> | |
| 117 | + <path d="m7.5 9.5 3 2.5-3 2.5m4.5 0h4"/> | |
| 118 | + </symbol> | |
| 119 | + <symbol id="hi-key" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 120 | + <circle cx="8" cy="15" r="4.5"/> | |
| 121 | + <path d="m11.2 11.8 8.3-8.3m-2.5 2.5 2.5 2.5m-5 0 2 2"/> | |
| 122 | + </symbol> | |
| 123 | + <symbol id="hi-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 124 | + <path d="M12 3 4.5 6.2v5.3c0 5 3.2 8.7 7.5 9.5 4.3-.8 7.5-4.5 7.5-9.5V6.2L12 3Z"/> | |
| 125 | + <path d="m9 12.2 2 2 4-4"/> | |
| 126 | + </symbol> | |
| 127 | + <symbol id="hi-alert-circle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 128 | + <circle cx="12" cy="12" r="9"/> | |
| 129 | + <path d="M12 8v4.5m0 3.5h.01"/> | |
| 130 | + </symbol> | |
| 131 | + <symbol id="hi-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 132 | + <circle cx="12" cy="12" r="9"/> | |
| 133 | + <path d="M12 11.5v4.5m0-8h.01"/> | |
| 134 | + </symbol> | |
| 135 | + <symbol id="hi-code" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 136 | + <path d="m8 8-4.5 4L8 16m8-8 4.5 4L16 16m-2.5-10-3 12"/> | |
| 137 | + </symbol> | |
| 138 | + <symbol id="hi-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 139 | + <path d="M12 4v11.5m0 0-4.5-4.5M12 15.5l4.5-4.5M4.5 19.5h15"/> | |
| 140 | + </symbol> | |
| 141 | + <symbol id="hi-history" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 142 | + <path d="M3.5 12a8.5 8.5 0 1 0 2.3-5.8L3.5 8.5M3.5 4v4.5H8M12 7.5v4.8l3.2 1.8"/> | |
| 143 | + </symbol> | |
| 144 | + <symbol id="hi-eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 145 | + <path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z"/> | |
| 146 | + <circle cx="12" cy="12" r="3"/> | |
| 147 | + </symbol> | |
| 148 | + <symbol id="hi-edit" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 149 | + <path d="M15.5 4.5a2.12 2.12 0 0 1 3 3L8 18l-4 1 1-4 10.5-10.5Z"/> | |
| 150 | + </symbol> | |
| 151 | + <symbol id="hi-log-out" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 152 | + <path d="M10 4H6.5A2.5 2.5 0 0 0 4 6.5v11A2.5 2.5 0 0 0 6.5 20H10m6-12 4 4-4 4m4-4H9.5"/> | |
| 153 | + </symbol> | |
| 154 | + <symbol id="hi-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 155 | + <path d="m3.5 10.5 8.5-7 8.5 7v8a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2v-8Z"/> | |
| 156 | + <path d="M9.5 20.5v-6h5v6"/> | |
| 157 | + </symbol> | |
| 158 | + <symbol id="hi-command" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 159 | + <path d="M18 3a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0-3-3H6a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3V6a3 3 0 0 0-3-3 3 3 0 0 0-3 3 3 3 0 0 0 3 3h12a3 3 0 0 0 3-3 3 3 0 0 0-3-3Z"/> | |
| 160 | + </symbol> | |
| 161 | + <symbol id="hi-keyboard" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 162 | + <rect x="2.5" y="5.5" width="19" height="13" rx="2.5"/> | |
| 163 | + <path d="M6.5 9.5h.01M10 9.5h.01M14 9.5h.01M17.5 9.5h.01M6.5 13h.01M17.5 13h.01M9 15h6"/> | |
| 164 | + </symbol> | |
| 165 | + <symbol id="hi-server" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 166 | + <rect x="3.5" y="4" width="17" height="6.5" rx="2"/> | |
| 167 | + <rect x="3.5" y="13.5" width="17" height="6.5" rx="2"/> | |
| 168 | + <path d="M7.5 7.25h.01M7.5 16.75h.01"/> | |
| 169 | + </symbol> | |
| 170 | + <symbol id="hi-database" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 171 | + <ellipse cx="12" cy="5.5" rx="7.5" ry="2.8"/> | |
| 172 | + <path d="M4.5 5.5v13c0 1.55 3.36 2.8 7.5 2.8s7.5-1.25 7.5-2.8v-13M4.5 12c0 1.55 3.36 2.8 7.5 2.8s7.5-1.25 7.5-2.8"/> | |
| 173 | + </symbol> | |
| 174 | + <symbol id="hi-zap" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 175 | + <path d="M13 2.5 4.5 13.5h6.5L10 21.5l9.5-11H13V2.5Z"/> | |
| 176 | + </symbol> | |
| 177 | + <symbol id="hi-more" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 178 | + <circle cx="5.5" cy="12" r="1.25" fill="currentColor"/> | |
| 179 | + <circle cx="12" cy="12" r="1.25" fill="currentColor"/> | |
| 180 | + <circle cx="18.5" cy="12" r="1.25" fill="currentColor"/> | |
| 181 | + </symbol> | |
| 182 | + <symbol id="hi-sidebar" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 183 | + <rect x="3" y="4" width="18" height="16" rx="2.5"/> | |
| 184 | + <path d="M9 4v16"/> | |
| 185 | + </symbol> | |
| 186 | + <symbol id="hi-map-pin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 187 | + <path d="M12 21s-6.5-5.6-6.5-10.5a6.5 6.5 0 1 1 13 0C18.5 15.4 12 21 12 21Z"/> | |
| 188 | + <circle cx="12" cy="10.5" r="2.2"/> | |
| 189 | + </symbol> | |
| 190 | + <symbol id="hi-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 191 | + <path d="M10 13a5 5 0 0 0 7.07 0l2.83-2.83a5 5 0 0 0-7.07-7.07L11 4.93"/> | |
| 192 | + <path d="M14 11a5 5 0 0 0-7.07 0L4.1 13.83a5 5 0 0 0 7.07 7.07L13 19.07"/> | |
| 193 | + </symbol> | |
| 194 | + <symbol id="hi-calendar" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 195 | + <rect x="3.5" y="5" width="17" height="15.5" rx="2.5"/> | |
| 196 | + <path d="M8 3.5v3M16 3.5v3M3.5 9.5h17"/> | |
| 197 | + </symbol> | |
| 198 | + <symbol id="hi-message" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 199 | + <path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.4 8.4 0 0 1 3.8-.9h.5a8.5 8.5 0 0 1 8 8v.5Z"/> | |
| 200 | + </symbol> | |
| 201 | + <symbol id="hi-image" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 202 | + <rect x="3.5" y="3.5" width="17" height="17" rx="2.5"/> | |
| 203 | + <circle cx="8.5" cy="8.5" r="1.5"/> | |
| 204 | + <path d="m20.5 15-4.5-4.5L6 20.5"/> | |
| 205 | + </symbol> | |
| 206 | + <symbol id="hi-issue" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 207 | + <circle cx="12" cy="12" r="9"/> | |
| 208 | + <circle cx="12" cy="12" r="2" fill="currentColor"/> | |
| 209 | + </symbol> | |
| 210 | + <symbol id="hi-git-pull-request" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 211 | + <circle cx="18" cy="18" r="2.5"/> | |
| 212 | + <circle cx="6" cy="6" r="2.5"/> | |
| 213 | + <path d="M13 6h3a2 2 0 0 1 2 2v7.5"/> | |
| 214 | + <path d="M6 8.5v12"/> | |
| 215 | + <polyline points="15 9 12 6 15 3"/> | |
| 216 | + </symbol> | |
| 217 | + <symbol id="hi-activity" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| 218 | + <path d="M22 12h-4l-3 9L9 3l-3 9H2"/> | |
| 219 | + </symbol> | |
| 220 | +</svg> | |
| 221 | + |
internal/web/static/js/app.js
+467
-0
| ... | ... | @@ -0,0 +1,467 @@ |
| 1 | +(function () { | |
| 2 | + 'use strict'; | |
| 3 | + | |
| 4 | + function showToast(message) { | |
| 5 | + var container = document.getElementById('toast-container'); | |
| 6 | + if (!container) return; | |
| 7 | + var toast = document.createElement('div'); | |
| 8 | + toast.className = 'toast'; | |
| 9 | + toast.setAttribute('role', 'status'); | |
| 10 | + toast.textContent = message; | |
| 11 | + container.appendChild(toast); | |
| 12 | + setTimeout(function () { | |
| 13 | + if (toast.parentNode) toast.parentNode.removeChild(toast); | |
| 14 | + }, 2800); | |
| 15 | + } | |
| 16 | + window.PolarToast = showToast; | |
| 17 | + | |
| 18 | + function setTheme(theme, silent) { | |
| 19 | + var allowed = ['light', 'dark', 'system']; | |
| 20 | + if (allowed.indexOf(theme) === -1) theme = 'system'; | |
| 21 | + document.documentElement.setAttribute('data-theme', theme); | |
| 22 | + try { | |
| 23 | + localStorage.setItem('polar_theme', theme); | |
| 24 | + } catch (e) {} | |
| 25 | + document.cookie = 'polar_theme=' + encodeURIComponent(theme) + '; path=/; max-age=31536000; SameSite=Lax'; | |
| 26 | + | |
| 27 | + document.querySelectorAll('[data-set-theme]').forEach(function (el) { | |
| 28 | + var isCurrent = el.getAttribute('data-set-theme') === theme; | |
| 29 | + el.classList.toggle('active', isCurrent); | |
| 30 | + el.setAttribute('aria-checked', isCurrent ? 'true' : 'false'); | |
| 31 | + }); | |
| 32 | + } | |
| 33 | + window.PolarSetTheme = setTheme; | |
| 34 | + | |
| 35 | + try { | |
| 36 | + var currentAttr = document.documentElement.getAttribute('data-theme') || 'system'; | |
| 37 | + document.querySelectorAll('[data-set-theme]').forEach(function (el) { | |
| 38 | + el.classList.toggle('active', el.getAttribute('data-set-theme') === currentAttr); | |
| 39 | + }); | |
| 40 | + } catch (e) {} | |
| 41 | + | |
| 42 | + function toggleSidebar() { | |
| 43 | + var html = document.documentElement; | |
| 44 | + var isCollapsed = html.getAttribute('data-sidebar') === 'collapsed'; | |
| 45 | + if (isCollapsed) { | |
| 46 | + html.removeAttribute('data-sidebar'); | |
| 47 | + try { | |
| 48 | + localStorage.setItem('polar_sidebar', 'expanded'); | |
| 49 | + } catch (e) {} | |
| 50 | + } else { | |
| 51 | + html.setAttribute('data-sidebar', 'collapsed'); | |
| 52 | + try { | |
| 53 | + localStorage.setItem('polar_sidebar', 'collapsed'); | |
| 54 | + } catch (e) {} | |
| 55 | + } | |
| 56 | + } | |
| 57 | + window.PolarToggleSidebar = toggleSidebar; | |
| 58 | + | |
| 59 | + function closeAllDropdowns() { | |
| 60 | + document.querySelectorAll('[data-dropdown-menu].open').forEach(function (menu) { | |
| 61 | + menu.classList.remove('open'); | |
| 62 | + var parent = menu.closest('[data-dropdown]'); | |
| 63 | + if (parent) { | |
| 64 | + var btn = parent.querySelector('[data-dropdown-trigger]'); | |
| 65 | + if (btn) btn.setAttribute('aria-expanded', 'false'); | |
| 66 | + } | |
| 67 | + }); | |
| 68 | + } | |
| 69 | + | |
| 70 | + function openDialog(id) { | |
| 71 | + var dlg = document.getElementById(id); | |
| 72 | + if (!dlg) return; | |
| 73 | + closeAllDropdowns(); | |
| 74 | + dlg.classList.add('open'); | |
| 75 | + dlg.setAttribute('aria-hidden', 'false'); | |
| 76 | + var firstInput = dlg.querySelector('input:not([type="hidden"]), button[data-autofocus]'); | |
| 77 | + if (firstInput) { | |
| 78 | + setTimeout(function () { firstInput.focus(); }, 20); | |
| 79 | + } | |
| 80 | + } | |
| 81 | + window.PolarOpenDialog = openDialog; | |
| 82 | + | |
| 83 | + function closeDialog(dlg) { | |
| 84 | + if (!dlg) return; | |
| 85 | + dlg.classList.remove('open'); | |
| 86 | + dlg.setAttribute('aria-hidden', 'true'); | |
| 87 | + } | |
| 88 | + | |
| 89 | + // Command Palette logic | |
| 90 | + var paletteIndex = 0; | |
| 91 | + | |
| 92 | + function getVisiblePaletteItems() { | |
| 93 | + var list = document.querySelectorAll('#command-palette [data-palette-item]'); | |
| 94 | + var visible = []; | |
| 95 | + list.forEach(function (item) { | |
| 96 | + if (item.style.display !== 'none') visible.push(item); | |
| 97 | + }); | |
| 98 | + return visible; | |
| 99 | + } | |
| 100 | + | |
| 101 | + function updatePaletteSelection() { | |
| 102 | + var items = getVisiblePaletteItems(); | |
| 103 | + if (paletteIndex >= items.length) paletteIndex = 0; | |
| 104 | + if (paletteIndex < 0) paletteIndex = Math.max(0, items.length - 1); | |
| 105 | + items.forEach(function (el, idx) { | |
| 106 | + el.classList.toggle('selected', idx === paletteIndex); | |
| 107 | + if (idx === paletteIndex) { | |
| 108 | + el.scrollIntoView({ block: 'nearest' }); | |
| 109 | + } | |
| 110 | + }); | |
| 111 | + } | |
| 112 | + | |
| 113 | + function filterPalette(query) { | |
| 114 | + var q = (query || '').trim().toLowerCase(); | |
| 115 | + var allItems = document.querySelectorAll('#command-palette [data-palette-item]'); | |
| 116 | + allItems.forEach(function (item) { | |
| 117 | + var text = (item.getAttribute('data-palette-keywords') || item.textContent || '').toLowerCase(); | |
| 118 | + var show = q === '' || text.indexOf(q) !== -1; | |
| 119 | + item.style.display = show ? '' : 'none'; | |
| 120 | + }); | |
| 121 | + document.querySelectorAll('#command-palette [data-palette-group]').forEach(function (group) { | |
| 122 | + var hasVisible = false; | |
| 123 | + group.querySelectorAll('[data-palette-item]').forEach(function (it) { | |
| 124 | + if (it.style.display !== 'none') hasVisible = true; | |
| 125 | + }); | |
| 126 | + group.style.display = hasVisible ? '' : 'none'; | |
| 127 | + }); | |
| 128 | + paletteIndex = 0; | |
| 129 | + updatePaletteSelection(); | |
| 130 | + } | |
| 131 | + | |
| 132 | + function openPalette(initialQuery) { | |
| 133 | + var palette = document.getElementById('command-palette'); | |
| 134 | + if (!palette) return; | |
| 135 | + closeAllDropdowns(); | |
| 136 | + palette.classList.add('open'); | |
| 137 | + palette.setAttribute('aria-hidden', 'false'); | |
| 138 | + var input = document.getElementById('command-palette-input'); | |
| 139 | + if (input) { | |
| 140 | + input.value = initialQuery || ''; | |
| 141 | + filterPalette(input.value); | |
| 142 | + setTimeout(function () { | |
| 143 | + input.focus(); | |
| 144 | + if (initialQuery) input.select(); | |
| 145 | + }, 20); | |
| 146 | + } | |
| 147 | + } | |
| 148 | + window.PolarOpenPalette = openPalette; | |
| 149 | + | |
| 150 | + function activatePaletteItem(item) { | |
| 151 | + if (!item) return; | |
| 152 | + var palette = document.getElementById('command-palette'); | |
| 153 | + var href = item.getAttribute('data-palette-href'); | |
| 154 | + var theme = item.getAttribute('data-palette-theme'); | |
| 155 | + var action = item.getAttribute('data-palette-action'); | |
| 156 | + if (palette) closeDialog(palette); | |
| 157 | + | |
| 158 | + if (theme) { | |
| 159 | + setTheme(theme, false); | |
| 160 | + } else if (action === 'shortcuts') { | |
| 161 | + openDialog('shortcuts-dialog'); | |
| 162 | + } else if (action === 'toggle-sidebar') { | |
| 163 | + toggleSidebar(); | |
| 164 | + } else if (href) { | |
| 165 | + window.location.href = href; | |
| 166 | + } | |
| 167 | + } | |
| 168 | + | |
| 169 | + document.addEventListener('click', function (e) { | |
| 170 | + // Toggle sidebar | |
| 171 | + var sbBtn = e.target.closest('[data-toggle-sidebar]'); | |
| 172 | + if (sbBtn) { | |
| 173 | + e.preventDefault(); | |
| 174 | + toggleSidebar(); | |
| 175 | + return; | |
| 176 | + } | |
| 177 | + | |
| 178 | + // Dropdown trigger | |
| 179 | + var trigger = e.target.closest('[data-dropdown-trigger]'); | |
| 180 | + if (trigger) { | |
| 181 | + e.stopPropagation(); | |
| 182 | + var dropdown = trigger.closest('[data-dropdown]'); | |
| 183 | + var menu = dropdown ? dropdown.querySelector('[data-dropdown-menu]') : null; | |
| 184 | + var wasOpen = menu && menu.classList.contains('open'); | |
| 185 | + closeAllDropdowns(); | |
| 186 | + if (menu && !wasOpen) { | |
| 187 | + menu.classList.add('open'); | |
| 188 | + trigger.setAttribute('aria-expanded', 'true'); | |
| 189 | + } | |
| 190 | + return; | |
| 191 | + } | |
| 192 | + | |
| 193 | + // Theme button | |
| 194 | + var themeBtn = e.target.closest('[data-set-theme]'); | |
| 195 | + if (themeBtn) { | |
| 196 | + e.preventDefault(); | |
| 197 | + setTheme(themeBtn.getAttribute('data-set-theme'), false); | |
| 198 | + closeAllDropdowns(); | |
| 199 | + return; | |
| 200 | + } | |
| 201 | + | |
| 202 | + // Open Command Palette | |
| 203 | + var paletteTrigger = e.target.closest('[data-open-palette]'); | |
| 204 | + if (paletteTrigger) { | |
| 205 | + e.preventDefault(); | |
| 206 | + openPalette(paletteTrigger.getAttribute('data-open-palette') || ''); | |
| 207 | + return; | |
| 208 | + } | |
| 209 | + | |
| 210 | + // Palette item click | |
| 211 | + var paletteItem = e.target.closest('[data-palette-item]'); | |
| 212 | + if (paletteItem) { | |
| 213 | + e.preventDefault(); | |
| 214 | + activatePaletteItem(paletteItem); | |
| 215 | + return; | |
| 216 | + } | |
| 217 | + | |
| 218 | + // Dialog open | |
| 219 | + var dlgOpen = e.target.closest('[data-dialog-open]'); | |
| 220 | + if (dlgOpen) { | |
| 221 | + e.preventDefault(); | |
| 222 | + openDialog(dlgOpen.getAttribute('data-dialog-open')); | |
| 223 | + return; | |
| 224 | + } | |
| 225 | + | |
| 226 | + // Dialog close | |
| 227 | + var dlgClose = e.target.closest('[data-dialog-close]'); | |
| 228 | + if (dlgClose) { | |
| 229 | + e.preventDefault(); | |
| 230 | + closeDialog(dlgClose.closest('.backdrop')); | |
| 231 | + return; | |
| 232 | + } | |
| 233 | + | |
| 234 | + // Backdrop click | |
| 235 | + if (e.target.classList.contains('backdrop')) { | |
| 236 | + closeDialog(e.target); | |
| 237 | + return; | |
| 238 | + } | |
| 239 | + | |
| 240 | + // Mobile drawer | |
| 241 | + var drawerOpen = e.target.closest('[data-drawer-open]'); | |
| 242 | + if (drawerOpen) { | |
| 243 | + var drw = document.getElementById('mobile-drawer'); | |
| 244 | + if (drw) drw.classList.add('open'); | |
| 245 | + return; | |
| 246 | + } | |
| 247 | + var drawerClose = e.target.closest('[data-drawer-close]'); | |
| 248 | + if (drawerClose || e.target.id === 'mobile-drawer') { | |
| 249 | + var drw2 = document.getElementById('mobile-drawer'); | |
| 250 | + if (drw2) drw2.classList.remove('open'); | |
| 251 | + return; | |
| 252 | + } | |
| 253 | + | |
| 254 | + // Copy to clipboard | |
| 255 | + var copyBtn = e.target.closest('[data-copy]'); | |
| 256 | + if (copyBtn) { | |
| 257 | + e.preventDefault(); | |
| 258 | + var text = copyBtn.getAttribute('data-copy'); | |
| 259 | + var copiedMsg = document.documentElement.getAttribute('data-copied-text') || 'Copied'; | |
| 260 | + if (navigator.clipboard && navigator.clipboard.writeText) { | |
| 261 | + navigator.clipboard.writeText(text).then(function () { | |
| 262 | + showToast(copiedMsg); | |
| 263 | + }); | |
| 264 | + } else { | |
| 265 | + var ta = document.createElement('textarea'); | |
| 266 | + ta.value = text; | |
| 267 | + document.body.appendChild(ta); | |
| 268 | + ta.select(); | |
| 269 | + document.execCommand('copy'); | |
| 270 | + document.body.removeChild(ta); | |
| 271 | + showToast(copiedMsg); | |
| 272 | + } | |
| 273 | + return; | |
| 274 | + } | |
| 275 | + | |
| 276 | + if (!e.target.closest('[data-dropdown-menu]')) { | |
| 277 | + closeAllDropdowns(); | |
| 278 | + } | |
| 279 | + }); | |
| 280 | + | |
| 281 | + // Input handlers (palette filter + delete repo confirmation match) | |
| 282 | + document.addEventListener('input', function (e) { | |
| 283 | + if (e.target.id === 'command-palette-input') { | |
| 284 | + filterPalette(e.target.value); | |
| 285 | + return; | |
| 286 | + } | |
| 287 | + | |
| 288 | + var matchExpected = e.target.getAttribute('data-confirm-input'); | |
| 289 | + if (matchExpected !== null) { | |
| 290 | + var btnId = e.target.getAttribute('data-confirm-button'); | |
| 291 | + var btn = btnId ? document.getElementById(btnId) : null; | |
| 292 | + if (btn) { | |
| 293 | + btn.disabled = e.target.value.trim() !== matchExpected; | |
| 294 | + } | |
| 295 | + } | |
| 296 | + | |
| 297 | + if (e.target.hasAttribute('data-file-filter')) { | |
| 298 | + var q = e.target.value.trim().toLowerCase(); | |
| 299 | + document.querySelectorAll('[data-tree-row]').forEach(function (row) { | |
| 300 | + var name = (row.getAttribute('data-tree-name') || '').toLowerCase(); | |
| 301 | + row.style.display = !q || name.indexOf(q) !== -1 ? '' : 'none'; | |
| 302 | + }); | |
| 303 | + } | |
| 304 | + }); | |
| 305 | + | |
| 306 | + // Keyboard navigation & chords | |
| 307 | + var lastChordKey = ''; | |
| 308 | + var lastChordTime = 0; | |
| 309 | + | |
| 310 | + document.addEventListener('keydown', function (e) { | |
| 311 | + var palette = document.getElementById('command-palette'); | |
| 312 | + var isPaletteOpen = palette && palette.classList.contains('open'); | |
| 313 | + | |
| 314 | + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') { | |
| 315 | + e.preventDefault(); | |
| 316 | + if (isPaletteOpen) { | |
| 317 | + closeDialog(palette); | |
| 318 | + } else { | |
| 319 | + openPalette(''); | |
| 320 | + } | |
| 321 | + return; | |
| 322 | + } | |
| 323 | + | |
| 324 | + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'b') { | |
| 325 | + e.preventDefault(); | |
| 326 | + toggleSidebar(); | |
| 327 | + return; | |
| 328 | + } | |
| 329 | + | |
| 330 | + if (e.key === 'Escape') { | |
| 331 | + closeAllDropdowns(); | |
| 332 | + document.querySelectorAll('.backdrop.open').forEach(function (b) { | |
| 333 | + closeDialog(b); | |
| 334 | + }); | |
| 335 | + var drw = document.getElementById('mobile-drawer'); | |
| 336 | + if (drw) drw.classList.remove('open'); | |
| 337 | + return; | |
| 338 | + } | |
| 339 | + | |
| 340 | + if (isPaletteOpen) { | |
| 341 | + var items = getVisiblePaletteItems(); | |
| 342 | + if (e.key === 'ArrowDown') { | |
| 343 | + e.preventDefault(); | |
| 344 | + paletteIndex = (paletteIndex + 1) % Math.max(1, items.length); | |
| 345 | + updatePaletteSelection(); | |
| 346 | + } else if (e.key === 'ArrowUp') { | |
| 347 | + e.preventDefault(); | |
| 348 | + paletteIndex = (paletteIndex - 1 + Math.max(1, items.length)) % Math.max(1, items.length); | |
| 349 | + updatePaletteSelection(); | |
| 350 | + } else if (e.key === 'Enter') { | |
| 351 | + e.preventDefault(); | |
| 352 | + if (items[paletteIndex]) activatePaletteItem(items[paletteIndex]); | |
| 353 | + } | |
| 354 | + return; | |
| 355 | + } | |
| 356 | + | |
| 357 | + var tag = e.target && e.target.tagName ? e.target.tagName.toUpperCase() : ''; | |
| 358 | + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (e.target && e.target.isContentEditable)) { | |
| 359 | + return; | |
| 360 | + } | |
| 361 | + if (e.ctrlKey || e.metaKey || e.altKey) return; | |
| 362 | + | |
| 363 | + var now = Date.now(); | |
| 364 | + if (lastChordKey === 'g' && now - lastChordTime < 900) { | |
| 365 | + lastChordKey = ''; | |
| 366 | + if (e.key.toLowerCase() === 'h') { | |
| 367 | + e.preventDefault(); | |
| 368 | + window.location.href = '/'; | |
| 369 | + return; | |
| 370 | + } | |
| 371 | + if (e.key.toLowerCase() === 'r') { | |
| 372 | + e.preventDefault(); | |
| 373 | + window.location.href = '/repos'; | |
| 374 | + return; | |
| 375 | + } | |
| 376 | + } | |
| 377 | + | |
| 378 | + if (e.key.toLowerCase() === 'g') { | |
| 379 | + lastChordKey = 'g'; | |
| 380 | + lastChordTime = now; | |
| 381 | + return; | |
| 382 | + } | |
| 383 | + | |
| 384 | + if (e.key === '/') { | |
| 385 | + e.preventDefault(); | |
| 386 | + var searchInput = document.querySelector('[data-search-input]'); | |
| 387 | + if (searchInput) { | |
| 388 | + searchInput.focus(); | |
| 389 | + } else { | |
| 390 | + openPalette(''); | |
| 391 | + } | |
| 392 | + return; | |
| 393 | + } | |
| 394 | + | |
| 395 | + if (e.key === '?') { | |
| 396 | + e.preventDefault(); | |
| 397 | + openDialog('shortcuts-dialog'); | |
| 398 | + return; | |
| 399 | + } | |
| 400 | + | |
| 401 | + if (e.key.toLowerCase() === 't') { | |
| 402 | + e.preventDefault(); | |
| 403 | + var fileFilter = document.querySelector('[data-file-filter]'); | |
| 404 | + if (fileFilter) { | |
| 405 | + fileFilter.focus(); | |
| 406 | + } else { | |
| 407 | + openPalette('file:'); | |
| 408 | + } | |
| 409 | + } | |
| 410 | + }); | |
| 411 | + | |
| 412 | + // Avatar file picker preview & client-side 256x256 square crop/resize | |
| 413 | + document.addEventListener('change', function (e) { | |
| 414 | + if (e.target && e.target.id === 'avatar-file-input') { | |
| 415 | + var file = e.target.files && e.target.files[0]; | |
| 416 | + if (!file) return; | |
| 417 | + var reader = new FileReader(); | |
| 418 | + reader.onload = function (ev) { | |
| 419 | + var img = new Image(); | |
| 420 | + img.onload = function () { | |
| 421 | + var size = 256; | |
| 422 | + var canvas = document.createElement('canvas'); | |
| 423 | + canvas.width = size; | |
| 424 | + canvas.height = size; | |
| 425 | + var ctx = canvas.getContext('2d'); | |
| 426 | + var minSide = Math.min(img.width, img.height); | |
| 427 | + var sx = (img.width - minSide) / 2; | |
| 428 | + var sy = (img.height - minSide) / 2; | |
| 429 | + ctx.drawImage(img, sx, sy, minSide, minSide, 0, 0, size, size); | |
| 430 | + var dataUrl = canvas.toDataURL('image/webp', 0.88); | |
| 431 | + if (!dataUrl || dataUrl.indexOf('data:image/webp') !== 0) { | |
| 432 | + dataUrl = canvas.toDataURL('image/jpeg', 0.88); | |
| 433 | + } | |
| 434 | + var hidden = document.getElementById('avatar-data-input'); | |
| 435 | + var rmHidden = document.getElementById('remove-avatar-input'); | |
| 436 | + if (hidden) hidden.value = dataUrl; | |
| 437 | + if (rmHidden) rmHidden.value = 'false'; | |
| 438 | + var previewWrap = document.getElementById('avatar-preview-wrap'); | |
| 439 | + if (previewWrap) { | |
| 440 | + previewWrap.innerHTML = '<span class="avatar avatar-xl"><img src="' + dataUrl + '" alt="Avatar" class="avatar-img"></span>'; | |
| 441 | + } | |
| 442 | + }; | |
| 443 | + img.src = ev.target.result; | |
| 444 | + }; | |
| 445 | + reader.readAsDataURL(file); | |
| 446 | + } | |
| 447 | + }); | |
| 448 | + | |
| 449 | + document.addEventListener('click', function (e) { | |
| 450 | + var rmBtn = e.target.closest('#remove-avatar-btn'); | |
| 451 | + if (rmBtn) { | |
| 452 | + e.preventDefault(); | |
| 453 | + var rmHidden = document.getElementById('remove-avatar-input'); | |
| 454 | + var hidden = document.getElementById('avatar-data-input'); | |
| 455 | + var fileInput = document.getElementById('avatar-file-input'); | |
| 456 | + if (rmHidden) rmHidden.value = 'true'; | |
| 457 | + if (hidden) hidden.value = ''; | |
| 458 | + if (fileInput) fileInput.value = ''; | |
| 459 | + var previewWrap = document.getElementById('avatar-preview-wrap'); | |
| 460 | + if (previewWrap) { | |
| 461 | + previewWrap.innerHTML = '<span class="avatar avatar-xl">?</span>'; | |
| 462 | + } | |
| 463 | + rmBtn.style.display = 'none'; | |
| 464 | + } | |
| 465 | + }); | |
| 466 | +})(); | |
| 467 | + |
internal/web/static/js/htmx.min.js
+1
-0
| ... | ... | @@ -0,0 +1 @@ |
| 1 | +var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.3"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=de;Q.ajax=Rn;Q.find=r;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=h;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:dn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:i,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:dt,triggerEvent:de,triggerErrorEvent:fe,withExtensions:Ft};const o=["get","post","put","delete","patch"];const R=o.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function h(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function i(e,t){while(e&&!t(e)){e=c(e)}return e||null}function H(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;i(t,function(e){return!!(r=H(t,ue(e),n))});if(r!=="unset"){return r}}function d(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function N(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function A(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(A(e)){const t=N(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){C(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q('<body><template class="internal-htmx-wrapper">'+t+"</template></body>");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function D(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function M(t){const n=[];if(t){for(let e=0;e<t.length;e++){n.push(t[e])}}return n}function se(t,n){if(t){for(let e=0;e<t.length;e++){n(t[e])}}}function X(e){const t=e.getBoundingClientRect();const n=t.top;const r=t.bottom;return n<window.innerHeight&&r>=0}function le(e){const t=e.getRootNode&&e.getRootNode();if(t&&t instanceof window.ShadowRoot){return ne().body.contains(t.host)}else{return ne().body.contains(e)}}function F(e){return e.trim().split(/\s+/)}function ce(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){C(e);return null}}function B(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function U(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return vn(ne().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function r(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return r(ne(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(ne(),e)}}function E(){return window}function z(e,t){e=y(e);if(t){E().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function $(e){return e instanceof HTMLElement?e:null}function J(e){return typeof e==="string"?e:null}function f(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function K(e,t,n){e=ue(y(e));if(!e){return}if(n){E().setTimeout(function(){K(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function G(e,t,n){let r=ue(y(e));if(!r){return}if(n){E().setTimeout(function(){G(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=y(e);e.classList.toggle(t)}function Z(e,t){e=y(e);se(e.parentElement.children,function(e){G(e,t)});K(ue(e),t)}function g(e,t){e=ue(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||d(e,t)){return e}}while(e=e&&ue(c(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function ge(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function p(e,t,n){e=y(e);if(t.indexOf("closest ")===0){return[g(ue(e),ge(t.substr(8)))]}else if(t.indexOf("find ")===0){return[r(f(e),ge(t.substr(5)))]}else if(t==="next"){return[ue(e).nextElementSibling]}else if(t.indexOf("next ")===0){return[pe(e,ge(t.substr(5)),!!n)]}else if(t==="previous"){return[ue(e).previousElementSibling]}else if(t.indexOf("previous ")===0){return[me(e,ge(t.substr(9)),!!n)]}else if(t==="document"){return[document]}else if(t==="window"){return[window]}else if(t==="body"){return[document.body]}else if(t==="root"){return[m(e,!!n)]}else if(t==="host"){return[e.getRootNode().host]}else if(t.indexOf("global ")===0){return p(e,t.slice(7),true)}else{return M(f(m(e,!!n)).querySelectorAll(ge(t)))}}var pe=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=0;e<r.length;e++){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_PRECEDING){return o}}};var me=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=r.length-1;e>=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ae(e,t){if(typeof e!=="string"){return p(e,t)[0]}else{return p(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return r(f(t)||document,e)}else{return e}}function xe(e,t,n,r){if(k(t)){return{target:ne().body,event:J(e),listener:t,options:n}}else{return{target:y(e),event:J(t),listener:n,options:r}}}function ye(t,n,r,o){Vn(function(){const e=xe(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function be(t,n,r){Vn(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const ve=ne().createElement("output");function we(e,t){const n=re(e,t);if(n){if(n==="this"){return[Se(e,t)]}else{const r=p(e,n);if(r.length===0){C('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ue(i(e,function(e){return te(ue(e),t)!=null}))}function Ee(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ae(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Ce(t){const n=Q.config.attributesToSettle;for(let e=0;e<n.length;e++){if(t===n[e]){return true}}return false}function Oe(t,n){se(t.attributes,function(e){if(!n.hasAttribute(e.name)&&Ce(e.name)){t.removeAttribute(e.name)}});se(n.attributes,function(e){if(Ce(e.name)){t.setAttribute(e.name,e.value)}})}function Re(t,e){const n=Un(e);for(let e=0;e<n.length;e++){const r=n[e];try{if(r.isInlineSwap(t)){return true}}catch(e){C(e)}}return t==="outerHTML"}function He(e,o,i,t){t=t||ne();let n="#"+ee(o,"id");let s="outerHTML";if(e==="true"){}else if(e.indexOf(":")>0){s=e.substr(0,e.indexOf(":"));n=e.substr(e.indexOf(":")+1,e.length)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=p(t,n,false);if(r){se(r,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!Re(s,e)){t=f(n)}const r={shouldSwap:true,target:e,fragment:t};if(!de(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);_e(s,e,e,t,i);Te()}se(i.elts,function(e){de(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function Te(){const e=r("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=r("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){se(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){if(e.moveBefore){let e=r("#--htmx-preserve-pantry--");if(e==null){ne().body.insertAdjacentHTML("afterend","<div id='--htmx-preserve-pantry--'></div>");e=r("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Le(l,e,c){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=f(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Ne(e){return function(){G(e,Q.config.addedClass);kt(ue(e));Ae(f(e));de(e,"htmx:load")}}function Ae(e){const t="[autofocus]";const n=$(d(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function u(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;K(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ne(o))}}}function Ie(e,t){let n=0;while(n<e.length){t=(t<<5)-t+e.charCodeAt(n++)|0}return t}function Pe(t){let n=0;if(t.attributes){for(let e=0;e<t.attributes.length;e++){const r=t.attributes[e];if(r.value){n=Ie(r.name,n);n=Ie(r.value,n)}}}return n}function ke(t){const n=ie(t);if(n.onHandlers){for(let e=0;e<n.onHandlers.length;e++){const r=n.onHandlers[e];be(t,r.event,r.listener)}delete n.onHandlers}}function De(e){const t=ie(e);if(t.timeout){clearTimeout(t.timeout)}if(t.listenerInfos){se(t.listenerInfos,function(e){if(e.on){be(e.on,e.trigger,e.listener)}})}ke(e);se(Object.keys(t),function(e){delete t[e]})}function a(e){de(e,"htmx:beforeCleanupElement");De(e);if(e.children){se(e.children,function(e){a(e)})}}function Me(t,e,n){if(t instanceof Element&&t.tagName==="BODY"){return Ve(t,e,n)}let r;const o=t.previousSibling;const i=c(t);if(!i){return}u(i,t,e,n);if(o==null){r=i.firstChild}else{r=o.nextSibling}n.elts=n.elts.filter(function(e){return e!==t});while(r&&r!==t){if(r instanceof Element){n.elts.push(r)}r=r.nextSibling}a(t);if(t instanceof Element){t.remove()}else{t.parentNode.removeChild(t)}}function Xe(e,t,n){return u(e,e.firstChild,t,n)}function Fe(e,t,n){return u(c(e),e,t,n)}function Be(e,t,n){return u(e,null,t,n)}function Ue(e,t,n){return u(c(e),e.nextSibling,t,n)}function je(e){a(e);const t=c(e);if(t){return t.removeChild(e)}}function Ve(e,t,n){const r=e.firstChild;u(e,r,t,n);if(r){while(r.nextSibling){a(r.nextSibling);e.removeChild(r.nextSibling)}a(r);e.removeChild(r)}}function _e(t,e,n,r,o){switch(t){case"none":return;case"outerHTML":Me(n,r,o);return;case"afterbegin":Xe(n,r,o);return;case"beforebegin":Fe(n,r,o);return;case"beforeend":Be(n,r,o);return;case"afterend":Ue(n,r,o);return;case"delete":je(n);return;default:var i=Un(e);for(let e=0;e<i.length;e++){const s=i[e];try{const l=s.handleSwap(t,n,r,o);if(l){if(Array.isArray(l)){for(let e=0;e<l.length;e++){const c=l[e];if(c.nodeType!==Node.TEXT_NODE&&c.nodeType!==Node.COMMENT_NODE){o.tasks.push(Ne(c))}}}return}}catch(e){C(e)}}if(t==="innerHTML"){Ve(n,r,o)}else{_e(Q.config.defaultSwapStyle,e,n,r,o)}}}function ze(e,n,r){var t=x(e,"[hx-swap-oob], [data-hx-swap-oob]");se(t,function(e){if(Q.config.allowNestedOobSwaps||e.parentElement===null){const t=te(e,"hx-swap-oob");if(t!=null){He(t,e,n,r)}}else{e.removeAttribute("hx-swap-oob");e.removeAttribute("data-hx-swap-oob")}});return t.length>0}function $e(e,t,r,o){if(!o){o={}}e=y(e);const i=o.contextElement?m(o.contextElement,false):ne();const n=document.activeElement;let s={};try{s={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const l=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=P(t);l.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t<u.length;t++){const a=u[t].split(":",2);let e=a[0].trim();if(e.indexOf("#")===0){e=e.substring(1)}const f=a[1]||"true";const d=n.querySelector("#"+e);if(d){He(f,d,l,i)}}}ze(n,l,i);se(x(n,"template"),function(e){if(ze(e.content,l,i)){e.remove()}});if(o.select){const h=ne().createDocumentFragment();se(n.querySelectorAll(o.select),function(e){h.appendChild(e)});n=h}qe(n);_e(r.swapStyle,o.contextElement,e,n,l);Te()}if(s.elt&&!le(s.elt)&&ee(s.elt,"id")){const g=document.getElementById(ee(s.elt,"id"));const p={preventScroll:r.focusScroll!==undefined?!r.focusScroll:!Q.config.defaultFocusScroll};if(g){if(s.start&&g.setSelectionRange){try{g.setSelectionRange(s.start,s.end)}catch(e){}}g.focus(p)}}e.classList.remove(Q.config.swappingClass);se(l.elts,function(e){if(e.classList){e.classList.add(Q.config.settlingClass)}de(e,"htmx:afterSwap",o.eventInfo)});if(o.afterSwapCallback){o.afterSwapCallback()}if(!r.ignoreTitle){kn(l.title)}const c=function(){se(l.tasks,function(e){e.call()});se(l.elts,function(e){if(e.classList){e.classList.remove(Q.config.settlingClass)}de(e,"htmx:afterSettle",o.eventInfo)});if(o.anchor){const e=ue(y("#"+o.anchor));if(e){e.scrollIntoView({block:"start",behavior:"auto"})}}yn(l.elts,r);if(o.afterSettleCallback){o.afterSettleCallback()}};if(r.settleDelay>0){E().setTimeout(c,r.settleDelay)}else{c()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(D(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}de(n,i,e)}}}else{const s=r.split(",");for(let e=0;e<s.length;e++){de(n,s[e].trim(),[])}}}const Ke=/\s/;const b=/[\s,]/;const Ge=/[_$a-zA-Z]/;const We=/[_$a-zA-Z0-9]/;const Ze=['"',"'","/"];const v=/[^\s]/;const Ye=/[{(]/;const Qe=/[})]/;function et(e){const t=[];let n=0;while(n<e.length){if(Ge.exec(e.charAt(n))){var r=n;while(We.exec(e.charAt(n+1))){n++}t.push(e.substr(r,n-r+1))}else if(Ze.indexOf(e.charAt(n))!==-1){const o=e.charAt(n);var r=n;n++;while(n<e.length&&e.charAt(n)!==o){if(e.charAt(n)==="\\"){n++}n++}t.push(e.substr(r,n-r+1))}else{const i=e.charAt(n);t.push(i)}n++}return t}function tt(e,t,n){return Ge.exec(e.charAt(0))&&e!=="true"&&e!=="false"&&e!=="this"&&e!==n&&t!=="."}function nt(r,o,i){if(o[0]==="["){o.shift();let e=1;let t=" return (function("+i+"){ return (";let n=null;while(o.length>0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function w(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=w(e,Qe).trim();e.shift()}else{t=w(e,b)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{w(o,v);const l=o.length;const c=w(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};w(o,v);u.pollInterval=h(w(o,/[,\[\s]/));w(o,v);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const a={trigger:c};var i=nt(e,o,"event");if(i){a.eventFilter=i}w(o,v);while(o.length>0&&o[0]!==","){const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=h(w(o,b))}else if(f==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=w(o,b);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const d=rt(o);if(d.length>0){s+=" "+d}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=rt(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=h(w(o,b))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=w(o,b)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=rt(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=w(o,b)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}w(o,v)}r.push(a)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}w(o,v)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(d(e,"form")){return[{trigger:"submit"}]}else if(d(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(d(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!gt(n,e,Mt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function at(e){return g(e,Q.config.disableSelector)}function ft(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){pt(t,function(e,t){const n=ue(e);if(at(n)){a(n);return}he(r,o,n,t)},n,e,true)})}}function dt(e,t){const n=ue(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(d(n,'input[type="submit"], button')&&g(n,"form")!==null){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function ht(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function gt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function pt(l,c,e,u,a){const f=ie(l);let t;if(u.from){t=p(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in f)){f.lastValue=new WeakMap}t.forEach(function(e){if(!f.lastValue.has(u)){f.lastValue.set(u,new WeakMap)}f.lastValue.get(u).set(e,e.value)})}se(t,function(i){const s=function(e){if(!le(l)){i.removeEventListener(u.trigger,s);return}if(ht(l,e)){return}if(a||dt(e,l)){e.preventDefault()}if(gt(u,l,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!d(ue(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=event.target;const r=n.value;const o=f.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){de(l,"htmx:trigger");c(l,e);f.throttle=E().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=E().setTimeout(function(){de(l,"htmx:trigger");c(l,e)},u.delay)}else{de(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let xt=null;function yt(){if(!xt){xt=function(){mt=true};window.addEventListener("scroll",xt);window.addEventListener("resize",xt);setInterval(function(){if(mt){mt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&X(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){de(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){de(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;se(o,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(g(n,Q.config.disableSelector)){a(n);return}he(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){yt();pt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ae(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e<t.length;e++){const n=t[e];if(n.isIntersecting){de(r,"intersect");break}}},o);i.observe(ue(r));pt(ue(r),n,t,e)}else if(e.trigger==="load"){if(!gt(e,r,Mt("load",{elt:r}))){vt(ue(r),n,t,e.delay)}}else if(e.pollInterval>0){t.polling=true;ct(ue(r),n,e)}else{pt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e<n.length;e++){const r=n[e].name;if(l(r,"hx-on:")||l(r,"data-hx-on:")||l(r,"hx-on-")||l(r,"data-hx-on-")){return true}}return false}const Ct=(new XPathEvaluator).createExpression('.//*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") or'+' starts-with(name(), "hx-on-") or starts-with(name(), "data-hx-on-") ]]');function Ot(e,t){if(Et(e)){t.push(ue(e))}const n=Ct.evaluate(e);let r=null;while(r=n.iterateNext())t.push(ue(r))}function Rt(e){const t=[];if(e instanceof DocumentFragment){for(const n of e.childNodes){Ot(n,t)}}else{Ot(e,t)}return t}function Ht(e){if(e.querySelectorAll){const n=", [hx-boost] a, [data-hx-boost] a, a[hx-boost], a[data-hx-boost]";const r=[];for(const i in Mn){const s=Mn[i];if(s.getSelectors){var t=s.getSelectors();if(t){r.push(t)}}}const o=e.querySelectorAll(R+n+", form, [type='submit'],"+" [hx-ext], [data-hx-ext], [hx-trigger], [data-hx-trigger]"+r.flat().map(e=>", "+e).join(""));return o}else{return[]}}function Tt(e){const t=g(ue(e.target),"button, input[type='submit']");const n=Lt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function Lt(e){const t=g(ue(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function Nt(e){e.addEventListener("click",Tt);e.addEventListener("focusin",Tt);e.addEventListener("focusout",qt)}function At(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(at(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function It(t){ke(t);for(let e=0;e<t.attributes.length;e++){const n=t.attributes[e].name;const r=t.attributes[e].value;if(l(n,"hx-on")||l(n,"data-hx-on")){const o=n.indexOf("-on")+3;const i=n.slice(o,o+1);if(i==="-"||i===":"){let e=n.slice(o+1);if(l(e,":")){e="htmx"+e}else if(l(e,"-")){e="htmx:"+e.slice(1)}else if(l(e,"htmx-")){e="htmx:"+e.slice(5)}At(t,e,r)}}}}function Pt(t){if(g(t,Q.config.disableSelector)){a(t);return}const n=ie(t);if(n.initHash!==Pe(t)){De(t);n.initHash=Pe(t);de(t,"htmx:beforeProcessNode");const e=st(t);const r=wt(t,n,e);if(!r){if(re(t,"hx-boost")==="true"){ft(t,n,e)}else if(s(t,"hx-trigger")){e.forEach(function(e){St(t,e,n,function(){})})}}if(t.tagName==="FORM"||ee(t,"type")==="submit"&&s(t,"form")){Nt(t)}de(t,"htmx:afterProcessNode")}}function kt(e){e=y(e);if(g(e,Q.config.disableSelector)){a(e);return}Pt(e);se(Ht(e),function(e){Pt(e)});se(Rt(e),It)}function Dt(e){return e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase()}function Mt(e,t){let n;if(window.CustomEvent&&typeof window.CustomEvent==="function"){n=new CustomEvent(e,{bubbles:true,cancelable:true,composed:true,detail:t})}else{n=ne().createEvent("CustomEvent");n.initCustomEvent(e,true,true,t)}return n}function fe(e,t,n){de(e,t,ce({error:t},n))}function Xt(e){return e==="htmx:afterProcessNode"}function Ft(e,t){se(Un(e),function(e){try{t(e)}catch(e){C(e)}})}function C(e){if(console.error){console.error(e)}else if(console.log){console.log("ERROR: ",e)}}function de(e,t,n){e=y(e);if(n==null){n={}}n.elt=e;const r=Mt(t,n);if(Q.logger&&!Xt(t)){Q.logger(e,t,n)}if(n.error){C(n.error);de(e,"htmx:error",{errorInfo:n})}let o=e.dispatchEvent(r);const i=Dt(t);if(o&&i!==t){const s=Mt(i,r.detail);o=o&&e.dispatchEvent(s)}Ft(ue(e),function(e){o=o&&(e.onEvent(t,r)!==false&&!r.defaultPrevented)});return o}let Bt=location.pathname+location.search;function Ut(){const e=ne().querySelector("[hx-history-elt],[data-hx-history-elt]");return e||ne().body}function jt(t,e){if(!B()){return}const n=_t(e);const r=ne().title;const o=window.scrollY;if(Q.config.historyCacheSize<=0){localStorage.removeItem("htmx-history-cache");return}t=U(t);const i=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e<i.length;e++){if(i[e].url===t){i.splice(e,1);break}}const s={url:t,content:n,title:r,scroll:o};de(ne().body,"htmx:historyItemCreated",{item:s,cache:i});i.push(s);while(i.length>Q.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Vt(t){if(!B()){return null}t=U(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e<n.length;e++){if(n[e].url===t){return n[e]}}return null}function _t(e){const t=Q.config.requestClass;const n=e.cloneNode(true);se(x(n,"."+t),function(e){G(e,t)});se(x(n,"[data-disabled-by-htmx]"),function(e){e.removeAttribute("disabled")});return n.innerHTML}function zt(){const e=Ut();const t=Bt||location.pathname+location.search;let n;try{n=ne().querySelector('[hx-history="false" i],[data-hx-history="false" i]')}catch(e){n=ne().querySelector('[hx-history="false"],[data-hx-history="false"]')}if(!n){de(ne().body,"htmx:beforeHistorySave",{path:t,historyElt:e});jt(t,e)}if(Q.config.historyEnabled)history.replaceState({htmx:true},ne().title,window.location.href)}function $t(e){if(Q.config.getCacheBusterParam){e=e.replace(/org\.htmx\.cache-buster=[^&]*&?/,"");if(Y(e,"&")||Y(e,"?")){e=e.slice(0,-1)}}if(Q.config.historyEnabled){history.pushState({htmx:true},"",e)}Bt=e}function Jt(e){if(Q.config.historyEnabled)history.replaceState({htmx:true},"",e);Bt=e}function Kt(e){se(e,function(e){e.call(undefined)})}function Gt(o){const e=new XMLHttpRequest;const i={path:o,xhr:e};de(ne().body,"htmx:historyCacheMiss",i);e.open("GET",o,true);e.setRequestHeader("HX-Request","true");e.setRequestHeader("HX-History-Restore-Request","true");e.setRequestHeader("HX-Current-URL",ne().location.href);e.onload=function(){if(this.status>=200&&this.status<400){de(ne().body,"htmx:historyCacheMissLoad",i);const e=P(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=Ut();const r=xn(n);kn(e.title);qe(e);Ve(n,t,r);Te();Kt(r.tasks);Bt=o;de(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{fe(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Wt(e){zt();e=e||location.pathname+location.search;const t=Vt(e);if(t){const n=P(t.content);const r=Ut();const o=xn(r);kn(t.title);qe(n);Ve(r,n,o);Te();Kt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Bt=e;de(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Gt(e)}}}function Zt(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Yt(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function Qt(e,t){se(e.concat(t),function(e){const t=ie(e);t.requestCount=(t.requestCount||1)-1});se(e,function(e){const t=ie(e);if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function en(t,n){for(let e=0;e<t.length;e++){const r=t[e];if(r.isSameNode(n)){return true}}return false}function tn(e){const t=e;if(t.name===""||t.name==null||t.disabled||g(t,"fieldset[disabled]")){return false}if(t.type==="button"||t.type==="submit"||t.tagName==="image"||t.tagName==="reset"||t.tagName==="file"){return false}if(t.type==="checkbox"||t.type==="radio"){return t.checked}return true}function nn(t,e,n){if(t!=null&&e!=null){if(Array.isArray(e)){e.forEach(function(e){n.append(t,e)})}else{n.append(t,e)}}}function rn(t,n,r){if(t!=null&&n!=null){let e=r.getAll(t);if(Array.isArray(n)){e=e.filter(e=>n.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function on(t,n,r,o,i){if(o==null||en(t,o)){return}else{t.push(o)}if(tn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=M(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=M(o.files)}nn(s,e,n);if(i){sn(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){rn(e.name,e.value,n)}else{t.push(e)}if(i){sn(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}nn(t,e,n)})}}function sn(e,t){const n=e;if(n.willValidate){de(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});de(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function ln(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){on(n,o,i,g(e,"form"),l)}on(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const a=ee(u,"name");nn(a,u.value,o)}const c=we(e,"hx-include");se(c,function(e){on(n,r,i,ue(e),l);if(!d(e,"form")){se(f(e).querySelectorAll(ot),function(e){on(n,r,i,e,l)})}});ln(r,o);return{errors:i,formData:r,values:Nn(r)}}function un(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function an(e){e=qn(e);let n="";e.forEach(function(e,t){n=un(n,t,e)});return n}function fn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};bn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function dn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.substr(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function hn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function gn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!hn(e)){r.show="top"}if(n){const s=F(n);if(s.length>0){for(let e=0;e<s.length;e++){const l=s[e];if(l.indexOf("swap:")===0){r.swapDelay=h(l.substr(5))}else if(l.indexOf("settle:")===0){r.settleDelay=h(l.substr(7))}else if(l.indexOf("transition:")===0){r.transition=l.substr(11)==="true"}else if(l.indexOf("ignoreTitle:")===0){r.ignoreTitle=l.substr(12)==="true"}else if(l.indexOf("scroll:")===0){const c=l.substr(7);var o=c.split(":");const u=o.pop();var i=o.length>0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.substr(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const d=l.substr("focus-scroll:".length);r.focusScroll=d=="true"}else if(e==0){r.swapStyle=l}else{C("Unknown modifier in hx-swap: "+l)}}}}return r}function pn(e){return re(e,"hx-encoding")==="multipart/form-data"||d(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function mn(t,n,r){let o=null;Ft(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(pn(n)){return ln(new FormData,qn(r))}else{return an(r)}}}function xn(e){return{tasks:[],elts:[e]}}function yn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ae(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ae(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function bn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.substr(11);t=true}else if(e.indexOf("js:")===0){e=e.substr(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return bn(ue(c(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function wn(e,t){return bn(e,"hx-vars",true,t)}function Sn(e,t){return bn(e,"hx-vals",false,t)}function En(e){return ce(wn(e),Sn(e))}function Cn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function On(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function O(e,t){return t.test(e.getAllResponseHeaders())}function Rn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return he(t,n,null,null,{targetOverride:y(r)||ve,returnPromise:true})}else{let e=y(r.target);if(r.target&&!e||!e&&!y(r.source)){e=ve}return he(t,n,y(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return he(t,n,null,null,{returnPromise:true})}}function Hn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Tn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return de(e,"htmx:validateUrl",ce({url:o,sameHost:r},n))}function qn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Ln(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function Nn(r){return new Proxy(r,{get:function(e,t){if(typeof t==="symbol"){return Reflect.get(e,t)}if(t==="toJSON"){return()=>Object.fromEntries(r)}if(t in e){if(typeof e[t]==="function"){return function(){return r[t].apply(r,arguments)}}else{return e[t]}}const n=r.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Ln(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function he(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Dn;const X=i.select||null;if(!le(r)){oe(s);return e}const c=i.targetOverride||ue(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let u=ie(r);const a=u.lastButtonClicked;if(a){const L=ee(a,"formaction");if(L!=null){n=L}const N=ee(a,"formmethod");if(N!=null){if(N.toLowerCase()!=="dialog"){t=N}}}const f=re(r,"hx-confirm");if(D===undefined){const K=function(e){return he(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(de(r,"htmx:confirm",G)===false){oe(s);return e}}let d=r;let h=re(r,"hx-sync");let g=null;let F=false;if(h){const A=h.split(":");const I=A[0].trim();if(I==="this"){d=Se(r,"hx-sync")}else{d=ue(ae(r,I))}h=(A[1]||"drop").trim();u=ie(d);if(h==="drop"&&u.xhr&&u.abortable!==true){oe(s);return e}else if(h==="abort"){if(u.xhr){oe(s);return e}else{F=true}}else if(h==="replace"){de(d,"htmx:abort")}else if(h.indexOf("queue")===0){const W=h.split(" ");g=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){de(d,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(g==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(g==="all"){u.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(g==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){he(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;u.xhr=p;u.abortable=F;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const B=re(r,"hx-prompt");if(B){var x=prompt(B);if(x===null||!de(r,"htmx:prompt",{prompt:x,target:c})){oe(s);m();return e}}if(f&&!D){if(!confirm(f)){oe(s);m();return e}}let y=fn(r,c,x);if(t!=="get"&&!pn(r)){y["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){y=ce(y,i.headers)}const U=cn(r,t);let b=U.errors;const j=U.formData;if(i.values){ln(j,qn(i.values))}const V=qn(En(r));const v=ln(j,V);let w=dn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=bn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:w,parameters:Nn(w),unfilteredFormData:v,unfilteredParameters:Nn(v),headers:y,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!de(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;y=C.headers;w=qn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){de(r,"htmx:validation:halted",C);oe(s);m();return e}const z=n.split("#");const $=z[0];const O=z[1];let R=n;if(E){R=$;const Z=!w.keys().next().done;if(Z){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=an(w);if(O){R+="#"+O}}}if(!Tn(r,R,C)){fe(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),R,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in y){if(y.hasOwnProperty(k)){const Y=y[k];Cn(p,k,Y)}}}const H={xhr:p,target:c,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};p.onload=function(){try{const t=Hn(r);H.pathInfo.responsePath=On(p);M(r,H);if(H.keepIndicators!==true){Qt(T,q)}de(r,"htmx:afterRequest",H);de(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){de(e,"htmx:afterRequest",H);de(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){fe(r,"htmx:onLoadError",ce({error:e},H));throw e}};p.onerror=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);oe(l);m()};if(!de(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Zt(r);var q=Yt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){de(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});de(r,"htmx:beforeSend",H);const J=E?null:mn(p,r,w);p.send(J);return e}function An(e,t){const n=t.xhr;let r=null;let o=null;if(O(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(O(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(O(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const c=re(e,"hx-replace-url");const u=ie(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(c){a="replace";f=c}else if(u){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function In(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Pn(e){for(var t=0;t<Q.config.responseHandling.length;t++){var n=Q.config.responseHandling[t];if(In(n,e.status)){return n}}return{swap:false}}function kn(e){if(e){const t=r("title");if(t){t.innerHTML=e}else{window.document.title=e}}}function Dn(o,i){const s=i.xhr;let l=i.target;const e=i.etc;const c=i.select;if(!de(o,"htmx:beforeOnLoad",i))return;if(O(s,/HX-Trigger:/i)){Je(s,"HX-Trigger",o)}if(O(s,/HX-Location:/i)){zt();let e=s.getResponseHeader("HX-Location");var t;if(e.indexOf("{")===0){t=S(e);e=t.path;delete t.path}Rn("get",e,t).then(function(){$t(e)});return}const n=O(s,/HX-Refresh:/i)&&s.getResponseHeader("HX-Refresh")==="true";if(O(s,/HX-Redirect:/i)){i.keepIndicators=true;location.href=s.getResponseHeader("HX-Redirect");n&&location.reload();return}if(n){i.keepIndicators=true;location.reload();return}if(O(s,/HX-Retarget:/i)){if(s.getResponseHeader("HX-Retarget")==="this"){i.target=o}else{i.target=ue(ae(o,s.getResponseHeader("HX-Retarget")))}}const u=An(o,i);const r=Pn(s);const a=r.swap;let f=!!r.error;let d=Q.config.ignoreTitle||r.ignoreTitle;let h=r.select;if(r.target){i.target=ue(ae(o,r.target))}var g=e.swapOverride;if(g==null&&r.swapOverride){g=r.swapOverride}if(O(s,/HX-Retarget:/i)){if(s.getResponseHeader("HX-Retarget")==="this"){i.target=o}else{i.target=ue(ae(o,s.getResponseHeader("HX-Retarget")))}}if(O(s,/HX-Reswap:/i)){g=s.getResponseHeader("HX-Reswap")}var p=s.response;var m=ce({shouldSwap:a,serverResponse:p,isError:f,ignoreTitle:d,selectOverride:h,swapOverride:g},i);if(r.event&&!de(l,r.event,m))return;if(!de(l,"htmx:beforeSwap",m))return;l=m.target;p=m.serverResponse;f=m.isError;d=m.ignoreTitle;h=m.selectOverride;g=m.swapOverride;i.target=l;i.failed=f;i.successful=!f;if(m.shouldSwap){if(s.status===286){lt(o)}Ft(o,function(e){p=e.transformResponse(p,s,o)});if(u.type){zt()}var x=gn(o,g);if(!x.hasOwnProperty("ignoreTitle")){x.ignoreTitle=d}l.classList.add(Q.config.swappingClass);let n=null;let r=null;if(c){h=c}if(O(s,/HX-Reselect:/i)){h=s.getResponseHeader("HX-Reselect")}const y=re(o,"hx-select-oob");const b=re(o,"hx-select");let e=function(){try{if(u.type){de(ne().body,"htmx:beforeHistoryUpdate",ce({history:u},i));if(u.type==="push"){$t(u.path);de(ne().body,"htmx:pushedIntoHistory",{path:u.path})}else{Jt(u.path);de(ne().body,"htmx:replacedInHistory",{path:u.path})}}$e(l,p,x,{select:h||b,selectOOB:y,eventInfo:i,anchor:i.pathInfo.anchor,contextElement:o,afterSwapCallback:function(){if(O(s,/HX-Trigger-After-Swap:/i)){let e=o;if(!le(o)){e=ne().body}Je(s,"HX-Trigger-After-Swap",e)}},afterSettleCallback:function(){if(O(s,/HX-Trigger-After-Settle:/i)){let e=o;if(!le(o)){e=ne().body}Je(s,"HX-Trigger-After-Settle",e)}oe(n)}})}catch(e){fe(o,"htmx:swapError",i);oe(r);throw e}};let t=Q.config.globalViewTransitions;if(x.hasOwnProperty("transition")){t=x.transition}if(t&&de(o,"htmx:beforeTransition",i)&&typeof Promise!=="undefined"&&document.startViewTransition){const v=new Promise(function(e,t){n=e;r=t});const w=e;e=function(){document.startViewTransition(function(){w();return v})}}if(x.swapDelay>0){E().setTimeout(e,x.swapDelay)}else{e()}}if(f){fe(o,"htmx:responseError",ce({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Mn={};function Xn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Fn(e,t){if(t.init){t.init(n)}Mn[e]=ce(Xn(),t)}function Bn(e){delete Mn[e]}function Un(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Mn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return Un(ue(c(e)),n,r)}var jn=false;ne().addEventListener("DOMContentLoaded",function(){jn=true});function Vn(e){if(jn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function _n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend","<style"+e+"> ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} </style>")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function $n(){const e=zn();if(e){Q.config=ce(Q.config,e)}}Vn(function(){$n();_n();let e=ne().body;kt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Wt();se(t,function(e){de(e,"htmx:restored",{document:ne(),triggerEvent:de})})}else{if(n){n(e)}}};E().setTimeout(function(){de(e,"htmx:load",{});e=null},0)});return Q}(); |
internal/web/views/layouts/base.html
+381
-0
| ... | ... | @@ -0,0 +1,381 @@ |
| 1 | +<!DOCTYPE html> | |
| 2 | +<html lang="{{.Lang}}" data-theme="{{if .Theme}}{{.Theme}}{{else}}system{{end}}" data-copied-text="{{t .Lang "common.copied"}}"> | |
| 3 | +<head> | |
| 4 | + <meta charset="utf-8"> | |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 6 | + <link rel="icon" type="image/svg+xml" href="/static/favicon.svg"> | |
| 7 | + <title>{{if .Title}}{{.Title}} · Polar{{else}}Polar{{end}}</title> | |
| 8 | + <meta name="description" content="{{t .Lang "app.tagline"}}"> | |
| 9 | + <script> | |
| 10 | + (function(){ | |
| 11 | + try { | |
| 12 | + var saved = localStorage.getItem('polar_theme'); | |
| 13 | + if (saved && ['light','dark','system'].indexOf(saved) !== -1) { | |
| 14 | + document.documentElement.setAttribute('data-theme', saved); | |
| 15 | + } | |
| 16 | + var sb = localStorage.getItem('polar_sidebar'); | |
| 17 | + if (sb === 'collapsed') { | |
| 18 | + document.documentElement.setAttribute('data-sidebar', 'collapsed'); | |
| 19 | + } | |
| 20 | + } catch(e) {} | |
| 21 | + })(); | |
| 22 | + </script> | |
| 23 | + <style>{{inlineCSS}}</style> | |
| 24 | + <link rel="stylesheet" href="/static/css/app.css?v={{assetVer}}"> | |
| 25 | +</head> | |
| 26 | +<body> | |
| 27 | + {{sprite}} | |
| 28 | + | |
| 29 | + <!-- Floating button to expand sidebar when collapsed --> | |
| 30 | + <button type="button" class="sidebar-expand-btn" data-toggle-sidebar title="{{t .Lang "nav.toggle_sidebar"}} (Ctrl+B)" aria-label="{{t .Lang "nav.toggle_sidebar"}}"> | |
| 31 | + {{icon "sidebar" "size-4"}} | |
| 32 | + </button> | |
| 33 | + | |
| 34 | + <div class="app-shell"> | |
| 35 | + <!-- Left Sidebar --> | |
| 36 | + <aside class="app-sidebar" aria-label="Sidebar"> | |
| 37 | + <div class="sidebar-inner"> | |
| 38 | + <div class="flex flex-col gap-2 min-w-0"> | |
| 39 | + <!-- Brand, Language & Collapse Sidebar --> | |
| 40 | + <div class="flex items-center justify-between px-3 py-2" style="padding:0.25rem 0.35rem;"> | |
| 41 | + <a href="/" class="font-semibold text-base tracking-tight" style="letter-spacing:-0.02em;">Polar</a> | |
| 42 | + <div class="flex items-center gap-1"> | |
| 43 | + {{if eq .Lang "en"}} | |
| 44 | + <a href="?lang=ru" class="btn btn-ghost btn-sm font-mono" title="{{t .Lang "nav.lang"}}">RU</a> | |
| 45 | + {{else}} | |
| 46 | + <a href="?lang=en" class="btn btn-ghost btn-sm font-mono" title="{{t .Lang "nav.lang"}}">EN</a> | |
| 47 | + {{end}} | |
| 48 | + <button type="button" class="btn btn-ghost btn-icon" style="width:1.65rem;height:1.65rem;" data-toggle-sidebar title="{{t .Lang "nav.toggle_sidebar"}} (Ctrl+B)" aria-label="{{t .Lang "nav.toggle_sidebar"}}"> | |
| 49 | + {{icon "sidebar" "size-4"}} | |
| 50 | + </button> | |
| 51 | + </div> | |
| 52 | + </div> | |
| 53 | + | |
| 54 | + <!-- Search / Command Palette Trigger --> | |
| 55 | + <button type="button" class="btn btn-outline w-full justify-between text-muted" style="padding:0 0.55rem;height:1.95rem;" data-open-palette=""> | |
| 56 | + <span class="flex items-center gap-2 text-xs"> | |
| 57 | + {{icon "search" "size-3.5"}} | |
| 58 | + <span>{{t .Lang "nav.search_placeholder"}}</span> | |
| 59 | + </span> | |
| 60 | + <kbd class="kbd">⌘K</kbd> | |
| 61 | + </button> | |
| 62 | + | |
| 63 | + <!-- Primary Navigation --> | |
| 64 | + <nav class="flex flex-col gap-1 mt-1"> | |
| 65 | + {{if .CurrentUser}} | |
| 66 | + <a href="/repos" class="nav-item"> | |
| 67 | + <span class="flex items-center gap-2">{{icon "git-branch" "size-4"}} {{t .Lang "nav.repositories"}}</span> | |
| 68 | + </a> | |
| 69 | + {{else}} | |
| 70 | + <a href="/" class="nav-item"> | |
| 71 | + <span class="flex items-center gap-2">{{icon "home" "size-4"}} {{t .Lang "nav.home"}}</span> | |
| 72 | + </a> | |
| 73 | + {{end}} | |
| 74 | + {{if .ExploreEnabled}} | |
| 75 | + <a href="/explore" class="nav-item"> | |
| 76 | + <span class="flex items-center gap-2">{{icon "globe" "size-4"}} {{t .Lang "nav.explore"}}</span> | |
| 77 | + </a> | |
| 78 | + {{end}} | |
| 79 | + {{if .CurrentUser}} | |
| 80 | + <a href="/settings" class="nav-item"> | |
| 81 | + <span class="flex items-center gap-2">{{icon "settings" "size-4"}} {{t .Lang "nav.settings"}}</span> | |
| 82 | + </a> | |
| 83 | + {{if .CurrentUser.IsAdmin}} | |
| 84 | + <a href="/admin" class="nav-item"> | |
| 85 | + <span class="flex items-center gap-2">{{icon "shield" "size-4"}} {{t .Lang "nav.admin"}}</span> | |
| 86 | + </a> | |
| 87 | + {{end}} | |
| 88 | + {{end}} | |
| 89 | + </nav> | |
| 90 | + | |
| 91 | + <!-- Quick Repository List in Sidebar --> | |
| 92 | + <div class="mt-2"> | |
| 93 | + <div class="nav-section-label"> | |
| 94 | + <span>{{t .Lang "nav.repositories"}}</span> | |
| 95 | + {{if .CurrentUser}} | |
| 96 | + <a href="/new" class="btn btn-ghost btn-icon" style="width:1.35rem;height:1.35rem;" title="{{t .Lang "repo.new.title"}}"> | |
| 97 | + {{icon "plus" "size-3.5"}} | |
| 98 | + </a> | |
| 99 | + {{end}} | |
| 100 | + </div> | |
| 101 | + <div class="flex flex-col gap-1"> | |
| 102 | + {{if .PaletteRepos}} | |
| 103 | + {{range .PaletteRepos}} | |
| 104 | + <a href="/{{.OwnerUsername}}/{{.Name}}" class="nav-item" style="height:1.8rem;font-size:0.8rem;"> | |
| 105 | + <span class="flex items-center gap-2 min-w-0 truncate"> | |
| 106 | + {{if .IsPrivate}} | |
| 107 | + {{icon "lock" "size-3.5"}} | |
| 108 | + {{else}} | |
| 109 | + {{icon "folder" "size-3.5"}} | |
| 110 | + {{end}} | |
| 111 | + <span class="truncate">{{.OwnerUsername}}/<strong>{{.Name}}</strong></span> | |
| 112 | + </span> | |
| 113 | + </a> | |
| 114 | + {{end}} | |
| 115 | + {{else}} | |
| 116 | + {{if .CurrentUser}} | |
| 117 | + <a href="/new" class="nav-item text-xs"> | |
| 118 | + <span class="flex items-center gap-1.5">{{icon "plus" "size-3.5"}} {{t .Lang "repo.new.title"}}</span> | |
| 119 | + </a> | |
| 120 | + {{end}} | |
| 121 | + {{end}} | |
| 122 | + </div> | |
| 123 | + </div> | |
| 124 | + </div> | |
| 125 | + | |
| 126 | + <!-- Sidebar Bottom: 3-Theme Switcher + Account --> | |
| 127 | + <div class="flex flex-col gap-2 pt-2" style="border-top:1px solid hsl(var(--border));"> | |
| 128 | + <div class="segmented" role="radiogroup" aria-label="{{t .Lang "nav.theme"}}"> | |
| 129 | + <button type="button" class="segmented-btn {{if eq .Theme "light"}}active{{end}}" data-set-theme="light" title="{{t .Lang "theme.light"}}"> | |
| 130 | + {{icon "sun" "size-3.5"}} | |
| 131 | + <span>{{t .Lang "theme.light"}}</span> | |
| 132 | + </button> | |
| 133 | + <button type="button" class="segmented-btn {{if eq .Theme "dark"}}active{{end}}" data-set-theme="dark" title="{{t .Lang "theme.dark"}}"> | |
| 134 | + {{icon "moon" "size-3.5"}} | |
| 135 | + <span>{{t .Lang "theme.dark"}}</span> | |
| 136 | + </button> | |
| 137 | + <button type="button" class="segmented-btn {{if eq .Theme "system"}}active{{end}}" data-set-theme="system" title="{{t .Lang "theme.system"}}"> | |
| 138 | + {{icon "laptop" "size-3.5"}} | |
| 139 | + <span>{{t .Lang "theme.system"}}</span> | |
| 140 | + </button> | |
| 141 | + </div> | |
| 142 | + | |
| 143 | + {{if .CurrentUser}} | |
| 144 | + <div class="flex items-center justify-between gap-1" style="padding:0.15rem 0.25rem;"> | |
| 145 | + <a href="/{{.CurrentUser.Username}}" class="flex items-center gap-2 min-w-0 truncate text-sm font-medium"> | |
| 146 | + {{avatar .CurrentUser.Username .CurrentUser.AvatarURL}} | |
| 147 | + <span class="truncate">{{.CurrentUser.Username}}</span> | |
| 148 | + </a> | |
| 149 | + <div class="flex items-center gap-1"> | |
| 150 | + <form method="post" action="/logout" style="margin:0;"> | |
| 151 | + <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> | |
| 152 | + <button type="submit" class="btn btn-ghost btn-icon" style="width:1.65rem;height:1.65rem;" title="{{t .Lang "nav.logout"}}" aria-label="{{t .Lang "nav.logout"}}"> | |
| 153 | + {{icon "log-out" "size-3.5"}} | |
| 154 | + </button> | |
| 155 | + </form> | |
| 156 | + </div> | |
| 157 | + </div> | |
| 158 | + {{else}} | |
| 159 | + <div class="flex flex-col gap-1.5"> | |
| 160 | + <a href="/login" class="btn btn-outline btn-sm w-full">{{t .Lang "nav.login"}}</a> | |
| 161 | + {{if .RegistrationEnabled}} | |
| 162 | + <a href="/register" class="btn btn-primary btn-sm w-full">{{t .Lang "nav.register"}}</a> | |
| 163 | + {{end}} | |
| 164 | + </div> | |
| 165 | + {{end}} | |
| 166 | + </div> | |
| 167 | + </div> | |
| 168 | + </aside> | |
| 169 | + | |
| 170 | + <!-- Main Content Column --> | |
| 171 | + <div class="app-col"> | |
| 172 | + <!-- Mobile Topbar (only visible on small screens) --> | |
| 173 | + <div class="mobile-topbar"> | |
| 174 | + <div class="flex items-center gap-2"> | |
| 175 | + <button type="button" class="btn btn-ghost btn-icon" data-drawer-open aria-label="Menu"> | |
| 176 | + {{icon "menu" "size-4"}} | |
| 177 | + </button> | |
| 178 | + <a href="/" class="font-semibold text-base tracking-tight" style="letter-spacing:-0.02em;">Polar</a> | |
| 179 | + </div> | |
| 180 | + <div class="flex items-center gap-1.5"> | |
| 181 | + <button type="button" class="btn btn-ghost btn-icon" data-open-palette="" aria-label="{{t .Lang "nav.command_palette"}}"> | |
| 182 | + {{icon "search" "size-4"}} | |
| 183 | + </button> | |
| 184 | + {{if .CurrentUser}} | |
| 185 | + <a href="/new" class="btn btn-primary btn-sm">{{icon "plus" "size-3.5"}}</a> | |
| 186 | + {{else}} | |
| 187 | + <a href="/login" class="btn btn-outline btn-sm">{{t .Lang "nav.login"}}</a> | |
| 188 | + {{end}} | |
| 189 | + </div> | |
| 190 | + </div> | |
| 191 | + | |
| 192 | + <main class="app-main"> | |
| 193 | + {{if .Flash}} | |
| 194 | + <div class="container mb-4"> | |
| 195 | + <div class="card p-3 flex items-center justify-between gap-2" role="status" style="border-color:hsl(var(--primary) / 0.4);"> | |
| 196 | + <span class="flex items-center gap-2 text-sm">{{icon "check" "size-4"}} {{.Flash}}</span> | |
| 197 | + </div> | |
| 198 | + </div> | |
| 199 | + {{end}} | |
| 200 | + {{if .Error}} | |
| 201 | + <div class="container mb-4"> | |
| 202 | + <div class="card p-3 flex items-center justify-between gap-2" role="alert" style="border-color:hsl(var(--destructive) / 0.6);color:hsl(var(--destructive));"> | |
| 203 | + <span class="flex items-center gap-2 text-sm">{{icon "alert-circle" "size-4"}} {{.Error}}</span> | |
| 204 | + </div> | |
| 205 | + </div> | |
| 206 | + {{end}} | |
| 207 | + | |
| 208 | + {{template "content" .}} | |
| 209 | + </main> | |
| 210 | + </div> | |
| 211 | + </div> | |
| 212 | + | |
| 213 | + <!-- Mobile Drawer --> | |
| 214 | + <div id="mobile-drawer" class="mobile-drawer" aria-hidden="true"> | |
| 215 | + <div class="mobile-drawer-panel"> | |
| 216 | + <div class="flex flex-col gap-2"> | |
| 217 | + <div class="flex items-center justify-between" style="padding-bottom:0.5rem;border-bottom:1px solid hsl(var(--border));"> | |
| 218 | + <span class="font-semibold text-base tracking-tight" style="letter-spacing:-0.02em;">Polar</span> | |
| 219 | + <button type="button" class="btn btn-ghost btn-icon" data-drawer-close aria-label="{{t .Lang "common.close"}}"> | |
| 220 | + {{icon "close" "size-4"}} | |
| 221 | + </button> | |
| 222 | + </div> | |
| 223 | + <nav class="flex flex-col gap-1"> | |
| 224 | + <a href="/" class="nav-item">{{icon "home" "size-4"}} {{t .Lang "nav.home"}}</a> | |
| 225 | + {{if .CurrentUser}} | |
| 226 | + <a href="/repos" class="nav-item">{{icon "git-branch" "size-4"}} {{t .Lang "nav.repositories"}}</a> | |
| 227 | + <a href="/new" class="nav-item">{{icon "plus" "size-4"}} {{t .Lang "repo.new.title"}}</a> | |
| 228 | + <a href="/settings" class="nav-item">{{icon "settings" "size-4"}} {{t .Lang "nav.settings"}}</a> | |
| 229 | + {{if .CurrentUser.IsAdmin}} | |
| 230 | + <a href="/admin" class="nav-item">{{icon "shield" "size-4"}} {{t .Lang "nav.admin"}}</a> | |
| 231 | + {{end}} | |
| 232 | + {{end}} | |
| 233 | + {{if .ExploreEnabled}} | |
| 234 | + <a href="/explore" class="nav-item">{{icon "globe" "size-4"}} {{t .Lang "nav.explore"}}</a> | |
| 235 | + {{end}} | |
| 236 | + </nav> | |
| 237 | + </div> | |
| 238 | + <div class="segmented"> | |
| 239 | + <button type="button" class="segmented-btn" data-set-theme="light">{{t .Lang "theme.light"}}</button> | |
| 240 | + <button type="button" class="segmented-btn" data-set-theme="dark">{{t .Lang "theme.dark"}}</button> | |
| 241 | + <button type="button" class="segmented-btn" data-set-theme="system">{{t .Lang "theme.system"}}</button> | |
| 242 | + </div> | |
| 243 | + </div> | |
| 244 | + </div> | |
| 245 | + | |
| 246 | + <!-- Command Palette (Ctrl/Cmd+K) --> | |
| 247 | + <div id="command-palette" class="backdrop" role="dialog" aria-modal="true" aria-label="{{t .Lang "nav.command_palette"}}" aria-hidden="true"> | |
| 248 | + <div class="palette-box"> | |
| 249 | + <div class="palette-input-wrap"> | |
| 250 | + {{icon "search" "size-4"}} | |
| 251 | + <input id="command-palette-input" type="text" class="palette-input" placeholder="{{t .Lang "palette.placeholder"}}" autocomplete="off"> | |
| 252 | + <kbd class="kbd">ESC</kbd> | |
| 253 | + </div> | |
| 254 | + <div class="palette-list" role="listbox"> | |
| 255 | + <div data-palette-group> | |
| 256 | + <div class="palette-group-title">{{t .Lang "palette.group.nav"}}</div> | |
| 257 | + <button type="button" class="palette-item" data-palette-item data-palette-href="/" data-palette-keywords="home главная домой"> | |
| 258 | + <span class="flex items-center gap-2">{{icon "home" "size-4"}} {{t .Lang "nav.home"}}</span> | |
| 259 | + <span class="flex items-center gap-1"><kbd class="kbd">g</kbd><kbd class="kbd">h</kbd></span> | |
| 260 | + </button> | |
| 261 | + <button type="button" class="palette-item" data-palette-item data-palette-href="/repos" data-palette-keywords="repositories repos репозитории"> | |
| 262 | + <span class="flex items-center gap-2">{{icon "git-branch" "size-4"}} {{t .Lang "nav.repositories"}}</span> | |
| 263 | + <span class="flex items-center gap-1"><kbd class="kbd">g</kbd><kbd class="kbd">r</kbd></span> | |
| 264 | + </button> | |
| 265 | + {{if .ExploreEnabled}} | |
| 266 | + <button type="button" class="palette-item" data-palette-item data-palette-href="/explore" data-palette-keywords="explore обзор публичные"> | |
| 267 | + <span class="flex items-center gap-2">{{icon "globe" "size-4"}} {{t .Lang "nav.explore"}}</span> | |
| 268 | + </button> | |
| 269 | + {{end}} | |
| 270 | + {{if .CurrentUser}} | |
| 271 | + <button type="button" class="palette-item" data-palette-item data-palette-href="/new" data-palette-keywords="new create создать репозиторий"> | |
| 272 | + <span class="flex items-center gap-2">{{icon "plus" "size-4"}} {{t .Lang "repo.new.title"}}</span> | |
| 273 | + </button> | |
| 274 | + <button type="button" class="palette-item" data-palette-item data-palette-href="/settings" data-palette-keywords="settings tokens настройки токены"> | |
| 275 | + <span class="flex items-center gap-2">{{icon "settings" "size-4"}} {{t .Lang "nav.settings"}}</span> | |
| 276 | + </button> | |
| 277 | + {{if .CurrentUser.IsAdmin}} | |
| 278 | + <button type="button" class="palette-item" data-palette-item data-palette-href="/admin" data-palette-keywords="admin админка"> | |
| 279 | + <span class="flex items-center gap-2">{{icon "shield" "size-4"}} {{t .Lang "nav.admin"}}</span> | |
| 280 | + </button> | |
| 281 | + {{end}} | |
| 282 | + {{else}} | |
| 283 | + <button type="button" class="palette-item" data-palette-item data-palette-href="/login" data-palette-keywords="login войти"> | |
| 284 | + <span class="flex items-center gap-2">{{icon "user" "size-4"}} {{t .Lang "nav.login"}}</span> | |
| 285 | + </button> | |
| 286 | + <button type="button" class="palette-item" data-palette-item data-palette-href="/register" data-palette-keywords="register регистрация"> | |
| 287 | + <span class="flex items-center gap-2">{{icon "plus" "size-4"}} {{t .Lang "nav.register"}}</span> | |
| 288 | + </button> | |
| 289 | + {{end}} | |
| 290 | + <button type="button" class="palette-item" data-palette-item data-palette-action="toggle-sidebar" data-palette-keywords="sidebar toggle боковая панель скрыть"> | |
| 291 | + <span class="flex items-center gap-2">{{icon "sidebar" "size-4"}} {{t .Lang "nav.toggle_sidebar"}}</span> | |
| 292 | + <span class="flex items-center gap-1"><kbd class="kbd">Ctrl</kbd><kbd class="kbd">B</kbd></span> | |
| 293 | + </button> | |
| 294 | + </div> | |
| 295 | + | |
| 296 | + {{if .PaletteFiles}} | |
| 297 | + <div data-palette-group> | |
| 298 | + <div class="palette-group-title">{{t .Lang "repo.files"}} ({{.Repo.OwnerUsername}}/{{.Repo.Name}})</div> | |
| 299 | + {{range .PaletteFiles}} | |
| 300 | + <button type="button" class="palette-item" data-palette-item data-palette-href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/blob/{{$.Ref}}/{{.}}" data-palette-keywords="file: {{.}}"> | |
| 301 | + <span class="flex items-center gap-2 font-mono text-xs">{{icon "file-code" "size-4"}} {{.}}</span> | |
| 302 | + </button> | |
| 303 | + {{end}} | |
| 304 | + </div> | |
| 305 | + {{end}} | |
| 306 | + | |
| 307 | + {{if .PaletteRepos}} | |
| 308 | + <div data-palette-group> | |
| 309 | + <div class="palette-group-title">{{t .Lang "palette.group.repos"}}</div> | |
| 310 | + {{range .PaletteRepos}} | |
| 311 | + <button type="button" class="palette-item" data-palette-item data-palette-href="/{{.OwnerUsername}}/{{.Name}}" data-palette-keywords="repo {{.OwnerUsername}}/{{.Name}} {{.Description}}"> | |
| 312 | + <span class="flex items-center gap-2"> | |
| 313 | + {{if .IsPrivate}}{{icon "lock" "size-4"}}{{else}}{{icon "git-branch" "size-4"}}{{end}} | |
| 314 | + <span>{{.OwnerUsername}}/<strong>{{.Name}}</strong></span> | |
| 315 | + </span> | |
| 316 | + <span class="text-xs text-muted">{{if .IsPrivate}}{{t $.Lang "repo.private"}}{{else}}{{t $.Lang "repo.public"}}{{end}}</span> | |
| 317 | + </button> | |
| 318 | + {{end}} | |
| 319 | + </div> | |
| 320 | + {{end}} | |
| 321 | + | |
| 322 | + <div data-palette-group> | |
| 323 | + <div class="palette-group-title">{{t .Lang "palette.group.themes"}}</div> | |
| 324 | + <button type="button" class="palette-item" data-palette-item data-palette-theme="light" data-palette-keywords="theme light светлая"> | |
| 325 | + <span class="flex items-center gap-2">{{icon "sun" "size-4"}} {{t .Lang "theme.light"}}</span> | |
| 326 | + </button> | |
| 327 | + <button type="button" class="palette-item" data-palette-item data-palette-theme="dark" data-palette-keywords="theme dark темная тёмная"> | |
| 328 | + <span class="flex items-center gap-2">{{icon "moon" "size-4"}} {{t .Lang "theme.dark"}}</span> | |
| 329 | + </button> | |
| 330 | + <button type="button" class="palette-item" data-palette-item data-palette-theme="system" data-palette-keywords="theme system авто системная"> | |
| 331 | + <span class="flex items-center gap-2">{{icon "laptop" "size-4"}} {{t .Lang "theme.system"}}</span> | |
| 332 | + </button> | |
| 333 | + </div> | |
| 334 | + </div> | |
| 335 | + </div> | |
| 336 | + </div> | |
| 337 | + | |
| 338 | + <!-- Keyboard Shortcuts Dialog (?) --> | |
| 339 | + <div id="shortcuts-dialog" class="backdrop" role="dialog" aria-modal="true" aria-label="{{t .Lang "shortcuts.title"}}" aria-hidden="true"> | |
| 340 | + <div class="dialog-box"> | |
| 341 | + <div class="card-header"> | |
| 342 | + <span class="font-semibold flex items-center gap-2">{{icon "keyboard" "size-4"}} {{t .Lang "shortcuts.title"}}</span> | |
| 343 | + <button type="button" class="btn btn-ghost btn-icon" data-dialog-close aria-label="{{t .Lang "common.close"}}"> | |
| 344 | + {{icon "close" "size-4"}} | |
| 345 | + </button> | |
| 346 | + </div> | |
| 347 | + <div class="card-body flex flex-col gap-2 text-sm"> | |
| 348 | + <div class="flex items-center justify-between py-2" style="border-bottom:1px solid hsl(var(--border));"> | |
| 349 | + <span>{{t .Lang "shortcuts.palette"}}</span> | |
| 350 | + <span class="flex items-center gap-1"><kbd class="kbd">Ctrl</kbd><kbd class="kbd">K</kbd></span> | |
| 351 | + </div> | |
| 352 | + <div class="flex items-center justify-between py-2" style="border-bottom:1px solid hsl(var(--border));"> | |
| 353 | + <span>{{t .Lang "shortcuts.sidebar"}}</span> | |
| 354 | + <span class="flex items-center gap-1"><kbd class="kbd">Ctrl</kbd><kbd class="kbd">B</kbd></span> | |
| 355 | + </div> | |
| 356 | + <div class="flex items-center justify-between py-2" style="border-bottom:1px solid hsl(var(--border));"> | |
| 357 | + <span>{{t .Lang "shortcuts.search"}}</span> | |
| 358 | + <kbd class="kbd">/</kbd> | |
| 359 | + </div> | |
| 360 | + <div class="flex items-center justify-between py-2" style="border-bottom:1px solid hsl(var(--border));"> | |
| 361 | + <span>{{t .Lang "shortcuts.home"}}</span> | |
| 362 | + <span class="flex items-center gap-1"><kbd class="kbd">g</kbd><kbd class="kbd">h</kbd></span> | |
| 363 | + </div> | |
| 364 | + <div class="flex items-center justify-between py-2" style="border-bottom:1px solid hsl(var(--border));"> | |
| 365 | + <span>{{t .Lang "shortcuts.repos"}}</span> | |
| 366 | + <span class="flex items-center gap-1"><kbd class="kbd">g</kbd><kbd class="kbd">r</kbd></span> | |
| 367 | + </div> | |
| 368 | + <div class="flex items-center justify-between py-2"> | |
| 369 | + <span>{{t .Lang "shortcuts.files"}}</span> | |
| 370 | + <kbd class="kbd">t</kbd> | |
| 371 | + </div> | |
| 372 | + </div> | |
| 373 | + </div> | |
| 374 | + </div> | |
| 375 | + | |
| 376 | + <div id="toast-container" class="toast-container" aria-live="polite"></div> | |
| 377 | + | |
| 378 | + <script src="/static/js/htmx.min.js" defer></script> | |
| 379 | + <script src="/static/js/app.js?v={{assetVer}}" defer></script> | |
| 380 | +</body> | |
| 381 | +</html> |
internal/web/views/pages/activity.html
+107
-0
| ... | ... | @@ -0,0 +1,107 @@ |
| 1 | +{{define "content"}} | |
| 2 | +<div class="container"> | |
| 3 | + {{template "repo_header" .}} | |
| 4 | + | |
| 5 | + <!-- Pulse Statistics --> | |
| 6 | + {{if .Stats}} | |
| 7 | + <div class="grid-4 mb-4"> | |
| 8 | + <div class="card p-3"> | |
| 9 | + <div class="flex items-center justify-between text-muted text-xs font-semibold uppercase mb-1"> | |
| 10 | + <span>{{t .Lang "activity.commits"}}</span> | |
| 11 | + {{icon "git-commit" "size-4"}} | |
| 12 | + </div> | |
| 13 | + <div class="text-xl font-bold font-mono">{{.Stats.CommitCount}}</div> | |
| 14 | + <div class="text-xs text-muted mt-1">{{t .Lang "activity.on_default_branch"}}</div> | |
| 15 | + </div> | |
| 16 | + | |
| 17 | + <div class="card p-3"> | |
| 18 | + <div class="flex items-center justify-between text-muted text-xs font-semibold uppercase mb-1"> | |
| 19 | + <span>{{t .Lang "repo.tab.pulls"}}</span> | |
| 20 | + {{icon "git-pull-request" "size-4"}} | |
| 21 | + </div> | |
| 22 | + <div class="flex items-baseline gap-2 font-mono"> | |
| 23 | + <span class="text-xl font-bold" style="color:hsl(var(--primary));">{{.Stats.OpenPRs}}</span> | |
| 24 | + <span class="text-xs text-muted">/ {{.Stats.MergedPRs}} {{t .Lang "pulls.status.merged"}}</span> | |
| 25 | + </div> | |
| 26 | + <div class="text-xs text-muted mt-1">{{t .Lang "activity.pull_requests"}}</div> | |
| 27 | + </div> | |
| 28 | + | |
| 29 | + <div class="card p-3"> | |
| 30 | + <div class="flex items-center justify-between text-muted text-xs font-semibold uppercase mb-1"> | |
| 31 | + <span>{{t .Lang "repo.tab.issues"}}</span> | |
| 32 | + {{icon "issue" "size-4"}} | |
| 33 | + </div> | |
| 34 | + <div class="flex items-baseline gap-2 font-mono"> | |
| 35 | + <span class="text-xl font-bold" style="color:hsl(var(--primary));">{{.Stats.OpenIssues}}</span> | |
| 36 | + <span class="text-xs text-muted">/ {{.Stats.ClosedIssues}} {{t .Lang "issue.status.closed"}}</span> | |
| 37 | + </div> | |
| 38 | + <div class="text-xs text-muted mt-1">{{t .Lang "activity.issues"}}</div> | |
| 39 | + </div> | |
| 40 | + | |
| 41 | + <div class="card p-3"> | |
| 42 | + <div class="flex items-center justify-between text-muted text-xs font-semibold uppercase mb-1"> | |
| 43 | + <span>{{t .Lang "repo.tab.releases"}}</span> | |
| 44 | + {{icon "tag" "size-4"}} | |
| 45 | + </div> | |
| 46 | + <div class="text-xl font-bold font-mono">{{.Stats.ReleaseCount}}</div> | |
| 47 | + <div class="text-xs text-muted mt-1">{{t .Lang "activity.published_releases"}}</div> | |
| 48 | + </div> | |
| 49 | + </div> | |
| 50 | + {{end}} | |
| 51 | + | |
| 52 | + <!-- Activity Timeline --> | |
| 53 | + <div class="card"> | |
| 54 | + <div class="card-header flex items-center justify-between"> | |
| 55 | + <span class="font-semibold text-sm flex items-center gap-2"> | |
| 56 | + {{icon "activity" "size-4"}} | |
| 57 | + <span>{{t .Lang "activity.recent_feed"}}</span> | |
| 58 | + </span> | |
| 59 | + </div> | |
| 60 | + | |
| 61 | + {{if .Items}} | |
| 62 | + <div class="dense-list"> | |
| 63 | + {{range .Items}} | |
| 64 | + <div class="dense-row" style="min-height:3rem;padding:0.6rem 0.85rem;"> | |
| 65 | + <div class="flex items-center gap-3 min-w-0"> | |
| 66 | + <span class="text-muted flex-shrink-0" style="display:inline-flex;align-items:center;"> | |
| 67 | + {{if eq .Type "commit"}} | |
| 68 | + <span style="color:hsl(var(--primary));">{{icon "git-commit" "size-4"}}</span> | |
| 69 | + {{else if eq .Type "pull"}} | |
| 70 | + <span style="color:hsl(var(--accent));">{{icon "git-pull-request" "size-4"}}</span> | |
| 71 | + {{else if eq .Type "issue"}} | |
| 72 | + <span style="color:hsl(var(--primary));">{{icon "issue" "size-4"}}</span> | |
| 73 | + {{else if eq .Type "release"}} | |
| 74 | + <span style="color:hsl(var(--warning-fg, 38 92% 50%));">{{icon "tag" "size-4"}}</span> | |
| 75 | + {{else}} | |
| 76 | + {{icon "activity" "size-4"}} | |
| 77 | + {{end}} | |
| 78 | + </span> | |
| 79 | + | |
| 80 | + <div class="flex flex-col min-w-0"> | |
| 81 | + <div class="flex items-center gap-2 flex-wrap min-w-0"> | |
| 82 | + <a href="{{.Link}}" class="font-semibold text-sm truncate">{{.Title}}</a> | |
| 83 | + {{if .Subtitle}} | |
| 84 | + <span class="font-mono text-xs text-muted">{{.Subtitle}}</span> | |
| 85 | + {{end}} | |
| 86 | + {{if .Badge}} | |
| 87 | + <span class="badge badge-outline text-xs" style="padding:0.05rem 0.35rem;">{{activityBadge $.Lang .Type .Badge}}</span> | |
| 88 | + {{end}} | |
| 89 | + </div> | |
| 90 | + <div class="text-xs text-muted flex items-center gap-1.5 mt-0.5"> | |
| 91 | + {{if .AuthorUsername}} | |
| 92 | + <span>@{{.AuthorUsername}}</span> | |
| 93 | + <span>·</span> | |
| 94 | + {{end}} | |
| 95 | + <span>{{timeAgo .CreatedAt $.Lang}}</span> | |
| 96 | + </div> | |
| 97 | + </div> | |
| 98 | + </div> | |
| 99 | + </div> | |
| 100 | + {{end}} | |
| 101 | + </div> | |
| 102 | + {{else}} | |
| 103 | + <div class="card-body text-xs text-muted">{{t .Lang "activity.empty"}}</div> | |
| 104 | + {{end}} | |
| 105 | + </div> | |
| 106 | +</div> | |
| 107 | +{{end}} |
internal/web/views/pages/admin.html
+171
-0
| ... | ... | @@ -0,0 +1,171 @@ |
| 1 | +{{define "content"}} | |
| 2 | +<div class="container"> | |
| 3 | + <div class="flex items-center justify-between mb-4"> | |
| 4 | + <h1 class="text-lg font-semibold" style="margin:0;">{{t .Lang "admin.title"}}</h1> | |
| 5 | + </div> | |
| 6 | + | |
| 7 | + <!-- Feature Toggles (Modules & Access) --> | |
| 8 | + <section class="card mb-4"> | |
| 9 | + <div class="card-header"> | |
| 10 | + <span class="font-semibold flex items-center gap-2">{{icon "settings" "size-4"}} {{t .Lang "admin.features"}}</span> | |
| 11 | + </div> | |
| 12 | + <div class="dense-list"> | |
| 13 | + <!-- 1. Explore Page --> | |
| 14 | + <div class="dense-row" style="min-height:3.25rem;padding:0.65rem 0.9rem;"> | |
| 15 | + <div class="flex flex-col gap-1 min-w-0"> | |
| 16 | + <div class="flex items-center gap-2 font-medium text-sm"> | |
| 17 | + {{icon "globe" "size-4"}} | |
| 18 | + <span>{{t .Lang "admin.explore"}}</span> | |
| 19 | + {{if .ExploreEnabled}} | |
| 20 | + <span class="badge badge-primary">{{t .Lang "admin.enabled"}}</span> | |
| 21 | + {{else}} | |
| 22 | + <span class="badge badge-outline">{{t .Lang "admin.disabled"}}</span> | |
| 23 | + {{end}} | |
| 24 | + </div> | |
| 25 | + <div class="text-xs text-muted">{{t .Lang "admin.explore.desc"}}</div> | |
| 26 | + </div> | |
| 27 | + <form method="post" action="/admin/settings" style="margin:0;flex-shrink:0;"> | |
| 28 | + <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> | |
| 29 | + <input type="hidden" name="setting_key" value="explore_enabled"> | |
| 30 | + {{if .ExploreEnabled}} | |
| 31 | + <input type="hidden" name="setting_val" value="false"> | |
| 32 | + <button type="submit" class="btn btn-outline btn-sm">{{t .Lang "admin.disable_btn"}}</button> | |
| 33 | + {{else}} | |
| 34 | + <input type="hidden" name="setting_val" value="true"> | |
| 35 | + <button type="submit" class="btn btn-primary btn-sm">{{t .Lang "admin.enable_btn"}}</button> | |
| 36 | + {{end}} | |
| 37 | + </form> | |
| 38 | + </div> | |
| 39 | + | |
| 40 | + <!-- 2. Public Registration --> | |
| 41 | + <div class="dense-row" style="min-height:3.25rem;padding:0.65rem 0.9rem;"> | |
| 42 | + <div class="flex flex-col gap-1 min-w-0"> | |
| 43 | + <div class="flex items-center gap-2 font-medium text-sm"> | |
| 44 | + {{icon "user" "size-4"}} | |
| 45 | + <span>{{t .Lang "admin.registration"}}</span> | |
| 46 | + {{if .RegistrationEnabled}} | |
| 47 | + <span class="badge badge-primary">{{t .Lang "admin.enabled"}}</span> | |
| 48 | + {{else}} | |
| 49 | + <span class="badge badge-outline">{{t .Lang "admin.disabled"}}</span> | |
| 50 | + {{end}} | |
| 51 | + </div> | |
| 52 | + <div class="text-xs text-muted">{{t .Lang "admin.registration.desc"}}</div> | |
| 53 | + </div> | |
| 54 | + <form method="post" action="/admin/settings" style="margin:0;flex-shrink:0;"> | |
| 55 | + <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> | |
| 56 | + <input type="hidden" name="setting_key" value="registration_enabled"> | |
| 57 | + {{if .RegistrationEnabled}} | |
| 58 | + <input type="hidden" name="setting_val" value="false"> | |
| 59 | + <button type="submit" class="btn btn-outline btn-sm">{{t .Lang "admin.disable_btn"}}</button> | |
| 60 | + {{else}} | |
| 61 | + <input type="hidden" name="setting_val" value="true"> | |
| 62 | + <button type="submit" class="btn btn-primary btn-sm">{{t .Lang "admin.enable_btn"}}</button> | |
| 63 | + {{end}} | |
| 64 | + </form> | |
| 65 | + </div> | |
| 66 | + | |
| 67 | + | |
| 68 | + <!-- 4. Landing Page --> | |
| 69 | + <div class="dense-row" style="min-height:3.25rem;padding:0.65rem 0.9rem;"> | |
| 70 | + <div class="flex flex-col gap-1 min-w-0"> | |
| 71 | + <div class="flex items-center gap-2 font-medium text-sm"> | |
| 72 | + {{icon "home" "size-4"}} | |
| 73 | + <span>{{t .Lang "admin.landing"}}</span> | |
| 74 | + {{if .LandingEnabled}} | |
| 75 | + <span class="badge badge-primary">{{t .Lang "admin.enabled"}}</span> | |
| 76 | + {{else}} | |
| 77 | + <span class="badge badge-outline">{{t .Lang "admin.disabled"}}</span> | |
| 78 | + {{end}} | |
| 79 | + </div> | |
| 80 | + <div class="text-xs text-muted">{{t .Lang "admin.landing.desc"}}</div> | |
| 81 | + </div> | |
| 82 | + <form method="post" action="/admin/settings" style="margin:0;flex-shrink:0;"> | |
| 83 | + <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> | |
| 84 | + <input type="hidden" name="setting_key" value="landing_enabled"> | |
| 85 | + {{if .LandingEnabled}} | |
| 86 | + <input type="hidden" name="setting_val" value="false"> | |
| 87 | + <button type="submit" class="btn btn-outline btn-sm">{{t .Lang "admin.disable_btn"}}</button> | |
| 88 | + {{else}} | |
| 89 | + <input type="hidden" name="setting_val" value="true"> | |
| 90 | + <button type="submit" class="btn btn-primary btn-sm">{{t .Lang "admin.enable_btn"}}</button> | |
| 91 | + {{end}} | |
| 92 | + </form> | |
| 93 | + </div> | |
| 94 | + | |
| 95 | + <!-- 5. Anonymous Read Access --> | |
| 96 | + <div class="dense-row" style="min-height:3.25rem;padding:0.65rem 0.9rem;"> | |
| 97 | + <div class="flex flex-col gap-1 min-w-0"> | |
| 98 | + <div class="flex items-center gap-2 font-medium text-sm"> | |
| 99 | + {{icon "eye" "size-4"}} | |
| 100 | + <span>{{t .Lang "admin.anon_read"}}</span> | |
| 101 | + {{if .AnonymousReadEnabled}} | |
| 102 | + <span class="badge badge-primary">{{t .Lang "admin.enabled"}}</span> | |
| 103 | + {{else}} | |
| 104 | + <span class="badge badge-outline">{{t .Lang "admin.disabled"}}</span> | |
| 105 | + {{end}} | |
| 106 | + </div> | |
| 107 | + <div class="text-xs text-muted">{{t .Lang "admin.anon_read.desc"}}</div> | |
| 108 | + </div> | |
| 109 | + <form method="post" action="/admin/settings" style="margin:0;flex-shrink:0;"> | |
| 110 | + <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> | |
| 111 | + <input type="hidden" name="setting_key" value="anonymous_read_enabled"> | |
| 112 | + {{if .AnonymousReadEnabled}} | |
| 113 | + <input type="hidden" name="setting_val" value="false"> | |
| 114 | + <button type="submit" class="btn btn-outline btn-sm">{{t .Lang "admin.disable_btn"}}</button> | |
| 115 | + {{else}} | |
| 116 | + <input type="hidden" name="setting_val" value="true"> | |
| 117 | + <button type="submit" class="btn btn-primary btn-sm">{{t .Lang "admin.enable_btn"}}</button> | |
| 118 | + {{end}} | |
| 119 | + </form> | |
| 120 | + </div> | |
| 121 | + </div> | |
| 122 | + </section> | |
| 123 | + | |
| 124 | + <!-- Users Table --> | |
| 125 | + <section class="card"> | |
| 126 | + <div class="card-header"> | |
| 127 | + <span class="font-semibold flex items-center gap-2">{{icon "users" "size-4"}} {{t .Lang "admin.users"}} ({{len .Users}})</span> | |
| 128 | + </div> | |
| 129 | + <div class="table-wrap" style="border:0;border-radius:0;"> | |
| 130 | + <table class="table"> | |
| 131 | + <thead> | |
| 132 | + <tr> | |
| 133 | + <th>ID</th> | |
| 134 | + <th>{{t .Lang "admin.col.user"}}</th> | |
| 135 | + <th>{{t .Lang "auth.email"}}</th> | |
| 136 | + <th>{{t .Lang "admin.col.repos"}}</th> | |
| 137 | + <th>{{t .Lang "admin.col.role"}}</th> | |
| 138 | + <th>{{t .Lang "admin.col.joined"}}</th> | |
| 139 | + </tr> | |
| 140 | + </thead> | |
| 141 | + <tbody> | |
| 142 | + {{range .Users}} | |
| 143 | + <tr> | |
| 144 | + <td class="font-mono text-xs text-muted">#{{.ID}}</td> | |
| 145 | + <td> | |
| 146 | + <a href="/{{.Username}}" class="flex items-center gap-2 font-medium"> | |
| 147 | + {{avatar .Username .AvatarURL}} | |
| 148 | + <div class="flex flex-col"> | |
| 149 | + <span>{{.Username}}</span> | |
| 150 | + {{if .FullName}}<span class="text-xs text-muted">{{.FullName}}</span>{{end}} | |
| 151 | + </div> | |
| 152 | + </a> | |
| 153 | + </td> | |
| 154 | + <td class="text-xs text-muted">{{.Email}}</td> | |
| 155 | + <td class="font-mono text-xs">{{.RepoCount}}</td> | |
| 156 | + <td> | |
| 157 | + {{if .IsAdmin}} | |
| 158 | + <span class="badge badge-primary">{{t $.Lang "admin.role.admin"}}</span> | |
| 159 | + {{else}} | |
| 160 | + <span class="badge badge-outline">{{t $.Lang "admin.role.user"}}</span> | |
| 161 | + {{end}} | |
| 162 | + </td> | |
| 163 | + <td class="text-xs text-muted">{{timeAgo .CreatedAt $.Lang}}</td> | |
| 164 | + </tr> | |
| 165 | + {{end}} | |
| 166 | + </tbody> | |
| 167 | + </table> | |
| 168 | + </div> | |
| 169 | + </section> | |
| 170 | +</div> | |
| 171 | +{{end}} |
internal/web/views/pages/blob.html
+106
-0
| ... | ... | @@ -0,0 +1,106 @@ |
| 1 | +{{define "content"}} | |
| 2 | +<div class="container"> | |
| 3 | + {{template "repo_header" .}} | |
| 4 | + | |
| 5 | + <div class="flex items-center justify-between flex-wrap gap-2 mb-3"> | |
| 6 | + <div class="flex items-center gap-2 flex-wrap"> | |
| 7 | + <div class="dropdown" data-dropdown> | |
| 8 | + <button type="button" class="btn btn-outline btn-sm font-mono" data-dropdown-trigger aria-haspopup="true" aria-expanded="false"> | |
| 9 | + {{icon "git-branch" "size-3.5"}} | |
| 10 | + <span>{{.Ref}}</span> | |
| 11 | + {{icon "chevron-down" "size-3.5"}} | |
| 12 | + </button> | |
| 13 | + <div class="dropdown-menu" data-dropdown-menu role="menu" style="min-width:13rem;"> | |
| 14 | + <div class="palette-group-title">{{t .Lang "repo.branches"}}</div> | |
| 15 | + {{range .Branches}} | |
| 16 | + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/blob/{{.}}/{{$.SubPath}}" class="dropdown-item font-mono {{if eq . $.Ref}}active{{end}}" role="menuitem"> | |
| 17 | + <span class="flex items-center gap-2">{{icon "git-branch" "size-3.5"}} {{.}}</span> | |
| 18 | + {{if eq . $.Ref}}{{icon "check" "size-3.5"}}{{end}} | |
| 19 | + </a> | |
| 20 | + {{end}} | |
| 21 | + {{if .Tags}} | |
| 22 | + <hr class="separator" style="margin:0.25rem 0;"> | |
| 23 | + <div class="palette-group-title">{{t .Lang "repo.tags"}}</div> | |
| 24 | + {{range .Tags}} | |
| 25 | + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/blob/{{.}}/{{$.SubPath}}" class="dropdown-item font-mono {{if eq . $.Ref}}active{{end}}" role="menuitem"> | |
| 26 | + <span class="flex items-center gap-2">{{icon "tag" "size-3.5"}} {{.}}</span> | |
| 27 | + {{if eq . $.Ref}}{{icon "check" "size-3.5"}}{{end}} | |
| 28 | + </a> | |
| 29 | + {{end}} | |
| 30 | + {{end}} | |
| 31 | + </div> | |
| 32 | + </div> | |
| 33 | + | |
| 34 | + <nav class="breadcrumb font-mono text-xs" aria-label="File breadcrumb"> | |
| 35 | + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/tree/{{.Ref}}" class="text-muted">{{.Repo.Name}}</a> | |
| 36 | + {{range .Breadcrumbs}} | |
| 37 | + <span class="breadcrumb-sep">/</span> | |
| 38 | + {{if .IsLast}} | |
| 39 | + <span class="font-semibold">{{.Name}}</span> | |
| 40 | + {{else}} | |
| 41 | + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/tree/{{$.Ref}}/{{.Path}}" class="text-muted">{{.Name}}</a> | |
| 42 | + {{end}} | |
| 43 | + {{end}} | |
| 44 | + </nav> | |
| 45 | + </div> | |
| 46 | + | |
| 47 | + <div class="flex items-center gap-1.5"> | |
| 48 | + <button type="button" class="btn btn-outline btn-sm" data-open-palette="file:"> | |
| 49 | + {{icon "search" "size-3.5"}} | |
| 50 | + <kbd class="kbd">t</kbd> | |
| 51 | + </button> | |
| 52 | + </div> | |
| 53 | + </div> | |
| 54 | + | |
| 55 | + <div class="card"> | |
| 56 | + <div class="card-header" style="padding:0.5rem 0.8rem;"> | |
| 57 | + <div class="flex items-center gap-2 text-xs text-muted font-mono"> | |
| 58 | + {{icon "file-code" "size-4"}} | |
| 59 | + <span class="font-semibold" style="color:hsl(var(--foreground));">{{.Blob.Name}}</span> | |
| 60 | + <span>·</span> | |
| 61 | + {{if not .Blob.IsBinary}} | |
| 62 | + <span>{{.Blob.LineCount}} {{pluralT .Lang .Blob.LineCount "repo.lines"}}</span> | |
| 63 | + <span>·</span> | |
| 64 | + {{end}} | |
| 65 | + <span>{{formatSize .Blob.Size}}</span> | |
| 66 | + </div> | |
| 67 | + | |
| 68 | + <div class="flex items-center gap-1.5"> | |
| 69 | + {{if not .Blob.IsBinary}} | |
| 70 | + <button type="button" class="btn btn-outline btn-sm" data-copy="{{.RawText}}" aria-label="{{t .Lang "common.copy"}}"> | |
| 71 | + {{icon "copy" "size-3.5"}} | |
| 72 | + <span>{{t .Lang "common.copy"}}</span> | |
| 73 | + </button> | |
| 74 | + {{end}} | |
| 75 | + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.SubPath}}" class="btn btn-outline btn-sm"> | |
| 76 | + {{icon "download" "size-3.5"}} | |
| 77 | + <span>{{t .Lang "repo.raw"}}</span> | |
| 78 | + </a> | |
| 79 | + </div> | |
| 80 | + </div> | |
| 81 | + | |
| 82 | + {{if .Blob.IsBinary}} | |
| 83 | + <div class="card-body text-center py-2 text-sm text-muted"> | |
| 84 | + <p>{{t .Lang "repo.binary_notice"}} ({{formatSize .Blob.Size}})</p> | |
| 85 | + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.SubPath}}" class="btn btn-secondary btn-sm"> | |
| 86 | + {{icon "download" "size-3.5"}} | |
| 87 | + <span>{{t .Lang "repo.download_raw"}}</span> | |
| 88 | + </a> | |
| 89 | + </div> | |
| 90 | + {{else}} | |
| 91 | + <div style="overflow-x:auto;"> | |
| 92 | + <table class="code-table chroma"> | |
| 93 | + <tbody> | |
| 94 | + {{range .HighlightedLines}} | |
| 95 | + <tr id="L{{.Num}}"> | |
| 96 | + <td class="code-ln"><a href="#L{{.Num}}">{{.Num}}</a></td> | |
| 97 | + <td class="code-line">{{.HTML}}</td> | |
| 98 | + </tr> | |
| 99 | + {{end}} | |
| 100 | + </tbody> | |
| 101 | + </table> | |
| 102 | + </div> | |
| 103 | + {{end}} | |
| 104 | + </div> | |
| 105 | +</div> | |
| 106 | +{{end}} |
internal/web/views/pages/branches.html
+129
-0
| ... | ... | @@ -0,0 +1,129 @@ |
| 1 | +{{define "content"}} | |
| 2 | +<div class="container"> | |
| 3 | + {{template "repo_header" .}} | |
| 4 | + | |
| 5 | + <!-- Branches Section --> | |
| 6 | + <section class="card mb-4"> | |
| 7 | + <div class="card-header"> | |
| 8 | + <span class="font-semibold flex items-center gap-2"> | |
| 9 | + {{icon "git-branch" "size-4"}} | |
| 10 | + <span>{{t .Lang "repo.branches"}} ({{len .BranchDetails}})</span> | |
| 11 | + </span> | |
| 12 | + </div> | |
| 13 | + | |
| 14 | + {{if and .CanWrite (not .IsEmpty)}} | |
| 15 | + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/branches" class="card-body flex items-center flex-wrap gap-2" style="border-bottom:1px solid hsl(var(--border));background:hsl(var(--muted)/0.25);"> | |
| 16 | + <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> | |
| 17 | + <input name="branch_name" type="text" class="input font-mono" style="flex:1 1 12rem;height:1.9rem;" placeholder="{{t .Lang "repo.branch.name_ph"}}" required maxlength="100"> | |
| 18 | + <select name="from_ref" class="select font-mono" style="flex:0 1 11rem;height:1.9rem;"> | |
| 19 | + {{range .BranchDetails}} | |
| 20 | + <option value="{{.Name}}" {{if .IsDefault}}selected{{end}}>{{.Name}}</option> | |
| 21 | + {{end}} | |
| 22 | + </select> | |
| 23 | + <button type="submit" class="btn btn-primary btn-sm" style="height:1.9rem;"> | |
| 24 | + {{icon "plus" "size-3.5"}} | |
| 25 | + <span>{{t .Lang "repo.branch.create"}}</span> | |
| 26 | + </button> | |
| 27 | + </form> | |
| 28 | + {{end}} | |
| 29 | + | |
| 30 | + {{if .BranchDetails}} | |
| 31 | + <div class="dense-list"> | |
| 32 | + {{range .BranchDetails}} | |
| 33 | + <div class="dense-row" style="min-height:2.6rem;padding:0.45rem 0.85rem;"> | |
| 34 | + <div class="flex items-center gap-2 min-w-0"> | |
| 35 | + {{icon "git-branch" "size-4"}} | |
| 36 | + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/tree/{{.Name}}" class="font-mono font-semibold text-sm">{{.Name}}</a> | |
| 37 | + {{if .IsDefault}} | |
| 38 | + <span class="badge badge-primary">{{t $.Lang "repo.branch.default"}}</span> | |
| 39 | + {{end}} | |
| 40 | + {{if .Subject}} | |
| 41 | + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/commit/{{.Hash}}" class="text-xs text-muted truncate">— {{.Subject}}</a> | |
| 42 | + {{end}} | |
| 43 | + </div> | |
| 44 | + <div class="flex items-center gap-2 flex-shrink-0"> | |
| 45 | + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/commit/{{.Hash}}" class="badge badge-outline font-mono">{{.ShortHash}}</a> | |
| 46 | + <span class="text-xs text-muted">{{timeAgo .UpdatedAt $.Lang}}</span> | |
| 47 | + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/archive/{{.Name}}.zip" class="btn btn-ghost btn-icon" style="width:1.65rem;height:1.65rem;" title="{{t $.Lang "repo.download_zip"}}"> | |
| 48 | + {{icon "download" "size-3.5"}} | |
| 49 | + </a> | |
| 50 | + {{if and $.CanWrite (not .IsDefault)}} | |
| 51 | + <form method="post" action="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/branches/delete" style="margin:0;"> | |
| 52 | + <input type="hidden" name="_csrf" value="{{$.CSRFToken}}"> | |
| 53 | + <input type="hidden" name="branch_name" value="{{.Name}}"> | |
| 54 | + <button type="submit" class="btn btn-ghost btn-icon text-destructive" style="width:1.65rem;height:1.65rem;" title="{{t $.Lang "common.delete"}}"> | |
| 55 | + {{icon "trash" "size-3.5"}} | |
| 56 | + </button> | |
| 57 | + </form> | |
| 58 | + {{end}} | |
| 59 | + </div> | |
| 60 | + </div> | |
| 61 | + {{end}} | |
| 62 | + </div> | |
| 63 | + {{else}} | |
| 64 | + <div class="card-body text-xs text-muted">{{t .Lang "repo.no_commits"}}</div> | |
| 65 | + {{end}} | |
| 66 | + </section> | |
| 67 | + | |
| 68 | + <!-- Tags / Releases Section --> | |
| 69 | + <section class="card"> | |
| 70 | + <div class="card-header"> | |
| 71 | + <span class="font-semibold flex items-center gap-2"> | |
| 72 | + {{icon "tag" "size-4"}} | |
| 73 | + <span>{{t .Lang "repo.tags"}} ({{len .TagDetails}})</span> | |
| 74 | + </span> | |
| 75 | + </div> | |
| 76 | + | |
| 77 | + {{if and .CanWrite (not .IsEmpty)}} | |
| 78 | + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/tags" class="card-body flex items-center flex-wrap gap-2" style="border-bottom:1px solid hsl(var(--border));background:hsl(var(--muted)/0.25);"> | |
| 79 | + <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> | |
| 80 | + <input name="tag_name" type="text" class="input font-mono" style="flex:0 1 9rem;height:1.9rem;" placeholder="{{t .Lang "repo.tag.name_ph"}}" required maxlength="80"> | |
| 81 | + <select name="from_ref" class="select font-mono" style="flex:0 1 10rem;height:1.9rem;"> | |
| 82 | + {{range .BranchDetails}} | |
| 83 | + <option value="{{.Name}}" {{if .IsDefault}}selected{{end}}>{{.Name}}</option> | |
| 84 | + {{end}} | |
| 85 | + </select> | |
| 86 | + <input name="message" type="text" class="input" style="flex:1 1 12rem;height:1.9rem;" placeholder="{{t .Lang "repo.tag.msg_ph"}}" maxlength="200"> | |
| 87 | + <button type="submit" class="btn btn-outline btn-sm" style="height:1.9rem;"> | |
| 88 | + {{icon "plus" "size-3.5"}} | |
| 89 | + <span>{{t .Lang "repo.tag.create"}}</span> | |
| 90 | + </button> | |
| 91 | + </form> | |
| 92 | + {{end}} | |
| 93 | + | |
| 94 | + {{if .TagDetails}} | |
| 95 | + <div class="dense-list"> | |
| 96 | + {{range .TagDetails}} | |
| 97 | + <div class="dense-row" style="min-height:2.6rem;padding:0.45rem 0.85rem;"> | |
| 98 | + <div class="flex items-center gap-2 min-w-0"> | |
| 99 | + {{icon "tag" "size-4"}} | |
| 100 | + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/tree/{{.Name}}" class="font-mono font-semibold text-sm">{{.Name}}</a> | |
| 101 | + {{if .Subject}} | |
| 102 | + <span class="text-xs text-muted truncate">— {{.Subject}}</span> | |
| 103 | + {{end}} | |
| 104 | + </div> | |
| 105 | + <div class="flex items-center gap-2 flex-shrink-0"> | |
| 106 | + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/commit/{{.Hash}}" class="badge badge-outline font-mono">{{.ShortHash}}</a> | |
| 107 | + <span class="text-xs text-muted">{{timeAgo .CreatedAt $.Lang}}</span> | |
| 108 | + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/archive/{{.Name}}.zip" class="btn btn-ghost btn-icon" style="width:1.65rem;height:1.65rem;" title="{{t $.Lang "repo.download_zip"}}"> | |
| 109 | + {{icon "download" "size-3.5"}} | |
| 110 | + </a> | |
| 111 | + {{if $.CanWrite}} | |
| 112 | + <form method="post" action="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/tags/delete" style="margin:0;"> | |
| 113 | + <input type="hidden" name="_csrf" value="{{$.CSRFToken}}"> | |
| 114 | + <input type="hidden" name="tag_name" value="{{.Name}}"> | |
| 115 | + <button type="submit" class="btn btn-ghost btn-icon text-destructive" style="width:1.65rem;height:1.65rem;" title="{{t $.Lang "common.delete"}}"> | |
| 116 | + {{icon "trash" "size-3.5"}} | |
| 117 | + </button> | |
| 118 | + </form> | |
| 119 | + {{end}} | |
| 120 | + </div> | |
| 121 | + </div> | |
| 122 | + {{end}} | |
| 123 | + </div> | |
| 124 | + {{else}} | |
| 125 | + <div class="card-body text-xs text-muted">{{t .Lang "repo.tags.empty"}}</div> | |
| 126 | + {{end}} | |
| 127 | + </section> | |
| 128 | +</div> | |
| 129 | +{{end}} |
internal/web/views/pages/commit.html
+101
-0
| ... | ... | @@ -0,0 +1,101 @@ |
| 1 | +{{define "content"}} | |
| 2 | +<div class="container"> | |
| 3 | + {{template "repo_header" .}} | |
| 4 | + | |
| 5 | + <div class="card mb-4"> | |
| 6 | + <div class="card-header flex items-center justify-between flex-wrap gap-2"> | |
| 7 | + <div class="flex items-center gap-2 min-w-0"> | |
| 8 | + {{icon "git-commit" "size-4"}} | |
| 9 | + <h1 class="text-base font-semibold truncate" style="margin:0;">{{.Detail.Commit.Subject}}</h1> | |
| 10 | + </div> | |
| 11 | + <div class="flex items-center gap-1.5 font-mono text-xs"> | |
| 12 | + <span class="badge badge-outline">{{.Detail.Commit.ShortHash}}</span> | |
| 13 | + <button type="button" class="btn btn-ghost btn-icon" style="width:1.7rem;height:1.7rem;" data-copy="{{.Detail.Commit.Hash}}" aria-label="{{t .Lang "common.copy"}}"> | |
| 14 | + {{icon "copy" "size-3.5"}} | |
| 15 | + </button> | |
| 16 | + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/tree/{{.Detail.Commit.Hash}}" class="btn btn-outline btn-sm">{{t .Lang "repo.browse_at_commit"}}</a> | |
| 17 | + </div> | |
| 18 | + </div> | |
| 19 | + | |
| 20 | + {{if .Detail.Commit.Body}} | |
| 21 | + <div class="card-body" style="border-bottom:1px solid hsl(var(--border));background:hsl(var(--muted)/0.25);"> | |
| 22 | + <pre class="font-mono text-xs" style="margin:0;white-space:pre-wrap;">{{.Detail.Commit.Body}}</pre> | |
| 23 | + </div> | |
| 24 | + {{end}} | |
| 25 | + | |
| 26 | + <div class="card-body flex items-center justify-between flex-wrap gap-3 text-xs text-muted" style="padding:0.6rem 0.9rem;"> | |
| 27 | + <div class="flex items-center gap-2"> | |
| 28 | + <span class="avatar" style="width:1.35rem;height:1.35rem;font-size:0.62rem;">{{avatarInitial .Detail.Commit.AuthorName}}</span> | |
| 29 | + <span class="font-medium" style="color:hsl(var(--foreground));">{{.Detail.Commit.AuthorName}}</span> | |
| 30 | + <span>·</span> | |
| 31 | + <span>{{formatDate .Detail.Commit.AuthoredAt}}</span> | |
| 32 | + </div> | |
| 33 | + | |
| 34 | + <div class="flex items-center gap-3 font-mono"> | |
| 35 | + <span><strong>{{.Detail.FilesChanged}}</strong> {{pluralT .Lang .Detail.FilesChanged "repo.files_changed"}}</span> | |
| 36 | + <span style="color:hsl(var(--diff-add-fg));">+{{.Detail.TotalAdditions}}</span> | |
| 37 | + <span style="color:hsl(var(--diff-del-fg));">-{{.Detail.TotalDeletions}}</span> | |
| 38 | + </div> | |
| 39 | + </div> | |
| 40 | + </div> | |
| 41 | + | |
| 42 | + <div class="flex flex-col gap-4"> | |
| 43 | + {{range .Detail.Files}} | |
| 44 | + <section class="card"> | |
| 45 | + <div class="card-header" style="padding:0.5rem 0.8rem;"> | |
| 46 | + <div class="flex items-center gap-2 font-mono text-xs min-w-0"> | |
| 47 | + {{icon "file-code" "size-4"}} | |
| 48 | + {{if eq .Status "renamed"}} | |
| 49 | + <span>{{.OldPath}} → <strong>{{.NewPath}}</strong></span> | |
| 50 | + {{else}} | |
| 51 | + <strong>{{.NewPath}}</strong> | |
| 52 | + {{end}} | |
| 53 | + </div> | |
| 54 | + <div class="flex items-center gap-2 font-mono text-xs"> | |
| 55 | + <span style="color:hsl(var(--diff-add-fg));">+{{.Additions}}</span> | |
| 56 | + <span style="color:hsl(var(--diff-del-fg));">-{{.Deletions}}</span> | |
| 57 | + </div> | |
| 58 | + </div> | |
| 59 | + | |
| 60 | + {{if .IsBinary}} | |
| 61 | + <div class="card-body text-xs text-muted">{{t $.Lang "repo.binary_notice"}}</div> | |
| 62 | + {{else}} | |
| 63 | + <div style="overflow-x:auto;"> | |
| 64 | + <table class="diff-table"> | |
| 65 | + <tbody> | |
| 66 | + {{range .Lines}} | |
| 67 | + {{if eq .Type "hunk"}} | |
| 68 | + <tr class="diff-row-hunk"> | |
| 69 | + <td class="diff-num">...</td> | |
| 70 | + <td class="diff-num">...</td> | |
| 71 | + <td class="diff-code">{{.Content}}</td> | |
| 72 | + </tr> | |
| 73 | + {{else if eq .Type "add"}} | |
| 74 | + <tr class="diff-row-add"> | |
| 75 | + <td class="diff-num"></td> | |
| 76 | + <td class="diff-num">{{.NewNum}}</td> | |
| 77 | + <td class="diff-code">+{{.Content}}</td> | |
| 78 | + </tr> | |
| 79 | + {{else if eq .Type "del"}} | |
| 80 | + <tr class="diff-row-del"> | |
| 81 | + <td class="diff-num">{{.OldNum}}</td> | |
| 82 | + <td class="diff-num"></td> | |
| 83 | + <td class="diff-code">-{{.Content}}</td> | |
| 84 | + </tr> | |
| 85 | + {{else}} | |
| 86 | + <tr> | |
| 87 | + <td class="diff-num">{{.OldNum}}</td> | |
| 88 | + <td class="diff-num">{{.NewNum}}</td> | |
| 89 | + <td class="diff-code"> {{.Content}}</td> | |
| 90 | + </tr> | |
| 91 | + {{end}} | |
| 92 | + {{end}} | |
| 93 | + </tbody> | |
| 94 | + </table> | |
| 95 | + </div> | |
| 96 | + {{end}} | |
| 97 | + </section> | |
| 98 | + {{end}} | |
| 99 | + </div> | |
| 100 | +</div> | |
| 101 | +{{end}} |
internal/web/views/pages/commits.html
+66
-0
| ... | ... | @@ -0,0 +1,66 @@ |
| 1 | +{{define "content"}} | |
| 2 | +<div class="container"> | |
| 3 | + {{template "repo_header" .}} | |
| 4 | + | |
| 5 | + <div class="flex items-center justify-between flex-wrap gap-2 mb-3"> | |
| 6 | + <div class="dropdown" data-dropdown> | |
| 7 | + <button type="button" class="btn btn-outline btn-sm font-mono" data-dropdown-trigger aria-haspopup="true" aria-expanded="false"> | |
| 8 | + {{icon "git-branch" "size-3.5"}} | |
| 9 | + <span>{{.Ref}}</span> | |
| 10 | + {{icon "chevron-down" "size-3.5"}} | |
| 11 | + </button> | |
| 12 | + <div class="dropdown-menu" data-dropdown-menu role="menu" style="min-width:13rem;"> | |
| 13 | + <div class="palette-group-title">{{t .Lang "repo.branches"}}</div> | |
| 14 | + {{range .Branches}} | |
| 15 | + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/commits/{{.}}" class="dropdown-item font-mono {{if eq . $.Ref}}active{{end}}" role="menuitem"> | |
| 16 | + <span class="flex items-center gap-2">{{icon "git-branch" "size-3.5"}} {{.}}</span> | |
| 17 | + {{if eq . $.Ref}}{{icon "check" "size-3.5"}}{{end}} | |
| 18 | + </a> | |
| 19 | + {{end}} | |
| 20 | + </div> | |
| 21 | + </div> | |
| 22 | + | |
| 23 | + <span class="text-xs text-muted">{{t .Lang "repo.history"}} · {{.Ref}}</span> | |
| 24 | + </div> | |
| 25 | + | |
| 26 | + {{if .Commits}} | |
| 27 | + <div class="card"> | |
| 28 | + {{range .Commits}} | |
| 29 | + <div class="dense-row" style="min-height:2.6rem;padding:0.4rem 0.85rem;"> | |
| 30 | + <div class="flex items-center gap-2.5 min-w-0"> | |
| 31 | + {{icon "git-commit" "size-4"}} | |
| 32 | + <div class="min-w-0"> | |
| 33 | + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/commit/{{.Hash}}" class="font-medium text-sm truncate block">{{.Subject}}</a> | |
| 34 | + <div class="text-xs text-muted flex items-center gap-1.5 mt-1"> | |
| 35 | + <span>{{.AuthorName}}</span> | |
| 36 | + <span>·</span> | |
| 37 | + <span>{{timeAgo .AuthoredAt $.Lang}}</span> | |
| 38 | + </div> | |
| 39 | + </div> | |
| 40 | + </div> | |
| 41 | + | |
| 42 | + <div class="flex items-center gap-1.5"> | |
| 43 | + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/commit/{{.Hash}}" class="btn btn-outline btn-sm font-mono">{{.ShortHash}}</a> | |
| 44 | + <button type="button" class="btn btn-ghost btn-icon" style="width:1.7rem;height:1.7rem;" data-copy="{{.Hash}}" aria-label="{{t $.Lang "common.copy"}}"> | |
| 45 | + {{icon "copy" "size-3.5"}} | |
| 46 | + </button> | |
| 47 | + </div> | |
| 48 | + </div> | |
| 49 | + {{end}} | |
| 50 | + </div> | |
| 51 | + | |
| 52 | + <div class="flex items-center justify-between mt-4"> | |
| 53 | + {{if gt .Page 1}} | |
| 54 | + <a href="?page={{sub .Page 1}}" class="btn btn-outline btn-sm">{{icon "arrow-left" "size-3.5"}} {{t .Lang "common.newer"}}</a> | |
| 55 | + {{else}} | |
| 56 | + <span></span> | |
| 57 | + {{end}} | |
| 58 | + {{if .HasMore}} | |
| 59 | + <a href="?page={{add .Page 1}}" class="btn btn-outline btn-sm">{{t .Lang "common.older"}} {{icon "arrow-right" "size-3.5"}}</a> | |
| 60 | + {{end}} | |
| 61 | + </div> | |
| 62 | + {{else}} | |
| 63 | + {{template "ui_empty_state" (dict "Icon" "git-commit" "Title" (t .Lang "repo.no_commits"))}} | |
| 64 | + {{end}} | |
| 65 | +</div> | |
| 66 | +{{end}} |
internal/web/views/pages/device.html
+75
-0
| ... | ... | @@ -0,0 +1,75 @@ |
| 1 | +{{define "content"}} | |
| 2 | +<div class="container-narrow"> | |
| 3 | + <div class="card mb-4" style="margin-top:2.5rem;"> | |
| 4 | + <div class="card-header"> | |
| 5 | + <span class="font-semibold flex items-center gap-2">{{icon "key" "size-4"}} {{t .Lang "device.title"}}</span> | |
| 6 | + <span class="badge badge-outline font-mono">{{t .Lang "device.badge"}}</span> | |
| 7 | + </div> | |
| 8 | + <div class="card-body flex flex-col gap-3"> | |
| 9 | + <p class="text-sm text-muted" style="margin:0;">{{t .Lang "device.desc"}}</p> | |
| 10 | + | |
| 11 | + {{if .CodeError}} | |
| 12 | + <div class="text-sm flex items-center gap-2" style="color:hsl(var(--diff-del-fg));"> | |
| 13 | + {{icon "alert-circle" "size-4"}} | |
| 14 | + <span>{{.CodeError}}</span> | |
| 15 | + </div> | |
| 16 | + {{end}} | |
| 17 | + | |
| 18 | + <form method="get" action="/login/device" class="flex items-center gap-2"> | |
| 19 | + <input name="user_code" class="input font-mono" style="letter-spacing:0.18em;text-transform:uppercase;max-width:12rem;" placeholder="XXXX-XXXX" value="{{.UserCode}}" maxlength="12" autocomplete="off" autofocus> | |
| 20 | + <button class="btn btn-outline btn-sm" type="submit"> | |
| 21 | + {{icon "search" "size-3.5"}} | |
| 22 | + <span>{{t .Lang "device.check"}}</span> | |
| 23 | + </button> | |
| 24 | + </form> | |
| 25 | + | |
| 26 | + {{if .CodeApproved}} | |
| 27 | + <div class="flex items-center gap-2 text-sm" style="color:hsl(var(--diff-add-fg));border-top:1px solid hsl(var(--border));padding-top:0.85rem;"> | |
| 28 | + {{icon "check" "size-4"}} | |
| 29 | + <span>{{t .Lang "device.approved_now"}}</span> | |
| 30 | + </div> | |
| 31 | + {{else if .CodeDenied}} | |
| 32 | + <div class="flex items-center gap-2 text-sm" style="color:hsl(var(--diff-del-fg));border-top:1px solid hsl(var(--border));padding-top:0.85rem;"> | |
| 33 | + {{icon "close" "size-4"}} | |
| 34 | + <span>{{t .Lang "device.denied_now"}}</span> | |
| 35 | + </div> | |
| 36 | + {{else if .SignedIn}} | |
| 37 | + {{if and .UserCode .CodePending}} | |
| 38 | + <form method="post" action="/login/device" class="flex flex-col gap-3" style="border-top:1px solid hsl(var(--border));padding-top:0.85rem;"> | |
| 39 | + <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> | |
| 40 | + <input type="hidden" name="user_code" value="{{.UserCode}}"> | |
| 41 | + <div class="text-sm"> | |
| 42 | + {{t .Lang "device.authorize_for"}} | |
| 43 | + <span class="font-mono font-semibold">{{.UserCode}}</span> | |
| 44 | + {{if .ClientName}}· <span class="font-medium">{{.ClientName}}</span>{{end}} | |
| 45 | + </div> | |
| 46 | + <div class="text-xs text-muted">{{t .Lang "device.scope_hint"}}</div> | |
| 47 | + <div class="flex items-center gap-2"> | |
| 48 | + <button type="submit" name="action" value="approve" class="btn btn-primary btn-sm"> | |
| 49 | + {{icon "check" "size-3.5"}} | |
| 50 | + <span>{{t .Lang "device.approve"}}</span> | |
| 51 | + </button> | |
| 52 | + <button type="submit" name="action" value="deny" class="btn btn-outline btn-sm"> | |
| 53 | + {{icon "close" "size-3.5"}} | |
| 54 | + <span>{{t .Lang "device.deny"}}</span> | |
| 55 | + </button> | |
| 56 | + </div> | |
| 57 | + </form> | |
| 58 | + {{end}} | |
| 59 | + {{else}} | |
| 60 | + <div class="flex items-center gap-2 text-sm" style="border-top:1px solid hsl(var(--border));padding-top:0.85rem;"> | |
| 61 | + <a class="btn btn-primary btn-sm" href="/login?next=%2Flogin%2Fdevice%3Fuser_code%3D{{.UserCode}}"> | |
| 62 | + {{icon "user" "size-3.5"}} | |
| 63 | + <span>{{t .Lang "device.signin_to_approve"}}</span> | |
| 64 | + </a> | |
| 65 | + </div> | |
| 66 | + {{end}} | |
| 67 | + </div> | |
| 68 | + </div> | |
| 69 | + | |
| 70 | + <div class="text-xs text-muted text-center" style="margin-bottom:2.5rem;"> | |
| 71 | + {{t .Lang "device.helper_hint"}} | |
| 72 | + <code class="font-mono">scripts/git-credential-polar</code> | |
| 73 | + </div> | |
| 74 | +</div> | |
| 75 | +{{end}} |
internal/web/views/pages/error.html
+13
-0
| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | +{{define "content"}} | |
| 2 | +<div class="container" style="max-width:28rem;padding-top:3rem;"> | |
| 3 | + <div class="empty-state"> | |
| 4 | + <div class="font-mono text-2xl font-bold mb-1" style="color:hsl(var(--foreground));">{{.StatusCode}}</div> | |
| 5 | + <div class="font-semibold text-base" style="color:hsl(var(--foreground));">{{.Title}}</div> | |
| 6 | + <p class="text-sm text-muted mt-1">{{.Message}}</p> | |
| 7 | + <div class="mt-4 flex items-center justify-center gap-2"> | |
| 8 | + <a href="/" class="btn btn-primary btn-sm">{{icon "home" "size-3.5"}} {{t .Lang "nav.home"}}</a> | |
| 9 | + <a href="/repos" class="btn btn-outline btn-sm">{{icon "git-branch" "size-3.5"}} {{t .Lang "nav.repositories"}}</a> | |
| 10 | + </div> | |
| 11 | + </div> | |
| 12 | +</div> | |
| 13 | +{{end}} |
internal/web/views/pages/forks.html
+46
-0
| ... | ... | @@ -0,0 +1,46 @@ |
| 1 | +{{define "content"}} | |
| 2 | +<div class="container"> | |
| 3 | + {{template "repo_header" .}} | |
| 4 | + | |
| 5 | + <div class="card mb-4"> | |
| 6 | + <div class="card-header"> | |
| 7 | + <span class="font-semibold flex items-center gap-2">{{icon "git-branch" "size-4"}} {{t .Lang "forks.title"}}</span> | |
| 8 | + <span class="text-xs text-muted font-mono">{{.Repo.OwnerUsername}}/{{.Repo.Name}}</span> | |
| 9 | + </div> | |
| 10 | + | |
| 11 | + {{if .Upstream}} | |
| 12 | + <div class="card-body text-xs text-muted flex items-center gap-1.5" style="border-bottom:1px solid hsl(var(--border));"> | |
| 13 | + {{icon "git-branch" "size-3.5"}} | |
| 14 | + {{t .Lang "fork.forked_from"}} | |
| 15 | + <a class="font-medium" style="color:hsl(var(--foreground));" href="/{{.Upstream.Owner}}/{{.Upstream.Name}}">{{.Upstream.Owner}}/{{.Upstream.Name}}</a> | |
| 16 | + </div> | |
| 17 | + {{end}} | |
| 18 | + | |
| 19 | + <div class="dense-list"> | |
| 20 | + {{range .Forks}} | |
| 21 | + <div class="dense-row" style="min-height:3rem;padding:0.55rem 0.85rem;"> | |
| 22 | + <div class="flex items-center gap-2.5 min-w-0"> | |
| 23 | + {{avatar .OwnerUsername .OwnerAvatarURL}} | |
| 24 | + <div class="min-w-0"> | |
| 25 | + <a href="/{{.OwnerUsername}}/{{.Name}}" class="font-semibold text-sm">{{.OwnerUsername}}/{{.Name}}</a> | |
| 26 | + {{if .Description}} | |
| 27 | + <div class="text-xs text-muted truncate">{{.Description}}</div> | |
| 28 | + {{end}} | |
| 29 | + </div> | |
| 30 | + </div> | |
| 31 | + <div class="flex items-center gap-3 text-xs text-muted"> | |
| 32 | + <span>{{timeAgo .UpdatedAt $.Lang}}</span> | |
| 33 | + {{if $.CurrentUser}} | |
| 34 | + {{if eq .OwnerID $.CurrentUser.ID}} | |
| 35 | + <span class="badge badge-outline">{{t $.Lang "fork.your_fork"}}</span> | |
| 36 | + {{end}} | |
| 37 | + {{end}} | |
| 38 | + </div> | |
| 39 | + </div> | |
| 40 | + {{else}} | |
| 41 | + <div class="card-body text-sm text-muted">{{t .Lang "forks.empty"}}</div> | |
| 42 | + {{end}} | |
| 43 | + </div> | |
| 44 | + </div> | |
| 45 | +</div> | |
| 46 | +{{end}} |
internal/web/views/pages/issue_detail.html
+95
-0
| ... | ... | @@ -0,0 +1,95 @@ |
| 1 | +{{define "content"}} | |
| 2 | +<div class="container"> | |
| 3 | + {{template "repo_header" .}} | |
| 4 | + | |
| 5 | + <!-- Issue Header --> | |
| 6 | + <div class="mb-4 pb-3" style="border-bottom:1px solid hsl(var(--border));"> | |
| 7 | + <div class="flex items-start justify-between flex-wrap gap-3"> | |
| 8 | + <div class="flex items-center gap-2 flex-wrap"> | |
| 9 | + <h1 class="text-lg font-bold" style="margin:0;">{{.Issue.Title}}</h1> | |
| 10 | + <span class="font-mono text-base text-muted">#{{.Issue.Number}}</span> | |
| 11 | + </div> | |
| 12 | + {{if .CanToggleIssue}} | |
| 13 | + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/state" style="margin:0;"> | |
| 14 | + <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> | |
| 15 | + {{if .Issue.IsClosed}} | |
| 16 | + <input type="hidden" name="state" value="open"> | |
| 17 | + <button type="submit" class="btn btn-outline btn-sm"> | |
| 18 | + {{icon "alert-circle" "size-3.5"}} | |
| 19 | + <span>{{t .Lang "issue.reopen_btn"}}</span> | |
| 20 | + </button> | |
| 21 | + {{else}} | |
| 22 | + <input type="hidden" name="state" value="closed"> | |
| 23 | + <button type="submit" class="btn btn-outline btn-sm"> | |
| 24 | + {{icon "check" "size-3.5"}} | |
| 25 | + <span>{{t .Lang "issue.close_btn"}}</span> | |
| 26 | + </button> | |
| 27 | + {{end}} | |
| 28 | + </form> | |
| 29 | + {{end}} | |
| 30 | + </div> | |
| 31 | + | |
| 32 | + <div class="flex items-center gap-2 mt-2 text-xs text-muted"> | |
| 33 | + {{if .Issue.IsClosed}} | |
| 34 | + <span class="badge badge-outline">{{icon "check" "size-3.5"}} {{t .Lang "issue.status.closed"}}</span> | |
| 35 | + {{else}} | |
| 36 | + <span class="badge badge-primary">{{icon "alert-circle" "size-3.5"}} {{t .Lang "issue.status.open"}}</span> | |
| 37 | + {{end}} | |
| 38 | + <a href="/{{.Issue.AuthorUsername}}" class="font-medium" style="color:hsl(var(--foreground));">@{{.Issue.AuthorUsername}}</a> | |
| 39 | + <span>·</span> | |
| 40 | + <span>{{timeAgo .Issue.CreatedAt .Lang}}</span> | |
| 41 | + </div> | |
| 42 | + </div> | |
| 43 | + | |
| 44 | + <!-- Issue Description Card --> | |
| 45 | + <div class="card mb-4"> | |
| 46 | + <div class="card-header text-xs" style="padding:0.5rem 0.85rem;background:hsl(var(--muted)/0.3);"> | |
| 47 | + <div class="flex items-center gap-2"> | |
| 48 | + {{avatar .Issue.AuthorUsername .Issue.AuthorAvatar}} | |
| 49 | + <a href="/{{.Issue.AuthorUsername}}" class="font-semibold">@{{.Issue.AuthorUsername}}</a> | |
| 50 | + <span class="text-muted">{{timeAgo .Issue.CreatedAt .Lang}}</span> | |
| 51 | + </div> | |
| 52 | + </div> | |
| 53 | + <div class="card-body markdown-body"> | |
| 54 | + {{if .Issue.Body}} | |
| 55 | + {{renderMD .Issue.Body}} | |
| 56 | + {{else}} | |
| 57 | + <span class="text-xs text-muted">—</span> | |
| 58 | + {{end}} | |
| 59 | + </div> | |
| 60 | + </div> | |
| 61 | + | |
| 62 | + <!-- Comments --> | |
| 63 | + {{if .Comments}} | |
| 64 | + <div class="flex flex-col gap-3 mb-4"> | |
| 65 | + {{range .Comments}} | |
| 66 | + <div class="card"> | |
| 67 | + <div class="card-header text-xs" style="padding:0.5rem 0.85rem;background:hsl(var(--muted)/0.2);"> | |
| 68 | + <div class="flex items-center gap-2"> | |
| 69 | + {{avatar .AuthorUsername .AuthorAvatar}} | |
| 70 | + <a href="/{{.AuthorUsername}}" class="font-semibold">@{{.AuthorUsername}}</a> | |
| 71 | + <span class="text-muted">{{timeAgo .CreatedAt $.Lang}}</span> | |
| 72 | + </div> | |
| 73 | + </div> | |
| 74 | + <div class="card-body markdown-body"> | |
| 75 | + {{renderMD .Body}} | |
| 76 | + </div> | |
| 77 | + </div> | |
| 78 | + {{end}} | |
| 79 | + </div> | |
| 80 | + {{end}} | |
| 81 | + | |
| 82 | + <!-- Add Comment Form --> | |
| 83 | + {{if .CurrentUser}} | |
| 84 | + <div class="card"> | |
| 85 | + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/comments" class="card-body flex flex-col gap-2.5"> | |
| 86 | + <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> | |
| 87 | + <textarea name="body" class="textarea" rows="4" placeholder="{{t .Lang "issue.comment_ph"}}" required maxlength="20000"></textarea> | |
| 88 | + <div class="flex items-center justify-end"> | |
| 89 | + <button type="submit" class="btn btn-primary btn-sm">{{t .Lang "issue.comment_btn"}}</button> | |
| 90 | + </div> | |
| 91 | + </form> | |
| 92 | + </div> | |
| 93 | + {{end}} | |
| 94 | +</div> | |
| 95 | +{{end}} |
internal/web/views/pages/issue_new.html
+26
-0
| ... | ... | @@ -0,0 +1,26 @@ |
| 1 | +{{define "content"}} | |
| 2 | +<div class="container"> | |
| 3 | + {{template "repo_header" .}} | |
| 4 | + | |
| 5 | + <div class="card"> | |
| 6 | + <div class="card-header"> | |
| 7 | + <span class="font-semibold flex items-center gap-2">{{icon "plus" "size-4"}} {{t .Lang "issue.new"}}</span> | |
| 8 | + </div> | |
| 9 | + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/issues/new" class="card-body flex flex-col gap-3"> | |
| 10 | + <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> | |
| 11 | + <div> | |
| 12 | + <label class="label" for="title">{{t .Lang "issue.title"}}</label> | |
| 13 | + <input id="title" name="title" type="text" class="input" placeholder="{{t .Lang "issue.title_ph"}}" required autofocus maxlength="200"> | |
| 14 | + </div> | |
| 15 | + <div> | |
| 16 | + <label class="label" for="body">{{t .Lang "issue.body"}}</label> | |
| 17 | + <textarea id="body" name="body" class="textarea" rows="6" placeholder="{{t .Lang "issue.body_ph"}}" maxlength="20000"></textarea> | |
| 18 | + </div> | |
| 19 | + <div class="flex items-center justify-end gap-2"> | |
| 20 | + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/issues" class="btn btn-ghost btn-sm">{{t .Lang "common.cancel"}}</a> | |
| 21 | + <button type="submit" class="btn btn-primary btn-sm">{{t .Lang "issue.create"}}</button> | |
| 22 | + </div> | |
| 23 | + </form> | |
| 24 | + </div> | |
| 25 | +</div> | |
| 26 | +{{end}} |
internal/web/views/pages/issues.html
+68
-0
| ... | ... | @@ -0,0 +1,68 @@ |
| 1 | +{{define "content"}} | |
| 2 | +<div class="container"> | |
| 3 | + {{template "repo_header" .}} | |
| 4 | + | |
| 5 | + <div class="flex items-center justify-between flex-wrap gap-2 mb-3"> | |
| 6 | + <div class="flex items-center gap-1.5"> | |
| 7 | + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/issues?state=open" class="btn {{if not .ShowClosed}}btn-secondary{{else}}btn-ghost{{end}} btn-sm"> | |
| 8 | + {{icon "alert-circle" "size-3.5"}} | |
| 9 | + <span>{{t .Lang "issue.open"}} ({{.OpenCount}})</span> | |
| 10 | + </a> | |
| 11 | + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/issues?state=closed" class="btn {{if .ShowClosed}}btn-secondary{{else}}btn-ghost{{end}} btn-sm"> | |
| 12 | + {{icon "check" "size-3.5"}} | |
| 13 | + <span>{{t .Lang "issue.closed"}} ({{.ClosedCount}})</span> | |
| 14 | + </a> | |
| 15 | + </div> | |
| 16 | + | |
| 17 | + {{if .CurrentUser}} | |
| 18 | + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/issues/new" class="btn btn-primary btn-sm"> | |
| 19 | + {{icon "plus" "size-3.5"}} | |
| 20 | + <span>{{t .Lang "issue.new"}}</span> | |
| 21 | + </a> | |
| 22 | + {{end}} | |
| 23 | + </div> | |
| 24 | + | |
| 25 | + {{if .Issues}} | |
| 26 | + <div class="card"> | |
| 27 | + <div class="dense-list"> | |
| 28 | + {{range .Issues}} | |
| 29 | + <div class="dense-row" style="min-height:2.75rem;padding:0.5rem 0.85rem;"> | |
| 30 | + <div class="flex items-center gap-2.5 min-w-0"> | |
| 31 | + {{if .IsClosed}} | |
| 32 | + <span class="text-muted">{{icon "check" "size-4"}}</span> | |
| 33 | + {{else}} | |
| 34 | + <span style="color:hsl(var(--primary));">{{icon "alert-circle" "size-4"}}</span> | |
| 35 | + {{end}} | |
| 36 | + <div class="flex flex-col min-w-0"> | |
| 37 | + <div class="flex items-center gap-2 min-w-0"> | |
| 38 | + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/issues/{{.Number}}" class="font-semibold text-sm truncate">{{.Title}}</a> | |
| 39 | + <span class="font-mono text-xs text-muted">#{{.Number}}</span> | |
| 40 | + </div> | |
| 41 | + <div class="text-xs text-muted flex items-center gap-1.5"> | |
| 42 | + <span>@{{.AuthorUsername}}</span> | |
| 43 | + <span>·</span> | |
| 44 | + <span>{{timeAgo .CreatedAt $.Lang}}</span> | |
| 45 | + </div> | |
| 46 | + </div> | |
| 47 | + </div> | |
| 48 | + <div class="flex items-center gap-2 text-xs text-muted flex-shrink-0"> | |
| 49 | + {{if gt .CommentCount 0}} | |
| 50 | + <span class="flex items-center gap-1 font-mono"> | |
| 51 | + {{icon "message" "size-3.5"}} | |
| 52 | + <span>{{.CommentCount}}</span> | |
| 53 | + </span> | |
| 54 | + {{end}} | |
| 55 | + </div> | |
| 56 | + </div> | |
| 57 | + {{end}} | |
| 58 | + </div> | |
| 59 | + </div> | |
| 60 | + {{else}} | |
| 61 | + {{if .CurrentUser}} | |
| 62 | + {{template "ui_empty_state" (dict "Icon" "alert-circle" "Title" (t .Lang "issue.empty") "ActionHref" (printf "/%s/%s/issues/new" .Repo.OwnerUsername .Repo.Name) "ActionLabel" (t .Lang "issue.new") "ActionIcon" "plus")}} | |
| 63 | + {{else}} | |
| 64 | + {{template "ui_empty_state" (dict "Icon" "alert-circle" "Title" (t .Lang "issue.empty"))}} | |
| 65 | + {{end}} | |
| 66 | + {{end}} | |
| 67 | +</div> | |
| 68 | +{{end}} |
internal/web/views/pages/landing.html
+98
-0
| ... | ... | @@ -0,0 +1,98 @@ |
| 1 | +{{define "content"}} | |
| 2 | +<div class="container"> | |
| 3 | + <!-- Minimal Hero --> | |
| 4 | + <section class="mb-6"> | |
| 5 | + <div class="mb-2"> | |
| 6 | + <span class="badge">{{icon "git-branch" "size-3.5"}} {{t .Lang "landing.badge"}}</span> | |
| 7 | + </div> | |
| 8 | + <h1 class="text-2xl font-bold" style="max-width:38rem;line-height:1.2;margin:0 0 0.6rem 0;"> | |
| 9 | + {{t .Lang "landing.hero.title"}} | |
| 10 | + </h1> | |
| 11 | + <p class="text-sm text-muted" style="max-width:36rem;margin:0 0 1.25rem 0;"> | |
| 12 | + {{t .Lang "landing.hero.subtitle"}} | |
| 13 | + </p> | |
| 14 | + | |
| 15 | + <div class="flex items-center flex-wrap gap-2"> | |
| 16 | + {{if .CurrentUser}} | |
| 17 | + <a href="/new" class="btn btn-primary"> | |
| 18 | + {{icon "plus" "size-4"}} | |
| 19 | + <span>{{t .Lang "repo.new.title"}}</span> | |
| 20 | + </a> | |
| 21 | + <a href="/repos" class="btn btn-outline"> | |
| 22 | + {{icon "git-branch" "size-4"}} | |
| 23 | + <span>{{t .Lang "nav.repositories"}}</span> | |
| 24 | + </a> | |
| 25 | + {{else}} | |
| 26 | + {{if .RegistrationEnabled}} | |
| 27 | + <a href="/register" class="btn btn-primary"> | |
| 28 | + <span>{{t .Lang "landing.hero.cta_start"}}</span> | |
| 29 | + {{icon "arrow-right" "size-4"}} | |
| 30 | + </a> | |
| 31 | + {{end}} | |
| 32 | + <a href="/login" class="btn btn-outline"> | |
| 33 | + <span>{{t .Lang "nav.login"}}</span> | |
| 34 | + </a> | |
| 35 | + {{end}} | |
| 36 | + </div> | |
| 37 | + </section> | |
| 38 | + | |
| 39 | + <!-- 4 Core Principles --> | |
| 40 | + <section class="mb-6"> | |
| 41 | + <div class="grid-2"> | |
| 42 | + <div class="card p-3"> | |
| 43 | + <div class="flex items-center gap-2 font-semibold text-sm mb-1"> | |
| 44 | + {{icon "server" "size-4"}} | |
| 45 | + <span>{{t .Lang "landing.feat1.title"}}</span> | |
| 46 | + </div> | |
| 47 | + <p class="text-xs text-muted" style="margin:0;">{{t .Lang "landing.feat1.desc"}}</p> | |
| 48 | + </div> | |
| 49 | + | |
| 50 | + <div class="card p-3"> | |
| 51 | + <div class="flex items-center gap-2 font-semibold text-sm mb-1"> | |
| 52 | + {{icon "database" "size-4"}} | |
| 53 | + <span>{{t .Lang "landing.feat2.title"}}</span> | |
| 54 | + </div> | |
| 55 | + <p class="text-xs text-muted" style="margin:0;">{{t .Lang "landing.feat2.desc"}}</p> | |
| 56 | + </div> | |
| 57 | + | |
| 58 | + <div class="card p-3"> | |
| 59 | + <div class="flex items-center gap-2 font-semibold text-sm mb-1"> | |
| 60 | + {{icon "zap" "size-4"}} | |
| 61 | + <span>{{t .Lang "landing.feat3.title"}}</span> | |
| 62 | + </div> | |
| 63 | + <p class="text-xs text-muted" style="margin:0;">{{t .Lang "landing.feat3.desc"}}</p> | |
| 64 | + </div> | |
| 65 | + | |
| 66 | + <div class="card p-3"> | |
| 67 | + <div class="flex items-center gap-2 font-semibold text-sm mb-1"> | |
| 68 | + {{icon "lock" "size-4"}} | |
| 69 | + <span>{{t .Lang "landing.feat4.title"}}</span> | |
| 70 | + </div> | |
| 71 | + <p class="text-xs text-muted" style="margin:0;">{{t .Lang "landing.feat4.desc"}}</p> | |
| 72 | + </div> | |
| 73 | + </div> | |
| 74 | + </section> | |
| 75 | + | |
| 76 | + {{if and .ExploreEnabled .Repos}} | |
| 77 | + <section> | |
| 78 | + <div class="flex items-center justify-between mb-2"> | |
| 79 | + <h2 class="text-base font-semibold" style="margin:0;">{{t .Lang "nav.explore"}}</h2> | |
| 80 | + </div> | |
| 81 | + <div class="card"> | |
| 82 | + {{range .Repos}} | |
| 83 | + <div class="dense-row"> | |
| 84 | + <div class="flex items-center gap-2 min-w-0"> | |
| 85 | + {{icon "git-branch" "size-4"}} | |
| 86 | + <a href="/{{.OwnerUsername}}/{{.Name}}" class="font-medium">{{.OwnerUsername}}/<strong>{{.Name}}</strong></a> | |
| 87 | + {{if .Description}} | |
| 88 | + <span class="text-xs text-muted truncate">— {{.Description}}</span> | |
| 89 | + {{end}} | |
| 90 | + </div> | |
| 91 | + <span class="text-xs text-muted">{{timeAgo .UpdatedAt $.Lang}}</span> | |
| 92 | + </div> | |
| 93 | + {{end}} | |
| 94 | + </div> | |
| 95 | + </section> | |
| 96 | + {{end}} | |
| 97 | +</div> | |
| 98 | +{{end}} |
internal/web/views/pages/login.html
+30
-0
| ... | ... | @@ -0,0 +1,30 @@ |
| 1 | +{{define "content"}} | |
| 2 | +<div class="container" style="max-width:23rem;padding-top:2rem;"> | |
| 3 | + <div class="card"> | |
| 4 | + <div class="card-header"> | |
| 5 | + <h1 class="text-base font-semibold" style="margin:0;">{{t .Lang "auth.login.title"}}</h1> | |
| 6 | + </div> | |
| 7 | + <form method="post" action="/login" class="card-body flex flex-col gap-3"> | |
| 8 | + <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> | |
| 9 | + {{if .Next}}<input type="hidden" name="next" value="{{.Next}}">{{end}} | |
| 10 | + <div> | |
| 11 | + <label class="label" for="login">{{t .Lang "auth.username_or_email"}}</label> | |
| 12 | + <input id="login" name="login" type="text" class="input" value="{{.FormLogin}}" required autofocus autocomplete="username"> | |
| 13 | + </div> | |
| 14 | + <div> | |
| 15 | + <label class="label" for="password">{{t .Lang "auth.password"}}</label> | |
| 16 | + <input id="password" name="password" type="password" class="input" required autocomplete="current-password"> | |
| 17 | + </div> | |
| 18 | + <button type="submit" class="btn btn-primary w-full mt-1"> | |
| 19 | + <span>{{t .Lang "auth.submit_login"}}</span> | |
| 20 | + </button> | |
| 21 | + </form> | |
| 22 | + {{if .RegistrationEnabled}} | |
| 23 | + <div class="card-footer text-xs text-muted flex items-center justify-between"> | |
| 24 | + <span>{{t .Lang "auth.no_account"}}</span> | |
| 25 | + <a href="/register" class="font-medium" style="color:hsl(var(--foreground));">{{t .Lang "nav.register"}} →</a> | |
| 26 | + </div> | |
| 27 | + {{end}} | |
| 28 | + </div> | |
| 29 | +</div> | |
| 30 | +{{end}} |
internal/web/views/pages/profile.html
+138
-0
| ... | ... | @@ -0,0 +1,138 @@ |
| 1 | +{{define "content"}} | |
| 2 | +<div class="container"> | |
| 3 | + <!-- Profile Card --> | |
| 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> | |
| 23 | + {{end}} | |
| 24 | + | |
| 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}} | |
| 38 | + <span class="flex items-center gap-1"> | |
| 39 | + {{icon "calendar" "size-3.5"}} | |
| 40 | + <span>{{t .Lang "profile.joined"}}: {{formatShortDate .ProfileUser.CreatedAt}}</span> | |
| 41 | + </span> | |
| 42 | + </div> | |
| 43 | + </div> | |
| 44 | + </div> | |
| 45 | + | |
| 46 | + <div class="flex items-center gap-2"> | |
| 47 | + {{if .CurrentUser}} | |
| 48 | + {{if eq .CurrentUser.ID .ProfileUser.ID}} | |
| 49 | + <a href="/settings" class="btn btn-outline btn-sm"> | |
| 50 | + {{icon "edit" "size-3.5"}} | |
| 51 | + <span>{{t .Lang "profile.edit"}}</span> | |
| 52 | + </a> | |
| 53 | + {{end}} | |
| 54 | + {{end}} | |
| 55 | + </div> | |
| 56 | + </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 | + </div> | |
| 79 | + | |
| 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> | |
| 83 | + <div class="flex items-center gap-2"> | |
| 84 | + <div class="flex items-center" style="position:relative;"> | |
| 85 | + <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> | |
| 86 | + <kbd class="kbd" style="position:absolute;right:0.4rem;pointer-events:none;">/</kbd> | |
| 87 | + </div> | |
| 88 | + {{if .CurrentUser}} | |
| 89 | + {{if eq .CurrentUser.ID .ProfileUser.ID}} | |
| 90 | + <a href="/new" class="btn btn-primary btn-sm" style="height:1.85rem;"> | |
| 91 | + {{icon "plus" "size-3.5"}} | |
| 92 | + <span>{{t .Lang "nav.new_repo"}}</span> | |
| 93 | + </a> | |
| 94 | + {{end}} | |
| 95 | + {{end}} | |
| 96 | + </div> | |
| 97 | + </div> | |
| 98 | + | |
| 99 | + {{if .Repos}} | |
| 100 | + <div class="grid-2"> | |
| 101 | + {{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> | |
| 113 | + {{if .IsPrivate}} | |
| 114 | + <span class="badge badge-outline">{{t $.Lang "repo.private"}}</span> | |
| 115 | + {{else}} | |
| 116 | + <span class="badge badge-outline">{{t $.Lang "repo.public"}}</span> | |
| 117 | + {{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}} | |
| 122 | + </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> | |
| 126 | + </div> | |
| 127 | + </div> | |
| 128 | + {{end}} | |
| 129 | + </div> | |
| 130 | + {{else}} | |
| 131 | + {{if and .CurrentUser (eq .CurrentUser.ID .ProfileUser.ID)}} | |
| 132 | + {{template "ui_empty_state" (dict "Icon" "git-branch" "Title" (t .Lang "repo.empty_list") "Subtitle" (t .Lang "repo.empty_list_sub") "ActionHref" "/new" "ActionLabel" (t .Lang "repo.new.title") "ActionIcon" "plus")}} | |
| 133 | + {{else}} | |
| 134 | + {{template "ui_empty_state" (dict "Icon" "git-branch" "Title" (t .Lang "repo.empty_list"))}} | |
| 135 | + {{end}} | |
| 136 | + {{end}} | |
| 137 | +</div> | |
| 138 | +{{end}} |
internal/web/views/pages/pull_detail.html
+343
-0
| ... | ... | @@ -0,0 +1,343 @@ |
| 1 | +{{define "content"}} | |
| 2 | +<div class="container"> | |
| 3 | + {{template "repo_header" .}} | |
| 4 | + | |
| 5 | + <!-- PR Header --> | |
| 6 | + <div class="mb-4 pb-3" style="border-bottom:1px solid hsl(var(--border));"> | |
| 7 | + <div class="flex items-start justify-between flex-wrap gap-3"> | |
| 8 | + <div class="flex items-center gap-2 flex-wrap min-w-0"> | |
| 9 | + <h1 class="text-lg font-bold" style="margin:0;">{{.PR.Title}}</h1> | |
| 10 | + <span class="font-mono text-base text-muted">#{{.PR.Number}}</span> | |
| 11 | + </div> | |
| 12 | + | |
| 13 | + {{if .CanToggle}} | |
| 14 | + {{if ne .PR.Status "merged"}} | |
| 15 | + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls/{{.PR.Number}}/state" style="margin:0;"> | |
| 16 | + <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> | |
| 17 | + {{if eq .PR.Status "closed"}} | |
| 18 | + <input type="hidden" name="state" value="open"> | |
| 19 | + <button type="submit" class="btn btn-outline btn-sm"> | |
| 20 | + {{icon "git-pull-request" "size-3.5"}} | |
| 21 | + <span>{{t .Lang "pulls.reopen_btn"}}</span> | |
| 22 | + </button> | |
| 23 | + {{else}} | |
| 24 | + <input type="hidden" name="state" value="closed"> | |
| 25 | + <button type="submit" class="btn btn-outline btn-sm"> | |
| 26 | + {{icon "close" "size-3.5"}} | |
| 27 | + <span>{{t .Lang "pulls.close_btn"}}</span> | |
| 28 | + </button> | |
| 29 | + {{end}} | |
| 30 | + </form> | |
| 31 | + {{end}} | |
| 32 | + {{end}} | |
| 33 | + </div> | |
| 34 | + | |
| 35 | + <div class="flex items-center gap-2 mt-2 text-xs text-muted flex-wrap"> | |
| 36 | + {{if eq .PR.Status "merged"}} | |
| 37 | + <span class="badge badge-primary font-medium flex items-center gap-1.5" style="background:hsl(var(--accent));color:#fff;"> | |
| 38 | + {{icon "git-merge" "size-3.5"}} {{t .Lang "pulls.status.merged"}} | |
| 39 | + </span> | |
| 40 | + {{else if eq .PR.Status "closed"}} | |
| 41 | + <span class="badge badge-outline flex items-center gap-1.5"> | |
| 42 | + {{icon "close" "size-3.5"}} {{t .Lang "pulls.status.closed"}} | |
| 43 | + </span> | |
| 44 | + {{else}} | |
| 45 | + <span class="badge badge-primary flex items-center gap-1.5"> | |
| 46 | + {{icon "git-pull-request" "size-3.5"}} {{t .Lang "pulls.status.open"}} | |
| 47 | + </span> | |
| 48 | + {{end}} | |
| 49 | + | |
| 50 | + <a href="/{{.PR.AuthorUsername}}" class="font-medium" style="color:hsl(var(--foreground));">@{{.PR.AuthorUsername}}</a> | |
| 51 | + <span>{{t .Lang "pulls.wants_to_merge"}}</span> | |
| 52 | + <span class="badge badge-outline font-mono text-xs">{{if .PR.HeadRepoID}}{{.PR.HeadRepoOwner}}/{{.PR.HeadRepoName}}:{{end}}{{.PR.HeadBranch}}</span> | |
| 53 | + <span>{{t .Lang "pulls.into"}}</span> | |
| 54 | + <span class="badge badge-outline font-mono text-xs">{{.PR.BaseBranch}}</span> | |
| 55 | + <span>·</span> | |
| 56 | + <span>{{timeAgo .PR.CreatedAt .Lang}}</span> | |
| 57 | + </div> | |
| 58 | + | |
| 59 | + <!-- Sub-tabs: Conversation, Commits, Files Changed --> | |
| 60 | + <div class="flex items-center gap-2 mt-3 pt-2" style="border-top:1px solid hsl(var(--border));"> | |
| 61 | + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls/{{.PR.Number}}?tab=conversation" class="btn {{if eq .SubTab "conversation"}}btn-secondary{{else}}btn-ghost{{end}} btn-sm"> | |
| 62 | + {{icon "message" "size-3.5"}} | |
| 63 | + <span>{{t .Lang "pulls.conversation"}}</span> | |
| 64 | + </a> | |
| 65 | + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls/{{.PR.Number}}?tab=commits" class="btn {{if eq .SubTab "commits"}}btn-secondary{{else}}btn-ghost{{end}} btn-sm"> | |
| 66 | + {{icon "git-commit" "size-3.5"}} | |
| 67 | + <span>{{t .Lang "pulls.commits"}} ({{len .CompareCommits}})</span> | |
| 68 | + </a> | |
| 69 | + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls/{{.PR.Number}}?tab=files" class="btn {{if eq .SubTab "files"}}btn-secondary{{else}}btn-ghost{{end}} btn-sm"> | |
| 70 | + {{icon "file-code" "size-3.5"}} | |
| 71 | + <span>{{t .Lang "pulls.files_changed"}} {{if .Compare}}({{.Compare.FilesChanged}}){{end}}</span> | |
| 72 | + </a> | |
| 73 | + </div> | |
| 74 | + </div> | |
| 75 | + | |
| 76 | + {{if eq .SubTab "commits"}} | |
| 77 | + <!-- Commits list --> | |
| 78 | + <div class="card mb-4"> | |
| 79 | + <div class="dense-list"> | |
| 80 | + {{range .CompareCommits}} | |
| 81 | + <div class="dense-row" style="min-height:2.8rem;padding:0.5rem 0.85rem;"> | |
| 82 | + <div class="flex items-center gap-2 min-w-0"> | |
| 83 | + <span class="text-muted">{{icon "git-commit" "size-3.5"}}</span> | |
| 84 | + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/commit/{{.Hash}}" class="font-semibold text-sm truncate">{{.Subject}}</a> | |
| 85 | + </div> | |
| 86 | + <div class="flex items-center gap-2 text-xs text-muted flex-shrink-0 font-mono"> | |
| 87 | + <span>{{.AuthorName}}</span> | |
| 88 | + <span class="badge badge-outline">{{.ShortHash}}</span> | |
| 89 | + </div> | |
| 90 | + </div> | |
| 91 | + {{else}} | |
| 92 | + <div class="card-body text-xs text-muted">{{t .Lang "pulls.no_commits"}}</div> | |
| 93 | + {{end}} | |
| 94 | + </div> | |
| 95 | + </div> | |
| 96 | + | |
| 97 | + {{else if eq .SubTab "files"}} | |
| 98 | + <!-- Files changed diff viewer --> | |
| 99 | + {{if .Compare}} | |
| 100 | + <div class="flex items-center justify-between mb-3 text-xs text-muted font-mono"> | |
| 101 | + <span><strong>{{.Compare.FilesChanged}}</strong> {{pluralT .Lang .Compare.FilesChanged "repo.files_changed"}}</span> | |
| 102 | + <div class="flex items-center gap-2"> | |
| 103 | + <span style="color:hsl(var(--diff-add-fg));">+{{.Compare.TotalAdditions}}</span> | |
| 104 | + <span style="color:hsl(var(--diff-del-fg));">-{{.Compare.TotalDeletions}}</span> | |
| 105 | + </div> | |
| 106 | + </div> | |
| 107 | + | |
| 108 | + <div class="flex flex-col gap-4"> | |
| 109 | + {{range .Compare.Files}} | |
| 110 | + <section class="card"> | |
| 111 | + <div class="card-header" style="padding:0.5rem 0.8rem;"> | |
| 112 | + <div class="flex items-center gap-2 font-mono text-xs min-w-0"> | |
| 113 | + {{icon "file-code" "size-4"}} | |
| 114 | + {{if eq .Status "renamed"}} | |
| 115 | + <span>{{.OldPath}} → <strong>{{.NewPath}}</strong></span> | |
| 116 | + {{else}} | |
| 117 | + <strong>{{.NewPath}}</strong> | |
| 118 | + {{end}} | |
| 119 | + </div> | |
| 120 | + <div class="flex items-center gap-2 font-mono text-xs"> | |
| 121 | + <span style="color:hsl(var(--diff-add-fg));">+{{.Additions}}</span> | |
| 122 | + <span style="color:hsl(var(--diff-del-fg));">-{{.Deletions}}</span> | |
| 123 | + </div> | |
| 124 | + </div> | |
| 125 | + | |
| 126 | + {{if .IsBinary}} | |
| 127 | + <div class="card-body text-xs text-muted">{{t $.Lang "repo.binary_notice"}}</div> | |
| 128 | + {{else}} | |
| 129 | + <div style="overflow-x:auto;"> | |
| 130 | + <table class="diff-table"> | |
| 131 | + <tbody> | |
| 132 | + {{range .Lines}} | |
| 133 | + {{if eq .Type "hunk"}} | |
| 134 | + <tr class="diff-row-hunk"> | |
| 135 | + <td class="diff-num">...</td> | |
| 136 | + <td class="diff-num">...</td> | |
| 137 | + <td class="diff-code">{{.Content}}</td> | |
| 138 | + </tr> | |
| 139 | + {{else if eq .Type "add"}} | |
| 140 | + <tr class="diff-row-add"> | |
| 141 | + <td class="diff-num"></td> | |
| 142 | + <td class="diff-num">{{.NewNum}}</td> | |
| 143 | + <td class="diff-code">+{{.Content}}</td> | |
| 144 | + </tr> | |
| 145 | + {{else if eq .Type "del"}} | |
| 146 | + <tr class="diff-row-del"> | |
| 147 | + <td class="diff-num">{{.OldNum}}</td> | |
| 148 | + <td class="diff-num"></td> | |
| 149 | + <td class="diff-code">-{{.Content}}</td> | |
| 150 | + </tr> | |
| 151 | + {{else}} | |
| 152 | + <tr> | |
| 153 | + <td class="diff-num">{{.OldNum}}</td> | |
| 154 | + <td class="diff-num">{{.NewNum}}</td> | |
| 155 | + <td class="diff-code"> {{.Content}}</td> | |
| 156 | + </tr> | |
| 157 | + {{end}} | |
| 158 | + {{end}} | |
| 159 | + </tbody> | |
| 160 | + </table> | |
| 161 | + </div> | |
| 162 | + {{end}} | |
| 163 | + </section> | |
| 164 | + {{else}} | |
| 165 | + <div class="card p-4 text-xs text-muted">{{t .Lang "pulls.no_changes"}}</div> | |
| 166 | + {{end}} | |
| 167 | + </div> | |
| 168 | + {{end}} | |
| 169 | + | |
| 170 | + {{else}} | |
| 171 | + <!-- Conversation Tab --> | |
| 172 | + | |
| 173 | + <!-- Initial PR Description Card --> | |
| 174 | + <div class="card mb-4"> | |
| 175 | + <div class="card-header text-xs" style="padding:0.5rem 0.85rem;background:hsl(var(--muted)/0.3);"> | |
| 176 | + <div class="flex items-center gap-2"> | |
| 177 | + {{avatar .PR.AuthorUsername .PR.AuthorAvatar}} | |
| 178 | + <a href="/{{.PR.AuthorUsername}}" class="font-semibold">@{{.PR.AuthorUsername}}</a> | |
| 179 | + <span class="text-muted">{{timeAgo .PR.CreatedAt .Lang}}</span> | |
| 180 | + </div> | |
| 181 | + </div> | |
| 182 | + <div class="card-body markdown-body"> | |
| 183 | + {{if .PR.Body}} | |
| 184 | + {{renderMD .PR.Body}} | |
| 185 | + {{else}} | |
| 186 | + <span class="text-xs text-muted">—</span> | |
| 187 | + {{end}} | |
| 188 | + </div> | |
| 189 | + </div> | |
| 190 | + | |
| 191 | + <!-- Comments List --> | |
| 192 | + {{if .Comments}} | |
| 193 | + <div class="flex flex-col gap-3 mb-4"> | |
| 194 | + {{range .Comments}} | |
| 195 | + <div class="card"> | |
| 196 | + <div class="card-header text-xs" style="padding:0.5rem 0.85rem;background:hsl(var(--muted)/0.2);"> | |
| 197 | + <div class="flex items-center gap-2"> | |
| 198 | + {{avatar .AuthorUsername .AuthorAvatar}} | |
| 199 | + <a href="/{{.AuthorUsername}}" class="font-semibold">@{{.AuthorUsername}}</a> | |
| 200 | + <span class="text-muted">{{timeAgo .CreatedAt $.Lang}}</span> | |
| 201 | + </div> | |
| 202 | + </div> | |
| 203 | + <div class="card-body markdown-body"> | |
| 204 | + {{renderMD .Body}} | |
| 205 | + </div> | |
| 206 | + </div> | |
| 207 | + {{end}} | |
| 208 | + </div> | |
| 209 | + {{end}} | |
| 210 | + | |
| 211 | + <!-- Merge Box / State Banner --> | |
| 212 | + <div class="card mb-4"> | |
| 213 | + {{if eq .PR.Status "merged"}} | |
| 214 | + <div class="card-body flex items-center gap-3" style="background:hsl(var(--muted)/0.2);"> | |
| 215 | + <span style="color:hsl(var(--accent));">{{icon "git-merge" "size-6"}}</span> | |
| 216 | + <div class="flex flex-col gap-0.5"> | |
| 217 | + <div class="font-semibold text-sm">{{t .Lang "pulls.merged_message"}}</div> | |
| 218 | + <div class="text-xs text-muted"> | |
| 219 | + {{if .PR.MergedByUsername}} | |
| 220 | + <span>@{{.PR.MergedByUsername}}</span> | |
| 221 | + <span>·</span> | |
| 222 | + {{end}} | |
| 223 | + {{if .PR.MergedAt}} | |
| 224 | + <span>{{formatDate .PR.MergedAt}}</span> | |
| 225 | + {{end}} | |
| 226 | + </div> | |
| 227 | + </div> | |
| 228 | + </div> | |
| 229 | + | |
| 230 | + {{else if eq .PR.Status "closed"}} | |
| 231 | + <div class="card-body flex items-center gap-3" style="background:hsl(var(--muted)/0.2);"> | |
| 232 | + <span class="text-muted">{{icon "close" "size-6"}}</span> | |
| 233 | + <div class="flex flex-col gap-0.5"> | |
| 234 | + <div class="font-semibold text-sm">{{t .Lang "pulls.closed_message"}}</div> | |
| 235 | + <div class="text-xs text-muted">{{t .Lang "pulls.closed_desc"}}</div> | |
| 236 | + </div> | |
| 237 | + </div> | |
| 238 | + | |
| 239 | + {{else}} | |
| 240 | + <!-- PR is open --> | |
| 241 | + {{if .CanMergeAction}} | |
| 242 | + <div class="card-body flex flex-col gap-3" style="background:hsl(var(--muted)/0.15);"> | |
| 243 | + <div class="flex items-center gap-2 text-sm font-semibold" style="color:hsl(var(--diff-add-fg));"> | |
| 244 | + {{icon "check" "size-5"}} | |
| 245 | + <span>{{t .Lang "pulls.able_to_merge"}}</span> | |
| 246 | + </div> | |
| 247 | + <div class="text-xs text-muted">{{t .Lang "pulls.merge_clean"}}</div> | |
| 248 | + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls/{{.PR.Number}}/merge" class="flex items-center gap-2"> | |
| 249 | + <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> | |
| 250 | + <button type="submit" class="btn btn-primary btn-sm"> | |
| 251 | + {{icon "git-merge" "size-3.5"}} | |
| 252 | + <span>{{t .Lang "pulls.merge_btn"}}</span> | |
| 253 | + </button> | |
| 254 | + </form> | |
| 255 | + </div> | |
| 256 | + {{else if not .CanMerge}} | |
| 257 | + <div class="card-body flex items-center gap-3" style="background:hsl(var(--muted)/0.15);"> | |
| 258 | + <span style="color:hsl(var(--diff-del-fg));">{{icon "alert-circle" "size-5"}}</span> | |
| 259 | + <div class="flex flex-col gap-0.5"> | |
| 260 | + <div class="font-semibold text-sm" style="color:hsl(var(--diff-del-fg));">{{t .Lang "pulls.conflicts_title"}}</div> | |
| 261 | + <div class="text-xs text-muted">{{t .Lang "pulls.conflicts_desc"}}</div> | |
| 262 | + </div> | |
| 263 | + </div> | |
| 264 | + {{else}} | |
| 265 | + <div class="card-body flex items-center gap-2 text-xs text-muted" style="background:hsl(var(--muted)/0.15);"> | |
| 266 | + {{icon "check" "size-4"}} | |
| 267 | + <span>{{t .Lang "pulls.only_write_merge"}}</span> | |
| 268 | + </div> | |
| 269 | + {{end}} | |
| 270 | + {{end}} | |
| 271 | + </div> | |
| 272 | + | |
| 273 | + <!-- Reviews Panel --> | |
| 274 | + {{if or .Reviews .CanReview}} | |
| 275 | + <div class="card mb-4"> | |
| 276 | + <div class="card-header" style="padding:0.55rem 0.85rem;"> | |
| 277 | + <span class="font-semibold text-xs flex items-center gap-2"> | |
| 278 | + {{icon "shield" "size-4"}} | |
| 279 | + <span>{{t .Lang "reviews.title"}}</span> | |
| 280 | + {{if gt .ApprovedCount 0}}<span class="badge" style="padding:0 0.35rem;font-size:0.68rem;color:hsl(var(--diff-add-fg));">{{.ApprovedCount}} ✓</span>{{end}} | |
| 281 | + {{if gt .ChangesRequestedCount 0}}<span class="badge" style="padding:0 0.35rem;font-size:0.68rem;color:hsl(var(--diff-del-fg));">{{.ChangesRequestedCount}} ✗</span>{{end}} | |
| 282 | + </span> | |
| 283 | + </div> | |
| 284 | + {{if .Reviews}} | |
| 285 | + <div class="dense-list"> | |
| 286 | + {{range .Reviews}} | |
| 287 | + <div class="dense-row" style="min-height:2.5rem;padding:0.45rem 0.85rem;"> | |
| 288 | + <div class="flex items-center gap-2 min-w-0"> | |
| 289 | + {{avatar .ReviewerUsername .ReviewerAvatar}} | |
| 290 | + <a href="/{{.ReviewerUsername}}" class="font-semibold text-sm">@{{.ReviewerUsername}}</a> | |
| 291 | + {{if eq .State "approved"}} | |
| 292 | + <span class="flex items-center gap-1 text-xs font-medium" style="color:hsl(var(--diff-add-fg));">{{icon "check" "size-3.5"}} {{t $.Lang "reviews.approved"}}</span> | |
| 293 | + {{else}} | |
| 294 | + <span class="flex items-center gap-1 text-xs font-medium" style="color:hsl(var(--diff-del-fg));">{{icon "alert-circle" "size-3.5"}} {{t $.Lang "reviews.changes_requested"}}</span> | |
| 295 | + {{end}} | |
| 296 | + </div> | |
| 297 | + <div class="flex items-center gap-2 text-xs text-muted"> | |
| 298 | + {{if .Body}}<span class="truncate" style="max-width:22rem;">{{.Body}}</span>{{end}} | |
| 299 | + <span>{{timeAgo .UpdatedAt $.Lang}}</span> | |
| 300 | + </div> | |
| 301 | + </div> | |
| 302 | + {{end}} | |
| 303 | + </div> | |
| 304 | + {{end}} | |
| 305 | + {{if .CanReview}} | |
| 306 | + <div class="card-body" style="border-top:1px solid hsl(var(--border));"> | |
| 307 | + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls/{{.PR.Number}}/review" class="flex flex-col gap-2.5"> | |
| 308 | + <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> | |
| 309 | + <textarea name="body" class="textarea" rows="2" placeholder="{{t .Lang "reviews.body_ph"}}" maxlength="10000"></textarea> | |
| 310 | + <div class="flex items-center gap-2 justify-end"> | |
| 311 | + <button type="submit" name="state" value="changes_requested" class="btn btn-outline btn-sm"> | |
| 312 | + {{icon "alert-circle" "size-3.5"}} | |
| 313 | + <span>{{t .Lang "reviews.request_changes"}}</span> | |
| 314 | + </button> | |
| 315 | + <button type="submit" name="state" value="approved" class="btn btn-primary btn-sm"> | |
| 316 | + {{icon "check" "size-3.5"}} | |
| 317 | + <span>{{t .Lang "reviews.approve"}}</span> | |
| 318 | + </button> | |
| 319 | + </div> | |
| 320 | + </form> | |
| 321 | + </div> | |
| 322 | + {{end}} | |
| 323 | + </div> | |
| 324 | + {{end}} | |
| 325 | + | |
| 326 | + <!-- Add Comment Form --> {{if .CurrentUser}} | |
| 327 | + <div class="card"> | |
| 328 | + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls/{{.PR.Number}}/comments" class="card-body flex flex-col gap-2.5"> | |
| 329 | + <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> | |
| 330 | + <textarea name="body" class="textarea" rows="4" placeholder="{{t .Lang "pulls.comment_ph"}}" required maxlength="20000"></textarea> | |
| 331 | + <div class="flex items-center justify-end"> | |
| 332 | + <button type="submit" class="btn btn-primary btn-sm"> | |
| 333 | + {{icon "message" "size-3.5"}} | |
| 334 | + <span>{{t .Lang "pulls.comment_btn"}}</span> | |
| 335 | + </button> | |
| 336 | + </div> | |
| 337 | + </form> | |
| 338 | + </div> | |
| 339 | + {{end}} | |
| 340 | + | |
| 341 | + {{end}} | |
| 342 | +</div> | |
| 343 | +{{end}} |
internal/web/views/pages/pull_new.html
+187
-0
| ... | ... | @@ -0,0 +1,187 @@ |
| 1 | +{{define "content"}} | |
| 2 | +<div class="container"> | |
| 3 | + {{template "repo_header" .}} | |
| 4 | + | |
| 5 | + <div class="card mb-4"> | |
| 6 | + <div class="card-header flex items-center justify-between flex-wrap gap-3"> | |
| 7 | + <div class="flex items-center gap-2"> | |
| 8 | + {{icon "git-pull-request" "size-4"}} | |
| 9 | + <span class="font-semibold text-sm">{{t .Lang "pulls.compare_branches"}}</span> | |
| 10 | + </div> | |
| 11 | + <div class="flex items-center gap-2 flex-wrap text-xs"> | |
| 12 | + {{if gt (len .HeadRepos) 1}} | |
| 13 | + <label class="text-muted font-medium">{{t .Lang "pulls.head_repo"}}:</label> | |
| 14 | + <select class="select font-mono text-xs" style="width:auto;min-width:150px;padding:0.25rem 0.5rem;" onchange="window.location.href='?head_repo='+encodeURIComponent(this.value)"> | |
| 15 | + {{range .HeadRepos}} | |
| 16 | + <option value="{{.ID}}" {{if eq .ID $.HeadRepo.ID}}selected{{end}}>{{.OwnerUsername}}/{{.Name}}</option> | |
| 17 | + {{end}} | |
| 18 | + </select> | |
| 19 | + {{end}} | |
| 20 | + <label class="text-muted font-medium">{{t .Lang "pulls.base"}}:</label> | |
| 21 | + <select class="select font-mono text-xs" style="width:auto;min-width:130px;padding:0.25rem 0.5rem;" onchange="window.location.href='?base='+encodeURIComponent(this.value)+'&head={{.HeadBranch}}&head_repo={{.HeadRepo.ID}}'"> | |
| 22 | + {{range .BaseBranches}} | |
| 23 | + <option value="{{.}}" {{if eq . $.BaseBranch}}selected{{end}}>{{.}}</option> | |
| 24 | + {{end}} | |
| 25 | + </select> | |
| 26 | + | |
| 27 | + <span class="text-muted font-mono">←</span> | |
| 28 | + | |
| 29 | + <label class="text-muted font-medium">{{t .Lang "pulls.compare"}}:</label> | |
| 30 | + <select class="select font-mono text-xs" style="width:auto;min-width:130px;padding:0.25rem 0.5rem;" onchange="window.location.href='?base={{.BaseBranch}}&head='+encodeURIComponent(this.value)+'&head_repo={{.HeadRepo.ID}}'"> | |
| 31 | + {{range .Branches}} | |
| 32 | + <option value="{{.}}" {{if eq . $.HeadBranch}}selected{{end}}>{{.}}</option> | |
| 33 | + {{end}} | |
| 34 | + </select> | |
| 35 | + </div> | |
| 36 | + </div> | |
| 37 | + | |
| 38 | + {{if and (eq .BaseBranch .HeadBranch) (eq .HeadRepo.ID .Repo.ID)}} | |
| 39 | + <div class="card-body text-xs text-muted" style="background:hsl(var(--muted)/0.2);"> | |
| 40 | + {{icon "alert-circle" "size-4"}} {{t .Lang "pulls.branches_identical"}} | |
| 41 | + </div> | |
| 42 | + {{else}} | |
| 43 | + <div class="card-body text-xs flex items-center gap-2" style="background:hsl(var(--muted)/0.2);"> | |
| 44 | + {{if .CanMerge}} | |
| 45 | + <span style="color:hsl(var(--diff-add-fg));" class="flex items-center gap-1.5 font-medium"> | |
| 46 | + {{icon "check" "size-4"}} {{t .Lang "pulls.able_to_merge"}} | |
| 47 | + </span> | |
| 48 | + {{else}} | |
| 49 | + <span style="color:hsl(var(--diff-del-fg));" class="flex items-center gap-1.5 font-medium"> | |
| 50 | + {{icon "alert-circle" "size-4"}} {{t .Lang "pulls.cannot_merge_warning"}} | |
| 51 | + </span> | |
| 52 | + {{end}} | |
| 53 | + </div> | |
| 54 | + {{end}} | |
| 55 | + </div> | |
| 56 | + | |
| 57 | + {{if or (ne .BaseBranch .HeadBranch) (ne .HeadRepo.ID .Repo.ID)}} | |
| 58 | + <!-- Create PR Form --> | |
| 59 | + <section class="card mb-4"> | |
| 60 | + <div class="card-header"> | |
| 61 | + <span class="font-semibold text-sm">{{t .Lang "pulls.new"}}</span> | |
| 62 | + {{if ne .HeadRepo.ID .Repo.ID}} | |
| 63 | + <span class="text-xs text-muted font-mono">{{.HeadRepo.OwnerUsername}}/{{.HeadRepo.Name}} → {{.Repo.OwnerUsername}}/{{.Repo.Name}}</span> | |
| 64 | + {{end}} | |
| 65 | + </div> | |
| 66 | + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls/new" class="card-body flex flex-col gap-3"> | |
| 67 | + <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> | |
| 68 | + <input type="hidden" name="base_branch" value="{{.BaseBranch}}"> | |
| 69 | + <input type="hidden" name="head_branch" value="{{.HeadBranch}}"> | |
| 70 | + {{if ne .HeadRepo.ID .Repo.ID}}<input type="hidden" name="head_repo" value="{{.HeadRepo.ID}}">{{end}} | |
| 71 | + | |
| 72 | + <div> | |
| 73 | + <label class="label" for="title">{{t .Lang "pulls.title"}}</label> | |
| 74 | + <input id="title" name="title" type="text" class="input" placeholder="{{t .Lang "pulls.title_ph"}}" required maxlength="200" autofocus> | |
| 75 | + </div> | |
| 76 | + | |
| 77 | + <div> | |
| 78 | + <label class="label" for="body">{{t .Lang "pulls.body"}}</label> | |
| 79 | + <textarea id="body" name="body" class="textarea" rows="6" placeholder="{{t .Lang "pulls.body_ph"}}" maxlength="20000"></textarea> | |
| 80 | + </div> | |
| 81 | + | |
| 82 | + <div class="flex items-center justify-end"> | |
| 83 | + <button type="submit" class="btn btn-primary btn-sm"> | |
| 84 | + {{icon "git-pull-request" "size-3.5"}} | |
| 85 | + <span>{{t .Lang "pulls.create_btn"}}</span> | |
| 86 | + </button> | |
| 87 | + </div> | |
| 88 | + </form> | |
| 89 | + </section> | |
| 90 | + | |
| 91 | + <!-- Commits in this comparison --> | |
| 92 | + {{if .CompareCommits}} | |
| 93 | + <div class="card mb-4"> | |
| 94 | + <div class="card-header flex items-center justify-between"> | |
| 95 | + <span class="font-semibold text-xs text-muted uppercase" style="letter-spacing:0.04em;">{{t .Lang "pulls.commits"}} ({{len .CompareCommits}})</span> | |
| 96 | + </div> | |
| 97 | + <div class="dense-list"> | |
| 98 | + {{range .CompareCommits}} | |
| 99 | + <div class="dense-row" style="min-height:2.6rem;padding:0.45rem 0.85rem;"> | |
| 100 | + <div class="flex items-center gap-2 min-w-0"> | |
| 101 | + <span class="text-muted">{{icon "git-commit" "size-3.5"}}</span> | |
| 102 | + <span class="font-semibold text-sm truncate">{{.Subject}}</span> | |
| 103 | + </div> | |
| 104 | + <div class="flex items-center gap-2 text-xs text-muted flex-shrink-0 font-mono"> | |
| 105 | + <span>{{.AuthorName}}</span> | |
| 106 | + <span class="badge badge-outline">{{.ShortHash}}</span> | |
| 107 | + </div> | |
| 108 | + </div> | |
| 109 | + {{end}} | |
| 110 | + </div> | |
| 111 | + </div> | |
| 112 | + {{end}} | |
| 113 | + | |
| 114 | + <!-- Diff Viewer --> | |
| 115 | + {{if .Compare}} | |
| 116 | + {{if .Compare.Files}} | |
| 117 | + <div class="flex items-center justify-between mb-3 text-xs text-muted font-mono"> | |
| 118 | + <span><strong>{{.Compare.FilesChanged}}</strong> {{pluralT .Lang .Compare.FilesChanged "repo.files_changed"}}</span> | |
| 119 | + <div class="flex items-center gap-2"> | |
| 120 | + <span style="color:hsl(var(--diff-add-fg));">+{{.Compare.TotalAdditions}}</span> | |
| 121 | + <span style="color:hsl(var(--diff-del-fg));">-{{.Compare.TotalDeletions}}</span> | |
| 122 | + </div> | |
| 123 | + </div> | |
| 124 | + | |
| 125 | + <div class="flex flex-col gap-4"> | |
| 126 | + {{range .Compare.Files}} | |
| 127 | + <section class="card"> | |
| 128 | + <div class="card-header" style="padding:0.5rem 0.8rem;"> | |
| 129 | + <div class="flex items-center gap-2 font-mono text-xs min-w-0"> | |
| 130 | + {{icon "file-code" "size-4"}} | |
| 131 | + {{if eq .Status "renamed"}} | |
| 132 | + <span>{{.OldPath}} → <strong>{{.NewPath}}</strong></span> | |
| 133 | + {{else}} | |
| 134 | + <strong>{{.NewPath}}</strong> | |
| 135 | + {{end}} | |
| 136 | + </div> | |
| 137 | + <div class="flex items-center gap-2 font-mono text-xs"> | |
| 138 | + <span style="color:hsl(var(--diff-add-fg));">+{{.Additions}}</span> | |
| 139 | + <span style="color:hsl(var(--diff-del-fg));">-{{.Deletions}}</span> | |
| 140 | + </div> | |
| 141 | + </div> | |
| 142 | + | |
| 143 | + {{if .IsBinary}} | |
| 144 | + <div class="card-body text-xs text-muted">{{t $.Lang "repo.binary_notice"}}</div> | |
| 145 | + {{else}} | |
| 146 | + <div style="overflow-x:auto;"> | |
| 147 | + <table class="diff-table"> | |
| 148 | + <tbody> | |
| 149 | + {{range .Lines}} | |
| 150 | + {{if eq .Type "hunk"}} | |
| 151 | + <tr class="diff-row-hunk"> | |
| 152 | + <td class="diff-num">...</td> | |
| 153 | + <td class="diff-num">...</td> | |
| 154 | + <td class="diff-code">{{.Content}}</td> | |
| 155 | + </tr> | |
| 156 | + {{else if eq .Type "add"}} | |
| 157 | + <tr class="diff-row-add"> | |
| 158 | + <td class="diff-num"></td> | |
| 159 | + <td class="diff-num">{{.NewNum}}</td> | |
| 160 | + <td class="diff-code">+{{.Content}}</td> | |
| 161 | + </tr> | |
| 162 | + {{else if eq .Type "del"}} | |
| 163 | + <tr class="diff-row-del"> | |
| 164 | + <td class="diff-num">{{.OldNum}}</td> | |
| 165 | + <td class="diff-num"></td> | |
| 166 | + <td class="diff-code">-{{.Content}}</td> | |
| 167 | + </tr> | |
| 168 | + {{else}} | |
| 169 | + <tr> | |
| 170 | + <td class="diff-num">{{.OldNum}}</td> | |
| 171 | + <td class="diff-num">{{.NewNum}}</td> | |
| 172 | + <td class="diff-code"> {{.Content}}</td> | |
| 173 | + </tr> | |
| 174 | + {{end}} | |
| 175 | + {{end}} | |
| 176 | + </tbody> | |
| 177 | + </table> | |
| 178 | + </div> | |
| 179 | + {{end}} | |
| 180 | + </section> | |
| 181 | + {{end}} | |
| 182 | + </div> | |
| 183 | + {{end}} | |
| 184 | + {{end}} | |
| 185 | + {{end}} | |
| 186 | +</div> | |
| 187 | +{{end}} |
internal/web/views/pages/pulls.html
+79
-0
| ... | ... | @@ -0,0 +1,79 @@ |
| 1 | +{{define "content"}} | |
| 2 | +<div class="container"> | |
| 3 | + {{template "repo_header" .}} | |
| 4 | + | |
| 5 | + <div class="flex items-center justify-between flex-wrap gap-2 mb-3"> | |
| 6 | + <div class="flex items-center gap-1.5"> | |
| 7 | + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls?state=open" class="btn {{if eq .CurrentState "open"}}btn-secondary{{else}}btn-ghost{{end}} btn-sm"> | |
| 8 | + <span style="color:hsl(var(--primary));">{{icon "git-pull-request" "size-3.5"}}</span> | |
| 9 | + <span>{{t .Lang "pulls.open"}} ({{.OpenCount}})</span> | |
| 10 | + </a> | |
| 11 | + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls?state=merged" class="btn {{if eq .CurrentState "merged"}}btn-secondary{{else}}btn-ghost{{end}} btn-sm"> | |
| 12 | + <span style="color:hsl(var(--accent));">{{icon "git-merge" "size-3.5"}}</span> | |
| 13 | + <span>{{t .Lang "pulls.merged"}} ({{.MergedCount}})</span> | |
| 14 | + </a> | |
| 15 | + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls?state=closed" class="btn {{if eq .CurrentState "closed"}}btn-secondary{{else}}btn-ghost{{end}} btn-sm"> | |
| 16 | + <span class="text-muted">{{icon "close" "size-3.5"}}</span> | |
| 17 | + <span>{{t .Lang "pulls.closed"}} ({{.ClosedCount}})</span> | |
| 18 | + </a> | |
| 19 | + </div> | |
| 20 | + | |
| 21 | + {{if .CurrentUser}} | |
| 22 | + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls/new" class="btn btn-primary btn-sm"> | |
| 23 | + {{icon "plus" "size-3.5"}} | |
| 24 | + <span>{{t .Lang "pulls.new"}}</span> | |
| 25 | + </a> | |
| 26 | + {{end}} | |
| 27 | + </div> | |
| 28 | + | |
| 29 | + {{if .Pulls}} | |
| 30 | + <div class="card"> | |
| 31 | + <div class="dense-list"> | |
| 32 | + {{range .Pulls}} | |
| 33 | + <div class="dense-row" style="min-height:3rem;padding:0.6rem 0.85rem;"> | |
| 34 | + <div class="flex items-center gap-2.5 min-w-0"> | |
| 35 | + {{if eq .Status "merged"}} | |
| 36 | + <span style="color:hsl(var(--accent));">{{icon "git-merge" "size-4"}}</span> | |
| 37 | + {{else if eq .Status "closed"}} | |
| 38 | + <span class="text-muted">{{icon "close" "size-4"}}</span> | |
| 39 | + {{else}} | |
| 40 | + <span style="color:hsl(var(--primary));">{{icon "git-pull-request" "size-4"}}</span> | |
| 41 | + {{end}} | |
| 42 | + <div class="flex flex-col min-w-0"> | |
| 43 | + <div class="flex items-center gap-2 min-w-0 flex-wrap"> | |
| 44 | + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/pulls/{{.Number}}" class="font-semibold text-sm truncate">{{.Title}}</a> | |
| 45 | + <span class="font-mono text-xs text-muted">#{{.Number}}</span> | |
| 46 | + <span class="badge badge-outline font-mono text-xs" style="padding:0.1rem 0.4rem;">{{.HeadBranch}} → {{.BaseBranch}}</span> | |
| 47 | + </div> | |
| 48 | + <div class="text-xs text-muted flex items-center gap-1.5 mt-0.5"> | |
| 49 | + <span>@{{.AuthorUsername}}</span> | |
| 50 | + <span>·</span> | |
| 51 | + <span>{{timeAgo .CreatedAt $.Lang}}</span> | |
| 52 | + {{if eq .Status "merged"}} | |
| 53 | + <span>·</span> | |
| 54 | + <span style="color:hsl(var(--accent));font-weight:500;">{{t $.Lang "pulls.merged"}}</span> | |
| 55 | + {{end}} | |
| 56 | + </div> | |
| 57 | + </div> | |
| 58 | + </div> | |
| 59 | + <div class="flex items-center gap-2 text-xs text-muted flex-shrink-0"> | |
| 60 | + {{if gt .CommentCount 0}} | |
| 61 | + <span class="flex items-center gap-1 font-mono"> | |
| 62 | + {{icon "message" "size-3.5"}} | |
| 63 | + <span>{{.CommentCount}}</span> | |
| 64 | + </span> | |
| 65 | + {{end}} | |
| 66 | + </div> | |
| 67 | + </div> | |
| 68 | + {{end}} | |
| 69 | + </div> | |
| 70 | + </div> | |
| 71 | + {{else}} | |
| 72 | + {{if .CurrentUser}} | |
| 73 | + {{template "ui_empty_state" (dict "Icon" "git-pull-request" "Title" (t .Lang "pulls.empty") "ActionHref" (printf "/%s/%s/pulls/new" .Repo.OwnerUsername .Repo.Name) "ActionLabel" (t .Lang "pulls.new") "ActionIcon" "plus")}} | |
| 74 | + {{else}} | |
| 75 | + {{template "ui_empty_state" (dict "Icon" "git-pull-request" "Title" (t .Lang "pulls.empty"))}} | |
| 76 | + {{end}} | |
| 77 | + {{end}} | |
| 78 | +</div> | |
| 79 | +{{end}} |
internal/web/views/pages/register.html
+42
-0
| ... | ... | @@ -0,0 +1,42 @@ |
| 1 | +{{define "content"}} | |
| 2 | +<div class="container" style="max-width:23rem;padding-top:2rem;"> | |
| 3 | + <div class="card"> | |
| 4 | + <div class="card-header"> | |
| 5 | + <h1 class="text-base font-semibold" style="margin:0;">{{t .Lang "auth.register.title"}}</h1> | |
| 6 | + </div> | |
| 7 | + {{if not .RegistrationEnabled}} | |
| 8 | + <div class="card-body text-sm text-muted"> | |
| 9 | + <div class="flex items-center gap-2 text-destructive"> | |
| 10 | + {{icon "lock" "size-4"}} | |
| 11 | + <span>{{t .Lang "auth.register.disabled"}}</span> | |
| 12 | + </div> | |
| 13 | + </div> | |
| 14 | + {{else}} | |
| 15 | + <form method="post" action="/register" class="card-body flex flex-col gap-3"> | |
| 16 | + <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> | |
| 17 | + <div> | |
| 18 | + <label class="label" for="username">{{t .Lang "auth.username"}}</label> | |
| 19 | + <input id="username" name="username" type="text" class="input font-mono" value="{{.FormUsername}}" placeholder="polar" required autofocus pattern="^[a-zA-Z0-9](?:[a-zA-Z0-9]|[-_][a-zA-Z0-9]){1,31}$"> | |
| 20 | + <div class="field-hint">{{t .Lang "auth.username_hint"}}</div> | |
| 21 | + </div> | |
| 22 | + <div> | |
| 23 | + <label class="label" for="email">{{t .Lang "auth.email"}}</label> | |
| 24 | + <input id="email" name="email" type="email" class="input" value="{{.FormEmail}}" placeholder="user@example.com" required autocomplete="email"> | |
| 25 | + </div> | |
| 26 | + <div> | |
| 27 | + <label class="label" for="password">{{t .Lang "auth.password"}}</label> | |
| 28 | + <input id="password" name="password" type="password" class="input" minlength="8" required autocomplete="new-password"> | |
| 29 | + <div class="field-hint">{{t .Lang "auth.password_hint"}}</div> | |
| 30 | + </div> | |
| 31 | + <button type="submit" class="btn btn-primary w-full mt-1"> | |
| 32 | + <span>{{t .Lang "auth.submit_register"}}</span> | |
| 33 | + </button> | |
| 34 | + </form> | |
| 35 | + {{end}} | |
| 36 | + <div class="card-footer text-xs text-muted flex items-center justify-between"> | |
| 37 | + <span>{{t .Lang "auth.have_account"}}</span> | |
| 38 | + <a href="/login" class="font-medium" style="color:hsl(var(--foreground));">{{t .Lang "nav.login"}} →</a> | |
| 39 | + </div> | |
| 40 | + </div> | |
| 41 | +</div> | |
| 42 | +{{end}} |
internal/web/views/pages/release_new.html
+64
-0
| ... | ... | @@ -0,0 +1,64 @@ |
| 1 | +{{define "content"}} | |
| 2 | +<div class="container"> | |
| 3 | + {{template "repo_header" .}} | |
| 4 | + | |
| 5 | + <section class="card mb-4"> | |
| 6 | + <div class="card-header"> | |
| 7 | + <span class="font-semibold text-sm flex items-center gap-2"> | |
| 8 | + {{icon "tag" "size-4"}} | |
| 9 | + <span>{{t .Lang "releases.new"}}</span> | |
| 10 | + </span> | |
| 11 | + </div> | |
| 12 | + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/releases/new" class="card-body flex flex-col gap-3"> | |
| 13 | + <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> | |
| 14 | + | |
| 15 | + <div class="grid-2"> | |
| 16 | + <div> | |
| 17 | + <label class="label" for="tag_name">{{t .Lang "releases.tag_name"}}</label> | |
| 18 | + <input id="tag_name" name="tag_name" type="text" class="input font-mono" list="existing-tags" placeholder="v1.0.0" value="{{.SelectedTag}}" required autofocus> | |
| 19 | + <datalist id="existing-tags"> | |
| 20 | + {{range .Tags}} | |
| 21 | + <option value="{{.}}"> | |
| 22 | + {{end}} | |
| 23 | + </datalist> | |
| 24 | + <div class="text-xs text-muted mt-1">{{t .Lang "releases.tag_desc"}}</div> | |
| 25 | + </div> | |
| 26 | + | |
| 27 | + <div> | |
| 28 | + <label class="label" for="target_ref">{{t .Lang "releases.target"}}</label> | |
| 29 | + <select id="target_ref" name="target_ref" class="select font-mono"> | |
| 30 | + {{range .Branches}} | |
| 31 | + <option value="{{.}}" {{if eq . $.Repo.DefaultBranch}}selected{{end}}>{{.}}</option> | |
| 32 | + {{end}} | |
| 33 | + </select> | |
| 34 | + <div class="text-xs text-muted mt-1">{{t .Lang "releases.target_desc"}}</div> | |
| 35 | + </div> | |
| 36 | + </div> | |
| 37 | + | |
| 38 | + <div> | |
| 39 | + <label class="label" for="title">{{t .Lang "releases.title"}}</label> | |
| 40 | + <input id="title" name="title" type="text" class="input" placeholder="{{t .Lang "releases.title_ph"}}" maxlength="200"> | |
| 41 | + </div> | |
| 42 | + | |
| 43 | + <div> | |
| 44 | + <label class="label" for="body">{{t .Lang "releases.body"}}</label> | |
| 45 | + <textarea id="body" name="body" class="textarea" rows="8" placeholder="{{t .Lang "releases.body_ph"}}" maxlength="50000"></textarea> | |
| 46 | + </div> | |
| 47 | + | |
| 48 | + <div> | |
| 49 | + <label class="flex items-center gap-2 cursor-pointer text-sm"> | |
| 50 | + <input type="checkbox" name="is_prerelease" value="true" class="switch"> | |
| 51 | + <span>{{t .Lang "releases.is_prerelease"}}</span> | |
| 52 | + </label> | |
| 53 | + </div> | |
| 54 | + | |
| 55 | + <div class="flex items-center justify-end"> | |
| 56 | + <button type="submit" class="btn btn-primary btn-sm"> | |
| 57 | + {{icon "tag" "size-3.5"}} | |
| 58 | + <span>{{t .Lang "releases.publish_btn"}}</span> | |
| 59 | + </button> | |
| 60 | + </div> | |
| 61 | + </form> | |
| 62 | + </section> | |
| 63 | +</div> | |
| 64 | +{{end}} |
internal/web/views/pages/releases.html
+91
-0
| ... | ... | @@ -0,0 +1,91 @@ |
| 1 | +{{define "content"}} | |
| 2 | +<div class="container"> | |
| 3 | + {{template "repo_header" .}} | |
| 4 | + | |
| 5 | + <div class="flex items-center justify-between flex-wrap gap-2 mb-4"> | |
| 6 | + <div class="flex items-center gap-2 text-sm text-muted"> | |
| 7 | + <span class="font-medium text-foreground">{{len .Releases}}</span> | |
| 8 | + <span>{{pluralT .Lang (len .Releases) "releases.count"}}</span> | |
| 9 | + <span>·</span> | |
| 10 | + <span class="font-medium text-foreground">{{.TagCount}}</span> | |
| 11 | + <span>{{pluralT .Lang .TagCount "releases.tags"}}</span> | |
| 12 | + </div> | |
| 13 | + | |
| 14 | + {{if .CanWrite}} | |
| 15 | + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/releases/new" class="btn btn-primary btn-sm"> | |
| 16 | + {{icon "plus" "size-3.5"}} | |
| 17 | + <span>{{t .Lang "releases.new"}}</span> | |
| 18 | + </a> | |
| 19 | + {{end}} | |
| 20 | + </div> | |
| 21 | + | |
| 22 | + {{if .Releases}} | |
| 23 | + <div class="flex flex-col gap-6"> | |
| 24 | + {{range .Releases}} | |
| 25 | + <article class="card" id="{{.TagName}}"> | |
| 26 | + <div class="card-header flex items-center justify-between flex-wrap gap-2"> | |
| 27 | + <div class="flex items-center gap-2.5 flex-wrap min-w-0"> | |
| 28 | + <h2 class="text-base font-bold" style="margin:0;">{{.Title}}</h2> | |
| 29 | + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/tree/{{.TagName}}" class="badge badge-primary font-mono text-xs flex items-center gap-1"> | |
| 30 | + {{icon "tag" "size-3"}} | |
| 31 | + <span>{{.TagName}}</span> | |
| 32 | + </a> | |
| 33 | + {{if .IsPrerelease}} | |
| 34 | + <span class="badge badge-outline text-xs" style="color:hsl(var(--warning-fg, 38 92% 50%));border-color:currentColor;"> | |
| 35 | + {{t $.Lang "releases.prerelease"}} | |
| 36 | + </span> | |
| 37 | + {{end}} | |
| 38 | + </div> | |
| 39 | + | |
| 40 | + <div class="flex items-center gap-2 text-xs text-muted flex-shrink-0"> | |
| 41 | + <div class="flex items-center gap-1.5"> | |
| 42 | + {{avatar .AuthorUsername .AuthorAvatar}} | |
| 43 | + <a href="/{{.AuthorUsername}}" class="font-medium text-foreground">@{{.AuthorUsername}}</a> | |
| 44 | + <span>·</span> | |
| 45 | + <span>{{formatDate .PublishedAt}}</span> | |
| 46 | + </div> | |
| 47 | + {{if $.CanWrite}} | |
| 48 | + <form method="post" action="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/releases/{{.ID}}/delete" onsubmit="return confirm('{{t $.Lang "releases.delete_confirm"}}');" style="margin:0;"> | |
| 49 | + <input type="hidden" name="_csrf" value="{{$.CSRFToken}}"> | |
| 50 | + <button type="submit" class="btn btn-ghost btn-icon text-muted" style="width:1.8rem;height:1.8rem;" title="{{t $.Lang "releases.delete"}}"> | |
| 51 | + {{icon "trash" "size-3.5"}} | |
| 52 | + </button> | |
| 53 | + </form> | |
| 54 | + {{end}} | |
| 55 | + </div> | |
| 56 | + </div> | |
| 57 | + | |
| 58 | + <div class="card-body markdown-body"> | |
| 59 | + {{if .BodyHTML}} | |
| 60 | + {{.BodyHTML}} | |
| 61 | + {{else}} | |
| 62 | + <span class="text-xs text-muted">—</span> | |
| 63 | + {{end}} | |
| 64 | + </div> | |
| 65 | + | |
| 66 | + <!-- Release Assets (Source Code) --> | |
| 67 | + <div class="card-body" style="border-top:1px solid hsl(var(--border));padding:0.6rem 0.9rem;background:hsl(var(--muted)/0.15);"> | |
| 68 | + <div class="text-xs font-semibold text-muted uppercase mb-2" style="letter-spacing:0.04em;">{{t $.Lang "releases.assets"}}</div> | |
| 69 | + <div class="flex flex-col gap-1.5"> | |
| 70 | + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/archive/{{.TagName}}.zip" class="flex items-center gap-2 text-xs font-mono font-medium hover:underline" style="color:hsl(var(--primary));"> | |
| 71 | + {{icon "download" "size-3.5"}} | |
| 72 | + <span>{{t $.Lang "releases.source_zip"}}</span> | |
| 73 | + </a> | |
| 74 | + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/archive/{{.TagName}}.tar.gz" class="flex items-center gap-2 text-xs font-mono font-medium hover:underline" style="color:hsl(var(--primary));"> | |
| 75 | + {{icon "download" "size-3.5"}} | |
| 76 | + <span>{{t $.Lang "releases.source_tar"}}</span> | |
| 77 | + </a> | |
| 78 | + </div> | |
| 79 | + </div> | |
| 80 | + </article> | |
| 81 | + {{end}} | |
| 82 | + </div> | |
| 83 | + {{else}} | |
| 84 | + {{if .CanWrite}} | |
| 85 | + {{template "ui_empty_state" (dict "Icon" "tag" "Title" (t .Lang "releases.empty") "ActionHref" (printf "/%s/%s/releases/new" .Repo.OwnerUsername .Repo.Name) "ActionLabel" (t .Lang "releases.new") "ActionIcon" "plus")}} | |
| 86 | + {{else}} | |
| 87 | + {{template "ui_empty_state" (dict "Icon" "tag" "Title" (t .Lang "releases.empty"))}} | |
| 88 | + {{end}} | |
| 89 | + {{end}} | |
| 90 | +</div> | |
| 91 | +{{end}} |
internal/web/views/pages/repo.html
+218
-0
| ... | ... | @@ -0,0 +1,218 @@ |
| 1 | +{{define "content"}} | |
| 2 | +<div class="container"> | |
| 3 | + {{template "repo_header" .}} | |
| 4 | + | |
| 5 | + {{if .IsEmpty}} | |
| 6 | + <div class="card"> | |
| 7 | + <div class="card-header"> | |
| 8 | + <span class="font-semibold flex items-center gap-2">{{icon "terminal" "size-4"}} {{t .Lang "repo.empty.title"}}</span> | |
| 9 | + <a href="/settings#tokens" class="btn btn-outline btn-sm"> | |
| 10 | + {{icon "key" "size-3.5"}} | |
| 11 | + <span>{{t .Lang "settings.tokens"}}</span> | |
| 12 | + </a> | |
| 13 | + </div> | |
| 14 | + <div class="card-body flex flex-col gap-4"> | |
| 15 | + <div> | |
| 16 | + <div class="flex items-center justify-between mb-1.5"> | |
| 17 | + <span class="text-xs font-semibold">1. {{t .Lang "repo.empty.new_cmd"}}</span> | |
| 18 | + <button type="button" class="btn btn-ghost btn-sm" data-copy="{{.QuickStartNew}}"> | |
| 19 | + {{icon "copy" "size-3.5"}} | |
| 20 | + <span>{{t .Lang "common.copy"}}</span> | |
| 21 | + </button> | |
| 22 | + </div> | |
| 23 | + <pre class="card p-3 font-mono text-xs" style="margin:0;background:hsl(var(--muted)/0.4);overflow-x:auto;"><code>{{.QuickStartNew}}</code></pre> | |
| 24 | + </div> | |
| 25 | + | |
| 26 | + <div> | |
| 27 | + <div class="flex items-center justify-between mb-1.5"> | |
| 28 | + <span class="text-xs font-semibold">2. {{t .Lang "repo.empty.push_cmd"}}</span> | |
| 29 | + <button type="button" class="btn btn-ghost btn-sm" data-copy="{{.QuickStartExisting}}"> | |
| 30 | + {{icon "copy" "size-3.5"}} | |
| 31 | + <span>{{t .Lang "common.copy"}}</span> | |
| 32 | + </button> | |
| 33 | + </div> | |
| 34 | + <pre class="card p-3 font-mono text-xs" style="margin:0;background:hsl(var(--muted)/0.4);overflow-x:auto;"><code>{{.QuickStartExisting}}</code></pre> | |
| 35 | + </div> | |
| 36 | + </div> | |
| 37 | + </div> | |
| 38 | + {{else}} | |
| 39 | + <div class="repo-layout"> | |
| 40 | + <div class="repo-main"> | |
| 41 | + <!-- Controls Bar: Branch/Tag Switcher + Breadcrumb + File Filter --> | |
| 42 | + <div class="flex items-center justify-between flex-wrap gap-2 mb-3"> | |
| 43 | + <div class="flex items-center gap-2 flex-wrap"> | |
| 44 | + <div class="dropdown" data-dropdown> | |
| 45 | + <button type="button" class="btn btn-outline btn-sm font-mono" data-dropdown-trigger aria-haspopup="true" aria-expanded="false"> | |
| 46 | + {{icon "git-branch" "size-3.5"}} | |
| 47 | + <span>{{.Ref}}</span> | |
| 48 | + {{icon "chevron-down" "size-3.5"}} | |
| 49 | + </button> | |
| 50 | + <div class="dropdown-menu" data-dropdown-menu role="menu" style="min-width:13rem;"> | |
| 51 | + <div class="palette-group-title">{{t .Lang "repo.branches"}} ({{len .Branches}})</div> | |
| 52 | + {{range .Branches}} | |
| 53 | + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/tree/{{.}}{{if $.SubPath}}/{{$.SubPath}}{{end}}" class="dropdown-item font-mono {{if eq . $.Ref}}active{{end}}" role="menuitem"> | |
| 54 | + <span class="flex items-center gap-2">{{icon "git-branch" "size-3.5"}} {{.}}</span> | |
| 55 | + {{if eq . $.Ref}}{{icon "check" "size-3.5"}}{{end}} | |
| 56 | + </a> | |
| 57 | + {{end}} | |
| 58 | + {{if .Tags}} | |
| 59 | + <hr class="separator" style="margin:0.25rem 0;"> | |
| 60 | + <div class="palette-group-title">{{t .Lang "repo.tags"}} ({{len .Tags}})</div> | |
| 61 | + {{range .Tags}} | |
| 62 | + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/tree/{{.}}{{if $.SubPath}}/{{$.SubPath}}{{end}}" class="dropdown-item font-mono {{if eq . $.Ref}}active{{end}}" role="menuitem"> | |
| 63 | + <span class="flex items-center gap-2">{{icon "tag" "size-3.5"}} {{.}}</span> | |
| 64 | + {{if eq . $.Ref}}{{icon "check" "size-3.5"}}{{end}} | |
| 65 | + </a> | |
| 66 | + {{end}} | |
| 67 | + {{end}} | |
| 68 | + </div> | |
| 69 | + </div> | |
| 70 | + | |
| 71 | + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/branches" class="btn btn-ghost btn-sm font-mono" title="{{t .Lang "repo.tab.branches"}}"> | |
| 72 | + {{icon "git-branch" "size-3.5"}} | |
| 73 | + <span class="text-xs">{{len .Branches}}</span> | |
| 74 | + </a> | |
| 75 | + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/commits/{{.Ref}}" class="btn btn-ghost btn-sm font-mono" title="{{t .Lang "repo.tab.commits"}}"> | |
| 76 | + {{icon "history" "size-3.5"}} | |
| 77 | + <span class="text-xs">{{t .Lang "repo.tab.commits"}}</span> | |
| 78 | + </a> | |
| 79 | + | |
| 80 | + {{if .Breadcrumbs}} | |
| 81 | + <nav class="breadcrumb font-mono text-xs" aria-label="Directory path"> | |
| 82 | + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/tree/{{.Ref}}" class="text-muted">{{.Repo.Name}}</a> | |
| 83 | + {{range .Breadcrumbs}} | |
| 84 | + <span class="breadcrumb-sep">/</span> | |
| 85 | + {{if .IsLast}} | |
| 86 | + <span class="font-semibold">{{.Name}}</span> | |
| 87 | + {{else}} | |
| 88 | + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/tree/{{$.Ref}}/{{.Path}}" class="text-muted">{{.Name}}</a> | |
| 89 | + {{end}} | |
| 90 | + {{end}} | |
| 91 | + </nav> | |
| 92 | + {{end}} | |
| 93 | + </div> | |
| 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> | |
| 98 | + </div> | |
| 99 | + </div> | |
| 100 | + | |
| 101 | + <!-- Unified Repository Card: Latest Commit Header + File List --> | |
| 102 | + <div class="card mb-4"> | |
| 103 | + {{if .LatestCommit}} | |
| 104 | + <div class="card-header text-xs" style="padding:0.5rem 0.8rem;background:hsl(var(--muted)/0.3);"> | |
| 105 | + <div class="flex items-center gap-2 min-w-0"> | |
| 106 | + <span class="avatar" style="width:1.25rem;height:1.25rem;font-size:0.62rem;">{{avatarInitial .LatestCommit.AuthorName}}</span> | |
| 107 | + <span class="font-medium">{{.LatestCommit.AuthorName}}</span> | |
| 108 | + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/commit/{{.LatestCommit.Hash}}" class="text-muted truncate">{{.LatestCommit.Subject}}</a> | |
| 109 | + </div> | |
| 110 | + <div class="flex items-center gap-2 text-muted font-mono"> | |
| 111 | + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/commit/{{.LatestCommit.Hash}}" class="badge badge-outline font-mono">{{.LatestCommit.ShortHash}}</a> | |
| 112 | + <span>{{timeAgo .LatestCommit.AuthoredAt .Lang}}</span> | |
| 113 | + </div> | |
| 114 | + </div> | |
| 115 | + {{end}} | |
| 116 | + | |
| 117 | + <div class="dense-list"> | |
| 118 | + {{if .SubPath}} | |
| 119 | + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/tree/{{.Ref}}{{if ne .ParentPath "."}}/{{.ParentPath}}{{end}}" class="dense-row font-mono text-xs text-muted"> | |
| 120 | + <span class="flex items-center gap-2">{{icon "arrow-left" "size-3.5"}} ..</span> | |
| 121 | + </a> | |
| 122 | + {{end}} | |
| 123 | + {{range .Entries}} | |
| 124 | + <div class="dense-row" data-tree-row data-tree-name="{{.Name}}"> | |
| 125 | + <div class="flex items-center gap-2 min-w-0" style="flex:1 1 40%;"> | |
| 126 | + {{if eq .Type "tree"}} | |
| 127 | + {{icon "folder" "size-4"}} | |
| 128 | + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/tree/{{$.Ref}}/{{.Path}}" class="font-medium truncate">{{.Name}}</a> | |
| 129 | + {{else}} | |
| 130 | + {{icon "file" "size-4"}} | |
| 131 | + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/blob/{{$.Ref}}/{{.Path}}" class="truncate">{{.Name}}</a> | |
| 132 | + {{end}} | |
| 133 | + </div> | |
| 134 | + <div class="hidden md-flex text-xs text-muted truncate" style="flex:1 1 45%;"> | |
| 135 | + {{if .CommitHash}} | |
| 136 | + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/commit/{{.CommitHash}}" class="truncate text-muted">{{.CommitMsg}}</a> | |
| 137 | + {{end}} | |
| 138 | + </div> | |
| 139 | + <div class="text-xs text-muted font-mono" style="text-align:right;min-width:5rem;"> | |
| 140 | + {{if not .CommitTime.IsZero}} | |
| 141 | + {{timeAgo .CommitTime $.Lang}} | |
| 142 | + {{else if eq .Type "blob"}} | |
| 143 | + {{formatSize .Size}} | |
| 144 | + {{end}} | |
| 145 | + </div> | |
| 146 | + </div> | |
| 147 | + {{end}} | |
| 148 | + </div> | |
| 149 | + </div> | |
| 150 | + | |
| 151 | + {{if .ReadmeHTML}} | |
| 152 | + <section class="card"> | |
| 153 | + <div class="card-header" style="padding:0.55rem 0.85rem;"> | |
| 154 | + <span class="font-semibold text-xs flex items-center gap-2 font-mono"> | |
| 155 | + {{icon "book-open" "size-3.5"}} | |
| 156 | + <span>{{.ReadmeName}}</span> | |
| 157 | + </span> | |
| 158 | + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/blob/{{.Ref}}/{{.ReadmeName}}" class="text-xs text-muted">{{t .Lang "repo.view_file"}}</a> | |
| 159 | + </div> | |
| 160 | + <div class="card-body markdown-body"> | |
| 161 | + {{.ReadmeHTML}} | |
| 162 | + </div> | |
| 163 | + </section> | |
| 164 | + {{end}} | |
| 165 | + </div> | |
| 166 | + | |
| 167 | + <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 | + <div class="side-card"> | |
| 200 | + <div class="side-card-header">{{icon "terminal" "size-3.5"}} {{t .Lang "repo.clone_title"}}</div> | |
| 201 | + <div class="side-card-body"> | |
| 202 | + <div class="clone-box"> | |
| 203 | + <code>{{.CloneURL}}</code> | |
| 204 | + <button type="button" class="btn btn-ghost btn-icon" style="width:1.6rem;height:1.6rem;" data-copy="{{.CloneURL}}" title="{{t .Lang "common.copy"}}"> | |
| 205 | + {{icon "copy" "size-3.5"}} | |
| 206 | + </button> | |
| 207 | + </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> | |
| 212 | + </div> | |
| 213 | + </div> | |
| 214 | + </aside> | |
| 215 | + </div> | |
| 216 | + {{end}} | |
| 217 | +</div> | |
| 218 | +{{end}} |
internal/web/views/pages/repo_new.html
+55
-0
| ... | ... | @@ -0,0 +1,55 @@ |
| 1 | +{{define "content"}} | |
| 2 | +<div class="container" style="max-width:32rem;"> | |
| 3 | + <div class="card"> | |
| 4 | + <div class="card-header"> | |
| 5 | + <h1 class="text-base font-semibold" style="margin:0;">{{t .Lang "repo.new.title"}}</h1> | |
| 6 | + </div> | |
| 7 | + | |
| 8 | + <form method="post" action="/new" class="card-body flex flex-col gap-3"> | |
| 9 | + <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> | |
| 10 | + | |
| 11 | + <div> | |
| 12 | + <label class="label" for="name">{{t .Lang "repo.name"}}</label> | |
| 13 | + <div class="flex items-center gap-2"> | |
| 14 | + <span class="text-sm text-muted font-mono flex-shrink-0">{{.CurrentUser.Username}} /</span> | |
| 15 | + <input id="name" name="name" type="text" class="input font-mono flex-1 min-w-0" value="{{.FormName}}" placeholder="project" required autofocus pattern="^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$"> | |
| 16 | + </div> | |
| 17 | + <div class="field-hint">{{t .Lang "repo.name_hint"}}</div> | |
| 18 | + </div> | |
| 19 | + | |
| 20 | + <div> | |
| 21 | + <label class="label" for="description">{{t .Lang "repo.description"}}</label> | |
| 22 | + <input id="description" name="description" type="text" class="input" value="{{.FormDescription}}" maxlength="500" placeholder="{{t .Lang "repo.description_ph"}}"> | |
| 23 | + </div> | |
| 24 | + | |
| 25 | + <div> | |
| 26 | + <span class="label">{{t .Lang "repo.visibility"}}</span> | |
| 27 | + <div class="flex flex-col gap-2"> | |
| 28 | + <label class="card p-3 flex items-start gap-2.5" style="cursor:pointer;"> | |
| 29 | + <input type="radio" name="visibility" value="public" checked style="margin-top:0.2rem;"> | |
| 30 | + <div> | |
| 31 | + <div class="font-medium text-sm flex items-center gap-1.5">{{icon "globe" "size-4"}} {{t .Lang "repo.public"}}</div> | |
| 32 | + <div class="text-xs text-muted mt-1">{{t .Lang "repo.public_desc"}}</div> | |
| 33 | + </div> | |
| 34 | + </label> | |
| 35 | + <label class="card p-3 flex items-start gap-2.5" style="cursor:pointer;"> | |
| 36 | + <input type="radio" name="visibility" value="private" style="margin-top:0.2rem;"> | |
| 37 | + <div> | |
| 38 | + <div class="font-medium text-sm flex items-center gap-1.5">{{icon "lock" "size-4"}} {{t .Lang "repo.private"}}</div> | |
| 39 | + <div class="text-xs text-muted mt-1">{{t .Lang "repo.private_desc"}}</div> | |
| 40 | + </div> | |
| 41 | + </label> | |
| 42 | + </div> | |
| 43 | + </div> | |
| 44 | + | |
| 45 | + <div class="flex items-center justify-end gap-2 pt-2"> | |
| 46 | + <a href="/repos" class="btn btn-outline">{{t .Lang "common.cancel"}}</a> | |
| 47 | + <button type="submit" class="btn btn-primary"> | |
| 48 | + {{icon "plus" "size-4"}} | |
| 49 | + <span>{{t .Lang "repo.create_btn"}}</span> | |
| 50 | + </button> | |
| 51 | + </div> | |
| 52 | + </form> | |
| 53 | + </div> | |
| 54 | +</div> | |
| 55 | +{{end}} |
internal/web/views/pages/repo_settings.html
+210
-0
| ... | ... | @@ -0,0 +1,210 @@ |
| 1 | +{{define "content"}} | |
| 2 | +<div class="container"> | |
| 3 | + {{template "repo_header" .}} | |
| 4 | + | |
| 5 | + <!-- General Settings --> | |
| 6 | + <section class="card mb-4"> | |
| 7 | + <div class="card-header"> | |
| 8 | + <span class="font-semibold flex items-center gap-2">{{icon "settings" "size-4"}} {{t .Lang "repo.tab.settings"}}</span> | |
| 9 | + </div> | |
| 10 | + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/settings" class="card-body flex flex-col gap-3"> | |
| 11 | + <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> | |
| 12 | + | |
| 13 | + <div> | |
| 14 | + <label class="label" for="description">{{t .Lang "repo.description"}}</label> | |
| 15 | + <input id="description" name="description" type="text" class="input" value="{{.Repo.Description}}" maxlength="500" placeholder="{{t .Lang "repo.description_ph"}}"> | |
| 16 | + </div> | |
| 17 | + | |
| 18 | + <div class="grid-2"> | |
| 19 | + <div> | |
| 20 | + <label class="label" for="default_branch">{{t .Lang "repo.default_branch"}}</label> | |
| 21 | + <select id="default_branch" name="default_branch" class="select font-mono"> | |
| 22 | + {{if .Branches}} | |
| 23 | + {{range .Branches}} | |
| 24 | + <option value="{{.}}" {{if eq . $.Repo.DefaultBranch}}selected{{end}}>{{.}}</option> | |
| 25 | + {{end}} | |
| 26 | + {{else}} | |
| 27 | + <option value="{{.Repo.DefaultBranch}}" selected>{{.Repo.DefaultBranch}}</option> | |
| 28 | + {{end}} | |
| 29 | + </select> | |
| 30 | + </div> | |
| 31 | + | |
| 32 | + <div> | |
| 33 | + <label class="label" for="visibility">{{t .Lang "repo.visibility"}}</label> | |
| 34 | + <select id="visibility" name="visibility" class="select"> | |
| 35 | + <option value="public" {{if not .Repo.IsPrivate}}selected{{end}}>{{t .Lang "repo.public"}}</option> | |
| 36 | + <option value="private" {{if .Repo.IsPrivate}}selected{{end}}>{{t .Lang "repo.private"}}</option> | |
| 37 | + </select> | |
| 38 | + </div> | |
| 39 | + </div> | |
| 40 | + | |
| 41 | + <!-- Feature Toggles for Repository --> | |
| 42 | + <div class="card p-3 mt-1" style="background:hsl(var(--muted) / 0.25);"> | |
| 43 | + <div class="font-semibold text-xs text-muted mb-2 uppercase" style="letter-spacing:0.04em;">{{t .Lang "repo.features"}}</div> | |
| 44 | + <div class="flex flex-col gap-2.5"> | |
| 45 | + <label class="flex items-center justify-between cursor-pointer"> | |
| 46 | + <span class="flex items-center gap-2 text-sm font-medium"> | |
| 47 | + {{icon "issue" "size-4"}} | |
| 48 | + <span>{{t .Lang "repo.tab.issues"}}</span> | |
| 49 | + </span> | |
| 50 | + <input type="checkbox" name="enable_issues" value="true" class="switch" {{if .Repo.EnableIssues}}checked{{end}}> | |
| 51 | + </label> | |
| 52 | + <label class="flex items-center justify-between cursor-pointer"> | |
| 53 | + <span class="flex items-center gap-2 text-sm font-medium"> | |
| 54 | + {{icon "git-pull-request" "size-4"}} | |
| 55 | + <span>{{t .Lang "repo.tab.pulls"}}</span> | |
| 56 | + </span> | |
| 57 | + <input type="checkbox" name="enable_pulls" value="true" class="switch" {{if .Repo.EnablePulls}}checked{{end}}> | |
| 58 | + </label> | |
| 59 | + <label class="flex items-center justify-between cursor-pointer"> | |
| 60 | + <span class="flex items-center gap-2 text-sm font-medium"> | |
| 61 | + {{icon "tag" "size-4"}} | |
| 62 | + <span>{{t .Lang "repo.tab.releases"}}</span> | |
| 63 | + </span> | |
| 64 | + <input type="checkbox" name="enable_releases" value="true" class="switch" {{if .Repo.EnableReleases}}checked{{end}}> | |
| 65 | + </label> | |
| 66 | + <label class="flex items-center justify-between cursor-pointer"> | |
| 67 | + <span class="flex items-center gap-2 text-sm font-medium"> | |
| 68 | + {{icon "activity" "size-4"}} | |
| 69 | + <span>{{t .Lang "repo.tab.activity"}}</span> | |
| 70 | + </span> | |
| 71 | + <input type="checkbox" name="enable_activity" value="true" class="switch" {{if .Repo.EnableActivity}}checked{{end}}> | |
| 72 | + </label> | |
| 73 | + </div> | |
| 74 | + </div> | |
| 75 | + | |
| 76 | + <div class="flex items-center justify-end"> | |
| 77 | + <button type="submit" class="btn btn-primary btn-sm">{{t .Lang "repo.save"}}</button> | |
| 78 | + </div> | |
| 79 | + </form> | |
| 80 | + </section> | |
| 81 | + | |
| 82 | + <!-- Collaborators --> | |
| 83 | + <section class="card mb-4" id="collaborators"> | |
| 84 | + <div class="card-header"> | |
| 85 | + <span class="font-semibold flex items-center gap-2">{{icon "users" "size-4"}} {{t .Lang "collab.title"}}</span> | |
| 86 | + <span class="text-xs text-muted">{{t .Lang "collab.desc"}}</span> | |
| 87 | + </div> | |
| 88 | + | |
| 89 | + <div class="dense-list" style="border-bottom:1px solid hsl(var(--border));"> | |
| 90 | + <div class="dense-row" style="min-height:2.6rem;padding:0.45rem 0.85rem;"> | |
| 91 | + <div class="flex items-center gap-2.5 min-w-0"> | |
| 92 | + {{avatar .Repo.OwnerUsername .Repo.OwnerAvatarURL}} | |
| 93 | + <div class="min-w-0"> | |
| 94 | + <a href="/{{.Repo.OwnerUsername}}" class="font-semibold text-sm">{{.Repo.OwnerUsername}}</a> | |
| 95 | + </div> | |
| 96 | + </div> | |
| 97 | + <span class="badge badge-outline">{{t .Lang "collab.owner"}}</span> | |
| 98 | + </div> | |
| 99 | + {{range .Collaborators}} | |
| 100 | + <div class="dense-row" style="min-height:2.6rem;padding:0.45rem 0.85rem;"> | |
| 101 | + <div class="flex items-center gap-2.5 min-w-0"> | |
| 102 | + {{avatar .Username .AvatarURL}} | |
| 103 | + <div class="min-w-0"> | |
| 104 | + <a href="/{{.Username}}" class="font-semibold text-sm">{{.Username}}</a> | |
| 105 | + {{if .FullName}}<div class="text-xs text-muted truncate">{{.FullName}}</div>{{end}} | |
| 106 | + </div> | |
| 107 | + </div> | |
| 108 | + <div class="flex items-center gap-2"> | |
| 109 | + <span class="badge badge-outline font-mono">{{if eq .Role "read"}}{{t $.Lang "collab.role_read"}}{{else}}{{t $.Lang "collab.role_write"}}{{end}}</span> | |
| 110 | + {{if $.CanManageCollab}} | |
| 111 | + <form method="post" action="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/settings/collaborators/remove" style="margin:0;"> | |
| 112 | + <input type="hidden" name="_csrf" value="{{$.CSRFToken}}"> | |
| 113 | + <input type="hidden" name="user_id" value="{{.UserID}}"> | |
| 114 | + <button type="submit" class="btn btn-ghost btn-icon" style="width:1.75rem;height:1.75rem;" title="{{t $.Lang "collab.remove"}}"> | |
| 115 | + {{icon "close" "size-3.5"}} | |
| 116 | + </button> | |
| 117 | + </form> | |
| 118 | + {{end}} | |
| 119 | + </div> | |
| 120 | + </div> | |
| 121 | + {{else}} | |
| 122 | + <div class="card-body text-xs text-muted">{{t .Lang "collab.empty"}}</div> | |
| 123 | + {{end}} | |
| 124 | + </div> | |
| 125 | + | |
| 126 | + {{if .CanManageCollab}} | |
| 127 | + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/settings/collaborators/add" class="card-body flex items-end gap-2 flex-wrap"> | |
| 128 | + <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> | |
| 129 | + <div style="flex:1 1 12rem;"> | |
| 130 | + <label class="label" for="collab_username">{{t .Lang "collab.username"}}</label> | |
| 131 | + <input id="collab_username" name="username" type="text" class="input font-mono" placeholder="{{t .Lang "collab.username_ph"}}" required> | |
| 132 | + </div> | |
| 133 | + <div> | |
| 134 | + <label class="label" for="collab_role">{{t .Lang "collab.role"}}</label> | |
| 135 | + <select id="collab_role" name="role" class="select"> | |
| 136 | + <option value="write">{{t .Lang "collab.role_write"}}</option> | |
| 137 | + <option value="read">{{t .Lang "collab.role_read"}}</option> | |
| 138 | + </select> | |
| 139 | + </div> | |
| 140 | + <button type="submit" class="btn btn-primary btn-sm"> | |
| 141 | + {{icon "plus" "size-3.5"}} | |
| 142 | + <span>{{t .Lang "collab.add"}}</span> | |
| 143 | + </button> | |
| 144 | + </form> | |
| 145 | + {{end}} | |
| 146 | + </section> | |
| 147 | + | |
| 148 | + <!-- Rename Repository --> <section class="card mb-4"> | |
| 149 | + <div class="card-header"> | |
| 150 | + <span class="font-semibold flex items-center gap-2">{{icon "edit" "size-4"}} {{t .Lang "repo.rename"}}</span> | |
| 151 | + </div> | |
| 152 | + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/settings/rename" class="card-body flex items-center gap-2"> | |
| 153 | + <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> | |
| 154 | + <input name="new_name" type="text" class="input font-mono" value="{{.Repo.Name}}" required pattern="^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$"> | |
| 155 | + <button type="submit" class="btn btn-outline">{{t .Lang "repo.rename"}}</button> | |
| 156 | + </form> | |
| 157 | + </section> | |
| 158 | + | |
| 159 | + <!-- Danger Zone --> | |
| 160 | + <section class="card" style="border-color:hsl(var(--destructive) / 0.45);"> | |
| 161 | + <div class="card-header" style="border-bottom-color:hsl(var(--destructive) / 0.2);"> | |
| 162 | + <span class="font-semibold text-destructive flex items-center gap-2">{{icon "alert-circle" "size-4"}} {{t .Lang "repo.danger_zone"}}</span> | |
| 163 | + </div> | |
| 164 | + <div class="card-body flex items-center justify-between flex-wrap gap-3"> | |
| 165 | + <div> | |
| 166 | + <div class="font-medium text-sm">{{t .Lang "repo.delete"}}</div> | |
| 167 | + <div class="text-xs text-muted">{{t .Lang "repo.delete_desc"}}</div> | |
| 168 | + </div> | |
| 169 | + <button type="button" class="btn btn-destructive btn-sm" data-dialog-open="delete-repo-dialog"> | |
| 170 | + {{icon "trash" "size-3.5"}} | |
| 171 | + <span>{{t .Lang "repo.delete"}}</span> | |
| 172 | + </button> | |
| 173 | + </div> | |
| 174 | + </section> | |
| 175 | +</div> | |
| 176 | + | |
| 177 | +<!-- Delete Confirmation Modal --> | |
| 178 | +<div id="delete-repo-dialog" class="backdrop" role="dialog" aria-modal="true" aria-label="{{t .Lang "repo.delete"}}" aria-hidden="true"> | |
| 179 | + <div class="dialog-box"> | |
| 180 | + <div class="card-header"> | |
| 181 | + <span class="font-semibold text-destructive flex items-center gap-2">{{icon "trash" "size-4"}} {{t .Lang "repo.delete"}}</span> | |
| 182 | + <button type="button" class="btn btn-ghost btn-icon" data-dialog-close aria-label="{{t .Lang "common.close"}}">{{icon "close" "size-4"}}</button> | |
| 183 | + </div> | |
| 184 | + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/settings/delete"> | |
| 185 | + <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> | |
| 186 | + <div class="card-body flex flex-col gap-3"> | |
| 187 | + <p class="text-sm text-muted" style="margin:0;"> | |
| 188 | + {{t .Lang "repo.delete_confirm"}}: <strong class="font-mono" style="color:hsl(var(--foreground));">{{.Repo.Name}}</strong> | |
| 189 | + </p> | |
| 190 | + <input | |
| 191 | + type="text" | |
| 192 | + name="confirm_name" | |
| 193 | + class="input font-mono" | |
| 194 | + placeholder="{{.Repo.Name}}" | |
| 195 | + autocomplete="off" | |
| 196 | + required | |
| 197 | + data-confirm-input="{{.Repo.Name}}" | |
| 198 | + data-confirm-button="confirm-delete-btn"> | |
| 199 | + </div> | |
| 200 | + <div class="card-footer flex items-center justify-end gap-2"> | |
| 201 | + <button type="button" class="btn btn-outline btn-sm" data-dialog-close>{{t .Lang "common.cancel"}}</button> | |
| 202 | + <button id="confirm-delete-btn" type="submit" class="btn btn-destructive btn-sm" disabled> | |
| 203 | + {{icon "trash" "size-3.5"}} | |
| 204 | + <span>{{t .Lang "repo.delete"}}</span> | |
| 205 | + </button> | |
| 206 | + </div> | |
| 207 | + </form> | |
| 208 | + </div> | |
| 209 | +</div> | |
| 210 | +{{end}} |
internal/web/views/pages/repos.html
+78
-0
| ... | ... | @@ -0,0 +1,78 @@ |
| 1 | +{{define "content"}} | |
| 2 | +<div class="container"> | |
| 3 | + {{if .ProfileUser}} | |
| 4 | + <div class="card p-4 mb-4 flex items-center justify-between flex-wrap gap-3"> | |
| 5 | + <div class="flex items-center gap-3"> | |
| 6 | + <span class="avatar avatar-lg">{{avatarInitial .ProfileUser.Username}}</span> | |
| 7 | + <div> | |
| 8 | + <div class="flex items-center gap-2"> | |
| 9 | + <h1 class="text-lg font-semibold" style="margin:0;">{{.ProfileUser.Username}}</h1> | |
| 10 | + {{if .ProfileUser.IsAdmin}} | |
| 11 | + <span class="badge">{{t .Lang "admin.role.admin"}}</span> | |
| 12 | + {{end}} | |
| 13 | + </div> | |
| 14 | + {{if .ProfileUser.Bio}} | |
| 15 | + <p class="text-sm text-muted mt-1" style="margin-bottom:0;">{{.ProfileUser.Bio}}</p> | |
| 16 | + {{end}} | |
| 17 | + </div> | |
| 18 | + </div> | |
| 19 | + {{if .CurrentUser}} | |
| 20 | + {{if eq .CurrentUser.ID .ProfileUser.ID}} | |
| 21 | + <a href="/settings" class="btn btn-outline btn-sm">{{icon "settings" "size-3.5"}} {{t .Lang "nav.settings"}}</a> | |
| 22 | + {{end}} | |
| 23 | + {{end}} | |
| 24 | + </div> | |
| 25 | + {{end}} | |
| 26 | + | |
| 27 | + <div class="flex items-center justify-between flex-wrap gap-3 mb-4"> | |
| 28 | + <h2 class="text-lg font-semibold" style="margin:0;">{{.PageHeading}}</h2> | |
| 29 | + <div class="flex items-center gap-2"> | |
| 30 | + <div class="flex items-center" style="position:relative;"> | |
| 31 | + <input type="text" class="input" style="height:1.9rem;width:14rem;padding-right:1.8rem;" placeholder="{{t .Lang "repo.filter_ph"}}" data-search-input data-file-filter> | |
| 32 | + <kbd class="kbd" style="position:absolute;right:0.4rem;pointer-events:none;">/</kbd> | |
| 33 | + </div> | |
| 34 | + {{if .CurrentUser}} | |
| 35 | + <a href="/new" class="btn btn-primary"> | |
| 36 | + {{icon "plus" "size-4"}} | |
| 37 | + <span>{{t .Lang "nav.new_repo"}}</span> | |
| 38 | + </a> | |
| 39 | + {{end}} | |
| 40 | + </div> | |
| 41 | + </div> | |
| 42 | + | |
| 43 | + {{if .Repos}} | |
| 44 | + <div class="card"> | |
| 45 | + {{range .Repos}} | |
| 46 | + <div class="dense-row" style="min-height:2.5rem;padding:0.4rem 0.85rem;" data-tree-row data-tree-name="{{.OwnerUsername}}/{{.Name}} {{.Description}}"> | |
| 47 | + <div class="flex items-center gap-2 min-w-0"> | |
| 48 | + {{if .IsPrivate}} | |
| 49 | + {{icon "lock" "size-4"}} | |
| 50 | + {{else}} | |
| 51 | + {{icon "git-branch" "size-4"}} | |
| 52 | + {{end}} | |
| 53 | + <a href="/{{.OwnerUsername}}/{{.Name}}" class="font-medium text-sm"> | |
| 54 | + <span class="text-muted">{{.OwnerUsername}} /</span> <strong>{{.Name}}</strong> | |
| 55 | + </a> | |
| 56 | + {{if .IsPrivate}} | |
| 57 | + <span class="badge badge-outline">{{t $.Lang "repo.private"}}</span> | |
| 58 | + {{end}} | |
| 59 | + {{if .Description}} | |
| 60 | + <span class="text-xs text-muted truncate">— {{.Description}}</span> | |
| 61 | + {{end}} | |
| 62 | + </div> | |
| 63 | + <div class="flex items-center gap-2.5 text-xs text-muted"> | |
| 64 | + <span class="font-mono">{{.DefaultBranch}}</span> | |
| 65 | + <span>{{timeAgo .UpdatedAt $.Lang}}</span> | |
| 66 | + </div> | |
| 67 | + </div> | |
| 68 | + {{end}} | |
| 69 | + </div> | |
| 70 | + {{else}} | |
| 71 | + {{if .CurrentUser}} | |
| 72 | + {{template "ui_empty_state" (dict "Icon" "git-branch" "Title" (t .Lang "repo.empty_list") "Subtitle" (t .Lang "repo.empty_list_sub") "ActionHref" "/new" "ActionLabel" (t .Lang "repo.new.title") "ActionIcon" "plus")}} | |
| 73 | + {{else}} | |
| 74 | + {{template "ui_empty_state" (dict "Icon" "git-branch" "Title" (t .Lang "repo.empty_list"))}} | |
| 75 | + {{end}} | |
| 76 | + {{end}} | |
| 77 | +</div> | |
| 78 | +{{end}} |
internal/web/views/pages/settings.html
+184
-0
| ... | ... | @@ -0,0 +1,184 @@ |
| 1 | +{{define "content"}} | |
| 2 | +<div class="container"> | |
| 3 | + <div class="flex items-center justify-between mb-4"> | |
| 4 | + <h1 class="text-lg font-semibold" style="margin:0;">{{t .Lang "settings.title"}}</h1> | |
| 5 | + <a href="/{{.CurrentUser.Username}}" class="btn btn-outline btn-sm"> | |
| 6 | + {{icon "user" "size-3.5"}} | |
| 7 | + <span>@{{.CurrentUser.Username}}</span> | |
| 8 | + </a> | |
| 9 | + </div> | |
| 10 | + | |
| 11 | + <!-- Avatar & Profile --> | |
| 12 | + <section class="card mb-4"> | |
| 13 | + <div class="card-header"> | |
| 14 | + <span class="font-semibold flex items-center gap-2">{{icon "user" "size-4"}} {{t .Lang "settings.profile"}}</span> | |
| 15 | + </div> | |
| 16 | + <form method="post" action="/settings/profile" enctype="multipart/form-data" class="card-body flex flex-col gap-4"> | |
| 17 | + <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> | |
| 18 | + <input type="hidden" id="avatar-data-input" name="avatar_data" value=""> | |
| 19 | + <input type="hidden" id="remove-avatar-input" name="remove_avatar" value="false"> | |
| 20 | + | |
| 21 | + <!-- Avatar Picker Row --> | |
| 22 | + <div class="flex items-center gap-4 flex-wrap pb-3" style="border-bottom:1px solid hsl(var(--border));"> | |
| 23 | + <div id="avatar-preview-wrap"> | |
| 24 | + {{avatar .CurrentUser.Username .CurrentUser.AvatarURL "avatar-xl"}} | |
| 25 | + </div> | |
| 26 | + <div class="flex flex-col gap-1.5"> | |
| 27 | + <div class="text-sm font-medium">{{t .Lang "settings.avatar"}}</div> | |
| 28 | + <div class="flex items-center gap-2 flex-wrap"> | |
| 29 | + <label class="btn btn-outline btn-sm" style="cursor:pointer;"> | |
| 30 | + {{icon "image" "size-3.5"}} | |
| 31 | + <span>{{t .Lang "settings.avatar_upload"}}</span> | |
| 32 | + <input id="avatar-file-input" name="avatar_file" type="file" accept="image/png,image/jpeg,image/webp,image/gif" style="display:none;"> | |
| 33 | + </label> | |
| 34 | + {{if .CurrentUser.AvatarURL}} | |
| 35 | + <button type="button" id="remove-avatar-btn" class="btn btn-ghost btn-sm text-destructive"> | |
| 36 | + {{icon "trash" "size-3.5"}} | |
| 37 | + <span>{{t .Lang "settings.avatar_remove"}}</span> | |
| 38 | + </button> | |
| 39 | + {{end}} | |
| 40 | + </div> | |
| 41 | + <div class="field-hint" style="margin:0;">{{t .Lang "settings.avatar_hint"}}</div> | |
| 42 | + </div> | |
| 43 | + </div> | |
| 44 | + | |
| 45 | + <div class="grid-2"> | |
| 46 | + <div> | |
| 47 | + <label class="label">{{t .Lang "auth.username"}}</label> | |
| 48 | + <input type="text" class="input font-mono" value="{{.CurrentUser.Username}}" disabled> | |
| 49 | + </div> | |
| 50 | + <div> | |
| 51 | + <label class="label" for="full_name">{{t .Lang "settings.full_name"}}</label> | |
| 52 | + <input id="full_name" name="full_name" type="text" class="input" value="{{.CurrentUser.FullName}}" maxlength="80" placeholder="{{t .Lang "settings.full_name_ph"}}"> | |
| 53 | + </div> | |
| 54 | + </div> | |
| 55 | + | |
| 56 | + <div class="grid-2"> | |
| 57 | + <div> | |
| 58 | + <label class="label" for="email">{{t .Lang "auth.email"}}</label> | |
| 59 | + <input id="email" name="email" type="email" class="input" value="{{.CurrentUser.Email}}" required> | |
| 60 | + </div> | |
| 61 | + <div> | |
| 62 | + <label class="label" for="location">{{t .Lang "settings.location"}}</label> | |
| 63 | + <input id="location" name="location" type="text" class="input" value="{{.CurrentUser.Location}}" maxlength="80" placeholder="{{t .Lang "settings.location_ph"}}"> | |
| 64 | + </div> | |
| 65 | + </div> | |
| 66 | + | |
| 67 | + <div class="grid-2"> | |
| 68 | + <div> | |
| 69 | + <label class="label" for="bio">{{t .Lang "settings.bio"}}</label> | |
| 70 | + <input id="bio" name="bio" type="text" class="input" value="{{.CurrentUser.Bio}}" maxlength="280" placeholder="{{t .Lang "settings.bio_ph"}}"> | |
| 71 | + </div> | |
| 72 | + <div> | |
| 73 | + <label class="label" for="website">{{t .Lang "settings.website"}}</label> | |
| 74 | + <input id="website" name="website" type="text" class="input" value="{{.CurrentUser.Website}}" maxlength="200" placeholder="{{t .Lang "settings.website_ph"}}"> | |
| 75 | + </div> | |
| 76 | + </div> | |
| 77 | + | |
| 78 | + <div class="flex items-center justify-between flex-wrap gap-2 pt-2"> | |
| 79 | + <div class="flex items-center gap-1.5 flex-wrap"> | |
| 80 | + <span class="text-xs text-muted">{{t .Lang "nav.theme"}}:</span> | |
| 81 | + <button type="button" class="btn btn-outline btn-sm" data-set-theme="light">{{t .Lang "theme.light"}}</button> | |
| 82 | + <button type="button" class="btn btn-outline btn-sm" data-set-theme="dark">{{t .Lang "theme.dark"}}</button> | |
| 83 | + <button type="button" class="btn btn-outline btn-sm" data-set-theme="system">{{t .Lang "theme.system"}}</button> | |
| 84 | + </div> | |
| 85 | + <button type="submit" class="btn btn-primary btn-sm">{{t .Lang "repo.save"}}</button> | |
| 86 | + </div> | |
| 87 | + </form> | |
| 88 | + </section> | |
| 89 | + | |
| 90 | + <!-- Personal Access Tokens --> | |
| 91 | + <section id="tokens" class="card mb-4"> | |
| 92 | + <div class="card-header"> | |
| 93 | + <span class="font-semibold flex items-center gap-2">{{icon "key" "size-4"}} {{t .Lang "settings.tokens"}}</span> | |
| 94 | + </div> | |
| 95 | + <div class="card-body flex flex-col gap-3"> | |
| 96 | + <p class="text-xs text-muted" style="margin:0;">{{t .Lang "settings.tokens.desc"}}</p> | |
| 97 | + | |
| 98 | + {{if .NewRawToken}} | |
| 99 | + <div class="card p-3" style="border-color:hsl(var(--primary));background:hsl(var(--muted)/0.5);"> | |
| 100 | + <div class="text-xs font-medium mb-2">{{t .Lang "settings.token.created_once"}}</div> | |
| 101 | + <div class="flex items-center justify-between gap-2"> | |
| 102 | + <code class="font-mono text-sm select-all" id="created-token-val">{{.NewRawToken}}</code> | |
| 103 | + <button type="button" class="btn btn-secondary btn-sm" data-copy="{{.NewRawToken}}"> | |
| 104 | + {{icon "copy" "size-3.5"}} | |
| 105 | + <span>{{t .Lang "common.copy"}}</span> | |
| 106 | + </button> | |
| 107 | + </div> | |
| 108 | + </div> | |
| 109 | + {{end}} | |
| 110 | + | |
| 111 | + <form method="post" action="/settings/tokens" class="flex items-center gap-2"> | |
| 112 | + <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> | |
| 113 | + <input name="name" type="text" class="input" placeholder="{{t .Lang "settings.token.name"}}" required maxlength="64"> | |
| 114 | + <button type="submit" class="btn btn-primary"> | |
| 115 | + {{icon "plus" "size-4"}} | |
| 116 | + <span>{{t .Lang "settings.token.create"}}</span> | |
| 117 | + </button> | |
| 118 | + </form> | |
| 119 | + | |
| 120 | + {{if .Tokens}} | |
| 121 | + <div class="table-wrap"> | |
| 122 | + <table class="table"> | |
| 123 | + <thead> | |
| 124 | + <tr> | |
| 125 | + <th>{{t .Lang "settings.token.col_name"}}</th> | |
| 126 | + <th>{{t .Lang "settings.token.col_prefix"}}</th> | |
| 127 | + <th>{{t .Lang "settings.token.col_created"}}</th> | |
| 128 | + <th>{{t .Lang "settings.token.col_used"}}</th> | |
| 129 | + <th style="text-align:right;"></th> | |
| 130 | + </tr> | |
| 131 | + </thead> | |
| 132 | + <tbody> | |
| 133 | + {{range .Tokens}} | |
| 134 | + <tr> | |
| 135 | + <td class="font-medium">{{.Name}}</td> | |
| 136 | + <td><code class="font-mono text-xs text-muted">{{.TokenPrefix}}…</code></td> | |
| 137 | + <td class="text-xs text-muted">{{timeAgo .CreatedAt $.Lang}}</td> | |
| 138 | + <td class="text-xs text-muted"> | |
| 139 | + {{if .LastUsedAt}}{{timeAgo .LastUsedAt $.Lang}}{{else}}{{t $.Lang "common.never"}}{{end}} | |
| 140 | + </td> | |
| 141 | + <td style="text-align:right;"> | |
| 142 | + <form method="post" action="/settings/tokens/{{.ID}}/revoke" style="display:inline;"> | |
| 143 | + <input type="hidden" name="_csrf" value="{{$.CSRFToken}}"> | |
| 144 | + <button type="submit" class="btn btn-destructive btn-sm"> | |
| 145 | + {{icon "trash" "size-3.5"}} | |
| 146 | + <span>{{t $.Lang "settings.token.revoke"}}</span> | |
| 147 | + </button> | |
| 148 | + </form> | |
| 149 | + </td> | |
| 150 | + </tr> | |
| 151 | + {{end}} | |
| 152 | + </tbody> | |
| 153 | + </table> | |
| 154 | + </div> | |
| 155 | + {{else}} | |
| 156 | + <div class="text-xs text-muted">{{t .Lang "settings.token.empty"}}</div> | |
| 157 | + {{end}} | |
| 158 | + </div> | |
| 159 | + </section> | |
| 160 | + | |
| 161 | + <!-- Change Password --> | |
| 162 | + <section class="card"> | |
| 163 | + <div class="card-header"> | |
| 164 | + <span class="font-semibold flex items-center gap-2">{{icon "lock" "size-4"}} {{t .Lang "auth.password"}}</span> | |
| 165 | + </div> | |
| 166 | + <form method="post" action="/settings/password" class="card-body flex flex-col gap-3"> | |
| 167 | + <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> | |
| 168 | + <div class="grid-2"> | |
| 169 | + <div> | |
| 170 | + <label class="label" for="current_password">{{t .Lang "settings.password.current"}}</label> | |
| 171 | + <input id="current_password" name="current_password" type="password" class="input" required> | |
| 172 | + </div> | |
| 173 | + <div> | |
| 174 | + <label class="label" for="new_password">{{t .Lang "settings.password.new"}}</label> | |
| 175 | + <input id="new_password" name="new_password" type="password" class="input" minlength="8" required> | |
| 176 | + </div> | |
| 177 | + </div> | |
| 178 | + <div class="flex items-center justify-end"> | |
| 179 | + <button type="submit" class="btn btn-outline btn-sm">{{t .Lang "settings.password.update"}}</button> | |
| 180 | + </div> | |
| 181 | + </form> | |
| 182 | + </section> | |
| 183 | +</div> | |
| 184 | +{{end}} |
internal/web/views/partials/components.html
+160
-0
| ... | ... | @@ -0,0 +1,160 @@ |
| 1 | +{{define "ui_button"}} | |
| 2 | + <button | |
| 3 | + type="{{if .Type}}{{.Type}}{{else}}button{{end}}" | |
| 4 | + class="btn {{if .Variant}}btn-{{.Variant}}{{else}}btn-default{{end}} {{if .Size}}btn-{{.Size}}{{end}} {{.Class}}" | |
| 5 | + {{if .ID}}id="{{.ID}}"{{end}} | |
| 6 | + {{if .Disabled}}disabled{{end}}> | |
| 7 | + {{if .Icon}}{{icon .Icon "size-4"}}{{end}} | |
| 8 | + {{if .Label}}<span>{{.Label}}</span>{{end}} | |
| 9 | + </button> | |
| 10 | +{{end}} | |
| 11 | + | |
| 12 | +{{define "ui_badge"}} | |
| 13 | + <span class="badge {{if .Variant}}badge-{{.Variant}}{{end}}"> | |
| 14 | + {{if .Icon}}{{icon .Icon "size-3.5"}}{{end}} | |
| 15 | + <span>{{.Text}}</span> | |
| 16 | + </span> | |
| 17 | +{{end}} | |
| 18 | + | |
| 19 | +{{define "ui_kbd"}} | |
| 20 | + <kbd class="kbd">{{.}}</kbd> | |
| 21 | +{{end}} | |
| 22 | + | |
| 23 | +{{define "ui_avatar"}} | |
| 24 | + {{if .Large}}{{avatar .Username .AvatarURL "avatar-lg"}}{{else}}{{avatar .Username .AvatarURL}}{{end}} | |
| 25 | +{{end}} | |
| 26 | + | |
| 27 | +{{define "ui_empty_state"}} | |
| 28 | + <div class="empty-state"> | |
| 29 | + {{if .Icon}} | |
| 30 | + <div class="mb-2" style="color:hsl(var(--muted-foreground));">{{icon .Icon "size-6"}}</div> | |
| 31 | + {{end}} | |
| 32 | + <div class="font-semibold text-base" style="color:hsl(var(--foreground));">{{.Title}}</div> | |
| 33 | + {{if .Subtitle}}<div class="text-sm text-muted mt-1">{{.Subtitle}}</div>{{end}} | |
| 34 | + {{if .ActionHref}} | |
| 35 | + <div class="mt-4"> | |
| 36 | + <a href="{{.ActionHref}}" class="btn btn-primary btn-sm"> | |
| 37 | + {{if .ActionIcon}}{{icon .ActionIcon "size-3.5"}}{{end}} | |
| 38 | + <span>{{.ActionLabel}}</span> | |
| 39 | + </a> | |
| 40 | + </div> | |
| 41 | + {{end}} | |
| 42 | + </div> | |
| 43 | +{{end}} | |
| 44 | + | |
| 45 | +{{define "repo_header"}} | |
| 46 | + <div class="repo-banner mb-3"> | |
| 47 | + <div class="repo-banner-main"> | |
| 48 | + <a href="/{{.Repo.OwnerUsername}}" class="flex-shrink-0">{{avatar .Repo.OwnerUsername .Repo.OwnerAvatarURL}}</a> | |
| 49 | + <div class="min-w-0"> | |
| 50 | + <div class="flex items-center gap-2 flex-wrap"> | |
| 51 | + <nav class="breadcrumb" aria-label="Repository breadcrumb"> | |
| 52 | + <a href="/{{.Repo.OwnerUsername}}" class="text-sm font-medium text-muted">{{.Repo.OwnerUsername}}</a> | |
| 53 | + <span class="breadcrumb-sep">/</span> | |
| 54 | + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}" class="font-semibold text-lg tracking-tight" style="color:hsl(var(--foreground));">{{.Repo.Name}}</a> | |
| 55 | + </nav> | |
| 56 | + {{if .Repo.IsPrivate}} | |
| 57 | + <span class="badge badge-outline">{{icon "lock" "size-3.5"}} {{t .Lang "repo.private"}}</span> | |
| 58 | + {{else}} | |
| 59 | + <span class="badge badge-outline">{{icon "globe" "size-3.5"}} {{t .Lang "repo.public"}}</span> | |
| 60 | + {{end}} | |
| 61 | + </div> | |
| 62 | + <div class="text-xs text-muted mt-1 flex items-center gap-2 flex-wrap"> | |
| 63 | + {{if .Repo.ForkedFromRepoID}} | |
| 64 | + <span class="flex items-center gap-1.5"> | |
| 65 | + {{icon "git-branch" "size-3.5"}} | |
| 66 | + {{t .Lang "fork.forked_from"}} | |
| 67 | + <a class="font-medium" style="color:hsl(var(--foreground));" href="/{{.Repo.ForkedFromOwner}}/{{.Repo.ForkedFromName}}">{{.Repo.ForkedFromOwner}}/{{.Repo.ForkedFromName}}</a> | |
| 68 | + </span> | |
| 69 | + {{else if .Repo.Description}} | |
| 70 | + <span class="truncate" style="max-width:36rem;">{{.Repo.Description}}</span> | |
| 71 | + {{end}} | |
| 72 | + </div> | |
| 73 | + </div> | |
| 74 | + </div> | |
| 75 | + | |
| 76 | + <div class="repo-banner-actions"> | |
| 77 | + {{if .CurrentUser}} | |
| 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> | |
| 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> | |
| 82 | + {{else}} | |
| 83 | + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/fork" style="margin:0;"> | |
| 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> | |
| 86 | + </form> | |
| 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> | |
| 109 | + {{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 | + </div> | |
| 113 | + | |
| 114 | + <div class="tabs-nav" role="tablist"> | |
| 115 | + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}" class="tab-link {{if eq .ActiveTab "code"}}active{{end}}" role="tab"> | |
| 116 | + {{icon "code" "size-4"}} | |
| 117 | + <span>{{t .Lang "repo.tab.code"}}</span> | |
| 118 | + </a> | |
| 119 | + {{if .Repo.EnableIssues}} | |
| 120 | + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/issues" class="tab-link {{if eq .ActiveTab "issues"}}active{{end}}" role="tab"> | |
| 121 | + {{icon "issue" "size-4"}} | |
| 122 | + <span>{{t .Lang "repo.tab.issues"}}</span> | |
| 123 | + {{if gt .Repo.OpenIssueCount 0}} | |
| 124 | + <span class="badge" style="padding:0 0.35rem;font-size:0.68rem;">{{.Repo.OpenIssueCount}}</span> | |
| 125 | + {{end}} | |
| 126 | + </a> | |
| 127 | + {{end}} | |
| 128 | + {{if .Repo.EnablePulls}} | |
| 129 | + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls" class="tab-link {{if eq .ActiveTab "pulls"}}active{{end}}" role="tab"> | |
| 130 | + {{icon "git-pull-request" "size-4"}} | |
| 131 | + <span>{{t .Lang "repo.tab.pulls"}}</span> | |
| 132 | + {{if gt .Repo.OpenPRCount 0}} | |
| 133 | + <span class="badge" style="padding:0 0.35rem;font-size:0.68rem;">{{.Repo.OpenPRCount}}</span> | |
| 134 | + {{end}} | |
| 135 | + </a> | |
| 136 | + {{end}} | |
| 137 | + {{if .Repo.EnableReleases}} | |
| 138 | + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/releases" class="tab-link {{if eq .ActiveTab "releases"}}active{{end}}" role="tab"> | |
| 139 | + {{icon "tag" "size-4"}} | |
| 140 | + <span>{{t .Lang "repo.tab.releases"}}</span> | |
| 141 | + {{if gt .Repo.ReleaseCount 0}} | |
| 142 | + <span class="badge" style="padding:0 0.35rem;font-size:0.68rem;">{{.Repo.ReleaseCount}}</span> | |
| 143 | + {{end}} | |
| 144 | + </a> | |
| 145 | + {{end}} | |
| 146 | + {{if .Repo.EnableActivity}} | |
| 147 | + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/activity" class="tab-link {{if eq .ActiveTab "activity"}}active{{end}}" role="tab"> | |
| 148 | + {{icon "activity" "size-4"}} | |
| 149 | + <span>{{t .Lang "repo.tab.activity"}}</span> | |
| 150 | + </a> | |
| 151 | + {{end}} | |
| 152 | + {{if .CanWrite}} | |
| 153 | + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/settings" class="tab-link {{if eq .ActiveTab "settings"}}active{{end}}" role="tab"> | |
| 154 | + {{icon "settings" "size-4"}} | |
| 155 | + <span>{{t .Lang "repo.tab.settings"}}</span> | |
| 156 | + </a> | |
| 157 | + {{end}} | |
| 158 | + </div> | |
| 159 | +{{end}} | |
| 160 | + |
internal/web/views/views.go
+342
-0
| ... | ... | @@ -0,0 +1,342 @@ |
| 1 | +package views | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "bytes" | |
| 5 | + "embed" | |
| 6 | + "fmt" | |
| 7 | + "html" | |
| 8 | + "html/template" | |
| 9 | + "io" | |
| 10 | + "strings" | |
| 11 | + "time" | |
| 12 | + | |
| 13 | + "polar/internal/i18n" | |
| 14 | + "polar/internal/web/static" | |
| 15 | + | |
| 16 | + "github.com/alecthomas/chroma/v2" | |
| 17 | + chromahtml "github.com/alecthomas/chroma/v2/formatters/html" | |
| 18 | + "github.com/alecthomas/chroma/v2/lexers" | |
| 19 | + "github.com/alecthomas/chroma/v2/styles" | |
| 20 | + "github.com/microcosm-cc/bluemonday" | |
| 21 | + "github.com/yuin/goldmark" | |
| 22 | + "github.com/yuin/goldmark/extension" | |
| 23 | + "github.com/yuin/goldmark/parser" | |
| 24 | +) | |
| 25 | + | |
| 26 | +//go:embed layouts/*.html partials/*.html pages/*.html | |
| 27 | +var templatesFS embed.FS | |
| 28 | + | |
| 29 | +type HighlightedLine struct { | |
| 30 | + Num int | |
| 31 | + HTML template.HTML | |
| 32 | +} | |
| 33 | + | |
| 34 | +type Engine struct { | |
| 35 | + pages map[string]*template.Template | |
| 36 | +} | |
| 37 | + | |
| 38 | +func NewEngine() (*Engine, error) { | |
| 39 | + funcMap := template.FuncMap{ | |
| 40 | + "icon": renderIcon, | |
| 41 | + "sprite": func() template.HTML { return template.HTML(static.SpriteSVG) }, | |
| 42 | + "inlineCSS": func() template.CSS { return template.CSS(static.AppCSS) }, | |
| 43 | + "assetVer": static.AssetVersion, | |
| 44 | + "t": i18n.T, | |
| 45 | + "timeAgo": formatTimeAgo, | |
| 46 | + "pluralT": pluralT, | |
| 47 | + "activityBadge": activityBadge, | |
| 48 | + "formatDate": func(tm time.Time) string { return tm.UTC().Format("2006-01-02 15:04 UTC") }, | |
| 49 | + "formatShortDate": func(tm time.Time) string { return tm.UTC().Format("2006-01-02") }, | |
| 50 | + "formatSize": formatBytes, | |
| 51 | + "avatarInitial": avatarInitial, | |
| 52 | + "avatar": renderAvatar, | |
| 53 | + "renderMD": func(s string) template.HTML { return RenderMarkdown([]byte(s)) }, | |
| 54 | + "add": func(a, b int) int { return a + b }, | |
| 55 | + "sub": func(a, b int) int { return a - b }, | |
| 56 | + "dict": makeDict, | |
| 57 | + "safeHTML": func(s string) template.HTML { return template.HTML(s) }, | |
| 58 | + "upper": strings.ToUpper, | |
| 59 | + "lower": strings.ToLower, | |
| 60 | + } | |
| 61 | + | |
| 62 | + entries, err := templatesFS.ReadDir("pages") | |
| 63 | + if err != nil { | |
| 64 | + return nil, fmt.Errorf("read pages dir: %w", err) | |
| 65 | + } | |
| 66 | + | |
| 67 | + pages := make(map[string]*template.Template, len(entries)) | |
| 68 | + for _, entry := range entries { | |
| 69 | + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".html") { | |
| 70 | + continue | |
| 71 | + } | |
| 72 | + name := strings.TrimSuffix(entry.Name(), ".html") | |
| 73 | + tmpl, err := template.New("base.html").Funcs(funcMap).ParseFS( | |
| 74 | + templatesFS, | |
| 75 | + "layouts/base.html", | |
| 76 | + "partials/components.html", | |
| 77 | + "pages/"+entry.Name(), | |
| 78 | + ) | |
| 79 | + if err != nil { | |
| 80 | + return nil, fmt.Errorf("parse template %s: %w", entry.Name(), err) | |
| 81 | + } | |
| 82 | + pages[name] = tmpl | |
| 83 | + } | |
| 84 | + | |
| 85 | + return &Engine{pages: pages}, nil | |
| 86 | +} | |
| 87 | + | |
| 88 | +func (e *Engine) Render(w io.Writer, page string, data any) error { | |
| 89 | + tmpl, ok := e.pages[page] | |
| 90 | + if !ok { | |
| 91 | + return fmt.Errorf("template %q not found", page) | |
| 92 | + } | |
| 93 | + return tmpl.ExecuteTemplate(w, "base.html", data) | |
| 94 | +} | |
| 95 | + | |
| 96 | +func renderIcon(name string, classes ...string) template.HTML { | |
| 97 | + cls := "size-4" | |
| 98 | + if len(classes) > 0 && strings.TrimSpace(classes[0]) != "" { | |
| 99 | + cls = strings.Join(classes, " ") | |
| 100 | + } | |
| 101 | + safeName := html.EscapeString(strings.TrimSpace(name)) | |
| 102 | + safeCls := html.EscapeString(cls) | |
| 103 | + return template.HTML(fmt.Sprintf( | |
| 104 | + `<svg class="icon %s" aria-hidden="true"><use href="#hi-%s"></use></svg>`, | |
| 105 | + safeCls, safeName, | |
| 106 | + )) | |
| 107 | +} | |
| 108 | + | |
| 109 | +func makeDict(values ...any) (map[string]any, error) { | |
| 110 | + if len(values)%2 != 0 { | |
| 111 | + return nil, fmt.Errorf("dict requires an even number of arguments") | |
| 112 | + } | |
| 113 | + m := make(map[string]any, len(values)/2) | |
| 114 | + for i := 0; i < len(values); i += 2 { | |
| 115 | + k, ok := values[i].(string) | |
| 116 | + if !ok { | |
| 117 | + return nil, fmt.Errorf("dict keys must be strings") | |
| 118 | + } | |
| 119 | + m[k] = values[i+1] | |
| 120 | + } | |
| 121 | + return m, nil | |
| 122 | +} | |
| 123 | + | |
| 124 | +func avatarInitial(username string) string { | |
| 125 | + username = strings.TrimSpace(username) | |
| 126 | + if username == "" { | |
| 127 | + return "?" | |
| 128 | + } | |
| 129 | + runes := []rune(username) | |
| 130 | + return strings.ToUpper(string(runes[0])) | |
| 131 | +} | |
| 132 | + | |
| 133 | +func renderAvatar(args ...any) template.HTML { | |
| 134 | + if len(args) == 0 { | |
| 135 | + return template.HTML(`<span class="avatar">?</span>`) | |
| 136 | + } | |
| 137 | + var username, avatarURL, extraClass string | |
| 138 | + if args[0] != nil { | |
| 139 | + username = fmt.Sprint(args[0]) | |
| 140 | + } | |
| 141 | + if len(args) > 1 && args[1] != nil { | |
| 142 | + avatarURL = fmt.Sprint(args[1]) | |
| 143 | + } | |
| 144 | + if len(args) > 2 { | |
| 145 | + var classes []string | |
| 146 | + for _, c := range args[2:] { | |
| 147 | + if c != nil && fmt.Sprint(c) != "" { | |
| 148 | + classes = append(classes, fmt.Sprint(c)) | |
| 149 | + } | |
| 150 | + } | |
| 151 | + extraClass = strings.Join(classes, " ") | |
| 152 | + } | |
| 153 | + if strings.HasPrefix(avatarURL, "avatar-") && extraClass == "" { | |
| 154 | + extraClass = avatarURL | |
| 155 | + avatarURL = "" | |
| 156 | + } | |
| 157 | + | |
| 158 | + cls := "avatar" | |
| 159 | + if extraClass != "" { | |
| 160 | + cls = "avatar " + extraClass | |
| 161 | + } | |
| 162 | + safeCls := html.EscapeString(cls) | |
| 163 | + safeUser := html.EscapeString(strings.TrimSpace(username)) | |
| 164 | + avatarURL = strings.TrimSpace(avatarURL) | |
| 165 | + if avatarURL != "" && (strings.HasPrefix(avatarURL, "data:image/") || strings.HasPrefix(avatarURL, "http://") || strings.HasPrefix(avatarURL, "https://") || strings.HasPrefix(avatarURL, "/")) { | |
| 166 | + safeURL := html.EscapeString(avatarURL) | |
| 167 | + return template.HTML(fmt.Sprintf( | |
| 168 | + `<span class="%s" title="%s"><img src="%s" alt="%s" class="avatar-img" loading="lazy"></span>`, | |
| 169 | + safeCls, safeUser, safeURL, safeUser, | |
| 170 | + )) | |
| 171 | + } | |
| 172 | + return template.HTML(fmt.Sprintf( | |
| 173 | + `<span class="%s" title="%s">%s</span>`, | |
| 174 | + safeCls, safeUser, html.EscapeString(avatarInitial(username)), | |
| 175 | + )) | |
| 176 | +} | |
| 177 | + | |
| 178 | +func formatBytes(b int64) string { | |
| 179 | + if b < 1024 { | |
| 180 | + return fmt.Sprintf("%d B", b) | |
| 181 | + } | |
| 182 | + kb := float64(b) / 1024.0 | |
| 183 | + if kb < 1024 { | |
| 184 | + return fmt.Sprintf("%.1f KB", kb) | |
| 185 | + } | |
| 186 | + mb := kb / 1024.0 | |
| 187 | + return fmt.Sprintf("%.1f MB", mb) | |
| 188 | +} | |
| 189 | + | |
| 190 | +func formatTimeAgo(tm time.Time, lang i18n.Lang) string { | |
| 191 | + if tm.IsZero() { | |
| 192 | + return "—" | |
| 193 | + } | |
| 194 | + d := time.Since(tm) | |
| 195 | + isEN := i18n.Normalize(string(lang)) == i18n.EN | |
| 196 | + switch { | |
| 197 | + case d < time.Minute: | |
| 198 | + if isEN { | |
| 199 | + return "just now" | |
| 200 | + } | |
| 201 | + return "только что" | |
| 202 | + case d < time.Hour: | |
| 203 | + m := int(d.Minutes()) | |
| 204 | + if isEN { | |
| 205 | + return fmt.Sprintf("%dm ago", m) | |
| 206 | + } | |
| 207 | + return fmt.Sprintf("%d мин. назад", m) | |
| 208 | + case d < 24*time.Hour: | |
| 209 | + h := int(d.Hours()) | |
| 210 | + if isEN { | |
| 211 | + return fmt.Sprintf("%dh ago", h) | |
| 212 | + } | |
| 213 | + return fmt.Sprintf("%d ч. назад", h) | |
| 214 | + case d < 30*24*time.Hour: | |
| 215 | + days := int(d.Hours() / 24) | |
| 216 | + if isEN { | |
| 217 | + return fmt.Sprintf("%dd ago", days) | |
| 218 | + } | |
| 219 | + return fmt.Sprintf("%d дн. назад", days) | |
| 220 | + default: | |
| 221 | + return tm.UTC().Format("2006-01-02") | |
| 222 | + } | |
| 223 | +} | |
| 224 | + | |
| 225 | +// pluralT returns the count-dependent form of key ("<key>_one", "<key>_few", | |
| 226 | +// "<key>_many") for the given language, falling back to the base key. | |
| 227 | +func pluralT(lang i18n.Lang, n int, key string) string { | |
| 228 | + suffix := "_many" | |
| 229 | + if i18n.Normalize(string(lang)) == i18n.EN { | |
| 230 | + if n == 1 { | |
| 231 | + suffix = "_one" | |
| 232 | + } | |
| 233 | + } else { | |
| 234 | + mod10, mod100 := n%10, n%100 | |
| 235 | + switch { | |
| 236 | + case mod10 == 1 && mod100 != 11: | |
| 237 | + suffix = "_one" | |
| 238 | + case mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14): | |
| 239 | + suffix = "_few" | |
| 240 | + } | |
| 241 | + } | |
| 242 | + k := key + suffix | |
| 243 | + if v := i18n.T(lang, k); v != k { | |
| 244 | + return v | |
| 245 | + } | |
| 246 | + return i18n.T(lang, key) | |
| 247 | +} | |
| 248 | + | |
| 249 | +// activityBadge localizes the status badge of an activity feed entry. | |
| 250 | +func activityBadge(lang i18n.Lang, itemType, badge string) string { | |
| 251 | + switch itemType { | |
| 252 | + case "commit": | |
| 253 | + return i18n.T(lang, "activity.badge_commit") | |
| 254 | + case "release": | |
| 255 | + return i18n.T(lang, "activity.badge_release") | |
| 256 | + case "pull": | |
| 257 | + return i18n.T(lang, "pulls.status."+badge) | |
| 258 | + case "issue": | |
| 259 | + return i18n.T(lang, "issue.status."+badge) | |
| 260 | + default: | |
| 261 | + return badge | |
| 262 | + } | |
| 263 | +} | |
| 264 | + | |
| 265 | +var mdParser = goldmark.New( | |
| 266 | + goldmark.WithExtensions( | |
| 267 | + extension.GFM, | |
| 268 | + ), | |
| 269 | + goldmark.WithParserOptions( | |
| 270 | + parser.WithAutoHeadingID(), | |
| 271 | + ), | |
| 272 | +) | |
| 273 | + | |
| 274 | +func RenderMarkdown(src []byte) template.HTML { | |
| 275 | + var buf bytes.Buffer | |
| 276 | + if err := mdParser.Convert(src, &buf); err != nil { | |
| 277 | + return template.HTML("<pre>" + html.EscapeString(string(src)) + "</pre>") | |
| 278 | + } | |
| 279 | + policy := bluemonday.UGCPolicy() | |
| 280 | + policy.AllowAttrs("class").OnElements("code", "pre", "span") | |
| 281 | + policy.AllowAttrs("type", "checked", "disabled").OnElements("input") | |
| 282 | + sanitized := policy.SanitizeBytes(buf.Bytes()) | |
| 283 | + return template.HTML(sanitized) | |
| 284 | +} | |
| 285 | + | |
| 286 | +func HighlightCode(filename, code string) []HighlightedLine { | |
| 287 | + code = strings.ReplaceAll(code, "\r\n", "\n") | |
| 288 | + lexer := lexers.Match(filename) | |
| 289 | + if lexer == nil { | |
| 290 | + lexer = lexers.Fallback | |
| 291 | + } | |
| 292 | + lexer = chroma.Coalesce(lexer) | |
| 293 | + | |
| 294 | + formatter := chromahtml.New( | |
| 295 | + chromahtml.WithClasses(true), | |
| 296 | + chromahtml.PreventSurroundingPre(true), | |
| 297 | + ) | |
| 298 | + style := styles.Get("monokai") | |
| 299 | + if style == nil { | |
| 300 | + style = styles.Fallback | |
| 301 | + } | |
| 302 | + | |
| 303 | + rawLines := strings.Split(code, "\n") | |
| 304 | + if len(rawLines) > 0 && rawLines[len(rawLines)-1] == "" { | |
| 305 | + rawLines = rawLines[:len(rawLines)-1] | |
| 306 | + } | |
| 307 | + | |
| 308 | + result := make([]HighlightedLine, 0, len(rawLines)) | |
| 309 | + iterator, err := lexer.Tokenise(nil, code) | |
| 310 | + if err != nil { | |
| 311 | + for idx, l := range rawLines { | |
| 312 | + result = append(result, HighlightedLine{ | |
| 313 | + Num: idx + 1, | |
| 314 | + HTML: template.HTML(html.EscapeString(l)), | |
| 315 | + }) | |
| 316 | + } | |
| 317 | + return result | |
| 318 | + } | |
| 319 | + | |
| 320 | + var buf bytes.Buffer | |
| 321 | + if err := formatter.Format(&buf, style, iterator); err != nil { | |
| 322 | + for idx, l := range rawLines { | |
| 323 | + result = append(result, HighlightedLine{ | |
| 324 | + Num: idx + 1, | |
| 325 | + HTML: template.HTML(html.EscapeString(l)), | |
| 326 | + }) | |
| 327 | + } | |
| 328 | + return result | |
| 329 | + } | |
| 330 | + | |
| 331 | + htmlLines := strings.Split(buf.String(), "\n") | |
| 332 | + if len(htmlLines) > 0 && htmlLines[len(htmlLines)-1] == "" { | |
| 333 | + htmlLines = htmlLines[:len(htmlLines)-1] | |
| 334 | + } | |
| 335 | + for idx, hl := range htmlLines { | |
| 336 | + result = append(result, HighlightedLine{ | |
| 337 | + Num: idx + 1, | |
| 338 | + HTML: template.HTML(hl), | |
| 339 | + }) | |
| 340 | + } | |
| 341 | + return result | |
| 342 | +} |
polar.example.toml
+14
-0
| ... | ... | @@ -0,0 +1,14 @@ |
| 1 | +[server] | |
| 2 | +host = "0.0.0.0" | |
| 3 | +port = 3000 | |
| 4 | +base_url = "http://localhost:3000" | |
| 5 | + | |
| 6 | +[storage] | |
| 7 | +data_dir = "./data" | |
| 8 | + | |
| 9 | +[auth] | |
| 10 | +registration_enabled = true | |
| 11 | + | |
| 12 | +[landing] | |
| 13 | +enabled = true | |
| 14 | +default_lang = "en" |
scripts/git-credential-polar.ps1
+155
-0
| ... | ... | @@ -0,0 +1,155 @@ |
| 1 | +# Git credential helper for Polar (RFC 8628 device flow). | |
| 2 | +# | |
| 3 | +# Usage: | |
| 4 | +# git config --global credential.helper "!powershell -NoProfile -ExecutionPolicy Bypass -File C:/path/to/polar/scripts/git-credential-polar.ps1" | |
| 5 | +# | |
| 6 | +# On "get" the helper reuses a cached token for the server, otherwise it starts | |
| 7 | +# a device authorization flow, opens the verification page in the browser and | |
| 8 | +# waits until the request is approved. Tokens are cached in | |
| 9 | +# ~/.polar/credentials.json. | |
| 10 | +param([Parameter(Position = 0)][string]$Operation = 'get') | |
| 11 | + | |
| 12 | +$ErrorActionPreference = 'Stop' | |
| 13 | + | |
| 14 | +function Read-CredentialInput { | |
| 15 | + $values = @{} | |
| 16 | + while ($null -ne ($line = [Console]::In.ReadLine())) { | |
| 17 | + if ($line -match '^([^=]+)=(.*)$') { | |
| 18 | + $values[$Matches[1]] = $Matches[2] | |
| 19 | + } | |
| 20 | + } | |
| 21 | + return $values | |
| 22 | +} | |
| 23 | + | |
| 24 | +function Get-ServerBase([hashtable]$values) { | |
| 25 | + if ($env:POLAR_URL) { return $env:POLAR_URL.TrimEnd('/') } | |
| 26 | + $hostName = $values['host'] | |
| 27 | + if ($hostName) { | |
| 28 | + $scheme = $values['protocol'] | |
| 29 | + if (-not $scheme) { $scheme = 'https' } | |
| 30 | + return ('{0}://{1}' -f $scheme, $hostName).TrimEnd('/') | |
| 31 | + } | |
| 32 | + return 'http://localhost:3100' | |
| 33 | +} | |
| 34 | + | |
| 35 | +function Get-CachePath { | |
| 36 | + $dir = Join-Path $HOME '.polar' | |
| 37 | + if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir | Out-Null } | |
| 38 | + return (Join-Path $dir 'credentials.json') | |
| 39 | +} | |
| 40 | + | |
| 41 | +function Read-Cache { | |
| 42 | + $path = Get-CachePath | |
| 43 | + if (-not (Test-Path $path)) { return @{} } | |
| 44 | + try { | |
| 45 | + $raw = Get-Content -Raw -Path $path | ConvertFrom-Json | |
| 46 | + $table = @{} | |
| 47 | + foreach ($prop in $raw.PSObject.Properties) { | |
| 48 | + $table[$prop.Name] = @{ username = $prop.Value.username; token = $prop.Value.token } | |
| 49 | + } | |
| 50 | + return $table | |
| 51 | + } catch { | |
| 52 | + return @{} | |
| 53 | + } | |
| 54 | +} | |
| 55 | + | |
| 56 | +function Write-Cache([hashtable]$cache) { | |
| 57 | + $obj = [ordered]@{} | |
| 58 | + foreach ($key in $cache.Keys) { $obj[$key] = @{ username = $cache[$key].username; token = $cache[$key].token } } | |
| 59 | + ($obj | ConvertTo-Json -Depth 4) | Set-Content -Path (Get-CachePath) -Encoding UTF8 | |
| 60 | +} | |
| 61 | + | |
| 62 | +function Write-Credential([string]$user, [string]$token) { | |
| 63 | + [Console]::Out.WriteLine('username=' + $user) | |
| 64 | + [Console]::Out.WriteLine('password=' + $token) | |
| 65 | +} | |
| 66 | + | |
| 67 | +function Read-ErrorBody($exception) { | |
| 68 | + try { | |
| 69 | + $stream = $exception.Response.GetResponseStream() | |
| 70 | + $reader = New-Object System.IO.StreamReader($stream) | |
| 71 | + return $reader.ReadToEnd() | |
| 72 | + } catch { | |
| 73 | + return '' | |
| 74 | + } | |
| 75 | +} | |
| 76 | + | |
| 77 | +$values = Read-CredentialInput | |
| 78 | +$base = Get-ServerBase $values | |
| 79 | +$cache = Read-Cache | |
| 80 | + | |
| 81 | +switch ($Operation) { | |
| 82 | + 'get' { | |
| 83 | + if ($cache.ContainsKey($base)) { | |
| 84 | + Write-Credential $cache[$base].username $cache[$base].token | |
| 85 | + exit 0 | |
| 86 | + } | |
| 87 | + | |
| 88 | + $clientId = 'git-credential-polar' | |
| 89 | + $code = $null | |
| 90 | + try { | |
| 91 | + $code = Invoke-RestMethod -Method Post -Uri "$base/api/v1/device/code" -Body @{ client_id = $clientId } | |
| 92 | + } catch { | |
| 93 | + [Console]::Error.WriteLine("polar: could not reach $base/api/v1/device/code: $($_.Exception.Message)") | |
| 94 | + exit 1 | |
| 95 | + } | |
| 96 | + | |
| 97 | + [Console]::Error.WriteLine('') | |
| 98 | + [Console]::Error.WriteLine("polar: open $($code.verification_uri) and enter code $($code.user_code)") | |
| 99 | + [Console]::Error.WriteLine('polar: waiting for approval...') | |
| 100 | + try { Start-Process $code.verification_uri_complete | Out-Null } catch { } | |
| 101 | + | |
| 102 | + $interval = 5 | |
| 103 | + if ($code.interval) { $interval = [int]$code.interval } | |
| 104 | + if ($interval -lt 3) { $interval = 3 } | |
| 105 | + $expiresIn = 900 | |
| 106 | + if ($code.expires_in) { $expiresIn = [int]$code.expires_in } | |
| 107 | + $deadline = (Get-Date).AddSeconds($expiresIn) | |
| 108 | + | |
| 109 | + while ((Get-Date) -lt $deadline) { | |
| 110 | + Start-Sleep -Seconds $interval | |
| 111 | + try { | |
| 112 | + $resp = Invoke-RestMethod -Method Post -Uri "$base/api/v1/device/token" -Body @{ device_code = $code.device_code; client_id = $clientId } | |
| 113 | + if ($resp.access_token) { | |
| 114 | + $username = $resp.username | |
| 115 | + if (-not $username) { $username = 'x-access-token' } | |
| 116 | + $cache[$base] = @{ username = $username; token = $resp.access_token } | |
| 117 | + Write-Cache $cache | |
| 118 | + Write-Credential $username $resp.access_token | |
| 119 | + exit 0 | |
| 120 | + } | |
| 121 | + } catch { | |
| 122 | + $status = 0 | |
| 123 | + try { $status = [int]$_.Exception.Response.StatusCode } catch { } | |
| 124 | + if ($status -eq 429) { continue } | |
| 125 | + $body = Read-ErrorBody $_.Exception | |
| 126 | + if ($body -match 'access_denied') { | |
| 127 | + [Console]::Error.WriteLine('polar: access denied') | |
| 128 | + exit 1 | |
| 129 | + } | |
| 130 | + if ($body -match 'expired_token') { | |
| 131 | + [Console]::Error.WriteLine('polar: device code expired, run the command again') | |
| 132 | + exit 1 | |
| 133 | + } | |
| 134 | + [Console]::Error.WriteLine("polar: authorization failed: $body") | |
| 135 | + exit 1 | |
| 136 | + } | |
| 137 | + } | |
| 138 | + [Console]::Error.WriteLine('polar: timed out waiting for approval') | |
| 139 | + exit 1 | |
| 140 | + } | |
| 141 | + 'store' { | |
| 142 | + # The helper keeps its own cache; nothing to do. | |
| 143 | + exit 0 | |
| 144 | + } | |
| 145 | + 'erase' { | |
| 146 | + if ($cache.ContainsKey($base)) { | |
| 147 | + $cache.Remove($base) | |
| 148 | + Write-Cache $cache | |
| 149 | + } | |
| 150 | + exit 0 | |
| 151 | + } | |
| 152 | + default { | |
| 153 | + exit 0 | |
| 154 | + } | |
| 155 | +} |
scripts/git-credential-polar.sh
+110
-0
| ... | ... | @@ -0,0 +1,110 @@ |
| 1 | +#!/bin/sh | |
| 2 | +# Git credential helper for Polar (RFC 8628 device flow). | |
| 3 | +# | |
| 4 | +# Usage: | |
| 5 | +# git config --global credential.helper '!sh /path/to/polar/scripts/git-credential-polar.sh' | |
| 6 | +# | |
| 7 | +# On "get" the helper reuses a cached token for the server, otherwise it starts | |
| 8 | +# a device authorization flow, opens the verification page in the browser and | |
| 9 | +# waits until the request is approved. Tokens are cached in | |
| 10 | +# ~/.polar/credentials.json. | |
| 11 | + | |
| 12 | +op="${1:-get}" | |
| 13 | +base="${POLAR_URL:-}" | |
| 14 | +cache_dir="${HOME}/.polar" | |
| 15 | +cache_file="${cache_dir}/credentials.json" | |
| 16 | +client_id="git-credential-polar" | |
| 17 | + | |
| 18 | +input="$(cat)" | |
| 19 | +if [ -z "$base" ]; then | |
| 20 | + host="$(printf '%s\n' "$input" | sed -n 's/^host=//p' | head -n1)" | |
| 21 | + proto="$(printf '%s\n' "$input" | sed -n 's/^protocol=//p' | head -n1)" | |
| 22 | + [ -z "$proto" ] && proto="https" | |
| 23 | + if [ -n "$host" ]; then | |
| 24 | + base="${proto}://${host}" | |
| 25 | + else | |
| 26 | + base="http://localhost:3100" | |
| 27 | + fi | |
| 28 | +fi | |
| 29 | +base="${base%/}" | |
| 30 | + | |
| 31 | +json_field() { | |
| 32 | + # $1 = json, $2 = field | |
| 33 | + printf '%s' "$1" | sed -n 's/.*"'"$2"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1 | |
| 34 | +} | |
| 35 | + | |
| 36 | +case "$op" in | |
| 37 | + get) | |
| 38 | + if [ -f "$cache_file" ]; then | |
| 39 | + cached="$(grep -F "\"${base}\":" "$cache_file" | head -n1)" | |
| 40 | + if [ -n "$cached" ]; then | |
| 41 | + token="$(json_field "$cached" token)" | |
| 42 | + username="$(json_field "$cached" username)" | |
| 43 | + if [ -n "$token" ] && [ -n "$username" ]; then | |
| 44 | + printf 'username=%s\npassword=%s\n' "$username" "$token" | |
| 45 | + exit 0 | |
| 46 | + fi | |
| 47 | + fi | |
| 48 | + fi | |
| 49 | + | |
| 50 | + code="$(curl -fsS -X POST "${base}/api/v1/device/code" -d "client_id=${client_id}" 2>/dev/null)" || { | |
| 51 | + echo "polar: could not reach ${base}/api/v1/device/code" >&2 | |
| 52 | + exit 1 | |
| 53 | + } | |
| 54 | + device_code="$(json_field "$code" device_code)" | |
| 55 | + user_code="$(json_field "$code" user_code)" | |
| 56 | + verify_uri="$(json_field "$code" verification_uri)" | |
| 57 | + verify_full="$(json_field "$code" verification_uri_complete)" | |
| 58 | + interval="$(json_field "$code" interval)" | |
| 59 | + [ -z "$interval" ] && interval=5 | |
| 60 | + [ "$interval" -lt 3 ] && interval=3 | |
| 61 | + | |
| 62 | + echo "" >&2 | |
| 63 | + echo "polar: open ${verify_uri} and enter code ${user_code}" >&2 | |
| 64 | + echo "polar: waiting for approval..." >&2 | |
| 65 | + if command -v xdg-open >/dev/null 2>&1; then | |
| 66 | + xdg-open "$verify_full" >/dev/null 2>&1 & | |
| 67 | + elif command -v open >/dev/null 2>&1; then | |
| 68 | + open "$verify_full" >/dev/null 2>&1 & | |
| 69 | + fi | |
| 70 | + | |
| 71 | + elapsed=0 | |
| 72 | + while [ "$elapsed" -lt 900 ]; do | |
| 73 | + sleep "$interval" | |
| 74 | + elapsed=$((elapsed + interval)) | |
| 75 | + resp="$(curl -sS -X POST "${base}/api/v1/device/token" -d "device_code=${device_code}&client_id=${client_id}" 2>/dev/null)" | |
| 76 | + token="$(json_field "$resp" access_token)" | |
| 77 | + if [ -n "$token" ]; then | |
| 78 | + username="$(json_field "$resp" username)" | |
| 79 | + [ -z "$username" ] && username="x-access-token" | |
| 80 | + mkdir -p "$cache_dir" | |
| 81 | + printf '{"%s":{"username":"%s","token":"%s"}}\n' "$base" "$username" "$token" >>"$cache_file" | |
| 82 | + printf 'username=%s\npassword=%s\n' "$username" "$token" | |
| 83 | + exit 0 | |
| 84 | + fi | |
| 85 | + case "$resp" in | |
| 86 | + *authorization_pending*) continue ;; | |
| 87 | + *access_denied*) | |
| 88 | + echo "polar: access denied" >&2 | |
| 89 | + exit 1 | |
| 90 | + ;; | |
| 91 | + *expired_token*) | |
| 92 | + echo "polar: device code expired, run the command again" >&2 | |
| 93 | + exit 1 | |
| 94 | + ;; | |
| 95 | + *) | |
| 96 | + echo "polar: authorization failed: $resp" >&2 | |
| 97 | + exit 1 | |
| 98 | + ;; | |
| 99 | + esac | |
| 100 | + done | |
| 101 | + echo "polar: timed out waiting for approval" >&2 | |
| 102 | + exit 1 | |
| 103 | + ;; | |
| 104 | + store|erase) | |
| 105 | + exit 0 | |
| 106 | + ;; | |
| 107 | + *) | |
| 108 | + exit 0 | |
| 109 | + ;; | |
| 110 | +esac |