From db82da0c797f08e5489256794fdec8c1805bb93d Mon Sep 17 00:00:00 2001 From: Jordan Doyle Date: Mon, 28 Apr 2025 04:58:18 +0700 Subject: [PATCH] Add tree to file view --- 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::() + std::mem::size_of::(), + std::mem::size_of::(), )); 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) { 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, + sorted: &'a mut SortedTree, database: &'a rocksdb::DB, batch: &'a mut WriteBatch, submodules: &'a BTreeMap, @@ -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 @@ + 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 %} -
-    {%- match file.content -%}
-        {%- when crate::git::Content::Text with (content) -%}
-            {{- content|safe -}}
-        {%- when crate::git::Content::Binary with (_) -%}
-            <binary file not displayed>
-    {%- endmatch -%}
-
+{% call sidebar_toggle::sidebar_toggle("Open file browser") %} + +
+ + +
+
+            {%- match file.content -%}
+                {%- when crate::git::Content::Text with (content) -%}
+                    {{- content|safe -}}
+                {%- when crate::git::Content::Binary with (_) -%}
+                    <binary file not displayed>
+            {%- endmatch -%}
+        
+
+
{% 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 = Yoke>; -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::<::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); + +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::(self)?, + ); + + Ok(()) + } + + pub fn get(digest: u64, database: &DB) -> Result, 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 ::Archived>; pub type YokedTreeItem = Yoked<&'static ::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> + 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::() + std::mem::size_of::()]; - buffer[..std::mem::size_of::()].copy_from_slice(&digest.to_ne_bytes()); - buffer[std::mem::size_of::()..].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::() + prefix.len() + std::mem::size_of::(), - ); - 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::() + std::mem::size_of::()]; + buffer[..std::mem::size_of::()].copy_from_slice(&digest.to_ne_bytes()); + buffer[std::mem::size_of::()..].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::() + prefix.len() + std::mem::size_of::(), + ); + 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>, + pub full_tree: YokedSortedTree, } enum LookupResult { @@ -81,11 +101,11 @@ Extension(RepositoryPath(repository_path)): Extension, Extension(ChildPath(child_path)): Extension, Extension(git): Extension>, - Extension(db): Extension>, + Extension(db_orig): Extension>, Query(query): Query, ) -> Result { - // 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) -%} + +{%- endmacro -%} -- rgit 0.1.5