Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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