use maud::{html, Markup, PreEscaped};
use crate::filesystem::{DirEntry, EntryKind};
use crate::types::{BuiltinTool, ToolCall, ToolResult};
use super::tenant::Host;
use super::VerifyState;
pub(crate) fn rendered_markdown(raw: &str) -> Markup {
use pulldown_cmark::{html, Options, Parser};
let mut opts = Options::empty();
opts.insert(Options::ENABLE_STRIKETHROUGH);
opts.insert(Options::ENABLE_TABLES);
opts.insert(Options::ENABLE_TASKLISTS);
let parser = Parser::new_ext(raw, opts);
let mut out = String::with_capacity(raw.len());
html::push_html(&mut out, parser);
html! { (PreEscaped(out)) }
}
pub(crate) fn site_header(_host: &Host) -> Markup {
html! {
header.site-header {
div.header-inner {
h1.header-brand {
a href="https://localharness.xyz/" title="go home" { "localharness" }
}
button type="button"
data-action="feedback-open"
.header-button.feedback-button { "feedback" }
div #header-admin .header-admin {
button type="button"
data-action="header-admin-toggle"
.header-button.admin-button { "admin" }
div #header-admin-panel hidden {}
}
}
}
}
}
pub(crate) const APP_VERSION: &str = "0.10.27";
pub(crate) fn terminal_input() -> Markup {
html! {
div.terminal-body {
div #status .terminal-status {}
div.terminal-row {
span.terminal-prompt { ">" }
textarea #prompt rows="1" {}
button.terminal-send data-action="send" title="send" { "→" }
}
}
}
}
#[allow(dead_code)] pub(crate) fn tba_pill(address: &str) -> Markup {
let short = short_addr(address);
let title = format!("agent wallet (ERC-6551): {address}");
html! {
a #tba-pill
class="tag tba-pill"
href=(format!("https://moderato.tempo.xyz/address/{address}"))
target="_blank"
rel="noopener"
title=(title) {
"💰 " (short)
}
}
}
pub(crate) fn verify_pill(state: &VerifyState) -> Markup {
let (class, label, title) = match state {
VerifyState::Pending => (
"tag verify-pill verify-pending",
"verifying…".to_string(),
"checking ownership against the on-chain registry".to_string(),
),
VerifyState::Verified { address } => (
"tag verify-pill verify-ok",
"✓ owner".to_string(),
format!("signature recovered {address} — matches on-chain owner"),
),
VerifyState::Visitor { owner_address, .. } => (
"tag verify-pill verify-visitor",
format!("visitor · owner {}", short_addr(owner_address)),
format!("the on-chain owner of this name is {owner_address}"),
),
VerifyState::Unregistered => (
"tag verify-pill verify-unregistered",
"not on-chain".to_string(),
"this name isn't in the registry — local-only".to_string(),
),
VerifyState::Failed { reason } => (
"tag verify-pill verify-failed",
"verify failed".to_string(),
format!("verification didn't complete: {reason}"),
),
};
html! {
span #verify-pill class=(class) title=(title) { (label) }
}
}
fn short_addr(addr: &str) -> String {
let stripped = addr.trim_start_matches("0x");
if stripped.len() < 8 {
return addr.to_string();
}
format!("0x{}…{}", &stripped[..4], &stripped[stripped.len() - 4..])
}
pub(crate) fn embed_card(
name: &str,
owner_hex: Option<&str>,
tba_hex: Option<&str>,
lh_balance_wei: Option<u128>,
is_main: Option<bool>,
) -> Markup {
let lh_whole = lh_balance_wei.map(|w| w / 1_000_000_000_000_000_000u128);
html! {
section.embed-card {
div.embed-card-header {
a.embed-card-name
href=(format!("https://{name}.localharness.xyz/"))
target="_top"
rel="noopener" {
(name)
}
@if let Some(true) = is_main {
span.embed-card-badge { "main" }
}
}
div.embed-card-rows {
@if let Some(addr) = owner_hex {
div.embed-card-row {
span.embed-card-label { "owner" }
code.embed-card-value title=(addr) { (short_addr(addr)) }
}
} @else if owner_hex.is_some() {
} @else {
div.embed-card-row {
span.embed-card-label { "owner" }
code.embed-card-value.embed-card-muted { "…" }
}
}
@if let Some(addr) = tba_hex {
div.embed-card-row {
span.embed-card-label { "wallet" }
code.embed-card-value title=(addr) { (short_addr(addr)) }
}
}
@if let Some(lh) = lh_whole {
div.embed-card-row {
span.embed-card-label { "balance" }
code.embed-card-value { (lh) " LH" }
}
}
}
}
}
}
pub(crate) fn compose_chrome(names: &[String]) -> Markup {
html! {
main.compose-shell {
header.compose-header {
h1.compose-title { "compose" }
p.compose-sub { (names.len()) " module" @if names.len() != 1 { "s" } }
}
div.compose-grid {
@for name in names {
div.compose-cell {
iframe.compose-iframe
src=(format!("https://{name}.localharness.xyz/?embed=1"))
data-embed-name=(name)
loading="lazy"
referrerpolicy="no-referrer" {}
}
}
}
}
}
}
pub(crate) fn chrome(host: &Host) -> Markup {
html! {
(site_header(host))
(mobile_tabs())
main #layout .layout.view-collapsed.tab-chat {
button type="button" data-action="toggle-files"
.side-rail.files-rail {
span.rail-label { "files" }
}
(col_side(
html! {
div #fs-breadcrumb .fs-breadcrumb { "/" }
ul #fs-list .fs-list {}
},
"col-fs",
))
div.col-chat {
button type="button" data-action="toggle-view"
.top-rail.view-rail {
span.rail-label { "edit" }
}
section.view-panel {
div #view-content .view-content {}
}
div #transcript .transcript {}
section.terminal-panel {
(terminal_input())
}
button type="button" data-action="toggle-terminal"
.bottom-rail.terminal-rail {
span.rail-label { "terminal" }
}
}
(col_side(
html! {
div #financial-slot .financial-placeholder { "—" }
},
"col-financial",
))
button type="button" data-action="toggle-financial"
.side-rail.financial-rail {
span.rail-label { "agent" }
}
}
}
}
pub(crate) fn mobile_tabs() -> Markup {
html! {
nav.mobile-tabs {
button #tab-btn-files type="button" data-action="show-tab" data-arg="files" .tab-button { "files" }
button #tab-btn-edit type="button" data-action="show-tab" data-arg="edit" .tab-button { "edit" }
button #tab-btn-chat type="button" data-action="show-tab" data-arg="chat" .tab-button.active { "chat" }
button #tab-btn-agent type="button" data-action="show-tab" data-arg="agent" .tab-button { "agent" }
}
}
}
pub(crate) fn feedback_modal() -> Markup {
html! {
div #feedback-modal .feedback-modal {
div.feedback-card {
div.feedback-title { "feedback" }
p.feedback-blurb {
"what's broken, missing, or wrong. saved to "
code { ".lh_feedback.txt" }
" for now; on-chain submission lands soon."
}
textarea #feedback-text
.feedback-textarea
rows="6"
placeholder="type here…" {}
div.feedback-actions {
button type="button" data-action="feedback-submit" { "submit" }
button type="button" data-action="feedback-close" .ghost { "cancel" }
}
div #feedback-msg .feedback-msg {}
}
}
}
}
fn col_side(body: Markup, extra_class: &str) -> Markup {
let cls = format!("col-side {extra_class}");
html! {
aside class=(cls) {
div.panel-body { (body) }
}
}
}
pub(crate) fn turn(turn_id: u32, role: &str, body: Markup, streaming: bool) -> Markup {
let role_class = role; let id_str = format!("turn-{turn_id}");
let body_id = format!("turn-body-{turn_id}");
let cls = if streaming {
format!("turn {role_class} streaming")
} else {
format!("turn {role_class}")
};
html! {
div id=(id_str) class=(cls) {
div.role { (role) }
div id=(body_id) .body { (body) }
}
}
}
pub(crate) fn text_segment(seg_id: u32, text: &str) -> Markup {
let id_str = format!("seg-{seg_id}");
html! {
div id=(id_str) .text-segment { (text) }
}
}
pub(crate) fn tool_call_block(seg_id: u32, call: &ToolCall) -> Markup {
let block_id = format!("tool-{seg_id}");
let status_id = format!("tool-{seg_id}-status");
let result_id = format!("tool-{seg_id}-result");
let args_pretty = serde_json::to_string_pretty(&call.args).unwrap_or_else(|_| "{}".into());
html! {
details id=(block_id) .tool-call {
summary {
span.tc-name { (call.name) }
span id=(status_id) .tc-status.running {}
}
div.tc-body {
div.tc-section-label { "args" }
pre { (args_pretty) }
div id=(result_id) {}
}
}
}
}
pub(crate) fn tool_call_result(result: &ToolResult) -> Markup {
let ok = result.error.is_none();
html! {
div.tc-section-label { (if ok { "result" } else { "error" }) }
@if ok {
pre {
(match &result.result {
Some(v) => serde_json::to_string_pretty(v).unwrap_or_else(|_| "(unserializable)".into()),
None => "(no output)".into(),
})
}
} @else {
div.tc-error {
pre { (result.error.as_deref().unwrap_or("(unknown error)")) }
}
}
}
}
pub(crate) fn apex(host: &Host, _wallet_address_hex: Option<&str>) -> Markup {
html! {
(site_header(host))
main.apex-main {
div.col-chat {
(apex_claim())
}
}
}
}
fn apex_claim() -> Markup {
html! {
section.step.step-agents {
div #agents-list .agents-list {}
form.create-form data-action="apex-claim" {
input #apex-input
.create-input
type="text"
placeholder="choose a name"
autocomplete="off"
spellcheck="false"
maxlength="32"
required {}
button #create-btn type="submit" .create-button disabled { "create" }
}
}
}
}
pub(crate) fn admin_dropdown_apex() -> Markup {
let owner_hex = super::APP.with(|cell| {
cell.borrow().wallet.as_ref().map(|w| w.address_hex())
});
let has_wallet = owner_hex.is_some();
html! {
div #header-admin-panel .header-admin-panel {
(admin_identity_section(None, owner_hex.as_deref(), None))
@if has_wallet {
(admin_credits_section())
(admin_devices_section())
}
(admin_security_collapsed())
div.admin-footer {
button type="button" data-action="header-admin-close" .ghost { "close" }
span.admin-version { (APP_VERSION) }
}
}
}
}
pub(crate) fn admin_dropdown_tenant() -> Markup {
let name = match super::tenant::current() {
super::tenant::Host::Tenant(n) => Some(n),
_ => None,
};
let (owner_hex, tba_hex) = super::APP.with(|cell| {
use super::VerifyState;
let app = cell.borrow();
let owner = match &app.verify_state {
VerifyState::Verified { address } => Some(address.clone()),
VerifyState::Visitor { visitor_address, .. } => Some(visitor_address.clone()),
_ => None,
};
(owner, app.tba_address.clone())
});
html! {
div #header-admin-panel .header-admin-panel {
(admin_identity_section(name.as_deref(), owner_hex.as_deref(), tba_hex.as_deref()))
div.admin-section {
div.admin-section-title { "gemini api key " span #keymeta {} }
form.key-form onsubmit="return false" {
div.key-row {
input #key
type="password"
autocomplete="off"
placeholder="paste key" {}
button.ghost
type="button"
data-action="clear-key" { "clear" }
}
}
}
(admin_prompt_section())
(admin_tool_allowlist_section())
(admin_security_collapsed())
div.admin-footer {
button type="button" data-action="header-admin-close" .ghost { "close" }
span.admin-version { (APP_VERSION) }
}
}
}
}
pub(crate) fn admin_prompt_section() -> Markup {
html! {
div.admin-section {
div.admin-section-title { "agent prompt" }
form.prompt-form data-action="save-prompt" onsubmit="return false" {
textarea #prompt-input
.prompt-input
rows="5"
placeholder="optional — empty uses the default" {}
div.prompt-actions {
button type="submit" .ghost { "save" }
}
}
div #prompt-msg .admin-msg-slot {}
}
}
}
pub(crate) fn admin_tool_allowlist_section() -> Markup {
html! {
div.admin-section {
div.admin-section-title { "tool allowlist" }
div #tool-allowlist-status .admin-msg-slot { "loading…" }
div.tool-allowlist-grid {
@for tool in BuiltinTool::ALL {
label.tool-checkbox-label {
input.tool-checkbox
type="checkbox"
data-tool=(tool.wire_name())
checked {}
" " (tool.wire_name())
}
}
}
div.prompt-actions {
button type="button"
data-action="save-tool-allowlist"
.ghost { "save" }
button type="button"
data-action="reset-tool-allowlist"
.ghost { "reset (all)" }
}
div #tool-allowlist-msg .admin-msg-slot {}
}
}
}
fn admin_identity_section(
name: Option<&str>,
owner_hex: Option<&str>,
tba_hex: Option<&str>,
) -> Markup {
html! {
div.admin-section {
@if let Some(n) = name {
div.admin-identity-row {
span.admin-identity-label { "name" }
code.admin-identity-value { (n) }
}
}
@if let Some(addr) = owner_hex {
div.admin-identity-row {
span.admin-identity-label { "owner" }
a.admin-identity-value
href=(format!("https://moderato.tempo.xyz/address/{addr}"))
target="_blank" rel="noopener"
title=(addr) {
(short_addr(addr))
}
}
} @else {
p.admin-blurb { "verifying…" }
}
@if let Some(addr) = tba_hex {
div.admin-identity-row {
span.admin-identity-label { "wallet" }
a.admin-identity-value
href=(format!("https://moderato.tempo.xyz/address/{addr}"))
target="_blank" rel="noopener"
title=(addr) {
(short_addr(addr))
}
}
}
}
}
}
pub(crate) fn admin_credits_section() -> Markup {
html! {
div.admin-section {
div.admin-section-title { "credits" }
div.admin-credits-row {
code #credits-balance .admin-identity-value { "…" }
button #claim-credits-btn
type="button"
data-action="claim-credits"
.ghost { "claim daily" }
}
div #claim-credits-msg .admin-msg-slot {}
}
}
}
pub(crate) fn admin_devices_section() -> Markup {
html! {
div.admin-section {
div.admin-section-title { "linked devices" }
form #add-device-form .add-device-form
data-action="add-device" {
input #add-device-input
type="text"
placeholder="another device's 0x…"
autocomplete="off"
spellcheck="false"
maxlength="42" {}
button #add-device-btn type="submit" .ghost { "add" }
}
div #add-device-msg .admin-msg-slot {}
}
}
}
pub(crate) fn admin_security_collapsed() -> Markup {
html! {
div #security-slot .admin-section {
div.admin-section-title { "security" }
button type="button" data-action="reveal-security" .ghost {
"seed phrase, import, reset"
}
}
}
}
pub(crate) fn admin_security_expanded() -> Markup {
html! {
div #security-slot .admin-section {
div.admin-section-title { "security" }
div.admin-subsection {
div.admin-subsection-title { "seed phrase" }
div #seed-reveal .seed-reveal {
button type="button" data-action="reveal-seed" .ghost { "reveal" }
}
}
div.admin-subsection {
div.admin-subsection-title { "import a different seed" }
(import_seed_inline())
}
div.admin-subsection {
div.admin-subsection-title { "reset this device" }
div #reset-confirm-slot {
button type="button" data-action="reset-arm" .ghost { "reset…" }
}
}
button type="button" data-action="hide-security" .ghost { "hide" }
}
}
}
pub(crate) fn reset_confirm_inline() -> Markup {
html! {
div #reset-confirm-slot .reset-confirm {
span.reset-confirm-prompt { "are you sure?" }
div.reset-confirm-actions {
button type="button" data-action="reset-confirm" .danger { "yes, wipe" }
button type="button" data-action="reset-cancel" .ghost { "cancel" }
}
}
}
}
pub(crate) fn reset_armed_inline() -> Markup {
html! {
div #reset-confirm-slot {
button type="button" data-action="reset-arm" .ghost { "reset…" }
}
}
}
pub(crate) fn opfs_wipe_armed_inline() -> Markup {
html! {
span #opfs-wipe-slot {
button data-action="opfs-wipe" { "wipe" }
}
}
}
pub(crate) fn opfs_wipe_confirm_inline() -> Markup {
html! {
span #opfs-wipe-slot .opfs-wipe-confirm {
button data-action="opfs-wipe-confirm" .danger { "wipe?" }
button data-action="opfs-wipe-cancel" .ghost { "no" }
}
}
}
#[allow(dead_code)]
pub(crate) fn pricing_card(price_wei: u128) -> Markup {
html! {
section .pricing-card {
div.pricing-header {
div.pricing-title { "pricing" }
}
(pricing_card_body(price_wei, true))
}
}
}
#[allow(dead_code)] pub(crate) fn pricing_readonly_line(price_wei: u128) -> Markup {
let display = if price_wei == 0 {
"free".to_string()
} else {
format!("{} $LH/turn", super::format_wei_as_test_eth(price_wei))
};
html! {
div.financial-line {
span.financial-label { "pricing" }
span.financial-value { (display) }
}
}
}
pub(crate) fn financial_card(
name: &str,
tba_hex: &str,
owner_hex: &str,
lh_balance_wei: u128,
_price_wei: u128,
_is_owner: bool,
) -> Markup {
let tba_url = format!("https://moderato.tempo.xyz/address/{tba_hex}");
let owner_url = format!("https://moderato.tempo.xyz/address/{owner_hex}");
let balance_display = super::format_wei_as_test_eth(lh_balance_wei);
html! {
section #financial-slot .financial-card {
div.financial-line {
span.financial-label { "name" }
span.financial-value { (name) }
}
div.financial-line {
span.financial-label { "owner" }
a.financial-tba href=(owner_url) target="_blank" rel="noopener"
title=(owner_hex) {
(short_addr(owner_hex))
}
}
div.financial-line {
span.financial-label { "wallet" }
a.financial-tba href=(tba_url) target="_blank" rel="noopener"
title=(tba_hex) {
(short_addr(tba_hex))
}
}
div.financial-line {
span.financial-label { "balance" }
span.financial-value.financial-balance { (balance_display) }
}
(lh_transfer_form(tba_hex))
}
}
}
pub(crate) fn lh_transfer_form(default_recipient: &str) -> Markup {
html! {
form #lh-transfer-form .lh-transfer data-action="lh-transfer" {
div.lh-transfer-title { "send $localharness" }
div.lh-transfer-row {
input #lh-transfer-to
type="text"
autocomplete="off"
spellcheck="false"
placeholder="0x… recipient"
value=(default_recipient) {}
}
div.lh-transfer-row {
input #lh-transfer-amount
type="text"
inputmode="decimal"
autocomplete="off"
spellcheck="false"
placeholder="amount" {}
button type="submit" .lh-transfer-send { "send" }
}
div #lh-transfer-msg .lh-transfer-msg {}
}
}
}
pub(crate) fn pricing_card_body(price_wei: u128, is_owner: bool) -> Markup {
let display = if price_wei == 0 {
"free".to_string()
} else {
format!("{} $localharness/turn", super::format_wei_as_test_eth(price_wei))
};
html! {
div #pricing-body .pricing-body {
div.pricing-value { (display) }
@if is_owner {
div.pricing-edit {
input #pricing-input
type="text"
inputmode="decimal"
placeholder="1.0"
value=(if price_wei == 0 { String::new() } else { super::format_wei_as_test_eth(price_wei) }) {}
span.pricing-unit { "$localharness/turn" }
button.ghost
type="button"
data-action="pricing-save" { "save" }
}
div #pricing-msg .pricing-msg {}
}
}
}
}
pub(crate) fn import_seed_inline() -> Markup {
html! {
div #import-slot .seed-import {
textarea #import-seed
placeholder="paste 12 words separated by spaces"
rows="3" {}
div.seed-import-actions {
button type="button" data-action="import-seed" { "import" }
button type="button" data-action="cancel-import" .ghost { "cancel" }
}
div #seed-msg .step-msg {}
}
}
}
pub(crate) fn agents_list(
agents: &[crate::app::registry::OwnedToken],
main_token_id: u64,
) -> Markup {
if agents.is_empty() {
return html! {
div #agents-list .agents-list .agents-empty {}
};
}
html! {
div #agents-list .agents-list {
ul.agents-rows {
@for agent in agents {
li.agent-row {
div.agent-row-line {
a.agent-name
href=(format!("https://{}.localharness.xyz/", agent.name)) {
(agent.name)
}
@if main_token_id != 0 && agent.token_id == main_token_id {
span.main-badge title="primary identity" { "main" }
}
span.agent-row-spacer {}
button type="button"
data-action="agent-act-toggle"
data-arg=(agent.token_id)
.ghost.agent-act-btn { "act" }
}
div #(format!("agent-act-{}", agent.token_id))
.agent-act-panel hidden {}
}
}
}
}
}
}
pub(crate) fn agent_act_panel(
token_id: u64,
tba_address: &str,
lh_balance_wei: u128,
) -> Markup {
let lh_whole = lh_balance_wei / 1_000_000_000_000_000_000u128;
html! {
div.agent-act-rows {
div.agent-act-row {
span.agent-act-label { "wallet" }
a.agent-act-value
href=(format!("https://moderato.tempo.xyz/address/{tba_address}"))
target="_blank" rel="noopener"
title=(tba_address) {
(short_addr(tba_address))
}
}
div.agent-act-row {
span.agent-act-label { "balance" }
code.agent-act-value { (lh_whole) " LH" }
}
}
form.agent-act-form
data-action="agent-send-lh"
data-arg=(token_id) {
input
id=(format!("agent-send-to-{token_id}"))
type="text"
placeholder="recipient 0x…"
autocomplete="off"
spellcheck="false"
maxlength="42" {}
input
id=(format!("agent-send-amt-{token_id}"))
type="text"
placeholder="amount LH"
autocomplete="off"
spellcheck="false"
inputmode="decimal" {}
button type="submit" .ghost { "send" }
}
div
id=(format!("agent-act-msg-{token_id}"))
.admin-msg-slot {}
}
}
pub(crate) fn seed_phrase(words: &str) -> Markup {
html! {
div.seed-words { (words) }
p.apex-fine {
"12 words above. close this page or click "
button type="button" data-action="hide-seed" .link-button { "hide" }
" when you're done."
}
}
}
#[allow(dead_code)]
pub(crate) fn visitor_banner(owner_address: &str) -> Markup {
html! {
div #input-region .visitor-banner {
h3 { "visitor mode · read-only" }
p {
"this subdomain is owned by "
code { (owner_address) }
"."
}
}
}
}
pub(crate) fn signer_no_identity() -> Markup {
html! {
main.apex-main {
div.col-chat {
section.apex-hero {
h2.apex-headline { "localharness signer" }
p.apex-sub {
"no identity exists on this device yet, so this signer "
"tab can't sign anything. "
a href="https://localharness.xyz/" { "go to apex" }
" to create or import one."
}
}
}
}
}
}
pub(crate) fn signer_chrome(address_hex: &str) -> Markup {
html! {
main.apex-main {
div.col-chat {
section.apex-hero {
h2.apex-headline { "localharness signer" }
p.apex-sub {
"this tab is acting as a signing service for an embedded "
"subdomain. it will sign authentication challenges from "
"any *.localharness.xyz origin using the master wallet:"
}
div.wallet-address-row {
span.wallet-label { "address" }
code .wallet-address { (address_hex) }
}
p.apex-fine {
"if you opened this manually rather than via an iframe, "
a href="https://localharness.xyz/" { "go home" }
"."
}
}
}
}
}
}
pub(crate) fn unclaimed(host: &Host, name: &str) -> Markup {
html! {
(site_header(host))
main.apex-main {
div.col-chat {
section.step.step-unclaimed {
h2.unclaimed-name { (name) ".localharness.xyz" }
p.step-msg {
"this name is open. claim it to make it the home of an agent you own."
}
button type="button" data-action="claim-on-chain" .button-link {
"claim " (name)
}
div #claim-msg .step-msg {}
}
}
}
}
}
pub(crate) fn opfs_breadcrumb(cwd: &[String]) -> Markup {
html! {
a data-action="opfs-nav" data-arg="" { "/" }
@for i in 0..cwd.len() {
@let arg = cwd[..=i].join("/");
a data-action="opfs-nav" data-arg=(arg) { (cwd[i]) "/" }
}
}
}
pub(crate) fn opfs_list(cwd: &[String], entries: &[DirEntry]) -> Markup {
html! {
@if entries.is_empty() {
li.empty { "(empty)" }
} @else {
@for entry in entries {
@match entry.kind {
EntryKind::Directory => {
@let arg = if cwd.is_empty() {
entry.name.clone()
} else {
format!("{}/{}", cwd.join("/"), entry.name)
};
li.dir data-action="opfs-nav" data-arg=(arg) {
span.name { (entry.name) }
}
}
_ => {
li.file {
span.name data-action="opfs-open" data-arg=(entry.name) {
(entry.name)
}
@if let Some(size) = entry.size {
span.size { (format_bytes(size)) }
}
button.file-delete
type="button"
data-action="opfs-delete"
data-arg=(entry.name)
title=(format!("delete {}", entry.name)) { "×" }
}
}
}
}
}
}
}
pub(crate) fn opfs_error(message: &str) -> Markup {
html! {
li.empty { "error: " (message) }
}
}
#[allow(dead_code)]
pub(crate) fn opfs_viewer(display_path: &str, name: &str, text: &str) -> Markup {
html! {
div #fs-viewer-wrap {
div.fs-viewer-header {
span #fs-viewer-name { (display_path) }
span.fs-viewer-actions {
button.close-viewer
type="button"
data-action="opfs-edit"
data-arg=(name) { "edit" }
" "
button.close-viewer
type="button"
data-action="opfs-close-viewer" { "close" }
}
}
pre #fs-viewer .fs-viewer { (text) }
}
}
}
pub(crate) fn opfs_editor(display_path: &str, name: &str, text: &str) -> Markup {
html! {
div.editor {
div.editor-header {
span.editor-path { (display_path) }
div.editor-actions {
button.panel-button
type="button"
data-action="opfs-save"
data-arg=(name) { "save" }
button.panel-button
type="button"
data-action="opfs-close-viewer" { "close" }
}
}
textarea #fs-editor .editor-textarea { (text) }
}
}
}
#[allow(dead_code)]
pub(crate) fn opfs_viewer_placeholder() -> Markup {
html! {
div #fs-viewer-wrap hidden {
div.fs-viewer-header {
span #fs-viewer-name {}
button.close-viewer
type="button"
data-action="opfs-close-viewer" { "close" }
}
pre #fs-viewer .fs-viewer {}
}
}
}
fn format_bytes(n: u64) -> String {
if n < 1024 {
format!("{n} B")
} else if n < 1024 * 1024 {
format!("{:.1} KB", n as f64 / 1024.0)
} else {
format!("{:.1} MB", n as f64 / (1024.0 * 1024.0))
}
}
pub(crate) fn keymeta(key: &str) -> Markup {
let n = key.len();
if n == 0 {
return html! {};
}
let looks_right = (30..=60).contains(&n)
&& key
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
let suffix = if looks_right { "" } else { " - check" };
html! {
span style=(if looks_right { "" } else { "color: var(--error)" }) {
"(" (n) " chars" (suffix) ")"
}
}
}