max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
12 files changed,
+758 insertions,
-115 deletions
| @@ -10759,6 +10759,18 @@ | |||
| 10759 | 10759 | "pkg-config", | |
| 10760 | 10760 | ] | |
| 10761 | 10761 | ||
| 10762 | + | [[patch.unused]] | |
| 10763 | + | name = "kberg" | |
| 10764 | + | version = "0.1.0" | |
| 10765 | + | ||
| 10766 | + | [[patch.unused]] | |
| 10767 | + | name = "ops-status" | |
| 10768 | + | version = "0.1.0" | |
| 10769 | + | ||
| 10770 | + | [[patch.unused]] | |
| 10771 | + | name = "painhours" | |
| 10772 | + | version = "0.1.0" | |
| 10773 | + | ||
| 10762 | 10774 | [[patch.unused]] | |
| 10763 | 10775 | name = "synckit-client" | |
| 10764 | 10776 | version = "0.10.0" | |
| @@ -10782,15 +10794,3 @@ | |||
| 10782 | 10794 | [[patch.unused]] | |
| 10783 | 10795 | name = "quasi-tauri" | |
| 10784 | 10796 | version = "0.99.0" | |
| 10785 | - | ||
| 10786 | - | [[patch.unused]] | |
| 10787 | - | name = "kberg" | |
| 10788 | - | version = "0.1.0" | |
| 10789 | - | ||
| 10790 | - | [[patch.unused]] | |
| 10791 | - | name = "ops-status" | |
| 10792 | - | version = "0.1.0" | |
| 10793 | - | ||
| 10794 | - | [[patch.unused]] | |
| 10795 | - | name = "painhours" | |
| 10796 | - | version = "0.1.0" |
| @@ -6398,26 +6398,44 @@ | |||
| 6398 | 6398 | .git-linked-project { margin-top: var(--gap-peer); font-size: var(--text-note); } | |
| 6399 | 6399 | .git-linked-project a { color: var(--content); } | |
| 6400 | 6400 | ||
| 6401 | - | /* Ref bar (branch/tag selector + nav links) */ | |
| 6402 | - | .git-ref-bar { | |
| 6401 | + | /* Ref bar (branch/tag chooser + the places the repo has). | |
| 6402 | + | ||
| 6403 | + | Described: `crate::quasi::widgets::git_nav` emits the region, the field and | |
| 6404 | + | the list, so these rules dress the renderer's own class names rather than | |
| 6405 | + | hand-written ones. The strip is a list drawn across, which is where "how a | |
| 6406 | + | set is laid out" has always lived. */ | |
| 6407 | + | #git-nav { margin-bottom: var(--gap-section); font-size: var(--text-note); } | |
| 6408 | + | /* The run already lays out across and wraps; only the rhythm is the app's. */ | |
| 6409 | + | #git-nav .run { gap: var(--gap-section); } | |
| 6410 | + | /* The label reads beside the control rather than over it: the bar is one line | |
| 6411 | + | and a stacked label would set its height. */ | |
| 6412 | + | #git-nav .form-group { | |
| 6403 | 6413 | display: flex; | |
| 6404 | 6414 | align-items: center; | |
| 6405 | - | gap: var(--gap-section); | |
| 6406 | - | margin-bottom: var(--gap-section); | |
| 6407 | - | font-size: var(--text-note); | |
| 6415 | + | gap: var(--gap-peer); | |
| 6416 | + | margin: 0; | |
| 6408 | 6417 | } | |
| 6409 | - | .git-ref-select { | |
| 6418 | + | #git-nav .form-label { margin: 0; white-space: nowrap; } | |
| 6419 | + | #git-nav select.field { | |
| 6420 | + | width: auto; | |
| 6410 | 6421 | font-family: var(--font-mono); | |
| 6411 | 6422 | font-size: var(--text-fine); | |
| 6412 | 6423 | padding: var(--gap-bound) var(--gap-peer); | |
| 6413 | - | background: var(--surface-overlay); | |
| 6414 | 6424 | border: 1px solid var(--border); | |
| 6415 | 6425 | border-radius: var(--radius-control); | |
| 6416 | 6426 | } | |
| 6417 | - | .git-nav-links { display: flex; gap: var(--gap-section); } | |
| 6418 | - | .git-nav-links a { text-decoration: none; color: var(--content); opacity: 0.6; } | |
| 6419 | - | .git-nav-links a:hover, | |
| 6420 | - | .git-nav-links a.is-selected { opacity: 1; } | |
| 6427 | + | /* The strip is the list drawn across. Everything else about a list -- the | |
| 6428 | + | markers, the box -- is the design system's and is not restated here. */ | |
| 6429 | + | #git-nav .list { display: flex; gap: var(--gap-section); } | |
| 6430 | + | #git-nav .row { | |
| 6431 | + | margin: 0; | |
| 6432 | + | padding: 0; | |
| 6433 | + | border: 0; | |
| 6434 | + | background: none; | |
| 6435 | + | } | |
| 6436 | + | #git-nav .row-activate { text-decoration: none; color: var(--content); opacity: 0.6; } | |
| 6437 | + | #git-nav .row-activate:hover, | |
| 6438 | + | #git-nav .row-current .row-activate { opacity: 1; } | |
| 6421 | 6439 | ||
| 6422 | 6440 | /* Breadcrumb navigation */ | |
| 6423 | 6441 | .git-breadcrumb { | |
| @@ -6700,28 +6718,28 @@ | |||
| 6700 | 6718 | .git-replace-table td { padding: var(--gap-peer) var(--gap-section) var(--gap-peer) 0; border-bottom: 1px solid var(--border); } | |
| 6701 | 6719 | .git-replace-table a { color: var(--content); text-decoration: none; } | |
| 6702 | 6720 | .git-replace-table a:hover { text-decoration: underline; } | |
| 6703 | - | /* Git notes (refs/notes/*) shown against the object they annotate */ | |
| 6704 | - | .git-notes { margin-bottom: var(--gap-pane); } | |
| 6705 | - | .git-note { | |
| 6721 | + | /* Git notes (refs/notes/*) shown against the object they annotate. | |
| 6722 | + | ||
| 6723 | + | Described: `crate::quasi::widgets::git_notes` emits the panel, so these rules | |
| 6724 | + | dress the renderer's classes. The body is the one part that is still the | |
| 6725 | + | app's own markup -- it comes out of docengine already -- and it keeps | |
| 6726 | + | `.git-note-body`, which the annotations panel writes too. */ | |
| 6727 | + | #notes { margin-bottom: var(--gap-pane); } | |
| 6728 | + | #notes > .region { | |
| 6706 | 6729 | border: 1px solid var(--border); | |
| 6707 | 6730 | border-left: 2px solid var(--content); | |
| 6708 | 6731 | margin-bottom: var(--gap-section); | |
| 6709 | 6732 | } | |
| 6710 | - | .git-note-header { | |
| 6711 | - | display: flex; | |
| 6712 | - | align-items: baseline; | |
| 6713 | - | justify-content: space-between; | |
| 6714 | - | gap: var(--gap-group); | |
| 6733 | + | /* The header is the note's leading row: the namespace on the left and where it | |
| 6734 | + | came from on the right. The auto margin is what puts it there; the shipped | |
| 6735 | + | rule used `space-between` on a header that held its own elements. */ | |
| 6736 | + | #notes > .region > .region .run { | |
| 6715 | 6737 | padding: var(--gap-peer) var(--gap-section); | |
| 6716 | 6738 | border-bottom: 1px solid var(--border); | |
| 6717 | 6739 | font-size: var(--text-fine); | |
| 6718 | 6740 | } | |
| 6719 | - | .git-note-namespace { font-family: var(--font-mono); } | |
| 6720 | - | /* Sits beside the namespace rather than being spread across the header: the | |
| 6721 | - | auto margin absorbs the free space `space-between` would otherwise put | |
| 6722 | - | between the two, leaving the attribution on the right where it was. */ | |
| 6723 | - | .git-note-owned { margin-right: auto; opacity: 0.6; } | |
| 6724 | - | .git-note-attribution { opacity: 0.6; } | |
| 6741 | + | #notes > .region > .region .run > :first-child { margin-right: auto; } | |
| 6742 | + | #notes .text { margin: 0; opacity: 0.6; } | |
| 6725 | 6743 | .git-note-namespaces { | |
| 6726 | 6744 | display: flex; | |
| 6727 | 6745 | flex-wrap: wrap; | |
| @@ -6983,7 +7001,7 @@ | |||
| 6983 | 7001 | ||
| 6984 | 7002 | @media (max-width: 839px) { | |
| 6985 | 7003 | /* Git browser mobile */ | |
| 6986 | - | .git-ref-bar { flex-wrap: wrap; gap: var(--gap-peer); } | |
| 7004 | + | #git-nav .run { gap: var(--gap-peer); } | |
| 6987 | 7005 | .git-repo-name { font-size: var(--text-subhead); } | |
| 6988 | 7006 | .git-clone-url { font-size: var(--text-fine); word-break: break-all; } | |
| 6989 | 7007 | .git-tree .icon { display: none; } |
| @@ -1,20 +1,13 @@ | |||
| 1 | - | <div class="git-ref-bar"> | |
| 2 | - | <select class="git-ref-select" data-change="nav-to-value"> | |
| 3 | - | {% for r in refs %} | |
| 4 | - | <option value="/git/{{ owner }}/{{ repo_name }}/tree/{{ r.name }}" | |
| 5 | - | {% if r.name == current_ref %}selected{% endif %}> | |
| 6 | - | {% if r.is_branch %}{% else %}tag: {% endif %}{{ r.name }} | |
| 7 | - | </option> | |
| 8 | - | {% endfor %} | |
| 9 | - | </select> | |
| 10 | - | <nav class="git-nav-links"> | |
| 11 | - | <a href="/git/{{ owner }}/{{ repo_name }}/tree/{{ current_ref }}"{% if active_tab == "files" %} class="is-selected"{% endif %}>Files</a> | |
| 12 | - | <a href="/git/{{ owner }}/{{ repo_name }}/commits/{{ current_ref }}"{% if active_tab == "commits" || active_tab == "commit" %} class="is-selected"{% endif %}>Commits</a> | |
| 13 | - | <a href="/git/{{ owner }}/{{ repo_name }}/tags"{% if active_tab == "tags" %} class="is-selected"{% endif %}>Tags</a> | |
| 14 | - | <a href="/git/{{ owner }}/{{ repo_name }}/notes"{% if active_tab == "notes" %} class="is-selected"{% endif %}>Notes</a> | |
| 15 | - | <a href="/git/{{ owner }}/{{ repo_name }}/issues"{% if active_tab == "issues" %} class="is-selected"{% endif %}>Issues{% if open_issue_count > 0 %} ({{ open_issue_count }}){% endif %}</a> | |
| 16 | - | {% if is_owner %} | |
| 17 | - | <a href="/git/{{ owner }}/{{ repo_name }}/settings"{% if active_tab == "settings" %} class="is-selected"{% endif %}>Settings</a> | |
| 18 | - | {% endif %} | |
| 19 | - | </nav> | |
| 20 | - | </div> | |
| 1 | + | {# The repository nav bar. Described, not written: the members and the | |
| 2 | + | reasoning are in `crate::quasi::widgets::git_nav`, which the git pages will | |
| 3 | + | embed directly as they convert. Expects `refs`, `owner`, `repo_name`, | |
| 4 | + | `current_ref`, `active_tab`, `open_issue_count` and `is_owner`. #} | |
| 5 | + | {{ crate::quasi::widgets::git_nav::html(&crate::quasi::widgets::git_nav::Nav { | |
| 6 | + | owner: owner, | |
| 7 | + | repo: repo_name, | |
| 8 | + | current_ref: current_ref, | |
| 9 | + | active_tab: active_tab, | |
| 10 | + | open_issue_count: *open_issue_count, | |
| 11 | + | is_owner: *is_owner, | |
| 12 | + | refs: refs, | |
| 13 | + | })|safe }} |
| @@ -1,29 +1,5 @@ | |||
| 1 | - | {# Notes on the object a page is showing. Expects `notes: Vec<CommitNote>`. | |
| 2 | - | Shared by the commit page (notes on the commit) and the file view (notes on | |
| 3 | - | the blob), so the two cannot drift. #} | |
| 4 | - | {% if !notes.is_empty() %} | |
| 5 | - | <div class="git-notes" id="notes"> | |
| 6 | - | {% for note in notes %} | |
| 7 | - | <div class="git-note"> | |
| 8 | - | <div class="git-note-header"> | |
| 9 | - | <span class="git-note-namespace">{{ note.namespace }}</span> | |
| 10 | - | {% if note.read_only %} | |
| 11 | - | {# Says where it came from, so a note nobody on the repo wrote does | |
| 12 | - | not read as one of theirs they have forgotten. #} | |
| 13 | - | <span class="git-note-owned">written by makenot.work</span> | |
| 14 | - | {% endif %} | |
| 15 | - | {% if let Some(a) = note.attribution %} | |
| 16 | - | <span class="git-note-attribution"> | |
| 17 | - | {% if a.exact %} | |
| 18 | - | {{ a.by }} · {{ a.when }} | |
| 19 | - | {% else %} | |
| 20 | - | edited since {{ a.short_commit }} · {{ a.when }} | |
| 21 | - | {% endif %} | |
| 22 | - | </span> | |
| 23 | - | {% endif %} | |
| 24 | - | </div> | |
| 25 | - | <div class="git-note-body git-readme-body">{{ note.html|safe }}</div> | |
| 26 | - | </div> | |
| 27 | - | {% endfor %} | |
| 28 | - | </div> | |
| 29 | - | {% endif %} | |
| 1 | + | {# Notes on the object a page is showing. Described, not written: the members | |
| 2 | + | and the reasoning are in `crate::quasi::widgets::git_notes`. Shared by the | |
| 3 | + | commit page (notes on the commit) and the file view (notes on the blob), so | |
| 4 | + | the two cannot drift. Expects `notes: Vec<CommitNote>`. #} | |
| 5 | + | {{ crate::quasi::widgets::git_notes::html(notes)|safe }} |
| @@ -60,3 +60,5 @@ | |||
| 60 | 60 | ||
| 61 | 61 | pub mod carousel; | |
| 62 | 62 | pub mod code; | |
| 63 | + | pub mod git_nav; | |
| 64 | + | pub mod git_notes; |
| @@ -1023,3 +1023,59 @@ | |||
| 1023 | 1023 | active_tab: "files", | |
| 1024 | 1024 | }) | |
| 1025 | 1025 | } | |
| 1026 | + | ||
| 1027 | + | /// What the ref chooser submits. See [`crate::quasi::widgets::git_nav`]. | |
| 1028 | + | #[derive(Deserialize)] | |
| 1029 | + | pub(super) struct RefChoice { | |
| 1030 | + | #[serde(rename = "ref")] | |
| 1031 | + | pub chosen: String, | |
| 1032 | + | } | |
| 1033 | + | ||
| 1034 | + | /// `GET /git/{owner}/{repo}/ref?ref=`: the ref chooser's destination. | |
| 1035 | + | /// | |
| 1036 | + | /// The chooser is a described field, so its answer arrives as a parameter, | |
| 1037 | + | /// while the canonical address of a tree is a path segment. This is the join, | |
| 1038 | + | /// and it answers a redirect rather than a page so that there stays exactly one | |
| 1039 | + | /// address for the content: the reader lands on `/tree/{ref}` and that is what | |
| 1040 | + | /// their history, their bookmark and any link they copy will hold. | |
| 1041 | + | /// | |
| 1042 | + | /// The chosen ref is checked against the repository's own refs rather than | |
| 1043 | + | /// against a character rule. Anything the chooser can submit is in that list by | |
| 1044 | + | /// construction, so the check costs nothing a reader will ever meet, and it is | |
| 1045 | + | /// what keeps a hand-written parameter from reaching the `Location` header. | |
| 1046 | + | #[tracing::instrument(skip_all, name = "git::ref_chosen")] | |
| 1047 | + | pub(super) async fn ref_chosen( | |
| 1048 | + | State(db): State<PgPool>, | |
| 1049 | + | State(config): State<Config>, | |
| 1050 | + | MaybeUserVerified(maybe_user): MaybeUserVerified, | |
| 1051 | + | headers: axum::http::HeaderMap, | |
| 1052 | + | Path((owner, repo_name)): Path<(String, String)>, | |
| 1053 | + | ValidatedQuery(choice): ValidatedQuery<RefChoice>, | |
| 1054 | + | ) -> Result<Response> { | |
| 1055 | + | let resolved = resolve_repo( | |
| 1056 | + | &db, | |
| 1057 | + | &config, | |
| 1058 | + | &owner, | |
| 1059 | + | &repo_name, | |
| 1060 | + | maybe_user.as_ref().map(|u| u.id), | |
| 1061 | + | ) | |
| 1062 | + | .await?; | |
| 1063 | + | ||
| 1064 | + | let chosen = choice.chosen.clone(); | |
| 1065 | + | let known = resolved | |
| 1066 | + | .with_repo(move |gix_repo| { | |
| 1067 | + | Ok(git::list_refs(gix_repo) | |
| 1068 | + | .into_iter() | |
| 1069 | + | .any(|r| r.name == chosen)) | |
| 1070 | + | }) | |
| 1071 | + | .await?; | |
| 1072 | + | ||
| 1073 | + | if !known { | |
| 1074 | + | return Err(AppError::NotFound); | |
| 1075 | + | } | |
| 1076 | + | ||
| 1077 | + | Ok(crate::helpers::redirect_to( | |
| 1078 | + | &headers, | |
| 1079 | + | &format!("/git/{owner}/{repo_name}/tree/{}", choice.chosen), | |
| 1080 | + | )) | |
| 1081 | + | } |
| @@ -60,6 +60,10 @@ | |||
| 60 | 60 | "/git/{owner}/{repo}/blame/{ref}/{*path}", | |
| 61 | 61 | get(browsing::blame_view), | |
| 62 | 62 | ) | |
| 63 | + | // The ref chooser's destination. A control's address, not a page's: | |
| 64 | + | // it answers a redirect to the canonical tree URL. See | |
| 65 | + | // `crate::quasi::widgets::git_nav`. | |
| 66 | + | .route("/git/{owner}/{repo}/ref", get(browsing::ref_chosen)) | |
| 63 | 67 | .route("/git/{owner}/{repo}/tags", get(browsing::tags_tab)) | |
| 64 | 68 | .route("/git/{owner}/{repo}/replace", get(browsing::replace_tab)) | |
| 65 | 69 | .route("/git/{owner}/{repo}/notes", get(browsing::notes_tab)) |
| @@ -12,16 +12,15 @@ | |||
| 12 | 12 | <a href="/git/{{ owner }}/{{ repo_name }}">{{ repo_name }}</a> | |
| 13 | 13 | </h1> | |
| 14 | 14 | ||
| 15 | - | <div class="git-ref-bar"> | |
| 16 | - | <nav class="git-nav-links"> | |
| 17 | - | <a href="/git/{{ owner }}/{{ repo_name }}/tree/{{ current_ref }}">Files</a> | |
| 18 | - | <a href="/git/{{ owner }}/{{ repo_name }}/commits/{{ current_ref }}">Commits</a> | |
| 19 | - | <a href="/git/{{ owner }}/{{ repo_name }}/issues" class="is-selected">Issues ({{ open_issue_count }})</a> | |
| 20 | - | {% if is_owner %} | |
| 21 | - | <a href="/git/{{ owner }}/{{ repo_name }}/settings">Settings</a> | |
| 22 | - | {% endif %} | |
| 23 | - | </nav> | |
| 24 | - | </div> | |
| 15 | + | {{ crate::quasi::widgets::git_nav::html(&crate::quasi::widgets::git_nav::Nav { | |
| 16 | + | owner: owner, | |
| 17 | + | repo: repo_name, | |
| 18 | + | current_ref: current_ref, | |
| 19 | + | active_tab: "issues", | |
| 20 | + | open_issue_count: *open_issue_count, | |
| 21 | + | is_owner: *is_owner, | |
| 22 | + | refs: crate::quasi::widgets::git_nav::NO_REFS, | |
| 23 | + | })|safe }} | |
| 25 | 24 | ||
| 26 | 25 | <div class="issue-detail"> | |
| 27 | 26 | <div class="issue-detail-header"> |
| @@ -12,16 +12,15 @@ | |||
| 12 | 12 | <a href="/git/{{ owner }}/{{ repo_name }}">{{ repo_name }}</a> | |
| 13 | 13 | </h1> | |
| 14 | 14 | ||
| 15 | - | <div class="git-ref-bar"> | |
| 16 | - | <nav class="git-nav-links"> | |
| 17 | - | <a href="/git/{{ owner }}/{{ repo_name }}/tree/{{ current_ref }}">Files</a> | |
| 18 | - | <a href="/git/{{ owner }}/{{ repo_name }}/commits/{{ current_ref }}">Commits</a> | |
| 19 | - | <a href="/git/{{ owner }}/{{ repo_name }}/issues" class="is-selected">Issues ({{ open_count }})</a> | |
| 20 | - | {% if is_owner %} | |
| 21 | - | <a href="/git/{{ owner }}/{{ repo_name }}/settings">Settings</a> | |
| 22 | - | {% endif %} | |
| 23 | - | </nav> | |
| 24 | - | </div> | |
| 15 | + | {{ crate::quasi::widgets::git_nav::html(&crate::quasi::widgets::git_nav::Nav { | |
| 16 | + | owner: owner, | |
| 17 | + | repo: repo_name, | |
| 18 | + | current_ref: current_ref, | |
| 19 | + | active_tab: "issues", | |
| 20 | + | open_issue_count: *open_count, | |
| 21 | + | is_owner: *is_owner, | |
| 22 | + | refs: crate::quasi::widgets::git_nav::NO_REFS, | |
| 23 | + | })|safe }} | |
| 25 | 24 | ||
| 26 | 25 | <div class="issue-header"> | |
| 27 | 26 | <div class="issue-tabs"> |