add backup panel
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m47s
Rust / build (ubuntu-latest, stable) (pull_request) Failing after 1m52s
Rust / build (macos-latest, stable) (push) Has been cancelled
Rust / build (windows-latest, stable) (push) Has been cancelled
Rust / build (macos-latest, stable) (pull_request) Has been cancelled
Rust / build (windows-latest, stable) (pull_request) Has been cancelled

This commit is contained in:
2026-02-25 09:11:23 +07:00
parent 6d863d8bbe
commit 971a82df1b
10 changed files with 308 additions and 123 deletions

View File

@@ -0,0 +1,194 @@
use std::time::Duration;
use anyhow::Error;
use gpui::{
div, AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Task, Window,
};
use nostr_sdk::prelude::*;
use state::KEYRING;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::input::{InputState, TextInput};
use ui::{divider, v_flex, IconName, Sizable, StyledExt};
const MSG: &str = "Store your account keys in a safe location. \
You can restore your account or move to another client anytime you want.";
pub fn init(window: &mut Window, cx: &mut App) -> Entity<BackupPanel> {
cx.new(|cx| BackupPanel::new(window, cx))
}
#[derive(Debug)]
pub struct BackupPanel {
name: SharedString,
focus_handle: FocusHandle,
/// Public key input
npub_input: Entity<InputState>,
/// Secret key input
nsec_input: Entity<InputState>,
/// Copied status
copied: bool,
/// Background tasks
tasks: Vec<Task<Result<(), Error>>>,
}
impl BackupPanel {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let npub_input = cx.new(|cx| InputState::new(window, cx).disabled(true));
let nsec_input = cx.new(|cx| InputState::new(window, cx).disabled(true));
// Run at the end of current cycle
cx.defer_in(window, |this, window, cx| {
this.load(window, cx);
});
Self {
name: "Backup".into(),
focus_handle: cx.focus_handle(),
npub_input,
nsec_input,
copied: false,
tasks: vec![],
}
}
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let keyring = cx.read_credentials(KEYRING);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
if let Some((_, secret)) = keyring.await? {
let secret = SecretKey::from_slice(&secret)?;
let keys = Keys::new(secret);
this.update_in(cx, |this, window, cx| {
this.npub_input.update(cx, |this, cx| {
this.set_value(keys.public_key().to_bech32().unwrap(), window, cx);
});
this.nsec_input.update(cx, |this, cx| {
this.set_value(keys.secret_key().to_bech32().unwrap(), window, cx);
});
})?;
}
Ok(())
}));
}
fn copy_secret_key(&mut self, cx: &mut Context<Self>) {
let value = self.nsec_input.read(cx).value();
let item = ClipboardItem::new_string(value.to_string());
// Copy to clipboard
cx.write_to_clipboard(item);
// Set the copied status to true
self.set_copied(true, cx);
}
fn set_copied(&mut self, status: bool, cx: &mut Context<Self>) {
self.copied = status;
cx.notify();
self.tasks.push(cx.spawn(async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
// Clear the error message after a delay
this.update(cx, |this, cx| {
this.set_copied(false, cx);
})?;
Ok(())
}));
}
}
impl Panel for BackupPanel {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
}
impl EventEmitter<PanelEvent> for BackupPanel {}
impl Focusable for BackupPanel {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for BackupPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.p_3()
.gap_3()
.w_full()
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from(MSG)),
)
.child(divider(cx))
.child(
v_flex()
.gap_2()
.flex_1()
.w_full()
.text_sm()
.child(
v_flex()
.gap_1p5()
.w_full()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Public Key:")),
)
.child(TextInput::new(&self.npub_input).small().bordered(false)),
)
.child(
v_flex()
.gap_1p5()
.w_full()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Secret Key:")),
)
.child(TextInput::new(&self.nsec_input).small().bordered(false)),
)
.child(
Button::new("copy")
.icon(IconName::Copy)
.label({
if self.copied {
"Copied"
} else {
"Copy secret key"
}
})
.primary()
.small()
.font_semibold()
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.copy_secret_key(cx);
})),
),
)
}
}

View File

@@ -144,16 +144,17 @@ impl MessagingRelayPanel {
self.error = Some(error.into());
cx.notify();
cx.spawn_in(window, async move |this, cx| {
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
// Clear the error message after a delay
this.update(cx, |this, cx| {
this.error = None;
cx.notify();
})
.ok();
})
.detach();
})?;
Ok(())
}));
}
fn set_updating(&mut self, updating: bool, cx: &mut Context<Self>) {

View File

@@ -1,3 +1,4 @@
pub mod backup;
pub mod connect;
pub mod encryption_key;
pub mod greeter;

View File

@@ -163,16 +163,17 @@ impl RelayListPanel {
self.error = Some(error.into());
cx.notify();
cx.spawn_in(window, async move |this, cx| {
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
// Clear the error message after a delay
this.update(cx, |this, cx| {
this.error = None;
cx.notify();
})
.ok();
})
.detach();
})?;
Ok(())
}));
}
fn set_updating(&mut self, updating: bool, cx: &mut Context<Self>) {

View File

@@ -20,7 +20,7 @@ use ui::dock_area::{ClosePanel, DockArea, DockItem};
use ui::menu::DropdownMenu;
use ui::{h_flex, v_flex, IconName, Root, Sizable, WindowExtension};
use crate::panels::{encryption_key, greeter, messaging_relays, relay_list};
use crate::panels::{backup, encryption_key, greeter, messaging_relays, profile, relay_list};
use crate::sidebar;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Workspace> {
@@ -30,11 +30,17 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<Workspace> {
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
#[action(namespace = workspace, no_json)]
enum Command {
ReloadRelayList,
OpenRelayPanel,
ReloadInbox,
OpenInboxPanel,
OpenEncryptionPanel,
ToggleTheme,
RefreshRelayList,
RefreshMessagingRelays,
ShowRelayList,
ShowMessaging,
ShowEncryption,
ShowProfile,
ShowSettings,
ShowBackup,
}
pub struct Workspace {
@@ -181,7 +187,32 @@ impl Workspace {
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
match command {
Command::OpenEncryptionPanel => {
Command::ShowProfile => {
let nostr = NostrRegistry::global(cx);
let signer = nostr.read(cx).signer();
if let Some(public_key) = signer.public_key() {
self.dock.update(cx, |this, cx| {
this.add_panel(
Arc::new(profile::init(public_key, window, cx)),
DockPlacement::Right,
window,
cx,
);
});
}
}
Command::ShowBackup => {
self.dock.update(cx, |this, cx| {
this.add_panel(
Arc::new(backup::init(window, cx)),
DockPlacement::Right,
window,
cx,
);
});
}
Command::ShowEncryption => {
let nostr = NostrRegistry::global(cx);
let signer = nostr.read(cx).signer();
@@ -196,7 +227,7 @@ impl Workspace {
});
}
}
Command::OpenInboxPanel => {
Command::ShowMessaging => {
self.dock.update(cx, |this, cx| {
this.add_panel(
Arc::new(messaging_relays::init(window, cx)),
@@ -206,7 +237,7 @@ impl Workspace {
);
});
}
Command::OpenRelayPanel => {
Command::ShowRelayList => {
self.dock.update(cx, |this, cx| {
this.add_panel(
Arc::new(relay_list::init(window, cx)),
@@ -216,18 +247,19 @@ impl Workspace {
);
});
}
Command::ReloadInbox => {
Command::RefreshRelayList => {
let nostr = NostrRegistry::global(cx);
nostr.update(cx, |this, cx| {
this.ensure_relay_list(cx);
});
}
Command::ReloadRelayList => {
Command::RefreshMessagingRelays => {
let chat = ChatRegistry::global(cx);
chat.update(cx, |this, cx| {
this.ensure_messaging_relays(cx);
});
}
_ => {}
}
}
@@ -252,12 +284,29 @@ impl Workspace {
.compact()
.transparent()
.dropdown_menu(move |this, _window, _cx| {
this.label(profile.name())
this.min_w(px(256.))
.label(profile.name())
.separator()
.menu("Profile", Box::new(ClosePanel))
.menu("Backup", Box::new(ClosePanel))
.menu("Themes", Box::new(ClosePanel))
.menu("Settings", Box::new(ClosePanel))
.menu_with_icon(
"Profile",
IconName::Profile,
Box::new(Command::ShowProfile),
)
.menu_with_icon(
"Backup",
IconName::UserKey,
Box::new(Command::ShowBackup),
)
.menu_with_icon(
"Themes",
IconName::Sun,
Box::new(Command::ToggleTheme),
)
.menu_with_icon(
"Settings",
IconName::Settings,
Box::new(Command::ShowSettings),
)
}),
)
})
@@ -298,7 +347,7 @@ impl Workspace {
.small()
.ghost()
.on_click(|_ev, window, cx| {
window.dispatch_action(Box::new(Command::OpenEncryptionPanel), cx);
window.dispatch_action(Box::new(Command::ShowEncryption), cx);
}),
)
.child(
@@ -333,7 +382,7 @@ impl Workspace {
this.min_w(px(260.))
.label("Messaging Relays")
.menu_element_with_disabled(
Box::new(Command::OpenRelayPanel),
Box::new(Command::ShowRelayList),
true,
move |_window, cx| {
let persons = PersonRegistry::global(cx);
@@ -380,12 +429,12 @@ impl Workspace {
.menu_with_icon(
"Reload",
IconName::Refresh,
Box::new(Command::ReloadInbox),
Box::new(Command::RefreshMessagingRelays),
)
.menu_with_icon(
"Update relays",
IconName::Settings,
Box::new(Command::OpenInboxPanel),
Box::new(Command::ShowMessaging),
)
}),
),
@@ -421,7 +470,7 @@ impl Workspace {
this.min_w(px(260.))
.label("Relays")
.menu_element_with_disabled(
Box::new(Command::OpenRelayPanel),
Box::new(Command::ShowRelayList),
true,
move |_window, cx| {
let nostr = NostrRegistry::global(cx);
@@ -465,12 +514,12 @@ impl Workspace {
.menu_with_icon(
"Reload",
IconName::Refresh,
Box::new(Command::ReloadRelayList),
Box::new(Command::RefreshRelayList),
)
.menu_with_icon(
"Update relay list",
IconName::Settings,
Box::new(Command::OpenRelayPanel),
Box::new(Command::ShowRelayList),
)
}),
),

View File

@@ -25,61 +25,3 @@ pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<Url, Er
.await
.map_err(|e| anyhow!("Upload error: {e}"))?
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mime_type_detection() {
// Test common file extensions
assert_eq!(
from_path("image.jpg").first_or_octet_stream().to_string(),
"image/jpeg"
);
assert_eq!(
from_path("document.pdf")
.first_or_octet_stream()
.to_string(),
"application/pdf"
);
assert_eq!(
from_path("page.html").first_or_octet_stream().to_string(),
"text/html"
);
assert_eq!(
from_path("data.json").first_or_octet_stream().to_string(),
"application/json"
);
assert_eq!(
from_path("script.js").first_or_octet_stream().to_string(),
"text/javascript"
);
assert_eq!(
from_path("style.css").first_or_octet_stream().to_string(),
"text/css"
);
// Test unknown extension falls back to octet-stream
assert_eq!(
from_path("unknown.xyz").first_or_octet_stream().to_string(),
"chemical/x-xyz"
);
// Test no extension falls back to octet-stream
assert_eq!(
from_path("file_without_extension")
.first_or_octet_stream()
.to_string(),
"application/octet-stream"
);
// Test truly unknown extension
assert_eq!(
from_path("unknown.unknown123")
.first_or_octet_stream()
.to_string(),
"application/octet-stream"
);
}
}

View File

@@ -544,16 +544,12 @@ impl InputState {
/// Set the text of the input field.
///
/// And the selection_range will be reset to 0..0.
pub fn set_value(
&mut self,
value: impl Into<SharedString>,
window: &mut Window,
cx: &mut Context<Self>,
) {
pub fn set_value<T>(&mut self, value: T, window: &mut Window, cx: &mut Context<Self>)
where
T: Into<SharedString>,
{
self.history.ignore = true;
let was_disabled = self.disabled;
self.replace_text(value, window, cx);
self.disabled = was_disabled;
self.history.ignore = false;
// Ensure cursor to start when set text
@@ -565,48 +561,50 @@ impl InputState {
// Move scroll to top
self.scroll_handle.set_offset(point(px(0.), px(0.)));
cx.notify();
}
/// Insert text at the current cursor position.
///
/// And the cursor will be moved to the end of inserted text.
pub fn insert(
&mut self,
text: impl Into<SharedString>,
window: &mut Window,
cx: &mut Context<Self>,
) {
pub fn insert<T>(&mut self, text: T, window: &mut Window, cx: &mut Context<Self>)
where
T: Into<SharedString>,
{
let was_disabled = self.disabled;
self.disabled = false;
let text: SharedString = text.into();
let range_utf16 = self.range_to_utf16(&(self.cursor()..self.cursor()));
self.replace_text_in_range_silent(Some(range_utf16), &text, window, cx);
self.selected_range = (self.selected_range.end..self.selected_range.end).into();
self.disabled = was_disabled;
}
/// Replace text at the current cursor position.
///
/// And the cursor will be moved to the end of replaced text.
pub fn replace(
&mut self,
text: impl Into<SharedString>,
window: &mut Window,
cx: &mut Context<Self>,
) {
pub fn replace<T>(&mut self, text: T, window: &mut Window, cx: &mut Context<Self>)
where
T: Into<SharedString>,
{
let was_disabled = self.disabled;
self.disabled = false;
let text: SharedString = text.into();
self.replace_text_in_range_silent(None, &text, window, cx);
self.selected_range = (self.selected_range.end..self.selected_range.end).into();
self.disabled = was_disabled;
}
fn replace_text(
&mut self,
text: impl Into<SharedString>,
window: &mut Window,
cx: &mut Context<Self>,
) {
fn replace_text<T>(&mut self, text: T, window: &mut Window, cx: &mut Context<Self>)
where
T: Into<SharedString>,
{
let was_disabled = self.disabled;
self.disabled = false;
let text: SharedString = text.into();
let range = 0..self.text.chars().map(|c| c.len_utf16()).sum();
self.replace_text_in_range_silent(Some(range), &text, window, cx);
self.disabled = was_disabled;
}
/// Set with password masked state.

View File

@@ -93,8 +93,7 @@ impl RenderOnce for MenuItemElement {
.id(self.id)
.group(&self.group_name)
.gap_x_1()
.py_1()
.px_2()
.p_1()
.text_sm()
.text_color(cx.theme().text)
.relative()

View File

@@ -1073,7 +1073,7 @@ impl PopupMenu {
let selected = self.selected_index == Some(ix);
const EDGE_PADDING: Pixels = px(4.);
const INNER_PADDING: Pixels = px(8.);
const INNER_PADDING: Pixels = px(4.);
let is_submenu = matches!(item, PopupMenuItem::Submenu { .. });
let group_name = format!("{}:item-{}", cx.entity().entity_id(), ix);
@@ -1143,7 +1143,7 @@ impl PopupMenu {
.flex_1()
.min_h(item_height)
.items_center()
.gap_x_1()
.gap_x_2()
.children(Self::render_icon(
has_left_icon,
is_left_check,