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

@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="7.75" r="4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M12.0014 12.25C7.80812 12.25 5.3732 14.9227 4.69664 18.2626C4.47735 19.3452 5.39684 20.25 6.50141 20.25H10.2515" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="19.0992" cy="15.4" r="0.9" fill="currentColor"/><path d="M18.8496 12.5498C20.5617 12.5498 21.9502 13.9383 21.9502 15.6504C21.95 17.3623 20.5616 18.75 18.8496 18.75C18.5179 18.75 18.199 18.6961 17.8994 18.5996L15.75 20.75H13.75V18.75L15.8994 16.5996C15.8032 16.3004 15.75 15.9816 15.75 15.6504C15.75 13.9384 17.1377 12.55 18.8496 12.5498Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
<path d="M12.9996 12.8145C12.675 12.7719 12.3415 12.75 11.9996 12.75C8.55174 12.75 5.94978 14.981 4.9305 18.114C4.56744 19.23 5.50919 20.25 6.68275 20.25H13.9996M15.7496 6.5C15.7496 8.57107 14.0706 10.25 11.9996 10.25C9.92851 10.25 8.24958 8.57107 8.24958 6.5C8.24958 4.42893 9.92851 2.75 11.9996 2.75C14.0706 2.75 15.7496 4.42893 15.7496 6.5ZM15.7496 14C15.7496 12.7574 16.7569 11.75 17.9996 11.75C19.2422 11.75 20.2496 12.7574 20.2496 14C20.2496 14.7801 19.8526 15.4675 19.2496 15.8711V17L18.7496 17.9356L19.2496 18.9678V20L17.9996 21L16.7496 20V15.8711C16.1466 15.4675 15.7496 14.7801 15.7496 14Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 832 B

After

Width:  |  Height:  |  Size: 771 B

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,