michal/tit

Browse tree · Show commit · Download archive

Diff

cd74ae809117b2db8e312f8f

assets/style.css

Mode 100644100644; object 3a8e737bef64f66bd67e6025

@@ -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 100644100644; object 0b228705d69e0aedb2803695

@@ -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 100644100644; object f1ddda9ecb4ee5ee3edf115b

@@ -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 100644100644; object d01825827a28dd6b420bd0ed

@@ -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 100644100644; object 0eef13d6afe1550ff0bb46f4

@@ -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 100644100644; object 45a8606a1897f331f9b5713b

@@ -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 100644100644; object 5ed95d1374bc032ac99b739a

@@ -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 100644100644; object 545678702426633b054d6c9b

@@ -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 100644100644; object 42d923807156683b8e256dbd

@@ -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 100644100644; object f148b7913197361fef6487fc

@@ -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 100644100644; object 0b2dbc6505bc6d1d5a228e69

@@ -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 100644100644; object c9b763fe942c490e30a01b57

@@ -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 100644100644; object 408c08c2af01172c27e5483c

@@ -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 100644100644; object 10086a706f1c3919f6aca81f

@@ -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 100644100644; object 393a5b099ec053d10b0cc8d9

@@ -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>&lt;safe&gt;</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 100644100644; object be7169da10a4e54389ae6f07

@@ -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 100644100644; object 166bf0008710ea15f1724366

@@ -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>