This commit is contained in:
Tim Bendt
2025-07-10 10:48:08 -04:00
parent 7cc1c30356
commit f7474a3805
5 changed files with 117 additions and 117 deletions

View File

@@ -1,6 +1,3 @@
import asyncio
import logging
from textual import work from textual import work
from src.services.himalaya import client as himalaya_client from src.services.himalaya import client as himalaya_client
@@ -23,7 +20,7 @@ async def archive_current(app):
next_id, next_idx = app.message_store.find_prev_valid_id(current_index) next_id, next_idx = app.message_store.find_prev_valid_id(current_index)
# Archive the message using our Himalaya client module # Archive the message using our Himalaya client module
success = await himalaya_client.archive_message(current_message_id) success = await himalaya_client.archive_messages([str(current_message_id)])
if success: if success:
app.show_status(f"Message {current_message_id} archived.", "success") app.show_status(f"Message {current_message_id} archived.", "success")

View File

@@ -1,12 +1,11 @@
from .message_store import MessageStore from .message_store import MessageStore
from .widgets.ContentContainer import ContentContainer from .widgets.ContentContainer import ContentContainer
from .widgets.EnvelopeHeader import EnvelopeHeader
from .actions.task import action_create_task from .actions.task import action_create_task
from .actions.open import action_open from .actions.open import action_open
from .actions.delete import delete_current from .actions.delete import delete_current
from src.services.taskwarrior import client as taskwarrior_client from src.services.taskwarrior import client as taskwarrior_client
from src.services.himalaya import client as himalaya_client from src.services.himalaya import client as himalaya_client
from textual.containers import ScrollableContainer, Vertical, Horizontal from textual.containers import Container, ScrollableContainer, Vertical, Horizontal
from textual.timer import Timer from textual.timer import Timer
from textual.binding import Binding from textual.binding import Binding
from textual.reactive import reactive, Reactive from textual.reactive import reactive, Reactive
@@ -16,14 +15,11 @@ from textual.logging import TextualHandler
from textual.app import App, ComposeResult, SystemCommand, RenderResult from textual.app import App, ComposeResult, SystemCommand, RenderResult
from textual.worker import Worker from textual.worker import Worker
from textual import work from textual import work
import re
import sys import sys
import os import os
from datetime import UTC, datetime from datetime import UTC, datetime
import asyncio
import logging import logging
from typing import Iterable, Optional, List, Dict, Any, Generator, Tuple from typing import Iterable, Optional, List, Dict, Any, Generator, Tuple
from collections import defaultdict
# Add the parent directory to the system path to resolve relative imports # 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__)))) sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -57,6 +53,7 @@ class EmailViewerApp(App):
title = "Maildir GTD Reader" title = "Maildir GTD Reader"
current_message_id: Reactive[int] = reactive(0) current_message_id: Reactive[int] = reactive(0)
current_message_index: Reactive[int] = reactive(0) current_message_index: Reactive[int] = reactive(0)
highlighted_message_index: Reactive[int] = reactive(0)
folder = reactive("INBOX") folder = reactive("INBOX")
header_expanded = reactive(False) header_expanded = reactive(False)
reload_needed = reactive(True) reload_needed = reactive(True)
@@ -147,8 +144,7 @@ class EmailViewerApp(App):
self.title = "MaildirGTD" self.title = "MaildirGTD"
self.query_one("#main_content").border_title = self.status_title self.query_one("#main_content").border_title = self.status_title
sort_indicator = "" if self.sort_order_ascending else "" sort_indicator = "" if self.sort_order_ascending else ""
self.query_one("#envelopes_list").border_title = f"1⃣ Emails { self.query_one("#envelopes_list").border_title = f"1⃣ Emails {sort_indicator}"
sort_indicator}"
self.query_one("#accounts_list").border_title = "2⃣ Accounts" self.query_one("#accounts_list").border_title = "2⃣ Accounts"
self.query_one("#folders_list").border_title = "3⃣ Folders" self.query_one("#folders_list").border_title = "3⃣ Folders"
@@ -171,8 +167,7 @@ class EmailViewerApp(App):
def watch_sort_order_ascending(self, old_value: bool, new_value: bool) -> None: def watch_sort_order_ascending(self, old_value: bool, new_value: bool) -> None:
"""Update the border title of the envelopes list when the sort order changes.""" """Update the border title of the envelopes list when the sort order changes."""
sort_indicator = "" if new_value else "" sort_indicator = "" if new_value else ""
self.query_one("#envelopes_list").border_title = f"1⃣ Emails { self.query_one("#envelopes_list").border_title = f"1⃣ Emails {sort_indicator}"
sort_indicator}"
def watch_current_message_index(self, old_index: int, new_index: int) -> None: def watch_current_message_index(self, old_index: int, new_index: int) -> None:
if new_index < 0: if new_index < 0:
@@ -183,9 +178,11 @@ class EmailViewerApp(App):
self.current_message_index = new_index self.current_message_index = new_index
self._update_list_view_subtitle() self._update_list_view_subtitle()
self.query_one("#envelopes_list").index = new_index self.query_one("#envelopes_list", ListView).index = new_index
def watch_selected_messages(self, old_messages: set[int], new_messages: set[int]) -> None: def watch_selected_messages(
self, old_messages: set[int], new_messages: set[int]
) -> None:
self._update_list_view_subtitle() self._update_list_view_subtitle()
def _update_list_view_subtitle(self) -> None: def _update_list_view_subtitle(self) -> None:
@@ -210,8 +207,7 @@ class EmailViewerApp(App):
) -> None: ) -> None:
"""Called when the current message ID changes.""" """Called when the current message ID changes."""
logging.info( logging.info(
f"Current message ID changed from { f"Current message ID changed from {old_message_id} to {new_message_id}"
old_message_id} to {new_message_id}"
) )
if new_message_id == old_message_id: if new_message_id == old_message_id:
return return
@@ -273,9 +269,9 @@ class EmailViewerApp(App):
if current_item is None or current_item.get("type") == "header": if current_item is None or current_item.get("type") == "header":
return return
message_id = int(current_item["id"]) # message_id = int(current_item["id"])
self.current_message_id = message_id # self.current_message_id = message_id
self.current_message_index = highlighted_index self.highlighted_message_index = highlighted_index
@work(exclusive=False) @work(exclusive=False)
async def fetch_envelopes(self) -> None: async def fetch_envelopes(self) -> None:
@@ -307,7 +303,7 @@ class EmailViewerApp(App):
@work(exclusive=False) @work(exclusive=False)
async def fetch_accounts(self) -> None: async def fetch_accounts(self) -> None:
accounts_list = self.query_one("#accounts_list") accounts_list = self.query_one("#accounts_list", ListView)
try: try:
accounts_list.loading = True accounts_list.loading = True
@@ -333,7 +329,7 @@ class EmailViewerApp(App):
@work(exclusive=False) @work(exclusive=False)
async def fetch_folders(self) -> None: async def fetch_folders(self) -> None:
folders_list = self.query_one("#folders_list") folders_list = self.query_one("#folders_list", ListView)
folders_list.clear() folders_list.clear()
folders_list.append( folders_list.append(
ListItem(Label("INBOX", classes="folder_name", markup=False)) ListItem(Label("INBOX", classes="folder_name", markup=False))
@@ -370,27 +366,29 @@ class EmailViewerApp(App):
if item and item.get("type") == "header": if item and item.get("type") == "header":
envelopes_list.append( envelopes_list.append(
ListItem( ListItem(
Horizontal( Container(
Label("", classes="checkbox"), # Hidden checkbox for header Label("", classes="checkbox"), # Hidden checkbox for header
Label( Label(
item["label"], item["label"],
classes="group_header", classes="group_header",
markup=False, markup=False,
), ),
classes="envelope_item_row" classes="envelope_item_row",
) )
) )
) )
elif item: # Check if not None elif item: # Check if not None
# Extract sender and date # Extract sender and date
sender_name = item.get("from", {}).get("name", item.get("from", {}).get("addr", "Unknown")) sender_name = item.get("from", {}).get(
"name", item.get("from", {}).get("addr", "Unknown")
)
if not sender_name: if not sender_name:
sender_name = item.get("from", {}).get("addr", "Unknown") sender_name = item.get("from", {}).get("addr", "Unknown")
# Truncate sender name # Truncate sender name
max_sender_len = 25 # Adjust as needed max_sender_len = 25 # Adjust as needed
if len(sender_name) > max_sender_len: if len(sender_name) > max_sender_len:
sender_name = sender_name[:max_sender_len-3] + "..." sender_name = sender_name[: max_sender_len - 3] + "..."
message_date_str = item.get("date", "") message_date_str = item.get("date", "")
formatted_date = "" formatted_date = ""
@@ -403,25 +401,26 @@ class EmailViewerApp(App):
formatted_date = "Invalid Date" formatted_date = "Invalid Date"
list_item = ListItem( list_item = ListItem(
Vertical( Container(
Horizontal( Container(
Label("", classes="checkbox"), # Placeholder for checkbox Label("", classes="checkbox"), # Placeholder for checkbox
Label(sender_name, classes="sender_name"), Label(sender_name, classes="sender_name"),
Label(formatted_date, classes="message_date"), Label(formatted_date, classes="message_date"),
classes="envelope_header_row" classes="envelope_header_row",
), ),
Horizontal( Container(
Label( Label(
str(item.get("subject", "")).strip(), str(item.get("subject", "")).strip(),
classes="email_subject", classes="email_subject",
markup=False, markup=False,
) ),
classes="envelope_subject_row",
), ),
classes="envelope_item_row" classes="envelope_item_row",
) )
) )
envelopes_list.append(list_item) envelopes_list.append(list_item)
self.refresh_list_view_items() # Initial refresh of item states self.refresh_list_view_items() # Initial refresh of item states
def refresh_list_view_items(self) -> None: def refresh_list_view_items(self) -> None:
"""Update the visual state of existing ListItems without clearing the list.""" """Update the visual state of existing ListItems without clearing the list."""
@@ -429,48 +428,44 @@ class EmailViewerApp(App):
for i, list_item in enumerate(envelopes_list.children): for i, list_item in enumerate(envelopes_list.children):
if isinstance(list_item, ListItem): if isinstance(list_item, ListItem):
item_data = self.message_store.envelopes[i] item_data = self.message_store.envelopes[i]
# Find the checkbox label within the ListItem's children # Find the checkbox label within the ListItem's children
checkbox_label = None # checkbox_label = list_item.query_one(".checkbox", Label)
for child in list_item.walk_children(): if item_data and item_data.get("type") != "header":
if isinstance(child, Label) and "checkbox" in child.classes: message_id = int(item_data["id"])
checkbox_label = child is_selected = message_id in self.selected_messages or False
break list_item.set_class(is_selected, "selection")
if checkbox_label: # if checkbox_label:
if item_data and item_data.get("type") != "header": # checkbox_label.update("\uf4a7" if is_selected else "\ue640")
message_id = int(item_data["id"]) # checkbox_label.display = True # Always display checkbox
is_selected = message_id in self.selected_messages
checkbox_label.update("\uf4a7" if is_selected else "\ue640") # list_item.highlighted = is_selected
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)
# Update sender and date labels # message_date_str = item_data.get("date", "")
sender_name = item_data.get("from", {}).get("name", item_data.get("from", {}).get("addr", "Unknown")) # formatted_date = ""
if not sender_name: # if message_date_str:
sender_name = item_data.get("from", {}).get("addr", "Unknown") # try:
max_sender_len = 25 # dt_object = datetime.fromisoformat(message_date_str)
if len(sender_name) > max_sender_len: # formatted_date = dt_object.strftime("%m/%d %H:%M")
sender_name = sender_name[:max_sender_len-3] + "..." # except ValueError:
list_item.query_one(".sender_name", Label).update(sender_name) # formatted_date = "Invalid Date"
# list_item.query_one(".message_date", Label).update(formatted_date)
message_date_str = item_data.get("date", "") # else:
formatted_date = "" # # For header items, checkbox should be unchecked and visible
if message_date_str: # checkbox_label.update("\ue640") # Always unchecked for headers
try: # checkbox_label.display = True # Always display checkbox
dt_object = datetime.fromisoformat(message_date_str) # list_item.highlighted = False # Headers are never highlighted for selection
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) # Update total messages count (this is still fine here)
# self.total_messages = self.message_store.total_messages # self.total_messages = self.message_store.total_messages
@@ -501,7 +496,7 @@ class EmailViewerApp(App):
async def action_toggle_mode(self) -> None: async def action_toggle_mode(self) -> None:
"""Toggle the content mode between plaintext and markdown.""" """Toggle the content mode between plaintext and markdown."""
content_container = self.query_one(ContentContainer) content_container = self.query_one(ContentContainer)
await content_container.toggle_mode() await content_container.action_toggle_mode()
def action_next(self) -> None: def action_next(self) -> None:
if not self.current_message_index >= 0: if not self.current_message_index >= 0:
@@ -543,7 +538,9 @@ class EmailViewerApp(App):
highest_archived_id = max(message_ids_to_archive) highest_archived_id = max(message_ids_to_archive)
metadata = self.message_store.get_metadata(highest_archived_id) metadata = self.message_store.get_metadata(highest_archived_id)
if metadata: if metadata:
next_id, _ = self.message_store.find_next_valid_id(metadata["index"]) next_id, _ = self.message_store.find_next_valid_id(
metadata["index"]
)
if next_id is None: if next_id is None:
next_id, _ = self.message_store.find_prev_valid_id( next_id, _ = self.message_store.find_prev_valid_id(
metadata["index"] metadata["index"]
@@ -555,7 +552,7 @@ class EmailViewerApp(App):
) )
if success: if success:
self.show_status(message) self.show_status(message or "Success archived")
self.selected_messages.clear() self.selected_messages.clear()
else: else:
self.show_status(f"Failed to archive messages: {message}", "error") self.show_status(f"Failed to archive messages: {message}", "error")
@@ -575,10 +572,10 @@ class EmailViewerApp(App):
next_id, _ = self.message_store.find_prev_valid_id(current_idx) next_id, _ = self.message_store.find_prev_valid_id(current_idx)
next_id_to_select = next_id next_id_to_select = next_id
message, success = await himalaya_client.archive_message(current_id) message, success = await himalaya_client.archive_messages([str(current_id)])
if success: if success:
self.show_status(message) self.show_status(message or "Archived")
else: else:
self.show_status( self.show_status(
f"Failed to archive message {current_id}: {message}", "error" f"Failed to archive message {current_id}: {message}", "error"
@@ -642,32 +639,27 @@ class EmailViewerApp(App):
def action_toggle_selection(self) -> None: def action_toggle_selection(self) -> None:
"""Toggle selection for the current message.""" """Toggle selection for the current message."""
current_item_data = self.message_store.envelopes[self.current_message_index] current_item_data = self.message_store.envelopes[self.highlighted_message_index]
if current_item_data and current_item_data.get("type") != "header": if current_item_data and current_item_data.get("type") != "header":
message_id = int(current_item_data["id"]) message_id = int(current_item_data["id"])
envelopes_list = self.query_one("#envelopes_list", ListView)
current_list_item = envelopes_list.children[self.highlighted_message_index]
checkbox_label = current_list_item.query_one(".checkbox", Label)
if message_id in self.selected_messages: if message_id in self.selected_messages:
self.selected_messages.remove(message_id) self.selected_messages.remove(message_id)
checkbox_label.remove_class("x-list")
checkbox_label.update("\ue640")
else: else:
self.selected_messages.add(message_id) self.selected_messages.add(message_id)
checkbox_label.add_class("x-list")
# Manually update the current ListItem checkbox_label.update("\uf4a7")
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() self._update_list_view_subtitle()
def action_clear_selection(self) -> None: def action_clear_selection(self) -> None:
"""Clear all selected messages.""" """Clear all selected messages."""
self.selected_messages.clear() self.selected_messages.clear()
self.refresh_list_view_items() # Refresh all items to uncheck checkboxes self.refresh_list_view_items() # Refresh all items to uncheck checkboxes
self._update_list_view_subtitle() self._update_list_view_subtitle()
def action_oldest(self) -> None: def action_oldest(self) -> None:

View File

@@ -16,6 +16,10 @@
} }
.envelope-selected {
tint: $accent 20%;
}
#sidebar:focus-within { #sidebar:focus-within {
background: $panel; background: $panel;
.list_view:blur { .list_view:blur {
@@ -112,6 +116,7 @@ Markdown {
ListItem:even { ListItem:even {
background: rgb(50, 50, 56); background: rgb(50, 50, 56);
} }
& > ListItem { & > ListItem {
&.-highlight, .selection { &.-highlight, .selection {
color: $block-cursor-blurred-foreground; color: $block-cursor-blurred-foreground;
@@ -124,11 +129,18 @@ Markdown {
.envelope_item_row { .envelope_item_row {
height: auto; height: auto;
width: 1fr; width: 1fr;
Horizontal {
.envelope_header_row, .envelope_subject_row {
height: auto; height: auto;
} }
} }
.x-list {
tint: $accent 20%;
}
#open_message_container, #create_task_container { #open_message_container, #create_task_container {
border: panel $border; border: panel $border;
dock: right; dock: right;

View File

@@ -7,7 +7,7 @@ from .client import (
list_accounts, list_accounts,
list_folders, list_folders,
delete_message, delete_message,
archive_message, archive_messages,
get_message_content, get_message_content,
) )
@@ -16,6 +16,6 @@ __all__ = [
"list_accounts", "list_accounts",
"list_folders", "list_folders",
"delete_message", "delete_message",
"archive_message", "archive_messages",
"get_message_content", "get_message_content",
] ]

View File

@@ -116,28 +116,28 @@ async def delete_message(message_id: int) -> bool:
return False return False
async def archive_message(message_id: int) -> bool: # async def archive_message(message_id: int) -> [str, bool]:
""" # """
Archive a message by its ID. # Archive a message by its ID.
Args: # Args:
message_id: The ID of the message to archive # message_id: The ID of the message to archive
Returns: # Returns:
True if archiving was successful, False otherwise # True if archiving was successful, False otherwise
""" # """
try: # try:
process = await asyncio.create_subprocess_shell( # process = await asyncio.create_subprocess_shell(
f"himalaya message move Archives {message_id}", # f"himalaya message move Archives {message_id}",
stdout=asyncio.subprocess.PIPE, # stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, # stderr=asyncio.subprocess.PIPE,
) # )
stdout, stderr = await process.communicate() # stdout, stderr = await process.communicate()
return process.returncode == 0 # return [stdout.decode(), process.returncode == 0]
except Exception as e: # except Exception as e:
logging.error(f"Exception during message archiving: {e}") # logging.error(f"Exception during message archiving: {e}")
return False # return False
async def archive_messages(message_ids: List[str]) -> Tuple[Optional[str], bool]: async def archive_messages(message_ids: List[str]) -> Tuple[Optional[str], bool]:
@@ -197,8 +197,7 @@ async def get_message_content(message_id: int) -> Tuple[Optional[str], bool]:
content = stdout.decode() content = stdout.decode()
return content, True return content, True
else: else:
logging.error(f"Error retrieving message content: { logging.error(f"Error retrieving message content: {stderr.decode()}")
stderr.decode()}")
return None, False return None, False
except Exception as e: except Exception as e:
logging.error(f"Exception during message content retrieval: {e}") logging.error(f"Exception during message content retrieval: {e}")