crates/gunnar-ui-web/src/pages/browse.rs
//! Walking a repository: directories, files, and the lines inside them.
//!
//! # A markdown file has two views, and which one opens is not the same
//! question for everybody
//!
//! The server typesets any markdown file in a tree, not only the README
//! (`api::render`), so a file view of one offers **View rendered** / **View
//! source** — and, for somebody with a session, **View PDF**.
//!
//! The console does no markdown work at all: the rendered grade arrives as the
//! same four kinds the README panel draws, already sanitised, with its links
//! already pointing at console URLs. See `pages::readme` for why `inner_html`
//! is the right tool for exactly these bytes and no others.
//!
//! **What a session gets is asked once, of the server.** `GET
//! /api/account/reading` answers with the whole fork — which view opens,
//! whether the choice is remembered, whether the PDF is offered — and a refusal
//! IS the answer for a visitor with no session: rendered, no PDF, nothing
//! written down. The console does not decide any of that for itself, so the
//! button and the route it calls cannot come to different conclusions about who
//! may press it.
use leptos::prelude::*;
use leptos_router::hooks::{use_params_map, use_query_map};
use serde::Deserialize;
use crate::api::segment;
use crate::fmt;
use crate::pages::readme::Readme;
use crate::pages::resolved;
use crate::ui::{Badge, Empty, PageHeader, Panel, Tone};
/// Which view of a markdown file is on screen.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum View {
Rendered,
Source,
}
impl View {
/// The word the server stores this under.
fn name(self) -> &'static str {
match self {
View::Rendered => "rendered",
View::Source => "source",
}
}
fn other(self) -> View {
match self {
View::Rendered => View::Source,
View::Source => View::Rendered,
}
}
}
/// What `GET /api/account/reading` answers: everything the logged-in /
/// anonymous fork decides, in one value. `crate::reading::Offer` on the server.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
pub struct Offer {
pub markdown: View,
pub remembered: bool,
pub pdf: bool,
}
/// What a visitor with no session gets, and what the console assumes the moment
/// the server refuses to answer the question: the typeset page, no PDF, and
/// nothing remembered about them.
const ANONYMOUS: Offer = Offer {
markdown: View::Rendered,
remembered: false,
pdf: false,
};
/// True when this file goes through the markdown pipeline.
///
/// The server's `readme::is_markdown`, restated for the one decision the
/// console makes alone: whether to offer the toggle at all. A file this is
/// wrong about is a button that leads to a 400 rather than a wrong page — the
/// server decides what it will typeset.
fn is_markdown(path: &str) -> bool {
let lower = path.to_ascii_lowercase();
lower.ends_with(".md") || lower.ends_with(".markdown")
}
/// Whatever the path named.
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum Found {
Tree(Tree),
Blob(Blob),
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Tree {
pub revision: Revision,
pub path: String,
pub crumbs: Vec<Crumb>,
pub entries: Vec<Entry>,
pub budget_left: usize,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Blob {
pub revision: Revision,
pub path: String,
pub crumbs: Vec<Crumb>,
pub oid: String,
pub size: u64,
pub truncated: bool,
pub too_large: bool,
pub binary: bool,
pub syntax: Option<String>,
pub lines: Vec<Vec<Token>>,
/// The bytes are a gunnar-crypt envelope: an encrypted repository's file,
/// ciphertext on the server. Opened only in this tab (`SealedFile`).
#[serde(default)]
pub sealed: bool,
/// The file is kept in Git LFS: everything above describes the FILE the
/// tree's pointer names, and this says how to show it (`LfsFileView`).
#[serde(default)]
pub lfs: Option<LfsFile>,
}
pub use crate::lfs::{lfs_type_label, raw_url, LfsFile};
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Token {
pub class: String,
pub text: String,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Revision {
pub commit_oid: String,
pub tree_oid: String,
pub subject: String,
pub author: String,
pub at_unix_seconds: i64,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Crumb {
pub name: String,
pub path: String,
}
/// Build a link into the browser at a path: `/tree/…` (GUN-GUEST, `crate::addr`).
/// One page draws a directory or a file whichever the path is, so a link whose
/// kind is not known (a crumb, the way up) is a `tree` link.
fn href(namespace: &str, name: &str, rev: &str, path: &str) -> String {
crate::addr::tree(namespace, name, path, rev)
}
/// A link to one file: `/blob/…`.
fn file_href(namespace: &str, name: &str, rev: &str, path: &str) -> String {
crate::addr::blob(namespace, name, path, rev)
}
#[component]
pub fn Browse() -> impl IntoView {
let params = use_params_map();
let query = use_query_map();
let namespace = Memo::new(move |_| params.read().get("namespace").unwrap_or_default());
let name = Memo::new(move |_| params.read().get("name").unwrap_or_default());
let rev = Memo::new(move |_| query.read().get("rev").unwrap_or_default());
// The path is the address's own tail (`/tree/{path}`, `/blob/{path}`); an old
// `?path=` still reads (GUN-GUEST).
let path = Memo::new(move |_| match params.read().get("path") {
Some(tail) if !tail.is_empty() => crate::addr::path_param(&tail),
_ => query.read().get("path").unwrap_or_default(),
});
let found = LocalResource::new(move || {
let url = format!(
"/api/repos/{}/{}/browse?rev={}&path={}",
segment(&namespace.get()),
segment(&name.get()),
crate::auth::percent_encode(&rev.get()),
crate::auth::percent_encode(&path.get()),
);
async move { crate::api::get::<Found>(&url).await }
});
view! {
<PageHeader
title=Signal::derive(move || format!("{}/{}", namespace.get(), name.get()))
/>
<crate::pages::repo::RepoTabs/>
{move || {
let ns = namespace.get();
let repo = name.get();
let at = rev.get();
resolved(found.get(), None, move |found: Found| match found {
Found::Tree(tree) => view! {
<Directory tree ns=ns.clone() repo=repo.clone() rev=at.clone()/>
}
.into_any(),
Found::Blob(blob) => view! {
<File blob ns=ns.clone() repo=repo.clone() rev=at.clone()/>
}
.into_any(),
})
}}
}
}
/// The path, as links back up the tree.
#[component]
fn Breadcrumbs(
crumbs: Vec<Crumb>,
ns: String,
repo: String,
rev: String,
#[prop(optional)] trailing_is_plain: bool,
) -> impl IntoView {
let root = href(&ns, &repo, &rev, "");
let last = crumbs.len().saturating_sub(1);
view! {
<nav class="crumbs" aria-label="Path">
<a href=root>{repo.clone()}</a>
{crumbs
.into_iter()
.enumerate()
.map(|(index, crumb)| {
let target = href(&ns, &repo, &rev, &crumb.path);
let is_last = index == last;
view! {
<span class="sep" aria-hidden="true">"/"</span>
{if is_last && trailing_is_plain {
view! { <span aria-current="page">{crumb.name.clone()}</span> }.into_any()
} else {
view! { <a href=target>{crumb.name.clone()}</a> }.into_any()
}}
}
})
.collect_view()}
</nav>
}
}
/// What the revision resolved to, shown so a browse is reproducible.
#[component]
fn RevisionLine(revision: Revision, budget_left: Option<usize>) -> impl IntoView {
view! {
<p class="truncation">
<span class="mono">{fmt::short_oid(&revision.commit_oid)}</span>
" · "
{revision.subject.clone()}
" · "
{revision.author.clone()}
{budget_left.map(|left| {
// Every object is a call to gunnar spending one single-use
// credential, so the cost of browsing is worth being able to see.
view! {
<span class="unset" style="font-style:normal">
{format!(" · {left} object fetches left for this request")}
</span>
}
})}
</p>
}
}
#[component]
fn Directory(tree: Tree, ns: String, repo: String, rev: String) -> impl IntoView {
let title = if tree.path.is_empty() {
"Contents".to_owned()
} else {
tree.path.clone()
};
let entries = tree.entries.clone();
let crumbs = tree.crumbs.clone();
let revision = tree.revision.clone();
let budget = tree.budget_left;
let parent = tree
.crumbs
.len()
.checked_sub(2)
.and_then(|i| tree.crumbs.get(i).map(|c| c.path.clone()))
.or_else(|| (!tree.path.is_empty()).then(String::new));
view! {
<div class="stack">
<Breadcrumbs crumbs ns=ns.clone() repo=repo.clone() rev=rev.clone()/>
<Panel title=title>
{if entries.is_empty() {
view! {
<Empty
title="Empty directory"
detail="Git does not store empty directories, so this one came from \
a tree that only contains other directories."
/>
}
.into_any()
} else {
let ns2 = ns.clone();
let repo2 = repo.clone();
let rev2 = rev.clone();
view! {
<div class="table-wrap">
<table class="tree">
<caption class="vh">"Files and directories"</caption>
<thead>
<tr>
<th scope="col">"Name"</th>
<th scope="col">"Kind"</th>
<th scope="col">"Id"</th>
</tr>
</thead>
<tbody>
{parent
.map(|up| {
let target = href(&ns2, &repo2, &rev2, &up);
view! {
<tr>
<td><a href=target class="up">"../"</a></td>
<td></td>
<td></td>
</tr>
}
})}
{entries
.into_iter()
.map(|entry| {
let child = if tree.path.is_empty() {
entry.name.clone()
} else {
format!("{}/{}", tree.path, entry.name)
};
let is_dir = entry.kind == "dir";
let target = if is_dir { href(&ns2, &repo2, &rev2, &child) } else { file_href(&ns2, &repo2, &rev2, &child) };
// A submodule's object is in another
// repository; a lossy name can never
// be matched back against the bytes
// git stored. Neither link can work.
let linkable =
entry.kind != "submodule" && !entry.lossy_name;
let label = entry.name.clone();
view! {
<tr>
<td class=if is_dir { "dir" } else { "file" }>
{if linkable {
view! { <a href=target>{label}</a> }.into_any()
} else {
// Its object lives in another
// repository, so there is nothing
// here to open.
view! { <span>{label}</span> }.into_any()
}}
</td>
<td>
{(entry.kind != "file")
.then(|| view! {
<Badge tone=Tone::Plain label=entry.kind.clone()/>
})}
{entry.lossy_name.then(|| view! {
<Badge tone=Tone::Warn label="unreadable name"/>
})}
</td>
<td class="oid">{fmt::short_oid(&entry.oid)}</td>
</tr>
}
})
.collect_view()}
</tbody>
</table>
</div>
}
.into_any()
}}
<RevisionLine revision budget_left=Some(budget)/>
</Panel>
</div>
}
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Entry {
pub name: String,
pub kind: String,
pub oid: String,
pub mode: String,
#[serde(default)]
pub lossy_name: bool,
}
#[component]
fn File(blob: Blob, ns: String, repo: String, rev: String) -> impl IntoView {
// A file kept in Git LFS is shown as the file the pointer names — never
// the pointer as its content (mikronova/modgunn, 2026-10-15).
if let Some(lfs) = blob.lfs.clone() {
return view! { <LfsFileView blob lfs ns repo rev/> }.into_any();
}
let crumbs = blob.crumbs.clone();
let revision = blob.revision.clone();
let size = blob.size;
let lines = blob.lines.clone();
let syntax = blob.syntax.clone();
let path = blob.path.clone();
let (too_large, binary, truncated) = (blob.too_large, blob.binary, blob.truncated);
let sealed = blob.sealed;
let (sealed_ns, sealed_repo, sealed_rev, sealed_path) =
(ns.clone(), repo.clone(), rev.clone(), blob.path.clone());
let markdown = is_markdown(&path) && !binary && !too_large;
// **One question, asked of the server.** A refusal is the answer for a
// visitor with no session — see the module docs — so this never fails, it
// only ever settles on `ANONYMOUS`.
let reading =
LocalResource::new(|| async { crate::api::get::<Offer>("/api/account/reading").await });
// What this reader has pressed on this page, which outranks what the server
// remembers for them until they leave it.
let chosen = RwSignal::new(None::<View>);
let offer = Memo::new(move |_| match reading.get() {
Some(Ok(offer)) => Some(offer),
Some(Err(_)) => Some(ANONYMOUS),
// Still asking. `None` here draws the loading line rather than guessing
// a view and flipping it a moment later.
None => None,
});
let settled = Memo::new(move |_| {
offer
.get()
.map(|offer| chosen.get().unwrap_or(offer.markdown))
});
// A session is one the server answered the question for. That is a better
// test than any flag in the answer: it is the same thing the PUT below
// needs to be true, established by the same call.
let session = Memo::new(move |_| matches!(reading.get(), Some(Ok(_))));
let file = path.clone();
let at = rev.clone();
let (owner, name) = (ns.clone(), repo.clone());
let url = move |leaf: &str| {
format!(
"/api/repos/{}/{}/{leaf}?rev={}&path={}",
segment(&owner),
segment(&name),
crate::auth::percent_encode(&at),
crate::auth::percent_encode(&file),
)
};
// On a phone the plain grade (GUN-FIELD-TEST, 2026-11-01; `crate::narrow`).
let render_url = if crate::narrow::plain_first(crate::pages::readme::viewport_width()) {
format!("{}&as=html", url("render"))
} else {
url("render")
};
// Held in a `StoredValue` because the panel's action slot is drawn more
// than once and a `String` moved into it would make that closure `FnOnce`.
let pdf_url = StoredValue::new(url("pdf"));
let rendered = LocalResource::new(move || {
// Fetched only when it is going to be shown: the typeset page is the
// expensive answer on the server (`readme::queue`), and asking for it
// while looking at the source would spend a place in that queue on
// nobody.
let wanted = markdown && settled.get() == Some(View::Rendered);
let url = render_url.clone();
async move {
match wanted {
true => Some(crate::api::get::<Readme>(&url).await),
false => None,
}
}
});
let toggle = move |_| {
let next = settled.get().unwrap_or(View::Rendered).other();
chosen.set(Some(next));
if session.get() {
// The choice takes effect above, whatever happens here. This is
// only how it outlives the page, so a store that refuses is a
// preference that is not remembered and not a view that does not
// change.
leptos::task::spawn_local(async move {
let _ = crate::api::put::<_, Offer>(
"/api/account/reading",
&serde_json::json!({ "markdown": next.name() }),
)
.await;
});
}
};
let source = lines.clone();
view! {
<div class="stack">
<Breadcrumbs crumbs ns repo rev trailing_is_plain=true/>
<Panel
title=path.clone()
actions=Box::new(move || {
view! {
<Show when=move || markdown>
<button
class="btn"
aria-pressed=move || {
(settled.get() == Some(View::Rendered)).to_string()
}
on:click=toggle
>
{move || match settled.get() {
Some(View::Rendered) => "View source",
_ => "View rendered",
}}
</button>
// The PDF is a file, not a route: `rel="external"`
// hands the click back to the browser, which the
// router would otherwise claim and draw nothing for
// — the same bug the README's own links had.
<Show when=move || offer.get().is_some_and(|offer| offer.pdf)>
<a
class="btn"
href=move || pdf_url.get_value()
rel="noopener noreferrer external"
target="_blank"
>
"View PDF"
</a>
</Show>
</Show>
}
.into_any()
})
>
<div class="panel-body row" style="padding-bottom:.4rem">
<Badge tone=Tone::Plain label=fmt::bytes(size)/>
{syntax.map(|s| view! { <Badge tone=Tone::Plain label=s/> })}
<span class="mono unset" style="font-style:normal">
{fmt::short_oid(&blob.oid)}
</span>
{(!binary && !too_large)
.then(|| view! {
<span class="unset" style="font-style:normal">
{format!("{} lines", lines.len())}
</span>
})}
</div>
{move || {
// An ENCRYPTED file over the console's 2 MiB cannot be opened in a
// tab at all (the raw route answers 413): its panel says so in plain
// words naming the CLI, and offers no button that cannot work
// (round 5.3.1; the UI row an-encrypted-file-over-2-mib-… waited
// for a sealed panel this page never drew for a large file).
if too_large && sealed {
return view! {
<div class="panel-body sealed-file" data-sealed="failed">
<div class="empty">
<h3>"Encrypted file"</h3>
<p class="fail sealed-why">{crate::sealed::raw_refusal(413, String::new())}</p>
<crate::pages::cli::NeedsCli needs=crate::cli::Needs::Read/>
</div>
</div>
}
.into_any();
}
if too_large {
return view! {
<Empty
title="Too large to display"
detail="The console does not fetch files over 2 MiB."
more="Gunnar also refuses any object over 64 MiB, and cuts a single \
response off at 4 MiB."
/>
}
.into_any();
}
if sealed {
return view! {
<SealedFile
ns=sealed_ns.clone()
repo=sealed_repo.clone()
rev=sealed_rev.clone()
path=sealed_path.clone()
/>
}
.into_any();
}
if binary {
return view! {
<Empty
title="Binary file"
detail="The contents are not text, so there is nothing to show as \
lines."
/>
}
.into_any();
}
match (markdown, settled.get()) {
// Still asking what this session gets. A guess here is a
// page that flips under the reader a moment later.
(true, None) => view! { <crate::ui::Loading/> }.into_any(),
// `resolved` draws the loading state and puts a refusal
// in a FailBox — which is where a reader meets a console
// too busy to typeset (503) or a document that took too
// long (504), in the server's own words.
(true, Some(View::Rendered)) => {
resolved(
rendered.get().flatten(),
None,
|readme: Readme| view! { <Rendered readme/> },
)
}
_ => view! { <Source lines=source.clone() truncated/> }.into_any(),
}
}}
<RevisionLine revision budget_left=None/>
</Panel>
</div>
}
.into_any()
}
/// **A file kept in Git LFS**: its real size and type, "stored in Git LFS",
/// and the file itself — a picture inline and a PDF to open, both from the raw
/// route; text as text; anything else a download. The pointer is a detail.
#[component]
fn LfsFileView(blob: Blob, lfs: LfsFile, ns: String, repo: String, rev: String) -> impl IntoView {
let raw = raw_url(&ns, &repo, &rev, &blob.path);
let label = lfs_type_label(&lfs.shown, &blob.path, blob.syntax.as_deref());
let file_name = blob.path.rsplit('/').next().unwrap_or(&blob.path).to_owned();
let lines = blob.lines.clone();
let line_count = lines.len();
let body = match lfs.shown.as_str() {
"image" => view! {
<div class="panel-body lfs-file" data-lfs="image">
<img class="lfs-image" src=raw.clone() alt=file_name.clone() style="max-width:100%;height:auto"/>
</div>
}
.into_any(),
"text" => view! {
<div data-lfs="text">
<Source lines truncated=false/>
</div>
}
.into_any(),
shown => {
let pdf = shown == "pdf";
view! {
<div class="panel-body lfs-file" data-lfs=shown.to_owned()>
<p>
{if pdf {
"A PDF kept in Git LFS. Open it in the browser's viewer, or download it."
} else {
"A file kept in Git LFS that is not text, so there is nothing to show as lines."
}}
</p>
<p class="row">
{pdf.then(|| view! {
<a class="btn lfs-open" href=raw.clone() rel="noopener noreferrer external" target="_blank">"Open"</a>
})}
<a class="btn lfs-download" href=raw.clone() rel="external" download=file_name.clone()>"Download"</a>
</p>
</div>
}
.into_any()
}
};
let crumbs = blob.crumbs.clone();
let revision = blob.revision.clone();
view! {
<div class="stack">
<Breadcrumbs crumbs ns repo rev trailing_is_plain=true/>
<Panel title=blob.path.clone()>
<div class="panel-body row lfs-head" style="padding-bottom:.4rem">
<Badge tone=Tone::Plain label=fmt::bytes(blob.size)/>
<Badge tone=Tone::Plain label=label/>
<Badge tone=Tone::Plain label="stored in Git LFS"/>
{(lfs.shown == "text").then(|| view! {
<span class="unset" style="font-style:normal">{format!("{line_count} lines")}</span>
})}
</div>
{body}
<details class="panel-body lfs-pointer">
<summary>"LFS pointer"</summary>
<pre class="mono">{lfs.pointer.clone()}</pre>
</details>
<RevisionLine revision budget_left=None/>
</Panel>
</div>
}
}
/// One typeset document, in whichever grade the server sent.
///
/// The same four kinds and the same `inner_html` argument as the README panel —
/// see `pages::readme`, which is where that reasoning lives and where it stays.
#[component]
fn Rendered(readme: Readme) -> impl IntoView {
let warning = readme.warning.clone();
let body = match readme.kind.as_str() {
"svg" => {
view! { <div class="readme-page" inner_html=readme.body.clone()></div> }.into_any()
}
"html" => view! { <div class="readme" inner_html=readme.body.clone()></div> }.into_any(),
"text" if !readme.body.is_empty() => {
view! { <pre class="readme-text">{readme.body.clone()}</pre> }.into_any()
}
_ => view! {
<Empty
title="Nothing to typeset"
detail="The server answered with no document for this file."
/>
}
.into_any(),
};
view! {
<div class="panel-body">
{body}
// Why this is not what was asked for — the typesetter's own words,
// quietly, under the page a reader did get.
{warning.map(|why| view! { <p class="readme-note">{why}</p> })}
</div>
}
}
/// What an encrypted file's panel is doing.
#[derive(Clone, PartialEq)]
enum Opening {
/// Not asked yet.
Idle,
/// Touching the key, fetching and opening.
Working,
/// Open: the plaintext, in this tab only.
Open(String),
/// Could not; the reason, in words.
Failed(String),
}
/// **How an encrypted file is opened**: the words above the buttons, the
/// security key, and a printed recovery code. Shared by the file and the diff.
#[component]
pub(crate) fn OpenWithKey(code: RwSignal<String>, busy: Signal<bool>, on_open: Callback<()>, label: &'static str) -> impl IntoView {
view! {
<crate::ui::Hint
line="Encrypted: the server holds ciphertext only. Open it with your security key or the Gunnar CLI."
more="Your security key opens your encryption key in this tab (one touch), and your encryption key opens every repository you are a member of. Nothing decrypted, and no key, leaves this browser. No security key yet? Add one under Account → Security keys for encryption."
/>
<crate::pages::cli::NeedsCli needs=crate::cli::Needs::Unlock/>
<div class="form-row">
<button class="btn sealed-open" on:click=move |_| on_open.run(()) disabled=move || busy.get()>
{label}
</button>
<div class="field">
<label for="sealed-code">"Or your recovery code"</label>
<input
id="sealed-code"
class="sealed-code"
type="text"
autocomplete="off"
placeholder="XXXX-XXXX-…"
prop:value=move || code.get()
on:input=move |ev| code.set(event_target_value(&ev))
/>
</div>
</div>
}
}
/// **An encrypted file.** The server holds ciphertext only; the reader's
/// security key opens their encryption key in this tab, that key opens the
/// repository's key ring, and the file is opened HERE with the same
/// `gunnar-crypt` code the CLI runs. Nothing decrypted, and no key, is sent
/// anywhere. See [`crate::sealed`].
#[component]
fn SealedFile(ns: String, repo: String, rev: String, path: String) -> impl IntoView {
let state = RwSignal::new(Opening::Idle);
let code = RwSignal::new(String::new());
let open = Callback::new(move |_: ()| {
let (ns, repo, rev, path) = (ns.clone(), repo.clone(), rev.clone(), path.clone());
state.set(Opening::Working);
leptos::task::spawn_local(async move {
let typed = code.get_untracked();
let got = match crate::sealed::ensure_open(Some(typed)).await {
Ok(()) => crate::sealed::open_file(&ns, &repo, &rev, &path).await,
Err(why) => Err(why),
};
match got {
Ok(text) => state.set(Opening::Open(text)),
Err(why) => state.set(Opening::Failed(why)),
}
});
});
let busy = Signal::derive(move || state.get() == Opening::Working);
view! {
<div class="panel-body sealed-file" data-sealed=move || match state.get() {
Opening::Idle => "idle",
Opening::Working => "working",
Opening::Open(_) => "open",
Opening::Failed(_) => "failed",
}>
{move || match state.get() {
Opening::Idle | Opening::Failed(_) => {
let failed = match state.get() {
Opening::Failed(why) => Some(why),
_ => None,
};
view! {
<div class="empty">
<h3>"Encrypted file"</h3>
{failed.map(|why| view! { <p class="fail sealed-why">{why}</p> })}
<OpenWithKey code busy on_open=open label="Open with your security key"/>
</div>
}
.into_any()
}
Opening::Working => view! { <crate::ui::Loading/> }.into_any(),
Opening::Open(text) => {
let lines: Vec<Vec<Token>> = crate::sealed::plain_lines(&text)
.into_iter()
.map(|line| {
line.into_iter()
.map(|(class, text)| Token { class, text })
.collect()
})
.collect();
view! {
<p class="hint-line sealed-note">
"Decrypted in this tab. Nothing decrypted left this browser."
</p>
<Source lines truncated=false/>
}
.into_any()
}
}}
</div>
}
}
/// The file's lines, numbered.
#[component]
fn Source(lines: Vec<Vec<Token>>, truncated: bool) -> impl IntoView {
view! {
<div class="source-wrap">
<table class="source">
<caption class="vh">"File contents"</caption>
<tbody>
{lines
.into_iter()
.enumerate()
.map(|(index, tokens)| {
view! {
<tr>
<td class="ln">
// Kept as an anchor so #L42 still works
// as a deep link, but out of the reading
// order: otherwise the file is read as
// "1 link, code, 2 link, code".
<a
id=format!("L{}", index + 1)
href=format!("#L{}", index + 1)
aria-hidden="true"
tabindex="-1"
>
{index + 1}
</a>
</td>
<td class="src">
{if tokens.is_empty() {
// A blank line still needs a row of
// the right height.
view! { <span>" "</span> }.into_any()
} else {
tokens
.into_iter()
.map(|token| {
// Text, never markup: Leptos escapes it, so a
// repository's contents can never become HTML.
let class = if token.class.is_empty() {
String::new()
} else {
format!("hl-{}", token.class)
};
view! { <span class=class>{token.text}</span> }
})
.collect_view()
.into_any()
}}
</td>
</tr>
}
})
.collect_view()}
</tbody>
</table>
</div>
{truncated.then(|| view! {
<p class="truncation">
"The file was cut off when fetched — this is the beginning of it, not all of it."
</p>
})}
}
}