Files
signed/crates/workspace/src/views/repo_detail/helpers.rs
T
2026-08-28 10:25:09 +07:00

818 lines
28 KiB
Rust

use std::collections::HashMap;
use std::path::{Path, PathBuf};
use assets::CustomIconName;
use gpui::prelude::*;
use gpui::{
Anchor, AnyElement, App, ClipboardItem, DismissEvent, ElementId, Entity, Focusable,
SharedString, StyleRefinement, Window, div, px,
};
use gpui_base::{Button as BaseButton, Popover, Selectable, StyledExt};
use gpui_component::clipboard::Clipboard;
use gpui_component::list::ListItem;
use gpui_component::menu::{PopupMenu, PopupMenuItem};
use gpui_component::tooltip::Tooltip;
use gpui_component::tree::{TreeEntry, TreeItem};
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex};
use nostr::nips::nip19::{Nip19Coordinate, ToBech32};
use signed_core::{Announcement, RepoStatus};
use signed_git::{DiffHunk, DiffLine, DiffLineKind, FileDiff};
/// A `Send` file-tree node: the tree is built on a background thread and
/// converted into [`TreeItem`]s (which hold `Rc` state,
/// so they cannot cross threads) on the main thread.
pub(super) struct TreeItemSeed {
/// Path of the node, relative to the worktree root.
id: String,
/// File or directory name.
label: String,
children: Vec<TreeItemSeed>,
}
/// Convert tree seeds into [`TreeItem`]s, expanding every folder
/// when `expand_folders` is set.
///
/// The commit diff explorer shows only changed files,
/// which is typically a handful of paths, so its folders start expanded;
/// the worktree explorer starts collapsed instead.
pub(super) fn tree_items(seeds: Vec<TreeItemSeed>, expand_folders: bool) -> Vec<TreeItem> {
fn convert(seed: TreeItemSeed, expand_folders: bool) -> TreeItem {
let mut item = TreeItem::new(seed.id, seed.label);
if expand_folders && !seed.children.is_empty() {
item = item.expanded(true);
}
item.children = seed
.children
.into_iter()
.map(|seed| convert(seed, expand_folders))
.collect();
item
}
seeds
.into_iter()
.map(|seed| convert(seed, expand_folders))
.collect()
}
/// One row of a file tree: icon + name, indented by depth.
/// Clicking a file runs `on_click`; folders expand/collapse via the tree itself.
pub(super) fn tree_row<F>(ix: usize, entry: &TreeEntry, selected: bool, on_click: F) -> ListItem
where
F: Fn(&mut Window, &mut App) + 'static,
{
let item = entry.item();
let is_folder = entry.is_folder();
let icon = if is_folder {
if entry.is_expanded() {
IconName::FolderOpen
} else {
IconName::FolderClosed
}
} else {
IconName::File
};
ListItem::new(ix)
.pl(px(8.) + px(14.) * entry.depth() as f32)
.selected(selected)
.child(
h_flex()
.gap_2()
.overflow_hidden()
.child(Icon::new(icon).small())
.child(div().text_sm().text_ellipsis().child(item.label.clone())),
)
.on_click(move |_event, window, cx| {
// Folders expand/collapse via the tree itself.
if is_folder {
return;
}
on_click(window, cx);
})
}
/// Build nested tree items from a flat, sorted (dirs-first) entry list.
///
/// Returns [`TreeItemSeed`]s so the build can run off the main thread; a
/// worktree walk can yield tens of thousands of entries. Nodes live in an
/// arena and parents are found via a path -> index map, which keeps the
/// build linear in the number of path components.
pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItemSeed> {
// Node indices by full path, for O(1) parent lookup while inserting.
let mut index: HashMap<String, usize> = HashMap::new();
let mut nodes: Vec<(String, String, Vec<usize>)> = Vec::new();
let mut roots: Vec<usize> = Vec::new();
for entry in entries {
let mut parent: Option<usize> = None;
let mut path = String::new();
for part in entry.components() {
let label = part.as_os_str().to_string_lossy().into_owned();
path = if path.is_empty() {
label.clone()
} else {
format!("{path}/{label}")
};
let ix = *index.entry(path.clone()).or_insert_with(|| {
let ix = nodes.len();
nodes.push((path.clone(), label.clone(), Vec::new()));
match parent {
Some(parent) => nodes[parent].2.push(ix),
None => roots.push(ix),
}
ix
});
parent = Some(ix);
}
}
fn assemble(ix: usize, nodes: &[(String, String, Vec<usize>)]) -> TreeItemSeed {
let (id, label, children) = &nodes[ix];
TreeItemSeed {
id: id.clone(),
label: label.clone(),
children: children
.iter()
.map(|child| assemble(*child, nodes))
.collect(),
}
}
roots.iter().map(|root| assemble(*root, &nodes)).collect()
}
/// The markdown fence language for a file path, or `None` for plain text.
///
/// Names are chosen so `gpui_component`'s highlighter can resolve them
/// (`highlighter::Language::from_name` accepts short aliases such as `rs` and `js`).
pub(super) fn code_language(path: &str) -> Option<&'static str> {
let name = Path::new(path)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default();
// Some common files are recognized by name rather than extension.
match name {
"Makefile" | "makefile" => return Some("make"),
"CMakeLists.txt" => return Some("cmake"),
_ => {}
}
let ext = Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
Some(match ext.as_str() {
"rs" => "rust",
"toml" => "toml",
"json" | "jsonc" => "json",
"py" => "python",
"js" | "mjs" | "cjs" => "javascript",
"ts" | "mts" | "cts" => "typescript",
"tsx" | "jsx" => "tsx",
"go" => "go",
"c" | "h" => "c",
"cc" | "cpp" | "cxx" | "hh" | "hpp" | "hxx" => "cpp",
"cs" => "csharp",
"java" => "java",
"kt" | "kts" | "ktm" => "kotlin",
"swift" => "swift",
"php" | "phtml" => "php",
"rb" => "ruby",
"sh" | "bash" | "zsh" => "bash",
"yml" | "yaml" => "yaml",
"css" | "scss" | "sass" => "css",
"html" | "htm" => "html",
"lua" => "lua",
"sql" => "sql",
"proto" | "protobuf" => "proto",
"cmake" => "cmake",
"zig" => "zig",
"ex" | "exs" => "elixir",
"graphql" | "gql" => "graphql",
"diff" | "patch" => "diff",
"svelte" => "svelte",
"astro" => "astro",
"scala" => "scala",
_ => return None,
})
}
/// Whether a file path has a markdown extension.
pub(super) fn is_markdown_path(path: &str) -> bool {
Path::new(path)
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| {
matches!(
ext.to_ascii_lowercase().as_str(),
"md" | "markdown" | "mdown" | "mkdn"
)
})
}
/// A centered muted placeholder message.
pub(super) fn placeholder(message: &str, cx: &App) -> AnyElement {
v_flex()
.size_full()
.items_center()
.justify_center()
.p_4()
.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(message.to_string()),
)
.into_any_element()
}
/// The status badge shown next to an issue or pull request: icon + colored square,
/// with a tooltip describing the status.
pub(super) fn status_badge(status: RepoStatus, cx: &App) -> AnyElement {
let (icon, label, tooltip, bg, fg) = match status {
RepoStatus::Open => (
CustomIconName::GitIssueDone,
"open",
"Issue is open",
cx.theme().primary,
cx.theme().primary_foreground,
),
RepoStatus::Closed => (
CustomIconName::GitIssueClosed,
"closed",
"Issue is closed",
cx.theme().danger,
cx.theme().danger_foreground,
),
RepoStatus::Draft => (
CustomIconName::GitIssueOngoing,
"draft",
"Issue is draft",
cx.theme().accent,
cx.theme().accent_foreground,
),
RepoStatus::Applied => (
CustomIconName::GitIssueOpen,
"applied",
"Issue is completed",
cx.theme().secondary,
cx.theme().secondary_foreground,
),
};
v_flex()
.id(label)
.flex_shrink_0()
.size_7()
.items_center()
.justify_center()
.rounded(cx.theme().radius)
.bg(bg)
.child(Icon::new(icon).small().text_color(fg))
.tooltip(move |window, cx| Tooltip::new(tooltip).build(window, cx))
.into_any_element()
}
/// A split dropdown button built on `gpui_base::Popover`: an action element
/// with a separate caret trigger that opens a [`PopupMenu`].
///
/// The action and the caret are ordinary elements supplied by the caller, so
/// the look — icons, borders, hover states, sizes — stays fully in the
/// application. The component only owns the popover wiring: opening on caret
/// click, Escape/outside dismissal, focus movement into the menu, and the
/// menu entity's lifecycle.
#[derive(IntoElement)]
pub(super) struct BaseDropdownButton {
id: ElementId,
style: StyleRefinement,
anchor: Anchor,
action: Option<AnyElement>,
caret: Option<CaretBuilder>,
menu: Option<MenuBuilder>,
}
type MenuBuilder =
Box<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static>;
type CaretBuilder = Box<dyn FnOnce(bool, &Window, &App) -> AnyElement>;
impl BaseDropdownButton {
pub(super) fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
style: StyleRefinement::default(),
anchor: Anchor::TopRight,
action: None,
caret: None,
menu: None,
}
}
/// The action half of the button. It keeps its own icon, label, tooltip
/// and click handler.
pub(super) fn action(mut self, action: impl IntoElement + 'static) -> Self {
self.action = Some(action.into_any_element());
self
}
/// The menu built by `builder` — the same signature as gpui-component's
/// `DropdownButton::dropdown_menu`, so existing menu code keeps working.
pub(super) fn dropdown_menu(
mut self,
builder: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
) -> Self {
self.menu = Some(Box::new(builder));
self
}
/// Which corner of the caret the menu anchors to. Defaults to
/// [`Anchor::TopRight`], so the menu's right edge lines up with the
/// caret's.
#[allow(dead_code)] // API knob; current call sites use the default anchor.
pub(super) fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
self.anchor = anchor.into();
self
}
}
impl Styled for BaseDropdownButton {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
/// Holds the [`PopupMenu`] entity of one popover between renders. Dismissal
/// drops it, so the menu is rebuilt with fresh items on the next open.
#[derive(Default)]
struct DropdownMenuState {
menu: Option<Entity<PopupMenu>>,
}
impl RenderOnce for BaseDropdownButton {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
debug_assert!(
self.menu.is_some(),
"a BaseDropdownButton needs a `dropdown_menu`"
);
// The popover needs its own id: both the container and the popover register keyed state on this window.
let popover_id = SharedString::from(format!("{}-popover", self.id));
let anchor = self.anchor;
let menu_state =
window.use_keyed_state(popover_id.clone(), cx, |_, _| DropdownMenuState::default());
let caret = self.caret.unwrap_or_else(|| {
let id = popover_id.clone();
Box::new(move |is_open, _, cx| {
let caret = default_caret(id.clone(), cx);
let selected = caret.is_selected();
caret.selected(selected || is_open).into_any_element()
})
});
h_flex()
.id(self.id)
.refine_style(&self.style)
.gap_0p5()
.when_some(self.action, |this, action| this.child(action))
.when_some(self.menu, |this, builder| {
this.child(
Popover::new(popover_id)
.anchor(anchor)
// The menu dismisses itself on outside click or Escape;
// the subscription below closes the popover along with it.
.overlay_closable(false)
.trigger_with(caret)
.content(
move |_, window, cx| match menu_state.read(cx).menu.clone() {
Some(menu) => menu,
None => {
let menu = PopupMenu::build(window, cx, |menu, window, cx| {
builder(menu, window, cx)
});
menu_state
.update(cx, |state, _| state.menu = Some(menu.clone()));
menu.focus_handle(cx).focus(window, cx);
let popover_state = cx.entity();
window
.subscribe(&menu, cx, {
let menu_state = menu_state.clone();
move |_, _: &DismissEvent, window, cx| {
popover_state.update(cx, |state, cx| {
state.dismiss(window, cx);
});
menu_state.update(cx, |state, _| {
state.menu = None;
});
}
})
.detach();
menu.clone()
}
},
),
)
})
}
}
/// The default caret: a chevron button the height of a medium button, tinted
/// by the theme, with hover and menu-open states.
fn default_caret(id: impl Into<ElementId>, cx: &App) -> BaseButton {
BaseButton::new(id)
.h(px(32.))
.px_1p5()
.text_color(cx.theme().muted_foreground)
.hover(|style| style.bg(cx.theme().secondary_hover))
.styles(|this| {
this.selected(|style| style.bg(cx.theme().secondary_active))
.disabled(|style| style.opacity(0.5))
})
.child(Icon::new(IconName::ChevronDown).xsmall())
}
pub(super) struct ShareTargets {
/// NIP-19 `naddr1...` of the announcement (with its announced relays).
pub(super) naddr: String,
/// Hex ID of the announcement event itself.
pub(super) event_id: String,
/// NIP-34 coordinate `30617:<pubkey>:<repo-id>`.
pub(super) coordinate: String,
/// `https://gitworkshop.dev/<naddr>`
pub(super) gitworkshop: String,
/// `https://ditto.pub/<naddr>`
pub(super) ditto: String,
}
impl ShareTargets {
pub(super) fn from_announcement(announcement: &Announcement) -> Self {
let addr = announcement.addr();
let coordinate = addr.to_string();
let naddr = Nip19Coordinate::new(addr, announcement.relays.iter().cloned())
.to_bech32()
.expect("a complete coordinate always encodes to naddr");
Self {
naddr: naddr.clone(),
event_id: announcement.event_id.to_bech32().unwrap(),
coordinate,
gitworkshop: format!("https://gitworkshop.dev/{naddr}"),
ditto: format!("https://ditto.pub/{naddr}"),
}
}
/// The share dropdown menu: one row per target, each showing a compact
/// label while the copy button (and row click) copy the full value.
pub(super) fn menu(&self, menu: PopupMenu) -> PopupMenu {
menu.min_w(px(340.))
.item(share_menu_row(
"copy-gitworkshop",
"GitWorkshop",
truncate_naddr_link(&self.gitworkshop, 4),
self.gitworkshop.clone(),
))
.item(share_menu_row(
"copy-ditto",
"Ditto",
truncate_naddr_link(&self.ditto, 4),
self.ditto.clone(),
))
.item(share_menu_row(
"copy-event-id",
"Event ID",
middle_truncate(&self.event_id, 10, 10),
self.event_id.clone(),
))
.item(share_menu_row(
"copy-coordinate",
"Coordinate",
middle_truncate(&self.coordinate, 10, 10),
self.coordinate.clone(),
))
}
}
/// One row of the share menu: a small title on top of the compact label,
/// with a copy button that flips to a check while the value is on the
/// clipboard. Clicking the row text copies and dismisses the menu; the copy
/// button stops propagation, so the menu stays open for further copies.
/// Both copy `copy`, never the truncated label.
pub(super) fn share_menu_row(
id: &'static str,
title: &'static str,
label: String,
copy: String,
) -> PopupMenuItem {
let row_copy = copy.clone();
PopupMenuItem::element(move |_window, _cx| {
let button_copy = copy.clone();
h_flex()
.flex_1()
.gap_2()
.items_end()
.child(
h_flex()
.flex_1()
.gap_1()
.text_xs()
.child(div().flex_shrink_0().w_20().font_semibold().child(title))
.child(div().flex_1().text_ellipsis().child(label.clone())),
)
.child(Clipboard::new(id).tooltip("Copy").value(button_copy))
})
.on_click(move |_, _, cx| {
cx.write_to_clipboard(ClipboardItem::new_string(row_copy.clone()));
})
}
/// `[head chars]...[tail chars]` middle truncation; the value is left alone
/// when it is too short for the ellipsis to save space.
pub(super) fn middle_truncate(value: &str, head: usize, tail: usize) -> String {
let len = value.chars().count();
if len <= head + tail + 3 {
return value.to_string();
}
let head: String = value.chars().take(head).collect();
let tail: String = value.chars().skip(len - tail).collect();
format!("{head}...{tail}")
}
/// Shorten an naddr link to `<url>/naddr1...[last tail chars]`, e.g.
/// `https://gitworkshop.dev/naddr1...abcd`. Only the label is shortened;
/// the value to be copied stays the full URL.
fn truncate_naddr_link(url: &str, tail: usize) -> String {
let Some(end) = url.find("naddr1").map(|i| i + "naddr1".len()) else {
return url.to_string();
};
if url.len() - end <= tail + 3 {
return url.to_string();
}
format!("{}...{}", &url[..end], &url[url.len() - tail..])
}
/// Width of one line-number gutter in a diff row.
pub(super) const GUTTER_WIDTH: f32 = 44.;
/// Height of one row in a virtual diff list.
pub(super) const DIFF_ROW_HEIGHT: f32 = 20.;
/// One row of a virtual diff list: a hunk header, or a line of a hunk.
/// Shared by the commit diff and pull request diff viewers.
#[derive(Clone, Copy)]
pub(super) enum DiffRow {
Hunk {
old_start: u32,
old_lines: u32,
new_start: u32,
new_lines: u32,
},
/// Line `line` of hunk `hunk` of the selected file's diff.
Line { hunk: usize, line: usize },
}
/// The rows of `file`'s diff: one header row per hunk, then its lines.
pub(super) fn diff_rows(file: &FileDiff) -> Vec<DiffRow> {
let mut rows = Vec::new();
for (hunk_ix, hunk) in file.hunks.iter().enumerate() {
rows.push(DiffRow::Hunk {
old_start: hunk.old_start,
old_lines: hunk.old_lines,
new_start: hunk.new_start,
new_lines: hunk.new_lines,
});
rows.extend((0..hunk.lines.len()).map(|line| DiffRow::Line {
hunk: hunk_ix,
line,
}));
}
rows
}
/// One row of the virtual diff list: a hunk header or a single line.
pub(super) fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement {
match row {
DiffRow::Hunk {
old_start,
old_lines,
new_start,
new_lines,
} => div()
.px_2()
.w_full()
.h(px(DIFF_ROW_HEIGHT))
.font_family(cx.theme().mono_font_family.clone())
.text_xs()
.bg(cx.theme().muted)
.border_y(px(1.))
.border_color(cx.theme().border)
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(format!(
"@@ -{},{} +{},{} @@",
old_start, old_lines, new_start, new_lines
)))
.into_any_element(),
DiffRow::Line { hunk, line } => render_diff_line(&hunks[hunk].lines[line], cx),
}
}
/// One diff line: old and new line numbers in gutters, then the content,
/// tinted by kind (addition / deletion / context).
pub(super) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement {
let bg = match line.kind {
DiffLineKind::Addition => Some(cx.theme().success.opacity(0.2)),
DiffLineKind::Deletion => Some(cx.theme().danger.opacity(0.2)),
DiffLineKind::Context => None,
};
let gutter = cx.theme().muted_foreground;
// Fixed height and nowrap: the virtual list assumes every row has
// the same height, so long lines are clipped instead of wrapped.
h_flex()
.w_full()
.h(px(DIFF_ROW_HEIGHT))
.items_center()
.font_family(cx.theme().mono_font_family.clone())
.text_xs()
.when_some(bg, |this, bg| this.bg(bg))
.child(
div()
.w(px(GUTTER_WIDTH))
.flex_none()
.pr_2()
.text_right()
.text_color(gutter)
.child(line.old.map(|n| n.to_string()).unwrap_or_default()),
)
.child(
div()
.w(px(GUTTER_WIDTH))
.flex_none()
.pr_2()
.text_right()
.text_color(gutter)
.child(line.new.map(|n| n.to_string()).unwrap_or_default()),
)
.child(
div()
.flex_1()
.min_w_0()
.overflow_hidden()
.whitespace_nowrap()
.text_color(cx.theme().foreground)
.child(line.text.clone()),
)
.into_any_element()
}
/// Find a tree item by id, searching into nested children.
pub(super) fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<&'a TreeItem> {
let id = id?;
items.iter().find_map(|item| {
if item.id.as_ref() == id {
Some(item)
} else {
find_item(&item.children, Some(id))
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builds_nested_tree_from_flat_entries() {
let entries = vec![
PathBuf::from("src"),
PathBuf::from("src/lib.rs"),
PathBuf::from("README.md"),
PathBuf::from("docs/guide.md"),
];
let items = build_tree_items(&entries);
// Input order is preserved (dirs-first, as produced by worktree_entries).
assert_eq!(items.len(), 3);
assert_eq!(items[0].label, "src");
assert_eq!(items[0].id, "src");
assert_eq!(items[0].children.len(), 1);
assert_eq!(items[0].children[0].label, "lib.rs");
assert_eq!(items[0].children[0].id, "src/lib.rs");
assert_eq!(items[1].label, "README.md");
assert_eq!(items[1].id, "README.md");
assert_eq!(items[2].label, "docs");
assert_eq!(items[2].children[0].label, "guide.md");
assert_eq!(items[2].children[0].id, "docs/guide.md");
}
#[test]
fn tree_builder_handles_deep_nesting() {
let entries = vec![
PathBuf::from("a"),
PathBuf::from("a/b"),
PathBuf::from("a/b/c.txt"),
];
let items = build_tree_items(&entries);
assert_eq!(items.len(), 1);
assert_eq!(items[0].children[0].id, "a/b");
assert_eq!(items[0].children[0].children[0].id, "a/b/c.txt");
}
#[test]
fn tree_builder_merges_shared_prefixes() {
// File children of a directory arrive after other directories'
// entries (the worktree list is dirs-first globally); the shared
// prefix must still resolve to one node.
let entries = vec![
PathBuf::from("a/x.txt"),
PathBuf::from("b/y.txt"),
PathBuf::from("a/z.txt"),
];
let items = build_tree_items(&entries);
assert_eq!(items.len(), 2);
assert_eq!(items[0].label, "a");
assert_eq!(items[0].children.len(), 2);
assert_eq!(items[1].label, "b");
}
#[test]
fn tree_seeds_convert_to_tree_items() {
let entries = vec![
PathBuf::from("src"),
PathBuf::from("src/main.rs"),
PathBuf::from("README.md"),
];
let items: Vec<TreeItem> = tree_items(build_tree_items(&entries), false);
assert_eq!(items.len(), 2);
assert_eq!(items[0].label, "src");
assert_eq!(items[0].children.len(), 1);
assert_eq!(items[0].children[0].label, "main.rs");
}
#[test]
fn code_language_maps_extensions_and_names() {
assert_eq!(code_language("src/main.rs"), Some("rust"));
assert_eq!(code_language("Cargo.toml"), Some("toml"));
assert_eq!(code_language("app.js"), Some("javascript"));
assert_eq!(code_language("index.tsx"), Some("tsx"));
assert_eq!(code_language("Makefile"), Some("make"));
assert_eq!(code_language("CMakeLists.txt"), Some("cmake"));
assert_eq!(code_language("data.csv"), None);
assert_eq!(code_language("LICENSE"), None);
assert_eq!(code_language("README.md"), None);
}
#[test]
fn middle_truncates_long_values_only() {
assert_eq!(
middle_truncate(
"a008def15796fba9a0d6fab04e8fd57089285d9fd505da5a83fe8aad57a3564d",
10,
10,
),
"a008def157...ad57a3564d"
);
assert_eq!(
middle_truncate(
"30617:a008def15796fba9a0d6fab04e8fd57089285d9fd505da5a83fe8aad57a3564d:ngit",
10,
10
),
"30617:a008...3564d:ngit"
);
// Too short to save space with the ellipsis: left alone.
assert_eq!(middle_truncate("short", 10, 10), "short");
}
#[test]
fn naddr_link_keeps_url_and_tail() {
assert_eq!(
truncate_naddr_link("https://gitworkshop.dev/naddr1qqqxyzabc1234", 4),
"https://gitworkshop.dev/naddr1...1234"
);
// No naddr1 prefix: unchanged.
assert_eq!(
truncate_naddr_link("https://example.com/x", 4),
"https://example.com/x"
);
}
#[test]
fn base_dropdown_button_builder_state() {
let button = BaseDropdownButton::new("issues")
.action(div())
.anchor(Anchor::BottomLeft)
.dropdown_menu(|menu, _, _| menu);
assert!(button.action.is_some());
assert!(button.caret.is_some());
assert!(button.menu.is_some());
assert_eq!(button.anchor, Anchor::BottomLeft);
}
}