git-ents.gitmain
⌘K
foforge
commit ebbb58e
feat: add Hooks, Issues, and Settings tabs

Complete the six-tab navigation with the remaining views. Hooks explains the git-hook CI model and renders the real .gitents/hooks.toml; run logs await a store that does not exist yet, so the run list is an empty state. Issues and Settings are design-accurate read-only shells — Settings reflects the repository’s actual name, description, branch, and feature state — pending their backing data stores.

feat: add Hooks tab rendering .gitents/hooks.toml with an empty run list feat: add Issues tab with filter chrome and an empty bug-report list feat: add read-only Settings tab for general, features, visibility, danger

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

crates/git-ents-server/src/web.rs @@ -324,6 +324,62 @@ .release-foot .sha { margin-left: auto; color: var(--color-text-muted); display: inline-flex; align-items: center; gap: .35rem; } .shell-note { color: var(--color-text-muted); font-size: .95rem; max-width: 44rem; margin-bottom: 1.5rem; } +.shell-note code { font-family: var(--font-mono); font-size: .85em; background: var(--color-code-bg); padding: .1rem .35rem; border-radius: 5px; } +.card-row.muted { color: var(--color-text-muted); } +.card-row.muted code { font-family: var(--font-mono); background: var(--color-code-bg); padding: .1rem .35rem; border-radius: 5px; } + +.hooks-grid { display: grid; grid-template-columns: 20rem minmax(0, 1fr); gap: 20px; align-items: start; } +.hooks-grid .card { margin-bottom: 0; } +.hooks-grid .blob { border: 0; border-radius: 0; box-shadow: none; margin: 0; } +@media (max-width: 800px) { .hooks-grid { grid-template-columns: 1fr; } } + +.issues-head { display: flex; align-items: center; gap: 1rem; margin-bottom: 1.5rem; padding-bottom: 1.25rem; border-bottom: 1px solid var(--color-border); position: relative; } +.issues-head::after { content: ""; position: absolute; bottom: -1px; left: 0; width: 3rem; height: 2px; background: var(--color-accent); border-radius: 1px; } +.issues-head .page-title { white-space: nowrap; } +.btn-primary { margin-left: auto; display: inline-flex; align-items: center; gap: .4rem; font-family: var(--font-sans); font-size: .85rem; font-weight: 600; color: var(--color-bg); background: var(--color-accent); border: none; border-radius: var(--radius-sm); padding: .45rem .9rem; cursor: pointer; transition: background .15s; } +.btn-primary:hover { background: var(--color-link-hover); } +.btn-primary .icon { color: currentColor; } +.filter-row { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin-bottom: 1.25rem; } +.filter-search { flex: 1; min-width: 14rem; position: relative; display: flex; align-items: center; } +.filter-search .icon { position: absolute; left: .65rem; color: var(--color-text-muted); } +.filter-search input { width: 100%; font-family: var(--font-sans); font-size: .85rem; color: var(--color-text); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: .42rem .7rem .42rem 2rem; } +.filter-search input:focus { outline: none; border-color: var(--color-accent); } +.chip { font-family: var(--font-mono); font-size: .76rem; color: var(--color-text-muted); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-pill); padding: .2rem .7rem; cursor: default; } +.chip.active { color: var(--color-bg); background: var(--color-accent); border-color: var(--color-accent); } +.card-header.subtabs { display: flex; gap: 1.25rem; text-transform: none; letter-spacing: 0; } +.subtab { display: inline-flex; align-items: center; gap: .35rem; color: var(--color-text-muted); } +.subtab.active { color: var(--color-text); font-weight: 600; } +.subtab .icon { width: 13px; height: 13px; } +.subtab.active .icon { color: var(--s-func); } + +.settings { max-width: 46rem; } +.field { padding: .85rem 1.1rem; } +.field + .field { border-top: 1px solid var(--color-border); } +.field label { display: block; font-size: .82rem; font-weight: 600; margin-bottom: .35rem; } +.field input, .field textarea, .field select { width: 100%; font-family: var(--font-sans); font-size: .9rem; color: var(--color-text); background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 8px; padding: .5rem .7rem; resize: vertical; } +.field input:focus, .field textarea:focus { outline: none; border-color: var(--color-accent); } +.field input[readonly], .field textarea[readonly] { color: var(--color-text-muted); } +.feature-row, .danger-row { display: flex; align-items: center; gap: 1rem; padding: .85rem 1.1rem; } +.feature-row + .feature-row, .danger-row + .danger-row { border-top: 1px solid var(--color-border); } +.feature-row > div:first-child, .danger-row > div:first-child { flex: 1; } +.feature-row p, .danger-row p { font-size: .82rem; margin: .1rem 0 0; } +.toggle { flex-shrink: 0; position: relative; width: 42px; height: 24px; border-radius: var(--radius-pill); background: var(--color-border); transition: background .18s; } +.toggle .knob { position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%; background: #fff; transition: left .18s; } +.toggle.on { background: var(--color-accent); } +.toggle.on .knob { left: 21px; } +.visibility-row { display: flex; align-items: flex-start; gap: .7rem; padding: .85rem 1.1rem; } +.visibility-row + .visibility-row { border-top: 1px solid var(--color-border); } +.visibility-row.sel { background: var(--color-accent-subtle); } +.visibility-row p { font-size: .82rem; margin: .1rem 0 0; } +.radio { flex-shrink: 0; margin-top: .2rem; width: 16px; height: 16px; border-radius: 50%; border: 1px solid var(--color-border); position: relative; } +.radio.on { border-color: var(--color-accent); } +.radio.on::after { content: ""; position: absolute; inset: 3px; border-radius: 50%; background: var(--color-accent); } +.card.danger { border-color: var(--s-keyword); } +.card.danger .card-header { color: var(--s-keyword); } +.btn-danger-outline, .btn-danger { font-family: var(--font-sans); font-size: .82rem; font-weight: 600; border-radius: var(--radius-sm); padding: .4rem .9rem; cursor: pointer; flex-shrink: 0; } +.btn-danger-outline { color: var(--s-keyword); background: transparent; border: 1px solid var(--s-keyword); } +.btn-danger-outline:hover { background: var(--diff-del); } +.btn-danger { color: #fff; background: var(--s-keyword); border: none; } .site-footer { border-top: 1px solid var(--color-border); color: var(--color-text-muted); font-size: .8rem; margin-top: auto; } .footer-inner { max-width: var(--max-width); margin: 0 auto; padding: 2rem 1.5rem; text-align: center; } @@ -395,6 +451,9 @@ Some((&"blob", sub)) => blob_page(repo, &meta, sub).await, Some((&"commit", &[sha])) => commit_page(repo, &meta, sha).await, Some((&"releases", &[])) => releases_page(repo, &meta).await.into_response(), + Some((&"hooks", &[])) => hooks_page(repo, &meta).await.into_response(), + Some((&"issues", &[])) => issues_page(&meta).into_response(), + Some((&"settings", &[])) => settings_page(&meta).into_response(), _ => not_found().into_response(), } } @@ -1368,6 +1427,174 @@ ) } +/// The Hooks tab: CI is defined as plain git hooks, configured in +/// `.gitents/hooks.toml`. Run logs need a store that does not exist yet, so the +/// run list is an empty state; the configuration shown is the real file. +async fn hooks_page(repo: &Path, meta: &RepoMeta) -> Markup { + let config = git_output_bytes(repo, &["cat-file", "-p", "HEAD:.gitents/hooks.toml"]) + .await + .map(|b| String::from_utf8_lossy(&b).into_owned()); + repo_shell( + meta, + Tab::Hooks, + "Hooks", + html! { + div.page-header { h1.page-title { "Hooks" } } + p.shell-note { + "CI runs as plain " + code { "pre-receive" } " / " code { "post-receive" } + " git hooks, configured in " code { ".gitents/hooks.toml" } + ". Runs and logs appear here once hooks execute on a push." + } + div.hooks-grid { + div.card { + div.card-header { "Recent runs" } + div.card-row.muted { "No runs recorded yet." } + } + @if let Some(config) = &config { + div.card { + div.card-header { (icon_file()) " .gitents/hooks.toml" } + (blob_body("hooks.toml", config)) + } + } @else { + div.card { + div.card-header { "Configuration" } + div.card-row.muted { "No " code { ".gitents/hooks.toml" } " in this repository." } + } + } + } + }, + ) +} + +/// The Issues ("Bug reports") tab. There is no issue store yet, so the filters +/// are present for the design and the list is an empty state. +fn issues_page(meta: &RepoMeta) -> Markup { + repo_shell( + meta, + Tab::Issues, + "Bug reports", + html! { + div.issues-head { + h1.page-title { "Bug reports" } + button.btn-primary type="button" { (icon_plus()) "New issue" } + } + div.filter-row { + div.filter-search { + (icon_search()) + input type="search" placeholder="Filter bug reports" aria-label="Filter"; + } + span.chip.active { "All" } + span.chip { "bug" } + span.chip { "enhancement" } + span.chip { "question" } + } + div.card { + div.card-header.subtabs { + span.subtab.active { (icon_issue()) "Open" span.tab-count { "0" } } + span.subtab { (icon_check()) "Closed" span.tab-count { "0" } } + } + div.blankslate { + h2 { "No bug reports yet" } + p { "Open one to start tracking a bug." } + } + } + }, + ) +} + +/// The Settings tab. Persisting changes needs a config store that does not +/// exist yet, so the controls reflect the repository's current real values and +/// are presented read-only. +fn settings_page(meta: &RepoMeta) -> Markup { + let name = meta.rel.rsplit('/').next().unwrap_or(&meta.rel); + repo_shell( + meta, + Tab::Settings, + "Repository settings", + html! { + div.settings { + div.page-header { h1.page-title { "Repository settings" } } + p.shell-note { "These reflect the repository's current configuration." } + + div.card { + div.card-header { "General" } + div.field { + label { "Repository name" } + input type="text" value=(name) readonly; + } + div.field { + label { "Description" } + textarea rows="2" readonly { (meta.description.as_deref().unwrap_or_default()) } + } + div.field { + label { "Default branch" } + input type="text" value=(meta.branch.as_deref().unwrap_or("—")) readonly; + } + } + + div.card { + div.card-header { "Features" } + (feature_row("Bug reports", "Track and triage bugs.", meta.issues > 0)) + (feature_row("Releases", "Publish tagged releases.", meta.releases > 0)) + (feature_row("Hooks (CI)", "Run git hooks on push.", meta.has_hooks)) + (feature_row("Wiki", "A separate documentation space.", false)) + } + + div.card { + div.card-header { "Visibility" } + (visibility_row("Public", "Anyone can read this repository.", true)) + (visibility_row("Private", "Only collaborators can read it.", false)) + } + + div.card.danger { + div.card-header { "Danger zone" } + div.danger-row { + div { + strong { "Archive this repository" } + p.muted { "Make it read-only." } + } + button.btn-danger-outline type="button" { "Archive" } + } + div.danger-row { + div { + strong { "Delete this repository" } + p.muted { "This cannot be undone." } + } + button.btn-danger type="button" { "Delete" } + } + } + } + }, + ) +} + +/// A Features row with a static toggle reflecting `on`. +fn feature_row(title: &str, desc: &str, on: bool) -> Markup { + html! { + div.feature-row { + div { + strong { (title) } + p.muted { (desc) } + } + span.toggle.on[on] { span.knob {} } + } + } +} + +/// A Visibility radio row, selected when `on`. +fn visibility_row(title: &str, desc: &str, on: bool) -> Markup { + html! { + div.visibility-row.sel[on] { + span.radio.on[on] {} + div { + strong { (title) } + p.muted { (desc) } + } + } + } +} + /// Render a unified diff, coloring each line by its leading marker. fn diff_view(patch: &str) -> Markup { if patch.trim().is_empty() { @@ -1532,6 +1759,24 @@ ) } +fn icon_plus() -> Markup { + svg( + "M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z", + ) +} + +fn icon_issue() -> Markup { + svg( + "M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z", + ) +} + +fn icon_check() -> Markup { + svg( + "M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z", + ) +} + fn icon_chevron() -> Markup { svg( "M6.22 3.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L9.94 8 6.22 4.28a.75.75 0 0 1 0-1.06Z",