michal/tit
Browse tree · Show commit · Download archive
Diff
cd74ae809117 → b2db8e312f8f
assets/style.css
Mode 100644 → 100644; object 3a8e737bef64 → f66bd67e6025
@@ -37,6 +37,64 @@
padding: 2rem 0;
}
+main > article,
+main > details,
+main > dl,
+main > form,
+main > ol,
+main > section:not(.repository-panel),
+main > ul,
+main > .field,
+main > .table-scroll,
+.two-column > section {
+ margin-block: 1.5rem;
+ padding: 1rem;
+ border: 1px solid GrayText;
+ border-radius: 0.25rem;
+}
+
+main > ol,
+main > ul {
+ padding-inline-start: 2.75rem;
+}
+
+main > article > :first-child,
+main > details > :first-child,
+main > dl > :first-child,
+main > form > :first-child,
+main > ol > :first-child,
+main > section:not(.repository-panel) > :first-child,
+main > ul > :first-child,
+main > .field > :first-child,
+.two-column > section > :first-child {
+ margin-top: 0;
+}
+
+main > article > :last-child,
+main > details > :last-child,
+main > dl > :last-child,
+main > form > :last-child,
+main > ol > :last-child,
+main > section:not(.repository-panel) > :last-child,
+main > ul > :last-child,
+main > .field > :last-child,
+.two-column > section > :last-child {
+ margin-bottom: 0;
+}
+
+main > .table-scroll {
+ padding: 0;
+}
+
+main > .table-scroll th,
+main > .table-scroll td {
+ padding: 0.6rem 1rem;
+}
+
+main > .table-scroll tbody tr:last-child > * {
+ border-bottom: 0;
+}
+
footer {
padding: 1rem 0;
border-top: 1px solid GrayText;
@@ -90,6 +148,11 @@
align-items: start;
}
+.repository-summary-main,
+.repository-sidebar {
+ min-width: 0;
+}
+
.repository-summary-main > section,
.repository-sidebar > section {
margin-bottom: 2rem;
@@ -110,12 +173,6 @@
overflow: hidden;
}
-.repository-file-panel > h2 {
- margin: 0;
- padding: 1rem;
- border-bottom: 1px solid GrayText;
-}
-
.repository-file-panel th,
.repository-file-panel td {
padding: 0.6rem 1rem;
@@ -123,6 +180,12 @@
.repository-file-panel tbody tr:last-child > * {
border-bottom: 0;
+}
+
+.repository-file-more {
+ margin: 0;
+ padding: 0.75rem 1rem;
+ border-top: 1px solid GrayText;
}
.repository-file-list th:first-child {
@@ -294,10 +357,184 @@
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;
+}
+
+.auth-page {
+ width: min(100%, 36rem);
+ margin-inline: auto;
+}
+
+.auth-panel {
+ padding: 1rem;
+ border: 1px solid GrayText;
+ border-radius: 0.25rem;
+}
+
+.auth-panel > :first-child {
+ margin-top: 0;
+}
+
+.auth-panel > :last-child {
+ margin-bottom: 0;
+}
+
+.auth-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;
+}
+
+.search-results {
+ padding-left: 1.5rem;
+}
+
+.search-results li {
+ padding-block: 0.75rem;
+}
+
+.search-results li + li {
+ border-top: 1px solid GrayText;
+}
+
+.page-section-header {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 0.5rem 1rem;
+ margin: 2rem 0 1rem;
+}
+
+.page-section-header h2 {
+ margin: 0;
+}
+
+.collection-panel > .filter-form {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: end;
+ gap: 0.75rem;
+ max-width: none;
+ margin-bottom: 0;
+ padding-bottom: 1rem;
+ border-bottom: 1px solid GrayText;
+}
+
+.filter-form .field {
+ margin: 0;
+}
+
+.filter-form select {
+ 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;
}
}
src/git/read.rs
Mode 100644 → 100644; object 0b228705d69e → 0aedb2803695
@@ -265,6 +265,26 @@
self.read_commit(id, &budget)
}
+ pub(crate) fn commit_metadata(
+ &self,
+ ids: &[ObjectId],
+ cancellation: &ReadCancellation,
+ ) -> Result<Vec<Option<CommitInfo>>, ReadError> {
+ if ids.len() > self.limits.max_refs {
+ return Err(ReadError::Limit("commit metadata"));
+ }
+ let budget = self.budget(cancellation);
+ let commits = ids
+ .iter()
+ .map(|id| {
+ budget.check()?;
+ Ok(self.read_commit(*id, &budget).ok())
+ })
+ .collect::<Result<Vec<_>, ReadError>>()?;
+ budget.check()?;
+ Ok(commits)
+ }
+
pub(crate) fn history(
&self,
start: ObjectId,
@@ -280,6 +300,17 @@
path: &[u8],
cancellation: &ReadCancellation,
) -> Result<Vec<TreeEntryInfo>, ReadError> {
+ self.tree_prefix(commit, path, self.limits.max_tree_entries, cancellation)
+ .map(|(entries, _)| entries)
+ }
+
+ pub(crate) fn tree_prefix(
+ &self,
+ commit: ObjectId,
+ path: &[u8],
+ maximum: usize,
+ cancellation: &ReadCancellation,
+ ) -> Result<(Vec<TreeEntryInfo>, bool), ReadError> {
validate_path(path, true, self.limits.max_path_bytes)?;
let budget = self.budget(cancellation);
let commit = self.read_commit(commit, &budget)?;
@@ -288,8 +319,11 @@
if tree.entries.len() > self.limits.max_tree_entries {
return Err(ReadError::Limit("tree entries"));
}
- tree.entries
+ let truncated = tree.entries.len() > maximum;
+ let entries = tree
+ .entries
.into_iter()
+ .take(maximum)
.map(|entry| {
budget.check()?;
Ok(TreeEntryInfo {
@@ -299,7 +333,8 @@
kind: entry.mode.kind(),
})
})
- .collect()
+ .collect::<Result<Vec<_>, ReadError>>()?;
+ Ok((entries, truncated))
}
pub(crate) fn blob(
src/http/public.rs
Mode 100644 → 100644; object f1ddda9ecb4e → e5ee3edf115b
@@ -40,6 +40,8 @@
const MAX_HISTORY_COMMITS: usize = 10_000;
const MAX_SUMMARY_COMMITS: usize = 3;
const MAX_SUMMARY_TAGS: usize = 3;
+const MAX_SUMMARY_TAG_CANDIDATES: usize = 128;
+const MAX_SUMMARY_FILES: usize = 50;
const COMMITS_PER_PAGE: usize = 100;
const MAX_SEARCH_QUERY_BYTES: usize = 256;
@@ -437,19 +439,31 @@
.iter()
.find(|reference| reference.name == default_branch.as_bytes())
.map(|reference| reference.target);
- let mut tags = references
+ let tag_references = references
.iter()
.filter(|reference| reference.name.starts_with(b"refs/tags/"))
- .filter_map(|reference| {
- let target = reference.peeled.unwrap_or(reference.target);
- let commit = service.commit(target, &cancellation).ok()?;
- Some(RepositorySummaryTag {
- name: reference.name[b"refs/tags/".len()..].to_vec(),
- target,
- committed_at: commit.committed_at,
- })
- })
.collect::<Vec<_>>();
+ let tags_omitted = tag_references.len() > MAX_SUMMARY_TAG_CANDIDATES;
+ let mut tags = if tags_omitted {
+ Vec::new()
+ } else {
+ let targets = tag_references
+ .iter()
+ .map(|reference| reference.peeled.unwrap_or(reference.target))
+ .collect::<Vec<_>>();
+ tag_references
+ .into_iter()
+ .zip(service.commit_metadata(&targets, &cancellation)?)
+ .filter_map(|(reference, commit)| {
+ let commit = commit?;
+ Some(RepositorySummaryTag {
+ name: reference.name[b"refs/tags/".len()..].to_vec(),
+ target: commit.id,
+ committed_at: commit.committed_at,
+ })
+ })
+ .collect::<Vec<_>>()
+ };
tags.sort_by(|left, right| {
right
.committed_at
@@ -457,13 +471,18 @@
.then_with(|| right.name.cmp(&left.name))
});
tags.truncate(MAX_SUMMARY_TAGS);
- let (history, readme, entries) = match head {
- Some(head) => (
- service.history(head, &cancellation)?,
- service.readme(head, &cancellation)?,
- service.tree(head, &[], &cancellation)?,
- ),
- None => (Vec::new(), None, Vec::new()),
+ let (history, readme, entries, entries_truncated) = match head {
+ Some(head) => {
+ let (entries, truncated) =
+ service.tree_prefix(head, &[], MAX_SUMMARY_FILES, &cancellation)?;
+ (
+ service.history(head, &cancellation)?,
+ service.readme(head, &cancellation)?,
+ entries,
+ truncated,
+ )
+ }
+ None => (Vec::new(), None, Vec::new(), false),
};
Ok(RepositoryPage::summary(
record,
@@ -474,8 +493,10 @@
head,
history,
tags,
+ tags_omitted,
readme: readme.map(|readme| (readme.path, readme.blob.data)),
entries,
+ entries_truncated,
},
))
},
@@ -1416,7 +1437,9 @@
readme_binary: bool,
history: Vec<CommitView>,
tags: Vec<TagView>,
+ tags_omitted: bool,
entries: Vec<TreeView>,
+ entries_truncated: bool,
blob_content: String,
blob_binary: bool,
commit: CommitView,
@@ -1444,8 +1467,10 @@
head: Option<ObjectId>,
history: Vec<CommitInfo>,
tags: Vec<RepositorySummaryTag>,
+ tags_omitted: bool,
readme: Option<(Vec<u8>, Vec<u8>)>,
entries: Vec<TreeEntryInfo>,
+ entries_truncated: bool,
}
impl RepositoryPage {
@@ -1473,7 +1498,9 @@
readme_binary: false,
history: Vec::new(),
tags: Vec::new(),
+ tags_omitted: false,
entries: Vec::new(),
+ entries_truncated: false,
blob_content: String::new(),
blob_binary: false,
commit: CommitView::default(),
@@ -1523,6 +1550,8 @@
committed_at: tag.committed_at,
})
.collect();
+ page.tags_omitted = summary.tags_omitted;
+ page.entries_truncated = summary.entries_truncated;
if let Some(head) = summary.head {
page.entries = summary
.entries
templates/home.html
Mode 100644 → 100644; object d01825827a28 → dd6b420bd0ed
@@ -1,54 +1,91 @@
{% extends "base.html" %}
{% block title %}{% if signed_in %}{{ username }}{% else %}Repositories{% endif %} · tit{% endblock %}
{% block content %}
-{% if signed_in %}
<div class="two-column">
+{% if signed_in %}
<section>
- <h1>{{ username }}</h1>
- <p><a href="/account">Open your account profile</a>.</p>
- <h2>Your repositories</h2>
+ <h1>{{ username }}</h1>
+ <p><a href="/account">Open your account profile</a>.</p>
+ <h2>Your repositories</h2>
{% if repositories.is_empty() %}
- <p>You do not own a repository.</p>
+ <p>You do not own a repository.</p>
{% else %}
- <ul>
+ <ul class="home-repository-list">
{% for item in repositories %}
{% if item.state == "archived" %}
- <li>
- {{ item.owner }}/{{ item.slug }} · archived
- <form class="inline-form" method="post" action="/{{ item.owner }}/{{ item.slug }}/settings/unarchive">
- <input type="hidden" name="csrf" value="{{ csrf }}">
- <input type="hidden" name="confirm" value="yes">
- <button type="submit">Unarchive repository</button>
- </form>
- </li>
+ <li>
+ <strong>{{ item.owner }}/{{ item.slug }}</strong>
+ <span class="repository-meta">archived</span>
+ <form class="inline-form" method="post" action="/{{ item.owner }}/{{ item.slug }}/settings/unarchive">
+ <input type="hidden" name="csrf" value="{{ csrf }}">
+ <input type="hidden" name="confirm" value="yes">
+ <button type="submit">Unarchive repository</button>
+ </form>
+ </li>
{% else %}
- <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>{% if item.description.is_empty() %}{% else %}<br>{{ item.description }}{% endif %}</li>
+ <li>
+ <a class="repository-name" href="/{{ item.owner }}/{{ item.slug }}">{{ item.owner }}/{{ item.slug }}</a>
+ <span class="repository-meta">{{ item.visibility }} · updated <time datetime="{{ item.updated_at }}">{{ item.updated_at|human_time }}</time></span>
+{% if item.description.is_empty() %}{% else %}
+ <p>{{ item.description }}</p>
+{% endif %}
+ </li>
{% endif %}
{% endfor %}
- </ul>
+ </ul>
{% endif %}
</section>
- <section>
- <h2>Recently updated public repositories</h2>
{% else %}
- <section>
- <h1>Recently updated public repositories</h1>
+ <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>
{% if recent_repositories.is_empty() %}
{% if signed_in %}
- <p>No public repositories are available.</p>
+ <p>No public repositories are available.</p>
{% else %}
- <p>No repositories are available.</p>
+ <p>No repositories are available.</p>
{% endif %}
{% else %}
- <ul>
+ <ul class="home-repository-list">
{% for item in recent_repositories %}
- <li><a href="/{{ item.owner }}/{{ item.slug }}">{{ item.owner }}/{{ item.slug }}</a> · updated <time datetime="{{ item.updated_at }}">{{ item.updated_at|human_time }}</time>{% if item.description.is_empty() %}{% else %}<br>{{ item.description }}{% endif %}</li>
+ <li>
+ <a class="repository-name" href="/{{ item.owner }}/{{ item.slug }}">{{ item.owner }}/{{ item.slug }}</a>
+ <span class="repository-meta">updated <time datetime="{{ item.updated_at }}">{{ item.updated_at|human_time }}</time></span>
+{% if item.description.is_empty() %}{% else %}
+ <p>{{ item.description }}</p>
+{% endif %}
+ </li>
{% endfor %}
- </ul>
+ </ul>
{% endif %}
</section>
-{% if signed_in %}
</div>
+{% if !signed_in %}
+ <section class="home-workflow" aria-labelledby="home-workflow-heading">
+ <h2 id="home-workflow-heading">Start with tit</h2>
+ <ol>
+ <li>
+ <strong>Create an account</strong>
+ <p>Create an account and add an SSH key.</p>
+ </li>
+ <li>
+ <strong>Create a repository</strong>
+ <p>Use the account page or the <code>repo create</code> SSH command.</p>
+ </li>
+ <li>
+ <strong>Collaborate</strong>
+ <p>Push branches, create issues, open pull requests, and follow updates with RSS.</p>
+ </li>
+ </ol>
+ </section>
{% endif %}
{% endblock %}
templates/issue.html
Mode 100644 → 100644; object 0eef13d6afe1 → 550ff0bb46f4
@@ -2,7 +2,7 @@
{% block title %}#{{ number }} {{ title }} · {{ owner }}/{{ repository }} · tit{% endblock %}
{% block content %}
<header class="repository-header">
- <h1><a href="/{{ owner }}/{{ repository }}">{{ owner }}/{{ repository }}</a></h1>
+ <h1><a href="/{{ owner }}">{{ owner }}</a>/<a href="/{{ owner }}/{{ repository }}">{{ repository }}</a></h1>
{% include "repository-nav.html" %}
</header>
templates/issues.html
Mode 100644 → 100644; object 45a8606a1897 → f331f9b5713b
@@ -2,40 +2,52 @@
{% block title %}Issues · {{ owner }}/{{ repository }} · tit{% endblock %}
{% block content %}
<header class="repository-header">
- <h1><a href="/{{ owner }}/{{ repository }}">{{ owner }}/{{ repository }}</a></h1>
+ <h1><a href="/{{ owner }}">{{ owner }}</a>/<a href="/{{ owner }}/{{ repository }}">{{ repository }}</a></h1>
{% include "repository-nav.html" %}
</header>
- <h2>Issues</h2>
- <p><a href="/{{ owner }}/{{ repository }}/issues/rss.xml">Issue RSS</a></p>
- <form method="get" action="/{{ owner }}/{{ repository }}/issues">
- <label for="issue-state">State</label>
- <select id="issue-state" name="state">
- <option value="open"{% if state_open %} selected{% endif %}>Open</option>
- <option value="closed"{% if state_closed %} selected{% endif %}>Closed</option>
- <option value="all"{% if state_all %} selected{% endif %}>All</option>
- </select>
- <button type="submit">Filter</button>
- </form>
+ <header class="page-section-header">
+ <h2>Issues</h2>
+ <a href="/{{ owner }}/{{ repository }}/issues/rss.xml">Issue RSS</a>
+ </header>
+ <section class="collection-panel" aria-label="Issues">
+ <form class="filter-form" method="get" action="/{{ owner }}/{{ repository }}/issues">
+ <div class="field">
+ <label for="issue-state">State</label>
+ <select id="issue-state" name="state">
+ <option value="open"{% if state_open %} selected{% endif %}>Open</option>
+ <option value="closed"{% if state_closed %} selected{% endif %}>Closed</option>
+ <option value="all"{% if state_all %} selected{% endif %}>All</option>
+ </select>
+ </div>
+ <button type="submit">Filter</button>
+ </form>
{% if issues.is_empty() %}
- <p>This repository has no issues.</p>
+ <p>This repository has no issues.</p>
{% else %}
- <ol class="issue-list">
+ <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 datetime="{{ issue.updated_at }}">{{ issue.updated_at|human_time }}</time></li>
+ <li>
+ <a class="issue-title" href="/{{ owner }}/{{ repository }}/issues/{{ issue.number }}">#{{ issue.number }} {{ issue.title }}</a>
+ <span class="issue-meta">{{ issue.state }} · opened by {{ issue.author }} · updated <time datetime="{{ issue.updated_at }}">{{ issue.updated_at|human_time }}</time></span>
+ </li>
{% endfor %}
- </ol>
+ </ol>
{% if has_previous || has_next %}
- <nav class="pagination" aria-label="Issue pages">
+ <nav class="pagination" aria-label="Issue pages">
{% if has_previous %}
- <a href="/{{ owner }}/{{ repository }}/issues?state={{ state }}&page={{ previous_page }}">Newer issues</a>
+ <a href="/{{ owner }}/{{ repository }}/issues?state={{ state }}&page={{ previous_page }}">Newer issues</a>
{% endif %}
{% if has_next %}
- <a href="/{{ owner }}/{{ repository }}/issues?state={{ state }}&page={{ next_page }}">Older issues</a>
+ <a href="/{{ owner }}/{{ repository }}/issues?state={{ state }}&page={{ next_page }}">Older issues</a>
{% endif %}
- </nav>
+ </nav>
{% endif %}
{% endif %}
+{% if !can_create %}
+ <p class="collection-action"><a href="/login">Log in</a> to create an issue.</p>
+{% endif %}
+ </section>
{% if can_create %}
<section aria-labelledby="new-issue-heading">
@@ -53,7 +65,5 @@
<button type="submit">Create issue</button>
</form>
</section>
-{% else %}
- <p><a href="/login">Log in</a> to create an issue.</p>
{% endif %}
{% endblock %}
templates/login.html
Mode 100644 → 100644; object 5ed95d1374bc → 032ac99b739a
@@ -1,23 +1,27 @@
{% extends "base.html" %}
{% block title %}Log in · tit{% endblock %}
{% block content %}
- <h1>Log in</h1>
- <p>Use an SSH key that is active on your account.</p>
+ <div class="auth-page">
+ <h1 id="login-heading">Log in</h1>
+ <section class="auth-panel" aria-labelledby="login-heading">
+ <p>Use an SSH key that is active on your account.</p>
{% if has_error %}
- <p class="error" role="alert">{{ error }}</p>
+ <p class="error" role="alert">{{ error }}</p>
{% endif %}
- <form action="/login/ssh" method="post">
- <button type="submit">Log in with SSH</button>
- </form>
- <details>
- <summary>Sign a challenge instead</summary>
- <p>Use this method when you cannot connect to the tit SSH service.</p>
- <form action="/login" method="post">
- <div class="field">
- <label for="username">Username</label>
- <input id="username" name="username" value="{{ username }}" required autocomplete="username" autocapitalize="none" spellcheck="false">
- </div>
- <button type="submit">Create challenge</button>
- </form>
- </details>
+ <form action="/login/ssh" method="post">
+ <button type="submit">Log in with SSH</button>
+ </form>
+ <details>
+ <summary>Sign a challenge instead</summary>
+ <p>Use this method when you cannot connect to the tit SSH service.</p>
+ <form action="/login" method="post">
+ <div class="field">
+ <label for="username">Username</label>
+ <input id="username" name="username" value="{{ username }}" required autocomplete="username" autocapitalize="none" spellcheck="false">
+ </div>
+ <button type="submit">Create challenge</button>
+ </form>
+ </details>
+ </section>
+ </div>
{% endblock %}
templates/metadata-search.html
Mode 100644 → 100644; object 545678702426 → 633b054d6c9b
@@ -1,13 +1,19 @@
{% extends "base.html" %}
{% block title %}Search · tit{% endblock %}
{% block content %}
- <h1>Search repositories</h1>
- <form method="get" action="/search">
- <label for="metadata-query">Search text</label>
- <input id="metadata-query" name="q" value="{{ query }}" maxlength="256" required>
- <button type="submit">Search</button>
- </form>
+ <h1 id="search-heading">Search repositories</h1>
+ <section class="search-panel" aria-labelledby="search-heading">
+ <form method="get" action="/search">
+ <div class="field">
+ <label for="metadata-query">Search text</label>
+ <input id="metadata-query" name="q" value="{{ query }}" maxlength="256" required>
+ </div>
+ <button type="submit">Search</button>
+ </form>
+ </section>
{% if searched %}
+ <section aria-labelledby="search-results-heading">
+ <h2 id="search-results-heading">Results</h2>
<p>Scanned {{ rows_scanned }} repository names.</p>
{% if truncated %}
<p>The result is incomplete because the search reached a limit.</p>
@@ -27,5 +33,6 @@
{% endfor %}
</ol>
{% endif %}
+ </section>
{% endif %}
{% endblock %}
templates/pull_request.html
Mode 100644 → 100644; object 42d923807156 → 683b8e256dbd
@@ -2,7 +2,7 @@
{% block title %}#{{ pull_request.number }} {{ pull_request.title }} · {{ owner }}/{{ repository }} · tit{% endblock %}
{% block content %}
<header class="repository-header">
- <h1><a href="/{{ owner }}/{{ repository }}">{{ owner }}/{{ repository }}</a></h1>
+ <h1><a href="/{{ owner }}">{{ owner }}</a>/<a href="/{{ owner }}/{{ repository }}">{{ repository }}</a></h1>
{% include "repository-nav.html" %}
</header>
templates/pull_requests.html
Mode 100644 → 100644; object f148b7913197 → 361fef6487fc
@@ -2,40 +2,52 @@
{% block title %}Pull requests · {{ owner }}/{{ repository }} · tit{% endblock %}
{% block content %}
<header class="repository-header">
- <h1><a href="/{{ owner }}/{{ repository }}">{{ owner }}/{{ repository }}</a></h1>
+ <h1><a href="/{{ owner }}">{{ owner }}</a>/<a href="/{{ owner }}/{{ repository }}">{{ repository }}</a></h1>
{% include "repository-nav.html" %}
</header>
- <h2>Pull requests</h2>
- <form method="get" action="/{{ owner }}/{{ repository }}/pulls">
- <label for="pull-request-state">State</label>
- <select id="pull-request-state" name="state">
- <option value="open"{% if state_open %} selected{% endif %}>Open</option>
- <option value="closed"{% if state_closed %} selected{% endif %}>Closed</option>
- <option value="merged"{% if state_merged %} selected{% endif %}>Merged</option>
- <option value="all"{% if state_all %} selected{% endif %}>All</option>
- </select>
- <button type="submit">Filter</button>
- </form>
+ <header class="page-section-header">
+ <h2>Pull requests</h2>
+ </header>
+ <section class="collection-panel" aria-label="Pull requests">
+ <form class="filter-form" method="get" action="/{{ owner }}/{{ repository }}/pulls">
+ <div class="field">
+ <label for="pull-request-state">State</label>
+ <select id="pull-request-state" name="state">
+ <option value="open"{% if state_open %} selected{% endif %}>Open</option>
+ <option value="closed"{% if state_closed %} selected{% endif %}>Closed</option>
+ <option value="merged"{% if state_merged %} selected{% endif %}>Merged</option>
+ <option value="all"{% if state_all %} selected{% endif %}>All</option>
+ </select>
+ </div>
+ <button type="submit">Filter</button>
+ </form>
{% if pull_requests.is_empty() %}
- <p>This repository has no pull requests.</p>
+ <p>This repository has no pull requests.</p>
{% else %}
- <ol class="issue-list">
+ <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 datetime="{{ pull_request.updated_at }}">{{ pull_request.updated_at|human_time }}</time></li>
+ <li>
+ <a class="issue-title" href="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}">#{{ pull_request.number }} {{ pull_request.title }}</a>
+ <span class="issue-meta">{{ pull_request.state }} · opened by {{ pull_request.author }} · updated <time datetime="{{ pull_request.updated_at }}">{{ pull_request.updated_at|human_time }}</time></span>
+ </li>
{% endfor %}
- </ol>
+ </ol>
{% if has_previous || has_next %}
- <nav class="pagination" aria-label="Pull-request pages">
+ <nav class="pagination" aria-label="Pull-request pages">
{% if has_previous %}
- <a href="/{{ owner }}/{{ repository }}/pulls?state={{ state }}&page={{ previous_page }}">Newer pull requests</a>
+ <a href="/{{ owner }}/{{ repository }}/pulls?state={{ state }}&page={{ previous_page }}">Newer pull requests</a>
{% endif %}
{% if has_next %}
- <a href="/{{ owner }}/{{ repository }}/pulls?state={{ state }}&page={{ next_page }}">Older pull requests</a>
+ <a href="/{{ owner }}/{{ repository }}/pulls?state={{ state }}&page={{ next_page }}">Older pull requests</a>
{% endif %}
- </nav>
+ </nav>
{% endif %}
{% endif %}
+{% if !can_create %}
+ <p class="collection-action">You need write access to open a pull request.</p>
+{% endif %}
+ </section>
{% if can_create %}
<section aria-labelledby="new-pull-request-heading">
@@ -66,7 +78,5 @@
<button type="submit">Open pull request</button>
</form>
</section>
-{% else %}
- <p>You need write access to open a pull request.</p>
{% endif %}
{% endblock %}
templates/repository-settings.html
Mode 100644 → 100644; object 0b2dbc6505bc → 6d1d5a228e69
@@ -2,7 +2,7 @@
{% block title %}Settings · {{ owner }}/{{ repository }} · tit{% endblock %}
{% block content %}
<header class="repository-header">
- <h1><a href="/{{ owner }}/{{ repository }}">{{ owner }}/{{ repository }}</a></h1>
+ <h1><a href="/{{ owner }}">{{ owner }}</a>/<a href="/{{ owner }}/{{ repository }}">{{ repository }}</a></h1>
{% include "repository-nav.html" %}
</header>
templates/repository.html
Mode 100644 → 100644; object c9b763fe942c → 490e30a01b57
@@ -2,7 +2,7 @@
{% block title %}{{ page_title }} · tit{% endblock %}
{% block content %}
<header class="repository-header">
- <h1><a href="/{{ owner }}/{{ repository }}">{{ owner }}/{{ repository }}</a></h1>
+ <h1><a href="/{{ owner }}">{{ owner }}</a>/<a href="/{{ owner }}/{{ repository }}">{{ repository }}</a></h1>
{% include "repository-nav.html" %}
</header>
@@ -22,8 +22,7 @@
<div class="repository-summary-layout">
<div class="repository-summary-main">
{% if has_head %}
- <section class="repository-panel repository-file-panel" aria-labelledby="files-heading">
- <h2 id="files-heading">Files</h2>
+ <section class="repository-panel repository-file-panel" aria-label="Files">
<div class="table-scroll"><table class="repository-file-list">
<thead><tr><th scope="col">Name</th><th scope="col">Type</th><th scope="col">Object</th></tr></thead>
<tbody>
@@ -36,6 +35,9 @@
{% endfor %}
</tbody>
</table></div>
+{% if entries_truncated %}
+ <p class="repository-file-more"><a href="/{{ owner }}/{{ repository }}/tree/{{ commit_id }}">View all files</a></p>
+{% endif %}
</section>
{% else %}
<p>This repository has no commits.</p>
@@ -72,7 +74,10 @@
<p><a href="/{{ owner }}/{{ repository }}/commits">View all commits</a></p>
{% endif %}
<h3>Tags</h3>
-{% if tags.is_empty() %}
+{% if tags_omitted %}
+ <p>This repository has too many tags for the summary.</p>
+ <p><a href="/{{ owner }}/{{ repository }}/refs">View all refs</a></p>
+{% else if tags.is_empty() %}
<p>No tags are available.</p>
{% else %}
<ul class="repository-recent-list">
templates/watch.html
Mode 100644 → 100644; object 408c08c2af01 → 172c27e5483c
@@ -2,7 +2,7 @@
{% block title %}Watch · {{ owner }}/{{ repository }} · tit{% endblock %}
{% block content %}
<header class="repository-header">
- <h1><a href="/{{ owner }}/{{ repository }}">{{ owner }}/{{ repository }}</a></h1>
+ <h1><a href="/{{ owner }}">{{ owner }}</a>/<a href="/{{ owner }}/{{ repository }}">{{ repository }}</a></h1>
{% include "repository-nav.html" %}
</header>
tests/git_reads.rs
Mode 100644 → 100644; object 10086a706f1c → 3919f6aca81f
@@ -80,6 +80,21 @@
b"src".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!(truncated);
+ let metadata = service
+ .commit_metadata(&[fixture.first, fixture.second], &cancellation)
+ .expect("read commit metadata");
+ assert_eq!(
+ metadata
+ .into_iter()
+ .map(|commit| commit.expect("find a commit").id)
+ .collect::<Vec<_>>(),
+ [fixture.first, fixture.second]
+ );
let source = service
.tree(fixture.second, b"src", &cancellation)
.expect("read a nested tree");
tests/public_routes.rs
Mode 100644 → 100644; object 393a5b099ec0 → 53d10b0cc8d9
@@ -106,12 +106,32 @@
.await
.expect("start the public Web server");
+ let home = request(server.address(), "GET", "/", &[], &[]);
+ assert_eq!(home.status, 200);
+ assert_html_policy(&home);
+ let home_text = home.text();
+ assert!(home_text.contains("<div class=\"two-column\">"));
+ assert!(home_text.contains("<h1>A small Git CDE</h1>"));
+ assert!(home_text.contains("aria-label=\"Get started\""));
+ assert!(home_text.contains("<ul class=\"home-repository-list\">"));
+ assert!(home_text.contains("href=\"/alice/example\""));
+ assert!(home_text.contains(
+ "<section class=\"home-workflow\" aria-labelledby=\"home-workflow-heading\">"
+ ));
+ assert!(home_text.contains("<code>repo create</code>"));
+
let summary = request(server.address(), "GET", "/alice/example", &[], &[]);
assert_eq!(summary.status, 200);
assert_html_policy(&summary);
assert_repository_navigation(&summary, "alice", "example");
let summary_text = summary.text();
- assert!(summary_text.contains("<h1><a href=\"/alice/example\">alice/example</a></h1>"));
+ assert!(summary_text.contains(
+ "<h1><a href=\"/alice\">alice</a>/<a href=\"/alice/example\">example</a></h1>"
+ ));
+ assert!(summary_text.contains(
+ "<section class=\"repository-panel repository-file-panel\" aria-label=\"Files\">"
+ ));
+ assert!(!summary_text.contains("<h2 id=\"files-heading\">Files</h2>"));
assert!(summary_text.contains("https://tit.example/alice/example"));
assert!(summary_text.contains("ssh://tit.example:2222/alice/example"));
assert!(summary_text.contains(&fixture.head));
@@ -129,6 +149,8 @@
fixture.head
)));
assert!(summary_text.contains("README.md"));
+ assert!(summary_text.contains(">View all files</a>"));
+ assert!(!summary_text.contains("summary-overflow-50.txt"));
assert!(summary_text.contains("<h1>tit fixture</h1>"));
assert!(summary_text.contains("<strong>safe</strong>"));
assert!(summary_text.contains("<code><safe></code>"));
@@ -643,6 +665,29 @@
b"first line\nsecond line\n"
);
+ let tag_directory = fixture
+ .instance
+ .path()
+ .join("repositories")
+ .join(format!("{}.git", fixture.repository_id))
+ .join("refs/tags");
+ fs::create_dir_all(&tag_directory).expect("create the tag directory");
+ for index in 0..=128 {
+ fs::write(
+ tag_directory.join(format!("summary-limit-{index:03}")),
+ format!("{}\n", fixture.head),
+ )
+ .expect("write a summary-limit tag");
+ }
+ let tag_limited_summary = request(server.address(), "GET", "/alice/example", &[], &[]);
+ assert_eq!(tag_limited_summary.status, 200);
+ assert!(
+ tag_limited_summary
+ .text()
+ .contains("This repository has too many tags for the summary.")
+ );
+ assert!(!tag_limited_summary.text().contains(">v0.4</a>"));
+
for route in [
"/alice/missing",
"/Alice/example",
@@ -757,6 +802,13 @@
assert_eq!(anonymous.status, 200);
assert_repository_navigation(&anonymous, "alice", "example");
assert!(anonymous.text().contains("This repository has no issues."));
+ assert!(
+ anonymous
+ .text()
+ .contains("<section class=\"collection-panel\" aria-label=\"Issues\">")
+ );
+ assert!(anonymous.text().contains("<form class=\"filter-form\""));
+ assert!(anonymous.text().contains("class=\"collection-action\""));
assert!(!anonymous.text().contains("Create an issue</h2>"));
let cookie = format!("tit-session={token}; tit-csrf={csrf}");
@@ -778,6 +830,17 @@
);
assert_eq!(created.status, 303);
assert_eq!(created.header("location"), "/alice/example/issues/1");
+
+ let issue_index = request(
+ server.address(),
+ "GET",
+ "/alice/example/issues",
+ &[("Cookie", cookie.as_str())],
+ &[],
+ );
+ let issue_index_text = issue_index.text();
+ assert!(issue_index_text.contains("<ol class=\"issue-list\">"));
+ assert!(issue_index_text.contains("<span class=\"issue-meta\">open · opened by alice"));
let detail = request(
server.address(),
@@ -890,6 +953,16 @@
opened_pull_request.header("location"),
"/alice/example/pulls/1"
);
+ let pull_request_index = request(
+ server.address(),
+ "GET",
+ "/alice/example/pulls",
+ &[("Cookie", cookie.as_str())],
+ &[],
+ );
+ let pull_request_index_text = pull_request_index.text();
+ assert!(pull_request_index_text.contains("<ol class=\"issue-list\">"));
+ assert!(pull_request_index_text.contains("<span class=\"issue-meta\">open · opened by alice"));
let pull_request_page = request(server.address(), "GET", "/alice/example/pulls/1", &[], &[]);
assert_eq!(pull_request_page.status, 200);
assert!(pull_request_page.text().contains("#1 Review the feature"));
@@ -1521,7 +1594,16 @@
&[("Cookie", cookie.as_str())],
&[],
);
- assert!(archived_home.text().contains("alice/renamed · archived"));
+ assert!(
+ archived_home
+ .text()
+ .contains("<strong>alice/renamed</strong>")
+ );
+ assert!(
+ archived_home
+ .text()
+ .contains("<span class=\"repository-meta\">archived</span>")
+ );
assert!(archived_home.text().contains("Unarchive repository"));
let denied_unarchive = form(&[("csrf", reader_csrf.as_str()), ("confirm", "yes")]);
assert_eq!(
@@ -1678,6 +1760,13 @@
.expect("write the percent-encoded path");
fs::write(worktree.join("malformed.txt"), b"start \xff needle\n")
.expect("write malformed UTF-8 content");
+ for index in 0..=50 {
+ fs::write(
+ worktree.join(format!("summary-overflow-{index:02}.txt")),
+ b"summary entry\n",
+ )
+ .expect("write a summary-limit file");
+ }
commit_all(&worktree, "first commit");
let parent = rev_parse(&worktree, "HEAD");
for index in 1..=100 {
@@ -1880,6 +1969,9 @@
fn assert_repository_navigation(response: &HttpResponse, owner: &str, repository: &str) {
let text = response.text();
+ assert!(text.contains(&format!(
+ "<h1><a href=\"/{owner}\">{owner}</a>/<a href=\"/{owner}/{repository}\">{repository}</a></h1>"
+ )));
for suffix in [
"", "/refs", "/issues", "/pulls", "/watch", "/rss.xml", "/search",
] {
tests/serve.rs
Mode 100644 → 100644; object be7169da10a4 → e54389ae6f07
@@ -231,9 +231,14 @@
assert!(signed_in_home.contains("<h1>alice</h1>"));
assert!(signed_in_home.contains("<h2>Your repositories</h2>"));
assert!(signed_in_home.contains("<h2>Recently updated public repositories</h2>"));
+ assert!(signed_in_home.contains("<div class=\"two-column\">"));
assert!(
signed_in_home
- .contains("</section>\n <section>\n <h2>Recently updated public repositories</h2>")
+ .find("<h2>Your repositories</h2>")
+ .expect("find the owned repository section")
+ < signed_in_home
+ .find("<h2>Recently updated public repositories</h2>")
+ .expect("find the recent repository section")
);
assert_eq!(signed_in_home.matches(">alice/example</a>").count(), 2);
assert!(signed_in_home.contains("<a href=\"/account\">Account</a>"));
tests/snapshots/web/home.html
Mode 100644 → 100644; object 166bf0008710 → ea15f1724366
@@ -22,15 +22,44 @@ </header> <main id="main"> + <div class="two-column"> + + <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> - <h1>Recently updated public repositories</h1> + <h2>Recently updated public repositories</h2> + <p>No repositories are available.</p> - <p>No repositories are available.</p> + </section> + </div> + <section class="home-workflow" aria-labelledby="home-workflow-heading"> + <h2 id="home-workflow-heading">Start with tit</h2> + <ol> + <li> + <strong>Create an account</strong> + <p>Create an account and add an SSH key.</p> + </li> + <li> + <strong>Create a repository</strong> + <p>Use the account page or the <code>repo create</code> SSH command.</p> + </li> + <li> + <strong>Collaborate</strong> + <p>Push branches, create issues, open pull requests, and follow updates with RSS.</p> + </li> + </ol> </section>