michal/tit
Browse tree · Show commit · Download archive
Diff
82a09578b0e3 → dd1017765cbd
assets/style.css
Mode 100644 → 100644; object a914c92a65b0 → d5e5709e4cf6
@@ -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 100644 → 100644; object 6cd94355ff00 → fd6989160393
@@ -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 100644 → 100644; object 0c098a44c454 → 9de454ffeb87
@@ -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 100644 → 100644; object 67e654a01d9a → 725c24ad1b60
@@ -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 100644 → 100644; object dcb4b473bfa6 → 5548a45f83b0
@@ -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 100644 → 100644; object d8c57d419770 → 4eaa560a0473
@@ -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 100644 → 100644; object 6054f0c54ba0 → 49596bbb9955
@@ -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 100644 → 100644; object 98cb691d05eb → eabcb5f593d4
@@ -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 100644 → 100644; object da0d81525b54 → 0fdd058ee6c9
@@ -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 100644 → 100644; object 3494e7a6aed3 → 0885c987b142
@@ -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 100644 → 100644; object eeb63fd6779e → 16d9339a7636
@@ -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 100644 → 100644; object b915f353613d → 59b4d59d6bb1
@@ -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 100644 → 100644; object ae8ff3c78b58 → 335850dc75d7
@@ -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 100644 → 100644; object 15154b9a98d2 → 1cb9c08dfdb9
@@ -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 100644 → 100644; object 07ffbcbc3203 → 68599b844fd6
@@ -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 }} <{{ commit.author_email }}></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 100644 → 100644; object a91b32708355 → d8a875e82b4c
@@ -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 100644 → 100644; object 548846ca83b9 → e8eba083e445
@@ -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"));