diff --git a/src/cli/sync.py b/src/cli/sync.py index 52a1592..ddf99f2 100644 --- a/src/cli/sync.py +++ b/src/cli/sync.py @@ -216,6 +216,8 @@ async def _sync_outlook_data(dry_run, vdir, icsfile, org, days_back, days_forwar task_archive = progress.add_task("[yellow]Archiving mail...", total=0) task_delete = progress.add_task("[red]Deleting mail...", total=0) + # Stage 1: Synchronize local changes (read, archive, delete) to the server + progress.console.print("[bold cyan]Step 1: Syncing local changes to server...[/bold cyan]") await asyncio.gather( synchronize_maildir_async( maildir_path, headers, progress, task_read, dry_run @@ -224,6 +226,12 @@ async def _sync_outlook_data(dry_run, vdir, icsfile, org, days_back, days_forwar progress, task_archive, dry_run), delete_mail_async(maildir_path, headers, progress, task_delete, dry_run), + ) + progress.console.print("[bold green]Step 1: Local changes synced.[/bold green]") + + # Stage 2: Fetch new data from the server + progress.console.print("\n[bold cyan]Step 2: Fetching new data from server...[/bold cyan]") + await asyncio.gather( fetch_mail_async( maildir_path, attachments_dir, @@ -235,6 +243,7 @@ async def _sync_outlook_data(dry_run, vdir, icsfile, org, days_back, days_forwar ), fetch_calendar_async(headers, progress, task_calendar, dry_run, vdir, icsfile, org, days_back, days_forward, continue_iteration), ) + progress.console.print("[bold green]Step 2: New data fetched.[/bold green]") click.echo("Sync complete.") diff --git a/src/maildir_gtd/app.py b/src/maildir_gtd/app.py index b9a3189..051a715 100644 --- a/src/maildir_gtd/app.py +++ b/src/maildir_gtd/app.py @@ -4,7 +4,6 @@ from .widgets.EnvelopeHeader import EnvelopeHeader from .actions.task import action_create_task from .actions.open import action_open from .actions.delete import delete_current -from .actions.archive import archive_current from src.services.taskwarrior import client as taskwarrior_client from src.services.himalaya import client as himalaya_client from textual.containers import ScrollableContainer, Vertical, Horizontal @@ -69,6 +68,7 @@ class EmailViewerApp(App): status_title = reactive("Message View") sort_order_ascending: Reactive[bool] = reactive(True) selected_messages: Reactive[set[int]] = reactive(set()) + main_content_visible: Reactive[bool] = reactive(True) def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]: yield from super().get_system_commands(screen) @@ -110,7 +110,7 @@ class EmailViewerApp(App): Binding("2", "focus_2", "Focus Folders Panel"), Binding("3", "focus_3", "Focus Envelopes Panel"), Binding("4", "focus_4", "Focus Main Content"), - Binding("m", "toggle_mode", "Toggle Content Mode"), + Binding("w", "toggle_main_content", "Toggle Message View Window"), ] BINDINGS.extend( @@ -215,35 +215,35 @@ class EmailViewerApp(App): ) if new_message_id == old_message_id: 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)}") + # If the main content view is not visible, don't load the message + if not self.main_content_visible: + return + + # Cancel any existing message loading worker + if self.msg_worker: + self.msg_worker.cancel() + + # Start a new worker to load the message content + self.msg_worker = self.load_message_content(new_message_id) + + @work(exclusive=True) + async def load_message_content(self, message_id: int) -> None: + """Worker to load message content asynchronously.""" content_container = self.query_one(ContentContainer) - content_container.display_content(new_message_id) + content_container.display_content(message_id) - metadata = self.message_store.get_metadata(new_message_id) + metadata = self.message_store.get_metadata(message_id) if metadata: - # Pass the complete date string with timezone information message_date = metadata["date"] - if self.current_message_index != metadata["index"]: self.current_message_index = metadata["index"] - # content_container.update_header( - # subject=metadata.get("subject", "").strip(), - # from_=metadata["from"].get("addr", ""), - # to=metadata["to"].get("addr", ""), - # date=message_date, - # cc=metadata["cc"].get("addr", "") if "cc" in metadata else "", - # ) - list_view = self.query_one("#envelopes_list", ListView) if list_view.index != metadata["index"]: list_view.index = metadata["index"] else: - logging.warning( - f"Message ID {new_message_id} not found in metadata.") + logging.warning(f"Message ID {message_id} not found in metadata.") def on_list_view_selected(self, event: ListView.Selected) -> None: """Called when an item in the list view is selected.""" @@ -286,9 +286,11 @@ class EmailViewerApp(App): # Use the Himalaya client to fetch envelopes envelopes, success = await himalaya_client.list_envelopes() - if success and envelopes: + if success: self.reload_needed = False - self.message_store.load(envelopes, self.sort_order_ascending) + # Ensure envelopes is a list, even if it's None from the client + envelopes_list = envelopes if envelopes is not None else [] + self.message_store.load(envelopes_list, self.sort_order_ascending) self.total_messages = self.message_store.total_messages # Use the centralized refresh method to update the ListView @@ -530,20 +532,72 @@ class EmailViewerApp(App): await worker.wait() async def action_archive(self) -> None: - """Archive the current message and update UI consistently.""" + """Archive the current or selected messages and update UI consistently.""" + next_id_to_select = None + if self.selected_messages: - message_ids = [str(msg_id) for msg_id in self.selected_messages] - _, success = await himalaya_client.archive_messages(message_ids) + # --- 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(metadata["index"]) + if next_id is None: + next_id, _ = self.message_store.find_prev_valid_id( + metadata["index"] + ) + next_id_to_select = next_id + + message, success = await himalaya_client.archive_messages( + [str(mid) for mid in message_ids_to_archive] + ) + if success: - self.show_status(f"{len(message_ids)} messages archived.") + self.show_status(message) self.selected_messages.clear() - self.fetch_envelopes() else: - self.show_status("Failed to archive messages.", "error") + self.show_status(f"Failed to archive messages: {message}", "error") + return + else: - # Call the archive_current function which uses our Himalaya client module - worker = archive_current(self) - await worker.wait() + # --- Single message archive --- + 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_message(current_id) + + if success: + self.show_status(message) + else: + self.show_status( + f"Failed to archive message {current_id}: {message}", "error" + ) + 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: + self.action_oldest() + else: + self.action_oldest() def action_open(self) -> None: action_open(self) @@ -567,6 +621,22 @@ class EmailViewerApp(App): """Scroll the main content up by a page.""" self.query_one("#main_content").scroll_page_up() + def action_toggle_main_content(self) -> None: + """Toggle the visibility of the main content pane.""" + self.main_content_visible = not self.main_content_visible + + def watch_main_content_visible(self, visible: bool) -> None: + """Called when main_content_visible changes.""" + main_content = self.query_one("#main_content") + accounts_list = self.query_one("#accounts_list") + folders_list = self.query_one("#folders_list") + + main_content.display = visible + accounts_list.display = visible + folders_list.display = visible + + self.query_one("#envelopes_list").focus() + def action_quit(self) -> None: self.exit() diff --git a/src/maildir_gtd/widgets/ContentContainer.py b/src/maildir_gtd/widgets/ContentContainer.py index 94c1e53..a6c003d 100644 --- a/src/maildir_gtd/widgets/ContentContainer.py +++ b/src/maildir_gtd/widgets/ContentContainer.py @@ -1,5 +1,6 @@ from markitdown import MarkItDown from textual import work +from textual.binding import Binding from textual.containers import Vertical, ScrollableContainer from textual.widgets import Static, Markdown, Label from src.services.himalaya import client as himalaya_client @@ -57,6 +58,9 @@ class EnvelopeHeader(Vertical): class ContentContainer(ScrollableContainer): can_focus = True + BINDINGS = [ + Binding("m", "toggle_mode", "Toggle View Mode") + ] def __init__(self, **kwargs): super().__init__(**kwargs) @@ -79,7 +83,7 @@ class ContentContainer(ScrollableContainer): self.content.styles.display = "none" self.html_content.styles.display = "block" - async def toggle_mode(self): + async def action_toggle_mode(self): """Toggle between plaintext and HTML viewing modes.""" if self.current_mode == "html": self.current_mode = "text" @@ -91,6 +95,7 @@ class ContentContainer(ScrollableContainer): self.html_content.styles.display = "block" # self.action_notify(f"switched to mode {self.current_mode}") # Reload the content if we have a message ID + self.border_sibtitle = self.current_mode; if self.current_message_id: self.display_content(self.current_message_id) @@ -119,6 +124,12 @@ class ContentContainer(ScrollableContainer): self.current_message_id = message_id + # Immediately show a loading message + if self.current_mode == "text": + self.content.update("Loading...") + else: + self.html_content.update("Loading...") + # Cancel any existing content fetch operations if self.content_worker: self.content_worker.cancel()