tirbofish/kitgit · commit
d03e8ac0517202e8b5ca61459a25474466c4ddb3
Improve repo UX with PR reviews, invite signup, and cleaner chrome.
Add pull reviews and full-diff expand, let invite codes bypass locked signups, and rework the branch toolbar / latest-commit bar plus related settings UI polish.
Type: SSH
SSH Key Fingerprint:
Verified
SgQHY4vUORbJC3ZtdixCl62ek/4QGh/iG2FRSyjfGPc
@@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS pull_reviews ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + pull_id UUID NOT NULL REFERENCES pull_requests(id) ON DELETE CASCADE, + reviewer_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + state TEXT NOT NULL CHECK (state IN ('approved','changes_requested','commented')), + body TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS pull_reviews_pull_id_idx ON pull_reviews(pull_id); @@ -288,6 +288,16 @@ pub struct Comment { } #[derive(Debug, Clone, FromRow)] +pub struct PullReview { + pub id: Uuid, + pub pull_id: Uuid, + pub reviewer_id: Uuid, + pub state: String, + pub body: String, + pub created_at: DateTime<Utc>, +} + +#[derive(Debug, Clone, FromRow)] pub struct Release { pub id: Uuid, pub repo_id: Uuid, @@ -1326,6 +1326,29 @@ pub async fn user_owns_email(pool: &PgPool, user_id: Uuid, email: &str) -> Resul Ok(row.is_some()) } +/// Resolve a kitgit username from a commit author email (primary or secondary). +pub async fn username_by_email(pool: &PgPool, email: &str) -> Result<Option<String>> { + let email = email.trim().to_ascii_lowercase(); + if email.is_empty() { + return Ok(None); + } + let row: Option<(String,)> = sqlx::query_as( + r#" + SELECT u.username FROM users u + WHERE lower(u.email) = $1 + UNION + SELECT u.username FROM users u + JOIN user_emails e ON e.user_id = u.id + WHERE lower(e.email) = $1 + LIMIT 1 + "#, + ) + .bind(&email) + .fetch_optional(pool) + .await?; + Ok(row.map(|(u,)| u)) +} + pub async fn list_all_gpg_keys(pool: &PgPool) -> Result<Vec<GpgKey>> { Ok(sqlx::query_as::<_, GpgKey>("SELECT * FROM gpg_keys ORDER BY created_at DESC") .fetch_all(pool) @@ -1739,6 +1762,36 @@ pub async fn list_pull_comments(pool: &PgPool, pull_id: Uuid) -> Result<Vec<Comm .await?) } +pub async fn create_review( + pool: &PgPool, + pull_id: Uuid, + reviewer_id: Uuid, + state: &str, + body: &str, +) -> Result<PullReview> { + Ok(sqlx::query_as::<_, PullReview>( + r#" + INSERT INTO pull_reviews (pull_id, reviewer_id, state, body) + VALUES ($1, $2, $3, $4) RETURNING * + "#, + ) + .bind(pull_id) + .bind(reviewer_id) + .bind(state) + .bind(body) + .fetch_one(pool) + .await?) +} + +pub async fn list_reviews_for_pull(pool: &PgPool, pull_id: Uuid) -> Result<Vec<PullReview>> { + Ok(sqlx::query_as::<_, PullReview>( + "SELECT * FROM pull_reviews WHERE pull_id = $1 ORDER BY created_at", + ) + .bind(pull_id) + .fetch_all(pool) + .await?) +} + pub async fn create_release( pool: &PgPool, repo_id: Uuid, @@ -1918,7 +1971,7 @@ pub async fn rewrite_asset_paths_for_tag( } -// ΓöÇΓöÇ stars / watches / forks ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ +// ── stars / watches / forks ────────────────────────────────────────────────── pub async fn toggle_star(pool: &PgPool, repo_id: Uuid, user_id: Uuid) -> Result<bool> { let existing: Option<(Uuid,)> = sqlx::query_as( @@ -2164,7 +2217,7 @@ pub async fn get_fork_of_user(pool: &PgPool, fork_of_id: Uuid, owner_id: Uuid) - .await?) } -// ΓöÇΓöÇ reactions ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ +// ── reactions ──────────────────────────────────────────────────────────────── pub async fn toggle_reaction( pool: &PgPool, @@ -2231,7 +2284,7 @@ pub async fn list_reaction_counts( Ok(rows.into_iter().map(|r| (r.emoji, r.count, r.mine)).collect()) } -// ΓöÇΓöÇ branch rules ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ +// ── branch rules ───────────────────────────────────────────────────────────── pub async fn list_branch_rules(pool: &PgPool, repo_id: Uuid) -> Result<Vec<BranchRule>> { Ok(sqlx::query_as::<_, BranchRule>( @@ -2275,7 +2328,7 @@ pub async fn delete_branch_rule(pool: &PgPool, id: Uuid, repo_id: Uuid) -> Resul Ok(()) } -// ΓöÇΓöÇ user emails / privacy / sessions / gpg ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ +// ── user emails / privacy / sessions / gpg ─────────────────────────────────── pub async fn update_username(pool: &PgPool, id: Uuid, username: &str) -> Result<User> { Ok(sqlx::query_as::<_, User>( @@ -2351,6 +2404,36 @@ pub async fn delete_user_email(pool: &PgPool, id: Uuid, user_id: Uuid) -> Result Ok(()) } +pub async fn set_primary_email(pool: &PgPool, user_id: Uuid, email_id: Uuid) -> Result<()> { + let mut tx = pool.begin().await?; + let row: Option<(String,)> = sqlx::query_as( + "SELECT email FROM user_emails WHERE id = $1 AND user_id = $2", + ) + .bind(email_id) + .bind(user_id) + .fetch_optional(&mut *tx) + .await?; + let Some((email,)) = row else { + anyhow::bail!("email not found"); + }; + sqlx::query("UPDATE user_emails SET is_primary = false WHERE user_id = $1") + .bind(user_id) + .execute(&mut *tx) + .await?; + sqlx::query("UPDATE user_emails SET is_primary = true WHERE id = $1 AND user_id = $2") + .bind(email_id) + .bind(user_id) + .execute(&mut *tx) + .await?; + sqlx::query("UPDATE users SET email = $2, updated_at = now() WHERE id = $1") + .bind(user_id) + .bind(&email) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) +} + pub async fn ensure_primary_email(pool: &PgPool, user_id: Uuid, email: &str) -> Result<()> { if email.trim().is_empty() { return Ok(()); @@ -2667,7 +2750,7 @@ pub async fn lfs_object_size(pool: &PgPool, oid: &str) -> Result<Option<i64>> { Ok(row.map(|r| r.0)) } -// ── repo mirrors ───────────────────────────────────────────────────────────── +// ── repo mirrors ───────────────────────────────────────────────────────────── pub async fn get_repo_mirror(pool: &PgPool, repo_id: Uuid) -> Result<Option<RepoMirror>> { Ok(sqlx::query_as::<_, RepoMirror>( @@ -2746,7 +2829,7 @@ pub async fn delete_repo_mirror(pool: &PgPool, repo_id: Uuid) -> Result<()> { Ok(()) } -// ── per-user audit log ─────────────────────────────────────────────────────── +// ── per-user audit log ─────────────────────────────────────────────────────── pub async fn record_audit_log( pool: &PgPool, @@ -2788,7 +2871,7 @@ pub async fn list_audit_log_for_user( .await?) } -// ── webhooks ───────────────────────────────────────────────────────────────── +// ── webhooks ───────────────────────────────────────────────────────────────── pub async fn list_webhooks(pool: &PgPool, repo_id: Uuid) -> Result<Vec<Webhook>> { Ok(sqlx::query_as::<_, Webhook>( @@ -2956,7 +3039,7 @@ pub async fn list_recent_webhook_deliveries( .collect()) } -// ── notifications ──────────────────────────────────────────────────────────── +// ── notifications ──────────────────────────────────────────────────────────── pub async fn create_notification( pool: &PgPool, @@ -391,6 +391,13 @@ pub fn list_commits(repo: &G2Repo, reference: &str, limit: usize) -> Result<Vec< Ok(out) } +pub fn count_commits(repo: &G2Repo, reference: &str) -> Result<usize> { + let oid = resolve_ref(repo, reference)?; + let mut revwalk = repo.revwalk()?; + revwalk.push(oid)?; + Ok(revwalk.count()) +} + pub fn get_commit(repo: &G2Repo, id: &str) -> Result<CommitInfo> { let oid = Oid::from_str(id)?; let c = repo.find_commit(oid)?; @@ -4,15 +4,73 @@ pub mod templates; use crate::git; use crate::state::AppState; +use axum::body::{Body, to_bytes}; use axum::extract::{DefaultBodyLimit, Request}; -use axum::http::{header, StatusCode}; +use axum::http::{header, HeaderValue, StatusCode}; use axum::middleware::{from_fn, Next}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::Router; +use std::time::{Duration, Instant}; use tower_http::limit::RequestBodyLimitLayer; use tower_http::services::ServeDir; +const RENDER_MS_MARKER: &[u8] = b"<!--kg-render-ms-->"; + +fn format_render_duration(elapsed: Duration) -> String { + let ms = elapsed.as_secs_f64() * 1000.0; + if ms < 10.0 { + format!("{ms:.1}ms") + } else { + format!("{:.0}ms", ms.round()) + } +} + +fn inject_render_marker(html: &[u8], label: &str) -> Option<Vec<u8>> { + let pos = html + .windows(RENDER_MS_MARKER.len()) + .position(|window| window == RENDER_MS_MARKER)?; + let mut out = Vec::with_capacity(html.len() - RENDER_MS_MARKER.len() + label.len()); + out.extend_from_slice(&html[..pos]); + out.extend_from_slice(label.as_bytes()); + out.extend_from_slice(&html[pos + RENDER_MS_MARKER.len()..]); + Some(out) +} + +/// Measure HTML page handler time and fill the layout footer timing marker. +async fn inject_render_timing(req: Request, next: Next) -> Response { + let start = Instant::now(); + let res = next.run(req).await; + let elapsed = start.elapsed(); + + let is_html = res + .headers() + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .is_some_and(|ct| ct.starts_with("text/html")); + if !is_html { + return res; + } + + let (mut parts, body) = res.into_parts(); + let Ok(bytes) = to_bytes(body, 16 * 1024 * 1024).await else { + return Response::from_parts(parts, Body::empty()); + }; + + let label = format_render_duration(elapsed); + let body = match inject_render_marker(&bytes, &label) { + Some(updated) => { + parts.headers.remove(header::CONTENT_LENGTH); + if let Ok(len) = HeaderValue::from_str(&updated.len().to_string()) { + parts.headers.insert(header::CONTENT_LENGTH, len); + } + Body::from(updated) + } + None => Body::from(bytes), + }; + Response::from_parts(parts, body) +} + /// Rewrite `/owner/repo.git` ├óΓÇáΓÇÖ `/owner/repo` and drop a trailing slash so /// browser pages and git smart-HTTP both work with classic forge URLs. fn normalize_repo_path(path: &str) -> String { @@ -138,6 +196,10 @@ pub fn app_router(state: AppState) -> Router { post(routes_extra::account_delete_email), ) .route( + "/settings/account/emails/{id}/primary", + post(routes_extra::account_set_primary_email), + ) + .route( "/settings/account/sessions/{id}/revoke", post(routes_extra::account_revoke_session), ) @@ -197,20 +259,20 @@ pub fn app_router(state: AppState) -> Router { .route("/{owner}/{repo}/upload", post(routes::repo_upload)) .route("/{owner}/{repo}/branches", get(routes::repo_branches)) .route( - "/{owner}/{repo}/branches/{branch}/rename", + "/{owner}/{repo}/branches/rename", post(routes_extra::branch_rename), ) .route( - "/{owner}/{repo}/branches/{branch}/delete", + "/{owner}/{repo}/branches/delete", post(routes_extra::branch_delete), ) .route("/{owner}/{repo}/tags", post(routes_extra::tag_create)) .route( - "/{owner}/{repo}/tags/{tag}/rename", + "/{owner}/{repo}/tags/rename", post(routes_extra::tag_rename), ) .route( - "/{owner}/{repo}/tags/{tag}/delete", + "/{owner}/{repo}/tags/delete", post(routes_extra::tag_delete), ) .route( @@ -264,6 +326,10 @@ pub fn app_router(state: AppState) -> Router { post(routes::pull_milestone_save), ) .route( + "/{owner}/{repo}/pulls/{number}/review", + post(routes::pull_review), + ) + .route( "/{owner}/{repo}/labels", get(routes_extra::labels_list).post(routes_extra::label_create), ) @@ -384,6 +450,7 @@ pub fn app_router(state: AppState) -> Router { ) .route("/{username}", get(routes::profile)) .nest_service("/static", ServeDir::new(static_dir)) + .layer(from_fn(inject_render_timing)) .layer(from_fn(normalize_git_url)) .layer(DefaultBodyLimit::max(100 * 1024 * 1024)) .layer(RequestBodyLimitLayer::new(100 * 1024 * 1024)) @@ -392,7 +459,8 @@ pub fn app_router(state: AppState) -> Router { #[cfg(test)] mod path_tests { - use super::normalize_repo_path; + use super::{format_render_duration, inject_render_marker, normalize_repo_path}; + use std::time::Duration; #[test] fn strips_git_suffix_and_slash() { @@ -402,5 +470,19 @@ mod path_tests { assert_eq!(normalize_repo_path("/o/r/"), "/o/r"); assert_eq!(normalize_repo_path("/"), "/"); } + + #[test] + fn injects_render_timing_marker() { + let html = b"<footer>rendered in <!--kg-render-ms--></footer>"; + let out = inject_render_marker(html, "4.2ms").unwrap(); + assert_eq!(out, b"<footer>rendered in 4.2ms</footer>"); + assert!(inject_render_marker(b"<footer>kitgit</footer>", "1ms").is_none()); + } + + #[test] + fn formats_render_duration() { + assert_eq!(format_render_duration(Duration::from_micros(4200)), "4.2ms"); + assert_eq!(format_render_duration(Duration::from_millis(42)), "42ms"); + } } @@ -20,7 +20,7 @@ use serde::Deserialize; use std::path::PathBuf; use uuid::Uuid; -// ├óΓÇ¥Γé¼├óΓÇ¥Γé¼ helpers ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼ +// ── helpers ────────────────────────────────────────────────────────────────── pub type AppResult<T> = Result<T, AppError>; @@ -570,16 +570,14 @@ fn append_diff_line(out: &mut String, line: &str) { out.push('\n'); } -fn render_diff_html(diff: &str) -> String { +fn render_diff_html(diff: &str, full: bool) -> (String, bool, usize) { let lines: Vec<&str> = diff.lines().collect(); - let (html, truncated, total) = render_diff_lines(&lines, Some(DIFF_RENDER_MAX_LINES)); - if truncated { - format!( - "{html}<p class=\"kg-diff__truncated\">Large diffs are not rendered by default. Showing the first {DIFF_RENDER_MAX_LINES} of {total} lines.</p>" - ) + let max = if full { + None } else { - html - } + Some(DIFF_RENDER_MAX_LINES) + }; + render_diff_lines(&lines, max) } /// Render unified-diff lines. When `max_lines` is set, stop after that many @@ -625,7 +623,7 @@ fn anchor_for_path(path: &str, idx: usize) -> String { format!("diff-{idx}-{safe}") } -fn parse_diff_files(diff: &str) -> Vec<DiffFileView> { +fn parse_diff_files(diff: &str, full: bool) -> Vec<DiffFileView> { let mut files = Vec::new(); if diff.trim().is_empty() { return files; @@ -635,23 +633,23 @@ fn parse_diff_files(diff: &str) -> Vec<DiffFileView> { for line in diff.lines() { if line.starts_with("diff --git ") { if let Some((path, lines)) = current.take() { - files.push(build_diff_file(files.len(), path, &lines)); + files.push(build_diff_file(files.len(), path, &lines, full)); } current = Some((diff_file_path(line), vec![line.to_string()])); } else if let Some((_, ref mut lines)) = current { lines.push(line.to_string()); } else { - // Preamble without a file header ├óΓé¼ΓÇ¥ treat as one blob. + // Preamble without a file header — treat as one blob. current = Some(("diff".into(), vec![line.to_string()])); } } if let Some((path, lines)) = current { - files.push(build_diff_file(files.len(), path, &lines)); + files.push(build_diff_file(files.len(), path, &lines, full)); } files } -fn build_diff_file(idx: usize, path: String, lines: &[String]) -> DiffFileView { +fn build_diff_file(idx: usize, path: String, lines: &[String], full: bool) -> DiffFileView { let mut additions = 0u32; let mut deletions = 0u32; let mut binary = false; @@ -666,6 +664,11 @@ fn build_diff_file(idx: usize, path: String, lines: &[String]) -> DiffFileView { } } let refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect(); + let max = if full { + None + } else { + Some(DIFF_RENDER_MAX_LINES) + }; let (html, truncated, total_lines) = if binary { ( String::from( @@ -675,7 +678,7 @@ fn build_diff_file(idx: usize, path: String, lines: &[String]) -> DiffFileView { refs.len(), ) } else { - render_diff_lines(&refs, Some(DIFF_RENDER_MAX_LINES)) + render_diff_lines(&refs, max) }; DiffFileView { anchor: anchor_for_path(&path, idx), @@ -803,6 +806,33 @@ fn breadcrumbs(path: &str) -> Vec<(String, String)> { out } +fn query_full(v: Option<&str>) -> bool { + matches!(v, Some("1") | Some("true") | Some("yes") | Some("on")) +} + +async fn review_views( + pool: &sqlx::PgPool, + reviews: Vec<crate::db::models::PullReview>, +) -> AppResult<Vec<PullReviewView>> { + let mut out = Vec::with_capacity(reviews.len()); + for r in reviews { + let reviewer = queries::get_user_by_id(pool, r.reviewer_id) + .await? + .ok_or_else(AppError::not_found)?; + let avatar_url = avatar_url_for(&reviewer); + out.push(PullReviewView { + id: r.id, + reviewer, + avatar_url, + state: r.state, + body: r.body.clone(), + body_html: render_markdown(&r.body), + created_at: r.created_at, + }); + } + Ok(out) +} + async fn comment_views( pool: &sqlx::PgPool, comments: Vec<Comment>, @@ -2226,16 +2256,11 @@ pub async fn repo_home( branches = git::list_branches(g).unwrap_or_default(); if git::resolve_ref(g, ¤t_branch).is_ok() { empty = false; - entries = git::list_tree(g, ¤t_branch, "") - .unwrap_or_default() - .into_iter() - .map(|e| TreeEntryView { - name: e.name, - path: e.path, - is_dir: e.is_dir, - mode: e.mode, - }) - .collect(); + entries = tree_entry_views( + g, + ¤t_branch, + git::list_tree(g, ¤t_branch, "").unwrap_or_default(), + ); if let Ok(Some((name, src))) = git::find_readme(g, ¤t_branch, "") { readme_html = Some(readme_to_html( &name, @@ -2265,6 +2290,10 @@ pub async fn repo_home( } None => None, }; + let commit_count = match grepo.as_ref() { + Some(g) => git::count_commits(g, ¤t_branch).unwrap_or(0), + None => 0, + }; let languages = language_stat_views(languages); let forked_from = if let Some(fid) = repository.fork_of_id { @@ -2300,6 +2329,7 @@ pub async fn repo_home( languages, empty, latest_commit, + commit_count, forked_from, starred, watching, @@ -2343,16 +2373,12 @@ pub async fn repo_tree( let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo) .map_err(|_| AppError::not_found())?; let (branch, path) = split_ref_path(&grepo, &rest); - let entries = git::list_tree(&grepo, &branch, &path) - .map_err(|e| AppError::not_found().with_message(e.to_string()))? - .into_iter() - .map(|e| TreeEntryView { - name: e.name, - path: e.path, - is_dir: e.is_dir, - mode: e.mode, - }) - .collect(); + let entries = tree_entry_views( + &grepo, + &branch, + git::list_tree(&grepo, &branch, &path) + .map_err(|e| AppError::not_found().with_message(e.to_string()))?, + ); let readme_html = match git::find_readme(&grepo, &branch, &path) { Ok(Some((name, src))) => Some(readme_to_html( &name, @@ -2368,6 +2394,7 @@ pub async fn repo_tree( let branches = git::list_branches(&grepo).unwrap_or_default(); let prepared = prepare_latest_commit(&grepo, &branch); let latest_commit = latest_commit_view(&state, prepared).await; + let commit_count = git::count_commits(&grepo, &branch).unwrap_or(0); let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); Ok(RepoTreeTemplate { viewer, @@ -2381,6 +2408,7 @@ pub async fn repo_tree( entries, readme_html, latest_commit, + commit_count, clone_http, clone_ssh, }) @@ -2441,12 +2469,27 @@ pub async fn repo_blob( }) } -fn blame_time_display(secs: i64) -> String { - chrono::DateTime::from_timestamp(secs, 0) - .map(|dt| dt.format("%Y-%m-%d").to_string()) - .unwrap_or_default() +fn tree_entry_views(grepo: &git2::Repository, branch: &str, entries: Vec<git::TreeEntry>) -> Vec<TreeEntryView> { + entries + .into_iter() + .map(|e| { + let (commit_message, commit_time) = git::list_commits_for_path(grepo, branch, &e.path, 1) + .ok() + .and_then(|mut v| v.pop()) + .map(|c| (c.message, format_unix_time(c.time))) + .unwrap_or_else(|| (String::new(), String::new())); + TreeEntryView { + name: e.name, + path: e.path, + is_dir: e.is_dir, + commit_message, + commit_time, + } + }) + .collect() } + pub async fn repo_blame( State(state): State<AppState>, headers: HeaderMap, @@ -2476,7 +2519,7 @@ pub async fn repo_blame( commit_id: l.commit_id, short_id: l.short_id, author: l.author, - time_display: blame_time_display(l.time), + time_display: format_unix_time(l.time), summary: l.summary, hunk_start: l.hunk_start, }) @@ -2587,10 +2630,16 @@ pub async fn repo_commits( }) } +#[derive(Deserialize)] +pub struct FullDiffQuery { + pub full: Option<String>, +} + pub async fn repo_commit( State(state): State<AppState>, headers: HeaderMap, Path((owner, repo, id)): Path<(String, String, String)>, + Query(q): Query<FullDiffQuery>, ) -> AppResult<impl IntoResponse> { let (repository, owner_user, viewer, access) = load_repo_context(&state, &owner, &repo, &headers).await?; @@ -2599,7 +2648,8 @@ pub async fn repo_commit( let commit = git::get_commit(&grepo, &id).map_err(|_| AppError::not_found())?; let extracted = git::extract_commit_signature(&grepo, &commit.id); let diff = git::commit_diff(&grepo, &id).unwrap_or_default(); - let diff_html = render_diff_html(&diff); + let show_full = query_full(q.full.as_deref()); + let (diff_html, truncated, total_lines) = render_diff_html(&diff, show_full); let message_html = linkify_commit_message( &state.pool, repository.id, @@ -2616,6 +2666,9 @@ pub async fn repo_commit( commit: commit_view(&state, commit, extracted).await, message_html, diff_html, + show_full, + truncated, + total_lines, clone_http: clone_urls(&state, &owner, &repo).0, clone_ssh: clone_urls(&state, &owner, &repo).1, }) @@ -2625,6 +2678,7 @@ pub async fn repo_diff( State(state): State<AppState>, headers: HeaderMap, Path((owner, repo, id)): Path<(String, String, String)>, + Query(q): Query<FullDiffQuery>, ) -> AppResult<impl IntoResponse> { let (repository, owner_user, viewer, access) = load_repo_context(&state, &owner, &repo, &headers).await?; @@ -2633,7 +2687,8 @@ pub async fn repo_diff( let commit = git::get_commit(&grepo, &id).map_err(|_| AppError::not_found())?; let extracted = git::extract_commit_signature(&grepo, &commit.id); let diff = git::commit_diff(&grepo, &id).unwrap_or_default(); - let diff_html = render_diff_html(&diff); + let show_full = query_full(q.full.as_deref()); + let (diff_html, truncated, total_lines) = render_diff_html(&diff, show_full); let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); Ok(RepoDiffTemplate { viewer, @@ -2642,6 +2697,9 @@ pub async fn repo_diff( access, commit: commit_view(&state, commit, extracted).await, diff_html, + show_full, + truncated, + total_lines, clone_http, clone_ssh, }) @@ -2850,7 +2908,7 @@ pub async fn repo_branches( let updated = git::list_commits(&grepo, &name, 1) .ok() .and_then(|mut v| v.pop()) - .map(|c| c.time.to_string()) + .map(|c| format_unix_time(c.time)) .unwrap_or_default(); BranchRow { name, @@ -2878,7 +2936,7 @@ pub async fn repo_branches( short_id: t.short_id, target: t.target, message: t.message, - updated: t.time.to_string(), + updated: format_unix_time(t.time), }) .collect(); let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); @@ -2933,7 +2991,11 @@ async fn commit_view( short_id: c.short_id, message: c.message, author: c.author, - email: c.email, + email: c.email.clone(), + author_username: queries::username_by_email(&state.pool, &c.email) + .await + .ok() + .flatten(), time: c.time, signed, verified, @@ -3673,6 +3735,7 @@ pub async fn pull_create( #[derive(Deserialize)] pub struct PullTabQuery { pub tab: Option<String>, + pub full: Option<String>, } pub async fn pull_view( @@ -3698,6 +3761,11 @@ pub async fn pull_view( viewer.as_ref().map(|u| u.id), ) .await?; + let reviews = review_views( + &state.pool, + queries::list_reviews_for_pull(&state.pool, pull.id).await?, + ) + .await?; let tab = match q.tab.as_deref() { Some("commits") => "commits", @@ -3705,6 +3773,7 @@ pub async fn pull_view( _ => "conversation", } .to_string(); + let show_full = query_full(q.full.as_deref()); let mut commits = Vec::new(); let mut diff_files = Vec::new(); @@ -3720,7 +3789,7 @@ pub async fn pull_view( commits = commit_views(&state, prepared).await; let diff = git::branch_diff(&g, &pull.source_branch, &pull.target_branch).unwrap_or_default(); - diff_files = parse_diff_files(&diff); + diff_files = parse_diff_files(&diff, show_full); } let can_merge = access.can_write() && pull.state == "open"; @@ -3753,8 +3822,12 @@ pub async fn pull_view( .as_ref() .map(|u| access.can_write() || pull.author_id == u.id) .unwrap_or(false); + let is_author = viewer + .as_ref() + .map(|u| u.id == pull.author_id) + .unwrap_or(false); - let conversation_count = comments.len() + 1; + let conversation_count = comments.len() + reviews.len() + 1; let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); Ok(PullViewTemplate { viewer, @@ -3766,10 +3839,12 @@ pub async fn pull_view( pull, author, comments, + reviews, commits, conversation_count, diff_files, tab, + show_full, can_merge, merge_styles, labels, @@ -3777,6 +3852,7 @@ pub async fn pull_view( milestone, milestone_options, can_triage, + is_author, clone_http, clone_ssh, }) @@ -3886,6 +3962,72 @@ pub async fn pull_comment( } #[derive(Deserialize)] +pub struct PullReviewForm { + pub state: String, + pub body: Option<String>, +} + +pub async fn pull_review( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, number)): Path<(String, String, i32)>, + Form(form): Form<PullReviewForm>, +) -> AppResult<Response> { + let (repository, _o, viewer, _a) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let user = viewer.ok_or_else(AppError::unauthorized)?; + let pull = queries::get_pull(&state.pool, repository.id, number) + .await? + .ok_or_else(AppError::not_found)?; + if pull.state != "open" { + return Err(AppError::bad("pull request is not open")); + } + let state_name = match form.state.trim() { + "approved" => "approved", + "changes_requested" => "changes_requested", + "commented" => "commented", + _ => return Err(AppError::bad("invalid review state")), + }; + if user.id == pull.author_id && state_name != "commented" { + return Err(AppError::bad( + "cannot approve or request changes on your own pull request", + )); + } + let body = form.body.as_deref().unwrap_or("").trim(); + queries::create_review(&state.pool, pull.id, user.id, state_name, body).await?; + queries::record_activity( + &state.pool, + Some(user.id), + Some(repository.id), + "pull.review", + &format!("reviewed pull #{number} ({state_name})"), + serde_json::json!({ "number": number, "state": state_name }), + ) + .await?; + let participants = queries::list_pull_participant_ids(&state.pool, pull.id).await?; + let href = format!("/{owner}/{repo}/pulls/{number}"); + let summary = match state_name { + "approved" => format!("{} approved pull #{number}", user.username), + "changes_requested" => { + format!("{} requested changes on pull #{number}", user.username) + } + _ => format!("{} left a review on pull #{number}", user.username), + }; + queries::notify_users( + &state.pool, + &participants, + user.id, + "pull.review", + &summary, + ¬if_snippet(body, 160), + &href, + Some(repository.id), + ) + .await?; + Ok(redirect_see_other(&href)) +} + +#[derive(Deserialize)] pub struct MergeForm { pub style: Option<String>, } @@ -234,13 +234,14 @@ pub async fn branch_rule_delete( #[derive(Deserialize)] pub struct RenameBranchForm { + pub branch: String, pub new_name: String, } pub async fn branch_rename( State(state): State<AppState>, headers: HeaderMap, - Path((owner, repo, branch)): Path<(String, String, String)>, + Path((owner, repo)): Path<(String, String)>, Form(form): Form<RenameBranchForm>, ) -> AppResult<Response> { let (repository, _, viewer, access) = @@ -249,22 +250,38 @@ pub async fn branch_rename( if !access.can_write() { return Err(AppError::forbidden()); } + let branch = form.branch.trim(); + if branch.is_empty() { + return Err(AppError::bad("branch required")); + } if branch == repository.default_branch { return Err(AppError::bad("cannot rename default branch here")); } let new_name = form.new_name.trim(); - if new_name.is_empty() || new_name.contains('/') { + // Allow hierarchical names like feat/foo; reject empty / traversal / whitespace. + if new_name.is_empty() + || new_name.contains("..") + || new_name.contains(char::is_whitespace) + || new_name.starts_with('/') + || new_name.ends_with('/') + { return Err(AppError::bad("invalid branch name")); } let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo)?; - git::rename_branch(&grepo, &branch, new_name)?; + git::rename_branch(&grepo, branch, new_name)?; Ok(redirect_see_other(&format!("/{owner}/{repo}/branches"))) } +#[derive(Deserialize)] +pub struct DeleteBranchForm { + pub branch: String, +} + pub async fn branch_delete( State(state): State<AppState>, headers: HeaderMap, - Path((owner, repo, branch)): Path<(String, String, String)>, + Path((owner, repo)): Path<(String, String)>, + Form(form): Form<DeleteBranchForm>, ) -> AppResult<Response> { let (repository, _, viewer, access) = load_repo_context(&state, &owner, &repo, &headers).await?; @@ -272,11 +289,15 @@ pub async fn branch_delete( if !access.can_write() { return Err(AppError::forbidden()); } + let branch = form.branch.trim(); + if branch.is_empty() { + return Err(AppError::bad("branch required")); + } if branch == repository.default_branch { return Err(AppError::bad("cannot delete default branch")); } let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo)?; - git::delete_branch(&grepo, &branch)?; + git::delete_branch(&grepo, branch)?; Ok(redirect_see_other(&format!("/{owner}/{repo}/branches"))) } @@ -322,13 +343,14 @@ pub async fn tag_create( #[derive(Deserialize)] pub struct RenameTagForm { + pub tag: String, pub new_name: String, } pub async fn tag_rename( State(state): State<AppState>, headers: HeaderMap, - Path((owner, repo, tag)): Path<(String, String, String)>, + Path((owner, repo)): Path<(String, String)>, Form(form): Form<RenameTagForm>, ) -> AppResult<Response> { let (repository, _, viewer, access) = @@ -337,21 +359,25 @@ pub async fn tag_rename( if !access.can_write() { return Err(AppError::forbidden()); } + let tag = form.tag.trim(); + if tag.is_empty() { + return Err(AppError::bad("tag required")); + } let new_name = form.new_name.trim(); if new_name.is_empty() || new_name.contains("..") { return Err(AppError::bad("invalid tag name")); } let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo)?; - git::rename_tag(&grepo, &tag, new_name) + git::rename_tag(&grepo, tag, new_name) .map_err(|e| AppError::bad(format!("rename tag failed: {e}")))?; if let Some(release) = - queries::rename_release_tag(&state.pool, repository.id, &tag, new_name).await? + queries::rename_release_tag(&state.pool, repository.id, tag, new_name).await? { let old_dir = state .config .releases_dir() .join(repository.id.to_string()) - .join(&tag); + .join(tag); let new_dir = state .config .releases_dir() @@ -368,10 +394,16 @@ pub async fn tag_rename( Ok(redirect_see_other(&format!("/{owner}/{repo}/branches"))) } +#[derive(Deserialize)] +pub struct DeleteTagForm { + pub tag: String, +} + pub async fn tag_delete( State(state): State<AppState>, headers: HeaderMap, - Path((owner, repo, tag)): Path<(String, String, String)>, + Path((owner, repo)): Path<(String, String)>, + Form(form): Form<DeleteTagForm>, ) -> AppResult<Response> { let (_repository, _, viewer, access) = load_repo_context(&state, &owner, &repo, &headers).await?; @@ -379,12 +411,16 @@ pub async fn tag_delete( if !access.can_write() { return Err(AppError::forbidden()); } + let tag = form.tag.trim(); + if tag.is_empty() { + return Err(AppError::bad("tag required")); + } let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo)?; - git::delete_tag(&grepo, &tag).map_err(|e| AppError::bad(format!("delete tag failed: {e}")))?; + git::delete_tag(&grepo, tag).map_err(|e| AppError::bad(format!("delete tag failed: {e}")))?; Ok(redirect_see_other(&format!("/{owner}/{repo}/branches"))) } -// ΓöÇΓöÇ account settings ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ +// ── account settings ───────────────────────────────────────────────────────── pub async fn account_settings( State(state): State<AppState>, @@ -782,6 +818,25 @@ pub async fn account_delete_email( Ok(redirect_see_other("/settings/account")) } +pub async fn account_set_primary_email( + State(state): State<AppState>, + headers: HeaderMap, + Path(id): Path<Uuid>, +) -> AppResult<Response> { + let user = require_login(&state.auth, &headers).await?; + queries::set_primary_email(&state.pool, user.id, id).await?; + record_audit( + &state, + &headers, + user.id, + Some(user.id), + "email.primary", + serde_json::json!({ "email_id": id }), + ) + .await; + Ok(redirect_see_other("/settings/account")) +} + pub async fn account_revoke_session( State(state): State<AppState>, headers: HeaderMap, @@ -9,7 +9,7 @@ use askama_web::WebTemplate; use chrono::{DateTime, Utc}; use uuid::Uuid; -// ΓöÇΓöÇ view models ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ +// ── view models ────────────────────────────────────────────────────────────── pub struct ActivityRow { pub kind: String, @@ -41,6 +41,34 @@ pub struct CommentView { pub reactions: Vec<ReactionView>, } +pub struct PullReviewView { + pub id: Uuid, + pub reviewer: User, + pub avatar_url: String, + pub state: String, + pub body: String, + pub body_html: String, + pub created_at: DateTime<Utc>, +} + +impl PullReviewView { + pub fn state_label(&self) -> &'static str { + match self.state.as_str() { + "approved" => "approved", + "changes_requested" => "changes requested", + _ => "commented", + } + } + + pub fn state_class(&self) -> &'static str { + match self.state.as_str() { + "approved" => "kg-badge--approved", + "changes_requested" => "kg-badge--changes", + _ => "kg-badge--commented", + } + } +} + pub struct DiffFileView { pub path: String, pub anchor: String, @@ -56,7 +84,10 @@ pub struct TreeEntryView { pub name: String, pub path: String, pub is_dir: bool, - pub mode: String, + /// Last commit that touched this path (summary). + pub commit_message: String, + /// Formatted timestamp for that commit. + pub commit_time: String, } pub struct CommitView { @@ -65,6 +96,8 @@ pub struct CommitView { pub message: String, pub author: String, pub email: String, + /// Kitgit username when the commit email matches a known account. + pub author_username: Option<String>, pub time: i64, /// True when a signature blob was present on the commit (SSH or GPG). pub signed: bool, @@ -83,6 +116,17 @@ impl CommitView { _ => "Unknown", } } + + pub fn time_display(&self) -> String { + format_unix_time(self.time) + } +} + +/// Human-readable UTC date/time from a Unix timestamp (seconds). +pub fn format_unix_time(secs: i64) -> String { + chrono::DateTime::from_timestamp(secs, 0) + .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string()) + .unwrap_or_default() } pub struct CollaboratorView { @@ -162,7 +206,7 @@ pub struct ReleaseAssetView { pub content_type: String, } -// ΓöÇΓöÇ page templates ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ +// ── page templates ──────────────────────────────────────────────────────────── #[derive(Template, WebTemplate)] #[template(path = "home.html")] @@ -346,6 +390,7 @@ pub struct RepoHomeTemplate { pub languages: Vec<LanguageStatView>, pub empty: bool, pub latest_commit: Option<CommitView>, + pub commit_count: usize, pub starred: bool, pub watching: bool, pub forked_from: Option<(String, String)>, @@ -366,6 +411,7 @@ pub struct RepoTreeTemplate { pub entries: Vec<TreeEntryView>, pub readme_html: Option<String>, pub latest_commit: Option<CommitView>, + pub commit_count: usize, pub clone_http: String, pub clone_ssh: String, } @@ -457,6 +503,9 @@ pub struct RepoCommitTemplate { pub commit: CommitView, pub message_html: String, pub diff_html: String, + pub show_full: bool, + pub truncated: bool, + pub total_lines: usize, pub clone_http: String, pub clone_ssh: String, } @@ -470,6 +519,9 @@ pub struct RepoDiffTemplate { pub access: Access, pub commit: CommitView, pub diff_html: String, + pub show_full: bool, + pub truncated: bool, + pub total_lines: usize, pub clone_http: String, pub clone_ssh: String, } @@ -585,9 +637,11 @@ pub struct PullViewTemplate { pub author_avatar: String, pub body_html: String, pub comments: Vec<CommentView>, + pub reviews: Vec<PullReviewView>, pub commits: Vec<CommitView>, pub diff_files: Vec<DiffFileView>, pub tab: String, + pub show_full: bool, pub conversation_count: usize, pub can_merge: bool, pub merge_styles: Vec<String>, @@ -596,6 +650,7 @@ pub struct PullViewTemplate { pub milestone: Option<Milestone>, pub milestone_options: Vec<MilestoneOption>, pub can_triage: bool, + pub is_author: bool, pub clone_http: String, pub clone_ssh: String, } @@ -193,12 +193,21 @@ a:hover { } .kg-footer { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: var(--kg-space-4); padding: var(--kg-space-4) 0 var(--kg-space-6); color: var(--kg-faint); font-size: 0.75rem; letter-spacing: 0.06em; } +.kg-footer__timing { + letter-spacing: 0; + font-variant-numeric: tabular-nums; +} + .kg-display { font-weight: 700; font-size: clamp(1.5rem, 3vw, 2rem); @@ -279,12 +288,6 @@ a:hover { background: var(--kg-danger-bg); } -.kg-actions { - display: flex; - flex-wrap: wrap; - gap: var(--kg-space-2); -} - .kg-list { list-style: none; margin: 0; @@ -536,32 +539,145 @@ a:hover { min-width: 0; } +.kg-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--kg-space-3); + margin: var(--kg-space-3) 0 var(--kg-space-2); + min-width: 0; +} + +.kg-toolbar__branch { + flex-shrink: 0; +} + +.kg-toolbar__actions { + display: flex; + align-items: center; + gap: var(--kg-space-2); + flex-wrap: nowrap; + flex-shrink: 0; + margin-left: auto; +} + +.kg-latest { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--kg-space-3); + flex-wrap: nowrap; + border: 1px solid var(--kg-line); + padding: 0.55rem 0.75rem; + margin: 0 0 var(--kg-space-3); + background: var(--kg-bg-raised); + min-width: 0; +} + +.kg-latest__main { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; + flex: 1 1 auto; + overflow: hidden; +} + +.kg-latest__author { + flex-shrink: 0; + font-weight: 600; + color: var(--kg-fg); + text-decoration: none; + white-space: nowrap; +} + +a.kg-latest__author:hover { + text-decoration: underline; + text-underline-offset: 0.15em; +} + +.kg-latest__msg { + color: var(--kg-fg); + text-decoration: none; + font-size: 0.875rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + flex: 1 1 auto; +} + +.kg-latest__msg:hover { + text-decoration: underline; + text-underline-offset: 0.15em; +} + +.kg-latest__meta { + display: flex; + align-items: center; + gap: 0.65rem; + flex-shrink: 0; + white-space: nowrap; +} + +.kg-latest__sha { + color: var(--kg-muted); + text-decoration: none; + font-size: 0.8125rem; +} + +.kg-latest__sha:hover { + text-decoration: underline; + text-underline-offset: 0.15em; +} + +.kg-latest__sha code { + font-size: inherit; +} + +.kg-latest__time { + white-space: nowrap; +} + +.kg-latest__count { + text-decoration: none; + white-space: nowrap; +} + +.kg-latest__count:hover { + text-decoration: underline; + text-underline-offset: 0.15em; +} + .kg-branchbar { display: flex; align-items: center; justify-content: space-between; gap: var(--kg-space-3); - flex-wrap: wrap; + flex-wrap: nowrap; border: 1px solid var(--kg-line); padding: 0.55rem 0.75rem; margin: var(--kg-space-3) 0; background: var(--kg-bg-raised); + min-width: 0; } .kg-branchbar__left { display: flex; align-items: center; gap: var(--kg-space-3); - flex-wrap: wrap; + flex-wrap: nowrap; min-width: 0; flex: 1 1 auto; + overflow: hidden; } .kg-branchbar__actions { display: flex; align-items: center; gap: var(--kg-space-2); - flex-wrap: wrap; + flex-wrap: nowrap; + flex-shrink: 0; } .kg-branchbar__branch { @@ -571,15 +687,16 @@ a:hover { text-decoration: none; font-weight: 700; color: var(--kg-fg); + flex-shrink: 0; } .kg-branchbar__commit { display: flex; align-items: center; - gap: var(--kg-space-2); + gap: 0.5rem; min-width: 0; - flex: 1; - justify-content: flex-end; + flex: 1 1 auto; + overflow: hidden; } .kg-branchbar__sha { @@ -599,7 +716,8 @@ a:hover { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - max-width: 28rem; + min-width: 0; + flex: 1 1 auto; } .kg-branchbar__msg:hover, @@ -785,15 +903,30 @@ a:hover { .kg-danger-row { display: flex; - flex-wrap: wrap; - align-items: flex-end; + flex-wrap: nowrap; + align-items: center; gap: var(--kg-space-2); } -.kg-danger-row label { - flex: 1 1 12rem; +.kg-danger-row label, +.kg-danger-confirm { + flex: 1 1 auto; margin: 0; - gap: 0.25rem; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + gap: 0.5rem; + white-space: nowrap; +} + +.kg-danger-confirm input { + flex: 1 1 10rem; + min-width: 8rem; +} + +.kg-danger-row > button { + flex-shrink: 0; } .kg-settings-section { @@ -808,6 +941,7 @@ a:hover { .kg-social { display: flex; + flex-direction: row; flex-wrap: wrap; gap: var(--kg-space-2); margin: 0; @@ -818,6 +952,29 @@ a:hover { .kg-social form { margin: 0; + display: inline-flex; +} + +.kg-branchbar__count { + display: inline-flex; + align-items: center; + gap: 0.35em; + text-decoration: none; + flex-shrink: 0; + white-space: nowrap; +} + +.kg-branchbar__count:hover { + text-decoration: underline; + text-underline-offset: 0.15em; +} + +.kg-actions { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: var(--kg-space-2); + align-items: center; } .kg-reactions { @@ -1342,12 +1499,38 @@ a:hover { .kg-tree li { display: grid; - grid-template-columns: 1.5rem 1fr auto auto; + grid-template-columns: 1.5rem minmax(5rem, 12rem) minmax(0, 1fr) 9rem 2rem; align-items: center; gap: var(--kg-space-2); padding: 0.35rem var(--kg-space-3); border-bottom: 1px solid var(--kg-line); font-size: 0.875rem; + min-width: 0; +} + +.kg-tree li > a { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.kg-tree__commit { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.kg-tree__time { + text-align: right; + white-space: nowrap; + flex-shrink: 0; +} + +.kg-tree__dl { + justify-self: end; + flex-shrink: 0; } .kg-tree li:last-child { @@ -1633,6 +1816,57 @@ a:hover { color: var(--kg-muted); } +.kg-badge--approved { + color: #1a7f37; + border-color: #1a7f37; + background: color-mix(in srgb, #1a7f37 12%, transparent); +} + +.kg-badge--changes { + color: #cf222e; + border-color: #cf222e; + background: color-mix(in srgb, #cf222e 12%, transparent); +} + +.kg-badge--commented { + color: var(--kg-muted); + border-color: var(--kg-line-strong); + background: transparent; +} + +.kg-review-state { + border: 1px solid var(--kg-line); + border-radius: var(--kg-radius); + padding: var(--kg-space-3); + margin: 0 0 var(--kg-space-3); + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--kg-space-3); +} + +.kg-review-state legend { + padding: 0 var(--kg-space-1); + font-size: 0.8125rem; + color: var(--kg-muted); + text-transform: lowercase; + width: 100%; + float: left; + margin-bottom: var(--kg-space-1); +} + +.kg-review-state .row { + display: inline-flex; + align-items: center; + gap: var(--kg-space-2); + margin: 0; +} + +.kg-diff__truncated .kg-btn { + margin-left: var(--kg-space-2); + vertical-align: middle; +} + .kg-badge--verified { color: #1a7f37; border-color: #1a7f37; @@ -97,11 +97,16 @@ {% if e.is_primary %}<span class="kg-badge">primary</span>{% endif %} {% if e.verified %}<span class="kg-badge">verified</span>{% endif %} </span> - {% if !e.is_primary %} - <form method="post" action="/settings/account/emails/{{ e.id }}/delete"> - <button class="kg-btn kg-btn--danger-ghost" type="submit">remove</button> - </form> - {% endif %} + <span class="kg-actions"> + {% if !e.is_primary %} + <form method="post" action="/settings/account/emails/{{ e.id }}/primary"> + <button class="kg-btn kg-btn--ghost" type="submit">make primary</button> + </form> + <form method="post" action="/settings/account/emails/{{ e.id }}/delete"> + <button class="kg-btn kg-btn--danger-ghost" type="submit">remove</button> + </form> + {% endif %} + </span> </li> {% endfor %} </ul> @@ -191,8 +196,8 @@ <span class="kg-kicker">delete account</span> <form class="kg-form" method="post" action="/settings/account/delete"> <div class="kg-danger-row"> - <label> - type <code>{{ user.username }}</code> to delete + <label class="kg-danger-confirm"> + <span>type <code>{{ user.username }}</code> to delete</span> <input type="text" name="confirm" required autocomplete="off" /> </label> <button class="kg-btn kg-btn--danger" type="submit">delete account</button> @@ -62,7 +62,7 @@ <p class="kg-meta" style="margin:0.25rem 0 0">{{ item.repo.description }}</p> {% endif %} </div> - <span class="kg-meta">{{ item.repo.updated_at }}</span> + <span class="kg-meta">{{ item.repo.updated_at.format("%Y-%m-%d") }}</span> </li> {% endfor %} </ul> @@ -113,7 +113,7 @@ {{ item.owner }}/{{ item.repo_name }}#{{ item.number }} {{ item.title }} </a> </div> - <span class="kg-meta">{{ item.updated_at }}</span> + <span class="kg-meta">{{ item.updated_at.format("%Y-%m-%d") }}</span> </li> {% endfor %} </ul> @@ -138,7 +138,7 @@ {{ item.owner }}/{{ item.repo_name }}#{{ item.number }} {{ item.title }} </a> </div> - <span class="kg-meta">{{ item.updated_at }}</span> + <span class="kg-meta">{{ item.updated_at.format("%Y-%m-%d") }}</span> </li> {% endfor %} </ul> @@ -13,8 +13,8 @@ {% include "partials/repo_tabs.html" %} <div class="kg-actions"> - <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/issues?state=open{% if let Some(lid) = label_filter %}&label={{ lid }}{% endif %}{% if let Some(mid) = milestone_filter %}&milestone={{ mid }}{% endif %}"{% if state_filter == "open" %} aria-current="page"{% endif %}>open</a> - <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/issues?state=closed{% if let Some(lid) = label_filter %}&label={{ lid }}{% endif %}{% if let Some(mid) = milestone_filter %}&milestone={{ mid }}{% endif %}"{% if state_filter == "closed" %} aria-current="page"{% endif %}>closed</a> + <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/issues?state=open"{% if state_filter == "open" %} aria-current="page"{% endif %}>open</a> + <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/issues?state=closed"{% if state_filter == "closed" %} aria-current="page"{% endif %}>closed</a> {% if viewer.is_some() %} <a class="kg-btn" href="/{{ owner.username }}/{{ repo.name }}/issues/new">new issue</a> {% endif %} @@ -22,29 +22,6 @@ <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/milestones">milestones</a> </div> - <form class="kg-form kg-filters" method="get" action="/{{ owner.username }}/{{ repo.name }}/issues"> - <input type="hidden" name="state" value="{{ state_filter }}" /> - <label> - label - <select name="label"> - <option value="">any</option> - {% for l in labels %} - <option value="{{ l.id }}"{% if let Some(lid) = label_filter %}{% if lid.to_string() == l.id.to_string() %} selected{% endif %}{% endif %}>{{ l.name }}</option> - {% endfor %} - </select> - </label> - <label> - milestone - <select name="milestone"> - <option value="">any</option> - {% for m in milestones %} - <option value="{{ m.id }}"{% if let Some(mid) = milestone_filter %}{% if mid.to_string() == m.id.to_string() %} selected{% endif %}{% endif %}>{{ m.title }}</option> - {% endfor %} - </select> - </label> - <button class="kg-btn kg-btn--ghost" type="submit">filter</button> - </form> - {% if issues.is_empty() %} <p class="kg-muted">No {{ state_filter }} issues.</p> {% else %} @@ -61,7 +38,7 @@ <span class="kg-meta">milestone: {{ m.title }}</span> {% endif %} </span> - <span class="kg-meta">{{ item.issue.updated_at }}</span> + <span class="kg-meta">{{ item.issue.updated_at.format("%Y-%m-%d") }}</span> </li> {% endfor %} </ul> @@ -44,7 +44,10 @@ <main> {% block content %}{% endblock %} </main> - <footer class="kg-footer">kitgit</footer> + <footer class="kg-footer"> + <span>kitgit</span> + <span class="kg-footer__timing">rendered in <!--kg-render-ms--></span> + </footer> </div> <script> (async () => { @@ -1,23 +1,10 @@ -{# Expects: owner, repo, branch, latest_commit: Option<CommitView>, access (optional for upload), upload_path (optional) #} -<div class="kg-branchbar"> - <div class="kg-branchbar__left"> - <a class="kg-branchbar__branch" href="/{{ owner.username }}/{{ repo.name }}/branches"> - <span class="kg-icon" style="--icon:url('/static/icons/git-branch.svg')"></span> - {{ branch }} - </a> - {% if let Some(c) = latest_commit %} - <div class="kg-branchbar__commit"> - <a class="kg-branchbar__sha" href="/{{ owner.username }}/{{ repo.name }}/commits" title="View commits"> - <span class="kg-icon" style="--icon:url('/static/icons/git-commit.svg')"></span> - {{ c.short_id }} - </a> - <a class="kg-branchbar__msg" href="/{{ owner.username }}/{{ repo.name }}/commit/{{ c.id }}" title="View commit">{{ c.message }}</a> - {% let show_unverified = false %} - {% include "partials/verified_badge.html" %} - </div> - {% endif %} - </div> - <div class="kg-branchbar__actions"> +{# Expects: owner, repo, branch, latest_commit: Option<CommitView>, access, upload_path, commit_count #} +<div class="kg-toolbar"> + <a class="kg-btn kg-btn--ghost kg-toolbar__branch" href="/{{ owner.username }}/{{ repo.name }}/branches"> + <span class="kg-icon" style="--icon:url('/static/icons/git-branch.svg')"></span> + {{ branch }} + </a> + <div class="kg-toolbar__actions"> {% if upload_path != "" %} <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/archive.zip?ref={{ branch }}&path={{ upload_path }}" title="Download this folder as ZIP"> <span class="kg-icon" style="--icon:url('/static/icons/download.svg')"></span> @@ -58,3 +45,27 @@ {% endif %} </div> </div> + +{% if let Some(c) = latest_commit %} +<div class="kg-latest"> + <div class="kg-latest__main"> + {% if let Some(uname) = c.author_username %} + <a class="kg-latest__author" href="/{{ uname }}">{{ c.author }}</a> + {% else %} + <span class="kg-latest__author">{{ c.author }}</span> + {% endif %} + <a class="kg-latest__msg" href="/{{ owner.username }}/{{ repo.name }}/commit/{{ c.id }}" title="{{ c.message }}">{{ c.message }}</a> + </div> + <div class="kg-latest__meta"> + <a class="kg-latest__sha" href="/{{ owner.username }}/{{ repo.name }}/commit/{{ c.id }}" title="{{ c.id }}"> + <code>{{ c.short_id }}</code> + </a> + {% let show_unverified = false %} + {% include "partials/verified_badge.html" %} + <time class="kg-meta kg-latest__time">{{ c.time_display() }}</time> + <a class="kg-latest__count kg-meta" href="/{{ owner.username }}/{{ repo.name }}/commits" title="{{ commit_count }} commits"> + {{ commit_count }} commits + </a> + </div> +</div> +{% endif %} @@ -31,6 +31,7 @@ </a> {% endif %} </div> + {% if tab == "code" %} <details class="kg-dropdown"> <summary class="kg-btn kg-btn--ghost"> <span class="kg-icon" style="--icon:url('/static/icons/download.svg')"></span> @@ -58,4 +59,5 @@ </div> </div> </details> + {% endif %} </nav> @@ -38,7 +38,7 @@ commits <span class="kg-pr-tabs__count">{{ commits.len() }}</span> </a> - <a href="/{{ owner.username }}/{{ repo.name }}/pulls/{{ pull.number }}?tab=files"{% if pr_tab == "files" %} aria-current="page"{% endif %}> + <a href="/{{ owner.username }}/{{ repo.name }}/pulls/{{ pull.number }}?tab=files{% if show_full %}&full=1{% endif %}"{% if pr_tab == "files" %} aria-current="page"{% endif %}> <span class="kg-icon" style="--icon:url('/static/icons/file.svg')"></span> files <span class="kg-pr-tabs__count">{{ diff_files.len() }}</span> @@ -71,6 +71,51 @@ </article> {% endfor %} + {% for r in reviews %} + <article class="kg-comment kg-review"> + <header> + <span> + <img class="kg-avatar kg-avatar--sm" src="{{ r.avatar_url }}" alt="" /> + <a href="/{{ r.reviewer.username }}">{{ r.reviewer.username }}</a> + <span class="kg-badge {{ r.state_class() }}">{{ r.state_label() }}</span> + </span> + <span>{{ r.created_at }}</span> + </header> + {% if !r.body.is_empty() %} + <div class="kg-readme">{{ r.body_html|safe }}</div> + {% endif %} + </article> + {% endfor %} + + {% if viewer.is_some() && pull.state == "open" %} + <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/pulls/{{ pull.number }}/review"> + <fieldset class="kg-review-state"> + <legend>review</legend> + {% if !is_author %} + <label class="row"> + <input type="radio" name="state" value="approved" /> + approve + </label> + <label class="row"> + <input type="radio" name="state" value="changes_requested" /> + request changes + </label> + {% endif %} + <label class="row"> + <input type="radio" name="state" value="commented" checked /> + comment + </label> + </fieldset> + <label> + review notes + <textarea name="body" rows="4" placeholder="Leave a review comment (optional)"></textarea> + </label> + <div class="kg-actions"> + <button class="kg-btn" type="submit">submit review</button> + </div> + </form> + {% endif %} + {% if viewer.is_some() %} <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/pulls/{{ pull.number }}"> <label> @@ -159,10 +204,11 @@ </span> </header> {{ f.html|safe }} - {% if f.truncated %} + {% if f.truncated && !show_full %} <p class="kg-diff__truncated"> Large diffs are not rendered by default. Showing the first 50 of {{ f.total_lines }} lines. + <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/pulls/{{ pull.number }}?tab=files&full=1#{{ f.anchor }}">Show full diff</a> </p> {% endif %} </section> @@ -14,13 +14,7 @@ <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/labels">labels</a> <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/milestones">milestones</a> </div> - <form class="kg-form kg-filters" method="get" action="/{{ owner.username }}/{{ repo.name }}/pulls"> - <input type="hidden" name="state" value="{{ state_filter }}" /> - <label>label<select name="label"><option value="">any</option>{% for l in labels %}<option value="{{ l.id }}"{% if let Some(lid) = label_filter %}{% if lid.to_string() == l.id.to_string() %} selected{% endif %}{% endif %}>{{ l.name }}</option>{% endfor %}</select></label> - <label>milestone<select name="milestone"><option value="">any</option>{% for m in milestones %}<option value="{{ m.id }}"{% if let Some(mid) = milestone_filter %}{% if mid.to_string() == m.id.to_string() %} selected{% endif %}{% endif %}>{{ m.title }}</option>{% endfor %}</select></label> - <button class="kg-btn kg-btn--ghost" type="submit">filter</button> - </form> {% if pulls.is_empty() %}<p class="kg-muted">No {{ state_filter }} pulls.</p>{% else %} - <ul class="kg-list">{% for item in pulls %}<li><span><span class="kg-badge {% if item.pull.state == "open" %}kg-badge--open{% else %}kg-badge--closed{% endif %}">{{ item.pull.state }}</span> <a href="/{{ owner.username }}/{{ repo.name }}/pulls/{{ item.pull.number }}">#{{ item.pull.number }} {{ item.pull.title }}</a> <span class="kg-meta">{{ item.pull.source_branch }} → {{ item.pull.target_branch }}</span>{% for l in item.labels %} <span class="kg-label-chip" style="background:{{ l.bg_color() }};color:{{ l.text_color() }}">{{ l.name }}</span>{% endfor %}{% if let Some(m) = item.milestone %} <span class="kg-meta">milestone: {{ m.title }}</span>{% endif %}</span><span class="kg-meta">{{ item.pull.updated_at }}</span></li>{% endfor %}</ul>{% endif %} + <ul class="kg-list">{% for item in pulls %}<li><span><span class="kg-badge {% if item.pull.state == "open" %}kg-badge--open{% else %}kg-badge--closed{% endif %}">{{ item.pull.state }}</span> <a href="/{{ owner.username }}/{{ repo.name }}/pulls/{{ item.pull.number }}">#{{ item.pull.number }} {{ item.pull.title }}</a> <span class="kg-meta">{{ item.pull.source_branch }} → {{ item.pull.target_branch }}</span>{% for l in item.labels %} <span class="kg-label-chip" style="background:{{ l.bg_color() }};color:{{ l.text_color() }}">{{ l.name }}</span>{% endfor %}{% if let Some(m) = item.milestone %} <span class="kg-meta">milestone: {{ m.title }}</span>{% endif %}</span><span class="kg-meta">{{ item.pull.updated_at.format("%Y-%m-%d") }}</span></li>{% endfor %}</ul>{% endif %} </section> {% endblock %} @@ -61,13 +61,15 @@ <td> {% if access.can_write() && !b.is_default %} <div class="kg-branch-actions"> - <form method="post" action="/{{ owner.username }}/{{ repo.name }}/branches/{{ b.name }}/rename" style="display:inline-flex;gap:0.35rem"> + <form method="post" action="/{{ owner.username }}/{{ repo.name }}/branches/rename" style="display:inline-flex;gap:0.35rem"> + <input type="hidden" name="branch" value="{{ b.name }}" /> <input type="text" name="new_name" placeholder="rename" required style="width:7rem;font:inherit;border:1px solid var(--kg-line);padding:0.25em 0.4em" /> <button class="kg-btn kg-btn--ghost" type="submit" title="Rename"> <span class="kg-icon" style="--icon:url('/static/icons/pencil.svg')"></span> </button> </form> - <form method="post" action="/{{ owner.username }}/{{ repo.name }}/branches/{{ b.name }}/delete" style="display:inline" onsubmit="return confirm('Delete branch {{ b.name }}?')"> + <form method="post" action="/{{ owner.username }}/{{ repo.name }}/branches/delete" style="display:inline" onsubmit="return confirm('Delete branch {{ b.name }}?')"> + <input type="hidden" name="branch" value="{{ b.name }}" /> <button class="kg-btn kg-btn--danger-ghost" type="submit" title="Delete"> <span class="kg-icon" style="--icon:url('/static/icons/trash.svg')"></span> </button> @@ -139,13 +141,15 @@ <td> {% if access.can_write() %} <div class="kg-branch-actions"> - <form method="post" action="/{{ owner.username }}/{{ repo.name }}/tags/{{ t.name }}/rename" style="display:inline-flex;gap:0.35rem"> + <form method="post" action="/{{ owner.username }}/{{ repo.name }}/tags/rename" style="display:inline-flex;gap:0.35rem"> + <input type="hidden" name="tag" value="{{ t.name }}" /> <input type="text" name="new_name" placeholder="rename" required style="width:7rem;font:inherit;border:1px solid var(--kg-line);padding:0.25em 0.4em" /> <button class="kg-btn kg-btn--ghost" type="submit" title="Rename"> <span class="kg-icon" style="--icon:url('/static/icons/pencil.svg')"></span> </button> </form> - <form method="post" action="/{{ owner.username }}/{{ repo.name }}/tags/{{ t.name }}/delete" style="display:inline" onsubmit="return confirm('Delete tag {{ t.name }}?')"> + <form method="post" action="/{{ owner.username }}/{{ repo.name }}/tags/delete" style="display:inline" onsubmit="return confirm('Delete tag {{ t.name }}?')"> + <input type="hidden" name="tag" value="{{ t.name }}" /> <button class="kg-btn kg-btn--danger-ghost" type="submit" title="Delete"> <span class="kg-icon" style="--icon:url('/static/icons/trash.svg')"></span> </button> @@ -19,9 +19,16 @@ {% let show_unverified = true %} {% include "partials/verified_badge.html" %} </p> - <p class="kg-meta">{{ commit.author }} <{{ commit.email }}> · {{ commit.time }}</p> + <p class="kg-meta">{{ commit.author }} <{{ commit.email }}> · {{ commit.time_display() }}</p> - <p><a href="/{{ owner.username }}/{{ repo.name }}/diff/{{ commit.id }}">view full diff</a></p> + <p><a href="/{{ owner.username }}/{{ repo.name }}/diff/{{ commit.id }}{% if show_full %}?full=1{% endif %}">view full diff</a></p> {{ diff_html|safe }} + {% if truncated && !show_full %} + <p class="kg-diff__truncated"> + Large diffs are not rendered by default. + Showing the first 50 of {{ total_lines }} lines. + <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/commit/{{ commit.id }}?full=1">Show full diff</a> + </p> + {% endif %} </section> {% endblock %} @@ -35,7 +35,7 @@ {% include "partials/verified_badge.html" %} <span class="kg-muted"> — {{ c.author }}</span> </span> - <span class="kg-meta">{{ c.time }}</span> + <span class="kg-meta">{{ c.time_display() }}</span> </li> {% endfor %} </ul> @@ -20,12 +20,19 @@ · {{ commit.author }} </p> <p class="kg-title"> - <a href="/{{ owner.username }}/{{ repo.name }}/commit/{{ commit.id }}">{{ commit.message }}</a> + <a href="/{{ owner.username }}/{{ repo.name }}/commit/{{ commit.id }}{% if show_full %}?full=1{% endif %}">{{ commit.message }}</a> {% let c = commit %} {% let show_unverified = true %} {% include "partials/verified_badge.html" %} </p> {{ diff_html|safe }} + {% if truncated && !show_full %} + <p class="kg-diff__truncated"> + Large diffs are not rendered by default. + Showing the first 50 of {{ total_lines }} lines. + <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/diff/{{ commit.id }}?full=1">Show full diff</a> + </p> + {% endif %} </section> {% endblock %} @@ -58,7 +58,7 @@ {% endif %} <span class="kg-muted"> — {{ c.author }}</span> </span> - <span class="kg-meta">{{ c.time }}</span> + <span class="kg-meta">{{ c.time_display() }}</span> </li> {% endfor %} </ul> @@ -104,7 +104,8 @@ {% if e.is_dir %} <span class="kg-icon" style="--icon:url('/static/icons/folder.svg')"></span> <a href="/{{ owner.username }}/{{ repo.name }}/tree/{{ current_branch }}/{{ e.path }}">{{ e.name }}</a> - <span class="kg-meta">{{ e.mode }}</span> + <span class="kg-tree__commit kg-meta" title="{{ e.commit_message }}">{{ e.commit_message }}</span> + <span class="kg-tree__time kg-meta">{{ e.commit_time }}</span> <a class="kg-tree__dl kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/archive.zip?ref={{ current_branch }}&path={{ e.path }}" title="Download {{ e.name }} as ZIP"> <span class="kg-icon" style="--icon:url('/static/icons/download.svg')"></span> <span class="kg-sr-only">Download</span> @@ -112,7 +113,8 @@ {% else %} <span class="kg-icon" style="--icon:url('/static/icons/file.svg')"></span> <a href="/{{ owner.username }}/{{ repo.name }}/blob/{{ current_branch }}/{{ e.path }}">{{ e.name }}</a> - <span class="kg-meta">{{ e.mode }}</span> + <span class="kg-tree__commit kg-meta" title="{{ e.commit_message }}">{{ e.commit_message }}</span> + <span class="kg-tree__time kg-meta">{{ e.commit_time }}</span> {% endif %} </li> {% endfor %} @@ -385,8 +385,8 @@ <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/danger/delete"> <div class="kg-danger-row"> - <label> - type <code>{{ repo.name }}</code> to delete + <label class="kg-danger-confirm"> + <span>type <code>{{ repo.name }}</code> to delete</span> <input type="text" name="confirm" required autocomplete="off" /> </label> <button class="kg-btn kg-btn--danger" type="submit">delete repository</button> @@ -27,7 +27,8 @@ {% if e.is_dir %} <span class="kg-icon" style="--icon:url('/static/icons/folder.svg')"></span> <a href="/{{ owner.username }}/{{ repo.name }}/tree/{{ branch }}/{{ e.path }}">{{ e.name }}</a> - <span class="kg-meta">{{ e.mode }}</span> + <span class="kg-tree__commit kg-meta" title="{{ e.commit_message }}">{{ e.commit_message }}</span> + <span class="kg-tree__time kg-meta">{{ e.commit_time }}</span> <a class="kg-tree__dl kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/archive.zip?ref={{ branch }}&path={{ e.path }}" title="Download {{ e.name }} as ZIP"> <span class="kg-icon" style="--icon:url('/static/icons/download.svg')"></span> <span class="kg-sr-only">Download</span> @@ -35,7 +36,8 @@ {% else %} <span class="kg-icon" style="--icon:url('/static/icons/file.svg')"></span> <a href="/{{ owner.username }}/{{ repo.name }}/blob/{{ branch }}/{{ e.path }}">{{ e.name }}</a> - <span class="kg-meta">{{ e.mode }}</span> + <span class="kg-tree__commit kg-meta" title="{{ e.commit_message }}">{{ e.commit_message }}</span> + <span class="kg-tree__time kg-meta">{{ e.commit_time }}</span> {% endif %} </li> {% endfor %} @@ -5,7 +5,13 @@ {% block content %} <section class="kg-section kg-auth"> {% if !signups_enabled %} - <div class="kg-flash kg-flash--banner">{{ signup_disabled_message }}</div> + <div class="kg-flash kg-flash--banner"> + {% if invite.is_empty() %} + {{ signup_disabled_message }} + {% else %} + Invite-only signup — your invite code unlocks registration. + {% endif %} + </div> {% endif %} <p class="kg-landing__brand"> <span class="kg-mark__glyph" aria-hidden="true">狐</span> @@ -15,12 +21,11 @@ {% if let Some(err) = error %} <div class="kg-flash">{{ err }}</div> {% endif %} - {% if signups_enabled || !invite.is_empty() %} <form class="kg-form" method="post" action="/auth/signup" autocomplete="on"> {% if !signups_enabled %} <label> invite code - <input type="text" name="invite" required value="{{ invite }}" autocomplete="off" spellcheck="false" /> + <input type="text" name="invite" required value="{{ invite }}" autocomplete="off" spellcheck="false" placeholder="Enter invite code" /> </label> {% endif %} <label> @@ -43,17 +48,6 @@ <button class="kg-btn" type="submit">create account</button> </div> </form> - {% else %} - <form class="kg-form" method="get" action="/auth/signup" style="margin-top:1rem"> - <label> - invite code - <input type="text" name="invite" required autocomplete="off" spellcheck="false" placeholder="Enter an invite code" /> - </label> - <div class="kg-actions"> - <button class="kg-btn" type="submit">continue</button> - </div> - </form> - {% endif %} <p class="kg-meta" style="margin-top:1.25rem"> Already have an account? <a href="/auth/login">Log in</a> </p>