diff --git a/src/gui/board.rs b/src/gui/board.rs index 4c515d5..8a2b079 100644 --- a/src/gui/board.rs +++ b/src/gui/board.rs @@ -3,6 +3,7 @@ pub mod actions; mod clear_confirm; mod clipboard_ops; mod color; +mod delete_confirm; pub(super) mod filtering; mod header; mod hotkey_record_handler; @@ -105,11 +106,19 @@ pub(crate) enum ClearConfirmState { Visible, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) enum DeleteConfirmState { + #[default] + Hidden, + Visible, +} + #[derive(Debug, Clone, Copy, Default)] pub(crate) struct UiState { pub(crate) preview: PreviewState, pub(crate) deletion: DeletionState, pub(crate) clear_confirm: ClearConfirmState, + pub(crate) delete_confirm: DeleteConfirmState, } impl UiState { @@ -121,6 +130,14 @@ impl UiState { matches!(self.clear_confirm, ClearConfirmState::Visible) } + pub(crate) const fn delete_confirm_visible(self) -> bool { + matches!(self.delete_confirm, DeleteConfirmState::Visible) + } + + pub(crate) const fn any_overlay_visible(self) -> bool { + self.clear_confirm_visible() || self.delete_confirm_visible() + } + pub(crate) const fn preview_visible(self) -> bool { matches!(self.preview, PreviewState::Visible) } @@ -178,6 +195,8 @@ pub(crate) struct RopyBoard { pub(crate) update_manager: UpdateManager, /// Which clear action is currently awaiting confirmation clear_confirm_action: ClearConfirmAction, + /// Record ID awaiting single-record delete confirmation + pending_delete_id: Option, pub(crate) filter_state: FilterState, } @@ -484,6 +503,7 @@ impl RopyBoard { hotkey_tx: None, update_manager: UpdateManager::new(), clear_confirm_action: ClearConfirmAction::AllHistory, + pending_delete_id: None, filter_state: FilterState::default(), } } diff --git a/src/gui/board/actions.rs b/src/gui/board/actions.rs index ec7b985..408c2d1 100644 --- a/src/gui/board/actions.rs +++ b/src/gui/board/actions.rs @@ -209,6 +209,10 @@ impl RopyBoard { window: &mut Window, cx: &mut Context<'_, Self>, ) { + if self.ui_state.delete_confirm_visible() { + self.confirm_pending_delete(cx); + return; + } self.confirm_record(window, cx, self.selected_index); } @@ -227,16 +231,15 @@ impl RopyBoard { _window: &mut Window, cx: &mut Context<'_, Self>, ) { + if self.ui_state.delete_confirm_visible() { + return; + } if let Some(id) = self.filtered_record_id_at(self.selected_index) { - self.delete_record(id, cx); - // Clamp selected_index after deletion - if self.selected_index > 0 - && self.selected_index >= self.filtered_record_len().saturating_sub(1) - { - self.selected_index -= 1; + let showed_confirm = self.request_delete_record(id, cx); + if !showed_confirm { + self.clamp_selection_after_delete(); + cx.notify(); } - self.reveal_selected_record(); - cx.notify(); } } @@ -273,6 +276,11 @@ impl RopyBoard { return; } + if self.ui_state.delete_confirm_visible() { + self.cancel_pending_delete(cx); + return; + } + if self.ui_state.clear_confirm_visible() { self.ui_state.clear_confirm = crate::gui::board::ClearConfirmState::Hidden; cx.notify(); @@ -331,6 +339,16 @@ impl RopyBoard { window: &mut Window, cx: &mut Context<'_, Self>, ) { + // Handle delete-confirm dialog keys before the general ignore guard + if self.ui_state.delete_confirm_visible() { + match event.keystroke.key.as_str() { + "d" | "enter" => self.confirm_pending_delete(cx), + "escape" => self.cancel_pending_delete(cx), + _ => {} + } + return; + } + if self.should_ignore_board_key_event(window, cx) { return; } @@ -408,7 +426,7 @@ impl RopyBoard { } fn should_ignore_board_key_event(&self, window: &Window, cx: &Context<'_, Self>) -> bool { - if self.ui_state.clear_confirm_visible() { + if self.ui_state.any_overlay_visible() { return true; } diff --git a/src/gui/board/delete_confirm.rs b/src/gui/board/delete_confirm.rs new file mode 100644 index 0000000..4be0fd8 --- /dev/null +++ b/src/gui/board/delete_confirm.rs @@ -0,0 +1,82 @@ +use gpui::{ + Context, div, + prelude::{InteractiveElement, IntoElement, ParentElement, StatefulInteractiveElement, Styled}, + px, +}; +use gpui_component::{ + ActiveTheme, Sizable, + button::{Button, ButtonVariants}, + h_flex, v_flex, +}; + +use super::RopyBoard; +use crate::i18n::I18n; + +pub(super) fn render_delete_confirm_overlay(cx: &Context<'_, RopyBoard>) -> impl IntoElement { + let title = I18n::translate(cx, "delete_confirm_title"); + let message = I18n::translate(cx, "delete_confirm_message"); + let cancel_label = I18n::translate(cx, "delete_confirm_cancel"); + let confirm_label = I18n::translate(cx, "delete_confirm_button"); + + div() + .absolute() + .top_0() + .left_0() + .size_full() + .bg(gpui::rgba(0x0000_0050)) + .flex() + .items_center() + .justify_center() + .id("delete-confirm-backdrop") + .on_click(cx.listener(|this, _, _, cx| { + this.cancel_pending_delete(cx); + })) + .child( + v_flex() + .w(px(300.0)) + .p_5() + .bg(cx.theme().background) + .border_1() + .border_color(cx.theme().border) + .rounded_lg() + .gap_3() + .id("delete-confirm-card") + .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child( + div() + .text_base() + .font_weight(gpui::FontWeight::BOLD) + .text_color(cx.theme().foreground) + .child(title), + ) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(message), + ) + .child( + h_flex() + .justify_end() + .gap_2() + .child( + Button::new("delete-confirm-cancel") + .small() + .ghost() + .label(cancel_label) + .on_click(cx.listener(|this, _, _, cx| { + this.cancel_pending_delete(cx); + })), + ) + .child( + Button::new("delete-confirm-ok") + .small() + .danger() + .label(confirm_label) + .on_click(cx.listener(|this, _, _, cx| { + this.confirm_pending_delete(cx); + })), + ), + ), + ) +} diff --git a/src/gui/board/record_ops.rs b/src/gui/board/record_ops.rs index 20ab299..9824277 100644 --- a/src/gui/board/record_ops.rs +++ b/src/gui/board/record_ops.rs @@ -178,6 +178,56 @@ impl RopyBoard { }); } + /// Returns true if the record is pinned or favorited (i.e. non-ordinary). + fn is_record_special(&self, id: u64) -> bool { + self.favorite_ids.contains(&id) + || read_or_recover(&self.records) + .iter() + .any(|r| r.id == id && r.pinned) + } + + /// Requests deletion of a record. If the record is non-ordinary (pinned or + /// favorited), a confirmation dialog is shown first. + /// Returns `true` when a confirmation dialog was shown (deletion deferred). + pub(crate) fn request_delete_record(&mut self, id: u64, cx: &mut Context<'_, Self>) -> bool { + if self.is_record_special(id) { + self.pending_delete_id = Some(id); + self.ui_state.delete_confirm = crate::gui::board::DeleteConfirmState::Visible; + cx.notify(); + true + } else { + self.delete_record(id, cx); + false + } + } + + /// Clamps `selected_index` to stay within bounds after a record deletion. + pub(crate) fn clamp_selection_after_delete(&mut self) { + if self.selected_index > 0 + && self.selected_index >= self.filtered_record_len().saturating_sub(1) + { + self.selected_index -= 1; + } + self.reveal_selected_record(); + } + + /// Confirms and executes the pending single-record deletion. + pub(crate) fn confirm_pending_delete(&mut self, cx: &mut Context<'_, Self>) { + if let Some(id) = self.pending_delete_id.take() { + self.delete_record(id, cx); + self.clamp_selection_after_delete(); + } + self.ui_state.delete_confirm = crate::gui::board::DeleteConfirmState::Hidden; + cx.notify(); + } + + /// Cancels the pending single-record delete confirmation. + pub(crate) fn cancel_pending_delete(&mut self, cx: &mut Context<'_, Self>) { + self.pending_delete_id = None; + self.ui_state.delete_confirm = crate::gui::board::DeleteConfirmState::Hidden; + cx.notify(); + } + pub(crate) fn toggle_record_favorite(&mut self, id: u64, cx: &Context<'_, Self>) { GlobalRepository::read(cx, |repo| { let Some(repo) = repo else { diff --git a/src/gui/board/records_list/row.rs b/src/gui/board/records_list/row.rs index 0e6a475..bf02c0d 100644 --- a/src/gui/board/records_list/row.rs +++ b/src/gui/board/records_list/row.rs @@ -2,7 +2,10 @@ use std::{collections::HashSet, path::PathBuf, sync::Arc}; use gpui::{ AnyElement, AnyView, App, Context, Window, anchored, deferred, div, img, - prelude::{InteractiveElement, IntoElement, ParentElement, StatefulInteractiveElement, Styled}, + prelude::{ + FluentBuilder, InteractiveElement, IntoElement, ParentElement, StatefulInteractiveElement, + Styled, + }, px, }; use gpui_component::{ @@ -322,6 +325,7 @@ pub(super) struct RenderContext<'a> { flags: RenderFlags, layout_mode: LayoutMode, opacity_percent: u8, + overlay_visible: bool, view: &'a gpui::WeakEntity, } @@ -333,12 +337,14 @@ pub(super) struct RecordsListState { layout_mode: LayoutMode, show_preview: bool, hover_preview_enabled: bool, + overlay_visible: bool, opacity_percent: u8, pub(super) view: gpui::WeakEntity, } impl RecordsListState { pub(super) fn from_board(board: &RopyBoard, context: &Context<'_, RopyBoard>) -> Self { + let overlay_visible = board.ui_state.any_overlay_visible(); Self { filtered_record_indices: board.filtered_record_indices.clone(), records: board.records.clone(), @@ -347,7 +353,8 @@ impl RecordsListState { layout_mode: board.layout_mode, show_preview: board.ui_state.preview_visible(), hover_preview_enabled: board.settings_editor.panel_state.hover_preview_enabled - && !board.ui_state.clear_confirm_visible(), + && !overlay_visible, + overlay_visible, opacity_percent: board.settings_editor.window_opacity_percent, view: context.weak_entity(), } @@ -403,6 +410,7 @@ impl RecordsListState { }, layout_mode: self.layout_mode, opacity_percent: self.opacity_percent, + overlay_visible: self.overlay_visible, view: &self.view, } } @@ -575,8 +583,7 @@ fn render_record_actions(ctx: &RenderContext<'_>) -> AnyElement { .on_click(move |_event, _window, cx| { view_delete .update(cx, |this, cx| { - this.delete_record(record_id, cx); - cx.notify(); + this.request_delete_record(record_id, cx); }) .ok(); }); @@ -627,6 +634,7 @@ fn decorate_record_card( ) -> AnyElement { let view_click = ctx.view.clone(); let index = ctx.index; + let overlay_visible = ctx.overlay_visible; card.bg(styles.normal_background) .rounded_md() @@ -636,18 +644,23 @@ fn decorate_record_card( styles.border }) .border_1() - .hover(move |style| { - if ctx.flags.is_selected() { - style - } else { - style - .bg(styles.selected_background) - .border_color(styles.selected_background) - } + .when(!overlay_visible, |el| { + el.hover(move |style| { + if ctx.flags.is_selected() { + style + } else { + style + .bg(styles.selected_background) + .border_color(styles.selected_background) + } + }) + .cursor_pointer() }) - .cursor_pointer() .id(("record", ctx.index)) .on_click(move |event, window, cx| { + if overlay_visible { + return; + } let confirm_as_plain_text = event.modifiers().shift; view_click .update(cx, |this, cx| {