selection and archive

This commit is contained in:
Bendt
2025-12-18 13:44:17 -05:00
parent 8244bd94c9
commit 4a21eef6f8
6 changed files with 280 additions and 51 deletions

BIN
.coverage

Binary file not shown.

View File

@@ -3,6 +3,7 @@ from .message_store import MessageStore
from .widgets.ContentContainer import ContentContainer from .widgets.ContentContainer import ContentContainer
from .widgets.EnvelopeListItem import EnvelopeListItem, GroupHeader from .widgets.EnvelopeListItem import EnvelopeListItem, GroupHeader
from .screens.LinkPanel import LinkPanel from .screens.LinkPanel import LinkPanel
from .screens.ConfirmDialog import ConfirmDialog
from .actions.task import action_create_task from .actions.task import action_create_task
from .actions.open import action_open from .actions.open import action_open
from .actions.delete import delete_current from .actions.delete import delete_current
@@ -116,10 +117,11 @@ class EmailViewerApp(App):
BINDINGS.extend( BINDINGS.extend(
[ [
Binding("space", "scroll_page_down", "Scroll page down"), Binding("pagedown", "scroll_page_down", "Scroll page down"),
Binding("b", "scroll_page_up", "Scroll page up"), Binding("b", "scroll_page_up", "Scroll page up"),
Binding("s", "toggle_sort_order", "Toggle Sort Order"), Binding("s", "toggle_sort_order", "Toggle Sort Order"),
Binding("x", "toggle_selection", "Toggle selection"), Binding("x", "toggle_selection", "Toggle selection", show=False),
Binding("space", "toggle_selection", "Toggle selection"),
Binding("escape", "clear_selection", "Clear selection"), Binding("escape", "clear_selection", "Clear selection"),
] ]
) )
@@ -298,6 +300,16 @@ class EmailViewerApp(App):
# self.current_message_id = message_id # self.current_message_id = message_id
self.highlighted_message_index = highlighted_index self.highlighted_message_index = highlighted_index
def on_key(self, event) -> None:
"""Handle key events to intercept space on the envelopes list."""
# Intercept space key when envelopes list is focused to prevent default select behavior
if event.key == "space":
focused = self.focused
if focused and focused.id == "envelopes_list":
event.prevent_default()
event.stop()
self.action_toggle_selection()
@work(exclusive=False) @work(exclusive=False)
async def fetch_envelopes(self) -> None: async def fetch_envelopes(self) -> None:
msglist = self.query_one("#envelopes_list", ListView) msglist = self.query_one("#envelopes_list", ListView)
@@ -476,26 +488,108 @@ class EmailViewerApp(App):
self.current_message_index = prev_idx self.current_message_index = prev_idx
async def action_delete(self) -> None: async def action_delete(self) -> None:
"""Delete the current message and update UI consistently.""" """Delete the current or selected messages."""
# Call the delete_current function which uses our Himalaya client module if self.selected_messages:
# --- Multi-message delete: show confirmation ---
count = len(self.selected_messages)
def do_delete(confirmed: bool) -> None:
if confirmed:
self.run_worker(self._delete_selected_messages())
self.push_screen(
ConfirmDialog(
title="Delete Messages",
message=f"Delete {count} selected message{'s' if count > 1 else ''}?",
confirm_label="Delete",
cancel_label="Cancel",
),
do_delete,
)
else:
# --- Single message delete (no confirmation needed) ---
worker = delete_current(self) worker = delete_current(self)
await worker.wait() await worker.wait()
async def action_archive(self) -> None: async def _delete_selected_messages(self) -> None:
"""Archive the current or selected messages and update UI consistently.""" """Delete all selected messages."""
message_ids_to_delete = list(self.selected_messages)
next_id_to_select = None next_id_to_select = None
if message_ids_to_delete:
highest_deleted_id = max(message_ids_to_delete)
metadata = self.message_store.get_metadata(highest_deleted_id)
if metadata:
next_id, _ = self.message_store.find_next_valid_id(metadata["index"])
if next_id is None:
next_id, _ = self.message_store.find_prev_valid_id(
metadata["index"]
)
next_id_to_select = next_id
# Delete each message
success_count = 0
for mid in message_ids_to_delete:
message, success = await himalaya_client.delete_message(mid)
if success:
success_count += 1
else:
self.show_status(f"Failed to delete message {mid}: {message}", "error")
if success_count > 0:
self.show_status(
f"Deleted {success_count} message{'s' if success_count > 1 else ''}"
)
self.selected_messages.clear()
self.refresh_list_view_items()
# Refresh the envelope list
worker = self.fetch_envelopes()
await worker.wait()
# After refresh, select the next message
if next_id_to_select:
new_metadata = self.message_store.get_metadata(next_id_to_select)
if new_metadata:
self.current_message_id = next_id_to_select
else:
self.action_oldest()
else:
self.action_oldest()
async def action_archive(self) -> None:
"""Archive the current or selected messages and update UI consistently."""
if self.selected_messages: if self.selected_messages:
# --- Multi-message archive --- # --- Multi-message archive: show confirmation ---
count = len(self.selected_messages)
def do_archive(confirmed: bool) -> None:
if confirmed:
self.run_worker(self._archive_selected_messages())
self.push_screen(
ConfirmDialog(
title="Archive Messages",
message=f"Archive {count} selected message{'s' if count > 1 else ''}?",
confirm_label="Archive",
cancel_label="Cancel",
),
do_archive,
)
else:
# --- Single message archive (no confirmation needed) ---
await self._archive_single_message()
async def _archive_selected_messages(self) -> None:
"""Archive all selected messages."""
message_ids_to_archive = list(self.selected_messages) message_ids_to_archive = list(self.selected_messages)
next_id_to_select = None
if message_ids_to_archive: if message_ids_to_archive:
highest_archived_id = max(message_ids_to_archive) highest_archived_id = max(message_ids_to_archive)
metadata = self.message_store.get_metadata(highest_archived_id) metadata = self.message_store.get_metadata(highest_archived_id)
if metadata: if metadata:
next_id, _ = self.message_store.find_next_valid_id( next_id, _ = self.message_store.find_next_valid_id(metadata["index"])
metadata["index"]
)
if next_id is None: if next_id is None:
next_id, _ = self.message_store.find_prev_valid_id( next_id, _ = self.message_store.find_prev_valid_id(
metadata["index"] metadata["index"]
@@ -507,14 +601,31 @@ class EmailViewerApp(App):
) )
if success: if success:
self.show_status(message or "Success archived") self.show_status(
message or f"Archived {len(message_ids_to_archive)} messages"
)
self.selected_messages.clear() self.selected_messages.clear()
self.refresh_list_view_items()
else: else:
self.show_status(f"Failed to archive messages: {message}", "error") self.show_status(f"Failed to archive messages: {message}", "error")
return return
# Refresh the envelope list
worker = self.fetch_envelopes()
await worker.wait()
# After refresh, select the next message
if next_id_to_select:
new_metadata = self.message_store.get_metadata(next_id_to_select)
if new_metadata:
self.current_message_id = next_id_to_select
else: else:
# --- Single message archive --- self.action_oldest()
else:
self.action_oldest()
async def _archive_single_message(self) -> None:
"""Archive the current single message."""
if not self.current_message_id: if not self.current_message_id:
self.show_status("No message selected to archive.", "error") self.show_status("No message selected to archive.", "error")
return return

View File

@@ -66,8 +66,9 @@ class KeybindingsConfig(BaseModel):
create_task: str = "t" create_task: str = "t"
reload: str = "%" reload: str = "%"
toggle_sort: str = "s" toggle_sort: str = "s"
toggle_selection: str = "x" toggle_selection: str = "space"
clear_selection: str = "escape" clear_selection: str = "escape"
scroll_page_down: str = "pagedown"
scroll_page_down: str = "space" scroll_page_down: str = "space"
scroll_page_up: str = "b" scroll_page_up: str = "b"
toggle_main_content: str = "w" toggle_main_content: str = "w"

View File

@@ -0,0 +1,108 @@
"""Confirmation dialog screen for destructive actions."""
from textual import on
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical, Container
from textual.screen import ModalScreen
from textual.widgets import Button, Label, Static
class ConfirmDialog(ModalScreen[bool]):
"""A modal confirmation dialog that returns True if confirmed, False otherwise."""
BINDINGS = [
Binding("escape", "cancel", "Cancel"),
Binding("enter", "confirm", "Confirm"),
Binding("y", "confirm", "Yes"),
Binding("n", "cancel", "No"),
]
DEFAULT_CSS = """
ConfirmDialog {
align: center middle;
}
ConfirmDialog #confirm-container {
width: 50;
height: auto;
min-height: 7;
background: $surface;
border: thick $primary;
padding: 1 2;
}
ConfirmDialog #confirm-title {
text-style: bold;
width: 1fr;
height: 1;
text-align: center;
margin-bottom: 1;
}
ConfirmDialog #confirm-message {
width: 1fr;
height: 1;
text-align: center;
margin-bottom: 1;
}
ConfirmDialog #confirm-buttons {
width: 1fr;
height: 3;
align: center middle;
margin-top: 1;
}
ConfirmDialog Button {
margin: 0 1;
}
"""
def __init__(
self,
title: str = "Confirm",
message: str = "Are you sure?",
confirm_label: str = "Yes",
cancel_label: str = "No",
**kwargs,
):
"""Initialize the confirmation dialog.
Args:
title: The dialog title
message: The confirmation message
confirm_label: Label for the confirm button
cancel_label: Label for the cancel button
"""
super().__init__(**kwargs)
self._title = title
self._message = message
self._confirm_label = confirm_label
self._cancel_label = cancel_label
def compose(self) -> ComposeResult:
with Container(id="confirm-container"):
yield Label(self._title, id="confirm-title")
yield Label(self._message, id="confirm-message")
with Horizontal(id="confirm-buttons"):
yield Button(self._cancel_label, id="cancel", variant="default")
yield Button(self._confirm_label, id="confirm", variant="error")
def on_mount(self) -> None:
"""Focus the cancel button by default (safer option)."""
self.query_one("#cancel", Button).focus()
@on(Button.Pressed, "#confirm")
def handle_confirm(self) -> None:
self.dismiss(True)
@on(Button.Pressed, "#cancel")
def handle_cancel(self) -> None:
self.dismiss(False)
def action_confirm(self) -> None:
self.dismiss(True)
def action_cancel(self) -> None:
self.dismiss(False)

View File

@@ -3,6 +3,7 @@ from .CreateTask import CreateTaskScreen
from .OpenMessage import OpenMessageScreen from .OpenMessage import OpenMessageScreen
from .DocumentViewer import DocumentViewerScreen from .DocumentViewer import DocumentViewerScreen
from .LinkPanel import LinkPanel, LinkItem, extract_links_from_content from .LinkPanel import LinkPanel, LinkItem, extract_links_from_content
from .ConfirmDialog import ConfirmDialog
__all__ = [ __all__ = [
"CreateTaskScreen", "CreateTaskScreen",
@@ -11,4 +12,5 @@ __all__ = [
"LinkPanel", "LinkPanel",
"LinkItem", "LinkItem",
"extract_links_from_content", "extract_links_from_content",
"ConfirmDialog",
] ]

View File

@@ -54,18 +54,19 @@ class EnvelopeListItem(Static):
EnvelopeListItem .sender-name { EnvelopeListItem .sender-name {
width: 1fr; width: 1fr;
color: $text-muted;
} }
EnvelopeListItem .message-datetime { EnvelopeListItem .message-datetime {
width: auto; width: auto;
padding: 0 1; padding: 0 1;
color: $text-muted; color: $text-disabled;
} }
EnvelopeListItem .email-subject { EnvelopeListItem .email-subject {
width: 1fr; width: 1fr;
padding: 0 3; padding: 0 3;
text-style: bold; color: $text-muted;
} }
EnvelopeListItem .email-preview { EnvelopeListItem .email-preview {
@@ -76,10 +77,16 @@ class EnvelopeListItem(Static):
EnvelopeListItem.unread .sender-name { EnvelopeListItem.unread .sender-name {
text-style: bold; text-style: bold;
color: $text;
}
EnvelopeListItem.unread .message-datetime {
color: $text-muted;
} }
EnvelopeListItem.unread .email-subject { EnvelopeListItem.unread .email-subject {
text-style: bold; text-style: bold;
color: $text;
} }
""" """