gitscope
← rgit / Diff · db82da0 → db82da0
Changes db82da0..db82da0
Patch commit
diff --git a/src/database/indexer.rs b/src/database/indexer.rs
index 9f9b007..717b5f6 100644
--- a/src/database/indexer.rs
+++ b/src/database/indexer.rs
@@ -30,7 +30,7 @@ use crate::{
git::{PathVisitor, PathVisitorHandler},
};
-use super::schema::tree::{Tree, TreeItem, TreeItemKind};
+use super::schema::tree::{SortedTree, SortedTreeItem, Tree, TreeItem, TreeItemKind, TreeKey};
pub fn run(scan_path: &Path, repository_list: Option<&Path>, db: &Arc<rocksdb::DB>) {
let span = info_span!("index_update");
@@ -341,6 +341,8 @@ fn index_tree(
let digest = hasher.digest();
if !TreeItem::contains(database, digest)? {
+ let mut sorted_out = SortedTree::default();
+
tree.traverse()
.breadthfirst(&mut PathVisitor::new(TreeItemIndexerVisitor {
buffer: Vec::new(),
@@ -348,7 +350,10 @@ fn index_tree(
database,
batch,
submodules,
+ sorted: &mut sorted_out,
}))?;
+
+ sorted_out.insert(digest, database, batch)?;
}
Tree {
@@ -381,6 +386,7 @@ impl PathVisitorHandler for TreeHasherVisitor<'_> {
struct TreeItemIndexerVisitor<'a> {
digest: u64,
buffer: Vec<u8>,
+ sorted: &'a mut SortedTree,
database: &'a rocksdb::DB,
batch: &'a mut WriteBatch,
submodules: &'a BTreeMap<PathBuf, Url>,
@@ -417,6 +423,27 @@ impl PathVisitorHandler for TreeItemIndexerVisitor<'_> {
EntryKind::Tree => TreeItemKind::Tree,
};
+ // TODO: this could be more optimised doing a recursive DFS to not have to keep diving deep into self.sorted
+ if !entry.mode.is_tree() {
+ let mut tree = &mut *self.sorted;
+ let mut offset = 0;
+ for end in memchr::memchr_iter(b'/', path) {
+ let path = &path[offset..end];
+ offset = end + 1;
+
+ tree = match tree
+ .0
+ .entry(TreeKey(path.to_string()))
+ .or_insert(SortedTreeItem::Directory(SortedTree::default()))
+ {
+ SortedTreeItem::Directory(dir) => dir,
+ SortedTreeItem::File => panic!("a file is somehow not in a directory"),
+ };
+ }
+ tree.0
+ .insert(TreeKey(path[offset..].to_string()), SortedTreeItem::File);
+ }
+
TreeItem {
mode: entry.mode.0,
kind,
diff --git a/src/database/schema/mod.rs b/src/database/schema/mod.rs
index 2e8a91b..2dcf3c0 100644
--- a/src/database/schema/mod.rs
+++ b/src/database/schema/mod.rs
@@ -10,4 +10,4 @@ pub mod tree;
pub type Yoked<T> = Yoke<T, Box<[u8]>>;
-pub const SCHEMA_VERSION: &str = "4";
+pub const SCHEMA_VERSION: &str = "5";
diff --git a/src/database/schema/tree.rs b/src/database/schema/tree.rs
index 2cb67c9..726c3d8 100644
--- a/src/database/schema/tree.rs
+++ b/src/database/schema/tree.rs
@@ -1,3 +1,5 @@
+use std::collections::BTreeMap;
+
use anyhow::Context;
use gix::{bstr::BStr, ObjectId};
use itertools::{Either, Itertools};
@@ -50,6 +52,61 @@ impl Tree {
}
}
+#[derive(Serialize, Archive, Debug, PartialEq, Eq, Hash, Ord, PartialOrd)]
+#[rkyv(derive(Ord, PartialOrd, Eq, PartialEq, Debug))]
+#[rkyv(compare(PartialOrd, PartialEq))]
+pub struct TreeKey(pub String);
+
+#[derive(Serialize, Archive, Debug, PartialEq, Eq, Default, Yokeable)]
+pub struct SortedTree(pub BTreeMap<TreeKey, SortedTreeItem>);
+
+impl SortedTree {
+ pub fn insert(
+ &self,
+ digest: u64,
+ database: &DB,
+ batch: &mut WriteBatch,
+ ) -> Result<(), anyhow::Error> {
+ let cf = database
+ .cf_handle(TREE_ITEM_FAMILY)
+ .context("tree column family missing")?;
+
+ batch.put_cf(
+ cf,
+ digest.to_ne_bytes(),
+ rkyv::to_bytes::<rkyv::rancor::Error>(self)?,
+ );
+
+ Ok(())
+ }
+
+ pub fn get(digest: u64, database: &DB) -> Result<Option<YokedSortedTree>, anyhow::Error> {
+ let cf = database
+ .cf_handle(TREE_ITEM_FAMILY)
+ .expect("tree column family missing");
+
+ database
+ .get_cf(cf, digest.to_ne_bytes())?
+ .map(|data| {
+ Yoke::try_attach_to_cart(data.into_boxed_slice(), |data| {
+ rkyv::access::<_, rkyv::rancor::Error>(data)
+ })
+ })
+ .transpose()
+ .context("failed to parse full tree")
+ }
+}
+
+#[derive(Serialize, Archive, Debug, PartialEq, Eq)]
+#[rkyv(
+ bytecheck(bounds(__C: rkyv::validation::ArchiveContext)),
+ serialize_bounds(__S: rkyv::ser::Writer + rkyv::ser::Allocator, __S::Error: rkyv::rancor::Source),
+)]
+pub enum SortedTreeItem {
+ File,
+ Directory(#[rkyv(omit_bounds)] SortedTree),
+}
+
#[derive(Serialize, Archive, Debug, PartialEq, Eq, Hash)]
pub struct Submodule {
pub url: String,
@@ -69,6 +126,7 @@ pub struct TreeItem {
pub kind: TreeItemKind,
}
+pub type YokedSortedTree = Yoked<&'static <SortedTree as Archive>::Archived>;
pub type YokedTreeItem = Yoked<&'static <TreeItem as Archive>::Archived>;
pub type YokedTreeItemKey = Yoked<&'static [u8]>;
pub type YokedTreeItemKeyUtf8 = Yoked<&'static str>;
@@ -125,41 +183,51 @@ impl TreeItem {
pub fn find_prefix<'a>(
database: &'a DB,
digest: u64,
- prefix: &[u8],
+ prefix: Option<&[u8]>,
) -> impl Iterator<Item = Result<(YokedTreeItemKey, YokedTreeItem), anyhow::Error>> + use<'a>
{
let cf = database
.cf_handle(TREE_ITEM_FAMILY)
.expect("tree column family missing");
- let (iterator, key) = if prefix.is_empty() {
- let mut buffer = [0_u8; std::mem::size_of::<u64>() + std::mem::size_of::<usize>()];
- buffer[..std::mem::size_of::<u64>()].copy_from_slice(&digest.to_ne_bytes());
- buffer[std::mem::size_of::<u64>()..].copy_from_slice(&0_usize.to_be_bytes());
+ let (iterator, key) = match prefix {
+ None => {
+ let iterator = database.prefix_iterator_cf(cf, digest.to_ne_bytes());
+
+ (iterator, Either::Left(Either::Left(digest.to_be_bytes())))
+ }
+ Some([]) => {
+ let mut buffer = [0_u8; std::mem::size_of::<u64>() + std::mem::size_of::<usize>()];
+ buffer[..std::mem::size_of::<u64>()].copy_from_slice(&digest.to_ne_bytes());
+ buffer[std::mem::size_of::<u64>()..].copy_from_slice(&0_usize.to_be_bytes());
- let iterator = database.prefix_iterator_cf(cf, buffer);
+ let iterator = database.prefix_iterator_cf(cf, buffer);
- (iterator, Either::Left(buffer))
- } else {
- let mut buffer = Vec::with_capacity(
- std::mem::size_of::<u64>() + prefix.len() + std::mem::size_of::<usize>(),
- );
- buffer.extend_from_slice(&digest.to_ne_bytes());
- buffer
- .extend_from_slice(&(memchr::memchr_iter(b'/', prefix).count() + 1).to_be_bytes());
- buffer.extend_from_slice(prefix);
- buffer.push(b'/');
+ (iterator, Either::Left(Either::Right(buffer)))
+ }
+ Some(prefix) => {
+ let mut buffer = Vec::with_capacity(
+ std::mem::size_of::<u64>() + prefix.len() + std::mem::size_of::<usize>(),
+ );
+ buffer.extend_from_slice(&digest.to_ne_bytes());
+ buffer.extend_from_slice(
+ &(memchr::memchr_iter(b'/', prefix).count() + 1).to_be_bytes(),
+ );
+ buffer.extend_from_slice(prefix);
+ buffer.push(b'/');
- let iterator = database.prefix_iterator_cf(cf, &buffer);
+ let iterator = database.prefix_iterator_cf(cf, &buffer);
- (iterator, Either::Right(buffer))
+ (iterator, Either::Right(buffer))
+ }
};
iterator
.take_while(move |v| {
v.as_ref().is_ok_and(|(k, _)| {
k.starts_with(match key.as_ref() {
- Either::Left(v) => v.as_ref(),
+ Either::Left(Either::Right(v)) => v.as_ref(),
+ Either::Left(Either::Left(v)) => v.as_ref(),
Either::Right(v) => v.as_ref(),
})
})
diff --git a/src/main.rs b/src/main.rs
index 9105436..f557146 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -263,7 +263,7 @@ fn open_db(args: &Args) -> Result<Arc<rocksdb::DB>, anyhow::Error> {
let mut tree_item_family_options = Options::default();
tree_item_family_options.set_prefix_extractor(SliceTransform::create_fixed_prefix(
- std::mem::size_of::<u64>() + std::mem::size_of::<usize>(),
+ std::mem::size_of::<u64>(),
));
let db = rocksdb::DB::open_cf_with_opts(
diff --git a/src/methods/repo/mod.rs b/src/methods/repo/mod.rs
index c108ba3..b7d53fe 100644
--- a/src/methods/repo/mod.rs
+++ b/src/methods/repo/mod.rs
@@ -36,8 +36,10 @@ use self::{
tag::handle as handle_tag,
tree::handle as handle_tree,
};
-use crate::database::schema::tag::YokedString;
-use crate::database::schema::{commit::YokedCommit, tag::YokedTag};
+use crate::database::schema::{
+ commit::YokedCommit,
+ tag::{YokedString, YokedTag},
+};
pub const DEFAULT_BRANCHES: [&str; 2] = ["refs/heads/master", "refs/heads/main"];
diff --git a/src/methods/repo/tree.rs b/src/methods/repo/tree.rs
index 67c1f56..311644b 100644
--- a/src/methods/repo/tree.rs
+++ b/src/methods/repo/tree.rs
@@ -11,7 +11,8 @@ use std::{
};
use crate::database::schema::tree::{
- ArchivedTreeItemKind, Tree, TreeItem, YokedTreeItem, YokedTreeItemKeyUtf8,
+ ArchivedSortedTree, ArchivedSortedTreeItem, ArchivedTreeItemKind, SortedTree, Tree, TreeItem,
+ YokedSortedTree, YokedTreeItem, YokedTreeItemKeyUtf8,
};
use crate::{
git::FileWithContent,
@@ -51,6 +52,24 @@ impl Display for UriQuery {
}
}
+#[derive(Template)]
+#[template(path = "partials/file_tree.html")]
+pub struct FileTree<'a> {
+ pub inner: &'a ArchivedSortedTree,
+ pub base: &'a Repository,
+ pub path_stack: String,
+}
+
+impl<'a> FileTree<'a> {
+ pub fn new(inner: &'a ArchivedSortedTree, base: &'a Repository, path_stack: String) -> Self {
+ Self {
+ inner,
+ base,
+ path_stack,
+ }
+ }
+}
+
#[derive(Template)]
#[template(path = "repo/tree.html")]
#[allow(clippy::module_name_repetitions)]
@@ -69,6 +88,7 @@ pub struct FileView {
pub repo_path: PathBuf,
pub file: FileWithContent,
pub branch: Option<Arc<str>>,
+ pub full_tree: YokedSortedTree,
}
enum LookupResult {
@@ -81,11 +101,11 @@ pub async fn handle(
Extension(RepositoryPath(repository_path)): Extension<RepositoryPath>,
Extension(ChildPath(child_path)): Extension<ChildPath>,
Extension(git): Extension<Arc<Git>>,
- Extension(db): Extension<Arc<rocksdb::DB>>,
+ Extension(db_orig): Extension<Arc<rocksdb::DB>>,
Query(query): Query<UriQuery>,
) -> Result<impl IntoResponse> {
- // TODO: bit messy
- let (repo, query, child_path, lookup_result) = tokio::task::spawn_blocking(move || {
+ let db = db_orig.clone();
+ let (query, repo, tree_id) = tokio::task::spawn_blocking(move || {
let tree_id = if let Some(id) = query.id.as_deref() {
let hex = const_hex::decode_to_array(id).context("Failed to parse tree hash")?;
Tree::find(&db, ObjectId::Sha1(hex))
@@ -101,12 +121,19 @@ pub async fn handle(
commit.get().tree.to_native()
};
+ Ok::<_, anyhow::Error>((query, repo, tree_id))
+ })
+ .await
+ .context("failed to join tree_id task")??;
+
+ let db = db_orig.clone();
+ let (repo, child_path, lookup_result) = tokio::task::spawn_blocking(move || {
if let Some(path) = &child_path {
if let Some(item) =
TreeItem::find_exact(&db, tree_id, path.as_os_str().as_encoded_bytes())?
{
if let ArchivedTreeItemKind::File = item.get().kind {
- return Ok((repo, query, child_path, LookupResult::RealPath));
+ return Ok((repo, child_path, LookupResult::RealPath));
}
}
}
@@ -116,7 +143,7 @@ pub async fn handle(
.map(|v| v.as_os_str().as_encoded_bytes())
.unwrap_or_default();
- let tree_items = TreeItem::find_prefix(&db, tree_id, path)
+ let tree_items = TreeItem::find_prefix(&db, tree_id, Some(path))
// don't take the current path the user is on
.filter_ok(|(k, _)| !k.get()[path.len()..].is_empty())
// only take direct descendents
@@ -137,10 +164,10 @@ pub async fn handle(
bail!("Path doesn't exist in tree");
}
- Ok::<_, anyhow::Error>((repo, query, child_path, LookupResult::Children(tree_items)))
+ Ok::<_, anyhow::Error>((repo, child_path, LookupResult::Children(tree_items)))
})
.await
- .context("Failed to join on task")??;
+ .context("failed to join on tokio task")??;
Ok(match lookup_result {
LookupResult::RealPath => {
@@ -152,11 +179,18 @@ pub async fn handle(
if query.raw {
ResponseEither::Right(file.content)
} else {
+ let db = db_orig.clone();
+ let full_tree = tokio::task::spawn_blocking(move || SortedTree::get(tree_id, &db))
+ .await
+ .context("failed to join on tokio task")??
+ .context("missing file tree")?;
+
ResponseEither::Left(ResponseEither::Right(into_response(FileView {
repo,
file,
branch: query.branch,
repo_path: child_path.unwrap_or_default(),
+ full_tree,
})))
}
}
diff --git a/statics/sass/style.scss b/statics/sass/style.scss
index b92d654..e6adbdc 100644
--- a/statics/sass/style.scss
+++ b/statics/sass/style.scss
@@ -10,7 +10,8 @@
color: $darkModeTextColour;
}
- h2, h3 {
+ h2,
+ h3 {
color: darken($darkModeHighlightColour, 20%);
}
}
@@ -77,6 +78,168 @@ main {
}
}
+.two-col {
+ display: flex;
+ gap: 1rem;
+
+ .sidebar {
+ display: none;
+ overflow: hidden;
+ white-space: nowrap;
+ resize: horizontal;
+ max-width: 50%;
+ min-width: 18rem;
+ width: 10%;
+ }
+}
+
+.sidebar-toggle {
+ display: inline-block;
+ user-select: none;
+ cursor: pointer;
+ width: 1rem;
+ height: 0.75rem;
+ position: relative;
+ margin-bottom: 1rem;
+
+ span {
+ display: block;
+ position: absolute;
+ height: 0.125rem;
+ width: 100%;
+ background: #333;
+ border-radius: 0.125rem;
+ transition: 0.3s ease;
+
+ @media (prefers-color-scheme: dark) {
+ background: #abb2bf;
+ }
+
+ @media (prefers-reduced-motion) {
+ transition-duration: 0s;
+ }
+
+ &:nth-of-type(1) {
+ top: 0;
+ }
+
+ &:nth-of-type(2) {
+ top: 0.3rem;
+ }
+
+ &:nth-of-type(3) {
+ top: 0.6rem;
+ }
+ }
+
+ input:checked~span:nth-of-type(1) {
+ transform: rotate(45deg);
+ top: 0.23rem;
+ }
+
+ input:checked~span:nth-of-type(2) {
+ opacity: 0;
+ }
+
+ input:checked~span:nth-of-type(3) {
+ transform: rotate(-45deg);
+ top: 0.23rem;
+ }
+
+ input {
+ display: none;
+ }
+
+ &:has(input[type="checkbox"]:checked)+.two-col>.sidebar {
+ display: block;
+ }
+}
+
+.dropdown-link {
+ .dropdown-label {
+ display: flex;
+ width: 100%;
+ border-radius: .25rem;
+ overflow: hidden;
+ cursor: pointer;
+
+ &:hover {
+ background: rgba(0, 0, 255, .1);
+
+ @media (prefers-color-scheme: dark) {
+ background: rgba(255, 255, 255, .1);
+ }
+ }
+
+ .dropdown-toggle {
+ font-size: 1.25rem;
+ padding: .2rem .3rem;
+ cursor: pointer;
+ user-select: none;
+ display: flex;
+ align-items: center;
+
+ &:hover {
+ background: rgba(0, 0, 255, .1);
+
+ @media (prefers-color-scheme: dark) {
+ background: rgba(255, 255, 255, .1);
+ }
+ }
+ }
+ }
+
+ .link {
+ display: inline-block;
+ padding: .35rem .5rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ width: 100%;
+ text-decoration: none;
+ color: inherit;
+ }
+
+ .dropdown-content {
+ border-left: .15rem solid rgba(0, 0, 255, .1);
+ padding-left: .4rem;
+ margin-left: .4rem;
+ display: none;
+ }
+
+ input[type="checkbox"] {
+ display: none;
+ }
+
+ .dropdown-label:has(label input[type="checkbox"]:checked)+.dropdown-content {
+ display: block;
+ }
+
+ .dropdown-toggle span {
+ display: inline-block;
+ transition: transform 0.2s;
+
+ @media (prefers-reduced-motion) {
+ transition-duration: 0s;
+ }
+ }
+
+ input[type="checkbox"]:checked+.dropdown-toggle span {
+ transform: rotate(90deg);
+ }
+}
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border-width: 0;
+}
+
footer {
margin-top: 0.5em;
text-align: center;
@@ -107,4 +270,4 @@ a {
&:hover {
text-decoration: underline;
}
-}
+}
\ No newline at end of file
diff --git a/templates/partials/file_tree.html b/templates/partials/file_tree.html
new file mode 100644
index 0000000..1532235
--- /dev/null
+++ b/templates/partials/file_tree.html
@@ -0,0 +1,24 @@
+<div class="dropdown-link">
+ {% for (name, item) in inner.0.iter() -%}
+ {% match item -%}
+ {% when ArchivedSortedTreeItem::File -%}
+ <div class="dropdown-label">
+ <a class="link" href="/{{ base.display() }}/tree{{ path_stack }}/{{ name.0 }}">📄 {{ name.0 }}</a>
+ </div>
+
+ {% when ArchivedSortedTreeItem::Directory with (inner) -%}
+ <div class="dropdown-label">
+ <label>
+ <input type="checkbox">
+ <div class="dropdown-toggle"><span>›</span></div>
+ <span class="sr-only">Open directory</span>
+ </label>
+ <a class="link" href="/{{ base.display() }}/tree{{ path_stack }}/{{ name.0 }}">📁 {{ name.0 }}</a>
+ </div>
+
+ <div class="dropdown-content">
+ {{ FileTree::new(inner, self.base, format!("{}/{}", self.path_stack, name.0)).render()?|safe }}
+ </div>
+ {% endmatch -%}
+ {% endfor -%}
+</div>
diff --git a/templates/repo/file.html b/templates/repo/file.html
index 7862a54..c0ed01b 100644
--- a/templates/repo/file.html
+++ b/templates/repo/file.html
@@ -1,5 +1,6 @@
{% import "macros/link.html" as link %}
{% import "macros/breadcrumbs.html" as breadcrumbs %}
+{% import "macros/sidebar_toggle.html" as sidebar_toggle %}
{% extends "repo/base.html" %}
{% block head %}
@@ -18,12 +19,22 @@
{% endblock %}
{% block content %}
-<pre>
- {%- match file.content -%}
- {%- when crate::git::Content::Text with (content) -%}
- {{- content|safe -}}
- {%- when crate::git::Content::Binary with (_) -%}
- &lt;binary file not displayed&gt;
- {%- endmatch -%}
-</pre>
+{% call sidebar_toggle::sidebar_toggle("Open file browser") %}
+
+<div class="two-col">
+ <div class="sidebar">
+ {{ FileTree::new(full_tree.get(), &self.repo, Default::default()).render()?|safe }}
+ </div>
+
+ <div>
+ <pre>
+ {%- match file.content -%}
+ {%- when crate::git::Content::Text with (content) -%}
+ {{- content|safe -}}
+ {%- when crate::git::Content::Binary with (_) -%}
+ &lt;binary file not displayed&gt;
+ {%- endmatch -%}
+ </pre>
+ </div>
+</div>
{% endblock %}
diff --git a/templates/repo/macros/sidebar_toggle.html b/templates/repo/macros/sidebar_toggle.html
new file mode 100644
index 0000000..12e0164
--- /dev/null
+++ b/templates/repo/macros/sidebar_toggle.html
@@ -0,0 +1,9 @@
+{%- macro sidebar_toggle(screenreader_text) -%}
+<label class="sidebar-toggle">
+ <input type="checkbox">
+ <span></span>
+ <span></span>
+ <span></span>
+ <span class="sr-only">{{ screenreader_text }}</span>
+</label>
+{%- endmacro -%}