diff --git a/src/maildir_gtd/actions/archive.py b/src/maildir_gtd/actions/archive.py index d7927b6..11ddcb2 100644 --- a/src/maildir_gtd/actions/archive.py +++ b/src/maildir_gtd/actions/archive.py @@ -28,7 +28,7 @@ async def archive_current(app): if success: app.show_status(f"Message {current_message_id} archived.", "success") app.message_store.remove_envelope(current_message_id) - app.refresh_list_view() + app.refresh_list_view_items() # Select the next available message if it exists if next_id is not None and next_idx is not None: diff --git a/src/maildir_gtd/app.py b/src/maildir_gtd/app.py index b431ec0..b9a3189 100644 --- a/src/maildir_gtd/app.py +++ b/src/maildir_gtd/app.py @@ -68,6 +68,7 @@ class EmailViewerApp(App): total_messages: Reactive[int] = reactive(0) status_title = reactive("Message View") sort_order_ascending: Reactive[bool] = reactive(True) + selected_messages: Reactive[set[int]] = reactive(set()) def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]: yield from super().get_system_commands(screen) @@ -117,6 +118,8 @@ class EmailViewerApp(App): Binding("space", "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("escape", "clear_selection", "Clear selection"), ] ) @@ -158,7 +161,9 @@ class EmailViewerApp(App): self.action_oldest() def compute_status_title(self): - return f"✉️ Message ID: {self.current_message_id} " + metadata = self.message_store.get_metadata(self.current_message_id) + message_date = metadata["date"] if metadata else "N/A" + return f"✉️ Message ID: {self.current_message_id} | Date: {message_date}" def watch_status_title(self, old_status_title: str, new_status_title: str) -> None: self.query_one(ContentContainer).border_title = new_status_title @@ -176,11 +181,23 @@ class EmailViewerApp(App): if new_index > self.total_messages: new_index = self.total_messages self.current_message_index = new_index - self.query_one( - "#envelopes_list" - ).border_subtitle = f"[b]{new_index}[/b]/{self.total_messages}" + + self._update_list_view_subtitle() self.query_one("#envelopes_list").index = new_index + def watch_selected_messages(self, old_messages: set[int], new_messages: set[int]) -> None: + self._update_list_view_subtitle() + + def _update_list_view_subtitle(self) -> None: + subtitle = f"[b]{self.current_message_index}[/b]/{self.total_messages}" + if self.selected_messages: + subtitle = f"(✓{len(self.selected_messages)}) {subtitle}" + self.query_one("#envelopes_list").border_subtitle = subtitle + + def watch_total_messages(self, old_total: int, new_total: int) -> None: + """Called when the total_messages reactive attribute changes.""" + self._update_list_view_subtitle() + def watch_reload_needed( self, old_reload_needed: bool, new_reload_needed: bool ) -> None: @@ -221,7 +238,7 @@ class EmailViewerApp(App): # cc=metadata["cc"].get("addr", "") if "cc" in metadata else "", # ) - list_view = self.query_one("#envelopes_list") + list_view = self.query_one("#envelopes_list", ListView) if list_view.index != metadata["index"]: list_view.index = metadata["index"] else: @@ -230,18 +247,39 @@ class EmailViewerApp(App): def on_list_view_selected(self, event: ListView.Selected) -> None: """Called when an item in the list view is selected.""" - current_item = self.message_store.envelopes[event.list_view.index] + if event.list_view.index is None: + return + + selected_index = event.list_view.index + + current_item = self.message_store.envelopes[selected_index] if current_item is None or current_item.get("type") == "header": return message_id = int(current_item["id"]) self.current_message_id = message_id - self.current_message_index = event.list_view.index + self.current_message_index = selected_index + + def on_list_view_highlighted(self, event: ListView.Highlighted) -> None: + """Called when an item in the list view is highlighted (e.g., via arrow keys).""" + if event.list_view.index is None: + return + + highlighted_index = event.list_view.index + + current_item = self.message_store.envelopes[highlighted_index] + + if current_item is None or current_item.get("type") == "header": + return + + message_id = int(current_item["id"]) + self.current_message_id = message_id + self.current_message_index = highlighted_index @work(exclusive=False) async def fetch_envelopes(self) -> None: - msglist = self.query_one("#envelopes_list") + msglist = self.query_one("#envelopes_list", ListView) try: msglist.loading = True @@ -254,7 +292,7 @@ class EmailViewerApp(App): self.total_messages = self.message_store.total_messages # Use the centralized refresh method to update the ListView - self.refresh_list_view() + self._populate_list_view() # Restore the current index msglist.index = self.current_message_index @@ -321,35 +359,119 @@ class EmailViewerApp(App): finally: folders_list.loading = False - def refresh_list_view(self) -> None: - """Refresh the ListView to ensure it matches the MessageStore exactly.""" - envelopes_list = self.query_one("#envelopes_list") + def _populate_list_view(self) -> None: + """Populate the ListView with new items. This clears existing items.""" + envelopes_list = self.query_one("#envelopes_list", ListView) envelopes_list.clear() for item in self.message_store.envelopes: if item and item.get("type") == "header": envelopes_list.append( ListItem( - Label( - item["label"], - classes="group_header", - markup=False, + Horizontal( + Label("", classes="checkbox"), # Hidden checkbox for header + Label( + item["label"], + classes="group_header", + markup=False, + ), + classes="envelope_item_row" ) ) ) elif item: # Check if not None - envelopes_list.append( - ListItem( - Label( - str(item.get("subject", "")).strip(), - classes="email_subject", - markup=False, - ) + # Extract sender and date + sender_name = item.get("from", {}).get("name", item.get("from", {}).get("addr", "Unknown")) + if not sender_name: + sender_name = item.get("from", {}).get("addr", "Unknown") + + # Truncate sender name + max_sender_len = 25 # Adjust as needed + if len(sender_name) > max_sender_len: + sender_name = sender_name[:max_sender_len-3] + "..." + + message_date_str = item.get("date", "") + formatted_date = "" + if message_date_str: + try: + # Parse the date string, handling potential timezone info + dt_object = datetime.fromisoformat(message_date_str) + formatted_date = dt_object.strftime("%m/%d %H:%M") + except ValueError: + formatted_date = "Invalid Date" + + list_item = ListItem( + Vertical( + Horizontal( + Label("☐", classes="checkbox"), # Placeholder for checkbox + Label(sender_name, classes="sender_name"), + Label(formatted_date, classes="message_date"), + classes="envelope_header_row" + ), + Horizontal( + Label( + str(item.get("subject", "")).strip(), + classes="email_subject", + markup=False, + ) + ), + classes="envelope_item_row" ) ) + envelopes_list.append(list_item) + self.refresh_list_view_items() # Initial refresh of item states - # Update total messages count - self.total_messages = self.message_store.total_messages + def refresh_list_view_items(self) -> None: + """Update the visual state of existing ListItems without clearing the list.""" + envelopes_list = self.query_one("#envelopes_list", ListView) + for i, list_item in enumerate(envelopes_list.children): + if isinstance(list_item, ListItem): + item_data = self.message_store.envelopes[i] + + # Find the checkbox label within the ListItem's children + checkbox_label = None + for child in list_item.walk_children(): + if isinstance(child, Label) and "checkbox" in child.classes: + checkbox_label = child + break + + if checkbox_label: + if item_data and item_data.get("type") != "header": + message_id = int(item_data["id"]) + is_selected = message_id in self.selected_messages + + checkbox_label.update("\uf4a7" if is_selected else "\ue640") + checkbox_label.display = True # Always display checkbox + + list_item.highlighted = is_selected + + # Update sender and date labels + sender_name = item_data.get("from", {}).get("name", item_data.get("from", {}).get("addr", "Unknown")) + if not sender_name: + sender_name = item_data.get("from", {}).get("addr", "Unknown") + max_sender_len = 25 + if len(sender_name) > max_sender_len: + sender_name = sender_name[:max_sender_len-3] + "..." + list_item.query_one(".sender_name", Label).update(sender_name) + + message_date_str = item_data.get("date", "") + formatted_date = "" + if message_date_str: + try: + dt_object = datetime.fromisoformat(message_date_str) + formatted_date = dt_object.strftime("%m/%d %H:%M") + except ValueError: + formatted_date = "Invalid Date" + list_item.query_one(".message_date", Label).update(formatted_date) + + else: + # For header items, checkbox should be unchecked and visible + checkbox_label.update("\ue640") # Always unchecked for headers + checkbox_label.display = True # Always display checkbox + list_item.highlighted = False # Headers are never highlighted for selection + + # Update total messages count (this is still fine here) + # self.total_messages = self.message_store.total_messages def show_message(self, message_id: int, new_index=None) -> None: if new_index: @@ -390,8 +512,6 @@ class EmailViewerApp(App): self.current_message_id = next_id self.current_message_index = next_idx - self.fetch_envelopes() if self.reload_needed else None - def action_previous(self) -> None: if not self.current_message_index >= 0: return @@ -403,8 +523,6 @@ class EmailViewerApp(App): self.current_message_id = prev_id self.current_message_index = prev_idx - self.fetch_envelopes() if self.reload_needed else None - 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 @@ -413,9 +531,19 @@ class EmailViewerApp(App): async def action_archive(self) -> None: """Archive the current message and update UI consistently.""" - # Call the archive_current function which uses our Himalaya client module - worker = archive_current(self) - await worker.wait() + if self.selected_messages: + message_ids = [str(msg_id) for msg_id in self.selected_messages] + _, success = await himalaya_client.archive_messages(message_ids) + if success: + self.show_status(f"{len(message_ids)} messages archived.") + self.selected_messages.clear() + self.fetch_envelopes() + else: + self.show_status("Failed to archive messages.", "error") + else: + # Call the archive_current function which uses our Himalaya client module + worker = archive_current(self) + await worker.wait() def action_open(self) -> None: action_open(self) @@ -440,9 +568,38 @@ class EmailViewerApp(App): self.query_one("#main_content").scroll_page_up() def action_quit(self) -> None: - """Quit the application.""" self.exit() + def action_toggle_selection(self) -> None: + """Toggle selection for the current message.""" + current_item_data = self.message_store.envelopes[self.current_message_index] + if current_item_data and current_item_data.get("type") != "header": + message_id = int(current_item_data["id"]) + if message_id in self.selected_messages: + self.selected_messages.remove(message_id) + else: + self.selected_messages.add(message_id) + + # Manually update the current ListItem + envelopes_list = self.query_one("#envelopes_list", ListView) + current_list_item = envelopes_list.children[self.current_message_index] + if isinstance(current_list_item, ListItem): + checkbox_label = None + for child in current_list_item.walk_children(): + if isinstance(child, Label) and "checkbox" in child.classes: + checkbox_label = child + break + if checkbox_label: + checkbox_label.update("\uf4a7" if message_id in self.selected_messages else "\ue640") + current_list_item.highlighted = (message_id in self.selected_messages) + self._update_list_view_subtitle() + + def action_clear_selection(self) -> None: + """Clear all selected messages.""" + self.selected_messages.clear() + self.refresh_list_view_items() # Refresh all items to uncheck checkboxes + self._update_list_view_subtitle() + def action_oldest(self) -> None: self.fetch_envelopes() if self.reload_needed else None self.show_message(self.message_store.get_oldest_id()) diff --git a/src/maildir_gtd/email_viewer.tcss b/src/maildir_gtd/email_viewer.tcss index 6123de7..45662bd 100644 --- a/src/maildir_gtd/email_viewer.tcss +++ b/src/maildir_gtd/email_viewer.tcss @@ -68,7 +68,17 @@ Markdown { .email_subject { width: 1fr; - padding: 0 + padding: 0 2; + text-style: bold; +} + +.sender_name { + tint: gray 30%; +} + +.message_date { + padding: 0 2; + color: $secondary; } .header_key { @@ -103,7 +113,7 @@ Markdown { background: rgb(50, 50, 56); } & > ListItem { - &.-highlight { + &.-highlight, .selection { color: $block-cursor-blurred-foreground; background: $block-cursor-blurred-background; text-style: $block-cursor-blurred-text-style; @@ -111,6 +121,13 @@ Markdown { } } +.envelope_item_row { + height: auto; + width: 1fr; + Horizontal { + height: auto; + } +} #open_message_container, #create_task_container { border: panel $border; @@ -160,3 +177,6 @@ ContentContainer { width: 100%; height: 1fr; } +.checkbox { + padding-right: 1; +} diff --git a/src/services/himalaya/client.py b/src/services/himalaya/client.py index 72f3d16..9df2a94 100644 --- a/src/services/himalaya/client.py +++ b/src/services/himalaya/client.py @@ -140,6 +140,37 @@ async def archive_message(message_id: int) -> bool: return False +async def archive_messages(message_ids: List[str]) -> Tuple[Optional[str], bool]: + """ + Archive multiple messages by their IDs. + + Args: + message_ids: A list of message IDs to archive. + + Returns: + A tuple containing an optional output string and a boolean indicating success. + """ + try: + ids_str = " ".join(message_ids) + cmd = f"himalaya message move Archives {ids_str}" + + process = await asyncio.create_subprocess_shell( + cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + + if process.returncode == 0: + return stdout.decode(), True + else: + logging.error(f"Error archiving messages: {stderr.decode()}") + return None, False + except Exception as e: + logging.error(f"Exception during message archiving: {e}") + return None, False + + async def get_message_content(message_id: int) -> Tuple[Optional[str], bool]: """ Retrieve the content of a message by its ID.