michal/tit
Diff
a76e41a09078 → ee64ad60d39b
README.md
Mode 100644 → 100644; object e9c9248eb7d4 → 969ff20eac8e
@@ -292,3 +292,5 @@ ``` `tit` is licensed under the MIT License. See [LICENSE](LICENSE). +Space Mono is licensed under the SIL Open Font License, Version 1.1. See +[assets/SpaceMono-LICENSE.txt](assets/SpaceMono-LICENSE.txt).
assets/SpaceMono-Bold.ttf
Mode → 100644; object → 7c38f236f12f
Binary content changed.
assets/SpaceMono-LICENSE.txt
Mode → 100644; object → 389d65c1c36d
@@ -1,0 +1,93 @@ +Copyright 2016 The Space Mono Project Authors (https://github.com/googlefonts/spacemono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE.
assets/SpaceMono-Regular.ttf
Mode → 100644; object → 8bf82cece1da
Binary content changed.
assets/style.css
Mode 100644 → 100644; object f66bd67e6025 → 751412b5213b
@@ -1,40 +1,240 @@
+@font-face {
+ font-family: "Space Mono";
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url("/assets/SpaceMono-Regular.ttf") format("truetype");
+}
+
+@font-face {
+ font-family: "Space Mono";
+ font-style: normal;
+ font-weight: 700;
+ font-display: swap;
+ src: url("/assets/SpaceMono-Bold.ttf") format("truetype");
+}
+
:root {
- color-scheme: light dark;
- font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
- line-height: 1.5;
- background: Canvas;
- color: CanvasText;
+ color-scheme: dark;
+ --bg: #000;
+ --surface: #080808;
+ --surface-raised: #101010;
+ --panel: rgb(0 0 0 / 88%);
+ --fg: #e5e5e5;
+ --fg-dim: #999;
+ --fg-muted: #666;
+ --border: #333;
+ --border-strong: #777;
+ --error: #ffb4ab;
+ font-family: "Space Mono", ui-monospace, "SFMono-Regular", Consolas, monospace;
+ font-size: 16px;
+ line-height: 1.6;
+ background: var(--bg);
+ color: var(--fg);
}
* {
box-sizing: border-box;
}
+html {
+ min-width: 20rem;
+ background: var(--bg);
+}
+
body {
- width: min(100% - 2rem, 64rem);
- margin: 0 auto;
+ min-height: 100vh;
+ margin: 0;
+ padding: 2rem;
+ background: var(--bg);
+ color: var(--fg);
+ display: flex;
+ flex-direction: column;
+}
+
+a {
+ color: var(--fg);
+ text-decoration: none;
+}
+
+a:hover {
+ text-decoration: underline;
+ text-underline-offset: 0.18em;
+}
+
+:focus-visible {
+ outline: 0.125rem solid var(--fg);
+ outline-offset: 0.25rem;
+}
+
+code,
+pre,
+input,
+textarea,
+select,
+button {
+ font: inherit;
+}
+
+code {
+ color: var(--fg);
+ font-size: 0.875em;
+ overflow-wrap: anywhere;
+}
+
+h1,
+h2,
+h3 {
+ line-height: 1.25;
+}
+
+h1 {
+ margin-block: 0 1rem;
+ font-size: clamp(2rem, 5vw, 3.5rem);
+ letter-spacing: -0.04em;
+}
+
+h2 {
+ margin-block: 0 1rem;
+ font-size: 1rem;
+}
+
+h3 {
+ margin-block: 1.5rem 0.5rem;
+ font-size: 0.875rem;
+}
+
+p {
+ max-width: 72ch;
}
.site-header {
display: flex;
flex-wrap: wrap;
- align-items: baseline;
+ align-items: center;
justify-content: space-between;
- gap: 1rem;
- padding: 1rem 0;
- border-bottom: 1px solid GrayText;
+ gap: 1rem 2rem;
+ margin-bottom: 1rem;
+}
+
+.site-header-rule {
+ width: 100%;
+ margin: 0 0 4rem;
+ border: 0;
+ border-top: 1px solid var(--border);
}
.brand {
- color: inherit;
- font-size: 1.5rem;
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ color: var(--fg);
+ font-size: 1.25rem;
font-weight: 700;
text-decoration: none;
}
+.brand-icon {
+ width: 1.5rem;
+ height: 1.5rem;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 2;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.site-header nav,
+.repository-header nav,
+.pagination {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem 1rem;
+}
+
+.site-header nav {
+ align-items: center;
+ justify-content: flex-end;
+ font-size: 0.75rem;
+}
+
+.site-header .icon-link {
+ display: grid;
+ width: 2.25rem;
+ height: 2.25rem;
+ border: 1px solid transparent;
+ color: var(--fg-dim);
+ place-items: center;
+}
+
+.icon-link {
+ position: relative;
+}
+
+.icon-link::after {
+ position: absolute;
+ top: calc(100% + 0.45rem);
+ right: 0;
+ z-index: 4;
+ width: max-content;
+ max-width: 12rem;
+ padding: 0.25rem 0.4rem;
+ border: 1px solid var(--border);
+ background: var(--bg);
+ color: var(--fg);
+ content: attr(aria-label);
+ font-size: 0.65rem;
+ opacity: 0;
+ pointer-events: none;
+ transform: translateY(-0.2rem);
+ transition:
+ opacity 120ms ease,
+ transform 120ms ease;
+}
+
+.icon-link:hover::after,
+.icon-link:focus-visible::after {
+ opacity: 1;
+ transform: translateY(0);
+}
+
+.site-header .icon-link:hover {
+ border-color: var(--border);
+ color: var(--fg);
+ text-decoration: none;
+}
+
+.icon-link svg,
+.account-options svg {
+ width: 1.25rem;
+ height: 1.25rem;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 2;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
main {
- min-height: 65vh;
- padding: 2rem 0;
+ width: 100%;
+ max-width: 68.75rem;
+ flex: 1;
+}
+
+footer {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ gap: 0.5rem 2rem;
+ margin-top: 4rem;
+ padding: 1rem 0 0;
+ border-top: 1px solid var(--border);
+ color: var(--fg-muted);
+ font-size: 0.75rem;
+}
+
+footer code {
+ color: var(--fg-dim);
}
main > article,
@@ -42,20 +242,21 @@
main > dl,
main > form,
main > ol,
-main > section:not(.repository-panel),
+main > section:not(.repository-panel):not(.home-workflow),
main > ul,
main > .field,
main > .table-scroll,
-.two-column > section {
+.two-column > section,
+.auth-panel {
margin-block: 1.5rem;
- padding: 1rem;
- border: 1px solid GrayText;
- border-radius: 0.25rem;
+ padding: 1.25rem;
+ border: 1px solid var(--border);
+ background: var(--panel);
}
main > ol,
main > ul {
- padding-inline-start: 2.75rem;
+ padding-inline-start: 3rem;
}
main > article > :first-child,
@@ -63,10 +264,11 @@
main > dl > :first-child,
main > form > :first-child,
main > ol > :first-child,
-main > section:not(.repository-panel) > :first-child,
+main > section > :first-child,
main > ul > :first-child,
main > .field > :first-child,
-.two-column > section > :first-child {
+.two-column > section > :first-child,
+.auth-panel > :first-child {
margin-top: 0;
}
@@ -75,75 +277,268 @@
main > dl > :last-child,
main > form > :last-child,
main > ol > :last-child,
-main > section:not(.repository-panel) > :last-child,
+main > section > :last-child,
main > ul > :last-child,
main > .field > :last-child,
-.two-column > section > :last-child {
+.two-column > section > :last-child,
+.auth-panel > :last-child {
margin-bottom: 0;
}
-main > .table-scroll {
+.two-column {
+ display: grid;
+ grid-template-columns: minmax(0, 1.2fr) minmax(18rem, 0.8fr);
+ gap: 3rem;
+ align-items: start;
+}
+
+.home-intro {
+ min-height: 20rem;
+ align-self: stretch;
+}
+
+.home-grid > section {
+ display: flex;
+ height: 23rem;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.home-grid .home-repository-list {
+ min-height: 0;
+ overflow-y: auto;
+}
+
+.home-intro h1 {
+ max-width: 12ch;
+}
+
+.home-intro > p {
+ color: var(--fg-dim);
+}
+
+.pagination a {
+ display: inline-block;
+ padding: 0.55rem 0.75rem;
+ border: 1px solid var(--border-strong);
+ font-size: 0.75rem;
+}
+
+.pagination a:hover {
+ border-color: var(--fg);
+ text-decoration: none;
+}
+
+.home-repository-list,
+.issue-list,
+.repository-list,
+.activity-list,
+.key-list {
+ margin: 0;
padding: 0;
+ list-style: none;
}
-main > .table-scroll th,
-main > .table-scroll td {
- padding: 0.6rem 1rem;
+.home-repository-list li,
+.issue-list li,
+.repository-list li,
+.activity-list li {
+ padding-block: 0.9rem;
+ border-bottom: 1px solid var(--border);
}
-main > .table-scroll tbody tr:last-child > * {
+.home-repository-list li:last-child,
+.issue-list li:last-child,
+.repository-list li:last-child,
+.activity-list li:last-child {
border-bottom: 0;
}
-footer {
- padding: 1rem 0;
- border-top: 1px solid GrayText;
-}
-
-form {
- max-width: 32rem;
-}
-
-details {
- margin-block: 1.5rem;
-}
-
-summary {
- cursor: pointer;
+.repository-name,
+.issue-title {
font-weight: 700;
}
+.repository-meta,
+.issue-meta {
+ display: block;
+ margin-top: 0.2rem;
+ color: var(--fg-muted);
+ font-size: 0.7rem;
+}
+
+.home-repository-list p {
+ margin: 0.35rem 0 0;
+ color: var(--fg-dim);
+ font-size: 0.875rem;
+}
+
+.home-repository-list .inline-form {
+ margin-top: 0.75rem;
+}
+
+.home-workflow {
+ margin-top: 3rem;
+}
+
+.home-workflow > h2 {
+ color: var(--fg-muted);
+ font-size: 0.75rem;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+}
+
+.home-workflow ol {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 0.75rem;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.home-workflow li {
+ min-height: 11rem;
+ padding: 1rem;
+ border: 1px solid var(--border);
+ background: var(--panel);
+}
+
+.home-workflow li::before {
+ display: block;
+ margin-bottom: 2.5rem;
+ color: var(--fg-muted);
+ font-size: 0.7rem;
+ content: "0" counter(list-item);
+}
+
+.home-workflow strong {
+ display: block;
+ margin-bottom: 0.5rem;
+}
+
+.home-workflow p {
+ margin: 0;
+ color: var(--fg-dim);
+ font-size: 0.875rem;
+}
+
.repository-header {
- display: flex;
- flex-wrap: wrap;
- align-items: baseline;
- justify-content: space-between;
- gap: 0.5rem 2rem;
+ margin-bottom: 2rem;
+ padding-bottom: 1rem;
+ border-bottom: 1px solid var(--border);
}
.repository-header h1 {
- margin-block: 0.5rem;
+ margin-block: 0 1.25rem;
+ font-size: clamp(1.75rem, 4vw, 2.75rem);
+ overflow-wrap: anywhere;
}
-.repository-header nav {
- justify-content: flex-end;
- font-size: 0.875rem;
+.repository-navigation {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: stretch;
+ justify-content: space-between;
+ gap: 0.75rem 2rem;
+}
+
+.repository-tabs,
+.repository-tools,
+.repository-context,
+.repository-actions {
+ display: flex;
+ align-items: center;
+}
+
+.repository-tabs {
+ flex-wrap: wrap;
+}
+
+.repository-tabs a {
+ padding: 0.45rem 0.65rem;
+ border: 1px solid var(--border);
+ border-right: 0;
+ color: var(--fg-dim);
+ font-size: 0.7rem;
+}
+
+.repository-tabs a:last-child {
+ border-right: 1px solid var(--border);
+}
+
+.repository-tabs a:hover {
+ border-color: var(--border-strong);
+ color: var(--fg);
+ text-decoration: none;
+}
+
+.repository-tabs a[aria-current="page"] {
+ border-color: var(--fg);
+ background: var(--fg);
+ color: var(--bg);
+ font-weight: 700;
+}
+
+.repository-tools {
+ gap: 0.35rem;
+ margin-left: auto;
+}
+
+.repository-tools .icon-link,
+.repository-actions .icon-link,
+.page-section-header .icon-link {
+ display: grid;
+ width: 2.25rem;
+ height: 2.25rem;
+ border: 1px solid var(--border);
+ color: var(--fg-dim);
+ place-items: center;
+}
+
+.repository-tools .icon-link:hover,
+.repository-actions .icon-link:hover,
+.page-section-header .icon-link:hover {
+ border-color: var(--border-strong);
+ color: var(--fg);
+ text-decoration: none;
+}
+
+.repository-tools .icon-link[aria-current="page"] {
+ border-color: var(--fg);
+ color: var(--fg);
}
.repository-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
- gap: 0.5rem 1rem;
+ justify-content: space-between;
+ gap: 0.75rem 2rem;
margin-block: 1.5rem;
padding: 0.75rem 1rem;
- border: 1px solid GrayText;
- border-radius: 0.25rem;
+ border: 1px solid var(--border);
+ background: var(--surface);
+ font-size: 0.75rem;
+}
+
+.repository-toolbar strong {
+ color: var(--fg);
+}
+
+.repository-context {
+ flex-wrap: wrap;
+ gap: 0.5rem 1.5rem;
+}
+
+.repository-actions {
+ gap: 0.35rem;
+ margin-left: auto;
}
.repository-summary-layout {
display: grid;
- grid-template-columns: minmax(0, 1.7fr) minmax(23rem, 1fr);
+ grid-template-columns: minmax(0, 1.7fr) minmax(18rem, 0.8fr);
gap: 2rem;
align-items: start;
}
@@ -155,17 +550,21 @@
.repository-summary-main > section,
.repository-sidebar > section {
- margin-bottom: 2rem;
+ margin-bottom: 1.5rem;
}
.repository-panel {
padding: 1rem;
- border: 1px solid GrayText;
- border-radius: 0.25rem;
+ border: 1px solid var(--border);
+ background: var(--panel);
}
.repository-panel > h2:first-child {
margin-top: 0;
+ color: var(--fg-muted);
+ font-size: 0.75rem;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
}
.repository-file-panel {
@@ -175,7 +574,7 @@
.repository-file-panel th,
.repository-file-panel td {
- padding: 0.6rem 1rem;
+ padding: 0.65rem 0.85rem;
}
.repository-file-panel tbody tr:last-child > * {
@@ -185,21 +584,37 @@
.repository-file-more {
margin: 0;
padding: 0.75rem 1rem;
- border-top: 1px solid GrayText;
+ border-top: 1px solid var(--border);
}
.repository-file-list th:first-child {
width: 100%;
}
-.repository-sidebar dd {
- overflow-x: auto;
- overflow-wrap: normal;
+.repository-file-list code,
+.repository-toolbar code {
white-space: nowrap;
+ overflow-wrap: normal;
+}
+
+.repository-sidebar {
+ font-size: 0.8rem;
+}
+
+.repository-sidebar dd {
+ overflow-wrap: anywhere;
+}
+
+.repository-sidebar dd code {
+ display: block;
+ max-width: 100%;
+ overflow-x: auto;
+ white-space: nowrap;
+ overflow-wrap: normal;
}
.repository-recent-list {
- padding-left: 1.5rem;
+ padding-left: 1.25rem;
}
.repository-recent-list li {
@@ -207,20 +622,122 @@
}
.repository-readme {
- margin-block: 2rem;
+ margin-top: 1.5rem;
}
-.site-header nav,
-.repository-header nav {
+.markdown {
+ max-width: 78ch;
+}
+
+form {
+ max-width: 36rem;
+}
+
+.field {
+ margin-block: 1rem;
+}
+
+label {
+ display: block;
+ margin-bottom: 0.35rem;
+ color: var(--fg-dim);
+ font-size: 0.75rem;
+ font-weight: 700;
+}
+
+input,
+textarea,
+select,
+button {
+ max-width: 100%;
+ padding: 0.65rem 0.75rem;
+ border: 1px solid var(--border-strong);
+ border-radius: 0;
+ background: var(--surface-raised);
+ color: var(--fg);
+}
+
+input {
+ width: 100%;
+}
+
+input[type="radio"],
+input[type="checkbox"] {
+ width: auto;
+ accent-color: var(--fg);
+}
+
+textarea {
+ width: 100%;
+ resize: vertical;
+}
+
+button {
+ background: var(--fg);
+ color: var(--bg);
+ cursor: pointer;
+ font-weight: 700;
+}
+
+button:hover {
+ background: var(--fg-dim);
+}
+
+.page-actions {
display: flex;
flex-wrap: wrap;
- gap: 0.5rem 1rem;
+ gap: 0.75rem;
+ margin-top: 1.5rem;
+}
+
+.primary-action {
+ display: inline-block;
+ padding: 0.65rem 0.75rem;
+ border: 1px solid var(--fg);
+ background: var(--fg);
+ color: var(--bg);
+ font-weight: 700;
+}
+
+.primary-action:hover {
+ background: var(--fg-dim);
+ color: var(--bg);
+ text-decoration: none;
+}
+
+.danger-action {
+ border-color: var(--error);
+ background: var(--panel);
+ color: var(--error);
+}
+
+.danger-action:hover {
+ background: var(--error);
+ color: var(--bg);
+}
+
+.danger-link {
+ color: var(--error);
+}
+
+details {
+ margin-block: 1.5rem;
+}
+
+summary {
+ cursor: pointer;
+ font-weight: 700;
}
dl {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
- gap: 0.25rem 1rem;
+ gap: 0.35rem 1rem;
+}
+
+dt {
+ color: var(--fg-muted);
+ font-size: 0.75rem;
}
dd {
@@ -231,6 +748,7 @@
table {
width: 100%;
border-collapse: collapse;
+ font-size: 0.8rem;
}
.table-scroll {
@@ -238,19 +756,32 @@
overflow-x: auto;
}
+main > .table-scroll {
+ padding: 0;
+}
+
th,
td {
- padding: 0.4rem;
- border-bottom: 1px solid GrayText;
+ padding: 0.55rem;
+ border-bottom: 1px solid var(--border);
text-align: left;
vertical-align: top;
+}
+
+thead th {
+ color: var(--fg-muted);
+ font-size: 0.7rem;
+ text-transform: lowercase;
}
pre {
max-width: 100%;
padding: 1rem;
overflow: auto;
- border: 0.125rem solid GrayText;
+ border: 1px solid var(--border);
+ background: var(--surface);
+ color: var(--fg);
+ font-size: 0.8rem;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
@@ -260,197 +791,436 @@
overflow-wrap: anywhere;
}
-.field {
- margin-block: 1rem;
-}
-
-label {
- display: block;
- margin-bottom: 0.25rem;
- font-weight: 700;
-}
-
-input,
-textarea,
-select,
-button {
- max-width: 100%;
- padding: 0.6rem 0.75rem;
- border: 1px solid GrayText;
- border-radius: 0.25rem;
- background: Canvas;
- color: CanvasText;
- font: inherit;
-}
-
-input {
- width: 100%;
-}
-
-input[type="radio"],
-input[type="checkbox"] {
- width: auto;
-}
-
-textarea {
- width: 100%;
- resize: vertical;
-}
-
-button {
- cursor: pointer;
- font-weight: 700;
-}
-
-a {
- color: LinkText;
-}
-
-:focus-visible {
- outline: 0.2rem solid Highlight;
- outline-offset: 0.2rem;
-}
-
.error {
padding: 0.75rem;
- border-left: 0.25rem solid #b42318;
+ border: 1px solid var(--error);
+ color: var(--error);
}
-.comment-card {
- margin-block: 1rem;
- padding: 1rem;
- border: 1px solid GrayText;
- border-radius: 0.25rem;
-}
-
-.comment-card > h3 {
- margin-top: 0;
- padding-bottom: 0.5rem;
- border-bottom: 1px solid GrayText;
-}
-
-.key-list {
- padding: 0;
- list-style: none;
-}
-
+.comment-card,
.key-card {
margin-block: 1rem;
padding: 1rem;
- border: 1px solid GrayText;
- border-radius: 0.25rem;
+ border: 1px solid var(--border);
+ background: var(--panel);
}
+.comment-card > h3,
.key-card > h3 {
+ margin-top: 0;
+ padding-bottom: 0.5rem;
+ border-bottom: 1px solid var(--border);
+}
+
+.pull-request-page {
+ display: grid;
+ gap: 1.5rem;
+ margin-block: 0;
+ padding: 0;
+ border: 0;
+ background: transparent;
+}
+
+.pull-request-hero,
+.pull-request-section,
+.pull-request-history {
+ padding: 1.25rem;
+ border: 1px solid var(--border);
+ background: var(--panel);
+}
+
+.pull-request-title,
+.pull-request-section-heading {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 1rem 2rem;
+}
+
+.pull-request-title h2 {
+ max-width: 24ch;
+ margin: 0;
+ font-size: clamp(1.6rem, 4vw, 2.6rem);
+ overflow-wrap: anywhere;
+}
+
+.pull-request-kicker {
+ margin: 0 0 0.35rem;
+ color: var(--fg-muted);
+ font-size: 0.7rem;
+ font-weight: 700;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+}
+
+.pull-request-state,
+.pull-request-mergeability {
+ flex: 0 0 auto;
+ padding: 0.3rem 0.55rem;
+ border: 1px solid var(--border-strong);
+ color: var(--fg);
+ font-size: 0.7rem;
+ font-weight: 700;
+ text-transform: lowercase;
+}
+
+.pull-request-byline {
+ margin: 0.75rem 0 1.5rem;
+ color: var(--fg-dim);
+ font-size: 0.75rem;
+}
+
+.pull-request-description {
+ padding-block: 1rem;
+ border-block: 1px solid var(--border);
+}
+
+.pull-request-description > :first-child {
margin-top: 0;
}
-.two-column {
- display: grid;
- grid-template-columns: minmax(0, 1fr) minmax(0, 1.6fr);
- gap: 2rem;
- align-items: start;
+.pull-request-description > :last-child {
+ margin-bottom: 0;
}
-.profile-bio {
+.pull-request-branches {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
+ gap: 0.75rem;
+ align-items: center;
+ margin-top: 1rem;
+}
+
+.pull-request-branches > div {
+ min-width: 0;
+ padding: 0.75rem;
+ border: 1px solid var(--border);
+ background: var(--surface);
+}
+
+.pull-request-branches span,
+.pull-request-branches small {
+ display: block;
+ color: var(--fg-muted);
+ font-size: 0.65rem;
+}
+
+.pull-request-branches code {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.pull-request-direction {
+ color: var(--fg-dim);
+ font-size: 1.25rem;
+}
+
+.pull-request-actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-start;
+ gap: 0.75rem;
+ margin-top: 1rem;
+}
+
+.pull-request-actions > details {
+ flex: 1 1 24rem;
+ margin: 0;
+}
+
+.pull-request-actions > form {
+ margin-left: auto;
+}
+
+.pull-request-actions details[open] {
+ padding: 0.75rem;
+ border: 1px solid var(--border);
+}
+
+.pull-request-section-heading h2 {
+ margin: 0;
+ font-size: 1.25rem;
+}
+
+.pull-request-change-summary {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 0;
+ margin-block: 1rem;
+ border: 1px solid var(--border);
+}
+
+.pull-request-change-summary > div {
+ min-width: 0;
+ padding: 0.75rem;
+ border-right: 1px solid var(--border);
+}
+
+.pull-request-change-summary > div:last-child {
+ border-right: 0;
+}
+
+.pull-request-change-summary dt,
+.pull-request-change-summary dd {
+ display: block;
+}
+
+.pull-request-change-summary dd {
+ margin-top: 0.15rem;
+ font-weight: 700;
+}
+
+.pull-request-change-index,
+.pull-request-history-grid,
+.pull-request-decision-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 1rem;
+}
+
+.pull-request-change-index > div,
+.pull-request-history-grid > section {
+ min-width: 0;
+ padding: 0.85rem;
+ border: 1px solid var(--border);
+ background: var(--surface);
+}
+
+.pull-request-change-index h3,
+.pull-request-history-grid h2 {
+ margin: 0 0 0.5rem;
+}
+
+.pull-request-change-index ol,
+.pull-request-change-index ul,
+.pull-request-history-grid ol {
+ margin: 0;
+ padding-left: 1.4rem;
+}
+
+.pull-request-change-index li,
+.pull-request-history-grid li {
+ margin-block: 0.35rem;
+}
+
+.pull-request-diff {
+ display: grid;
+ gap: 1rem;
+ margin-top: 1rem;
+}
+
+.pull-request-file {
+ min-width: 0;
+ border: 1px solid var(--border);
+ background: var(--surface);
+}
+
+.pull-request-file > h3 {
+ margin: 0;
+ padding: 0.65rem 0.85rem;
+ border-bottom: 1px solid var(--border);
+}
+
+.pull-request-file > pre {
+ margin: 0;
+ border: 0;
+ background: var(--bg);
+}
+
+.pull-request-line-comment {
+ margin: 0;
+ padding: 0.65rem 0.85rem;
+ border-top: 1px solid var(--border);
+}
+
+.pull-request-line-comment form {
+ max-width: 42rem;
+}
+
+.pull-request-line-fields {
+ display: grid;
+ grid-template-columns: minmax(8rem, 0.4fr) minmax(8rem, 0.6fr);
+ gap: 0.75rem;
+}
+
+.pull-request-discussion {
+ display: grid;
+ gap: 0.75rem;
+ margin-top: 1rem;
+}
+
+.pull-request-discussion .comment-card {
+ margin: 0;
+}
+
+.pull-request-discussion .comment-card > header {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ gap: 0.5rem 1rem;
+ padding-bottom: 0.55rem;
+ border-bottom: 1px solid var(--border);
+}
+
+.pull-request-discussion .comment-card > header span,
+.pull-request-comment-anchor,
+.pull-request-empty {
+ color: var(--fg-muted);
+ font-size: 0.7rem;
+}
+
+.pull-request-decision-grid > .pull-request-section {
+ min-width: 0;
+}
+
+.pull-request-decision-grid form {
+ max-width: none;
+}
+
+.pull-request-review-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.pull-request-merge {
+ display: flex;
+ flex-direction: column;
+}
+
+.pull-request-merge form {
+ margin-top: auto;
+}
+
+.pull-request-history {
+ margin: 0;
+}
+
+.pull-request-history > summary {
+ color: var(--fg-dim);
+}
+
+.pull-request-history[open] > summary {
+ margin-bottom: 1rem;
+ color: var(--fg);
+}
+
+.pull-request-history-grid li > span {
+ display: block;
+ color: var(--fg-muted);
+ font-size: 0.7rem;
+}
+
+.pull-request-git-details {
+ display: grid;
+ gap: 0.35rem;
+ margin-top: 1rem;
+ padding: 0.75rem;
+ border: 1px solid var(--border);
+ background: var(--surface);
+}
+
+.pull-request-git-details span {
+ color: var(--fg-muted);
+ font-size: 0.7rem;
+}
+
+.pull-request-git-details code {
+ overflow-x: auto;
+ white-space: nowrap;
+}
+
+.profile-bio,
+.preserve-whitespace {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
-.home-intro {
- align-self: stretch;
-}
-
-.home-actions {
- display: flex;
- flex-wrap: wrap;
- gap: 0.5rem 1rem;
- margin-top: 1.5rem;
-}
-
-.home-repository-list {
- margin: 0;
- padding: 0;
- list-style: none;
-}
-
-.home-repository-list li {
- padding-block: 0.75rem;
-}
-
-.home-repository-list li + li {
- border-top: 1px solid GrayText;
-}
-
-.repository-name {
- font-weight: 700;
-}
-
-.repository-meta {
- display: block;
- margin-top: 0.25rem;
-}
-
-.home-repository-list p {
- margin: 0.25rem 0 0;
-}
-
-.home-repository-list .inline-form {
- margin-top: 0.75rem;
-}
-
-.home-workflow {
- margin-top: 2rem;
-}
-
-.home-workflow ol {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 2rem;
- margin-bottom: 0;
- padding-left: 1.5rem;
-}
-
-.home-workflow strong {
- display: block;
- margin-bottom: 0.5rem;
-}
-
-.home-workflow p {
- margin: 0;
+.field-help {
+ color: var(--fg-dim);
+ font-size: 0.8rem;
}
.auth-page {
- width: min(100%, 36rem);
+ width: min(100%, 38rem);
margin-inline: auto;
}
-.auth-panel {
- padding: 1rem;
- border: 1px solid GrayText;
- border-radius: 0.25rem;
+.account-entry {
+ max-width: none;
}
-.auth-panel > :first-child {
+.account-entry > p {
+ color: var(--fg-dim);
+}
+
+.account-options {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 0.75rem;
+ margin-top: 2rem;
+}
+
+.account-options a {
+ display: flex;
+ min-height: 12rem;
+ padding: 1rem;
+ border: 1px solid var(--border);
+ background: var(--panel);
+ flex-direction: column;
+}
+
+.account-options a:hover {
+ border-color: var(--border-strong);
+ text-decoration: none;
+}
+
+.account-options svg {
+ width: 1.75rem;
+ height: 1.75rem;
+ margin-bottom: auto;
+}
+
+.account-options strong {
+ margin-top: 2rem;
+}
+
+.account-options span {
+ margin-top: 0.35rem;
+ color: var(--fg-dim);
+ font-size: 0.8rem;
+}
+
+.account-utilities {
+ gap: 0.75rem;
+}
+
+.account-key-layout {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.8fr);
+ gap: 2rem;
+ align-items: start;
+}
+
+.account-key-layout > * {
+ min-width: 0;
+}
+
+.account-key-layout h3 {
margin-top: 0;
}
-.auth-panel > :last-child {
- margin-bottom: 0;
-}
-
-.auth-panel form {
+.auth-panel form,
+.search-panel form {
max-width: none;
}
.auth-panel details {
margin-bottom: 0;
padding-top: 1rem;
- border-top: 1px solid GrayText;
-}
-
-.search-panel form {
- max-width: none;
+ border-top: 1px solid var(--border);
}
.search-results {
@@ -459,10 +1229,7 @@
.search-results li {
padding-block: 0.75rem;
-}
-
-.search-results li + li {
- border-top: 1px solid GrayText;
+ border-bottom: 1px solid var(--border);
}
.page-section-header {
@@ -486,7 +1253,7 @@
max-width: none;
margin-bottom: 0;
padding-bottom: 1rem;
- border-bottom: 1px solid GrayText;
+ border-bottom: 1px solid var(--border);
}
.filter-form .field {
@@ -497,66 +1264,39 @@
min-width: 9rem;
}
-.issue-list {
- margin: 0;
- padding: 0;
- list-style: none;
-}
-
-.issue-list li {
- padding-block: 1rem;
-}
-
-.issue-list li + li {
- border-top: 1px solid GrayText;
-}
-
-.issue-title {
- font-weight: 700;
-}
-
-.issue-meta {
- display: block;
- margin-top: 0.25rem;
-}
-
.collection-action {
margin-bottom: 0;
padding-top: 1rem;
- border-top: 1px solid GrayText;
-}
-
-@media (max-width: 44rem) {
- .two-column,
- .repository-summary-layout {
- grid-template-columns: 1fr;
- }
-
- .home-workflow ol {
- grid-template-columns: 1fr;
- gap: 1rem;
- }
+ border-top: 1px solid var(--border);
}
.pagination {
- display: flex;
- flex-wrap: wrap;
- gap: 1rem;
+ margin-top: 1.5rem;
}
.confirmation {
margin-block: 0.75rem;
- border: 1px solid GrayText;
+ border: 1px solid var(--border);
}
.confirmation label {
display: inline-block;
margin-right: 1rem;
+ color: var(--fg);
font-weight: 400;
}
+.inline-form {
+ display: inline;
+}
+
+.inline-form button {
+ padding: 0.3rem 0.5rem;
+ font-size: 0.7rem;
+}
+
.skip-link {
- position: absolute;
+ position: fixed;
left: -10000px;
top: auto;
}
@@ -564,7 +1304,212 @@
.skip-link:focus {
left: 1rem;
top: 1rem;
+ z-index: 10;
+ padding: 0.5rem 0.75rem;
+ background: var(--fg);
+ color: var(--bg);
+}
+
+.background-icon {
+ position: fixed;
+ right: -5%;
+ bottom: -5%;
+ z-index: 0;
+ width: 100vw;
+ height: 100vw;
+ max-width: 50rem;
+ max-height: 50rem;
+ color: var(--fg);
+ opacity: 0.08;
+ pointer-events: none;
+ transform: rotate(-15deg);
+}
+
+.background-icon svg {
+ width: 100%;
+ height: 100%;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 1;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.site-header,
+.site-header-rule,
+main,
+footer {
+ position: relative;
z-index: 1;
- padding: 0.5rem;
- background: Canvas;
+}
+
+@media (max-width: 52rem) {
+ .two-column,
+ .repository-summary-layout,
+ .account-key-layout,
+ .pull-request-change-index,
+ .pull-request-history-grid,
+ .pull-request-decision-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .home-intro {
+ min-height: 0;
+ }
+
+ .home-grid > section {
+ height: auto;
+ min-height: 0;
+ overflow: visible;
+ }
+
+ .home-grid .home-repository-list {
+ overflow: visible;
+ }
+}
+
+@media (max-width: 44rem) {
+ body {
+ padding: 2rem;
+ }
+
+ .site-header {
+ align-items: flex-start;
+ padding-block: 2rem;
+ }
+
+ .site-header-rule {
+ margin-bottom: 2rem;
+ }
+
+ .site-header nav {
+ justify-content: flex-start;
+ }
+
+ main {
+ padding-top: 0;
+ }
+
+ .home-workflow ol {
+ grid-template-columns: 1fr;
+ }
+
+ .account-options {
+ grid-template-columns: 1fr;
+ }
+
+ .account-options a {
+ min-height: 9rem;
+ }
+
+ .home-workflow li {
+ min-height: 0;
+ }
+
+ .home-workflow li::before {
+ margin-bottom: 1rem;
+ }
+
+ .repository-navigation {
+ align-items: flex-start;
+ }
+
+ .repository-tabs {
+ flex: 1 1 100%;
+ }
+
+ .repository-tabs a {
+ flex: 1;
+ text-align: center;
+ }
+
+ .repository-file-list,
+ .repository-file-list tbody,
+ .repository-file-list tr,
+ .repository-file-list th,
+ .repository-file-list td {
+ display: block;
+ width: 100%;
+ }
+
+ .repository-file-list thead {
+ display: none;
+ }
+
+ .repository-file-list tr {
+ padding: 0.75rem 0.85rem;
+ border-bottom: 1px solid var(--border);
+ }
+
+ .repository-file-list tbody tr:last-child {
+ border-bottom: 0;
+ }
+
+ .repository-file-list th,
+ .repository-file-list td {
+ padding: 0;
+ border: 0;
+ }
+
+ .repository-file-list td {
+ color: var(--fg-muted);
+ font-size: 0.7rem;
+ }
+
+ .repository-file-list td:nth-child(2)::before {
+ content: "type ";
+ }
+
+ .repository-file-list td:nth-child(3)::before {
+ content: "object ";
+ }
+
+ .pull-request-title,
+ .pull-request-section-heading {
+ flex-direction: column;
+ }
+
+ .pull-request-branches {
+ grid-template-columns: 1fr;
+ }
+
+ .pull-request-direction {
+ transform: rotate(90deg);
+ justify-self: center;
+ }
+
+ .pull-request-change-summary {
+ grid-template-columns: 1fr;
+ }
+
+ .pull-request-change-summary > div {
+ border-right: 0;
+ border-bottom: 1px solid var(--border);
+ }
+
+ .pull-request-change-summary > div:last-child {
+ border-bottom: 0;
+ }
+
+ .pull-request-line-fields {
+ grid-template-columns: 1fr;
+ }
+
+ dl {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (min-width: 48rem) {
+ body {
+ padding: 3rem;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ scroll-behavior: auto !important;
+ }
}
scripts/package-release
Mode 100755 → 100755; object a73b543597d1 → 22af0f939c0d
@@ -62,6 +62,7 @@
"$root/share/fish/vendor_completions.d"
install -m 755 "$binary" "$root/bin/tit"
install -m 644 "$project_dir/LICENSE" "$root/LICENSE"
+install -m 644 "$project_dir/assets/SpaceMono-LICENSE.txt" "$root/LICENSE-SpaceMono"
install -m 644 "$project_dir/README.md" "$root/README.md"
install -m 600 "$project_dir/config.example.toml" "$root/etc/tit/config.toml"
install -m 644 "$project_dir/release/examples/Caddyfile" "$root/share/doc/tit/examples/Caddyfile"
scripts/verify-release-artifact
Mode 100755 → 100755; object 8a8e7a9b2143 → 941685f20ece
@@ -66,6 +66,7 @@
for required in \
"$binary" \
"$package/LICENSE" \
+ "$package/LICENSE-SpaceMono" \
"$package/README.md" \
"$package/etc/tit/config.toml" \
"$package/share/doc/tit/examples/Caddyfile" \
src/git/read.rs
Mode 100644 → 100644; object 85bd77d6a38b → f462d340b3ce
@@ -340,10 +340,9 @@
return Err(ReadError::Limit("tree entries"));
}
let truncated = tree.entries.len() > maximum;
- let entries = tree
+ let mut entries = tree
.entries
.into_iter()
- .take(maximum)
.map(|entry| {
budget.check()?;
Ok(TreeEntryInfo {
@@ -354,6 +353,12 @@
})
})
.collect::<Result<Vec<_>, ReadError>>()?;
+ entries.sort_by(|left, right| {
+ (!matches!(left.kind, EntryKind::Tree))
+ .cmp(&(!matches!(right.kind, EntryKind::Tree)))
+ .then_with(|| left.name.cmp(&right.name))
+ });
+ entries.truncate(maximum);
Ok((entries, truncated))
}
src/http/issues.rs
Mode 100644 → 100644; object 3d80123a607e → 632e3f2bd339
@@ -14,7 +14,7 @@
use super::filters;
use super::{
CSRF_COOKIE, RequestActor, RequestId, WebState, authenticate_mutation, cookie,
- parse_named_form, render, render_error,
+ parse_named_form, render, render_error, repository_can_manage,
};
const MAX_ISSUE_REQUEST_BYTES: usize = 300 * 1024;
@@ -60,6 +60,13 @@
let owner = path.owner.clone();
let repository = path.repository.clone();
let authenticated = actor.0.is_some();
+ let can_manage = repository_can_manage(
+ state.clone(),
+ actor.0.clone(),
+ owner.clone(),
+ repository.clone(),
+ )
+ .await;
let state_filter = query.state.unwrap_or_else(|| "open".to_owned());
let page_number = query.page.unwrap_or(1);
if page_number == 0 {
@@ -84,6 +91,7 @@
&IssueListTemplate {
request_id: &request_id.0,
signed_in: authenticated,
+ can_manage,
owner: &record.owner,
repository: &record.slug,
issues: page
@@ -133,6 +141,13 @@
let owner = path.owner.clone();
let repository = path.repository.clone();
let number = path.number;
+ let can_manage = repository_can_manage(
+ state.clone(),
+ actor.0.clone(),
+ owner.clone(),
+ repository.clone(),
+ )
+ .await;
let comments_page = query.comments_page.unwrap_or(1);
let timeline_page = query.timeline_page.unwrap_or(1);
if comments_page == 0 || timeline_page == 0 {
@@ -150,7 +165,7 @@
})
.await;
match result {
- Ok(detail) => render_issue(&request_id.0, &headers, &detail),
+ Ok(detail) => render_issue(&request_id.0, &headers, &detail, can_manage),
Err(error) => issue_read_error(error, &request_id.0),
}
}
@@ -309,13 +324,19 @@
.map_err(|_| IssueError::Store(StoreError::Integrity("issue worker failed".to_owned())))?
}
-fn render_issue(request_id: &str, headers: &HeaderMap, detail: &IssueDetail) -> Response {
+fn render_issue(
+ request_id: &str,
+ headers: &HeaderMap,
+ detail: &IssueDetail,
+ can_manage: bool,
+) -> Response {
let csrf = cookie(headers, CSRF_COOKIE).unwrap_or_default();
render(
StatusCode::OK,
&IssueTemplate {
request_id,
signed_in: !csrf.is_empty(),
+ can_manage,
owner: &detail.repository.owner,
repository: &detail.repository.slug,
number: detail.issue.number,
@@ -479,6 +500,7 @@
struct IssueListTemplate<'a> {
request_id: &'a str,
signed_in: bool,
+ can_manage: bool,
owner: &'a str,
repository: &'a str,
issues: Vec<IssueListItem<'a>>,
@@ -507,6 +529,7 @@
struct IssueTemplate<'a> {
request_id: &'a str,
signed_in: bool,
+ can_manage: bool,
owner: &'a str,
repository: &'a str,
number: i64,
src/http/mod.rs
Mode 100644 → 100644; object 17f41572479c → 169a072788cb
@@ -45,7 +45,9 @@
use self::public::PublicWeb;
const STYLE: &str = include_str!("../../assets/style.css");
-const CONTENT_SECURITY_POLICY: &str = "default-src 'none'; style-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'";
+const SPACE_MONO_REGULAR: &[u8] = include_bytes!("../../assets/SpaceMono-Regular.ttf");
+const SPACE_MONO_BOLD: &[u8] = include_bytes!("../../assets/SpaceMono-Bold.ttf");
+const CONTENT_SECURITY_POLICY: &str = "default-src 'none'; style-src 'self'; font-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'";
const MAX_BLOCKING_WEB_JOBS: usize = 8;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const CONCURRENCY_WAIT: Duration = Duration::from_secs(1);
@@ -445,6 +447,8 @@
.layer(DefaultBodyLimit::max(1024)),
)
.route("/assets/style.css", get(style))
+ .route("/assets/SpaceMono-Regular.ttf", get(space_mono_regular))
+ .route("/assets/SpaceMono-Bold.ttf", get(space_mono_bold))
.merge(feeds::routes())
.merge(repository_routes)
.route("/{username}", get(public_profile))
@@ -1220,7 +1224,13 @@
headers: HeaderMap,
) -> Response {
let Some(session_token) = cookie(&headers, SESSION_COOKIE) else {
- return login_redirect(false);
+ return render(
+ StatusCode::OK,
+ &AccountEntryTemplate {
+ request_id: &request_id.0,
+ signed_in: false,
+ },
+ );
};
let Some(csrf) = cookie(&headers, CSRF_COOKIE) else {
return login_redirect(true);
@@ -1813,6 +1823,22 @@
})?
}
+async fn repository_can_manage(
+ state: WebState,
+ actor: Option<String>,
+ owner: String,
+ repository: String,
+) -> bool {
+ let Some(actor) = actor else {
+ return false;
+ };
+ repository_job(state, move |repositories| {
+ repositories.can_manage(&owner, &repository, &actor)
+ })
+ .await
+ .unwrap_or(false)
+}
+
async fn authenticate_mutation(
state: WebState,
headers: &HeaderMap,
@@ -2112,6 +2138,23 @@
.header(header::CACHE_CONTROL, "no-cache")
.body(Body::from(STYLE))
.expect("the embedded CSS response is valid")
+}
+
+async fn space_mono_regular() -> Response {
+ font_asset(SPACE_MONO_REGULAR)
+}
+
+async fn space_mono_bold() -> Response {
+ font_asset(SPACE_MONO_BOLD)
+}
+
+fn font_asset(bytes: &'static [u8]) -> Response {
+ Response::builder()
+ .status(StatusCode::OK)
+ .header(header::CONTENT_TYPE, "font/ttf")
+ .header(header::CACHE_CONTROL, "no-cache")
+ .body(Body::from(Bytes::from_static(bytes)))
+ .expect("the embedded font response is valid")
}
async fn not_found(
@@ -2433,6 +2476,13 @@
contact_email: &'a str,
keys: Vec<AccountKeyView<'a>>,
active_key_count: usize,
+ signed_in: bool,
+}
+
+#[derive(Template)]
+#[template(path = "account-entry.html")]
+struct AccountEntryTemplate<'a> {
+ request_id: &'a str,
signed_in: bool,
}
src/http/public.rs
Mode 100644 → 100644; object aca97529f24b → 8f28934fa8a9
@@ -146,6 +146,38 @@
.map_err(|_| RouteError::Internal)?
}
+ async fn read_page<F>(
+ &self,
+ actor: Option<String>,
+ owner: String,
+ repository: String,
+ operation: F,
+ ) -> Result<RepositoryPage, RouteError>
+ where
+ F: FnOnce(RepositoryRecord, RepositoryReadService) -> Result<RepositoryPage, RouteError>
+ + Send
+ + 'static,
+ {
+ let policy = self.policy.clone();
+ let permission_actor = actor.clone();
+ let permission_owner = owner.clone();
+ let permission_repository = repository.clone();
+ self.read(actor, owner, repository, move |record, service| {
+ let can_manage = policy
+ .authorize(
+ permission_actor.as_deref(),
+ &permission_owner,
+ &permission_repository,
+ RepositoryOperation::Maintain,
+ )
+ .is_ok();
+ let mut page = operation(record, service)?;
+ page.can_manage = can_manage;
+ Ok(page)
+ })
+ .await
+ }
+
async fn event_page(
&self,
actor: Option<String>,
@@ -426,7 +458,7 @@
let clone_urls = web.clone_urls(&path.owner, &path.repository);
let database = web.database.clone();
let result = web
- .read(
+ .read_page(
actor.0,
path.owner,
path.repository,
@@ -517,7 +549,7 @@
};
let signed_in = actor.0.is_some();
let result = web
- .read(
+ .read_page(
actor.0,
path.owner,
path.repository,
@@ -548,7 +580,7 @@
return route_error(RouteError::InvalidRequest, &request_id.0);
}
let result = web
- .read(
+ .read_page(
actor.0,
path.owner,
path.repository,
@@ -590,7 +622,7 @@
let signed_in = actor.0.is_some();
let database = web.database.clone();
let result = web
- .read(
+ .read_page(
actor.0,
path.owner,
path.repository,
@@ -639,7 +671,7 @@
};
let signed_in = actor.0.is_some();
let result = web
- .read(
+ .read_page(
actor.0,
path.owner,
path.repository,
@@ -715,7 +747,7 @@
};
let signed_in = actor.0.is_some();
let result = web
- .read(
+ .read_page(
actor.0,
path.owner,
path.repository,
@@ -769,7 +801,7 @@
};
let signed_in = actor.0.is_some();
let result = web
- .read(
+ .read_page(
actor.0,
path.owner,
path.repository,
@@ -803,7 +835,7 @@
};
let signed_in = actor.0.is_some();
let result = web
- .read(
+ .read_page(
actor.0,
path.owner,
path.repository,
@@ -886,7 +918,7 @@
};
let signed_in = actor.0.is_some();
let result = web
- .read(
+ .read_page(
actor.0,
path.owner,
path.repository,
@@ -1409,6 +1441,7 @@
struct RepositoryPage {
request_id: String,
signed_in: bool,
+ can_manage: bool,
owner: String,
repository: String,
created_at: i64,
@@ -1470,6 +1503,7 @@
Self {
request_id: String::new(),
signed_in: false,
+ can_manage: false,
owner: record.owner,
repository: record.slug,
created_at: record.created_at,
src/http/pull_requests.rs
Mode 100644 → 100644; object d0df38dc0b8f → 75be0389218b
@@ -18,7 +18,7 @@
use super::public::{patch_response, stream_patch};
use super::{
CSRF_COOKIE, RequestActor, RequestId, WebState, authenticate_mutation, cookie,
- parse_named_form, render, render_error,
+ parse_named_form, render, render_error, repository_can_manage,
};
const MAX_PULL_REQUEST_BYTES: usize = 300 * 1024;
@@ -126,6 +126,13 @@
let repository = path.repository.clone();
let signed_in = actor.0.is_some();
let actor_name = actor.0;
+ let can_manage = repository_can_manage(
+ state.clone(),
+ actor_name.clone(),
+ owner.clone(),
+ repository.clone(),
+ )
+ .await;
let actor_for_list = actor_name.clone();
let state_filter = query.state.unwrap_or_else(|| "open".to_owned());
let page_number = query.page.unwrap_or(1);
@@ -171,6 +178,7 @@
&PullRequestListTemplate {
request_id: &request_id.0,
signed_in,
+ can_manage,
owner: &record.owner,
repository: &record.slug,
pull_requests: page
@@ -218,6 +226,13 @@
let owner = path.owner.clone();
let repository = path.repository.clone();
let signed_in = actor.0.is_some();
+ let can_manage = repository_can_manage(
+ state.clone(),
+ actor.0.clone(),
+ owner.clone(),
+ repository.clone(),
+ )
+ .await;
let reviews_page = query.reviews_page.unwrap_or(1);
let timeline_page = query.timeline_page.unwrap_or(1);
if reviews_page == 0 || timeline_page == 0 {
@@ -247,6 +262,7 @@
&PullRequestTemplate {
request_id: &request_id.0,
signed_in,
+ can_manage,
owner: &detail.repository.owner,
repository: &detail.repository.slug,
pull_request,
@@ -257,7 +273,6 @@
.iter()
.map(|review| ReviewView {
id: &review.id,
- revision: review.revision,
author: &review.author,
kind: &review.kind,
body_html: markdown::render(&review.body),
@@ -723,6 +738,7 @@
struct PullRequestListTemplate<'a> {
request_id: &'a str,
signed_in: bool,
+ can_manage: bool,
owner: &'a str,
repository: &'a str,
pull_requests: Vec<PullRequestListItem<'a>>,
@@ -754,6 +770,7 @@
struct PullRequestTemplate<'a> {
request_id: &'a str,
signed_in: bool,
+ can_manage: bool,
owner: &'a str,
repository: &'a str,
pull_request: &'a crate::store::PullRequestRecord,
@@ -784,7 +801,6 @@
struct ReviewView<'a> {
id: &'a str,
- revision: i64,
author: &'a str,
kind: &'a str,
body_html: RenderedMarkdown,
src/http/repository_settings.rs
Mode 100644 → 100644; object 26c76f1dbc38 → aee97b0c072a
@@ -66,6 +66,7 @@
&RepositorySettingsTemplate {
request_id: &request_id.0,
signed_in: true,
+ can_manage: true,
owner: &settings.repository.owner,
repository: &settings.repository.slug,
description: &settings.description,
@@ -410,6 +411,7 @@
struct RepositorySettingsTemplate<'a> {
request_id: &'a str,
signed_in: bool,
+ can_manage: bool,
owner: &'a str,
repository: &'a str,
description: &'a str,
src/http/watches.rs
Mode 100644 → 100644; object b6b596449317 → 3279ac858070
@@ -12,7 +12,7 @@
use super::{
CSRF_COOKIE, RequestActor, RequestId, WebState, authenticate_mutation, cookie,
- parse_named_form, render, render_error,
+ parse_named_form, render, render_error, repository_can_manage,
};
pub(super) fn routes() -> Router<WebState> {
@@ -37,6 +37,13 @@
let owner = path.owner;
let repository = path.repository;
let authenticated = actor.0.is_some();
+ let can_manage = repository_can_manage(
+ state.clone(),
+ actor.0.clone(),
+ owner.clone(),
+ repository.clone(),
+ )
+ .await;
let result = watch_job(state, move || {
service.get(&owner, &repository, actor.0.as_deref())
})
@@ -50,6 +57,7 @@
&WatchTemplate {
request_id: &request_id.0,
signed_in: authenticated,
+ can_manage,
owner: &record.owner,
repository: &record.slug,
csrf: &csrf,
@@ -168,6 +176,7 @@
struct WatchTemplate<'a> {
request_id: &'a str,
signed_in: bool,
+ can_manage: bool,
owner: &'a str,
repository: &'a str,
csrf: &'a str,
src/repository.rs
Mode 100644 → 100644; object 22dc30248372 → 30b2486ea15e
@@ -71,6 +71,20 @@
.map_err(Into::into)
}
+ pub(crate) fn can_manage(
+ &self,
+ owner: &str,
+ repository: &str,
+ actor: &str,
+ ) -> Result<bool, RepositoryServiceError> {
+ validate_username(owner)?;
+ validate_slug(repository)?;
+ validate_username(actor)?;
+ Store::open(&self.database)?
+ .repository_can_maintain(owner, repository, actor)
+ .map_err(Into::into)
+ }
+
pub(crate) fn settings(
&self,
owner: &str,
src/store/mod.rs
Mode 100644 → 100644; object 40324df2ab4d → bb8a44bddc39
@@ -1605,6 +1605,15 @@
})
}
+ pub(crate) fn repository_can_maintain(
+ &self,
+ owner: &str,
+ slug: &str,
+ actor: &str,
+ ) -> Result<bool, StoreError> {
+ Ok(repository_issue_access(&self.connection, owner, slug, Some(actor))?.can_maintain())
+ }
+
pub(crate) fn repository_default_branch(
&self,
owner: &str,
templates/account-entry.html
Mode → 100644; object → b9249f77d20f
@@ -1,0 +1,40 @@
+{% extends "base.html" %}
+{% block title %}Account · tit{% endblock %}
+{% block content %}
+ <section class="account-entry" aria-labelledby="account-heading">
+ <h1 id="account-heading">Account</h1>
+ <p>Create an account, recover an account, or log in with an SSH key.</p>
+ <div class="account-options">
+ <a href="/login">
+ <svg viewBox="0 0 24 24" aria-hidden="true">
+ <path d="M10 5H5v14h5"/>
+ <path d="M14 8l4 4-4 4"/>
+ <path d="M8 12h10"/>
+ </svg>
+ <strong>Log in</strong>
+ <span>Use an SSH key that is active on your account.</span>
+ </a>
+ <a href="/signup">
+ <svg viewBox="0 0 24 24" aria-hidden="true">
+ <circle cx="9" cy="8" r="4"/>
+ <path d="M2 21a7 7 0 0 1 14 0"/>
+ <path d="M19 8v6"/>
+ <path d="M16 11h6"/>
+ </svg>
+ <strong>Create account</strong>
+ <span>Use a signup code and an SSH public key.</span>
+ </a>
+ <a href="/recover">
+ <svg viewBox="0 0 24 24" aria-hidden="true">
+ <circle cx="12" cy="12" r="3"/>
+ <path d="M15 12h7"/>
+ <path d="M19 12v3"/>
+ <path d="M22 12v2"/>
+ <path d="M9 12H5a3 3 0 1 1 0-6h5"/>
+ </svg>
+ <strong>Recover account</strong>
+ <span>Replace all SSH keys with a recovery code.</span>
+ </a>
+ </div>
+ </section>
+{% endblock %}
templates/account-page.html
Mode 100644 → 100644; object 52279b8a8c76 → 548daa5cea7c
@@ -35,11 +35,22 @@
</p>
<button type="submit">Create repository</button>
</form>
+ </section>
+ </div>
+ <div class="two-column account-utilities">
+ <section>
<h2>Feeds</h2>
<p><a href="/feeds">Manage private feed tokens</a></p>
+ </section>
+ <section>
<h2>Sessions</h2>
- <p><a href="/logout">Log out all sessions</a></p>
- <h2>SSH keys</h2>
+ <p><a class="danger-link" href="/logout">Log out all sessions</a></p>
+ </section>
+ </div>
+ <section class="account-keys" aria-labelledby="ssh-keys-heading">
+ <h2 id="ssh-keys-heading">SSH keys</h2>
+ <div class="account-key-layout">
+ <div>
<ul class="key-list">
{% for key in keys %}
<li class="key-card">
@@ -59,7 +70,7 @@
<form action="/account/keys/revoke" method="post">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="fingerprint" value="{{ key.fingerprint }}">
- <button type="submit">Revoke</button>
+ <button class="danger-action" type="submit">Revoke</button>
</form>
{% else %}
<p>This is your final active key.</p>
@@ -68,6 +79,8 @@
</li>
{% endfor %}
</ul>
+ </div>
+ <div>
<h3>Add SSH key</h3>
<form action="/account/keys/add" method="post">
<input type="hidden" name="csrf" value="{{ csrf }}">
@@ -81,6 +94,7 @@
</div>
<button type="submit">Add key</button>
</form>
- </section>
</div>
+ </div>
+ </section>
{% endblock %}
templates/base.html
Mode 100644 → 100644; object 2f962c1fde84 → 0450682bce93
@@ -4,30 +4,80 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}tit{% endblock %}</title>
- <link rel="stylesheet" href="/assets/style.css?v=2">
+ <link rel="stylesheet" href="/assets/style.css?v=10">
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
+ <div class="background-icon" aria-hidden="true">
+ <svg viewBox="0 0 24 24">
+ <circle cx="6" cy="5" r="2"/>
+ <circle cx="6" cy="19" r="2"/>
+ <circle cx="18" cy="12" r="2"/>
+ <path d="M6 7v10"/>
+ <path d="M8 7c0 3 2 5 8 5"/>
+ </svg>
+ </div>
<header class="site-header">
- <a class="brand" href="/" aria-label="tit home">tit</a>
+ <a class="brand" href="/" aria-label="tit home">
+ <svg class="brand-icon" viewBox="0 0 24 24" aria-hidden="true">
+ <circle cx="6" cy="5" r="2"/>
+ <circle cx="6" cy="19" r="2"/>
+ <circle cx="18" cy="12" r="2"/>
+ <path d="M6 7v10"/>
+ <path d="M8 7c0 3 2 5 8 5"/>
+ </svg>
+ tit
+ </a>
<nav aria-label="Primary">
- <a href="/">Home</a>
- <a href="/search">Search</a>
+ <a class="icon-link" href="/" aria-label="Home" title="Home">
+ <svg viewBox="0 0 24 24" aria-hidden="true">
+ <path d="M3 11.5 12 4l9 7.5"/>
+ <path d="M5.5 10.5V20h13v-9.5"/>
+ <path d="M9.5 20v-6h5v6"/>
+ </svg>
+ </a>
+ <a class="icon-link" href="/search" aria-label="Search" title="Search">
+ <svg viewBox="0 0 24 24" aria-hidden="true">
+ <circle cx="11" cy="11" r="7"/>
+ <path d="m20 20-4-4"/>
+ </svg>
+ </a>
{% if signed_in %}
- <a href="/activity">Activity</a>
- <a href="/account">Account</a>
- <a href="/logout">Log out</a>
+ <a class="icon-link" href="/activity" aria-label="Activity" title="Activity">
+ <svg viewBox="0 0 24 24" aria-hidden="true">
+ <path d="M4 18h16"/>
+ <path d="m5 14 4-4 3 3 6-7 2 2"/>
+ </svg>
+ </a>
+ <a class="icon-link" href="/account" aria-label="Account" title="Account">
+ <svg viewBox="0 0 24 24" aria-hidden="true">
+ <circle cx="12" cy="8" r="4"/>
+ <path d="M4 21a8 8 0 0 1 16 0"/>
+ </svg>
+ </a>
+ <a class="icon-link" href="/logout" aria-label="Log out" title="Log out">
+ <svg viewBox="0 0 24 24" aria-hidden="true">
+ <path d="M10 5H5v14h5"/>
+ <path d="M14 8l4 4-4 4"/>
+ <path d="M8 12h10"/>
+ </svg>
+ </a>
{% else %}
- <a href="/signup">Create account</a>
- <a href="/recover">Recover account</a>
- <a href="/login">Log in</a>
+ <a class="icon-link" href="/account" aria-label="Account" title="Account">
+ <svg viewBox="0 0 24 24" aria-hidden="true">
+ <circle cx="12" cy="8" r="4"/>
+ <path d="M4 21a8 8 0 0 1 16 0"/>
+ </svg>
+ </a>
{% endif %}
</nav>
</header>
+ <hr class="site-header-rule">
<main id="main">
{% block content %}{% endblock %}
</main>
<footer>
+ <small>tit · open source · MIT licensed</small>
<small>Request <code>{{ request_id }}</code></small>
</footer>
</body>
templates/feed-tokens.html
Mode 100644 → 100644; object 0b66bd11db8a → 93359aeb34e7
@@ -26,7 +26,7 @@
<label><input type="radio" name="confirm" value="no" checked> No</label>
<label><input type="radio" name="confirm" value="yes"> Yes</label>
</fieldset>
- <button type="submit">Rotate</button>
+ <button class="danger-action" type="submit">Rotate</button>
</form>
<form method="post" action="/feeds/tokens/{{ token.id }}/revoke">
<input type="hidden" name="csrf" value="{{ csrf }}">
@@ -35,7 +35,7 @@
<label><input type="radio" name="confirm" value="no" checked> No</label>
<label><input type="radio" name="confirm" value="yes"> Yes</label>
</fieldset>
- <button type="submit">Revoke</button>
+ <button class="danger-action" type="submit">Revoke</button>
</form>
{% else %}
Revoked.
templates/home.html
Mode 100644 → 100644; object dd6b420bd0ed → 4c3775025c25
@@ -1,7 +1,7 @@
{% extends "base.html" %}
{% block title %}{% if signed_in %}{{ username }}{% else %}Repositories{% endif %} · tit{% endblock %}
{% block content %}
- <div class="two-column">
+ <div class="two-column home-grid">
{% if signed_in %}
<section>
<h1>{{ username }}</h1>
@@ -39,15 +39,10 @@
<section class="home-intro">
<h1>A small Git CDE</h1>
<p>tit provides self-hosted Git repositories, issues, pull requests, and code review in one server.</p>
- <nav class="home-actions" aria-label="Get started">
- <a href="/signup">Create account</a>
- <a href="/login">Log in</a>
- <a href="/search">Search repositories</a>
- </nav>
</section>
{% endif %}
<section>
- <h2>Recently updated public repositories</h2>
+ <h2>Recently updated</h2>
{% if recent_repositories.is_empty() %}
{% if signed_in %}
<p>No public repositories are available.</p>
templates/issue.html
Mode 100644 → 100644; object 550ff0bb46f4 → b622e9c5d137
@@ -3,6 +3,7 @@
{% block content %}
<header class="repository-header">
<h1><a href="/{{ owner }}">{{ owner }}</a>/<a href="/{{ owner }}/{{ repository }}">{{ repository }}</a></h1>
+{% let nav_section = "issues" %}
{% include "repository-nav.html" %}
</header>
@@ -25,7 +26,7 @@
{% endfor %}
{% endif %}
{% if comments_has_previous || comments_has_next %}
- <nav aria-label="Comment pages">
+ <nav class="pagination" aria-label="Comment pages">
{% if comments_has_previous %}
<a href="?comments_page={{ comments_previous_page }}&timeline_page={{ timeline_page }}">Newer comments</a>
{% endif %}
@@ -44,7 +45,7 @@
{% endfor %}
</ol>
{% if timeline_has_previous || timeline_has_next %}
- <nav aria-label="Timeline pages">
+ <nav class="pagination" aria-label="Timeline pages">
{% if timeline_has_previous %}
<a href="?comments_page={{ comments_page }}&timeline_page={{ timeline_previous_page }}">Newer events</a>
{% endif %}
templates/issues.html
Mode 100644 → 100644; object f331f9b5713b → 5f675dbd0a67
@@ -3,12 +3,15 @@
{% block content %}
<header class="repository-header">
<h1><a href="/{{ owner }}">{{ owner }}</a>/<a href="/{{ owner }}/{{ repository }}">{{ repository }}</a></h1>
+{% let nav_section = "issues" %}
{% include "repository-nav.html" %}
</header>
<header class="page-section-header">
<h2>Issues</h2>
- <a href="/{{ owner }}/{{ repository }}/issues/rss.xml">Issue RSS</a>
+ <a class="icon-link" href="/{{ owner }}/{{ repository }}/issues/rss.xml" aria-label="Issue RSS feed" title="Issue RSS feed">
+ <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="5" cy="19" r="1"></circle><path d="M4 11a9 9 0 0 1 9 9M4 5a15 15 0 0 1 15 15"></path></svg>
+ </a>
</header>
<section class="collection-panel" aria-label="Issues">
<form class="filter-form" method="get" action="/{{ owner }}/{{ repository }}/issues">
templates/logout.html
Mode 100644 → 100644; object 59b4d59d6bb1 → 9a7b6aeadeb8
@@ -10,7 +10,7 @@
<label><input type="radio" name="confirm" value="no" checked> No</label>
<label><input type="radio" name="confirm" value="yes"> Yes</label>
</fieldset>
- <button type="submit">Log out</button>
+ <button class="danger-action" type="submit">Log out</button>
<a href="/account">Cancel</a>
</form>
{% endblock %}
templates/pull_request.html
Mode 100644 → 100644; object 683b8e256dbd → 4fd82ff038fa
@@ -3,240 +3,318 @@
{% block content %}
<header class="repository-header">
<h1><a href="/{{ owner }}">{{ owner }}</a>/<a href="/{{ owner }}/{{ repository }}">{{ repository }}</a></h1>
+{% let nav_section = "pulls" %}
{% include "repository-nav.html" %}
</header>
- <article>
- <h2>#{{ pull_request.number }} {{ pull_request.title }}</h2>
- <p>{{ pull_request.state }} · opened by {{ pull_request.author }} at <time datetime="{{ pull_request.created_at }}">{{ pull_request.created_at|human_time }}</time> · updated <time datetime="{{ pull_request.updated_at }}">{{ pull_request.updated_at|human_time }}</time></p>
- <p>Base: <code>{{ pull_request.base_ref }}</code> at <code title="{{ pull_request.base_object_id }}">{{ pull_request.base_object_id|short_id }}</code></p>
- <p>Head: <code>{{ pull_request.head_ref }}</code> at <code title="{{ pull_request.head_object_id }}">{{ pull_request.head_object_id|short_id }}</code></p>
- <p>Fetch: <code>git fetch origin refs/pull/{{ pull_request.number }}/head</code></p>
- <div class="markdown">{{ body_html|safe }}</div>
+ <article class="pull-request-page">
+ <header class="pull-request-hero">
+ <div class="pull-request-title">
+ <div>
+ <p class="pull-request-kicker">Pull request #{{ pull_request.number }}</p>
+ <h2>{{ pull_request.title }}</h2>
+ </div>
+ <span class="pull-request-state">{{ pull_request.state }}</span>
+ </div>
+ <p class="pull-request-byline">Opened by <strong>{{ pull_request.author }}</strong> <time datetime="{{ pull_request.created_at }}">{{ pull_request.created_at|human_time }}</time> · updated <time datetime="{{ pull_request.updated_at }}">{{ pull_request.updated_at|human_time }}</time></p>
+ <div class="markdown pull-request-description">{{ body_html|safe }}</div>
+
+ <div class="pull-request-branches" aria-label="Pull request branches">
+ <div>
+ <span>From</span>
+ <code>{{ pull_request.head_ref }}</code>
+ <small title="{{ pull_request.head_object_id }}">{{ pull_request.head_object_id|short_id }}</small>
+ </div>
+ <span class="pull-request-direction" aria-hidden="true">→</span>
+ <div>
+ <span>Into</span>
+ <code>{{ pull_request.base_ref }}</code>
+ <small title="{{ pull_request.base_object_id }}">{{ pull_request.base_object_id|short_id }}</small>
+ </div>
+ </div>
+
+{% if can_edit || can_change_state %}
+ <div class="pull-request-actions">
{% if can_edit %}
- <details>
- <summary>Edit pull request</summary>
- <form method="post" action="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/edit">
- <input type="hidden" name="csrf" value="{{ csrf }}">
- <div class="field">
- <label for="pull-request-title">Title</label>
- <input id="pull-request-title" name="title" value="{{ pull_request.title }}" maxlength="200" required>
- </div>
- <div class="field">
- <label for="pull-request-body">Description (Markdown)</label>
- <textarea id="pull-request-body" name="body" maxlength="262144" rows="10">{{ pull_request.body }}</textarea>
- </div>
- <button type="submit">Save pull request</button>
- </form>
- </details>
+ <details>
+ <summary>Edit details</summary>
+ <form method="post" action="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/edit">
+ <input type="hidden" name="csrf" value="{{ csrf }}">
+ <div class="field">
+ <label for="pull-request-title">Title</label>
+ <input id="pull-request-title" name="title" value="{{ pull_request.title }}" maxlength="200" required>
+ </div>
+ <div class="field">
+ <label for="pull-request-body">Description (Markdown)</label>
+ <textarea id="pull-request-body" name="body" maxlength="262144" rows="10">{{ pull_request.body }}</textarea>
+ </div>
+ <button type="submit">Save details</button>
+ </form>
+ </details>
{% endif %}
{% if can_change_state %}
- <form method="post" action="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/state">
- <input type="hidden" name="csrf" value="{{ csrf }}">
+ <form method="post" action="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/state">
+ <input type="hidden" name="csrf" value="{{ csrf }}">
{% if is_open %}
- <button type="submit" name="state" value="closed">Close pull request</button>
+ <button class="danger-action" type="submit" name="state" value="closed">Close pull request</button>
{% else %}
- <button type="submit" name="state" value="open">Reopen pull request</button>
+ <button type="submit" name="state" value="open">Reopen pull request</button>
{% endif %}
- </form>
+ </form>
{% endif %}
- </article>
+ </div>
+{% endif %}
+ </header>
- <section aria-labelledby="revisions-heading">
- <h2 id="revisions-heading">Revisions</h2>
- <ol>
-{% for revision in revisions %}
- <li value="{{ revision.number }}">
- {{ revision.author }} recorded <code title="{{ revision.head_object_id }}">{{ revision.head_object_id|short_id }}</code> against <code title="{{ revision.base_object_id }}">{{ revision.base_object_id|short_id }}</code> at <time datetime="{{ revision.created_at }}">{{ revision.created_at|human_time }}</time>.
- <a href="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/revisions/{{ revision.number }}.patch">Download patch</a>
-{% if revision.number == selected_revision %}
- <strong>Selected</strong>
-{% else %}
- <a href="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}?revision={{ revision.number }}">Compare this revision</a>
-{% endif %}
- </li>
-{% endfor %}
- </ol>
- </section>
+ <section class="pull-request-section" aria-labelledby="changes-heading">
+ <div class="pull-request-section-heading">
+ <div>
+ <p class="pull-request-kicker">Revision {{ selected_revision }}</p>
+ <h2 id="changes-heading">Changes</h2>
+ </div>
+ <span class="pull-request-mergeability">{{ comparison.mergeability }}</span>
+ </div>
- <section aria-labelledby="reviews-heading">
- <h2 id="reviews-heading">Reviews</h2>
-{% if reviews.is_empty() %}
- <p>This pull request has no review activity.</p>
-{% else %}
-{% for review in reviews %}
- <article class="comment-card" id="review-{{ review.id }}">
- <h3>{{ review.author }}: {{ review.kind|event_name }} at <time datetime="{{ review.created_at }}">{{ review.created_at|human_time }}</time></h3>
- <p>Revision {{ review.revision }}
-{% if review.kind == "line-comment" %}
- · <code title="{{ review.commit_object_id }}">{{ review.commit_object_id|short_id }}</code> · <code>{{ review.path }}</code> · {{ review.side }} line {{ review.line }}
-{% if review.outdated %}
- · <strong>Outdated</strong>
-{% endif %}
-{% endif %}
- </p>
-{% if review.has_body %}
- <div class="markdown">{{ review.body_html|safe }}</div>
-{% endif %}
- </article>
-{% endfor %}
-{% endif %}
-{% if reviews_has_previous || reviews_has_next %}
- <nav aria-label="Review pages">
-{% if reviews_has_previous %}
- <a href="?revision={{ selected_revision }}&reviews_page={{ reviews_previous_page }}&timeline_page={{ timeline_page }}">Newer reviews</a>
-{% endif %}
-{% if reviews_has_next %}
- <a href="?revision={{ selected_revision }}&reviews_page={{ reviews_next_page }}&timeline_page={{ timeline_page }}">Older reviews</a>
-{% endif %}
- </nav>
-{% endif %}
- </section>
+ <dl class="pull-request-change-summary">
+ <div>
+ <dt>Commits</dt>
+ <dd>{{ comparison.commits.len() }}</dd>
+ </div>
+ <div>
+ <dt>Files changed</dt>
+ <dd>{{ comparison.files.len() }}</dd>
+ </div>
+ <div>
+ <dt>Merge base</dt>
+ <dd><code title="{{ comparison.merge_base }}">{{ comparison.merge_base|short_id }}</code></dd>
+ </div>
+ </dl>
- <section aria-labelledby="timeline-heading">
- <h2 id="timeline-heading">Timeline</h2>
- <ol>
-{% for event in timeline %}
- <li value="{{ event.sequence }}">{{ event.actor }} {{ event.kind|event_name }} at <time datetime="{{ event.created_at }}">{{ event.created_at|human_time }}</time></li>
-{% endfor %}
- </ol>
-{% if timeline_has_previous || timeline_has_next %}
- <nav aria-label="Timeline pages">
-{% if timeline_has_previous %}
- <a href="?revision={{ selected_revision }}&reviews_page={{ reviews_page }}&timeline_page={{ timeline_previous_page }}">Newer events</a>
-{% endif %}
-{% if timeline_has_next %}
- <a href="?revision={{ selected_revision }}&reviews_page={{ reviews_page }}&timeline_page={{ timeline_next_page }}">Older events</a>
-{% endif %}
- </nav>
-{% endif %}
- </section>
-
- <section aria-labelledby="comparison-heading">
- <h2 id="comparison-heading">Comparison for revision {{ selected_revision }}</h2>
- <p>Merge base: <code title="{{ comparison.merge_base }}">{{ comparison.merge_base|short_id }}</code></p>
- <p>Mergeability: {{ comparison.mergeability }}</p>
-
- <h3>Commits</h3>
+ <div class="pull-request-change-index">
+ <div>
+ <h3>Commits</h3>
{% if comparison.commits.is_empty() %}
- <p>This revision has no commits that are not in the base.</p>
+ <p>This revision has no commits that are not in the base.</p>
{% else %}
- <ol>
+ <ol>
{% for commit in comparison.commits %}
- <li><code title="{{ commit.id }}">{{ commit.id|short_id }}</code> <span class="preserve-whitespace">{{ commit.message }}</span></li>
+ <li><code title="{{ commit.id }}">{{ commit.id|short_id }}</code> <span class="preserve-whitespace">{{ commit.message }}</span></li>
{% endfor %}
- </ol>
+ </ol>
{% endif %}
-
- <h3>Changed paths</h3>
+ </div>
+ <div>
+ <h3>Files</h3>
{% if comparison.changed_paths.is_empty() %}
- <p>This revision does not change a path.</p>
+ <p>This revision does not change a path.</p>
{% else %}
- <ul>
+ <ul>
{% for path in comparison.changed_paths %}
- <li><code>{{ path }}</code></li>
+ <li><code>{{ path }}</code></li>
{% endfor %}
- </ul>
+ </ul>
{% endif %}
+ </div>
+ </div>
- <h3>Diff</h3>
+ <div class="pull-request-diff">
{% for file in comparison.files %}
- <article>
- <h4><code>{{ file.path }}</code></h4>
+ <article class="pull-request-file" id="diff-{{ file.path_hex }}">
+ <h3><code>{{ file.path }}</code></h3>
{% if file.binary %}
- <p>This file has binary content.</p>
+ <p>This file has binary content.</p>
{% else %}
- <pre>{{ file.hunks }}</pre>
+ <pre>{{ file.hunks }}</pre>
{% if can_review %}
- <form method="post" action="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/reviews">
- <input type="hidden" name="csrf" value="{{ csrf }}">
- <input type="hidden" name="revision" value="{{ selected_revision }}">
- <input type="hidden" name="kind" value="line-comment">
- <input type="hidden" name="path-hex" value="{{ file.path_hex }}">
- <div class="field">
- <label for="review-side-{{ file.path_hex }}">Side</label>
- <select id="review-side-{{ file.path_hex }}" name="side">
+ <details class="pull-request-line-comment">
+ <summary>Comment on a line</summary>
+ <form method="post" action="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/reviews">
+ <input type="hidden" name="csrf" value="{{ csrf }}">
+ <input type="hidden" name="revision" value="{{ selected_revision }}">
+ <input type="hidden" name="kind" value="line-comment">
+ <input type="hidden" name="path-hex" value="{{ file.path_hex }}">
+ <div class="pull-request-line-fields">
+ <div class="field">
+ <label for="review-side-{{ file.path_hex }}">Side</label>
+ <select id="review-side-{{ file.path_hex }}" name="side">
{% if file.has_base %}
- <option value="base">Base</option>
+ <option value="base">Base</option>
{% endif %}
{% if file.has_head %}
- <option value="head">Head</option>
+ <option value="head">Head</option>
{% endif %}
- </select>
- </div>
- <div class="field">
- <label for="review-line-{{ file.path_hex }}">Line</label>
- <input id="review-line-{{ file.path_hex }}" name="line" type="number" min="1" required>
- <p class="field-help">Enter a line number from the selected side in the diff above.</p>
- </div>
- <div class="field">
- <label for="review-body-{{ file.path_hex }}">Line comment (Markdown)</label>
- <textarea id="review-body-{{ file.path_hex }}" name="body" maxlength="262144" rows="4" required></textarea>
- </div>
- <button type="submit">Add line comment</button>
- </form>
+ </select>
+ </div>
+ <div class="field">
+ <label for="review-line-{{ file.path_hex }}">Line</label>
+ <input id="review-line-{{ file.path_hex }}" name="line" type="number" min="1" required>
+ </div>
+ </div>
+ <p class="field-help">Use a line number from the selected side in the diff above.</p>
+ <div class="field">
+ <label for="review-body-{{ file.path_hex }}">Comment (Markdown)</label>
+ <textarea id="review-body-{{ file.path_hex }}" name="body" maxlength="262144" rows="4" required></textarea>
+ </div>
+ <button type="submit">Add line comment</button>
+ </form>
+ </details>
{% endif %}
{% endif %}
- </article>
+ </article>
{% endfor %}
- </section>
+ </div>
+ </section>
+
+ <section class="pull-request-section" aria-labelledby="discussion-heading">
+ <div class="pull-request-section-heading">
+ <div>
+ <p class="pull-request-kicker">Review activity</p>
+ <h2 id="discussion-heading">Discussion</h2>
+ </div>
+ </div>
+{% if reviews.is_empty() %}
+ <p class="pull-request-empty">No review comments yet.</p>
+{% else %}
+ <div class="pull-request-discussion">
+{% for review in reviews %}
+ <article class="comment-card" id="review-{{ review.id }}">
+ <header>
+ <strong>{{ review.author }}</strong>
+ <span>{{ review.kind|event_name }} · <time datetime="{{ review.created_at }}">{{ review.created_at|human_time }}</time></span>
+ </header>
+{% if review.kind == "line-comment" %}
+ <p class="pull-request-comment-anchor"><code title="{{ review.commit_object_id }}">{{ review.commit_object_id|short_id }}</code> · <code>{{ review.path }}</code> · {{ review.side }} line {{ review.line }}{% if review.outdated %} · <strong>Outdated</strong>{% endif %}</p>
+{% endif %}
+{% if review.has_body %}
+ <div class="markdown">{{ review.body_html|safe }}</div>
+{% endif %}
+ </article>
+{% endfor %}
+ </div>
+{% endif %}
+{% if reviews_has_previous || reviews_has_next %}
+ <nav class="pagination" aria-label="Review pages">
+{% if reviews_has_previous %}
+ <a href="?revision={{ selected_revision }}&reviews_page={{ reviews_previous_page }}&timeline_page={{ timeline_page }}">Newer reviews</a>
+{% endif %}
+{% if reviews_has_next %}
+ <a href="?revision={{ selected_revision }}&reviews_page={{ reviews_next_page }}&timeline_page={{ timeline_page }}">Older reviews</a>
+{% endif %}
+ </nav>
+{% endif %}
+ </section>
+
+ <div class="pull-request-decision-grid">
+{% if can_review %}
+ <section class="pull-request-section" aria-labelledby="review-heading">
+ <p class="pull-request-kicker">Your decision</p>
+ <h2 id="review-heading">Review revision {{ selected_revision }}</h2>
+ <form method="post" action="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/reviews">
+ <input type="hidden" name="csrf" value="{{ csrf }}">
+ <input type="hidden" name="revision" value="{{ selected_revision }}">
+ <input type="hidden" name="path-hex" value="">
+ <input type="hidden" name="side" value="">
+ <input type="hidden" name="line" value="">
+ <div class="field">
+ <label for="review-body">Review comment (Markdown)</label>
+ <textarea id="review-body" name="body" maxlength="262144" rows="6"></textarea>
+ </div>
+ <div class="pull-request-review-actions">
+ <button name="kind" value="comment" type="submit">Comment</button>
+ <button name="kind" value="approved" type="submit">Approve</button>
+ <button class="danger-action" name="kind" value="changes-requested" type="submit">Request changes</button>
+ </div>
+ </form>
+ </section>
+{% endif %}
{% if can_merge %}
{% if comparison.mergeability == "fast-forward" %}
- <section aria-labelledby="merge-heading">
- <h2 id="merge-heading">Merge this pull request</h2>
- <form method="post" action="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/merge">
- <input type="hidden" name="csrf" value="{{ csrf }}">
- <input type="hidden" name="method" value="fast-forward">
- <fieldset class="confirmation">
- <legend>Merge this pull request?</legend>
- <label><input type="radio" name="confirm" value="no" checked> No</label>
- <label><input type="radio" name="confirm" value="yes"> Yes</label>
- </fieldset>
- <button type="submit">Fast-forward {{ pull_request.base_ref }}</button>
- </form>
- </section>
+ <section class="pull-request-section pull-request-merge" aria-labelledby="merge-heading">
+ <p class="pull-request-kicker">Ready to land</p>
+ <h2 id="merge-heading">Merge pull request</h2>
+ <p>This revision can be fast-forwarded into <code>{{ pull_request.base_ref }}</code>.</p>
+ <form method="post" action="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/merge">
+ <input type="hidden" name="csrf" value="{{ csrf }}">
+ <input type="hidden" name="method" value="fast-forward">
+ <fieldset class="confirmation">
+ <legend>Confirm merge</legend>
+ <label><input type="radio" name="confirm" value="no" checked> No</label>
+ <label><input type="radio" name="confirm" value="yes"> Yes</label>
+ </fieldset>
+ <button type="submit">Fast-forward merge</button>
+ </form>
+ </section>
{% else if comparison.mergeability == "clean merge" %}
- <section aria-labelledby="merge-heading">
- <h2 id="merge-heading">Merge this pull request</h2>
- <form method="post" action="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/merge">
- <input type="hidden" name="csrf" value="{{ csrf }}">
- <input type="hidden" name="method" value="merge-commit">
- <fieldset class="confirmation">
- <legend>Merge this pull request?</legend>
- <label><input type="radio" name="confirm" value="no" checked> No</label>
- <label><input type="radio" name="confirm" value="yes"> Yes</label>
- </fieldset>
- <button type="submit">Create a merge commit on {{ pull_request.base_ref }}</button>
- </form>
- </section>
+ <section class="pull-request-section pull-request-merge" aria-labelledby="merge-heading">
+ <p class="pull-request-kicker">Ready to land</p>
+ <h2 id="merge-heading">Merge pull request</h2>
+ <p>This revision can be merged into <code>{{ pull_request.base_ref }}</code> with a merge commit.</p>
+ <form method="post" action="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/merge">
+ <input type="hidden" name="csrf" value="{{ csrf }}">
+ <input type="hidden" name="method" value="merge-commit">
+ <fieldset class="confirmation">
+ <legend>Confirm merge</legend>
+ <label><input type="radio" name="confirm" value="no" checked> No</label>
+ <label><input type="radio" name="confirm" value="yes"> Yes</label>
+ </fieldset>
+ <button type="submit">Create merge commit</button>
+ </form>
+ </section>
{% endif %}
{% endif %}
+ </div>
-{% if can_review %}
- <section aria-labelledby="review-heading">
- <h2 id="review-heading">Review this revision</h2>
- <form method="post" action="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/reviews">
- <input type="hidden" name="csrf" value="{{ csrf }}">
- <input type="hidden" name="revision" value="{{ selected_revision }}">
- <input type="hidden" name="path-hex" value="">
- <input type="hidden" name="side" value="">
- <input type="hidden" name="line" value="">
- <div class="field">
- <label for="review-body">Review comment (Markdown)</label>
- <textarea id="review-body" name="body" maxlength="262144" rows="8"></textarea>
+ <details class="pull-request-history">
+ <summary>History and Git details</summary>
+ <div class="pull-request-history-grid">
+ <section aria-labelledby="revisions-heading">
+ <h2 id="revisions-heading">Revisions</h2>
+ <ol>
+{% for revision in revisions %}
+ <li value="{{ revision.number }}">
+ <code title="{{ revision.head_object_id }}">{{ revision.head_object_id|short_id }}</code> against <code title="{{ revision.base_object_id }}">{{ revision.base_object_id|short_id }}</code>
+ <span>recorded by {{ revision.author }} <time datetime="{{ revision.created_at }}">{{ revision.created_at|human_time }}</time></span>
+ <a href="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/revisions/{{ revision.number }}.patch">Patch</a>
+{% if revision.number == selected_revision %}
+ <strong>Selected</strong>
+{% else %}
+ <a href="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}?revision={{ revision.number }}">Compare</a>
+{% endif %}
+ </li>
+{% endfor %}
+ </ol>
+ </section>
+
+ <section aria-labelledby="timeline-heading">
+ <h2 id="timeline-heading">Activity</h2>
+ <ol>
+{% for event in timeline %}
+ <li value="{{ event.sequence }}">{{ event.actor }} {{ event.kind|event_name }} <time datetime="{{ event.created_at }}">{{ event.created_at|human_time }}</time></li>
+{% endfor %}
+ </ol>
+{% if timeline_has_previous || timeline_has_next %}
+ <nav class="pagination" aria-label="Timeline pages">
+{% if timeline_has_previous %}
+ <a href="?revision={{ selected_revision }}&reviews_page={{ reviews_page }}&timeline_page={{ timeline_previous_page }}">Newer events</a>
+{% endif %}
+{% if timeline_has_next %}
+ <a href="?revision={{ selected_revision }}&reviews_page={{ reviews_page }}&timeline_page={{ timeline_next_page }}">Older events</a>
+{% endif %}
+ </nav>
+{% endif %}
+ </section>
</div>
- <button name="kind" value="comment" type="submit">Add comment</button>
- <button name="kind" value="approved" type="submit">Approve</button>
- <button name="kind" value="changes-requested" type="submit">Request changes</button>
- </form>
- </section>
-{% endif %}
-
+ <div class="pull-request-git-details">
+ <span>Fetch this pull request</span>
+ <code>git fetch origin refs/pull/{{ pull_request.number }}/head</code>
+ </div>
{% if can_revise %}
- <form method="post" action="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/revisions">
- <input type="hidden" name="csrf" value="{{ csrf }}">
- <button type="submit">Record current branch heads</button>
- </form>
+ <form method="post" action="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/revisions">
+ <input type="hidden" name="csrf" value="{{ csrf }}">
+ <button type="submit">Record current branch heads</button>
+ </form>
{% endif %}
+ </details>
+ </article>
{% endblock %}
templates/pull_requests.html
Mode 100644 → 100644; object 361fef6487fc → 2e2d673aab41
@@ -3,6 +3,7 @@
{% block content %}
<header class="repository-header">
<h1><a href="/{{ owner }}">{{ owner }}</a>/<a href="/{{ owner }}/{{ repository }}">{{ repository }}</a></h1>
+{% let nav_section = "pulls" %}
{% include "repository-nav.html" %}
</header>
templates/recovery-code.html
Mode 100644 → 100644; object b9eb1278be38 → 0b0216f8064c
@@ -4,4 +4,5 @@
<h1>Save your recovery code</h1>
<p>This code is shown one time. Store it offline. It replaces all SSH keys when you use it.</p>
<pre><code>{{ recovery }}</code></pre>
+ <p class="page-actions"><a class="primary-action" href="/login">Continue to log in</a></p>
{% endblock %}
templates/repository-nav.html
Mode 100644 → 100644; object 3a5a38a01b14 → 899528461b8e
@@ -1,12 +1,26 @@
- <nav aria-label="Repository">
- <a href="/{{ owner }}/{{ repository }}">Summary</a>
- <a href="/{{ owner }}/{{ repository }}/refs">Refs</a>
- <a href="/{{ owner }}/{{ repository }}/issues">Issues</a>
- <a href="/{{ owner }}/{{ repository }}/pulls">Pull requests</a>
- <a href="/{{ owner }}/{{ repository }}/watch">Watch</a>
- <a href="/{{ owner }}/{{ repository }}/rss.xml">RSS</a>
- <a href="/{{ owner }}/{{ repository }}/search">Search</a>
-{% if signed_in %}
- <a href="/{{ owner }}/{{ repository }}/settings">Settings</a>
+ <nav class="repository-navigation" aria-label="Repository">
+ <div class="repository-tabs">
+ <a href="/{{ owner }}/{{ repository }}"{% if nav_section == "summary" %} aria-current="page"{% endif %}>Summary</a>
+ <a href="/{{ owner }}/{{ repository }}/issues"{% if nav_section == "issues" %} aria-current="page"{% endif %}>Issues</a>
+ <a href="/{{ owner }}/{{ repository }}/pulls"{% if nav_section == "pulls" %} aria-current="page"{% endif %}>Pull requests</a>
+ </div>
+ <div class="repository-tools">
+ <a class="icon-link" href="/{{ owner }}/{{ repository }}/refs" aria-label="Refs" title="Refs"{% if nav_section == "refs" %} aria-current="page"{% endif %}>
+ <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="6" cy="5" r="2"></circle><circle cx="6" cy="19" r="2"></circle><circle cx="18" cy="12" r="2"></circle><path d="M6 7v10M8 5h3a7 7 0 0 1 7 7"></path></svg>
+ </a>
+ <a class="icon-link" href="/{{ owner }}/{{ repository }}/watch" aria-label="Watch" title="Watch"{% if nav_section == "watch" %} aria-current="page"{% endif %}>
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"></path><circle cx="12" cy="12" r="2.5"></circle></svg>
+ </a>
+ <a class="icon-link" href="/{{ owner }}/{{ repository }}/rss.xml" aria-label="RSS feed" title="RSS feed">
+ <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="5" cy="19" r="1"></circle><path d="M4 11a9 9 0 0 1 9 9M4 5a15 15 0 0 1 15 15"></path></svg>
+ </a>
+ <a class="icon-link" href="/{{ owner }}/{{ repository }}/search" aria-label="Search repository" title="Search repository"{% if nav_section == "search" %} aria-current="page"{% endif %}>
+ <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="6"></circle><path d="m16 16 4 4"></path></svg>
+ </a>
+{% if can_manage %}
+ <a class="icon-link" href="/{{ owner }}/{{ repository }}/settings" aria-label="Repository settings" title="Repository settings"{% if nav_section == "settings" %} aria-current="page"{% endif %}>
+ <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1A1.7 1.7 0 0 0 9 4.6 1.7 1.7 0 0 0 10 3V2.8h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z"></path></svg>
+ </a>
{% endif %}
+ </div>
</nav>
templates/repository-settings.html
Mode 100644 → 100644; object 6d1d5a228e69 → ece938beea62
@@ -3,6 +3,7 @@
{% block content %}
<header class="repository-header">
<h1><a href="/{{ owner }}">{{ owner }}</a>/<a href="/{{ owner }}/{{ repository }}">{{ repository }}</a></h1>
+{% let nav_section = "settings" %}
{% include "repository-nav.html" %}
</header>
@@ -56,7 +57,7 @@
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="username" value="{{ collaborator.username }}">
<input type="hidden" name="role" value="{{ collaborator.role }}">
- <button type="submit" name="action" value="remove">Remove</button>
+ <button class="danger-action" type="submit" name="action" value="remove">Remove</button>
</form>
</li>
{% endfor %}
@@ -104,7 +105,7 @@
<label><input type="radio" name="confirm" value="no" checked> No</label>
<label><input type="radio" name="confirm" value="yes"> Yes</label>
</fieldset>
- <button type="submit">Archive repository</button>
+ <button class="danger-action" type="submit">Archive repository</button>
</form>
</section>
{% endblock %}
templates/repository.html
Mode 100644 → 100644; object 490e30a01b57 → dea4aac39f67
@@ -3,20 +3,40 @@
{% block content %}
<header class="repository-header">
<h1><a href="/{{ owner }}">{{ owner }}</a>/<a href="/{{ owner }}/{{ repository }}">{{ repository }}</a></h1>
+{% let nav_section = *page_kind %}
{% include "repository-nav.html" %}
</header>
{% if has_head && page_kind != "summary" %}
- <p><a href="/{{ owner }}/{{ repository }}/tree/{{ commit_id }}">Browse tree</a> · <a href="/{{ owner }}/{{ repository }}/commit/{{ commit_id }}">Show commit</a> · <a href="/{{ owner }}/{{ repository }}/archive/{{ commit_id }}.tar">Download archive</a></p>
+ <nav class="repository-toolbar" aria-label="Repository revision">
+ <div class="repository-context">
+ <a href="/{{ owner }}/{{ repository }}/commit/{{ commit_id }}" aria-label="Commit {{ commit_id }}"><code title="{{ commit_id }}">{{ commit_id|short_id }}</code></a>
+ </div>
+ <div class="repository-actions">
+{% if page_kind != "tree" %}
+ <a class="icon-link" href="/{{ owner }}/{{ repository }}/tree/{{ commit_id }}" aria-label="Browse tree" title="Browse tree">
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 6.5h7l2 2h9v10H3z"></path></svg>
+ </a>
+{% endif %}
+ <a class="icon-link" href="/{{ owner }}/{{ repository }}/archive/{{ commit_id }}.tar" aria-label="Download archive" title="Download archive">
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12m-4-4 4 4 4-4M4 20h16"></path></svg>
+ </a>
+ </div>
+ </nav>
{% endif %}
{% if page_kind == "summary" %}
{% if has_head %}
<nav class="repository-toolbar" aria-label="Repository files">
- <strong>{{ default_branch }}</strong>
- <a href="/{{ owner }}/{{ repository }}/tree/{{ commit_id }}">Browse files</a>
- <a href="/{{ owner }}/{{ repository }}/commit/{{ commit_id }}"><code title="{{ commit_id }}">{{ commit_id|short_id }}</code></a>
- <a href="/{{ owner }}/{{ repository }}/archive/{{ commit_id }}.tar">Download archive</a>
+ <div class="repository-context">
+ <strong>{{ default_branch }}</strong>
+ <a href="/{{ owner }}/{{ repository }}/commit/{{ commit_id }}" aria-label="Commit {{ commit_id }}"><code title="{{ commit_id }}">{{ commit_id|short_id }}</code></a>
+ </div>
+ <div class="repository-actions">
+ <a class="icon-link" href="/{{ owner }}/{{ repository }}/archive/{{ commit_id }}.tar" aria-label="Download archive" title="Download archive">
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12m-4-4 4 4 4-4M4 20h16"></path></svg>
+ </a>
+ </div>
</nav>
{% endif %}
<div class="repository-summary-layout">
@@ -28,7 +48,7 @@
<tbody>
{% for item in entries %}
<tr>
- <th scope="row"><a href="/{{ owner }}/{{ repository }}/{{ item.href }}">{{ item.name }}</a></th>
+ <th scope="row"><a href="/{{ owner }}/{{ repository }}/{{ item.href }}">{{ item.name }}{% if item.kind == "tree" %}/{% endif %}</a></th>
<td>{{ item.kind }}</td>
<td><code title="{{ item.id }}">{{ item.id|short_id }}</code></td>
</tr>
@@ -41,6 +61,16 @@
</section>
{% else %}
<p>This repository has no commits.</p>
+{% endif %}
+{% if has_readme %}
+ <section class="repository-panel repository-readme" aria-labelledby="readme-heading">
+ <h2 id="readme-heading">{{ readme_path }}</h2>
+{% if readme_binary %}
+ <p>Binary README content cannot be shown.</p>
+{% else %}
+ <div class="markdown">{{ readme_html|safe }}</div>
+{% endif %}
+ </section>
{% endif %}
</div>
<aside class="repository-sidebar" aria-label="Repository information">
@@ -90,16 +120,6 @@
</section>
</aside>
</div>
-{% if has_readme %}
- <section class="repository-panel repository-readme" aria-labelledby="readme-heading">
- <h2 id="readme-heading">{{ readme_path }}</h2>
-{% if readme_binary %}
- <p>Binary README content cannot be shown.</p>
-{% else %}
- <div class="markdown">{{ readme_html|safe }}</div>
-{% endif %}
- </section>
-{% endif %}
{% endif %}
{% if page_kind == "commits" %}
@@ -149,7 +169,7 @@
{% for item in entries %}
<tr>
<td><code>{{ item.mode }}</code></td><td>{{ item.kind }}</td>
- <th scope="row"><a href="/{{ owner }}/{{ repository }}/{{ item.href }}">{{ item.name }}</a></th>
+ <th scope="row"><a href="/{{ owner }}/{{ repository }}/{{ item.href }}">{{ item.name }}{% if item.kind == "tree" %}/{% endif %}</a></th>
<td><code title="{{ item.id }}">{{ item.id|short_id }}</code></td>
</tr>
{% endfor %}
templates/watch.html
Mode 100644 → 100644; object 172c27e5483c → 610ffb2e4440
@@ -3,6 +3,7 @@
{% block content %}
<header class="repository-header">
<h1><a href="/{{ owner }}">{{ owner }}</a>/<a href="/{{ owner }}/{{ repository }}">{{ repository }}</a></h1>
+{% let nav_section = "watch" %}
{% include "repository-nav.html" %}
</header>
tests/git_reads.rs
Mode 100644 → 100644; object b08b72e0ca2b → e79c2d033d00
@@ -75,18 +75,25 @@
.map(|entry| entry.name.as_slice())
.collect::<Vec<_>>(),
[
+ b"docs".as_slice(),
+ b"src".as_slice(),
b"README.md".as_slice(),
b"binary".as_slice(),
- b"docs".as_slice(),
b"mode.sh".as_slice(),
- b"renamed.txt".as_slice(),
- b"src".as_slice()
+ b"renamed.txt".as_slice()
]
);
let (root_prefix, truncated) = service
.tree_prefix(fixture.second, b"", 2, &cancellation)
.expect("read a root-tree prefix");
assert_eq!(root_prefix.len(), 2);
+ assert_eq!(
+ root_prefix
+ .iter()
+ .map(|entry| entry.name.as_slice())
+ .collect::<Vec<_>>(),
+ [b"docs".as_slice(), b"src".as_slice()]
+ );
assert!(truncated);
let metadata = service
.commit_metadata(&[fixture.first, fixture.second], &cancellation)
tests/repository_policy.rs
Mode 100644 → 100644; object 2defcbea37b2 → c4ddedc399b3
@@ -81,6 +81,21 @@
for operation in [RepositoryOperation::Write, RepositoryOperation::Maintain] {
assert_denied(&policy, Some("reader"), operation);
}
+ assert!(
+ store
+ .repository_can_maintain("owner", "project", "owner")
+ .expect("read owner permission")
+ );
+ assert!(
+ store
+ .repository_can_maintain("owner", "project", "maintainer")
+ .expect("read maintainer permission")
+ );
+ assert!(
+ !store
+ .repository_can_maintain("owner", "project", "writer")
+ .expect("read writer permission")
+ );
for actor in [None, Some("stranger"), Some("suspended"), Some("missing")] {
for operation in operations() {
assert_denied(&policy, actor, operation);
tests/snapshots/web/home.html
Mode 100644 → 100644; object ea15f1724366 → 20512661677a
@@ -4,38 +4,66 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Repositories · tit</title>
- <link rel="stylesheet" href="/assets/style.css?v=2">
+ <link rel="stylesheet" href="/assets/style.css?v=10">
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
+ <div class="background-icon" aria-hidden="true">
+ <svg viewBox="0 0 24 24">
+ <circle cx="6" cy="5" r="2"/>
+ <circle cx="6" cy="19" r="2"/>
+ <circle cx="18" cy="12" r="2"/>
+ <path d="M6 7v10"/>
+ <path d="M8 7c0 3 2 5 8 5"/>
+ </svg>
+ </div>
<header class="site-header">
- <a class="brand" href="/" aria-label="tit home">tit</a>
+ <a class="brand" href="/" aria-label="tit home">
+ <svg class="brand-icon" viewBox="0 0 24 24" aria-hidden="true">
+ <circle cx="6" cy="5" r="2"/>
+ <circle cx="6" cy="19" r="2"/>
+ <circle cx="18" cy="12" r="2"/>
+ <path d="M6 7v10"/>
+ <path d="M8 7c0 3 2 5 8 5"/>
+ </svg>
+ tit
+ </a>
<nav aria-label="Primary">
- <a href="/">Home</a>
- <a href="/search">Search</a>
+ <a class="icon-link" href="/" aria-label="Home" title="Home">
+ <svg viewBox="0 0 24 24" aria-hidden="true">
+ <path d="M3 11.5 12 4l9 7.5"/>
+ <path d="M5.5 10.5V20h13v-9.5"/>
+ <path d="M9.5 20v-6h5v6"/>
+ </svg>
+ </a>
+ <a class="icon-link" href="/search" aria-label="Search" title="Search">
+ <svg viewBox="0 0 24 24" aria-hidden="true">
+ <circle cx="11" cy="11" r="7"/>
+ <path d="m20 20-4-4"/>
+ </svg>
+ </a>
- <a href="/signup">Create account</a>
- <a href="/recover">Recover account</a>
- <a href="/login">Log in</a>
+ <a class="icon-link" href="/account" aria-label="Account" title="Account">
+ <svg viewBox="0 0 24 24" aria-hidden="true">
+ <circle cx="12" cy="8" r="4"/>
+ <path d="M4 21a8 8 0 0 1 16 0"/>
+ </svg>
+ </a>
</nav>
</header>
+ <hr class="site-header-rule">
<main id="main">
- <div class="two-column">
+ <div class="two-column home-grid">
<section class="home-intro">
<h1>A small Git CDE</h1>
<p>tit provides self-hosted Git repositories, issues, pull requests, and code review in one server.</p>
- <nav class="home-actions" aria-label="Get started">
- <a href="/signup">Create account</a>
- <a href="/login">Log in</a>
- <a href="/search">Search repositories</a>
- </nav>
</section>
<section>
- <h2>Recently updated public repositories</h2>
+ <h2>Recently updated</h2>
<p>No repositories are available.</p>
@@ -65,6 +93,7 @@
</main>
<footer>
+ <small>tit · open source · MIT licensed</small>
<small>Request <code><request-id></code></small>
</footer>
</body>
tests/snapshots/web/method-not-allowed.html
Mode 100644 → 100644; object a72ec73ee6b3 → c45062f44849
@@ -4,22 +4,55 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Method not allowed · tit</title>
- <link rel="stylesheet" href="/assets/style.css?v=2">
+ <link rel="stylesheet" href="/assets/style.css?v=10">
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
+ <div class="background-icon" aria-hidden="true">
+ <svg viewBox="0 0 24 24">
+ <circle cx="6" cy="5" r="2"/>
+ <circle cx="6" cy="19" r="2"/>
+ <circle cx="18" cy="12" r="2"/>
+ <path d="M6 7v10"/>
+ <path d="M8 7c0 3 2 5 8 5"/>
+ </svg>
+ </div>
<header class="site-header">
- <a class="brand" href="/" aria-label="tit home">tit</a>
+ <a class="brand" href="/" aria-label="tit home">
+ <svg class="brand-icon" viewBox="0 0 24 24" aria-hidden="true">
+ <circle cx="6" cy="5" r="2"/>
+ <circle cx="6" cy="19" r="2"/>
+ <circle cx="18" cy="12" r="2"/>
+ <path d="M6 7v10"/>
+ <path d="M8 7c0 3 2 5 8 5"/>
+ </svg>
+ tit
+ </a>
<nav aria-label="Primary">
- <a href="/">Home</a>
- <a href="/search">Search</a>
+ <a class="icon-link" href="/" aria-label="Home" title="Home">
+ <svg viewBox="0 0 24 24" aria-hidden="true">
+ <path d="M3 11.5 12 4l9 7.5"/>
+ <path d="M5.5 10.5V20h13v-9.5"/>
+ <path d="M9.5 20v-6h5v6"/>
+ </svg>
+ </a>
+ <a class="icon-link" href="/search" aria-label="Search" title="Search">
+ <svg viewBox="0 0 24 24" aria-hidden="true">
+ <circle cx="11" cy="11" r="7"/>
+ <path d="m20 20-4-4"/>
+ </svg>
+ </a>
- <a href="/signup">Create account</a>
- <a href="/recover">Recover account</a>
- <a href="/login">Log in</a>
+ <a class="icon-link" href="/account" aria-label="Account" title="Account">
+ <svg viewBox="0 0 24 24" aria-hidden="true">
+ <circle cx="12" cy="8" r="4"/>
+ <path d="M4 21a8 8 0 0 1 16 0"/>
+ </svg>
+ </a>
</nav>
</header>
+ <hr class="site-header-rule">
<main id="main">
<h1>Method not allowed</h1>
@@ -28,6 +61,7 @@
</main>
<footer>
+ <small>tit · open source · MIT licensed</small>
<small>Request <code><request-id></code></small>
</footer>
</body>
tests/snapshots/web/not-found.html
Mode 100644 → 100644; object 99463ba8d462 → fca64b2d6ac4
@@ -4,22 +4,55 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Page not found · tit</title>
- <link rel="stylesheet" href="/assets/style.css?v=2">
+ <link rel="stylesheet" href="/assets/style.css?v=10">
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
+ <div class="background-icon" aria-hidden="true">
+ <svg viewBox="0 0 24 24">
+ <circle cx="6" cy="5" r="2"/>
+ <circle cx="6" cy="19" r="2"/>
+ <circle cx="18" cy="12" r="2"/>
+ <path d="M6 7v10"/>
+ <path d="M8 7c0 3 2 5 8 5"/>
+ </svg>
+ </div>
<header class="site-header">
- <a class="brand" href="/" aria-label="tit home">tit</a>
+ <a class="brand" href="/" aria-label="tit home">
+ <svg class="brand-icon" viewBox="0 0 24 24" aria-hidden="true">
+ <circle cx="6" cy="5" r="2"/>
+ <circle cx="6" cy="19" r="2"/>
+ <circle cx="18" cy="12" r="2"/>
+ <path d="M6 7v10"/>
+ <path d="M8 7c0 3 2 5 8 5"/>
+ </svg>
+ tit
+ </a>
<nav aria-label="Primary">
- <a href="/">Home</a>
- <a href="/search">Search</a>
+ <a class="icon-link" href="/" aria-label="Home" title="Home">
+ <svg viewBox="0 0 24 24" aria-hidden="true">
+ <path d="M3 11.5 12 4l9 7.5"/>
+ <path d="M5.5 10.5V20h13v-9.5"/>
+ <path d="M9.5 20v-6h5v6"/>
+ </svg>
+ </a>
+ <a class="icon-link" href="/search" aria-label="Search" title="Search">
+ <svg viewBox="0 0 24 24" aria-hidden="true">
+ <circle cx="11" cy="11" r="7"/>
+ <path d="m20 20-4-4"/>
+ </svg>
+ </a>
- <a href="/signup">Create account</a>
- <a href="/recover">Recover account</a>
- <a href="/login">Log in</a>
+ <a class="icon-link" href="/account" aria-label="Account" title="Account">
+ <svg viewBox="0 0 24 24" aria-hidden="true">
+ <circle cx="12" cy="8" r="4"/>
+ <path d="M4 21a8 8 0 0 1 16 0"/>
+ </svg>
+ </a>
</nav>
</header>
+ <hr class="site-header-rule">
<main id="main">
<h1>Page not found</h1>
@@ -28,6 +61,7 @@
</main>
<footer>
+ <small>tit · open source · MIT licensed</small>
<small>Request <code><request-id></code></small>
</footer>
</body>
tests/web_shell.rs
Mode 100644 → 100644; object 2bdafb662ad2 → f2795d5f7a3e
@@ -19,6 +19,7 @@
assert_eq!(home.header("content-type"), "text/html; charset=utf-8");
assert_eq!(home.header("cache-control"), "no-store");
assert!(home.body.contains("<header class=\"site-header\">"));
+ assert!(home.body.contains("<hr class=\"site-header-rule\">"));
assert!(home.body.contains("<nav aria-label=\"Primary\">"));
assert!(home.body.contains("<main id=\"main\">"));
assert!(home.body.contains("<footer>"));
@@ -27,6 +28,18 @@
assert!(!home.body.to_ascii_lowercase().contains("<script"));
assert_security_policy(&home);
assert_snapshot(&home, include_str!("snapshots/web/home.html"));
+
+ let account = request(server.address(), "GET", "/account", &[]).await;
+ assert_eq!(account.status, 200);
+ assert!(
+ account
+ .body
+ .contains("<h1 id=\"account-heading\">Account</h1>")
+ );
+ assert!(account.body.contains("href=\"/login\""));
+ assert!(account.body.contains("href=\"/signup\""));
+ assert!(account.body.contains("href=\"/recover\""));
+ assert_security_policy(&account);
let request_id = home.header("x-request-id");
assert_request_id(request_id);
@@ -53,8 +66,12 @@
assert_eq!(css.header("content-type"), "text/css; charset=utf-8");
assert_eq!(css.header("cache-control"), "no-cache");
assert_eq!(css.body, include_str!("../assets/style.css"));
+ assert!(css.body.contains("font-family: \"Space Mono\""));
+ assert!(css.body.contains(".background-icon"));
assert!(css.body.contains("@media (max-width: 44rem)"));
assert!(css.body.contains(".two-column"));
+ assert!(css.body.contains(".home-grid > section"));
+ assert!(css.body.contains(".repository-navigation"));
assert_security_policy(&css);
let css_head = request(server.address(), "HEAD", "/assets/style.css", &[]).await;
@@ -64,6 +81,34 @@
css_head.header("content-length"),
css.body.len().to_string()
);
+
+ let regular_font = request(
+ server.address(),
+ "HEAD",
+ "/assets/SpaceMono-Regular.ttf",
+ &[],
+ )
+ .await;
+ assert_eq!(regular_font.status, 200);
+ assert_eq!(regular_font.header("content-type"), "font/ttf");
+ assert_eq!(
+ regular_font.header("content-length"),
+ include_bytes!("../assets/SpaceMono-Regular.ttf")
+ .len()
+ .to_string()
+ );
+ assert_security_policy(®ular_font);
+
+ let bold_font = request(server.address(), "HEAD", "/assets/SpaceMono-Bold.ttf", &[]).await;
+ assert_eq!(bold_font.status, 200);
+ assert_eq!(bold_font.header("content-type"), "font/ttf");
+ assert_eq!(
+ bold_font.header("content-length"),
+ include_bytes!("../assets/SpaceMono-Bold.ttf")
+ .len()
+ .to_string()
+ );
+ assert_security_policy(&bold_font);
let signup = request(server.address(), "GET", "/signup", &[]).await;
assert_eq!(signup.status, 200);
@@ -287,7 +332,7 @@
fn assert_security_policy(response: &HttpResponse) {
assert_eq!(
response.header("content-security-policy"),
- "default-src 'none'; style-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"
+ "default-src 'none'; style-src 'self'; font-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"
);
assert_eq!(response.header("x-content-type-options"), "nosniff");
assert_eq!(response.header("x-frame-options"), "DENY");