import re import asyncio import logging from functools import lru_cache from textual import work from textual.app import ComposeResult from textual.widgets import Label, Markdown from textual.containers import ScrollableContainer from widgets.EnvelopeHeader import EnvelopeHeader class ContentContainer(ScrollableContainer): """A custom container that can switch between plaintext and markdown rendering.""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.plaintext_mode = True self.markup_worker = None self.current_text = "" self.current_id = None self.message_cache = dict() # LRU cache with a max size of 100 messages def compose(self) -> ComposeResult: """Compose the container with a label for plaintext and markdown for rich content.""" yield EnvelopeHeader() yield Label(id="plaintext_content", markup=False) yield Markdown(id="markdown_content", classes="hidden") def update_header(self, subject: str = "", date: str = "", from_: str = "", to: str = "", cc: str = "", bcc: str = "") -> None: header = self.query_one(EnvelopeHeader) header.subject = subject header.date = date header.from_ = from_ header.to = to header.cc = cc header.bcc = bcc def action_toggle_header(self) -> None: """Toggle the visibility of the EnvelopeHeader panel.""" header = self.query_one(EnvelopeHeader) header.styles.height = "1" if self.header_expanded else "auto" self.header_expanded = not self.header_expanded def display_content(self, message_id: int) -> None: """Display content for the given message ID.""" self.current_id = message_id # Show loading state self.loading = True # Check if the message is already cached if message_id in self.message_cache: self.current_text = self.message_cache[message_id] plaintext = self.query_one("#plaintext_content", Label) plaintext.update(self.current_text) if not self.plaintext_mode: # We're in markdown mode, so render the markdown self.render_markdown() else: # Hide markdown, show plaintext plaintext.remove_class("hidden") self.query_one("#markdown_content").add_class("hidden") self.loading = False return self.current_text else: # Get message body (from cache or fetch new) self.get_message_body(message_id) @work(exclusive=True) 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, stderr=asyncio.subprocess.PIPE, ) 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 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) if not self.plaintext_mode: # We're in markdown mode, so render the markdown self.render_markdown() else: # Hide markdown, show plaintext plaintext.remove_class("hidden") 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.""" if self.markup_worker: self.markup_worker.cancel() markdown = self.query_one("#markdown_content", Markdown) plaintext = self.query_one("#plaintext_content", Label) await markdown.update(self.current_text) # Show markdown, hide plaintext markdown.remove_class("hidden") plaintext.add_class("hidden") async def toggle_mode(self) -> None: """Toggle between plaintext and markdown mode.""" self.plaintext_mode = not self.plaintext_mode if self.plaintext_mode: # Switch to plaintext self.query_one("#plaintext_content").remove_class("hidden") self.query_one("#markdown_content").add_class("hidden") else: # Switch to markdown await self.render_markdown() return self.plaintext_mode