diff --git a/maildir_gtd/actions/archive.py b/maildir_gtd/actions/archive.py index ce021ec..397c56f 100644 --- a/maildir_gtd/actions/archive.py +++ b/maildir_gtd/actions/archive.py @@ -23,13 +23,25 @@ async def archive_current(app) -> None: stderr=asyncio.subprocess.PIPE, ) stdout, stderr = await process.communicate() - # app.reload_needed = True app.show_status(f"{stdout.decode()}", "info") logging.info(stdout.decode()) if process.returncode == 0: + # Remove the item from the ListView app.query_one(ListView).pop(index) - app.query_one(ListView).index = index - # app.action_next() # Automatically show the next message + + # Find the next message to display using the MessageStore + next_id, next_idx = app.message_store.find_next_valid_id(index) + + # Show the next available message + if next_id is not None and next_idx is not None: + # Set ListView index first to ensure UI is synchronized + app.query_one(ListView).index = next_idx + # Now update the current_message_id to trigger content update + app.current_message_id = next_id + else: + # No messages left, just update ListView + app.query_one(ListView).index = 0 + app.reload_needed = True else: app.show_status(f"Error archiving message: {stderr.decode()}", "error") except Exception as e: diff --git a/maildir_gtd/actions/delete.py b/maildir_gtd/actions/delete.py index 07e94ad..73053cc 100644 --- a/maildir_gtd/actions/delete.py +++ b/maildir_gtd/actions/delete.py @@ -14,12 +14,24 @@ async def delete_current(app) -> None: stderr=asyncio.subprocess.PIPE, ) stdout, stderr = await process.communicate() - # app.reload_needed = True app.show_status(f"{stdout.decode()}", "info") if process.returncode == 0: + # Remove the item from the ListView await app.query_one(ListView).pop(index) - app.query_one(ListView).index = index - # app.action_next() # Automatically show the next message + + # Find the next message to display using the MessageStore + next_id, next_idx = app.message_store.find_next_valid_id(index) + + # Show the next available message + if next_id is not None and next_idx is not None: + # Set ListView index first to ensure UI is synchronized + app.query_one(ListView).index = next_idx + # Now update the current_message_id to trigger content update + app.current_message_id = next_id + else: + # No messages left, just update ListView + app.query_one(ListView).index = 0 + app.reload_needed = True else: app.show_status( f"Failed to delete message {app.current_message_id}. {stderr.decode()}", diff --git a/maildir_gtd/app.py b/maildir_gtd/app.py index 2467d65..b150469 100644 --- a/maildir_gtd/app.py +++ b/maildir_gtd/app.py @@ -4,7 +4,7 @@ import os from datetime import datetime import asyncio import logging -from typing import Iterable +from typing import Iterable, Optional, List, Dict, Any, Generator, Tuple # Add the parent directory to the system path to resolve relative imports sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -37,6 +37,161 @@ logging.basicConfig( ) +class MessageStore: + """Centralized store for email message data with efficient lookups and updates.""" + + def __init__(self): + self.envelopes: List[Dict[str, Any]] = [] # Full envelope data including headers + self.by_id: Dict[int, Dict[str, Any]] = {} # Map message IDs to envelope data + self.id_to_index: Dict[int, int] = {} # Map message IDs to list indices + self.total_messages = 0 + self.sort_ascending = True + + def clear(self) -> None: + """Clear all data structures.""" + self.envelopes = [] + self.by_id = {} + self.id_to_index = {} + self.total_messages = 0 + + def load(self, raw_envelopes: List[Dict[str, Any]], sort_ascending: bool = True) -> None: + """Load envelopes from raw data and set up the data structures.""" + self.clear() + self.sort_ascending = sort_ascending + + # Sort the envelopes by date + sorted_envelopes = sorted( + raw_envelopes, + key=lambda x: x["date"], + reverse=not sort_ascending, + ) + + # Group them by date for display + self.envelopes = group_envelopes_by_date(sorted_envelopes) + + # Build lookup dictionaries + for idx, envelope in enumerate(self.envelopes): + if "id" in envelope and envelope.get("type") != "header": + msg_id = int(envelope["id"]) + self.by_id[msg_id] = envelope + self.id_to_index[msg_id] = idx + + # Count actual messages (excluding headers) + self.total_messages = len(self.by_id) + + def get_by_id(self, msg_id: int) -> Optional[Dict[str, Any]]: + """Get an envelope by its ID.""" + return self.by_id.get(msg_id) + + def get_index_by_id(self, msg_id: int) -> Optional[int]: + """Get the list index for a message ID.""" + return self.id_to_index.get(msg_id) + + def get_metadata(self, msg_id: int) -> Dict[str, Any]: + """Get essential metadata for a message.""" + envelope = self.get_by_id(msg_id) + if not envelope: + return {} + + return { + "subject": envelope.get("subject", ""), + "from": envelope.get("from", {}), + "to": envelope.get("to", {}), + "date": envelope.get("date", ""), + "cc": envelope.get("cc", {}), + "index": self.get_index_by_id(msg_id), + } + + def remove(self, msg_id: int) -> None: + """Remove a message from all data structures.""" + # Get the index first before we remove from dictionaries + idx = self.id_to_index.get(msg_id) + + # Remove from dictionaries + self.by_id.pop(msg_id, None) + self.id_to_index.pop(msg_id, None) + + # Remove from list if we found an index + if idx is not None: + self.envelopes[idx] = None # Mark as None rather than removing to maintain indices + + # Update total count + self.total_messages = len(self.by_id) + + def find_next_valid_id(self, current_idx: int) -> Tuple[Optional[int], Optional[int]]: + """Find the next valid message ID and its index after the current index.""" + # Look forward first + try: + # Optimized with better short-circuit logic + # Only check type if env exists and has an ID + idx, envelope = next( + (i, env) for i, env in enumerate(self.envelopes[current_idx + 1:], current_idx + 1) + if env and "id" in env and env.get("type") != "header" + ) + return int(envelope["id"]), idx + except StopIteration: + # If not found in forward direction, look from beginning + try: + idx, envelope = next( + (i, env) for i, env in enumerate(self.envelopes[:current_idx]) + if env and "id" in env and env.get("type") != "header" + ) + return int(envelope["id"]), idx + except StopIteration: + return None, None + + def find_prev_valid_id(self, current_idx: int) -> Tuple[Optional[int], Optional[int]]: + """Find the previous valid message ID and its index before the current index.""" + # Look backward first + try: + # Create a range of indices in reverse order + backward_range = range(current_idx - 1, -1, -1) # No need to convert to list + # Using optimized short-circuit evaluation + idx, envelope = next( + (i, self.envelopes[i]) for i in backward_range + if self.envelopes[i] and "id" in self.envelopes[i] and self.envelopes[i].get("type") != "header" + ) + return int(envelope["id"]), idx + except StopIteration: + # If not found, look from end downward to current + try: + backward_range = range(len(self.envelopes) - 1, current_idx, -1) # No need to convert to list + idx, envelope = next( + (i, self.envelopes[i]) for i in backward_range + if self.envelopes[i] and "id" in self.envelopes[i] and self.envelopes[i].get("type") != "header" + ) + return int(envelope["id"]), idx + except StopIteration: + return None, None + + def get_oldest_id(self) -> Optional[int]: + """Get the ID of the oldest message.""" + if not self.envelopes: + return None + + for envelope in self.envelopes: + if envelope and "id" in envelope and envelope.get("type") != "header": + return int(envelope["id"]) + + return None + + def get_newest_id(self) -> Optional[int]: + """Get the ID of the newest message.""" + if not self.envelopes: + return None + + for envelope in reversed(self.envelopes): + if envelope and "id" in envelope and envelope.get("type") != "header": + return int(envelope["id"]) + + return None + + def get_valid_envelopes(self) -> Generator[Dict[str, Any], None, None]: + """Get all valid (non-header) envelopes.""" + return (envelope for envelope in self.envelopes + if envelope and "id" in envelope and envelope.get("type") != "header") + + class StatusTitle(Static): total_messages: Reactive[int] = reactive(0) current_message_index: Reactive[int] = reactive(0) @@ -57,18 +212,13 @@ class EmailViewerApp(App): folder = reactive("INBOX") header_expanded = reactive(False) reload_needed = reactive(True) - all_envelopes = reactive([]) # Keep as list for compatibility with ListView - envelope_map = {} # Add a dictionary to map IDs to envelope data - envelope_index_map = {} # Map indices in the list to envelope IDs + message_store = MessageStore() oldest_id: Reactive[int] = reactive(0) newest_id: Reactive[int] = reactive(0) msg_worker: Worker | None = None - message_metadata: dict[int, dict] = {} - message_body_cache: dict[int, str] = {} total_messages: Reactive[int] = reactive(0) status_title = reactive("Message View") sort_order_ascending: Reactive[bool] = reactive(True) - valid_envelopes = reactive([]) def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]: yield from super().get_system_commands(screen) @@ -149,9 +299,6 @@ class EmailViewerApp(App): self.query_one("#folders_list").border_title = "\[3] Folders" - # self.query_one(ListView).data_bind(index=EmailViewerApp.current_message_index) - # self.watch(self.query_one(StatusTitle), "current_message_id", update_progress) - # Fetch the ID of the most recent message using the Himalaya CLI self.fetch_accounts() self.fetch_folders() worker = self.fetch_envelopes() @@ -162,9 +309,6 @@ class EmailViewerApp(App): def compute_status_title(self) -> None: return f"✉️ Message ID: {self.current_message_id} " - def compute_valid_envelopes(self) -> None: - return (envelope for envelope in self.all_envelopes if envelope.get("id")) - def watch_status_title(self, old_status_title: str, new_status_title: str) -> None: self.query_one(ContentContainer).border_title = new_status_title @@ -185,26 +329,6 @@ class EmailViewerApp(App): ).border_subtitle = f"[b]{new_index}[/b]/{self.total_messages}" self.query_one("#envelopes_list").index = new_index - def compute_newest_id(self) -> None: - if not self.all_envelopes: - return 0 - items = sorted( - self.valid_envelopes, - key=lambda x: x["date"], - reverse=not self.sort_order_ascending, - ) - return items[-1]["id"] if items else 0 - - def compute_oldest_id(self) -> None: - if not self.valid_envelopes: - return 0 - items = sorted( - self.valid_envelopes, - key=lambda x: x["date"], - reverse=not self.sort_order_ascending, - ) - return items[0]["id"] if items else 0 - def watch_reload_needed( self, old_reload_needed: bool, new_reload_needed: bool ) -> None: @@ -223,20 +347,17 @@ class EmailViewerApp(App): return self.msg_worker.cancel() if self.msg_worker else None logging.info(f"new_message_id: {new_message_id}, type: {type(new_message_id)}") - logging.info(f"message_metadata keys: {list(self.message_metadata.keys())}") content_container = self.query_one(ContentContainer) content_container.display_content(new_message_id) - if new_message_id in self.message_metadata: - metadata = self.message_metadata[new_message_id] + metadata = self.message_store.get_metadata(new_message_id) + if metadata: message_date = re.sub(r"[\+\-]\d\d:\d\d", "", metadata["date"]) message_date = datetime.strptime(message_date, "%Y-%m-%d %H:%M").strftime( "%a %b %d %H:%M" ) - # Only update the current_message_index if it's different from the index in the ListView - # This prevents the sidebar selection from getting out of sync with the displayed content if self.current_message_index != metadata["index"]: self.current_message_index = metadata["index"] @@ -248,7 +369,6 @@ class EmailViewerApp(App): cc=metadata["cc"].get("addr", "") if "cc" in metadata else "", ) - # Make sure the ListView index matches the current message index list_view = self.query_one("#envelopes_list") if list_view.index != metadata["index"]: list_view.index = metadata["index"] @@ -257,18 +377,13 @@ 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.all_envelopes[event.list_view.index] + current_item = self.message_store.envelopes[event.list_view.index] - # Skip if it's a header or None if current_item is None or current_item.get("type") == "header": return - # Get the message ID and update current index in a consistent way message_id = int(current_item["id"]) self.current_message_id = message_id - - # Update the index directly based on the ListView selection - # This ensures the sidebar selection and displayed content stay in sync self.current_message_index = event.list_view.index @work(exclusive=False) @@ -289,37 +404,11 @@ class EmailViewerApp(App): envelopes = json.loads(stdout.decode()) if envelopes: self.reload_needed = False - self.total_messages = len(envelopes) + self.message_store.load(envelopes, self.sort_order_ascending) + self.total_messages = self.message_store.total_messages msglist.clear() - envelopes = sorted( - envelopes, - key=lambda x: x["date"], - reverse=not self.sort_order_ascending, - ) - grouped_envelopes = group_envelopes_by_date(envelopes) - self.all_envelopes = grouped_envelopes - - # Update our dictionary mappings - self.envelope_map = {int(envelope["id"]): envelope for envelope in grouped_envelopes if "id" in envelope} - self.envelope_index_map = {index: int(envelope["id"]) for index, envelope in enumerate(grouped_envelopes) if "id" in envelope} - - # Store metadata with correct indices - self.message_metadata = { - int(envelope["id"]): { - "subject": envelope.get("subject", ""), - "from": envelope.get("from", {}), - "to": envelope.get("to", {}), - "date": envelope.get("date", ""), - "cc": envelope.get("cc", {}), - "index": index, # Store the position index - } - for index, envelope in enumerate(self.all_envelopes) - if "id" in envelope - } - - # Add items to the ListView - for item in grouped_envelopes: + for item in self.message_store.envelopes: if item.get("type") == "header": msglist.append( ListItem( @@ -431,7 +520,6 @@ class EmailViewerApp(App): worker = self.fetch_envelopes() await worker.wait() - # Call action_newest or action_oldest based on the new sort order if self.sort_order_ascending: self.action_oldest() else: @@ -445,109 +533,36 @@ class EmailViewerApp(App): def action_next(self) -> None: if not self.current_message_index >= 0: return - modifier = 1 - idx = self.current_message_index - try: - if ( - self.all_envelopes[idx + modifier] is None - or self.all_envelopes[idx + modifier].get("type") == "header" - ): - idx = idx + modifier - except IndexError: - # If we reach the end of the list, wrap around to the beginning - idx = 0 - self.show_message(self.all_envelopes[idx + modifier].get("id"), idx + modifier) + + next_id, next_idx = self.message_store.find_next_valid_id(self.current_message_index) + if next_id is not None and next_idx is not None: + 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 - modifier = -1 - idx = self.current_message_index - try: - if ( - self.all_envelopes[idx + modifier] is None - or self.all_envelopes[idx + modifier].get("type") == "header" - ): - idx = idx + modifier - except IndexError: - # If we reach the beginning of the list, wrap around to the end - idx = len(self.all_envelopes) - 1 - self.show_message(self.all_envelopes[idx + modifier].get("id"), idx + modifier) + + prev_id, prev_idx = self.message_store.find_prev_valid_id(self.current_message_index) + if prev_id is not None and prev_idx is not None: + 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: - # Remove from all data structures - self.all_envelopes = [item for item in self.all_envelopes if item and item.get("id") != self.current_message_id] - self.envelope_map.pop(self.current_message_id, None) - self.envelope_index_map = {index: id for index, id in self.envelope_index_map.items() if id != self.current_message_id} - self.message_metadata = { - k: v for k, v in self.message_metadata.items() if k != self.current_message_id - } - self.message_body_cache = { - k: v for k, v in self.message_body_cache.items() if k != self.current_message_id - } - self.total_messages = len(self.message_metadata) - - # Perform delete operation + message_id_to_delete = self.current_message_id + self.message_store.remove(message_id_to_delete) + self.total_messages = self.message_store.total_messages delete_current(self) - # Get next message to display - try: - newmsg = self.all_envelopes[self.current_message_index] - # Skip headers - if newmsg.get("type") == "header": - if self.current_message_index + 1 < len(self.all_envelopes): - newmsg = self.all_envelopes[self.current_message_index + 1] - else: - # If we're at the end, go to the previous message - newmsg = self.all_envelopes[self.current_message_index - 1] - self.current_message_index -= 1 - - # Show the next message - if "id" in newmsg: - self.show_message(newmsg["id"]) - except (IndexError, KeyError): - # If no more messages, just reload envelopes - self.reload_needed = True - self.fetch_envelopes() - async def action_archive(self) -> None: - # Remove from all data structures - self.all_envelopes = [item for item in self.all_envelopes if item and item.get("id") != self.current_message_id] - self.envelope_map.pop(self.current_message_id, None) - self.envelope_index_map = {index: id for index, id in self.envelope_index_map.items() if id != self.current_message_id} - self.message_metadata = { - k: v for k, v in self.message_metadata.items() if k != self.current_message_id - } - self.message_body_cache = { - k: v for k, v in self.message_body_cache.items() if k != self.current_message_id - } - self.total_messages = len(self.message_metadata) - - # Perform archive operation - worker = archive_current(self) - await worker.wait() - - # Get next message to display - try: - newmsg = self.all_envelopes[self.current_message_index] - # Skip headers - if newmsg.get("type") == "header": - if self.current_message_index + 1 < len(self.all_envelopes): - newmsg = self.all_envelopes[self.current_message_index + 1] - else: - # If we're at the end, go to the previous message - newmsg = self.all_envelopes[self.current_message_index - 1] - self.current_message_index -= 1 - - # Show the next message - if "id" in newmsg: - self.show_message(newmsg["id"]) - except (IndexError, KeyError): - # If no more messages, just reload envelopes - self.reload_needed = True - self.fetch_envelopes() + message_id_to_archive = self.current_message_id + self.message_store.remove(message_id_to_archive) + self.total_messages = self.message_store.total_messages + archive_current(self) def action_open(self) -> None: action_open(self) @@ -577,11 +592,11 @@ class EmailViewerApp(App): def action_oldest(self) -> None: self.fetch_envelopes() if self.reload_needed else None - self.show_message(self.oldest_id) + self.show_message(self.message_store.get_oldest_id()) def action_newest(self) -> None: self.fetch_envelopes() if self.reload_needed else None - self.show_message(self.newest_id) + self.show_message(self.message_store.get_newest_id()) def action_focus_1(self) -> None: self.query_one("#envelopes_list").focus() diff --git a/maildir_gtd/widgets/ContentContainer.py b/maildir_gtd/widgets/ContentContainer.py index a0c1d69..5d94d09 100644 --- a/maildir_gtd/widgets/ContentContainer.py +++ b/maildir_gtd/widgets/ContentContainer.py @@ -75,8 +75,10 @@ class ContentContainer(ScrollableContainer): async def get_message_body(self, message_id: int) -> str: """Fetch the message body from Himalaya CLI.""" - try: + # Store the ID of the message we're currently loading + loading_id = message_id + process = await asyncio.create_subprocess_shell( f"himalaya message read {str(message_id)} -p", stdout=asyncio.subprocess.PIPE, @@ -85,15 +87,46 @@ class ContentContainer(ScrollableContainer): stdout, stderr = await process.communicate() logging.info(f"stdout: {stdout.decode()[0:50]}...") + # Check if we're still loading the same message or if navigation has moved on + if loading_id != self.current_id: + logging.info(f"Message ID changed during loading. Abandoning load of {loading_id}") + return "" + if process.returncode == 0: # Process the email content - fixed_text = stdout.decode().replace("https://urldefense.com/v3/", "") + content = stdout.decode() + + # Remove header lines from the beginning of the message + # Headers typically end with a blank line before the message body + lines = content.split('\n') + body_start = 0 + + # Find the first blank line which typically separates headers from body + for i, line in enumerate(lines): + if line.strip() == '' and i > 0: + # Check if we're past the headers section + # Headers are typically in "Key: Value" format + has_headers = any(': ' in l for l in lines[:i]) + if has_headers: + body_start = i + 1 + break + + # Join the body lines back together + content = '\n'.join(lines[body_start:]) + + # Apply existing cleanup logic + fixed_text = content.replace("https://urldefense.com/v3/", "") fixed_text = re.sub(r"atlOrigin.+?\w", "", fixed_text) logging.info(f"rendering fixedText: {fixed_text[0:50]}") self.current_text = fixed_text self.message_cache[message_id] = fixed_text + # Check again if we're still on the same message before updating UI + if loading_id != self.current_id: + logging.info(f"Message ID changed after loading. Abandoning update for {loading_id}") + return fixed_text + # Update the plaintext content plaintext = self.query_one("#plaintext_content", Label) plaintext.update(fixed_text) @@ -107,12 +140,20 @@ class ContentContainer(ScrollableContainer): self.query_one("#markdown_content").add_class("hidden") self.loading = False + return fixed_text else: logging.error(f"Error fetching message: {stderr.decode()}") + self.loading = False return f"Error fetching message content: {stderr.decode()}" except Exception as e: logging.error(f"Error fetching message content: {e}") + self.loading = False return f"Error fetching message content: {e}" + finally: + # Ensure loading state is always reset if this worker completes + # This prevents the loading indicator from getting stuck + if loading_id == self.current_id: + self.loading = False async def render_markdown(self) -> None: """Render the markdown content asynchronously."""