michal/tit

Browse tree · Show commit · Download archive

Diff

82a09578b0e3dd1017765cbd

assets/style.css

Mode 100644100644; object a914c92a65b0d5e5709e4cf6

@@ -17,6 +17,7 @@
 
 .site-header {
   display: flex;
+  flex-wrap: wrap;
   align-items: baseline;
   justify-content: space-between;
   gap: 1rem;
@@ -50,8 +51,11 @@
   margin-block: 0.5rem;
 }
 
-.repository-header nav a {
-  margin-right: 1rem;
+.site-header nav,
+.repository-header nav {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.5rem 1rem;
 }
 
 dl {
@@ -68,6 +72,11 @@
 table {
   width: 100%;
   border-collapse: collapse;
+}
+
+.table-scroll {
+  max-width: 100%;
+  overflow-x: auto;
 }
 
 th,
@@ -119,6 +128,11 @@
   width: 100%;
 }
 
+input[type="radio"],
+input[type="checkbox"] {
+  width: auto;
+}
+
 textarea {
   width: 100%;
   resize: vertical;
@@ -141,6 +155,36 @@
 .error {
   padding: 0.75rem;
   border-left: 0.25rem solid #b42318;
+}
+
+.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;
+}
+
+.pagination {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 1rem;
+}
+
+.confirmation {
+  margin-block: 0.75rem;
+  border: 1px solid GrayText;
+}
+
+.confirmation label {
+  display: inline-block;
+  margin-right: 1rem;
+  font-weight: 400;
 }
 
 .skip-link {

src/http/feeds.rs

Mode 100644100644; object 6cd94355ff00fd6989160393

@@ -11,6 +11,7 @@
 use crate::feed_token::{FeedTokenError, IssuedFeedToken};
 use crate::store::{FeedTokenRecord, StoreError};
 
+use super::filters;
 use super::public::conditional_feed;
 use super::{
     CSRF_COOKIE, RequestId, SESSION_COOKIE, WebState, authenticate_mutation, cookie, login_job,
@@ -110,7 +111,7 @@
     headers: HeaderMap,
     body: Bytes,
 ) -> Response {
-    let fields = match parse_named_form(&headers, &body, &["csrf"]) {
+    let fields = match parse_named_form(&headers, &body, &["csrf", "confirm"]) {
         Ok(fields) => fields,
         Err(()) => return feed_bad_request(&request_id.0),
     };
@@ -119,6 +120,9 @@
             Ok(actor) => actor,
             Err(response) => return response,
         };
+    if fields[1] != "yes" {
+        return feed_tokens_redirect();
+    }
     let Some(service) = state.feeds.clone() else {
         return feed_internal(&request_id.0);
     };
@@ -133,7 +137,7 @@
     headers: HeaderMap,
     body: Bytes,
 ) -> Response {
-    let fields = match parse_named_form(&headers, &body, &["csrf"]) {
+    let fields = match parse_named_form(&headers, &body, &["csrf", "confirm"]) {
         Ok(fields) => fields,
         Err(()) => return feed_bad_request(&request_id.0),
     };
@@ -142,19 +146,26 @@
             Ok(actor) => actor,
             Err(response) => return response,
         };
+    if fields[1] != "yes" {
+        return feed_tokens_redirect();
+    }
     let Some(service) = state.feeds.clone() else {
         return feed_internal(&request_id.0);
     };
     let result = feed_job(state, move || service.revoke(&actor, &path.id)).await;
     match result {
-        Ok(()) => Response::builder()
-            .status(StatusCode::SEE_OTHER)
-            .header(header::LOCATION, "/feeds")
-            .header(header::CACHE_CONTROL, "no-store")
-            .body(axum::body::Body::empty())
-            .expect("the feed token redirect is valid"),
+        Ok(()) => feed_tokens_redirect(),
         Err(error) => feed_management_error(error, &request_id.0),
     }
+}
+
+fn feed_tokens_redirect() -> Response {
+    Response::builder()
+        .status(StatusCode::SEE_OTHER)
+        .header(header::LOCATION, "/feeds")
+        .header(header::CACHE_CONTROL, "no-store")
+        .body(axum::body::Body::empty())
+        .expect("the feed token redirect is valid")
 }
 
 async fn atom_feed(

src/http/issues.rs

Mode 100644100644; object 0c098a44c4549de454ffeb87

@@ -11,6 +11,7 @@
 use crate::markdown::{self, RenderedMarkdown};
 use crate::store::{IssueDetail, StoreError};
 
+use super::filters;
 use super::{
     CSRF_COOKIE, RequestActor, RequestId, WebState, authenticate_mutation, cookie,
     parse_named_form, render, render_error,

src/http/mod.rs

Mode 100644100644; object 67e654a01d9a725c24ad1b60

@@ -56,6 +56,48 @@
 const CSRF_COOKIE: &str = "tit-csrf";
 const LOGIN_CSRF_COOKIE: &str = "tit-login-csrf";
 
+mod filters {
+    use askama::{Result, Values};
+
+    #[askama::filter_fn]
+    pub fn human_time<T: std::borrow::Borrow<i64>>(timestamp: T, _: &dyn Values) -> Result<String> {
+        Ok(
+            jiff::Timestamp::from_second(*timestamp.borrow()).map_or_else(
+                |_| "Invalid time".to_owned(),
+                |timestamp| timestamp.strftime("%Y-%m-%d %H:%M UTC").to_string(),
+            ),
+        )
+    }
+
+    #[askama::filter_fn]
+    pub fn short_id<T: AsRef<str>>(id: T, _: &dyn Values) -> Result<String> {
+        Ok(id.as_ref().chars().take(12).collect())
+    }
+
+    #[askama::filter_fn]
+    pub fn event_name<T: AsRef<str>>(kind: T, _: &dyn Values) -> Result<String> {
+        let name = match kind.as_ref() {
+            "issue-created" => "created the issue",
+            "issue-edited" => "edited the issue",
+            "issue-commented" => "added a comment",
+            "issue-opened" | "issue-reopened" => "reopened the issue",
+            "issue-closed" => "closed the issue",
+            "issue-labeled" => "added a label",
+            "issue-unlabeled" => "removed a label",
+            "issue-assigned" => "assigned the issue",
+            "issue-unassigned" => "removed an assignee",
+            "pull-request-opened" => "opened the pull request",
+            "pull-request-revised" => "recorded a new revision",
+            "pull-request-commented" => "added a review comment",
+            "pull-request-approved" => "approved the pull request",
+            "pull-request-changes-requested" => "requested changes",
+            "pull-request-merged" => "merged the pull request",
+            other => return Ok(other.replace('-', " ")),
+        };
+        Ok(name.to_owned())
+    }
+}
+
 #[derive(Clone)]
 struct WebState {
     public: Option<PublicWeb>,
@@ -1059,7 +1101,7 @@
     headers: HeaderMap,
     body: Bytes,
 ) -> Response {
-    let fields = match parse_named_form(&headers, &body, &["csrf"]) {
+    let fields = match parse_named_form(&headers, &body, &["csrf", "confirm"]) {
         Ok(fields) => fields,
         Err(()) => {
             return render_error(
@@ -1083,6 +1125,14 @@
             "Forbidden",
             "The request is not authorized.",
         );
+    }
+    if fields[1] != "yes" {
+        return Response::builder()
+            .status(StatusCode::SEE_OTHER)
+            .header(header::LOCATION, "/account")
+            .header(header::CACHE_CONTROL, "no-store")
+            .body(Body::empty())
+            .expect("the account redirect is valid");
     }
     let result = login_job(state, move |login| {
         let session = login.authenticate(&session_token, Some(&csrf))?;
@@ -1434,24 +1484,30 @@
         .expect("the embedded CSS response is valid")
 }
 
-async fn not_found(Extension(request_id): Extension<RequestId>) -> Response {
-    render_error(
+async fn not_found(
+    Extension(request_id): Extension<RequestId>,
+    Extension(actor): Extension<RequestActor>,
+) -> Response {
+    render_error_with_auth(
         StatusCode::NOT_FOUND,
         &request_id.0,
         "Page not found",
         "The requested page does not exist.",
+        actor.0.is_some(),
     )
 }
 
 async fn method_not_allowed(
     Extension(request_id): Extension<RequestId>,
+    Extension(actor): Extension<RequestActor>,
     OriginalUri(uri): OriginalUri,
 ) -> Response {
-    let mut response = render_error(
+    let mut response = render_error_with_auth(
         StatusCode::METHOD_NOT_ALLOWED,
         &request_id.0,
         "Method not allowed",
         "This page does not accept the request method.",
+        actor.0.is_some(),
     );
     let allow = match uri.path() {
         "/signup" | "/recover" | "/login" | "/logout" => "GET, HEAD, POST",
@@ -1580,13 +1636,23 @@
 }
 
 fn render_error(status: StatusCode, request_id: &str, heading: &str, message: &str) -> Response {
+    render_error_with_auth(status, request_id, heading, message, false)
+}
+
+fn render_error_with_auth(
+    status: StatusCode,
+    request_id: &str,
+    heading: &str,
+    message: &str,
+    signed_in: bool,
+) -> Response {
     render(
         status,
         &ErrorTemplate {
             request_id,
             status: heading,
             message,
-            signed_in: false,
+            signed_in,
         },
     )
 }

src/http/public.rs

Mode 100644100644; object dcb4b473bfa65548a45f83b0

@@ -33,10 +33,12 @@
 use crate::policy::{PolicyError, RepositoryOperation, RepositoryPolicy};
 use crate::store::{DATABASE_FILE, RepositoryRecord, Store, StoreError};
 
-use super::{PublicWebConfig, RequestActor, RequestId, WebState, render_error};
+use super::filters;
+use super::{PublicWebConfig, RequestActor, RequestId, WebState, render_error_with_auth};
 
 const MAX_HISTORY_COMMITS: usize = 10_000;
 const MAX_SUMMARY_COMMITS: usize = 10;
+const COMMITS_PER_PAGE: usize = 100;
 const MAX_SEARCH_QUERY_BYTES: usize = 256;
 
 #[derive(Clone)]
@@ -84,6 +86,24 @@
 
     pub(super) fn http_clone_base(&self) -> &str {
         &self.http_clone_base
+    }
+
+    pub(super) async fn branch_names(
+        &self,
+        actor: Option<String>,
+        owner: String,
+        repository: String,
+    ) -> Result<Vec<String>, RouteError> {
+        self.read(actor, owner, repository, |_record, service| {
+            let cancellation = ReadCancellation::default();
+            Ok(service
+                .references(&cancellation)?
+                .into_iter()
+                .filter(|reference| reference.name.starts_with(b"refs/heads/"))
+                .map(|reference| display_bytes(&reference.name))
+                .collect())
+        })
+        .await
     }
 
     async fn read<T, F>(
@@ -491,11 +511,16 @@
     };
     let signed_in = actor.0.is_some();
     let result = web
-        .read(actor.0, path.owner, path.repository, |record, service| {
-            let cancellation = ReadCancellation::default();
-            let references = service.references(&cancellation)?;
-            Ok(RepositoryPage::refs(record, references))
-        })
+        .read(
+            actor.0,
+            path.owner,
+            path.repository,
+            move |record, service| {
+                let cancellation = ReadCancellation::default();
+                let references = service.references(&cancellation)?;
+                Ok(RepositoryPage::refs(record, references))
+            },
+        )
         .await;
     render_page(result, &request_id.0, signed_in)
 }
@@ -505,25 +530,35 @@
     Extension(request_id): Extension<RequestId>,
     Extension(actor): Extension<RequestActor>,
     AxumPath(path): AxumPath<RepositoryPath>,
+    Query(query): Query<CommitQuery>,
 ) -> Response {
     let Some(web) = state.public else {
         return route_error(RouteError::NotFound, &request_id.0);
     };
     let signed_in = actor.0.is_some();
+    let page = query.page.unwrap_or(1);
+    if page == 0 {
+        return route_error(RouteError::InvalidRequest, &request_id.0);
+    }
     let result = web
-        .read(actor.0, path.owner, path.repository, |record, service| {
-            let cancellation = ReadCancellation::default();
-            let references = service.references(&cancellation)?;
-            let head = references
-                .iter()
-                .find(|reference| reference.name == b"HEAD")
-                .map(|reference| reference.target);
-            let history = match head {
-                Some(head) => service.history(head, &cancellation)?,
-                None => Vec::new(),
-            };
-            Ok(RepositoryPage::commits(record, head, history))
-        })
+        .read(
+            actor.0,
+            path.owner,
+            path.repository,
+            move |record, service| {
+                let cancellation = ReadCancellation::default();
+                let references = service.references(&cancellation)?;
+                let head = references
+                    .iter()
+                    .find(|reference| reference.name == b"HEAD")
+                    .map(|reference| reference.target);
+                let history = match head {
+                    Some(head) => service.history(head, &cancellation)?,
+                    None => Vec::new(),
+                };
+                RepositoryPage::commits(record, head, history, page)
+            },
+        )
         .await;
     render_page(result, &request_id.0, signed_in)
 }
@@ -949,10 +984,10 @@
                     .header(header::CACHE_CONTROL, "no-store")
                     .body(Body::from(body))
                     .expect("the repository HTML response is valid"),
-                Err(_) => route_error(RouteError::Internal, request_id),
+                Err(_) => route_error_with_auth(RouteError::Internal, request_id, signed_in),
             }
         }
-        Err(error) => route_error(error, request_id),
+        Err(error) => route_error_with_auth(error, request_id, signed_in),
     }
 }
 
@@ -1027,30 +1062,38 @@
 }
 
 fn route_error(error: RouteError, request_id: &str) -> Response {
+    route_error_with_auth(error, request_id, false)
+}
+
+fn route_error_with_auth(error: RouteError, request_id: &str, signed_in: bool) -> Response {
     match error {
-        RouteError::NotFound => render_error(
+        RouteError::NotFound => render_error_with_auth(
             StatusCode::NOT_FOUND,
             request_id,
             "Repository content not found",
             "The requested repository content does not exist.",
+            signed_in,
         ),
-        RouteError::Unavailable => render_error(
+        RouteError::Unavailable => render_error_with_auth(
             StatusCode::SERVICE_UNAVAILABLE,
             request_id,
             "Repository service unavailable",
             "The repository service cannot complete this request now.",
+            signed_in,
         ),
-        RouteError::Internal => render_error(
+        RouteError::Internal => render_error_with_auth(
             StatusCode::INTERNAL_SERVER_ERROR,
             request_id,
             "Repository service error",
             "The repository service cannot complete this request.",
+            signed_in,
         ),
-        RouteError::InvalidRequest => render_error(
+        RouteError::InvalidRequest => render_error_with_auth(
             StatusCode::BAD_REQUEST,
             request_id,
             "Invalid repository request",
             "The repository request is not valid.",
+            signed_in,
         ),
     }
 }
@@ -1248,6 +1291,12 @@
     reference: Option<String>,
 }
 
+#[derive(Default, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct CommitQuery {
+    page: Option<usize>,
+}
+
 #[derive(Debug, Deserialize, Eq, PartialEq)]
 struct CommitPath {
     owner: String,
@@ -1307,6 +1356,10 @@
     search_files: usize,
     search_bytes: usize,
     search_matches: Vec<SearchMatchView>,
+    has_previous_page: bool,
+    has_next_page: bool,
+    previous_page: usize,
+    next_page: usize,
 }
 
 impl RepositoryPage {
@@ -1346,6 +1399,10 @@
             search_files: 0,
             search_bytes: 0,
             search_matches: Vec::new(),
+            has_previous_page: false,
+            has_next_page: false,
+            previous_page: 0,
+            next_page: 0,
         }
     }
 
@@ -1405,12 +1462,34 @@
         page
     }
 
-    fn commits(record: RepositoryRecord, head: Option<ObjectId>, history: Vec<CommitInfo>) -> Self {
+    fn commits(
+        record: RepositoryRecord,
+        head: Option<ObjectId>,
+        history: Vec<CommitInfo>,
+        page_number: usize,
+    ) -> Result<Self, RouteError> {
+        let start = page_number
+            .saturating_sub(1)
+            .checked_mul(COMMITS_PER_PAGE)
+            .ok_or(RouteError::InvalidRequest)?;
+        if !history.is_empty() && start >= history.len() {
+            return Err(RouteError::InvalidRequest);
+        }
+        let has_next_page = start.saturating_add(COMMITS_PER_PAGE) < history.len();
         let mut page = Self::base(record, "commits", "Commits".to_owned());
         page.has_head = head.is_some();
         page.commit_id = head.map(|id| id.to_string()).unwrap_or_default();
-        page.history = history.into_iter().map(CommitView::from).collect();
-        page
+        page.history = history
+            .into_iter()
+            .skip(start)
+            .take(COMMITS_PER_PAGE)
+            .map(CommitView::from)
+            .collect();
+        page.has_previous_page = page_number > 1;
+        page.has_next_page = has_next_page;
+        page.previous_page = page_number.saturating_sub(1);
+        page.next_page = page_number.saturating_add(1);
+        Ok(page)
     }
 
     fn commit(record: RepositoryRecord, commit: CommitInfo) -> Self {
@@ -1644,7 +1723,8 @@
     start_line: u32,
     source_start_line: u32,
     line_count: u32,
-    end_line: u32,
+    current_end_line: u32,
+    source_end_line: u32,
     commit_id: String,
     source_path: String,
     content: String,
@@ -1674,7 +1754,10 @@
             start_line: hunk.start_line,
             source_start_line: hunk.source_start_line,
             line_count: hunk.line_count,
-            end_line: hunk
+            current_end_line: hunk
+                .start_line
+                .saturating_add(hunk.line_count.saturating_sub(1)),
+            source_end_line: hunk
                 .source_start_line
                 .saturating_add(hunk.line_count.saturating_sub(1)),
             commit_id: hunk.commit_id.to_string(),
@@ -1688,7 +1771,7 @@
 }
 
 #[derive(Debug)]
-enum RouteError {
+pub(super) enum RouteError {
     NotFound,
     InvalidRequest,
     Unavailable,

src/http/pull_requests.rs

Mode 100644100644; object d8c57d4197704eaa560a0473

@@ -11,6 +11,7 @@
 use crate::pull_request::PullRequestError;
 use crate::store::StoreError;
 
+use super::filters;
 use super::{
     CSRF_COOKIE, RequestActor, RequestId, WebState, authenticate_mutation, cookie,
     parse_named_form, render, render_error,
@@ -56,13 +57,22 @@
     let owner = path.owner.clone();
     let repository = path.repository.clone();
     let signed_in = actor.0.is_some();
-    let result = job(state, move || {
-        service.list(&owner, &repository, actor.0.as_deref())
+    let actor_name = actor.0;
+    let actor_for_list = actor_name.clone();
+    let result = job(state.clone(), move || {
+        service.list(&owner, &repository, actor_for_list.as_deref())
     })
     .await;
     match result {
         Ok((record, pull_requests, can_create)) => {
             let csrf = cookie(&headers, CSRF_COOKIE).unwrap_or_default();
+            let branches = match state.public.as_ref() {
+                Some(public) => public
+                    .branch_names(actor_name, record.owner.clone(), record.slug.clone())
+                    .await
+                    .unwrap_or_default(),
+                None => Vec::new(),
+            };
             render(
                 StatusCode::OK,
                 &PullRequestListTemplate {
@@ -82,6 +92,7 @@
                         .collect(),
                     csrf: &csrf,
                     can_create: can_create && !csrf.is_empty(),
+                    branches,
                 },
             )
         }
@@ -187,7 +198,7 @@
     headers: HeaderMap,
     body: Bytes,
 ) -> Response {
-    let fields = match parse_named_form(&headers, &body, &["csrf", "method"]) {
+    let fields = match parse_named_form(&headers, &body, &["csrf", "method", "confirm"]) {
         Ok(fields) => fields,
         Err(()) => return bad_request(&request_id.0),
     };
@@ -196,6 +207,9 @@
             Ok(actor) => actor,
             Err(response) => return response,
         };
+    if fields[2] != "yes" {
+        return redirect(&path.owner, &path.repository, path.number);
+    }
     let Some(service) = state.pull_requests.clone() else {
         return internal(&request_id.0);
     };
@@ -515,6 +529,7 @@
     pull_requests: Vec<PullRequestListItem<'a>>,
     csrf: &'a str,
     can_create: bool,
+    branches: Vec<String>,
 }
 
 struct PullRequestListItem<'a> {

templates/account-page.html

Mode 100644100644; object 6054f0c54ba049596bbb9955

@@ -20,8 +20,5 @@
   <h2>Feeds</h2>
   <p><a href="/feeds">Manage private feed tokens</a></p>
   <h2>Sessions</h2>
-  <form action="/logout" method="post">
-    <input type="hidden" name="csrf" value="{{ csrf }}">
-    <button type="submit">Log out all sessions</button>
-  </form>
+  <p><a href="/logout">Log out all sessions</a></p>
 {% endblock %}

templates/feed-tokens.html

Mode 100644100644; object 98cb691d05ebeabcb5f593d4

@@ -7,15 +7,23 @@
   <h2>Create token</h2>
   <form method="post" action="/feeds/tokens">
     <input type="hidden" name="csrf" value="{{ csrf }}">
+    <input type="hidden" name="owner" value="">
+    <input type="hidden" name="repository" value="">
     <div class="field">
       <label for="feed-scope">Scope</label>
       <select id="feed-scope" name="scope">
         <option value="watched">Watched activity</option>
         <option value="assignments">Assignments</option>
         <option value="mentions">Mentions</option>
-        <option value="repository">One repository</option>
       </select>
     </div>
+    <button type="submit">Create feed token</button>
+  </form>
+
+  <h2>Create a repository token</h2>
+  <form method="post" action="/feeds/tokens">
+    <input type="hidden" name="csrf" value="{{ csrf }}">
+    <input type="hidden" name="scope" value="repository">
     <div class="field">
       <label for="feed-owner">Repository owner</label>
       <input id="feed-owner" name="owner" autocomplete="off" autocapitalize="none" spellcheck="false">
@@ -24,8 +32,7 @@
       <label for="feed-repository">Repository name</label>
       <input id="feed-repository" name="repository" autocomplete="off" autocapitalize="none" spellcheck="false">
     </div>
-    <p>Supply the owner and repository name only for a one-repository token.</p>
-    <button type="submit">Create feed token</button>
+    <button type="submit">Create repository token</button>
   </form>
 
   <h2>Existing tokens</h2>
@@ -35,14 +42,24 @@
   <ul>
 {% for token in tokens %}
     <li>
-      <strong>{{ token.scope }}</strong> for {{ token.target }}, created at {{ token.created_at }}.
+      <strong>{{ token.scope }}</strong> for {{ token.target }}, created at <time datetime="{{ token.created_at }}">{{ token.created_at|human_time }}</time>.
 {% if token.active %}
       <form method="post" action="/feeds/tokens/{{ token.id }}/rotate">
         <input type="hidden" name="csrf" value="{{ csrf }}">
+        <fieldset class="confirmation">
+          <legend>Rotate this token?</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">Rotate</button>
       </form>
       <form method="post" action="/feeds/tokens/{{ token.id }}/revoke">
         <input type="hidden" name="csrf" value="{{ csrf }}">
+        <fieldset class="confirmation">
+          <legend>Revoke this token?</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">Revoke</button>
       </form>
 {% else %}

templates/home.html

Mode 100644100644; object da0d81525b540fdd058ee6c9

@@ -13,7 +13,7 @@
 {% else %}
   <ul>
   {% for item in repositories %}
-    <li><a href="/{{ item.owner }}/{{ item.slug }}">{{ item.owner }}/{{ item.slug }}</a> · {{ item.visibility }} · updated <time>{{ item.updated_at }}</time></li>
+    <li><a href="/{{ item.owner }}/{{ item.slug }}">{{ item.owner }}/{{ item.slug }}</a> · {{ item.visibility }} · updated <time datetime="{{ item.updated_at }}">{{ item.updated_at|human_time }}</time></li>
   {% endfor %}
   </ul>
 {% endif %}

templates/issue.html

Mode 100644100644; object 3494e7a6aed30885c987b142

@@ -8,7 +8,7 @@
 
   <article>
     <h2>#{{ number }} {{ title }}</h2>
-    <p>{{ state }} · opened by {{ author }} at <time>{{ created_at }}</time> · updated <time>{{ updated_at }}</time></p>
+    <p>{{ state }} · opened by {{ author }} at <time datetime="{{ created_at }}">{{ created_at|human_time }}</time> · updated <time datetime="{{ updated_at }}">{{ updated_at|human_time }}</time></p>
 {% if labels.is_empty() %}{% else %}
     <p>Labels:{% for label in labels %} <span>{{ label }}</span>{% endfor %}</p>
 {% endif %}
@@ -24,8 +24,8 @@
     <p>This issue has no comments.</p>
 {% else %}
 {% for comment in comments %}
-    <article id="comment-{{ comment.id }}">
-      <h3>{{ comment.author }} commented at <time>{{ comment.created_at }}</time></h3>
+    <article class="comment-card" id="comment-{{ comment.id }}">
+      <h3>{{ comment.author }} commented at <time datetime="{{ comment.created_at }}">{{ comment.created_at|human_time }}</time></h3>
       <div class="markdown">{{ comment.body_html|safe }}</div>
     </article>
 {% endfor %}
@@ -36,7 +36,7 @@
     <h2 id="timeline-heading">Timeline</h2>
     <ol>
 {% for event in timeline %}
-      <li value="{{ event.sequence }}">{{ event.actor }}: {{ event.kind }} at <time>{{ event.created_at }}</time></li>
+      <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>
   </section>

templates/issues.html

Mode 100644100644; object eeb63fd6779e16d9339a7636

@@ -13,7 +13,7 @@
 {% else %}
   <ol class="issue-list">
 {% for issue in issues %}
-    <li><a href="/{{ owner }}/{{ repository }}/issues/{{ issue.number }}">#{{ issue.number }} {{ issue.title }}</a> — {{ issue.state }}, opened by {{ issue.author }}, updated <time>{{ issue.updated_at }}</time></li>
+    <li><a href="/{{ owner }}/{{ repository }}/issues/{{ issue.number }}">#{{ issue.number }} {{ issue.title }}</a> — {{ issue.state }}, opened by {{ issue.author }}, updated <time datetime="{{ issue.updated_at }}">{{ issue.updated_at|human_time }}</time></li>
 {% endfor %}
   </ol>
 {% endif %}

templates/logout.html

Mode 100644100644; object b915f353613d59b4d59d6bb1

@@ -5,6 +5,12 @@
   <p>Log out of all browser sessions for this account?</p>
   <form method="post" action="/logout">
     <input type="hidden" name="csrf" value="{{ csrf }}">
+    <fieldset class="confirmation">
+      <legend>Log out all sessions?</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">Log out</button>
+    <a href="/account">Cancel</a>
   </form>
 {% endblock %}

templates/pull_request.html

Mode 100644100644; object ae8ff3c78b58335850dc75d7

@@ -8,9 +8,9 @@
 
   <article>
     <h2>#{{ pull_request.number }} {{ pull_request.title }}</h2>
-    <p>{{ pull_request.state }} · opened by {{ pull_request.author }} at <time>{{ pull_request.created_at }}</time> · updated <time>{{ pull_request.updated_at }}</time></p>
-    <p>Base: <code>{{ pull_request.base_ref }}</code> at <code>{{ pull_request.base_object_id }}</code></p>
-    <p>Head: <code>{{ pull_request.head_ref }}</code> at <code>{{ pull_request.head_object_id }}</code></p>
+    <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>
@@ -20,7 +20,7 @@
     <ol>
 {% for revision in revisions %}
       <li value="{{ revision.number }}">
-        {{ revision.author }} recorded <code>{{ revision.head_object_id }}</code> against <code>{{ revision.base_object_id }}</code> at <time>{{ revision.created_at }}</time>.
+        {{ 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>.
 {% if revision.number == selected_revision %}
         <strong>Selected</strong>
 {% else %}
@@ -37,11 +37,11 @@
     <p>This pull request has no review activity.</p>
 {% else %}
 {% for review in reviews %}
-    <article id="review-{{ review.id }}">
-      <h3>{{ review.author }}: {{ review.kind }} at <time>{{ review.created_at }}</time></h3>
+    <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>{{ review.commit_object_id }}</code> · <code>{{ review.path }}</code> · {{ review.side }} line {{ review.line }}
+        · <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 %}
@@ -59,14 +59,14 @@
     <h2 id="timeline-heading">Timeline</h2>
     <ol>
 {% for event in timeline %}
-      <li value="{{ event.sequence }}">{{ event.actor }}: {{ event.kind }} at <time>{{ event.created_at }}</time></li>
+      <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>
   </section>
 
   <section aria-labelledby="comparison-heading">
     <h2 id="comparison-heading">Comparison for revision {{ selected_revision }}</h2>
-    <p>Merge base: <code>{{ comparison.merge_base }}</code></p>
+    <p>Merge base: <code title="{{ comparison.merge_base }}">{{ comparison.merge_base|short_id }}</code></p>
     <p>Mergeability: {{ comparison.mergeability }}</p>
 
     <h3>Commits</h3>
@@ -75,7 +75,7 @@
 {% else %}
     <ol>
 {% for commit in comparison.commits %}
-      <li><code>{{ commit.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>
 {% endif %}
@@ -119,6 +119,7 @@
         <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>
@@ -139,6 +140,11 @@
     <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>
@@ -148,6 +154,11 @@
     <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>

templates/pull_requests.html

Mode 100644100644; object 15154b9a98d21cb9c08dfdb9

@@ -12,7 +12,7 @@
 {% else %}
   <ol class="issue-list">
 {% for pull_request in pull_requests %}
-    <li><a href="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}">#{{ pull_request.number }} {{ pull_request.title }}</a> — {{ pull_request.state }}, opened by {{ pull_request.author }}, updated <time>{{ pull_request.updated_at }}</time></li>
+    <li><a href="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}">#{{ pull_request.number }} {{ pull_request.title }}</a> — {{ pull_request.state }}, opened by {{ pull_request.author }}, updated <time datetime="{{ pull_request.updated_at }}">{{ pull_request.updated_at|human_time }}</time></li>
 {% endfor %}
   </ol>
 {% endif %}
@@ -32,12 +32,17 @@
       </div>
       <div class="field">
         <label for="pull-request-base">Base branch</label>
-        <input id="pull-request-base" name="base-ref" maxlength="1024" value="refs/heads/main" required>
+        <input id="pull-request-base" name="base-ref" list="repository-branches" maxlength="1024" value="refs/heads/main" required>
       </div>
       <div class="field">
         <label for="pull-request-head">Head branch</label>
-        <input id="pull-request-head" name="head-ref" maxlength="1024" placeholder="refs/heads/feature" required>
+        <input id="pull-request-head" name="head-ref" list="repository-branches" maxlength="1024" placeholder="refs/heads/feature" required>
       </div>
+      <datalist id="repository-branches">
+{% for branch in branches %}
+        <option value="{{ branch }}"></option>
+{% endfor %}
+      </datalist>
       <button type="submit">Open pull request</button>
     </form>
   </section>

templates/repository.html

Mode 100644100644; object 07ffbcbc320368599b844fd6

@@ -16,7 +16,7 @@
     <dl>
       <dt>HTTP</dt><dd><code>{{ http_clone_url }}</code></dd>
       <dt>SSH</dt><dd><code>{{ ssh_clone_url }}</code></dd>
-      <dt>Created</dt><dd><time>{{ created_at }}</time></dd>
+      <dt>Created</dt><dd><time datetime="{{ created_at }}">{{ created_at|human_time }}</time></dd>
     </dl>
   </section>
 {% if has_head %}
@@ -24,7 +24,7 @@
     <h2 id="history-heading">Recent commits</h2>
     <ol class="commit-list">
 {% for item in history %}
-      <li><a href="/{{ owner }}/{{ repository }}/commit/{{ item.id }}"><code>{{ item.id }}</code></a> {{ item.summary }} — {{ item.author_name }}</li>
+      <li><a href="/{{ owner }}/{{ repository }}/commit/{{ item.id }}" aria-label="Commit {{ item.id }}"><code title="{{ item.id }}">{{ item.id|short_id }}</code></a> {{ item.summary }} — {{ item.author_name }}</li>
 {% endfor %}
     </ol>
     <p><a href="/{{ owner }}/{{ repository }}/commits">View all commits</a></p>
@@ -49,9 +49,19 @@
 {% if has_head %}
   <ol class="commit-list">
 {% for item in history %}
-    <li><a href="/{{ owner }}/{{ repository }}/commit/{{ item.id }}"><code>{{ item.id }}</code></a> {{ item.summary }} — {{ item.author_name }}</li>
+    <li><a href="/{{ owner }}/{{ repository }}/commit/{{ item.id }}" aria-label="Commit {{ item.id }}"><code title="{{ item.id }}">{{ item.id|short_id }}</code></a> {{ item.summary }} — {{ item.author_name }}</li>
 {% endfor %}
   </ol>
+{% if has_previous_page || has_next_page %}
+  <nav class="pagination" aria-label="Commit pages">
+{% if has_previous_page %}
+    <a href="/{{ owner }}/{{ repository }}/commits?page={{ previous_page }}">Newer commits</a>
+{% endif %}
+{% if has_next_page %}
+    <a href="/{{ owner }}/{{ repository }}/commits?page={{ next_page }}">Older commits</a>
+{% endif %}
+  </nav>
+{% endif %}
 {% else %}
   <p>This repository has no commits.</p>
 {% endif %}
@@ -59,34 +69,34 @@
 
 {% if page_kind == "refs" %}
   <h2>Refs</h2>
-  <table>
+  <div class="table-scroll"><table>
     <thead><tr><th scope="col">Name</th><th scope="col">Target</th><th scope="col">Details</th></tr></thead>
     <tbody>
 {% for item in refs %}
       <tr>
         <th scope="row">{{ item.name }}</th>
-        <td><a href="/{{ owner }}/{{ repository }}/commit/{{ item.href }}"><code>{{ item.target }}</code></a></td>
-        <td>{% if item.symbolic != "" %}→ {{ item.symbolic }}{% endif %}{% if item.peeled != "" %}peeled <code>{{ item.peeled }}</code>{% endif %}</td>
+        <td><a href="/{{ owner }}/{{ repository }}/commit/{{ item.href }}" aria-label="Commit {{ item.target }}"><code title="{{ item.target }}">{{ item.target|short_id }}</code></a></td>
+        <td>{% if item.symbolic != "" %}→ {{ item.symbolic }}{% endif %}{% if item.peeled != "" %}peeled <code title="{{ item.peeled }}">{{ item.peeled|short_id }}</code>{% endif %}</td>
       </tr>
 {% endfor %}
     </tbody>
-  </table>
+  </table></div>
 {% endif %}
 
 {% if page_kind == "tree" %}
   <h2>Tree{% if path != "" %}: {{ path }}{% endif %}</h2>
-  <table>
+  <div class="table-scroll"><table>
     <thead><tr><th scope="col">Mode</th><th scope="col">Type</th><th scope="col">Name</th><th scope="col">Object</th></tr></thead>
     <tbody>
 {% 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>
-        <td><code>{{ item.id }}</code></td>
+        <td><code title="{{ item.id }}">{{ item.id|short_id }}</code></td>
       </tr>
 {% endfor %}
     </tbody>
-  </table>
+  </table></div>
 {% endif %}
 
 {% if page_kind == "blob" %}
@@ -101,13 +111,13 @@
 
 {% if page_kind == "commit" %}
   <article>
-    <h2>Commit <code>{{ commit.id }}</code></h2>
+    <h2>Commit <code title="{{ commit.id }}">{{ commit.id|short_id }}</code></h2>
     <dl>
       <dt>Author</dt><dd>{{ commit.author_name }} &lt;{{ commit.author_email }}&gt;</dd>
-      <dt>Committed</dt><dd><time>{{ commit.committed_at }}</time></dd>
-      <dt>Tree</dt><dd><a href="/{{ owner }}/{{ repository }}/tree/{{ commit.id }}"><code>{{ commit.tree }}</code></a></dd>
+      <dt>Committed</dt><dd><time datetime="{{ commit.committed_at }}">{{ commit.committed_at|human_time }}</time></dd>
+      <dt>Tree</dt><dd><a href="/{{ owner }}/{{ repository }}/tree/{{ commit.id }}"><code title="{{ commit.tree }}">{{ commit.tree|short_id }}</code></a></dd>
 {% for parent in commit.parents %}
-      <dt>Parent</dt><dd><a href="/{{ owner }}/{{ repository }}/commit/{{ parent }}"><code>{{ parent }}</code></a> · <a href="/{{ owner }}/{{ repository }}/diff/{{ parent }}/{{ commit.id }}">Diff</a></dd>
+      <dt>Parent</dt><dd><a href="/{{ owner }}/{{ repository }}/commit/{{ parent }}"><code title="{{ parent }}">{{ parent|short_id }}</code></a> · <a href="/{{ owner }}/{{ repository }}/diff/{{ parent }}/{{ commit.id }}">Diff</a></dd>
 {% endfor %}
     </dl>
     <pre>{{ commit.message }}</pre>
@@ -116,11 +126,11 @@
 
 {% if page_kind == "diff" %}
   <h2>Diff</h2>
-  <p><code>{{ secondary_id }}</code> → <code>{{ commit_id }}</code></p>
+  <p><code title="{{ secondary_id }}">{{ secondary_id|short_id }}</code> → <code title="{{ commit_id }}">{{ commit_id|short_id }}</code></p>
 {% for file in diffs %}
   <section>
     <h3>{{ file.path }}</h3>
-    <p>Mode <code>{{ file.old_mode }}</code> → <code>{{ file.new_mode }}</code>; object <code>{{ file.old_id }}</code> → <code>{{ file.new_id }}</code></p>
+    <p>Mode <code>{{ file.old_mode }}</code> → <code>{{ file.new_mode }}</code>; object <code title="{{ file.old_id }}">{{ file.old_id|short_id }}</code> → <code title="{{ file.new_id }}">{{ file.new_id|short_id }}</code></p>
 {% if file.binary %}
     <p>Binary content changed.</p>
 {% else %}
@@ -138,7 +148,7 @@
 {% else %}
   <ol class="blame-list">
 {% for item in blame %}
-    <li value="{{ item.start_line }}"><a href="/{{ owner }}/{{ repository }}/commit/{{ item.commit_id }}"><code>{{ item.commit_id }}</code></a> lines {{ item.source_start_line }}–{{ item.end_line }}{% if item.source_path != "" %} from {{ item.source_path }}{% endif %}<pre>{{ item.content }}</pre></li>
+    <li value="{{ item.start_line }}"><a href="/{{ owner }}/{{ repository }}/commit/{{ item.commit_id }}"><code title="{{ item.commit_id }}">{{ item.commit_id|short_id }}</code></a> current lines {{ item.start_line }}–{{ item.current_end_line }}; source lines {{ item.source_start_line }}–{{ item.source_end_line }}{% if item.source_path != "" %} from {{ item.source_path }}{% endif %}<pre>{{ item.content }}</pre></li>
 {% endfor %}
   </ol>
 {% endif %}

tests/public_routes.rs

Mode 100644100644; object a91b32708355d8a875e82b4c

@@ -147,7 +147,43 @@
                 .text()
                 .matches("<li><a href=\"/alice/example/commit/")
                 .count(),
-            12
+            100
+        );
+        assert!(
+            commits
+                .text()
+                .contains("/alice/example/commits?page=2\">Older commits</a>")
+        );
+        let older_commits = request(
+            server.address(),
+            "GET",
+            "/alice/example/commits?page=2",
+            &[],
+            &[],
+        );
+        assert_eq!(older_commits.status, 200);
+        assert_eq!(
+            older_commits
+                .text()
+                .matches("<li><a href=\"/alice/example/commit/")
+                .count(),
+            2
+        );
+        assert!(
+            older_commits
+                .text()
+                .contains("/alice/example/commits?page=1\">Newer commits</a>")
+        );
+        assert_eq!(
+            request(
+                server.address(),
+                "GET",
+                "/alice/example/commits?page=3",
+                &[],
+                &[],
+            )
+            .status,
+            400
         );
         let mut feed_entry_ids = Vec::new();
         for (path, content_type) in [
@@ -765,10 +801,11 @@
     assert!(final_text.contains("#1 Edited issue"));
     assert!(final_text.contains("<em>Markdown</em>"));
     assert!(final_text.contains("<strong>comment</strong>"));
+    assert!(final_text.contains("<article class=\"comment-card\""));
     assert!(final_text.contains("Labels: <span>bug</span>"));
     assert!(final_text.contains("Assignees: <span>alice</span>"));
-    assert!(final_text.contains("issue-created"));
-    assert!(final_text.contains("issue-reopened"));
+    assert!(final_text.contains("created the issue"));
+    assert!(final_text.contains("reopened the issue"));
 
     let anonymous_pull_requests =
         request(server.address(), "GET", "/alice/example/pulls", &[], &[]);
@@ -859,7 +896,7 @@
     assert_eq!(
         revised_pull_request_page
             .text()
-            .matches("recorded <code>")
+            .matches("recorded <code title=")
             .count(),
         2
     );
@@ -923,7 +960,8 @@
     }
     let reviewed = request(server.address(), "GET", "/alice/example/pulls/1", &[], &[]);
     assert!(reviewed.text().contains("<strong>general review</strong>"));
-    assert!(reviewed.text().contains("pull-request-approved"));
+    assert!(reviewed.text().contains("<article class=\"comment-card\""));
+    assert!(reviewed.text().contains("approved the pull request"));
     assert!(reviewed.text().contains("head line 1"));
     assert!(!reviewed.text().contains("<strong>Outdated</strong>"));
 
@@ -954,17 +992,39 @@
         &[],
     );
     assert!(merge_page.text().contains("Fast-forward refs/heads/main"));
+    let cancelled_merge = request(
+        server.address(),
+        "POST",
+        "/alice/example/pulls/1/merge",
+        &headers,
+        form(&[
+            ("csrf", csrf.as_str()),
+            ("method", "fast-forward"),
+            ("confirm", "no"),
+        ])
+        .as_bytes(),
+    );
+    assert_eq!(cancelled_merge.status, 303);
+    assert_ne!(
+        rev_parse(&bare, "refs/heads/main"),
+        rev_parse(&bare, "refs/heads/feature")
+    );
     let merge = request(
         server.address(),
         "POST",
         "/alice/example/pulls/1/merge",
         &headers,
-        form(&[("csrf", csrf.as_str()), ("method", "fast-forward")]).as_bytes(),
+        form(&[
+            ("csrf", csrf.as_str()),
+            ("method", "fast-forward"),
+            ("confirm", "yes"),
+        ])
+        .as_bytes(),
     );
     assert_eq!(merge.status, 303);
     let merged = request(server.address(), "GET", "/alice/example/pulls/1", &[], &[]);
     assert!(merged.text().contains("merged · opened by alice"));
-    assert!(merged.text().contains("pull-request-merged"));
+    assert!(merged.text().contains("merged the pull request"));
     assert_eq!(
         rev_parse(&bare, "refs/heads/main"),
         rev_parse(&bare, "refs/heads/feature")
@@ -1165,7 +1225,20 @@
         200
     );
 
-    let rotate = form(&[("csrf", csrf.as_str())]);
+    let cancelled_rotate = form(&[("csrf", csrf.as_str()), ("confirm", "no")]);
+    let cancelled_rotation = request(
+        server.address(),
+        "POST",
+        &format!("/feeds/tokens/{private_id}/rotate"),
+        &headers,
+        cancelled_rotate.as_bytes(),
+    );
+    assert_eq!(cancelled_rotation.status, 303);
+    assert_eq!(
+        request(server.address(), "GET", &private_path, &[], &[]).status,
+        200
+    );
+    let rotate = form(&[("csrf", csrf.as_str()), ("confirm", "yes")]);
     let rotated = request(
         server.address(),
         "POST",
@@ -1376,7 +1449,7 @@
             .expect("write malformed UTF-8 content");
         commit_all(&worktree, "first commit");
         let parent = rev_parse(&worktree, "HEAD");
-        for index in 1..=10 {
+        for index in 1..=100 {
             commit_empty(&worktree, &format!("intermediate commit {index}"));
         }
         fs::write(

tests/serve.rs

Mode 100644100644; object 548846ca83b9e8eba083e445

@@ -246,6 +246,12 @@
     assert!(!signed_in_home.contains("<a href=\"/signup\">Create account</a>"));
     assert!(!signed_in_home.contains("<a href=\"/recover\">Recover account</a>"));
     assert!(!signed_in_home.contains("<a href=\"/login\">Log in</a>"));
+    let signed_in_missing =
+        http_get_with_headers(http, "/this-page-does-not-exist", &[("Cookie", &cookies)]);
+    assert!(signed_in_missing.starts_with("HTTP/1.1 404"));
+    assert!(signed_in_missing.contains("<a href=\"/account\">Account</a>"));
+    assert!(signed_in_missing.contains("<a href=\"/logout\">Log out</a>"));
+    assert!(!signed_in_missing.contains("<a href=\"/login\">Log in</a>"));
     let csrf = cookie_value(&cookies, "tit-csrf");
     let logout_page = http_get_with_headers(http, "/logout", &[("Cookie", &cookies)]);
     assert!(logout_page.starts_with("HTTP/1.1 200"));
@@ -278,8 +284,24 @@
         &[("Cookie", &cookies)],
     );
     assert!(rejected_logout.starts_with("HTTP/1.1 403"));
-    let logout =
-        http_form_with_headers(http, "/logout", &[("csrf", csrf)], &[("Cookie", &cookies)]);
+    let cancelled_logout = http_form_with_headers(
+        http,
+        "/logout",
+        &[("csrf", csrf), ("confirm", "no")],
+        &[("Cookie", &cookies)],
+    );
+    assert!(cancelled_logout.starts_with("HTTP/1.1 303"));
+    assert_eq!(response_header(&cancelled_logout, "location"), "/account");
+    assert!(
+        http_get_with_headers(http, "/account", &[("Cookie", &cookies)])
+            .starts_with("HTTP/1.1 200")
+    );
+    let logout = http_form_with_headers(
+        http,
+        "/logout",
+        &[("csrf", csrf), ("confirm", "yes")],
+        &[("Cookie", &cookies)],
+    );
     assert!(logout.starts_with("HTTP/1.1 303"));
     let ended = http_get_with_headers(http, "/account", &[("Cookie", &cookies)]);
     assert!(ended.starts_with("HTTP/1.1 303"));