Add tree to file view
Diff
src/main.rs | 2 +-
src/database/indexer.rs | 29 +++++++++++++++++++++++++++++
statics/sass/style.scss | 167 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
templates/partials/file_tree.html | 24 ++++++++++++++++++++++++
templates/repo/file.html | 27 ++++++++++++++++++++-------
src/database/schema/mod.rs | 2 +-
src/database/schema/tree.rs | 114 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
src/methods/repo/mod.rs | 6 ++++--
src/methods/repo/tree.rs | 50 ++++++++++++++++++++++++++++++++++++++++++++++----
templates/repo/macros/sidebar_toggle.html | 9 +++++++++
10 files changed, 384 insertions(+), 46 deletions(-)
@@ -263,7 +263,7 @@
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(
@@ -30,7 +30,7 @@
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 @@
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 @@
database,
batch,
submodules,
sorted: &mut sorted_out,
}))?;
sorted_out.insert(digest, database, batch)?;
}
Tree {
@@ -381,6 +386,7 @@
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>,
@@ -416,6 +422,27 @@
}
EntryKind::Tree => TreeItemKind::Tree,
};
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,
@@ -10,7 +10,8 @@
color: $darkModeTextColour;
}
h2, h3 {
h2,
h3 {
color: darken($darkModeHighlightColour, 20%);
}
}
@@ -77,6 +78,168 @@
}
}
.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 @@
&:hover {
text-decoration: underline;
}
}
}
@@ -1,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>
@@ -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 (_) -%}
<binary file not displayed>
{%- 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 (_) -%}
<binary file not displayed>
{%- endmatch -%}
</pre>
</div>
</div>
{% endblock %}
@@ -10,4 +10,4 @@
pub type Yoked<T> = Yoke<T, Box<[u8]>>;
pub const SCHEMA_VERSION: &str = "4";
pub const SCHEMA_VERSION: &str = "5";
@@ -1,3 +1,5 @@
use std::collections::BTreeMap;
use anyhow::Context;
use gix::{bstr::BStr, ObjectId};
use itertools::{Either, Itertools};
@@ -47,7 +49,62 @@
let data = rkyv::access::<<Self as Archive>::Archived, rkyv::rancor::Error>(data.as_ref())?;
Ok(Some(data.indexed_tree_id.to_native()))
}
}
#[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)]
@@ -69,6 +126,7 @@
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 @@
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 = 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'/');
let iterator = database.prefix_iterator_cf(cf, &buffer);
(iterator, Either::Right(buffer))
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);
(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);
(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(),
})
})
@@ -36,8 +36,10 @@
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"];
@@ -11,7 +11,8 @@
};
use crate::database::schema::tree::{
ArchivedTreeItemKind, Tree, TreeItem, YokedTreeItem, YokedTreeItemKeyUtf8,
ArchivedSortedTree, ArchivedSortedTreeItem, ArchivedTreeItemKind, SortedTree, Tree, TreeItem,
YokedSortedTree, YokedTreeItem, YokedTreeItemKeyUtf8,
};
use crate::{
git::FileWithContent,
@@ -52,6 +53,24 @@
}
#[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)]
pub struct TreeView {
@@ -69,6 +88,7 @@
pub repo_path: PathBuf,
pub file: FileWithContent,
pub branch: Option<Arc<str>>,
pub full_tree: YokedSortedTree,
}
enum LookupResult {
@@ -81,11 +101,11 @@
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> {
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))
@@ -100,13 +120,20 @@
.context("Branch not found")?;
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 @@
.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))
.filter_ok(|(k, _)| !k.get()[path.len()..].is_empty())
@@ -137,10 +164,10 @@
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 @@
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,
})))
}
}
@@ -1,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 -%}