Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Add source browsing
Sebastian Martinez committed 9 months ago
commit e0ae5b55018aa81c87a8b85cd33a90af22346659
parent 5954d8e
35 files changed +1195 -42
modified Cargo.lock
@@ -4078,6 +4078,7 @@ dependencies = [
 "anyhow",
 "base64 0.22.1",
 "either",
+
 "infer",
 "log",
 "radicle",
 "radicle-surf",
@@ -4139,6 +4140,7 @@ dependencies = [
 "tree-sitter-toml-ng",
 "tree-sitter-typescript",
 "ts-rs",
+
 "url",
]

[[package]]
modified crates/radicle-tauri/Cargo.toml
@@ -18,6 +18,7 @@ tauri-build = { version = "2.2.0", features = ["isolation"] }
anyhow = { version = "1.0.90" }
base64 = { version = "0.22.1" }
either = { version = "1.15" }
+
infer = { version = "0.19.0" }
log = { version = "0.4.22" }
radicle = { version = "0.15.0" }
radicle-types = { version = "0.1.0", path = "../radicle-types" }
modified crates/radicle-tauri/src/commands/repo.rs
@@ -45,6 +45,24 @@ pub fn repo_readme(
}

#[tauri::command]
+
pub fn repo_tree(
+
    ctx: tauri::State<AppState>,
+
    rid: RepoId,
+
    path: std::path::PathBuf,
+
) -> Result<types::source::tree::Tree, Error> {
+
    ctx.repo_tree(rid, path)
+
}
+

+
#[tauri::command]
+
pub fn repo_blob(
+
    ctx: tauri::State<AppState>,
+
    rid: RepoId,
+
    path: std::path::PathBuf,
+
) -> Result<types::source::blob::Blob, Error> {
+
    ctx.repo_blob(rid, path)
+
}
+

+
#[tauri::command]
pub async fn diff_stats(
    ctx: tauri::State<'_, AppState>,
    rid: RepoId,
modified crates/radicle-tauri/src/lib.rs
@@ -60,6 +60,8 @@ pub fn run() {
            repo::repo_by_id,
            repo::repo_count,
            repo::repo_readme,
+
            repo::repo_tree,
+
            repo::repo_blob,
            repo::seed,
            repo::seeded_not_replicated,
            repo::unseed,
modified crates/radicle-tauri/tauri.conf.json
@@ -20,8 +20,9 @@
    "security": {
      "csp": {
        "default-src": "'self'",
-
        "connect-src": "ipc: http://ipc.localhost https://minio-api.radworks.garden",
+
        "connect-src": "ipc: http://ipc.localhost https://minio-api.radworks.garden tauri://localhost/assets/onig-CwjCXqnP.wasm",
        "img-src": "'self' blob: data: https:",
+
        "script-src": "'wasm-unsafe-eval'",
        "style-src": "'unsafe-inline' 'self'"
      },
      "pattern": {
modified crates/radicle-types/Cargo.toml
@@ -45,6 +45,7 @@ ts-rs = { version = "10.1.0", features = [
    "no-serde-warnings",
    "format",
] }
+
url = { version = "2.5.4", features = ["serde"] }

[dev-dependencies]
radicle = { version = "0.15.0", features = ["test"] }
modified crates/radicle-types/bindings/repo/Readme.ts
@@ -1,3 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
import type { Commit } from "./Commit";

-
export type Readme = { path: string; content: string; binary: boolean };
+
export type Readme = {
+
  id: string;
+
  binary: boolean;
+
  commit: Commit;
+
  mimeType: string;
+
  content: string;
+
  path: string;
+
};
added crates/radicle-types/bindings/source/Blob.ts
@@ -0,0 +1,10 @@
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
import type { Commit } from "../repo/Commit";
+

+
export type Blob = {
+
  id: string;
+
  binary: boolean;
+
  commit: Commit;
+
  mimeType: string;
+
  content: string;
+
};
added crates/radicle-types/bindings/source/Commit.ts
@@ -0,0 +1,10 @@
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+

+
export type Commit = {
+
  id: string;
+
  author: { name: string; email: string; time: number };
+
  committer: { name: string; email: string; time: number };
+
  message: string;
+
  summary: string;
+
  parents: Array<string>;
+
};
added crates/radicle-types/bindings/source/Entry.ts
@@ -0,0 +1,7 @@
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+

+
export type Entry = {
+
  name: string;
+
  path: string;
+
  kind: "tree" | "blob" | "submodule";
+
};
added crates/radicle-types/bindings/source/EntryKind.ts
@@ -0,0 +1,5 @@
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+

+
export type EntryKind = { "tree": string } | { "blob": string } | {
+
  "submodule": { id: string; url: string | null };
+
};
added crates/radicle-types/bindings/source/Tree.ts
@@ -0,0 +1,4 @@
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
import type { Entry } from "./Entry";
+

+
export type Tree = { id: string; path: string; entries: Array<Entry> };
modified crates/radicle-types/src/error.rs
@@ -106,6 +106,10 @@ pub enum Error {

    /// Repository error.
    #[error(transparent)]
+
    SurfFsError(#[from] radicle_surf::fs::error::Directory),
+

+
    /// Repository error.
+
    #[error(transparent)]
    Repository(#[from] radicle::storage::RepositoryError),

    /// Policy store error.
@@ -181,6 +185,7 @@ impl Error {
            Error::AliasError(radicle::node::AliasError::InvalidCharacter) => {
                "AliasError.InvalidAlias"
            }
+
            Error::FileTooLarge(_) => "PayloadError.TooLarge",
            _ => "UnknownError",
        }
    }
modified crates/radicle-types/src/lib.rs
@@ -12,6 +12,7 @@ pub mod domain;
pub mod error;
pub mod outbound;
pub mod repo;
+
pub mod source;
pub mod syntax;
pub mod test;
pub mod traits;
modified crates/radicle-types/src/repo.rs
@@ -42,9 +42,13 @@ pub struct RepoInfo {
#[ts(export)]
#[ts(export_to = "repo/")]
pub struct Readme {
-
    pub path: String,
-
    pub content: String,
+
    #[ts(as = "String")]
+
    pub id: surf::Oid,
    pub binary: bool,
+
    pub commit: Commit,
+
    pub mime_type: String,
+
    pub content: String,
+
    pub path: String,
}

#[derive(Default, Serialize, TS)]
added crates/radicle-types/src/source.rs
@@ -0,0 +1,3 @@
+
pub mod blob;
+
pub mod commit;
+
pub mod tree;
added crates/radicle-types/src/source/blob.rs
@@ -0,0 +1,45 @@
+
use base64::{prelude::BASE64_STANDARD, Engine};
+
use radicle::git::Oid;
+
use radicle_surf as surf;
+
use serde::Serialize;
+
use ts_rs::TS;
+

+
use crate::repo::Commit;
+
use crate::traits::repo::MAX_BLOB_SIZE;
+

+
#[derive(TS, Serialize)]
+
#[ts(export)]
+
#[ts(export_to = "source/")]
+
#[serde(rename_all = "camelCase")]
+
pub struct Blob {
+
    #[ts(as = "String")]
+
    id: Oid,
+
    binary: bool,
+
    commit: Commit,
+
    mime_type: String,
+
    content: String,
+
}
+

+
impl<T: AsRef<[u8]>> From<surf::blob::Blob<T>> for Blob {
+
    fn from(blob: surf::blob::Blob<T>) -> Self {
+
        let content = match blob.size() {
+
            s if s > MAX_BLOB_SIZE && blob.is_binary() => {
+
                String::from("Payload too large, content has been truncated")
+
            }
+
            _ => match std::str::from_utf8(blob.content()) {
+
                Ok(s) => s.to_owned(),
+
                Err(_) => BASE64_STANDARD.encode(blob.content()),
+
            },
+
        };
+

+
        let mime_type = infer::get(blob.content()).map(|i| i.mime_type().to_string());
+

+
        Blob {
+
            id: blob.object_id(),
+
            binary: blob.is_binary(),
+
            commit: blob.commit().clone().into(),
+
            content,
+
            mime_type: mime_type.unwrap_or_else(|| "application/octet-stream".to_string()),
+
        }
+
    }
+
}
added crates/radicle-types/src/source/commit.rs
@@ -0,0 +1,34 @@
+
use radicle::git::Oid;
+
use radicle_surf as surf;
+
use serde::Serialize;
+
use ts_rs::TS;
+

+
#[derive(TS, Serialize)]
+
#[ts(export)]
+
#[ts(export_to = "source/")]
+
#[serde(rename_all = "camelCase")]
+
pub struct Commit {
+
    #[ts(as = "String")]
+
    pub id: Oid,
+
    #[ts(type = "{ name: string; email: string; time: number; }")]
+
    pub author: surf::Author,
+
    #[ts(type = "{ name: string; email: string; time: number; }")]
+
    pub committer: surf::Author,
+
    pub message: String,
+
    pub summary: String,
+
    #[ts(as = "Vec<String>")]
+
    pub parents: Vec<Oid>,
+
}
+

+
impl From<surf::Commit> for Commit {
+
    fn from(commit: surf::Commit) -> Self {
+
        Commit {
+
            id: commit.id,
+
            author: commit.author,
+
            committer: commit.committer,
+
            message: commit.message.to_string(),
+
            summary: commit.summary.to_string(),
+
            parents: commit.parents.into_iter().collect::<Vec<_>>(),
+
        }
+
    }
+
}
added crates/radicle-types/src/source/tree.rs
@@ -0,0 +1,105 @@
+
use std::cmp::Ordering;
+

+
use radicle::git::Oid;
+
use radicle_surf as surf;
+
use serde::Serialize;
+
use ts_rs::TS;
+

+
use serde::ser::SerializeStruct;
+

+
#[derive(TS, Serialize)]
+
#[ts(export)]
+
#[ts(export_to = "source/")]
+
#[serde(rename_all = "camelCase")]
+
pub struct Tree {
+
    #[ts(as = "String")]
+
    id: Oid,
+
    path: std::path::PathBuf,
+
    entries: Vec<Entry>,
+
}
+

+
impl Tree {
+
    pub fn from_surf(tree: surf::tree::Tree, path: &std::path::Path) -> Self {
+
        Tree {
+
            id: tree.object_id(),
+
            path: path.to_path_buf(),
+
            entries: tree
+
                .entries()
+
                .clone()
+
                .into_iter()
+
                .map(|entry| Entry::from_surf(entry, path))
+
                .collect::<Vec<Entry>>(),
+
        }
+
    }
+
}
+

+
#[derive(TS)]
+
#[ts(export)]
+
#[ts(export_to = "source/")]
+
pub struct Entry {
+
    name: String,
+
    path: std::path::PathBuf,
+
    #[ts(type = "'tree' | 'blob' | 'submodule'")]
+
    kind: surf::tree::EntryKind,
+
}
+

+
impl Ord for Entry {
+
    fn cmp(&self, other: &Self) -> Ordering {
+
        self.kind.cmp(&other.kind).then(self.name.cmp(&other.name))
+
    }
+
}
+

+
impl PartialOrd for Entry {
+
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+
        Some(self.cmp(other))
+
    }
+
}
+

+
impl PartialEq for Entry {
+
    fn eq(&self, other: &Self) -> bool {
+
        self.kind == other.kind && self.name == other.name
+
    }
+
}
+

+
impl Eq for Entry {}
+

+
impl Entry {
+
    fn from_surf(entry: surf::tree::Entry, path: &std::path::Path) -> Self {
+
        Entry {
+
            name: entry.name().to_string(),
+
            path: path.to_path_buf().join(entry.name()),
+
            kind: entry.entry().clone(),
+
        }
+
    }
+
}
+

+
impl Entry {
+
    pub fn object_id(&self) -> Oid {
+
        match self.kind {
+
            surf::tree::EntryKind::Blob(id) => id,
+
            surf::tree::EntryKind::Tree(id) => id,
+
            surf::tree::EntryKind::Submodule { id, .. } => id,
+
        }
+
    }
+
}
+

+
impl Serialize for Entry {
+
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+
    where
+
        S: serde::Serializer,
+
    {
+
        const FIELDS: usize = 3;
+
        let mut state = serializer.serialize_struct("TreeEntry", FIELDS)?;
+
        state.serialize_field("name", &self.name)?;
+
        state.serialize_field("path", &self.path)?;
+
        state.serialize_field(
+
            "kind",
+
            match self.kind {
+
                surf::tree::EntryKind::Blob(_) => "blob",
+
                surf::tree::EntryKind::Tree(_) => "tree",
+
                surf::tree::EntryKind::Submodule { .. } => "submodule",
+
            },
+
        )?;
+
        state.end()
+
    }
+
}
modified crates/radicle-types/src/traits/repo.rs
@@ -14,10 +14,13 @@ use crate::cobs;
use crate::diff;
use crate::diff::Diff;
use crate::error::Error;
-
use crate::repo::{self, RepoCount};
+
use crate::repo;
+
use crate::source;
use crate::syntax::{Highlighter, ToPretty};
use crate::traits::Profile;

+
pub const MAX_BLOB_SIZE: usize = 10_485_760;
+

#[derive(Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub enum Show {
@@ -100,7 +103,7 @@ pub trait Repo: Profile {
            }
        }

-
        Ok::<_, Error>(RepoCount {
+
        Ok::<_, Error>(repo::RepoCount {
            total,
            contributor,
            seeding,
@@ -137,12 +140,19 @@ pub trait Repo: Profile {
            .chain(paths.iter().map(|p| p.to_lowercase()))
        {
            if let Ok(blob) = repo.blob(oid, &path) {
+
                if blob.size() > MAX_BLOB_SIZE {
+
                    return Err(Error::FileTooLarge(blob.size()));
+
                }
+

                let content = match std::str::from_utf8(blob.content()) {
                    Ok(s) => s.to_owned(),
                    Err(_) => base64::engine::general_purpose::STANDARD.encode(blob.content()),
                };

                return Ok(Some(repo::Readme {
+
                    id: blob.object_id(),
+
                    commit: blob.commit().clone().into(),
+
                    mime_type: "text/plain".to_owned(),
                    path,
                    content,
                    binary: blob.is_binary(),
@@ -152,6 +162,36 @@ pub trait Repo: Profile {
        Ok(None)
    }

+
    fn repo_tree(
+
        &self,
+
        rid: identity::RepoId,
+
        path: std::path::PathBuf,
+
    ) -> Result<source::tree::Tree, Error> {
+
        let profile = self.profile();
+
        let repo = radicle_surf::Repository::open(radicle::storage::git::paths::repository(
+
            &profile.storage,
+
            &rid,
+
        ))?;
+
        let head = repo.head()?;
+
        let tree = repo.tree(head, &path)?;
+
        Ok(source::tree::Tree::from_surf(tree, &path))
+
    }
+

+
    fn repo_blob(
+
        &self,
+
        rid: identity::RepoId,
+
        path: std::path::PathBuf,
+
    ) -> Result<source::blob::Blob, Error> {
+
        let profile = self.profile();
+
        let repo = radicle_surf::Repository::open(radicle::storage::git::paths::repository(
+
            &profile.storage,
+
            &rid,
+
        ))?;
+
        let head = repo.head()?;
+

+
        repo.blob(head, &path).map(Into::into).map_err(Error::from)
+
    }
+

    fn repo_by_id(&self, rid: identity::RepoId) -> Result<repo::RepoInfo, Error> {
        let profile = self.profile();
        let repo = profile.storage.repository(rid)?;
modified crates/radicle-types/src/traits/thread.rs
@@ -14,6 +14,8 @@ use crate::cobs;
use crate::error::Error;
use crate::traits::Profile;

+
pub const MAX_EMBED_SIZE: usize = 10_485_760;
+

pub trait Thread: Profile {
    fn get_embed(
        &self,
@@ -65,7 +67,7 @@ pub trait Thread: Profile {
        let repo = profile.storage.repository(rid)?;
        let bytes = fs::read(path.clone())?;
        let file_size = bytes.len();
-
        if file_size > 10_485_760 {
+
        if file_size > MAX_EMBED_SIZE {
            return Err(Error::FileTooLarge(file_size));
        }
        let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("embed");
@@ -81,7 +83,7 @@ pub trait Thread: Profile {
        bytes: Vec<u8>,
    ) -> Result<git::Oid, Error> {
        let file_size = bytes.len();
-
        if file_size > 10_485_760 {
+
        if file_size > MAX_EMBED_SIZE {
            return Err(Error::FileTooLarge(file_size));
        }
        let profile = self.profile();
modified crates/test-http-api/src/api.rs
@@ -72,6 +72,8 @@ pub fn router(ctx: Context) -> Router {
            "/activity_by_patch",
            post(activity_patch_handler::<radicle::patch::Action, models::patch::Action>),
        )
+
        .route("/repo_tree", post(tree_handler))
+
        .route("/repo_blob", post(blob_handler))
        .route("/get_diff", post(diff_handler))
        .route("/list_issues", post(issues_handler))
        .route("/create_issue", post(create_issue_handler))
@@ -170,6 +172,36 @@ struct DiffBody {
    pub options: types::cobs::diff::DiffOptions,
}

+
#[derive(Serialize, Deserialize)]
+
struct TreeBody {
+
    pub rid: identity::RepoId,
+
    pub path: PathBuf,
+
}
+

+
async fn tree_handler(
+
    State(ctx): State<Context>,
+
    Json(TreeBody { rid, path }): Json<TreeBody>,
+
) -> impl IntoResponse {
+
    let info = ctx.repo_tree(rid, path)?;
+

+
    Ok::<_, Error>(Json(info))
+
}
+

+
#[derive(Serialize, Deserialize)]
+
struct BlobBody {
+
    pub rid: identity::RepoId,
+
    pub path: PathBuf,
+
}
+

+
async fn blob_handler(
+
    State(ctx): State<Context>,
+
    Json(BlobBody { rid, path }): Json<BlobBody>,
+
) -> impl IntoResponse {
+
    let info = ctx.repo_blob(rid, path)?;
+

+
    Ok::<_, Error>(Json(info))
+
}
+

async fn diff_handler(
    State(ctx): State<Context>,
    Json(DiffBody { rid, options }): Json<DiffBody>,
modified package-lock.json
@@ -16,6 +16,7 @@
        "@tauri-apps/plugin-log": "^2.4.0",
        "@tauri-apps/plugin-shell": "^2.2.1",
        "@tauri-apps/plugin-window-state": "^2.2.2",
+
        "hast-util-to-html": "^9.0.5",
        "overlayscrollbars": "^2.11.4",
        "overlayscrollbars-svelte": "^0.5.5",
        "semver": "^7.7.2",
@@ -1452,7 +1453,6 @@
      "version": "3.0.4",
      "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
      "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
-
      "dev": true,
      "dependencies": {
        "@types/unist": "*"
      }
@@ -1481,6 +1481,15 @@
      "integrity": "sha512-/i42wjYNgE6wf0j2bcTX6kuowmdL/6PE4IVitMpm2eYKBUuYCprdcWVK+xEF0gcV6ufMCRhtxmReGfc6hIK7Jw==",
      "dev": true
    },
+
    "node_modules/@types/mdast": {
+
      "version": "4.0.4",
+
      "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
+
      "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
+
      "license": "MIT",
+
      "dependencies": {
+
        "@types/unist": "*"
+
      }
+
    },
    "node_modules/@types/node": {
      "version": "22.15.17",
      "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.17.tgz",
@@ -1507,8 +1516,7 @@
    "node_modules/@types/unist": {
      "version": "3.0.3",
      "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
-
      "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
-
      "dev": true
+
      "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="
    },
    "node_modules/@types/wait-on": {
      "version": "5.3.4",
@@ -1715,6 +1723,12 @@
        "url": "https://opencollective.com/typescript-eslint"
      }
    },
+
    "node_modules/@ungap/structured-clone": {
+
      "version": "1.3.0",
+
      "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
+
      "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
+
      "license": "ISC"
+
    },
    "node_modules/@vitest/expect": {
      "version": "3.1.3",
      "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.3.tgz",
@@ -2118,6 +2132,16 @@
        "node": ">=6"
      }
    },
+
    "node_modules/ccount": {
+
      "version": "2.0.1",
+
      "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
+
      "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
+
      "license": "MIT",
+
      "funding": {
+
        "type": "github",
+
        "url": "https://github.com/sponsors/wooorm"
+
      }
+
    },
    "node_modules/chai": {
      "version": "5.2.0",
      "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz",
@@ -2146,6 +2170,26 @@
        "url": "https://github.com/chalk/chalk?sponsor=1"
      }
    },
+
    "node_modules/character-entities-html4": {
+
      "version": "2.1.0",
+
      "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
+
      "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
+
      "license": "MIT",
+
      "funding": {
+
        "type": "github",
+
        "url": "https://github.com/sponsors/wooorm"
+
      }
+
    },
+
    "node_modules/character-entities-legacy": {
+
      "version": "3.0.0",
+
      "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
+
      "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
+
      "license": "MIT",
+
      "funding": {
+
        "type": "github",
+
        "url": "https://github.com/sponsors/wooorm"
+
      }
+
    },
    "node_modules/charenc": {
      "version": "0.0.2",
      "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
@@ -2217,6 +2261,16 @@
        "node": ">= 0.8"
      }
    },
+
    "node_modules/comma-separated-tokens": {
+
      "version": "2.0.3",
+
      "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
+
      "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
+
      "license": "MIT",
+
      "funding": {
+
        "type": "github",
+
        "url": "https://github.com/sponsors/wooorm"
+
      }
+
    },
    "node_modules/commander": {
      "version": "8.3.0",
      "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
@@ -2379,6 +2433,28 @@
        "node": ">= 0.8"
      }
    },
+
    "node_modules/dequal": {
+
      "version": "2.0.3",
+
      "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+
      "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+
      "license": "MIT",
+
      "engines": {
+
        "node": ">=6"
+
      }
+
    },
+
    "node_modules/devlop": {
+
      "version": "1.1.0",
+
      "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
+
      "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
+
      "license": "MIT",
+
      "dependencies": {
+
        "dequal": "^2.0.0"
+
      },
+
      "funding": {
+
        "type": "github",
+
        "url": "https://github.com/sponsors/wooorm"
+
      }
+
    },
    "node_modules/dompurify": {
      "version": "3.2.5",
      "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.5.tgz",
@@ -3380,6 +3456,52 @@
        "url": "https://opencollective.com/unified"
      }
    },
+
    "node_modules/hast-util-to-html": {
+
      "version": "9.0.5",
+
      "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz",
+
      "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==",
+
      "license": "MIT",
+
      "dependencies": {
+
        "@types/hast": "^3.0.0",
+
        "@types/unist": "^3.0.0",
+
        "ccount": "^2.0.0",
+
        "comma-separated-tokens": "^2.0.0",
+
        "hast-util-whitespace": "^3.0.0",
+
        "html-void-elements": "^3.0.0",
+
        "mdast-util-to-hast": "^13.0.0",
+
        "property-information": "^7.0.0",
+
        "space-separated-tokens": "^2.0.0",
+
        "stringify-entities": "^4.0.0",
+
        "zwitch": "^2.0.4"
+
      },
+
      "funding": {
+
        "type": "opencollective",
+
        "url": "https://opencollective.com/unified"
+
      }
+
    },
+
    "node_modules/hast-util-whitespace": {
+
      "version": "3.0.0",
+
      "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
+
      "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
+
      "license": "MIT",
+
      "dependencies": {
+
        "@types/hast": "^3.0.0"
+
      },
+
      "funding": {
+
        "type": "opencollective",
+
        "url": "https://opencollective.com/unified"
+
      }
+
    },
+
    "node_modules/html-void-elements": {
+
      "version": "3.0.0",
+
      "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
+
      "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==",
+
      "license": "MIT",
+
      "funding": {
+
        "type": "github",
+
        "url": "https://github.com/sponsors/wooorm"
+
      }
+
    },
    "node_modules/http-errors": {
      "version": "2.0.0",
      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
@@ -3875,6 +3997,27 @@
        "is-buffer": "~1.1.6"
      }
    },
+
    "node_modules/mdast-util-to-hast": {
+
      "version": "13.2.0",
+
      "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz",
+
      "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==",
+
      "license": "MIT",
+
      "dependencies": {
+
        "@types/hast": "^3.0.0",
+
        "@types/mdast": "^4.0.0",
+
        "@ungap/structured-clone": "^1.0.0",
+
        "devlop": "^1.0.0",
+
        "micromark-util-sanitize-uri": "^2.0.0",
+
        "trim-lines": "^3.0.0",
+
        "unist-util-position": "^5.0.0",
+
        "unist-util-visit": "^5.0.0",
+
        "vfile": "^6.0.0"
+
      },
+
      "funding": {
+
        "type": "opencollective",
+
        "url": "https://opencollective.com/unified"
+
      }
+
    },
    "node_modules/media-typer": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
@@ -3905,6 +4048,95 @@
        "node": ">= 8"
      }
    },
+
    "node_modules/micromark-util-character": {
+
      "version": "2.1.1",
+
      "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz",
+
      "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
+
      "funding": [
+
        {
+
          "type": "GitHub Sponsors",
+
          "url": "https://github.com/sponsors/unifiedjs"
+
        },
+
        {
+
          "type": "OpenCollective",
+
          "url": "https://opencollective.com/unified"
+
        }
+
      ],
+
      "license": "MIT",
+
      "dependencies": {
+
        "micromark-util-symbol": "^2.0.0",
+
        "micromark-util-types": "^2.0.0"
+
      }
+
    },
+
    "node_modules/micromark-util-encode": {
+
      "version": "2.0.1",
+
      "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
+
      "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==",
+
      "funding": [
+
        {
+
          "type": "GitHub Sponsors",
+
          "url": "https://github.com/sponsors/unifiedjs"
+
        },
+
        {
+
          "type": "OpenCollective",
+
          "url": "https://opencollective.com/unified"
+
        }
+
      ],
+
      "license": "MIT"
+
    },
+
    "node_modules/micromark-util-sanitize-uri": {
+
      "version": "2.0.1",
+
      "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz",
+
      "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
+
      "funding": [
+
        {
+
          "type": "GitHub Sponsors",
+
          "url": "https://github.com/sponsors/unifiedjs"
+
        },
+
        {
+
          "type": "OpenCollective",
+
          "url": "https://opencollective.com/unified"
+
        }
+
      ],
+
      "license": "MIT",
+
      "dependencies": {
+
        "micromark-util-character": "^2.0.0",
+
        "micromark-util-encode": "^2.0.0",
+
        "micromark-util-symbol": "^2.0.0"
+
      }
+
    },
+
    "node_modules/micromark-util-symbol": {
+
      "version": "2.0.1",
+
      "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz",
+
      "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==",
+
      "funding": [
+
        {
+
          "type": "GitHub Sponsors",
+
          "url": "https://github.com/sponsors/unifiedjs"
+
        },
+
        {
+
          "type": "OpenCollective",
+
          "url": "https://opencollective.com/unified"
+
        }
+
      ],
+
      "license": "MIT"
+
    },
+
    "node_modules/micromark-util-types": {
+
      "version": "2.0.2",
+
      "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
+
      "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==",
+
      "funding": [
+
        {
+
          "type": "GitHub Sponsors",
+
          "url": "https://github.com/sponsors/unifiedjs"
+
        },
+
        {
+
          "type": "OpenCollective",
+
          "url": "https://opencollective.com/unified"
+
        }
+
      ],
+
      "license": "MIT"
+
    },
    "node_modules/micromatch": {
      "version": "4.0.8",
      "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@@ -4471,7 +4703,6 @@
      "version": "7.1.0",
      "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
      "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==",
-
      "dev": true,
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/wooorm"
@@ -4909,6 +5140,16 @@
        "node": ">=0.10.0"
      }
    },
+
    "node_modules/space-separated-tokens": {
+
      "version": "2.0.2",
+
      "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
+
      "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
+
      "license": "MIT",
+
      "funding": {
+
        "type": "github",
+
        "url": "https://github.com/sponsors/wooorm"
+
      }
+
    },
    "node_modules/stackback": {
      "version": "0.0.2",
      "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@@ -4930,6 +5171,20 @@
      "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==",
      "dev": true
    },
+
    "node_modules/stringify-entities": {
+
      "version": "4.0.4",
+
      "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
+
      "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
+
      "license": "MIT",
+
      "dependencies": {
+
        "character-entities-html4": "^2.0.0",
+
        "character-entities-legacy": "^3.0.0"
+
      },
+
      "funding": {
+
        "type": "github",
+
        "url": "https://github.com/sponsors/wooorm"
+
      }
+
    },
    "node_modules/strip-bom-string": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
@@ -5127,6 +5382,16 @@
        "node": ">=0.6"
      }
    },
+
    "node_modules/trim-lines": {
+
      "version": "3.0.1",
+
      "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
+
      "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
+
      "license": "MIT",
+
      "funding": {
+
        "type": "github",
+
        "url": "https://github.com/sponsors/wooorm"
+
      }
+
    },
    "node_modules/ts-api-utils": {
      "version": "2.1.0",
      "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
@@ -5248,6 +5513,74 @@
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
+
    "node_modules/unist-util-is": {
+
      "version": "6.0.0",
+
      "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz",
+
      "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==",
+
      "license": "MIT",
+
      "dependencies": {
+
        "@types/unist": "^3.0.0"
+
      },
+
      "funding": {
+
        "type": "opencollective",
+
        "url": "https://opencollective.com/unified"
+
      }
+
    },
+
    "node_modules/unist-util-position": {
+
      "version": "5.0.0",
+
      "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
+
      "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
+
      "license": "MIT",
+
      "dependencies": {
+
        "@types/unist": "^3.0.0"
+
      },
+
      "funding": {
+
        "type": "opencollective",
+
        "url": "https://opencollective.com/unified"
+
      }
+
    },
+
    "node_modules/unist-util-stringify-position": {
+
      "version": "4.0.0",
+
      "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
+
      "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
+
      "license": "MIT",
+
      "dependencies": {
+
        "@types/unist": "^3.0.0"
+
      },
+
      "funding": {
+
        "type": "opencollective",
+
        "url": "https://opencollective.com/unified"
+
      }
+
    },
+
    "node_modules/unist-util-visit": {
+
      "version": "5.0.0",
+
      "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz",
+
      "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==",
+
      "license": "MIT",
+
      "dependencies": {
+
        "@types/unist": "^3.0.0",
+
        "unist-util-is": "^6.0.0",
+
        "unist-util-visit-parents": "^6.0.0"
+
      },
+
      "funding": {
+
        "type": "opencollective",
+
        "url": "https://opencollective.com/unified"
+
      }
+
    },
+
    "node_modules/unist-util-visit-parents": {
+
      "version": "6.0.1",
+
      "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz",
+
      "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==",
+
      "license": "MIT",
+
      "dependencies": {
+
        "@types/unist": "^3.0.0",
+
        "unist-util-is": "^6.0.0"
+
      },
+
      "funding": {
+
        "type": "opencollective",
+
        "url": "https://opencollective.com/unified"
+
      }
+
    },
    "node_modules/universalify": {
      "version": "0.1.2",
      "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
@@ -5290,6 +5623,34 @@
        "node": ">= 0.8"
      }
    },
+
    "node_modules/vfile": {
+
      "version": "6.0.3",
+
      "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
+
      "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
+
      "license": "MIT",
+
      "dependencies": {
+
        "@types/unist": "^3.0.0",
+
        "vfile-message": "^4.0.0"
+
      },
+
      "funding": {
+
        "type": "opencollective",
+
        "url": "https://opencollective.com/unified"
+
      }
+
    },
+
    "node_modules/vfile-message": {
+
      "version": "4.0.2",
+
      "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz",
+
      "integrity": "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==",
+
      "license": "MIT",
+
      "dependencies": {
+
        "@types/unist": "^3.0.0",
+
        "unist-util-stringify-position": "^4.0.0"
+
      },
+
      "funding": {
+
        "type": "opencollective",
+
        "url": "https://opencollective.com/unified"
+
      }
+
    },
    "node_modules/vite": {
      "version": "6.3.5",
      "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
@@ -5648,6 +6009,16 @@
      "peerDependencies": {
        "zod": "^3.24.1"
      }
+
    },
+
    "node_modules/zwitch": {
+
      "version": "2.0.4",
+
      "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
+
      "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
+
      "license": "MIT",
+
      "funding": {
+
        "type": "github",
+
        "url": "https://github.com/sponsors/wooorm"
+
      }
    }
  }
}
modified package.json
@@ -31,6 +31,7 @@
    "@tauri-apps/plugin-log": "^2.4.0",
    "@tauri-apps/plugin-shell": "^2.2.1",
    "@tauri-apps/plugin-window-state": "^2.2.2",
+
    "hast-util-to-html": "^9.0.5",
    "overlayscrollbars": "^2.11.4",
    "overlayscrollbars-svelte": "^0.5.5",
    "semver": "^7.7.2",
modified src/components/Border.svelte
@@ -30,6 +30,7 @@
    flatBottom?: boolean;
    styleBackgroundColor?: string;
    styleFlexDirection?: string;
+
    styleAlignSelf?: string;
    styleAlignItems?: string;
    styleJustifyContent?: string;
  }
@@ -57,6 +58,7 @@
    flatBottom = false,
    styleBackgroundColor = "var(--color-background-default)",
    styleFlexDirection = "row",
+
    styleAlignSelf,
    styleAlignItems = "center",
    styleJustifyContent,
  }: Props = $props();
@@ -234,6 +236,7 @@
<div
  style:width={styleWidth}
  style:max-width={styleMaxWidth}
+
  style:align-self={styleAlignSelf}
  style:cursor={styleCursor}
  class="container"
  class:flat-top={flatTop}
modified src/components/File.svelte
@@ -76,8 +76,8 @@
    position: absolute;
    z-index: -1;
    content: " ";
-
    background-color: var(--color-background-float);
    clip-path: var(--2px-bottom-corner-fill);
+
    background-color: var(--color-background-float);
    width: 100%;
    height: 100%;
    top: 0;
modified src/components/Icon.svelte
@@ -26,6 +26,7 @@
      | "chevron-right"
      | "chevron-up"
      | "clock"
+
      | "code"
      | "collapse"
      | "collapse-panel"
      | "comment"
@@ -44,6 +45,8 @@
      | "face"
      | "file"
      | "filter"
+
      | "folder-closed"
+
      | "folder-open"
      | "home"
      | "hourglass"
      | "inbox"
@@ -362,6 +365,27 @@
    <path d="M7 7H8V8L7 8V7Z" />
    <path d="M6 6H7V7L6 7V6Z" />
    <path d="M5 5H6V6H5V5Z" />
+
  {:else if name === "code"}
+
    <path d="M13 7H14V8H13V7Z" />
+
    <path d="M3 9H2L2 8H3L3 9Z" />
+
    <path d="M12 6H13V7L12 7V6Z" />
+
    <path d="M4 10H3L3 9H4L4 10Z" />
+
    <path d="M11 5L12 5V6L11 6V5Z" />
+
    <path d="M5 11H4L4 10H5V11Z" />
+
    <path d="M10 4H11V5L10 5V4Z" />
+
    <path d="M6 12H5V11H6V12Z" />
+
    <path d="M9 3H10V4L9 4V3Z" />
+
    <path d="M7 13H6L6 12H7V13Z" />
+
    <path d="M9 12L10 12V13L9 13V12Z" />
+
    <path d="M7 4L6 4L6 3L7 3V4Z" />
+
    <path d="M10 11H11V12L10 12L10 11Z" />
+
    <path d="M6 5H5V4H6L6 5Z" />
+
    <path d="M11 10H12L12 11H11L11 10Z" />
+
    <path d="M5 6H4L4 5H5V6Z" />
+
    <path d="M13 8H14V9H13V8Z" />
+
    <path d="M3 8H2L2 7H3L3 8Z" />
+
    <path d="M12 9L13 9V10L12 10V9Z" />
+
    <path d="M4 7H3L3 6H4L4 7Z" />
  {:else if name === "collapse"}
    <path d="M7 5.5L8 5.5L8 4.5L7 4.5L7 5.5Z" />
    <path d="M6 4.5L7 4.5L7 3.5L6 3.5L6 4.5Z" />
@@ -674,6 +698,28 @@
    <path d="M8 13H9L9 14H8V13Z" />
    <path d="M6 4L12 4V5L6 5V4Z" />
    <path d="M7 12H8L8 13H7L7 12Z" />
+
  {:else if name === "folder-closed"}
+
    <path d="M8 4L13 4V5L8 5V4Z" />
+
    <path d="M3 6L13 6V7L3 7L3 6Z" />
+
    <path d="M7 3H8V4L7 4V3Z" />
+
    <path d="M3 13L13 13V14L3 14V13Z" />
+
    <path d="M3 2L7 2V3L3 3V2Z" />
+
    <path d="M14 5L14 13H13L13 5L14 5Z" />
+
    <path d="M3 3L3 13H2L2 3L3 3Z" />
+
  {:else if name === "folder-open"}
+
    <path d="M7.5 4L12.5 4V5L7.5 5V4Z" />
+
    <path d="M4.5 7L12.5 7V8L4.5 8L4.5 7Z" />
+
    <path d="M6.5 3H7.5V4L6.5 4V3Z" />
+
    <path d="M2.5 13L11.5 13V14L2.5 14L2.5 13Z" />
+
    <path d="M2.5 2L6.5 2L6.5 3L2.5 3V2Z" />
+
    <path d="M13.5 10V12H12.5L12.5 10H13.5Z" />
+
    <path d="M13.5 5V8H12.5L12.5 5L13.5 5Z" />
+
    <path d="M2.5 11L2.5 13H1.5L1.5 11H2.5Z" />
+
    <path d="M2.5 3L2.5 12H1.5L1.5 3L2.5 3Z" />
+
    <path d="M3.5 10L3.5 12H2.5L2.5 10H3.5Z" />
+
    <path d="M4.5 8V10H3.5L3.5 8H4.5Z" />
+
    <path d="M14.5 8L14.5 10H13.5L13.5 8H14.5Z" />
+
    <path d="M11.5 12H12.5V13H11.5V12Z" />
  {:else if name === "home"}
    <path d="M7 1.50003H9V2.50003H7V1.50003Z" />
    <path d="M6 2.50003L7 2.50003V3.50003H6V2.50003Z" />
added src/components/PreviewSwitch.svelte
@@ -0,0 +1,35 @@
+
<script lang="ts">
+
  import Button from "@app/components/Button.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+

+
  let { preview = $bindable(true) }: { preview?: boolean } = $props();
+
</script>
+

+
<style>
+
  .container {
+
    display: flex;
+
    align-items: center;
+
  }
+
</style>
+

+
<div class="container">
+
  <Button
+
    flatRight
+
    variant="ghost"
+
    active={!preview}
+
    onclick={() => {
+
      preview = !preview;
+
    }}>
+
    <Icon name="code" />Code
+
  </Button>
+

+
  <Button
+
    flatLeft
+
    variant="ghost"
+
    active={preview}
+
    onclick={() => {
+
      preview = !preview;
+
    }}>
+
    <Icon name="eye" />Preview
+
  </Button>
+
</div>
modified src/components/RepoHomeSecondColumn.svelte
@@ -1,17 +1,42 @@
<script lang="ts">
  import type { RepoInfo } from "@bindings/repo/RepoInfo";
+
  import type { Tree } from "@bindings/source/Tree";
+

+
  import { useOverlayScrollbars } from "overlayscrollbars-svelte";

  import Border from "@app/components/Border.svelte";
  import Icon from "@app/components/Icon.svelte";
  import Link from "@app/components/Link.svelte";
  import RepoTeaser from "@app/components/RepoTeaser.svelte";
  import Settings from "@app/components/Settings.svelte";
+
  import TreeComponent from "@app/components/Tree.svelte";

  interface Props {
    repo: RepoInfo;
+
    tree: Tree;
+
    fetchTree: (path: string) => Promise<Tree>;
+
    fetchBlob: (path: string) => Promise<void>;
  }

-
  const { repo }: Props = $props();
+
  const { repo, tree, fetchTree, fetchBlob }: Props = $props();
+

+
  let innerElement: HTMLElement | undefined = $state();
+

+
  $effect(() => {
+
    if (innerElement) {
+
      const [initialize] = useOverlayScrollbars({
+
        options: () => ({
+
          scrollbars: {
+
            theme: "global-os-theme-radicle",
+
            autoHide: "scroll",
+
          },
+
        }),
+
        defer: true,
+
      });
+

+
      initialize({ target: innerElement });
+
    }
+
  });

  const project = $derived(repo.payloads["xyz.radicle.project"]!);
</script>
@@ -51,11 +76,25 @@
    <div style:margin-bottom="0.75rem">
      <Border
        variant="ghost"
+
        styleMaxWidth="20rem"
+
        flatBottom={tree.entries.length > 0}
        styleBackgroundColor="var(--color-background-default)">
        <div class="tab active" style:color="var(--color-foreground-contrast)">
          <RepoTeaser name={project.data.name} seeding={repo.seeding} />
        </div>
      </Border>
+
      {#if tree.entries.length > 0}
+
        <Border
+
          bind:innerElement
+
          variant="ghost"
+
          styleMaxHeight="calc(100vh - 20rem)"
+
          styleOverflow="scroll"
+
          styleMaxWidth="20rem"
+
          flatTop
+
          styleWidth="100%">
+
          <TreeComponent {tree} {fetchTree} {fetchBlob} />
+
        </Border>
+
      {/if}
    </div>

    <div style:margin-bottom="0.5rem">
added src/components/Source/File.svelte
@@ -0,0 +1,46 @@
+
<script lang="ts">
+
  import Icon from "@app/components/Icon.svelte";
+

+
  const {
+
    name,
+
    fetchBlob,
+
    active,
+
  }: { name: string; fetchBlob: () => Promise<void>; active: boolean } =
+
    $props();
+
</script>
+

+
<style>
+
  .file {
+
    width: 100%;
+
    cursor: pointer;
+
  }
+
  .file:hover,
+
  .active {
+
    background-color: var(--color-background-float);
+
  }
+
  .active:hover {
+
    background-color: var(--color-fill-ghost-hover);
+
  }
+
  .file:hover .icon,
+
  .active .icon {
+
    color: var(--color-foreground-contrast);
+
  }
+
</style>
+

+
<!-- svelte-ignore a11y_no_static_element_interactions -->
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
+
<div
+
  class="file"
+
  class:active
+
  style:padding="0 0.5rem"
+
  style:margin="0.15rem 0"
+
  onclick={fetchBlob}>
+
  <div class="global-flex" style:padding="0.25rem 0">
+
    <div class="icon txt-missing">
+
      <Icon name="file" />
+
    </div>
+
    <div class="txt-small">
+
      {name}
+
    </div>
+
  </div>
+
</div>
added src/components/Source/Folder.svelte
@@ -0,0 +1,69 @@
+
<script lang="ts">
+
  import type { Tree } from "@bindings/source/Tree";
+

+
  import Icon from "@app/components/Icon.svelte";
+
  import File from "@app/components/Source/File.svelte";
+
  import Folder from "@app/components/Source/Folder.svelte";
+
  import { getCurrentPath } from "@app/views/repo/RepoHome.svelte";
+

+
  interface Props {
+
    fetchTree: (path: string) => Promise<Tree>;
+
    fetchBlob: (path: string) => Promise<void>;
+
    currentPath: string;
+
    name: string;
+
    prefix: string;
+
  }
+

+
  const { name, fetchBlob, currentPath, prefix, fetchTree }: Props = $props();
+
  let expanded = $derived(getCurrentPath().indexOf(prefix) === 0);
+

+
  const treePromise = $derived(
+
    expanded ? fetchTree(prefix) : Promise.resolve(undefined),
+
  );
+
</script>
+

+
<style>
+
  .folder {
+
    cursor: pointer;
+
  }
+
  .folder:hover {
+
    background-color: var(--color-background-float);
+
  }
+
</style>
+

+
<div class="folder" style:padding-left="0.5rem">
+
  <!-- svelte-ignore a11y_no_static_element_interactions -->
+
  <!-- svelte-ignore a11y_click_events_have_key_events -->
+
  <div
+
    class="global-flex txt-small"
+
    style:padding="0.25rem 0"
+
    onclick={() => (expanded = !expanded)}>
+
    <div class:txt-missing={!expanded}>
+
      <Icon name={expanded ? "folder-open" : "folder-closed"} />
+
    </div>
+
    {name}
+
  </div>
+
</div>
+
{#if expanded}
+
  {#await treePromise then tree}
+
    {#if tree}
+
      {#each tree.entries as entry (entry.path)}
+
        <div style:margin-left="1.5rem">
+
          {#if entry.kind === "tree"}
+
            <Folder
+
              {fetchTree}
+
              {fetchBlob}
+
              name={entry.name}
+
              {currentPath}
+
              prefix={`${entry.path}/`} />
+
          {:else if entry.kind === "blob"}
+
            <File
+
              name={entry.name}
+
              fetchBlob={() => fetchBlob(entry.path)}
+
              active={entry.path === getCurrentPath()} />
+
          {/if}
+
        </div>
+
      {/each}
+
    {/if}
+
  {/await}
+
{/if}
added src/components/Tree.svelte
@@ -0,0 +1,34 @@
+
<script lang="ts">
+
  import type { Tree } from "@bindings/source/Tree";
+

+
  import File from "@app/components/Source/File.svelte";
+
  import Folder from "@app/components/Source/Folder.svelte";
+
  import { getCurrentPath } from "@app/views/repo/RepoHome.svelte";
+

+
  interface Props {
+
    tree: Tree;
+
    path?: string;
+
    fetchTree: (path: string) => Promise<Tree>;
+
    fetchBlob: (path: string) => Promise<void>;
+
  }
+

+
  const { path = "", tree, fetchTree, fetchBlob }: Props = $props();
+
</script>
+

+
<div>
+
  {#each tree.entries as entry (entry.name)}
+
    {#if entry.kind === "tree"}
+
      <Folder
+
        name={entry.name}
+
        prefix={`${entry.path}/`}
+
        currentPath={path}
+
        {fetchTree}
+
        {fetchBlob} />
+
    {:else}
+
      <File
+
        name={entry.name}
+
        fetchBlob={() => fetchBlob(entry.path)}
+
        active={entry.path === getCurrentPath()} />
+
    {/if}
+
  {/each}
+
</div>
modified src/lib/invoke.ts
@@ -133,3 +133,5 @@ export async function writeToClipboard(
    await navigator.clipboard.writeText(text);
  }
}
+

+
export { InvokeError };
modified src/views/repo/RepoHome.svelte
@@ -1,31 +1,104 @@
+
<script lang="ts" module>
+
  let currentPath = $state("");
+

+
  export function getCurrentPath() {
+
    return currentPath;
+
  }
+
</script>
+

<script lang="ts">
  import type { Config } from "@bindings/config/Config";
  import type { Readme } from "@bindings/repo/Readme";
  import type { RepoInfo } from "@bindings/repo/RepoInfo";
+
  import type { Blob } from "@bindings/source/Blob";
+
  import type { Tree } from "@bindings/source/Tree";
+

+
  import { toHtml } from "hast-util-to-html";
+
  import { capitalize } from "lodash";
+
  import { useOverlayScrollbars } from "overlayscrollbars-svelte";
+

+
  import { invoke, InvokeError } from "@app/lib/invoke";
+
  import { highlight } from "@app/lib/syntax";
+
  import { formatOid } from "@app/lib/utils";

  import Border from "@app/components/Border.svelte";
  import CheckoutRepoButton from "@app/components/CheckoutRepoButton.svelte";
  import File from "@app/components/File.svelte";
  import Icon from "@app/components/Icon.svelte";
+
  import Id from "@app/components/Id.svelte";
  import Markdown from "@app/components/Markdown.svelte";
  import NodeBreadcrumb from "@app/components/NodeBreadcrumb.svelte";
  import Path from "@app/components/Path.svelte";
+
  import PreviewSwitch from "@app/components/PreviewSwitch.svelte";
  import RepoHomeSecondColumn from "@app/components/RepoHomeSecondColumn.svelte";
  import RepoMetadata from "@app/components/RepoMetadata.svelte";
-

-
  import Layout from "./Layout.svelte";
-
  import RepoBreadcrumb from "./RepoBreadcrumb.svelte";
+
  import Layout from "@app/views/repo/Layout.svelte";
+
  import RepoBreadcrumb from "@app/views/repo/RepoBreadcrumb.svelte";

  interface Props {
    config: Config;
-
    readme: Readme | null;
+
    tree: Tree;
    repo: RepoInfo;
+
    readme: Readme | null;
    notificationCount: number;
  }

-
  const { config, readme, repo, notificationCount }: Props = $props();
+
  /* eslint-disable prefer-const */
+
  let { config, tree, readme, repo, notificationCount }: Props = $props();
+
  /* eslint-enable prefer-const */
+

+
  let codeElement: HTMLElement | undefined = $state();
+
  let preview = $state(true);
+
  let error: InvokeError | undefined = $state();
+

+
  $effect(() => {
+
    currentPath = readme?.path || "";
+
  });
+

+
  function isMarkdownPath(path: string): boolean {
+
    return /\.(md|mkd|markdown)$/i.test(path);
+
  }
+

+
  async function fetchTree(path: string) {
+
    return await invoke<Tree>("repo_tree", { rid: repo.rid, path });
+
  }
+

+
  async function fetchBlob(path: string) {
+
    try {
+
      blob = await invoke<Blob>("repo_blob", { rid: repo.rid, path });
+
      currentPath = path;
+
      error = undefined;
+
    } catch (err) {
+
      if (err instanceof InvokeError) {
+
        error = err;
+
      }
+
      currentPath = path;
+
    }
+
    return;
+
  }
+

+
  $effect(() => {
+
    if (codeElement) {
+
      const [initialize] = useOverlayScrollbars({
+
        options: () => ({
+
          scrollbars: { theme: "global-os-theme-radicle", autoHide: "scroll" },
+
        }),
+
        defer: true,
+
      });
+

+
      initialize({ target: codeElement });
+
    }
+
  });
+

+
  $effect(() => {
+
    preview = isMarkdownPath(currentPath);
+
  });

  const project = $derived(repo.payloads["xyz.radicle.project"]!);
+
  let blob: Blob | Readme | null = $state(readme);
+
  const showLineNumbers = $derived(
+
    blob && !blob.binary && blob.content.trim() !== "" && !preview && !error,
+
  );
</script>

<style>
@@ -38,6 +111,30 @@
    grid-template-areas: "main-content right-sidebar";
    margin-top: 2rem;
  }
+
  .line-column {
+
    display: flex;
+
    flex-direction: column;
+
    align-items: flex-end;
+
  }
+
  .blob {
+
    display: flex;
+
    gap: 1rem;
+
    padding: 0.5rem 1rem;
+
    overflow: hidden;
+
  }
+
  .blob-placeholder {
+
    display: flex;
+
    flex-direction: column;
+
    align-items: center;
+
    padding: 1rem 0;
+
  }
+
  .code,
+
  .commit-msg {
+
    -webkit-touch-callout: initial;
+
    -webkit-user-select: text;
+
    user-select: text;
+
    cursor: text;
+
  }
</style>

<Layout
@@ -52,7 +149,7 @@
  {/snippet}

  {#snippet secondColumn()}
-
    <RepoHomeSecondColumn {repo} />
+
    <RepoHomeSecondColumn {repo} {tree} {fetchBlob} {fetchTree} />
  {/snippet}

  <div class="content">
@@ -77,7 +174,7 @@

    <div class="container">
      <div style:grid-area="main-content" style:min-width="0">
-
        {#if readme === null}
+
        {#if blob === null}
          <Border
            variant="ghost"
            stylePadding="1rem"
@@ -94,28 +191,89 @@
          <File expandable={false} sticky={false}>
            {#snippet leftHeader()}
              <div style:margin-left="0.5rem">
-
                <Path fullPath={readme.path} />
+
                <Path fullPath={currentPath} />
              </div>
            {/snippet}

-
            <div style:padding="1rem">
-
              {#if readme.binary}
-
                <div
-
                  class="global-flex txt-missing"
-
                  style:width="100%"
-
                  style:justify-content="center">
-
                  <Icon name="binary" />Binary file
-
                </div>
-
              {:else if readme.content.trim() === ""}
-
                <div
-
                  class="global-flex txt-missing"
-
                  style:width="100%"
-
                  style:justify-content="center">
-
                  <Icon name="none" />Empty file
-
                </div>
-
              {:else}
-
                <Markdown rid={repo.rid} content={readme.content} />
+
            {#snippet rightHeader()}
+
              {#if blob}
+
                <Border
+
                  styleMaxWidth="fit-content"
+
                  variant="float"
+
                  styleBackgroundColor="var(--color-background-float)"
+
                  stylePadding="0 0.5rem"
+
                  styleAlignItems="center"
+
                  styleAlignSelf="flex-end">
+
                  <Id variant="commit" id={blob.commit.id}>
+
                    {formatOid(blob.commit.id)}
+
                  </Id>
+
                  <span class="commit-msg txt-overflow" style:max-width="20rem">
+
                    {blob.commit.message}
+
                  </span>
+
                </Border>
+
              {/if}
+

+
              {#if isMarkdownPath(currentPath)}
+
                <PreviewSwitch bind:preview />
              {/if}
+
            {/snippet}
+

+
            <div class="blob">
+
              <div class="line-column">
+
                {#if showLineNumbers}
+
                  {#each blob.content
+
                    .trimEnd()
+
                    .split("\n")
+
                    .map((_, index) => index) as line}
+
                    <div class="txt-missing txt-monospace txt-small">
+
                      {line + 1}
+
                    </div>
+
                  {/each}
+
                {/if}
+
              </div>
+
              <div style:width="100%" bind:this={codeElement}>
+
                {#if blob.binary}
+
                  {#if blob.mimeType.startsWith("image")}
+
                    <img
+
                      src={`data:${blob.mimeType};base64,${blob.content}`}
+
                      alt={`Preview of ${blob.id}`} />
+
                  {:else}
+
                    <div class="txt-small blob-placeholder txt-missing">
+
                      <Icon name="file" size="32" />
+
                      <span>Binary file</span>
+
                    </div>
+
                  {/if}
+
                {:else if preview}
+
                  <div style:margin-top="1rem">
+
                    <Markdown content={blob.content} />
+
                  </div>
+
                {:else if blob.content.trim() === ""}
+
                  <div class="txt-small blob-placeholder txt-missing">
+
                    <Icon name="none" size="32" />
+
                    <span>Empty file</span>
+
                  </div>
+
                {:else if error}
+
                  <div class="txt-small blob-placeholder txt-missing">
+
                    <Icon name="warning" size="32" />
+
                    {#if error.code === "PayloadError.TooLarge"}
+
                      <span>File size exceeds limit of 10 MB.</span>
+
                    {:else}
+
                      <span>{capitalize(error.message)}</span>
+
                    {/if}
+
                  </div>
+
                {:else}
+
                  <code>
+
                    <pre
+
                      class="code txt-small"
+
                      style:margin="0"
+
                      style:padding="0">{#await highlight(blob.content, currentPath
+
                          .split(".")
+
                          .at(-1) || "raw")}{blob.content}{:then tree}{@html toHtml(
+
                          tree,
+
                        )}{/await}</pre>
+
                  </code>
+
                {/if}
+
              </div>
            </div>
          </File>
        {/if}
modified src/views/repo/router.ts
@@ -10,6 +10,7 @@ import type { Thread } from "@bindings/cob/thread/Thread";
import type { Config } from "@bindings/config/Config";
import type { Readme } from "@bindings/repo/Readme";
import type { RepoInfo } from "@bindings/repo/RepoInfo";
+
import type { Tree } from "@bindings/source/Tree";

import type { DraftReview } from "@app/lib/draftReviewStorage";
import { draftReviewStorage } from "@app/lib/draftReviewStorage";
@@ -22,6 +23,7 @@ export const DEFAULT_TAKE = 20;

export interface RepoHomeRoute {
  resource: "repo.home";
+
  sha?: string;
  rid: string;
}

@@ -42,6 +44,8 @@ export interface LoadedRepoHomeRoute {
  resource: "repo.home";
  params: {
    repo: RepoInfo;
+
    sha?: string;
+
    tree: Tree;
    config: Config;
    readme: Readme | null;
    notificationCount: number;
@@ -230,7 +234,7 @@ export async function loadPatches(
export async function loadRepoHome(
  route: RepoHomeRoute,
): Promise<LoadedRepoHomeRoute> {
-
  const [notificationCount, config, repo, readme] = await Promise.all([
+
  const [notificationCount, config, repo, readme, tree] = await Promise.all([
    invoke<number>("notification_count"),
    invoke<Config>("config"),
    invoke<RepoInfo>("repo_by_id", {
@@ -239,11 +243,16 @@ export async function loadRepoHome(
    invoke<Readme | null>("repo_readme", {
      rid: route.rid,
    }),
+
    invoke<Tree>("repo_tree", {
+
      rid: route.rid,
+
      path: "",
+
      sha: route.sha,
+
    }),
  ]);

  return {
    resource: "repo.home",
-
    params: { notificationCount, repo, config, readme },
+
    params: { notificationCount, repo, sha: route.sha, config, readme, tree },
  };
}

@@ -387,7 +396,7 @@ export function repoUrlToRoute(

  if (rid) {
    if (resource === "home") {
-
      return { resource: "repo.home", rid };
+
      return { resource: "repo.home", rid, sha: segments.shift() };
    } else if (resource === "issues") {
      const idOrAction = segments.shift();
      if (idOrAction) {