🏡 index : ~doyle/rgit.git

author Jordan Doyle <jordan@doyle.la> 2025-04-28 4:58:18.0 +07:00:00
committer Jordan Doyle <jordan@doyle.la> 2025-04-28 20:03:13.0 +07:00:00
commit
db82da0c797f08e5489256794fdec8c1805bb93d [patch]
tree
b16f686fa32fb00e54fbeb6ed56263f1a902fc87
parent
1a476f691146854c0afb059d625fd3faac19b924
download
db82da0c797f08e5489256794fdec8c1805bb93d.tar.gz

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(-)

diff --git a/src/main.rs b/src/main.rs
index 9105436..f557146 100644
--- a/src/main.rs
+++ a/src/main.rs
@@ -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(
diff --git a/src/database/indexer.rs b/src/database/indexer.rs
index 9f9b007..717b5f6 100644
--- a/src/database/indexer.rs
+++ a/src/database/indexer.rs
@@ -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,
        };

        // 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,
diff --git a/statics/sass/style.scss b/statics/sass/style.scss
index b92d654..e6adbdc 100644
--- a/statics/sass/style.scss
+++ a/statics/sass/style.scss
@@ -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;
  }
}
}
diff --git a/templates/partials/file_tree.html b/templates/partials/file_tree.html
new file mode 100644
index 0000000..1532235 100644
--- /dev/null
+++ a/templates/partials/file_tree.html
@@ -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>
diff --git a/templates/repo/file.html b/templates/repo/file.html
index 7862a54..c0ed01b 100644
--- a/templates/repo/file.html
+++ a/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/src/database/schema/mod.rs b/src/database/schema/mod.rs
index 2e8a91b..2dcf3c0 100644
--- a/src/database/schema/mod.rs
+++ a/src/database/schema/mod.rs
@@ -10,4 +10,4 @@

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
+++ a/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};
@@ -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(),
                    })
                })
diff --git a/src/methods/repo/mod.rs b/src/methods/repo/mod.rs
index c108ba3..b7d53fe 100644
--- a/src/methods/repo/mod.rs
+++ a/src/methods/repo/mod.rs
@@ -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"];

diff --git a/src/methods/repo/tree.rs b/src/methods/repo/tree.rs
index 67c1f56..311644b 100644
--- a/src/methods/repo/tree.rs
+++ a/src/methods/repo/tree.rs
@@ -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> {
    // 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))
@@ -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))
            // 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 @@
            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,
                })))
            }
        }
diff --git a/templates/repo/macros/sidebar_toggle.html b/templates/repo/macros/sidebar_toggle.html
new file mode 100644
index 0000000..12e0164 100644
--- /dev/null
+++ a/templates/repo/macros/sidebar_toggle.html
@@ -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 -%}