Compare commits

...

3 Commits

Author SHA1 Message Date
Bendt
37be42884f fix link shortcut and mark as read 2025-12-18 13:53:55 -05:00
Bendt
4a21eef6f8 selection and archive 2025-12-18 13:44:17 -05:00
Bendt
8244bd94c9 bug fix display and load 2025-12-18 13:29:56 -05:00
10 changed files with 475 additions and 69 deletions

BIN
.coverage

Binary file not shown.

View File

@@ -22,7 +22,7 @@ async def delete_current(app):
next_id, next_idx = app.message_store.find_prev_valid_id(current_index)
# Delete the message using our Himalaya client module
success = await himalaya_client.delete_message(current_message_id)
message, success = await himalaya_client.delete_message(current_message_id)
if success:
app.show_status(f"Message {current_message_id} deleted.", "success")
@@ -38,4 +38,6 @@ async def delete_current(app):
app.current_message_id = 0
app.show_status("No more messages available.", "warning")
else:
app.show_status(f"Failed to delete message {current_message_id}.", "error")
app.show_status(
f"Failed to delete message {current_message_id}: {message}", "error"
)

View File

@@ -3,6 +3,7 @@ from .message_store import MessageStore
from .widgets.ContentContainer import ContentContainer
from .widgets.EnvelopeListItem import EnvelopeListItem, GroupHeader
from .screens.LinkPanel import LinkPanel
from .screens.ConfirmDialog import ConfirmDialog
from .actions.task import action_create_task
from .actions.open import action_open
from .actions.delete import delete_current
@@ -116,10 +117,11 @@ class EmailViewerApp(App):
BINDINGS.extend(
[
Binding("space", "scroll_page_down", "Scroll page down"),
Binding("pagedown", "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("x", "toggle_selection", "Toggle selection", show=False),
Binding("space", "toggle_selection", "Toggle selection"),
Binding("escape", "clear_selection", "Clear selection"),
]
)
@@ -242,16 +244,57 @@ class EmailViewerApp(App):
list_view = self.query_one("#envelopes_list", ListView)
if list_view.index != metadata["index"]:
list_view.index = metadata["index"]
# Mark message as read
await self._mark_message_as_read(message_id, metadata["index"])
else:
logging.warning(f"Message ID {message_id} not found in metadata.")
async def _mark_message_as_read(self, message_id: int, index: int) -> None:
"""Mark a message as read and update the UI."""
# Check if already read
envelope_data = self.message_store.envelopes[index]
if envelope_data and envelope_data.get("type") != "header":
flags = envelope_data.get("flags", [])
if "Seen" in flags:
return # Already read
# Mark as read via himalaya
_, success = await himalaya_client.mark_as_read(message_id)
if success:
# Update the envelope flags in the store
if envelope_data:
if "flags" not in envelope_data:
envelope_data["flags"] = []
if "Seen" not in envelope_data["flags"]:
envelope_data["flags"].append("Seen")
# Update the visual state of the list item
try:
list_view = self.query_one("#envelopes_list", ListView)
list_item = list_view.children[index]
envelope_widget = list_item.query_one(EnvelopeListItem)
envelope_widget.is_read = True
envelope_widget.remove_class("unread")
except Exception:
pass # Widget may not exist
def on_list_view_selected(self, event: ListView.Selected) -> None:
"""Called when an item in the list view is selected."""
if event.list_view.index is None:
return
# Only handle selection from the envelopes list
if event.list_view.id != "envelopes_list":
return
selected_index = event.list_view.index
# Check bounds before accessing
if selected_index < 0 or selected_index >= len(self.message_store.envelopes):
return
current_item = self.message_store.envelopes[selected_index]
if current_item is None or current_item.get("type") == "header":
@@ -261,13 +304,26 @@ class EmailViewerApp(App):
self.current_message_id = message_id
self.current_message_index = selected_index
# Focus the main content panel after selecting a message
self.action_focus_4()
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
# Only handle highlights from the envelopes list
if event.list_view.id != "envelopes_list":
return
highlighted_index = event.list_view.index
# Check bounds before accessing
if highlighted_index < 0 or highlighted_index >= len(
self.message_store.envelopes
):
return
current_item = self.message_store.envelopes[highlighted_index]
if current_item is None or current_item.get("type") == "header":
@@ -277,6 +333,16 @@ class EmailViewerApp(App):
# self.current_message_id = message_id
self.highlighted_message_index = highlighted_index
def on_key(self, event) -> None:
"""Handle key events to intercept space on the envelopes list."""
# Intercept space key when envelopes list is focused to prevent default select behavior
if event.key == "space":
focused = self.focused
if focused and focused.id == "envelopes_list":
event.prevent_default()
event.stop()
self.action_toggle_selection()
@work(exclusive=False)
async def fetch_envelopes(self) -> None:
msglist = self.query_one("#envelopes_list", ListView)
@@ -455,26 +521,108 @@ class EmailViewerApp(App):
self.current_message_index = prev_idx
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
"""Delete the current or selected messages."""
if self.selected_messages:
# --- Multi-message delete: show confirmation ---
count = len(self.selected_messages)
def do_delete(confirmed: bool) -> None:
if confirmed:
self.run_worker(self._delete_selected_messages())
self.push_screen(
ConfirmDialog(
title="Delete Messages",
message=f"Delete {count} selected message{'s' if count > 1 else ''}?",
confirm_label="Delete",
cancel_label="Cancel",
),
do_delete,
)
else:
# --- Single message delete (no confirmation needed) ---
worker = delete_current(self)
await worker.wait()
async def action_archive(self) -> None:
"""Archive the current or selected messages and update UI consistently."""
async def _delete_selected_messages(self) -> None:
"""Delete all selected messages."""
message_ids_to_delete = list(self.selected_messages)
next_id_to_select = None
if message_ids_to_delete:
highest_deleted_id = max(message_ids_to_delete)
metadata = self.message_store.get_metadata(highest_deleted_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
# Delete each message
success_count = 0
for mid in message_ids_to_delete:
message, success = await himalaya_client.delete_message(mid)
if success:
success_count += 1
else:
self.show_status(f"Failed to delete message {mid}: {message}", "error")
if success_count > 0:
self.show_status(
f"Deleted {success_count} message{'s' if success_count > 1 else ''}"
)
self.selected_messages.clear()
self.refresh_list_view_items()
# 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()
async def action_archive(self) -> None:
"""Archive the current or selected messages and update UI consistently."""
if self.selected_messages:
# --- Multi-message archive ---
# --- Multi-message archive: show confirmation ---
count = len(self.selected_messages)
def do_archive(confirmed: bool) -> None:
if confirmed:
self.run_worker(self._archive_selected_messages())
self.push_screen(
ConfirmDialog(
title="Archive Messages",
message=f"Archive {count} selected message{'s' if count > 1 else ''}?",
confirm_label="Archive",
cancel_label="Cancel",
),
do_archive,
)
else:
# --- Single message archive (no confirmation needed) ---
await self._archive_single_message()
async def _archive_selected_messages(self) -> None:
"""Archive all selected messages."""
message_ids_to_archive = list(self.selected_messages)
next_id_to_select = None
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"]
)
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"]
@@ -486,14 +634,31 @@ class EmailViewerApp(App):
)
if success:
self.show_status(message or "Success archived")
self.show_status(
message or f"Archived {len(message_ids_to_archive)} messages"
)
self.selected_messages.clear()
self.refresh_list_view_items()
else:
self.show_status(f"Failed to archive messages: {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:
# --- Single message archive ---
self.action_oldest()
else:
self.action_oldest()
async def _archive_single_message(self) -> None:
"""Archive the current single message."""
if not self.current_message_id:
self.show_status("No message selected to archive.", "error")
return

View File

@@ -66,8 +66,9 @@ class KeybindingsConfig(BaseModel):
create_task: str = "t"
reload: str = "%"
toggle_sort: str = "s"
toggle_selection: str = "x"
toggle_selection: str = "space"
clear_selection: str = "escape"
scroll_page_down: str = "pagedown"
scroll_page_down: str = "space"
scroll_page_up: str = "b"
toggle_main_content: str = "w"
@@ -89,6 +90,13 @@ class LinkPanelConfig(BaseModel):
close_on_open: bool = False
class MailConfig(BaseModel):
"""Configuration for mail operations."""
# Folder to move messages to when archiving
archive_folder: str = "Archive"
class ThemeConfig(BaseModel):
"""Theme/appearance settings."""
@@ -104,6 +112,7 @@ class MaildirGTDConfig(BaseModel):
)
content_display: ContentDisplayConfig = Field(default_factory=ContentDisplayConfig)
link_panel: LinkPanelConfig = Field(default_factory=LinkPanelConfig)
mail: MailConfig = Field(default_factory=MailConfig)
keybindings: KeybindingsConfig = Field(default_factory=KeybindingsConfig)
theme: ThemeConfig = Field(default_factory=ThemeConfig)

View File

@@ -11,17 +11,23 @@
width: 1fr
}
.list_view {
height: 3;
}
#main_content {
width: 2fr;
}
.envelope-selected {
tint: $accent 20%;
}
#sidebar:focus-within {
background: $panel;
.list_view:blur {
height: 3;
}
@@ -30,6 +36,11 @@
}
}
#envelopes_list {
height: 2fr;
}
#main_content:focus, .list_view:focus {
border: round $secondary;
background: rgb(55, 53, 57);

View File

@@ -0,0 +1,108 @@
"""Confirmation dialog screen for destructive actions."""
from textual import on
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical, Container
from textual.screen import ModalScreen
from textual.widgets import Button, Label, Static
class ConfirmDialog(ModalScreen[bool]):
"""A modal confirmation dialog that returns True if confirmed, False otherwise."""
BINDINGS = [
Binding("escape", "cancel", "Cancel"),
Binding("enter", "confirm", "Confirm"),
Binding("y", "confirm", "Yes"),
Binding("n", "cancel", "No"),
]
DEFAULT_CSS = """
ConfirmDialog {
align: center middle;
}
ConfirmDialog #confirm-container {
width: 50;
height: auto;
min-height: 7;
background: $surface;
border: thick $primary;
padding: 1 2;
}
ConfirmDialog #confirm-title {
text-style: bold;
width: 1fr;
height: 1;
text-align: center;
margin-bottom: 1;
}
ConfirmDialog #confirm-message {
width: 1fr;
height: 1;
text-align: center;
margin-bottom: 1;
}
ConfirmDialog #confirm-buttons {
width: 1fr;
height: 3;
align: center middle;
margin-top: 1;
}
ConfirmDialog Button {
margin: 0 1;
}
"""
def __init__(
self,
title: str = "Confirm",
message: str = "Are you sure?",
confirm_label: str = "Yes",
cancel_label: str = "No",
**kwargs,
):
"""Initialize the confirmation dialog.
Args:
title: The dialog title
message: The confirmation message
confirm_label: Label for the confirm button
cancel_label: Label for the cancel button
"""
super().__init__(**kwargs)
self._title = title
self._message = message
self._confirm_label = confirm_label
self._cancel_label = cancel_label
def compose(self) -> ComposeResult:
with Container(id="confirm-container"):
yield Label(self._title, id="confirm-title")
yield Label(self._message, id="confirm-message")
with Horizontal(id="confirm-buttons"):
yield Button(self._cancel_label, id="cancel", variant="default")
yield Button(self._confirm_label, id="confirm", variant="error")
def on_mount(self) -> None:
"""Focus the cancel button by default (safer option)."""
self.query_one("#cancel", Button).focus()
@on(Button.Pressed, "#confirm")
def handle_confirm(self) -> None:
self.dismiss(True)
@on(Button.Pressed, "#cancel")
def handle_cancel(self) -> None:
self.dismiss(False)
def action_confirm(self) -> None:
self.dismiss(True)
def action_cancel(self) -> None:
self.dismiss(False)

View File

@@ -411,6 +411,10 @@ class LinkPanel(ModalScreen):
self._mnemonic_map: dict[str, LinkItem] = {
link.mnemonic: link for link in links if link.mnemonic
}
self._key_buffer: str = ""
self._key_timer = None
# Check if we have any multi-char mnemonics
self._has_multi_char = any(len(m) > 1 for m in self._mnemonic_map.keys())
def compose(self) -> ComposeResult:
with Container(id="link-panel-container"):
@@ -436,18 +440,68 @@ class LinkPanel(ModalScreen):
self.query_one("#link-list").focus()
def on_key(self, event) -> None:
"""Handle mnemonic key presses."""
"""Handle mnemonic key presses with buffering for multi-char mnemonics."""
key = event.key.lower()
# Check for single-char mnemonic
if key in self._mnemonic_map:
self._open_link(self._mnemonic_map[key])
# Only buffer alphabetic keys
if not key.isalpha() or len(key) != 1:
return
# Cancel any pending timer
if self._key_timer:
self._key_timer.stop()
self._key_timer = None
# Add key to buffer
self._key_buffer += key
# Check for exact match with buffered keys
if self._key_buffer in self._mnemonic_map:
# If no multi-char mnemonics exist, open immediately
if not self._has_multi_char:
self._open_link(self._mnemonic_map[self._key_buffer])
self._key_buffer = ""
event.prevent_default()
return
# Check for two-char mnemonics (accumulate?)
# For simplicity, we'll just support single-char for now
# A more sophisticated approach would use a timeout buffer
# Check if any longer mnemonic starts with our buffer
has_longer_match = any(
m.startswith(self._key_buffer) and len(m) > len(self._key_buffer)
for m in self._mnemonic_map.keys()
)
if has_longer_match:
# Wait for possible additional keys
self._key_timer = self.set_timer(0.4, self._flush_key_buffer)
else:
# No longer matches possible, open immediately
self._open_link(self._mnemonic_map[self._key_buffer])
self._key_buffer = ""
event.prevent_default()
return
# Check if buffer could still match something
could_match = any(
m.startswith(self._key_buffer) for m in self._mnemonic_map.keys()
)
if could_match:
# Wait for more keys
self._key_timer = self.set_timer(0.4, self._flush_key_buffer)
event.prevent_default()
else:
# No possible match, clear buffer
self._key_buffer = ""
def _flush_key_buffer(self) -> None:
"""Called after timeout to process buffered keys."""
self._key_timer = None
if self._key_buffer and self._key_buffer in self._mnemonic_map:
self._open_link(self._mnemonic_map[self._key_buffer])
self._key_buffer = ""
def action_open_selected(self) -> None:
"""Open the currently selected link."""

View File

@@ -3,6 +3,7 @@ from .CreateTask import CreateTaskScreen
from .OpenMessage import OpenMessageScreen
from .DocumentViewer import DocumentViewerScreen
from .LinkPanel import LinkPanel, LinkItem, extract_links_from_content
from .ConfirmDialog import ConfirmDialog
__all__ = [
"CreateTaskScreen",
@@ -11,4 +12,5 @@ __all__ = [
"LinkPanel",
"LinkItem",
"extract_links_from_content",
"ConfirmDialog",
]

View File

@@ -54,18 +54,19 @@ class EnvelopeListItem(Static):
EnvelopeListItem .sender-name {
width: 1fr;
color: $text-muted;
}
EnvelopeListItem .message-datetime {
width: auto;
padding: 0 1;
color: $text-muted;
color: $text-disabled;
}
EnvelopeListItem .email-subject {
width: 1fr;
padding: 0 3;
text-style: bold;
color: $text-muted;
}
EnvelopeListItem .email-preview {
@@ -76,10 +77,16 @@ class EnvelopeListItem(Static):
EnvelopeListItem.unread .sender-name {
text-style: bold;
color: $text;
}
EnvelopeListItem.unread .message-datetime {
color: $text-muted;
}
EnvelopeListItem.unread .email-subject {
text-style: bold;
color: $text;
}
"""
@@ -147,6 +154,9 @@ class EnvelopeListItem(Static):
date_str = date_str.replace("Z", "+00:00")
dt = datetime.fromisoformat(date_str)
# Convert to local timezone
dt = dt.astimezone()
parts = []
if self.config.show_date:
parts.append(dt.strftime(self.config.date_format))

View File

@@ -4,6 +4,8 @@ import json
import logging
import subprocess
from src.maildir_gtd.config import get_config
async def list_envelopes(limit: int = 9999) -> Tuple[List[Dict[str, Any]], bool]:
"""
@@ -92,7 +94,7 @@ async def list_folders() -> Tuple[List[Dict[str, Any]], bool]:
return [], False
async def delete_message(message_id: int) -> bool:
async def delete_message(message_id: int) -> Tuple[Optional[str], bool]:
"""
Delete a message by its ID.
@@ -100,7 +102,9 @@ async def delete_message(message_id: int) -> bool:
message_id: The ID of the message to delete
Returns:
True if deletion was successful, False otherwise
Tuple containing:
- Result message or error
- Success status (True if deletion was successful)
"""
try:
process = await asyncio.create_subprocess_shell(
@@ -110,10 +114,15 @@ async def delete_message(message_id: int) -> bool:
)
stdout, stderr = await process.communicate()
return process.returncode == 0
if process.returncode == 0:
return stdout.decode().strip() or "Deleted successfully", True
else:
error_msg = stderr.decode().strip()
logging.error(f"Error deleting message: {error_msg}")
return error_msg or "Unknown error", False
except Exception as e:
logging.error(f"Exception during message deletion: {e}")
return False
return str(e), False
# async def archive_message(message_id: int) -> [str, bool]:
@@ -151,8 +160,10 @@ async def archive_messages(message_ids: List[str]) -> Tuple[Optional[str], bool]
A tuple containing an optional output string and a boolean indicating success.
"""
try:
config = get_config()
archive_folder = config.mail.archive_folder
ids_str = " ".join(message_ids)
cmd = f"himalaya message move Archives {ids_str}"
cmd = f"himalaya message move {archive_folder} {ids_str}"
process = await asyncio.create_subprocess_shell(
cmd,
@@ -162,13 +173,14 @@ async def archive_messages(message_ids: List[str]) -> Tuple[Optional[str], bool]
stdout, stderr = await process.communicate()
if process.returncode == 0:
return stdout.decode(), True
return stdout.decode().strip() or "Archived successfully", True
else:
logging.error(f"Error archiving messages: {stderr.decode()}")
return None, False
error_msg = stderr.decode().strip()
logging.error(f"Error archiving messages: {error_msg}")
return error_msg or "Unknown error", False
except Exception as e:
logging.error(f"Exception during message archiving: {e}")
return None, False
return str(e), False
async def get_message_content(message_id: int) -> Tuple[Optional[str], bool]:
@@ -204,6 +216,39 @@ async def get_message_content(message_id: int) -> Tuple[Optional[str], bool]:
return None, False
async def mark_as_read(message_id: int) -> Tuple[Optional[str], bool]:
"""
Mark a message as read by adding the 'seen' flag.
Args:
message_id: The ID of the message to mark as read
Returns:
Tuple containing:
- Result message or error
- Success status (True if operation was successful)
"""
try:
cmd = f"himalaya flag add seen {message_id}"
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().strip() or "Marked as read", True
else:
error_msg = stderr.decode().strip()
logging.error(f"Error marking message as read: {error_msg}")
return error_msg or "Unknown error", False
except Exception as e:
logging.error(f"Exception during marking message as read: {e}")
return str(e), False
def sync_himalaya():
"""This command does not exist. Halucinated by AI."""
try: