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.EnvelopeListItem import EnvelopeListItem, GroupHeader
from .screens.LinkPanel import LinkPanel
from .screens.ConfirmDialog import ConfirmDialog
from .actions.task import action_create_task
from .actions.open import action_open
from .actions.delete import delete_current
@@ -116,10 +117,11 @@ class EmailViewerApp(App):
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("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"),
]
)
@@ -298,6 +300,16 @@ class EmailViewerApp(App):
# self.current_message_id = message_id
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)
async def fetch_envelopes(self) -> None:
msglist = self.query_one("#envelopes_list", ListView)
@@ -476,66 +488,165 @@ class EmailViewerApp(App):
self.current_message_index = prev_idx
async def action_delete(self) -> None:
"""Delete the current message and update UI consistently."""
# Call the delete_current function which uses our Himalaya client module
worker = delete_current(self)
"""Delete the current or selected messages."""
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)
await worker.wait()
async def _delete_selected_messages(self) -> None:
"""Delete all selected messages."""
message_ids_to_delete = list(self.selected_messages)
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:
# --- 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)
next_id_to_select = None
if self.selected_messages:
# --- Multi-message archive ---
message_ids_to_archive = list(self.selected_messages)
if message_ids_to_archive:
highest_archived_id = max(message_ids_to_archive)
metadata = self.message_store.get_metadata(highest_archived_id)
if metadata:
next_id, _ = self.message_store.find_next_valid_id(
if message_ids_to_archive:
highest_archived_id = max(message_ids_to_archive)
metadata = self.message_store.get_metadata(highest_archived_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"]
)
if next_id is None:
next_id, _ = self.message_store.find_prev_valid_id(
metadata["index"]
)
next_id_to_select = next_id
next_id_to_select = next_id
message, success = await himalaya_client.archive_messages(
[str(mid) for mid in message_ids_to_archive]
message, success = await himalaya_client.archive_messages(
[str(mid) for mid in message_ids_to_archive]
)
if success:
self.show_status(
message or f"Archived {len(message_ids_to_archive)} messages"
)
if success:
self.show_status(message or "Success archived")
self.selected_messages.clear()
else:
self.show_status(f"Failed to archive messages: {message}", "error")
return
self.selected_messages.clear()
self.refresh_list_view_items()
else:
# --- Single message archive ---
if not self.current_message_id:
self.show_status("No message selected to archive.", "error")
return
self.show_status(f"Failed to archive messages: {message}", "error")
return
current_id = self.current_message_id
current_idx = self.current_message_index
# Refresh the envelope list
worker = self.fetch_envelopes()
await worker.wait()
next_id, _ = self.message_store.find_next_valid_id(current_idx)
if next_id is None:
next_id, _ = self.message_store.find_prev_valid_id(current_idx)
next_id_to_select = next_id
message, success = await himalaya_client.archive_messages([str(current_id)])
if success:
self.show_status(message or "Archived")
# 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.show_status(
f"Failed to archive message {current_id}: {message}", "error"
)
return
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:
self.show_status("No message selected to archive.", "error")
return
current_id = self.current_message_id
current_idx = self.current_message_index
next_id, _ = self.message_store.find_next_valid_id(current_idx)
if next_id is None:
next_id, _ = self.message_store.find_prev_valid_id(current_idx)
next_id_to_select = next_id
message, success = await himalaya_client.archive_messages([str(current_id)])
if success:
self.show_status(message or "Archived")
else:
self.show_status(
f"Failed to archive message {current_id}: {message}", "error"
)
return
# Refresh the envelope list
worker = self.fetch_envelopes()

View File

@@ -66,8 +66,9 @@ class KeybindingsConfig(BaseModel):
create_task: str = "t"
reload: str = "%"
toggle_sort: str = "s"
toggle_selection: str = "x"
toggle_selection: str = "space"
clear_selection: str = "escape"
scroll_page_down: str = "pagedown"
scroll_page_down: str = "space"
scroll_page_up: str = "b"
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 .DocumentViewer import DocumentViewerScreen
from .LinkPanel import LinkPanel, LinkItem, extract_links_from_content
from .ConfirmDialog import ConfirmDialog
__all__ = [
"CreateTaskScreen",
@@ -11,4 +12,5 @@ __all__ = [
"LinkPanel",
"LinkItem",
"extract_links_from_content",
"ConfirmDialog",
]

View File

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