Compare commits

...

32 Commits

Author SHA1 Message Date
Bendt
d6e10e3dc5 Add calendar invite actions to mail app with A/D/T keybindings
- Add calendar_invite.py with detect/find/respond functions for calendar invites
- Keybindings: A (accept), D (decline), T (tentative)
- Searches Graph API calendarView to find matching event by subject
- Responds via Graph API POST to event/{id}/accept|decline|tentativelyAccept
2025-12-19 16:51:40 -05:00
Bendt
ab6e080bb4 Fix search: increase tasks height to 4, disable input initially to prevent focus steal 2025-12-19 16:44:15 -05:00
Bendt
44cfe3f714 Fix search input stealing focus on app launch - explicitly focus main widget 2025-12-19 16:39:01 -05:00
Bendt
19bc1c7832 Increase calendar search bar height and center label vertically 2025-12-19 16:37:50 -05:00
Bendt
c5202793d4 Update PROJECT_PLAN.md: mark calendar search feature as completed 2025-12-19 16:34:40 -05:00
Bendt
95d3098bf3 Add search feature to calendar app with / keybinding using khal search 2025-12-19 16:34:21 -05:00
Bendt
599507068a Update PROJECT_PLAN.md: mark tasks search feature as completed 2025-12-19 16:31:04 -05:00
Bendt
505fdbcd3d Add search feature to tasks app with / keybinding and live filtering 2025-12-19 16:30:45 -05:00
Bendt
1337d84369 Update PROJECT_PLAN.md: mark subject styling as completed 2025-12-19 16:27:24 -05:00
Bendt
f1ec6c23e1 Enhance mail subject styling - bold bright white, remove label, add spacing 2025-12-19 16:27:11 -05:00
Bendt
4836bda9f9 Add cursor hour header highlighting in calendar week view 2025-12-19 16:25:42 -05:00
Bendt
9f596b10ae Add folder message counts to mail app sidebar 2025-12-19 16:24:56 -05:00
Bendt
98c318af04 Replace emoji and > separator with nerdfont icons in URL shortener 2025-12-19 16:22:32 -05:00
Bendt
994e545bd0 Add toggle read/unread action with 'u' keybinding in mail app 2025-12-19 16:18:09 -05:00
Bendt
fb0af600a1 Update PROJECT_PLAN.md: mark sync TUI default as completed 2025-12-19 16:16:44 -05:00
Bendt
39a5efbb81 Add 'r' keybinding to refresh mail message list 2025-12-19 16:16:12 -05:00
Bendt
b903832d17 Update PROJECT_PLAN.md: mark URL compression as completed 2025-12-19 16:15:39 -05:00
Bendt
8233829621 Add URL compression for mail content viewer 2025-12-19 16:15:08 -05:00
Bendt
36a1ea7c47 Notify tasks app when task is created from mail app 2025-12-19 16:11:42 -05:00
Bendt
4e859613f9 Add IPC notifications to sync dashboard after sync completes 2025-12-19 16:01:44 -05:00
Bendt
b9d818ac09 Update PROJECT_PLAN.md: mark Phase 1 items as completed 2025-12-19 15:58:04 -05:00
Bendt
ab55d0836e Add IPC listener to calendar and tasks apps for sync daemon refresh notifications 2025-12-19 15:57:46 -05:00
Bendt
f5ad43323c Improve mail and calendar UI: tighter checkbox layout and current time styling 2025-12-19 15:56:01 -05:00
Bendt
8933dadcd0 Improve mail sync performance with connection pooling and larger batches 2025-12-19 15:53:34 -05:00
Bendt
aaabd83fc7 Fix sync TUI freeze by completing auth before starting dashboard 2025-12-19 15:50:23 -05:00
Bendt
560bc1d3bd Add date picker for search date/before/after keywords
- Add DatePickerModal using MonthCalendar widget from calendar app
- Detect when user types 'date ', 'before ', or 'after ' and show picker
- Insert selected date (YYYY-MM-DD format) into search input
- Support keyboard navigation (left/right for months, Enter to select)
- Today button for quick selection of current date
2025-12-19 15:45:15 -05:00
Bendt
d4b09e5338 Improve search autocomplete UX
- Tab key accepts autocomplete suggestion (like right arrow)
- Prevent search from firing while autocomplete suggestion is visible
2025-12-19 15:42:31 -05:00
Bendt
9a2f8ee211 Add search autocomplete and fix search state restoration
- Add SuggestFromList with Himalaya keywords for search input autocomplete
- Cache and restore metadata_by_id when cancelling search (fixes navigation)
- Set search_mode=True when opening panel for consistent Escape behavior
- Fix SearchPanel CSS vertical alignment with explicit heights
2025-12-19 15:33:42 -05:00
Bendt
5deebbbf98 Fix search stability and improve Escape key behavior
- Add bounds check in _mark_message_as_read to prevent IndexError
- Clear metadata_by_id when search returns no results
- Escape now focuses search input when in search mode instead of exiting
- Add focus_input() method to SearchPanel
2025-12-19 15:10:50 -05:00
Bendt
807736f808 Support raw Himalaya query syntax in search
Detect when user types a query starting with Himalaya keywords (from, to,
subject, body, date, before, after, flag, not, order by) and pass it
through as-is instead of wrapping it in the compound search pattern.

This allows both:
- Simple searches: 'edson' → searches from/to/subject/body
- Raw queries: 'from edson' → uses Himalaya syntax directly
2025-12-19 15:00:04 -05:00
Bendt
a5f7e78d8d Fix IndexError when pressing Escape to exit search mode
Add bounds check in refresh_list_view_items() to handle cases where
ListView and message_store.envelopes are temporarily out of sync
during transitions (e.g., when exiting search mode).
2025-12-19 14:54:40 -05:00
Bendt
f56f1931bf Fix IndexError when selecting search results
The search header was being added to ListView but not to message_store.envelopes,
causing an index mismatch when marking messages as read. Now the search header
is included in the envelopes list and metadata_by_id is properly updated so
indices align between ListView and the message store.
2025-12-19 14:52:00 -05:00
21 changed files with 1530 additions and 220 deletions

View File

@@ -439,26 +439,26 @@ Implement `/` keybinding for search across all apps with similar UX:
### Phase 1: Critical/High Priority
1. ~~Tasks App: Fix table display~~ (DONE)
2. Sync: Parallelize message downloads
3. Mail: Replace hardcoded RGB colors
4. Mail: Remove envelope icon/checkbox gap
5. Calendar: Current time hour line styling
6. IPC: Implement cross-app refresh notifications
2. ~~Sync: Parallelize message downloads~~ (DONE - connection pooling + batch size increase)
3. ~~Mail: Replace hardcoded RGB colors~~ (DONE - already using theme variables)
4. ~~Mail: Remove envelope icon/checkbox gap~~ (DONE)
5. ~~Calendar: Current time hour line styling~~ (DONE - added surface background)
6. ~~IPC: Implement cross-app refresh notifications~~ (DONE)
### Phase 2: Medium Priority
1. Sync: Default to TUI mode
2. Calendar: Cursor hour header highlighting
1. ~~Sync: Default to TUI mode~~ (DONE - already implemented)
2. ~~Calendar: Cursor hour header highlighting~~ (DONE)
3. Calendar: Responsive detail panel
4. Calendar: Sidebar mini-calendar
5. Calendar: Calendar invites sidebar
6. Mail: Add refresh keybinding
7. Mail: Add mark read/unread action
8. Mail: Folder message counts
9. Mail: URL compression in markdown view
10. Mail: Enhance subject styling
11. Mail: Search feature
12. Tasks: Search feature
13. Calendar: Search feature
6. ~~Mail: Add refresh keybinding~~ (DONE - `r` key)
7. ~~Mail: Add mark read/unread action~~ (DONE - `u` key)
8. ~~Mail: Folder message counts~~ (DONE)
8. ~~Mail: URL compression in markdown view~~ (DONE)
9. ~~Mail: Enhance subject styling~~ (DONE)
10. Mail: Search feature
11. ~~Tasks: Search feature~~ (DONE - `/` key with live filtering)
12. ~~Calendar: Search feature~~ (DONE - `/` key using khal search)
### Phase 3: Low Priority
1. Sync: UI consistency (j/k navigation, borders)

View File

@@ -13,7 +13,7 @@ from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Container, Horizontal, Vertical
from textual.logging import TextualHandler
from textual.widgets import Footer, Header, Static
from textual.widgets import Footer, Header, Static, Input
from textual.reactive import reactive
from src.calendar.backend import CalendarBackend, Event
@@ -22,6 +22,7 @@ from src.calendar.widgets.MonthCalendar import MonthCalendar
from src.calendar.widgets.InvitesPanel import InvitesPanel, CalendarInvite
from src.calendar.widgets.AddEventForm import EventFormData
from src.utils.shared_config import get_theme_name
from src.utils.ipc import IPCListener, IPCMessage
# 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__))))
@@ -115,6 +116,42 @@ class CalendarApp(App):
#event-detail.hidden {
display: none;
}
#search-container {
dock: top;
height: 4;
width: 100%;
background: $surface;
border-bottom: solid $primary;
padding: 0 1;
align: left middle;
}
#search-container.hidden {
display: none;
}
#search-container .search-label {
width: auto;
padding: 0 1;
color: $primary;
}
#search-input {
width: 1fr;
}
#search-results {
dock: bottom;
height: 40%;
border-top: solid $primary;
background: $surface;
padding: 1;
}
#search-results.hidden {
display: none;
}
"""
BINDINGS = [
@@ -132,6 +169,8 @@ class CalendarApp(App):
Binding("r", "refresh", "Refresh", show=True),
Binding("enter", "view_event", "View", show=True),
Binding("a", "add_event", "Add", show=True),
Binding("slash", "search", "Search", show=True),
Binding("escape", "clear_search", "Clear Search", show=False),
Binding("?", "help", "Help", show=True),
]
@@ -142,10 +181,12 @@ class CalendarApp(App):
# Instance attributes
backend: Optional[CalendarBackend]
_invites: list[CalendarInvite]
_search_results: list[Event]
def __init__(self, backend: Optional[CalendarBackend] = None):
super().__init__()
self._invites = []
self._search_results = []
if backend:
self.backend = backend
@@ -158,11 +199,18 @@ class CalendarApp(App):
def compose(self) -> ComposeResult:
"""Create the app layout."""
yield Header()
yield Horizontal(
Static("\uf002 Search:", classes="search-label"), # nf-fa-search
Input(placeholder="Search events...", id="search-input", disabled=True),
id="search-container",
classes="hidden",
)
with Horizontal(id="main-content"):
with Vertical(id="sidebar"):
yield MonthCalendar(id="sidebar-calendar")
yield InvitesPanel(id="sidebar-invites")
yield WeekGrid(id="week-grid")
yield Static(id="search-results", classes="hidden")
yield Static(id="event-detail", classes="hidden")
yield CalendarStatusBar(id="status-bar")
yield Footer()
@@ -171,6 +219,10 @@ class CalendarApp(App):
"""Initialize the app on mount."""
self.theme = get_theme_name()
# Start IPC listener for refresh notifications from sync daemon
self._ipc_listener = IPCListener("calendar", self._on_ipc_message)
self._ipc_listener.start()
# Load events for current week
self.load_events()
@@ -184,6 +236,15 @@ class CalendarApp(App):
self._update_status()
self._update_title()
# Focus the week grid (not the hidden search input)
self.query_one("#week-grid", WeekGrid).focus()
def _on_ipc_message(self, message: IPCMessage) -> None:
"""Handle IPC messages from sync daemon."""
if message.event == "refresh":
# Schedule a reload on the main thread
self.call_from_thread(self.load_events)
async def _load_invites_async(self) -> None:
"""Load pending calendar invites from Microsoft Graph."""
try:
@@ -521,6 +582,8 @@ Keybindings:
w - Toggle weekends (5/7 days)
s - Toggle sidebar
i - Focus invites panel
/ - Search events
Esc - Clear search
Enter - View event details
a - Add new event
r - Refresh
@@ -528,6 +591,90 @@ Keybindings:
"""
self.notify(help_text.strip(), timeout=10)
# Search actions
def action_search(self) -> None:
"""Show search input and focus it."""
search_container = self.query_one("#search-container")
search_container.remove_class("hidden")
search_input = self.query_one("#search-input", Input)
search_input.disabled = False
search_input.focus()
def action_clear_search(self) -> None:
"""Clear search and hide search UI."""
search_container = self.query_one("#search-container")
search_results = self.query_one("#search-results", Static)
search_input = self.query_one("#search-input", Input)
# Only act if search is visible
if not search_container.has_class("hidden") or not search_results.has_class(
"hidden"
):
search_input.value = ""
search_input.disabled = True
search_container.add_class("hidden")
search_results.add_class("hidden")
self._search_results = []
# Focus back to grid
grid = self.query_one("#week-grid", WeekGrid)
grid.focus()
def on_input_submitted(self, event: Input.Submitted) -> None:
"""Handle Enter in search input - perform search."""
if event.input.id != "search-input":
return
query = event.value.strip()
if not query:
return
self._perform_search(query)
def _perform_search(self, query: str) -> None:
"""Perform event search and display results."""
if not self.backend:
return
# Check if backend has search_events method
if not hasattr(self.backend, "search_events"):
self.notify(
"Search not supported by this calendar backend", severity="warning"
)
return
results = self.backend.search_events(query)
self._search_results = results
# Update results display
search_results = self.query_one("#search-results", Static)
if results:
lines = [f"[b]Search results for '{query}': {len(results)} found[/b]", ""]
for event in results[:20]: # Limit display to 20 results
date_str = event.start.strftime("%Y-%m-%d %H:%M")
lines.append(f" {date_str} [b]{event.title}[/b]")
if event.location:
lines.append(f" [dim]{event.location}[/dim]")
if len(results) > 20:
lines.append(f" ... and {len(results) - 20} more")
search_results.update("\n".join(lines))
search_results.remove_class("hidden")
self.notify(f"Found {len(results)} event(s)")
else:
search_results.update(f"[b]No events found matching '{query}'[/b]")
search_results.remove_class("hidden")
self.notify("No events found")
# Focus back to grid
grid = self.query_one("#week-grid", WeekGrid)
grid.focus()
async def action_quit(self) -> None:
"""Quit the app and clean up IPC listener."""
if hasattr(self, "_ipc_listener"):
self._ipc_listener.stop()
self.exit()
def action_focus_invites(self) -> None:
"""Focus on the invites panel and show invite count."""
if not self.show_sidebar:

View File

@@ -216,3 +216,17 @@ class CalendarBackend(ABC):
by_date[d].sort(key=lambda e: e.start)
return by_date
def search_events(self, query: str) -> List[Event]:
"""Search for events matching a query string.
Default implementation returns empty list. Override in subclasses
that support search.
Args:
query: Search string to match against event titles and descriptions
Returns:
List of matching events
"""
return []

View File

@@ -364,6 +364,9 @@ class WeekGridBody(ScrollView):
current_row = (now.hour * rows_per_hour) + (now.minute // minutes_per_row)
is_current_time_row = row_index == current_row
# Check if cursor is on this row
is_cursor_row = row_index == self.cursor_row
# Time label (only show on the hour)
if row_index % rows_per_hour == 0:
hour = row_index // rows_per_hour
@@ -371,10 +374,16 @@ class WeekGridBody(ScrollView):
else:
time_str = " " # Blank for half-hour
# Style time label - highlight current time, dim outside work hours
if is_current_time_row:
# Style time label - highlight current time or cursor, dim outside work hours
if is_cursor_row:
# Highlight the hour label when cursor is on this row
primary_color = self._get_theme_color("primary")
time_style = Style(color=primary_color, bold=True, reverse=True)
elif is_current_time_row:
error_color = self._get_theme_color("error")
time_style = Style(color=error_color, bold=True)
# Add subtle background to current time row for better visibility
surface_color = self._get_theme_color("surface")
time_style = Style(color=error_color, bold=True, bgcolor=surface_color)
elif (
row_index < self._work_day_start * rows_per_hour
or row_index >= self._work_day_end * rows_per_hour

View File

@@ -715,6 +715,24 @@ def sync(
else:
# Default: Launch interactive TUI dashboard
from .sync_dashboard import run_dashboard_sync
from src.services.microsoft_graph.auth import has_valid_cached_token
# Check if we need to authenticate before starting the TUI
# This prevents the TUI from appearing to freeze during device flow auth
if not demo:
scopes = [
"https://graph.microsoft.com/Calendars.Read",
"https://graph.microsoft.com/Mail.ReadWrite",
]
if not has_valid_cached_token(scopes):
click.echo("Authentication required. Please complete the login flow...")
try:
# This will trigger the device flow auth in the console
get_access_token(scopes)
click.echo("Authentication successful! Starting dashboard...")
except Exception as e:
click.echo(f"Authentication failed: {e}")
return
sync_config = {
"org": org,
@@ -936,6 +954,27 @@ def status():
def interactive(org, vdir, notify, dry_run, demo):
"""Launch interactive TUI dashboard for sync operations."""
from .sync_dashboard import run_dashboard_sync
from src.services.microsoft_graph.auth import (
has_valid_cached_token,
get_access_token,
)
# Check if we need to authenticate before starting the TUI
# This prevents the TUI from appearing to freeze during device flow auth
if not demo:
scopes = [
"https://graph.microsoft.com/Calendars.Read",
"https://graph.microsoft.com/Mail.ReadWrite",
]
if not has_valid_cached_token(scopes):
click.echo("Authentication required. Please complete the login flow...")
try:
# This will trigger the device flow auth in the console
get_access_token(scopes)
click.echo("Authentication successful! Starting dashboard...")
except Exception as e:
click.echo(f"Authentication failed: {e}")
return
sync_config = {
"org": org,

View File

@@ -1038,6 +1038,11 @@ async def run_dashboard_sync(
# Schedule next sync
dashboard.schedule_next_sync()
# Notify all running TUI apps to refresh their data
from src.utils.ipc import notify_all
await notify_all({"source": "sync_dashboard_demo"})
except Exception as e:
tracker.error_task("archive", str(e))
@@ -1070,6 +1075,7 @@ async def run_dashboard_sync(
)
from src.utils.calendar_utils import save_events_to_vdir, save_events_to_file
from src.utils.notifications import notify_new_emails
from src.utils.ipc import notify_all
config = dashboard._sync_config
@@ -1372,6 +1378,9 @@ async def run_dashboard_sync(
# Schedule next sync
dashboard.schedule_next_sync()
# Notify all running TUI apps to refresh their data
await notify_all({"source": "sync_dashboard"})
except Exception as e:
# If we fail early (e.g., auth), log to the first pending task
for task_id in [

View File

@@ -0,0 +1,237 @@
"""Calendar invite actions for mail app.
Allows responding to calendar invites directly from email.
"""
import asyncio
import logging
import re
from typing import Optional, Tuple
logger = logging.getLogger(__name__)
def detect_calendar_invite(message_content: str, headers: dict) -> Optional[str]:
"""Detect if a message is a calendar invite and extract event ID if possible.
Calendar invites from Microsoft/Outlook typically have:
- Content-Type: text/calendar or multipart with text/calendar part
- Meeting ID patterns in the content
- Teams/Outlook meeting links
Args:
message_content: The message body content
headers: Message headers
Returns:
Event identifier hint if detected, None otherwise
"""
# Check for calendar-related content patterns
calendar_patterns = [
r"Microsoft Teams meeting",
r"Join the meeting",
r"Meeting ID:",
r"teams\.microsoft\.com/l/meetup-join",
r"Accept\s+Tentative\s+Decline",
r"VEVENT",
r"BEGIN:VCALENDAR",
]
content_lower = message_content.lower() if message_content else ""
for pattern in calendar_patterns:
if re.search(pattern, message_content or "", re.IGNORECASE):
return "calendar_invite_detected"
return None
async def find_event_by_subject(
subject: str, organizer_email: Optional[str] = None
) -> Optional[dict]:
"""Find a calendar event by subject and optionally organizer.
Args:
subject: Event subject to search for
organizer_email: Optional organizer email to filter by
Returns:
Event dict if found, None otherwise
"""
try:
from src.services.microsoft_graph.auth import get_access_token
from src.services.microsoft_graph.client import fetch_with_aiohttp
from datetime import datetime, timedelta
scopes = ["https://graph.microsoft.com/Calendars.Read"]
_, headers = get_access_token(scopes)
# Search for events in the next 60 days with matching subject
start_date = datetime.now()
end_date = start_date + timedelta(days=60)
start_str = start_date.strftime("%Y-%m-%dT00:00:00Z")
end_str = end_date.strftime("%Y-%m-%dT23:59:59Z")
# URL encode the subject for the filter
subject_escaped = subject.replace("'", "''")
url = (
f"https://graph.microsoft.com/v1.0/me/calendarView?"
f"startDateTime={start_str}&endDateTime={end_str}&"
f"$filter=contains(subject,'{subject_escaped}')&"
f"$select=id,subject,organizer,start,end,responseStatus&"
f"$top=10"
)
response = await fetch_with_aiohttp(url, headers)
if not response:
return None
events = response.get("value", [])
if events:
# If organizer email provided, try to match
if organizer_email:
for event in events:
org_email = (
event.get("organizer", {})
.get("emailAddress", {})
.get("address", "")
)
if organizer_email.lower() in org_email.lower():
return event
# Return first match
return events[0]
return None
except Exception as e:
logger.error(f"Error finding event by subject: {e}")
return None
async def respond_to_calendar_invite(event_id: str, response: str) -> Tuple[bool, str]:
"""Respond to a calendar invite.
Args:
event_id: Microsoft Graph event ID
response: Response type - 'accept', 'tentativelyAccept', or 'decline'
Returns:
Tuple of (success, message)
"""
try:
from src.services.microsoft_graph.auth import get_access_token
from src.services.microsoft_graph.calendar import respond_to_invite
scopes = ["https://graph.microsoft.com/Calendars.ReadWrite"]
_, headers = get_access_token(scopes)
success = await respond_to_invite(headers, event_id, response)
if success:
response_text = {
"accept": "accepted",
"tentativelyAccept": "tentatively accepted",
"decline": "declined",
}.get(response, response)
return True, f"Successfully {response_text} the meeting"
else:
return False, "Failed to respond to the meeting invite"
except Exception as e:
logger.error(f"Error responding to invite: {e}")
return False, f"Error: {str(e)}"
def action_accept_invite(app):
"""Accept the current calendar invite."""
_respond_to_current_invite(app, "accept")
def action_decline_invite(app):
"""Decline the current calendar invite."""
_respond_to_current_invite(app, "decline")
def action_tentative_invite(app):
"""Tentatively accept the current calendar invite."""
_respond_to_current_invite(app, "tentativelyAccept")
def _respond_to_current_invite(app, response: str):
"""Helper to respond to the current message's calendar invite."""
current_message_id = app.current_message_id
if not current_message_id:
app.notify("No message selected", severity="warning")
return
# Get message metadata
metadata = app.message_store.get_metadata(current_message_id)
if not metadata:
app.notify("Could not load message metadata", severity="error")
return
subject = metadata.get("subject", "")
from_addr = metadata.get("from", {}).get("addr", "")
if not subject:
app.notify(
"No subject found - cannot match to calendar event", severity="warning"
)
return
# Run the async response in a worker
app.run_worker(
_async_respond_to_invite(app, subject, from_addr, response),
exclusive=True,
name="respond_invite",
)
async def _async_respond_to_invite(
app, subject: str, organizer_email: str, response: str
):
"""Async worker to find and respond to calendar invite."""
# First, find the event
app.call_from_thread(app.notify, f"Searching for calendar event: {subject[:40]}...")
event = await find_event_by_subject(subject, organizer_email)
if not event:
app.call_from_thread(
app.notify,
f"Could not find calendar event matching: {subject[:40]}",
severity="warning",
)
return
event_id = event.get("id")
if not event_id:
app.call_from_thread(
app.notify,
"Could not get event ID from calendar",
severity="error",
)
return
current_response = event.get("responseStatus", {}).get("response", "")
# Check if already responded
if current_response == "accepted" and response == "accept":
app.call_from_thread(
app.notify, "Already accepted this invite", severity="information"
)
return
elif current_response == "declined" and response == "decline":
app.call_from_thread(
app.notify, "Already declined this invite", severity="information"
)
return
# Respond to the invite
success, message = await respond_to_calendar_invite(event_id, response)
severity = "information" if success else "error"
app.call_from_thread(app.notify, message, severity=severity)

View File

@@ -8,6 +8,11 @@ from .screens.SearchPanel import SearchPanel
from .actions.task import action_create_task
from .actions.open import action_open
from .actions.delete import delete_current
from .actions.calendar_invite import (
action_accept_invite,
action_decline_invite,
action_tentative_invite,
)
from src.services.taskwarrior import client as taskwarrior_client
from src.services.himalaya import client as himalaya_client
from src.utils.shared_config import get_theme_name
@@ -77,6 +82,7 @@ class EmailViewerApp(App):
search_query: Reactive[str] = reactive("") # Current search filter
search_mode: Reactive[bool] = reactive(False) # True when showing search results
_cached_envelopes: List[Dict[str, Any]] = [] # Cached envelopes before search
_cached_metadata: Dict[int, Dict[str, Any]] = {} # Cached metadata before search
def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
yield from super().get_system_commands(screen)
@@ -114,7 +120,8 @@ class EmailViewerApp(App):
Binding("h", "toggle_header", "Toggle Envelope Header"),
Binding("t", "create_task", "Create Task"),
Binding("l", "open_links", "Show Links"),
Binding("%", "reload", "Reload message list"),
Binding("r", "reload", "Reload message list"),
Binding("%", "reload", "Reload message list", show=False),
Binding("1", "focus_1", "Focus Accounts Panel"),
Binding("2", "focus_2", "Focus Folders Panel"),
Binding("3", "focus_3", "Focus Envelopes Panel"),
@@ -131,6 +138,10 @@ class EmailViewerApp(App):
Binding("space", "toggle_selection", "Toggle selection"),
Binding("escape", "clear_selection", "Clear selection"),
Binding("/", "search", "Search"),
Binding("u", "toggle_read", "Toggle read/unread"),
Binding("A", "accept_invite", "Accept invite"),
Binding("D", "decline_invite", "Decline invite"),
Binding("T", "tentative_invite", "Tentative"),
]
)
@@ -273,6 +284,12 @@ class EmailViewerApp(App):
async def _mark_message_as_read(self, message_id: int, index: int) -> None:
"""Mark a message as read and update the UI."""
# Skip if message_id is invalid or index is out of bounds
if message_id <= 0:
return
if index < 0 or index >= len(self.message_store.envelopes):
return
# Check if already read
envelope_data = self.message_store.envelopes[index]
if envelope_data and envelope_data.get("type") != "header":
@@ -347,7 +364,13 @@ class EmailViewerApp(App):
try:
list_item = event.item
label = list_item.query_one(Label)
folder_name = str(label.renderable).strip()
folder_text = str(label.renderable).strip()
# Extract folder name (remove count suffix like " [dim](10)[/dim]")
# The format is "FolderName [dim](count)[/dim]" or just "FolderName"
import re
folder_name = re.sub(r"\s*\[dim\]\(\d+\)\[/dim\]$", "", folder_text)
if folder_name and folder_name != self.folder:
self.folder = folder_name
@@ -483,14 +506,19 @@ class EmailViewerApp(App):
async def fetch_folders(self) -> None:
folders_list = self.query_one("#folders_list", ListView)
folders_list.clear()
# Store folder names for count updates
folder_names = ["INBOX"]
# Use the Himalaya client to fetch folders for current account
account = self.current_account if self.current_account else None
folders_list.append(
ListItem(Label("INBOX", classes="folder_name", markup=False))
ListItem(Label("INBOX", classes="folder_name", markup=True))
)
try:
folders_list.loading = True
# Use the Himalaya client to fetch folders for current account
account = self.current_account if self.current_account else None
folders, success = await himalaya_client.list_folders(account=account)
if success and folders:
@@ -499,11 +527,12 @@ class EmailViewerApp(App):
# Skip INBOX since we already added it
if folder_name.upper() == "INBOX":
continue
folder_names.append(folder_name)
item = ListItem(
Label(
folder_name,
classes="folder_name",
markup=False,
markup=True,
)
)
folders_list.append(item)
@@ -514,6 +543,34 @@ class EmailViewerApp(App):
finally:
folders_list.loading = False
# Fetch counts in background and update labels
self._update_folder_counts(folder_names, account)
@work(exclusive=False)
async def _update_folder_counts(
self, folder_names: List[str], account: str | None
) -> None:
"""Fetch and display message counts for folders."""
import asyncio
folders_list = self.query_one("#folders_list", ListView)
async def get_count_for_folder(folder_name: str, index: int):
count, success = await himalaya_client.get_folder_count(
folder_name, account
)
if success and index < len(folders_list.children):
try:
list_item = folders_list.children[index]
label = list_item.query_one(Label)
label.update(f"{folder_name} [dim]({count})[/dim]")
except Exception:
pass # Widget may have been removed
# Fetch counts in parallel
tasks = [get_count_for_folder(name, i) for i, name in enumerate(folder_names)]
await asyncio.gather(*tasks)
def _populate_list_view(self) -> None:
"""Populate the ListView with new items using the new EnvelopeListItem widget."""
envelopes_list = self.query_one("#envelopes_list", ListView)
@@ -543,6 +600,9 @@ class EmailViewerApp(App):
envelopes_list = self.query_one("#envelopes_list", ListView)
for i, list_item in enumerate(envelopes_list.children):
if isinstance(list_item, ListItem):
# Bounds check - ListView and message_store may be out of sync during transitions
if i >= len(self.message_store.envelopes):
break
item_data = self.message_store.envelopes[i]
if item_data and item_data.get("type") != "header":
@@ -802,6 +862,18 @@ class EmailViewerApp(App):
def action_create_task(self) -> None:
action_create_task(self)
def action_accept_invite(self) -> None:
"""Accept the calendar invite from the current email."""
action_accept_invite(self)
def action_decline_invite(self) -> None:
"""Decline the calendar invite from the current email."""
action_decline_invite(self)
def action_tentative_invite(self) -> None:
"""Tentatively accept the calendar invite from the current email."""
action_tentative_invite(self)
def action_open_links(self) -> None:
"""Open the link panel showing links from the current message."""
content_container = self.query_one(ContentContainer)
@@ -882,31 +954,97 @@ class EmailViewerApp(App):
self._update_list_view_subtitle()
def action_clear_selection(self) -> None:
"""Clear all selected messages and exit search mode."""
"""Clear all selected messages or focus search input if in search mode."""
# If in search mode, focus the search input instead of exiting
if self.search_mode:
search_panel = self.query_one("#search_panel", SearchPanel)
search_panel.focus_input()
return
if self.selected_messages:
self.selected_messages.clear()
self.refresh_list_view_items() # Refresh all items to uncheck checkboxes
self._update_list_view_subtitle()
# Exit search mode if active
if self.search_mode:
search_panel = self.query_one("#search_panel", SearchPanel)
search_panel.hide()
self.search_mode = False
self.search_query = ""
async def action_toggle_read(self) -> None:
"""Toggle read/unread status for the current or selected messages."""
folder = self.folder if self.folder else None
account = self.current_account if self.current_account else None
# Restore cached envelopes
if self._cached_envelopes:
self.message_store.envelopes = self._cached_envelopes
self._cached_envelopes = []
self._populate_list_view()
if self.selected_messages:
# Toggle multiple selected messages
for message_id in self.selected_messages:
await self._toggle_message_read_status(message_id, folder, account)
self.show_status(
f"Toggled read status for {len(self.selected_messages)} messages"
)
self.selected_messages.clear()
else:
# Toggle current message
if self.current_message_id:
await self._toggle_message_read_status(
self.current_message_id, folder, account
)
# Restore envelope list title
sort_indicator = "" if self.sort_order_ascending else ""
self.query_one(
"#envelopes_list"
).border_title = f"1⃣ Emails {sort_indicator}"
self._update_list_view_subtitle()
# Refresh the list to show updated read status
await self.fetch_envelopes().wait()
async def _toggle_message_read_status(
self, message_id: int, folder: str | None, account: str | None
) -> None:
"""Toggle read status for a single message."""
# Find the message in the store to check current status
metadata = self.message_store.get_metadata(message_id)
if not metadata:
return
index = metadata.get("index", -1)
if index < 0 or index >= len(self.message_store.envelopes):
return
envelope_data = self.message_store.envelopes[index]
if not envelope_data or envelope_data.get("type") == "header":
return
flags = envelope_data.get("flags", [])
is_read = "Seen" in flags
if is_read:
# Mark as unread
result, success = await himalaya_client.mark_as_unread(
message_id, folder=folder, account=account
)
if success:
if "Seen" in envelope_data.get("flags", []):
envelope_data["flags"].remove("Seen")
self.show_status(f"Marked message {message_id} as unread")
self._update_envelope_read_state(index, is_read=False)
else:
# Mark as read
result, success = await himalaya_client.mark_as_read(
message_id, folder=folder, account=account
)
if success:
if "flags" not in envelope_data:
envelope_data["flags"] = []
if "Seen" not in envelope_data["flags"]:
envelope_data["flags"].append("Seen")
self.show_status(f"Marked message {message_id} as read")
self._update_envelope_read_state(index, is_read=True)
def _update_envelope_read_state(self, index: int, is_read: bool) -> None:
"""Update the visual state of an envelope in the list."""
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 = is_read
if is_read:
envelope_widget.remove_class("unread")
else:
envelope_widget.add_class("unread")
except Exception:
pass # Widget may not exist
def action_oldest(self) -> None:
self.fetch_envelopes() if self.reload_needed else None
@@ -916,12 +1054,19 @@ class EmailViewerApp(App):
self.fetch_envelopes() if self.reload_needed else None
self.show_message(self.message_store.get_newest_id())
def action_reload(self) -> None:
"""Reload the message list."""
self.fetch_envelopes()
self.show_status("Reloading messages...")
def action_search(self) -> None:
"""Open the search panel."""
search_panel = self.query_one("#search_panel", SearchPanel)
if not search_panel.is_visible:
# Cache current envelopes before searching
self._cached_envelopes = list(self.message_store.envelopes)
self._cached_metadata = dict(self.message_store.metadata_by_id)
self.search_mode = True
search_panel.show(self.search_query)
def on_search_panel_search_requested(
@@ -943,10 +1088,13 @@ class EmailViewerApp(App):
self.search_mode = False
self.search_query = ""
# Restore cached envelopes
# Restore cached envelopes and metadata
if self._cached_envelopes:
self.message_store.envelopes = self._cached_envelopes
self._cached_envelopes = []
if self._cached_metadata:
self.message_store.metadata_by_id = self._cached_metadata
self._cached_metadata = {}
self._populate_list_view()
# Restore envelope list title
@@ -1002,31 +1150,82 @@ class EmailViewerApp(App):
config = get_config()
# Add search results header
# Build search header label
if results:
header_label = f"Search: '{query}' ({len(results)} result{'s' if len(results) != 1 else ''})"
else:
header_label = f"Search: '{query}' - No results found"
envelopes_list.append(ListItem(GroupHeader(label=header_label)))
if not results:
# Clear the message viewer when no results
envelopes_list.append(ListItem(GroupHeader(label=header_label)))
content_container = self.query_one(ContentContainer)
content_container.clear_content()
self.message_store.envelopes = []
self.message_store.metadata_by_id = {}
self.total_messages = 0
self.current_message_id = 0
return
# Create a temporary message store for search results
# We need to include the search header in the envelopes so indices match
search_store = MessageStore()
search_store.load(results, self.sort_order_ascending)
# Store for navigation (replace main store temporarily)
# Manually build envelopes list with search header first
# so that ListView indices match message_store.envelopes indices
grouped_envelopes = [{"type": "header", "label": header_label}]
# Sort results by date
sorted_results = sorted(
results,
key=lambda x: x.get("date", ""),
reverse=not self.sort_order_ascending,
)
# Group by month and build metadata
months: Dict[str, bool] = {}
for envelope in sorted_results:
if "id" not in envelope:
continue
# Extract date and determine month group
date_str = envelope.get("date", "")
try:
date = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
month_key = date.strftime("%B %Y")
except (ValueError, TypeError):
month_key = "Unknown Date"
# Add month header if this is a new month
if month_key not in months:
months[month_key] = True
grouped_envelopes.append({"type": "header", "label": month_key})
# Add the envelope
grouped_envelopes.append(envelope)
# Store metadata for quick access (index matches grouped_envelopes)
envelope_id = int(envelope["id"])
search_store.metadata_by_id[envelope_id] = {
"id": envelope_id,
"subject": envelope.get("subject", ""),
"from": envelope.get("from", {}),
"to": envelope.get("to", {}),
"cc": envelope.get("cc", {}),
"date": date_str,
"index": len(grouped_envelopes) - 1,
}
search_store.envelopes = grouped_envelopes
search_store.total_messages = len(search_store.metadata_by_id)
# Store for navigation (replace main store)
self.message_store.envelopes = search_store.envelopes
self.message_store.metadata_by_id = search_store.metadata_by_id
self.total_messages = len(results)
for item in search_store.envelopes:
# Build ListView to match envelopes list exactly
for item in self.message_store.envelopes:
if item and item.get("type") == "header":
envelopes_list.append(ListItem(GroupHeader(label=item["label"])))
elif item:

View File

@@ -82,6 +82,10 @@ class ContentDisplayConfig(BaseModel):
# View mode: "markdown" for pretty rendering, "html" for raw/plain display
default_view_mode: Literal["markdown", "html"] = "markdown"
# URL compression: shorten long URLs for better readability
compress_urls: bool = True
max_url_length: int = 50 # Maximum length before URL is compressed
class LinkPanelConfig(BaseModel):
"""Configuration for the link panel."""

View File

@@ -123,7 +123,7 @@ EnvelopeListItem .status-icon.unread {
}
EnvelopeListItem .checkbox {
width: 2;
width: 1;
padding: 0;
}
@@ -139,12 +139,12 @@ EnvelopeListItem .message-datetime {
EnvelopeListItem .email-subject {
width: 1fr;
padding: 0 4;
padding: 0 3;
}
EnvelopeListItem .email-preview {
width: 1fr;
padding: 0 4;
padding: 0 3;
color: $text-muted;
}

View File

@@ -1,10 +1,12 @@
import logging
import asyncio
from textual.screen import ModalScreen
from textual.widgets import Input, Label, Button, ListView, ListItem
from textual.containers import Vertical, Horizontal, Container
from textual.binding import Binding
from textual import on, work
from src.services.task_client import create_task, get_backend_info
from src.utils.ipc import notify_refresh
class CreateTaskScreen(ModalScreen):
@@ -208,6 +210,8 @@ class CreateTaskScreen(ModalScreen):
if success:
self.app.show_status(f"Task created: {subject}", "success")
# Notify the tasks app to refresh
asyncio.create_task(notify_refresh("tasks", {"source": "mail"}))
self.dismiss()
else:
self.app.show_status(f"Failed to create task: {result}", "error")

View File

@@ -86,6 +86,9 @@ class LinkItem:
- Keeping first and last path segments, eliding middle only if needed
- Adapting to available width
"""
# Nerdfont chevron separator (nf-cod-chevron_right)
sep = " \ueab6 "
# Special handling for common sites
path = path.strip("/")
@@ -95,26 +98,26 @@ class LinkItem:
if match:
repo, type_, num = match.groups()
icon = "#" if type_ == "issues" else "PR#"
return f"{domain} > {repo} {icon}{num}"
return f"{domain}{sep}{repo} {icon}{num}"
match = re.match(r"([^/]+/[^/]+)", path)
if match:
return f"{domain} > {match.group(1)}"
return f"{domain}{sep}{match.group(1)}"
# Google Docs
if "docs.google.com" in domain:
if "/document/" in path:
return f"{domain} > Document"
return f"{domain}{sep}Document"
if "/spreadsheets/" in path:
return f"{domain} > Spreadsheet"
return f"{domain}{sep}Spreadsheet"
if "/presentation/" in path:
return f"{domain} > Slides"
return f"{domain}{sep}Slides"
# Jira/Atlassian
if "atlassian.net" in domain or "jira" in domain.lower():
match = re.search(r"([A-Z]+-\d+)", path)
if match:
return f"{domain} > {match.group(1)}"
return f"{domain}{sep}{match.group(1)}"
# GitLab
if "gitlab" in domain.lower():
@@ -122,7 +125,7 @@ class LinkItem:
if match:
repo, type_, num = match.groups()
icon = "#" if type_ == "issues" else "MR!"
return f"{domain} > {repo} {icon}{num}"
return f"{domain}{sep}{repo} {icon}{num}"
# Generic shortening - keep URL readable
if len(url) <= max_len:
@@ -136,31 +139,31 @@ class LinkItem:
# Try to fit the full path first
full_path = "/".join(path_parts)
result = f"{domain} > {full_path}"
result = f"{domain}{sep}{full_path}"
if len(result) <= max_len:
return result
# Keep first segment + last two segments if possible
if len(path_parts) >= 3:
short_path = f"{path_parts[0]}/.../{path_parts[-2]}/{path_parts[-1]}"
result = f"{domain} > {short_path}"
result = f"{domain}{sep}{short_path}"
if len(result) <= max_len:
return result
# Keep first + last segment
if len(path_parts) >= 2:
short_path = f"{path_parts[0]}/.../{path_parts[-1]}"
result = f"{domain} > {short_path}"
result = f"{domain}{sep}{short_path}"
if len(result) <= max_len:
return result
# Just last segment
result = f"{domain} > .../{path_parts[-1]}"
result = f"{domain}{sep}.../{path_parts[-1]}"
if len(result) <= max_len:
return result
# Truncate with ellipsis as last resort
result = f"{domain} > {path_parts[-1]}"
result = f"{domain}{sep}{path_parts[-1]}"
if len(result) > max_len:
result = result[: max_len - 3] + "..."

View File

@@ -4,8 +4,10 @@ Provides a search input docked to the top of the window with:
- Live search with 1 second debounce
- Cancel button to restore previous state
- Help button showing Himalaya search syntax
- Date picker for date/before/after keywords
"""
from datetime import date
from typing import Optional, List, Dict, Any
from textual.app import ComposeResult
@@ -17,6 +19,34 @@ from textual.screen import ModalScreen
from textual.timer import Timer
from textual.widget import Widget
from textual.widgets import Button, Input, Label, Static
from textual.suggester import SuggestFromList
from src.calendar.widgets.MonthCalendar import MonthCalendar
# Himalaya search keywords for autocomplete
HIMALAYA_KEYWORDS = [
"from ",
"to ",
"subject ",
"body ",
"date ",
"before ",
"after ",
"flag ",
"not ",
"and ",
"or ",
"order by ",
"order by date ",
"order by date asc",
"order by date desc",
"order by from ",
"order by to ",
"order by subject ",
"flag seen",
"flag flagged",
"not flag seen",
]
HIMALAYA_SEARCH_HELP = """
## Himalaya Search Query Syntax
@@ -106,6 +136,94 @@ class SearchHelpModal(ModalScreen[None]):
self.dismiss(None)
class DatePickerModal(ModalScreen[Optional[date]]):
"""Modal with a calendar for selecting a date."""
DEFAULT_CSS = """
DatePickerModal {
align: center middle;
}
DatePickerModal > Vertical {
width: 30;
height: auto;
border: solid $primary;
background: $surface;
padding: 1 2;
}
DatePickerModal > Vertical > Label {
width: 100%;
text-align: center;
margin-bottom: 1;
}
DatePickerModal > Vertical > Horizontal {
height: auto;
align: center middle;
margin-top: 1;
}
DatePickerModal > Vertical > Horizontal > Button {
margin: 0 1;
}
"""
BINDINGS = [
Binding("escape", "cancel", "Cancel"),
Binding("left", "prev_month", "Previous month", show=False),
Binding("right", "next_month", "Next month", show=False),
Binding("enter", "select_date", "Select date", show=False),
]
def __init__(self, keyword: str = "date") -> None:
super().__init__()
self.keyword = keyword
def compose(self) -> ComposeResult:
with Vertical():
yield Label(f"Select date for '{self.keyword}':", id="picker-title")
yield MonthCalendar(id="date-picker-calendar")
with Horizontal():
yield Button("Today", variant="default", id="today-btn")
yield Button("Select", variant="primary", id="select-btn")
yield Button("Cancel", variant="warning", id="cancel-btn")
def on_month_calendar_date_selected(
self, event: MonthCalendar.DateSelected
) -> None:
"""Handle date selection from calendar click."""
self.dismiss(event.date)
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "select-btn":
calendar = self.query_one("#date-picker-calendar", MonthCalendar)
self.dismiss(calendar.selected_date)
elif event.button.id == "today-btn":
calendar = self.query_one("#date-picker-calendar", MonthCalendar)
today = date.today()
calendar.selected_date = today
calendar.display_month = today.replace(day=1)
calendar.refresh()
elif event.button.id == "cancel-btn":
self.dismiss(None)
def action_cancel(self) -> None:
self.dismiss(None)
def action_prev_month(self) -> None:
calendar = self.query_one("#date-picker-calendar", MonthCalendar)
calendar.prev_month()
def action_next_month(self) -> None:
calendar = self.query_one("#date-picker-calendar", MonthCalendar)
calendar.next_month()
def action_select_date(self) -> None:
calendar = self.query_one("#date-picker-calendar", MonthCalendar)
self.dismiss(calendar.selected_date)
class SearchPanel(Widget):
"""Docked search panel with live search capability."""
@@ -125,7 +243,7 @@ class SearchPanel(Widget):
}
SearchPanel > Horizontal {
height: auto;
height: 3;
width: 100%;
align: left middle;
}
@@ -190,6 +308,7 @@ class SearchPanel(Widget):
super().__init__(name=name, id=id, classes=classes)
self._debounce_timer: Optional[Timer] = None
self._last_query: str = ""
self._pending_date_keyword: Optional[str] = None # Track keyword awaiting date
def compose(self) -> ComposeResult:
with Horizontal():
@@ -197,11 +316,40 @@ class SearchPanel(Widget):
yield Input(
placeholder="from <name> or subject <text> or body <text>...",
id="search-input",
suggester=SuggestFromList(HIMALAYA_KEYWORDS, case_sensitive=False),
)
yield Label("", classes="search-status", id="search-status")
yield Button("?", variant="default", id="help-btn")
yield Button("Cancel", variant="warning", id="cancel-btn")
def _has_suggestion(self) -> bool:
"""Check if the search input currently has an autocomplete suggestion."""
try:
input_widget = self.query_one("#search-input", Input)
return bool(input_widget._suggestion and input_widget._cursor_at_end)
except Exception:
return False
def _accept_suggestion(self) -> bool:
"""Accept the current autocomplete suggestion if present. Returns True if accepted."""
try:
input_widget = self.query_one("#search-input", Input)
if input_widget._suggestion and input_widget._cursor_at_end:
input_widget.value = input_widget._suggestion
input_widget.cursor_position = len(input_widget.value)
return True
except Exception:
pass
return False
def on_key(self, event) -> None:
"""Handle key events to intercept Tab for autocomplete."""
if event.key == "tab":
# Try to accept suggestion; if successful, prevent default tab behavior
if self._accept_suggestion():
event.prevent_default()
event.stop()
def show(self, initial_query: str = "") -> None:
"""Show the search panel and focus the input."""
self.add_class("visible")
@@ -216,6 +364,11 @@ class SearchPanel(Widget):
self._cancel_debounce()
self.result_count = -1
def focus_input(self) -> None:
"""Focus the search input field."""
input_widget = self.query_one("#search-input", Input)
input_widget.focus()
@property
def is_visible(self) -> bool:
"""Check if the panel is visible."""
@@ -234,12 +387,44 @@ class SearchPanel(Widget):
def _trigger_search(self) -> None:
"""Trigger the actual search after debounce."""
# Don't search if an autocomplete suggestion is visible
if self._has_suggestion():
return
query = self.query_one("#search-input", Input).value.strip()
if query and query != self._last_query:
self._last_query = query
self.is_searching = True
self.post_message(self.SearchRequested(query))
def _check_date_keyword(self, value: str) -> Optional[str]:
"""Check if the input ends with a date keyword that needs a date picker.
Returns the keyword (date/before/after) if found, None otherwise.
"""
value_lower = value.lower()
for keyword in ("date ", "before ", "after "):
if value_lower.endswith(keyword):
return keyword.strip()
return None
def _show_date_picker(self, keyword: str) -> None:
"""Show the date picker modal for the given keyword."""
self._pending_date_keyword = keyword
def on_date_selected(selected_date: Optional[date]) -> None:
if selected_date:
# Insert the date into the search input
input_widget = self.query_one("#search-input", Input)
date_str = selected_date.strftime("%Y-%m-%d")
input_widget.value = input_widget.value + date_str
input_widget.cursor_position = len(input_widget.value)
self._pending_date_keyword = None
# Refocus the input
self.query_one("#search-input", Input).focus()
self.app.push_screen(DatePickerModal(keyword), on_date_selected)
def on_input_changed(self, event: Input.Changed) -> None:
"""Handle input changes with debounce."""
if event.input.id != "search-input":
@@ -252,6 +437,12 @@ class SearchPanel(Widget):
if not event.value.strip():
return
# Check for date keywords and show picker
date_keyword = self._check_date_keyword(event.value)
if date_keyword:
self._show_date_picker(date_keyword)
return
# Set up new debounce timer (1 second)
self._debounce_timer = self.set_timer(1.0, self._trigger_search)

View File

@@ -6,10 +6,15 @@ from textual.widgets import Static, Markdown, Label
from textual.reactive import reactive
from src.services.himalaya import client as himalaya_client
from src.mail.config import get_config
from src.mail.screens.LinkPanel import extract_links_from_content, LinkItem
from src.mail.screens.LinkPanel import (
extract_links_from_content,
LinkItem,
LinkItem as LinkItemClass,
)
import logging
from datetime import datetime
from typing import Literal, List
from typing import Literal, List, Dict
from urllib.parse import urlparse
import re
import os
import sys
@@ -18,6 +23,88 @@ import sys
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def compress_urls_in_content(content: str, max_url_len: int = 50) -> str:
"""Compress long URLs in markdown/text content for better readability.
Replaces long URLs with shortened versions using the same algorithm
as LinkPanel._shorten_url. Preserves markdown link syntax.
Args:
content: The markdown/text content to process
max_url_len: Maximum length for displayed URLs (default 50)
Returns:
Content with compressed URLs
"""
# Pattern for markdown links: [text](url)
def replace_md_link(match):
anchor_text = match.group(1)
url = match.group(2)
# Don't compress if URL is already short
if len(url) <= max_url_len:
return match.group(0)
# Use LinkItem's shortening algorithm
short_url = LinkItemClass._shorten_url(
url,
urlparse(url).netloc.replace("www.", ""),
urlparse(url).path,
max_url_len,
)
# Keep original anchor text, but if it's the same as URL, use short version
if anchor_text == url or anchor_text.startswith("http"):
return f"[\uf0c1 {short_url}]({url})"
else:
return match.group(0) # Keep original if anchor text is meaningful
# Pattern for bare URLs (not inside markdown links)
def replace_bare_url(match):
url = match.group(0)
# Don't compress if URL is already short
if len(url) <= max_url_len:
return url
parsed = urlparse(url)
short_url = LinkItemClass._shorten_url(
url, parsed.netloc.replace("www.", ""), parsed.path, max_url_len
)
# Return as markdown link with icon
return f"[\uf0c1 {short_url}]({url})"
# First, process markdown links
md_link_pattern = r"\[([^\]]+)\]\((https?://[^)]+)\)"
content = re.sub(md_link_pattern, replace_md_link, content)
# Then process bare URLs that aren't already in markdown links
# This regex matches URLs not preceded by ]( which would indicate markdown link
bare_url_pattern = r'(?<!\]\()https?://[^\s<>"\'\)]+[^\s<>"\'\.\,\)\]]'
# Use a more careful approach to avoid double-processing
# Split content, process bare URLs, rejoin
result = []
last_end = 0
for match in re.finditer(bare_url_pattern, content):
# Check if this URL is inside a markdown link (preceded by "](")
prefix_start = max(0, match.start() - 2)
prefix = content[prefix_start : match.start()]
if prefix.endswith("]("):
continue # Skip URLs that are already markdown link targets
result.append(content[last_end : match.start()])
result.append(replace_bare_url(match))
last_end = match.end()
result.append(content[last_end:])
return "".join(result)
class EnvelopeHeader(Vertical):
def __init__(self, **kwargs):
super().__init__(**kwargs)
@@ -34,9 +121,12 @@ class EnvelopeHeader(Vertical):
self.mount(self.to_label)
self.mount(self.cc_label)
self.mount(self.date_label)
# Add bottom margin to subject for visual separation from metadata
self.subject_label.styles.margin = (0, 0, 1, 0)
def update(self, subject, from_, to, date, cc=None):
self.subject_label.update(f"[b]Subject:[/b] {subject}")
# Subject is prominent - bold, bright white, no label needed
self.subject_label.update(f"[b bright_white]{subject}[/b bright_white]")
self.from_label.update(f"[b]From:[/b] {from_}")
self.to_label.update(f"[b]To:[/b] {to}")
@@ -192,10 +282,18 @@ class ContentContainer(ScrollableContainer):
# Store the raw content for link extraction
self.current_content = content
# Get URL compression settings from config
config = get_config()
compress_urls = config.content_display.compress_urls
max_url_len = config.content_display.max_url_length
try:
if self.current_mode == "markdown":
# For markdown mode, use the Markdown widget
self.content.update(content)
display_content = content
if compress_urls:
display_content = compress_urls_in_content(content, max_url_len)
self.content.update(display_content)
else:
# For HTML mode, use the Static widget with markup
# First, try to extract the body content if it's HTML

View File

@@ -48,12 +48,7 @@ class EnvelopeListItem(Static):
}
EnvelopeListItem .checkbox {
width: 2;
padding: 0;
}
EnvelopeListItem .checkbox {
width: 2;
width: 1;
padding: 0;
}

View File

@@ -115,6 +115,47 @@ async def list_folders(
return [], False
async def get_folder_count(
folder: str,
account: Optional[str] = None,
) -> Tuple[int, bool]:
"""
Get the count of messages in a folder.
Args:
folder: The folder to count messages in
account: The account to use (defaults to default account)
Returns:
Tuple containing:
- Message count
- Success status (True if operation was successful)
"""
try:
# Use a high limit to get all messages, then count them
# This is the most reliable way with himalaya
cmd = f"himalaya envelope list -o json -s 9999 -f '{folder}'"
if account:
cmd += f" -a '{account}'"
process = await asyncio.create_subprocess_shell(
cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode == 0:
envelopes = json.loads(stdout.decode())
return len(envelopes), True
else:
logging.error(f"Error getting folder count: {stderr.decode()}")
return 0, False
except Exception as e:
logging.error(f"Exception during folder count: {e}")
return 0, False
async def delete_message(
message_id: int,
folder: Optional[str] = None,
@@ -312,6 +353,49 @@ async def mark_as_read(
return str(e), False
async def mark_as_unread(
message_id: int,
folder: Optional[str] = None,
account: Optional[str] = None,
) -> Tuple[Optional[str], bool]:
"""
Mark a message as unread by removing the 'seen' flag.
Args:
message_id: The ID of the message to mark as unread
folder: The folder containing the message
account: The account to use
Returns:
Tuple containing:
- Result message or error
- Success status (True if operation was successful)
"""
try:
cmd = f"himalaya flag remove seen {message_id}"
if folder:
cmd += f" -f '{folder}'"
if account:
cmd += f" -a '{account}'"
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 unread", True
else:
error_msg = stderr.decode().strip()
logging.error(f"Error marking message as unread: {error_msg}")
return error_msg or "Unknown error", False
except Exception as e:
logging.error(f"Exception during marking message as unread: {e}")
return str(e), False
async def search_envelopes(
query: str,
folder: Optional[str] = None,
@@ -335,9 +419,33 @@ async def search_envelopes(
- Success status (True if operation was successful)
"""
try:
# Himalaya query keywords that indicate the user is writing a raw query
query_keywords = (
"from ",
"to ",
"subject ",
"body ",
"date ",
"before ",
"after ",
"flag ",
"not ",
"order by ",
)
# Check if user is using raw query syntax
query_lower = query.lower()
is_raw_query = any(query_lower.startswith(kw) for kw in query_keywords)
if is_raw_query:
# Pass through as-is (user knows what they're doing)
search_query = query
else:
# Build a compound query to search from, to, subject, and body
# Himalaya query syntax: from <pattern> or to <pattern> or subject <pattern> or body <pattern>
search_query = f"from {query} or to {query} or subject {query} or body {query}"
search_query = (
f"from {query} or to {query} or subject {query} or body {query}"
)
# Build command with options before the query (query must be at the end, quoted)
cmd = "himalaya envelope list -o json"

View File

@@ -330,3 +330,44 @@ class KhalClient(CalendarBackend):
# khal edit is interactive, so this is limited via CLI
logger.warning("update_event not fully implemented for khal CLI")
return None
def search_events(self, query: str) -> List[Event]:
"""Search for events matching a query string.
Args:
query: Search string to match against event titles and descriptions
Returns:
List of matching events
"""
if not query:
return []
# Use khal search with custom format
format_str = "{title}|{start-time}|{end-time}|{start}|{end}|{location}|{uid}|{description}|{organizer}|{url}|{categories}|{status}|{repeat-symbol}"
args = ["search", "-f", format_str, query]
result = self._run_khal(args)
if result.returncode != 0:
logger.error(f"khal search failed: {result.stderr}")
return []
events = []
for line in result.stdout.strip().split("\n"):
line = line.strip()
if not line:
continue
# Skip day headers
if ", " in line and "|" not in line:
continue
event = self._parse_event_line(line)
if event:
events.append(event)
# Sort by start time
events.sort(key=lambda e: e.start)
return events

View File

@@ -25,6 +25,49 @@ def ensure_directory_exists(path):
os.makedirs(path)
def has_valid_cached_token(scopes=None):
"""
Check if we have a valid cached token (without triggering auth flow).
Args:
scopes: List of scopes to check. If None, uses default scopes.
Returns:
bool: True if a valid cached token exists, False otherwise.
"""
if scopes is None:
scopes = ["https://graph.microsoft.com/Mail.Read"]
client_id = os.getenv("AZURE_CLIENT_ID")
tenant_id = os.getenv("AZURE_TENANT_ID")
if not client_id or not tenant_id:
return False
cache = msal.SerializableTokenCache()
cache_file = "token_cache.bin"
if not os.path.exists(cache_file):
return False
try:
cache.deserialize(open(cache_file, "r").read())
authority = f"https://login.microsoftonline.com/{tenant_id}"
app = msal.PublicClientApplication(
client_id, authority=authority, token_cache=cache
)
accounts = app.get_accounts()
if not accounts:
return False
# Try silent auth - this will return None if token is expired
token_response = app.acquire_token_silent(scopes, account=accounts[0])
return token_response is not None and "access_token" in token_response
except Exception:
return False
def get_access_token(scopes):
"""
Authenticate with Microsoft Graph API and obtain an access token.

View File

@@ -111,7 +111,8 @@ async def fetch_mail_async(
downloaded_count = 0
# Download messages in parallel batches for better performance
BATCH_SIZE = 5
# Using 10 concurrent downloads with connection pooling for better throughput
BATCH_SIZE = 10
for i in range(0, len(messages_to_download), BATCH_SIZE):
# Check if task was cancelled/disabled
@@ -487,7 +488,8 @@ async def fetch_archive_mail_async(
downloaded_count = 0
# Download messages in parallel batches for better performance
BATCH_SIZE = 5
# Using 10 concurrent downloads with connection pooling for better throughput
BATCH_SIZE = 10
for i in range(0, len(messages_to_download), BATCH_SIZE):
# Check if task was cancelled/disabled

View File

@@ -12,12 +12,13 @@ from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import ScrollableContainer, Vertical, Horizontal
from textual.logging import TextualHandler
from textual.widgets import DataTable, Footer, Header, Static, Markdown
from textual.widgets import DataTable, Footer, Header, Static, Markdown, Input
from .config import get_config, TasksAppConfig
from .backend import Task, TaskBackend, TaskPriority, TaskStatus, Project
from .widgets.FilterSidebar import FilterSidebar
from src.utils.shared_config import get_theme_name
from src.utils.ipc import IPCListener, IPCMessage
# 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__))))
@@ -153,6 +154,30 @@ class TasksApp(App):
#notes-pane.hidden {
display: none;
}
#search-container {
dock: top;
height: 4;
width: 100%;
background: $surface;
border-bottom: solid $primary;
padding: 0 1;
align: left middle;
}
#search-container.hidden {
display: none;
}
#search-container .search-label {
width: auto;
padding: 0 1;
color: $primary;
}
#search-input {
width: 1fr;
}
"""
BINDINGS = [
@@ -174,6 +199,8 @@ class TasksApp(App):
Binding("r", "refresh", "Refresh", show=True),
Binding("y", "sync", "Sync", show=True),
Binding("?", "help", "Help", show=True),
Binding("slash", "search", "Search", show=True),
Binding("escape", "clear_search", "Clear Search", show=False),
Binding("enter", "view_task", "View", show=False),
]
@@ -208,6 +235,7 @@ class TasksApp(App):
self.notes_visible = False
self.detail_visible = False
self.sidebar_visible = True # Start with sidebar visible
self.current_search_query = "" # Current search filter
self.config = get_config()
if backend:
@@ -221,6 +249,12 @@ class TasksApp(App):
def compose(self) -> ComposeResult:
"""Create the app layout."""
yield Header()
yield Horizontal(
Static("\uf002 Search:", classes="search-label"), # nf-fa-search
Input(placeholder="Filter tasks...", id="search-input", disabled=True),
id="search-container",
classes="hidden",
)
yield FilterSidebar(id="sidebar")
yield Vertical(
DataTable(id="task-table", cursor_type="row"),
@@ -246,6 +280,11 @@ class TasksApp(App):
def on_mount(self) -> None:
"""Initialize the app on mount."""
self.theme = get_theme_name()
# Start IPC listener for refresh notifications from sync daemon
self._ipc_listener = IPCListener("tasks", self._on_ipc_message)
self._ipc_listener.start()
table = self.query_one("#task-table", DataTable)
# Setup columns based on config with dynamic widths
@@ -271,6 +310,9 @@ class TasksApp(App):
# Load tasks (filtered by current filters)
self.load_tasks()
# Focus the task table (not the hidden search input)
table.focus()
def _setup_columns(self, table: DataTable, columns: list[str]) -> None:
"""Setup table columns with dynamic widths based on available space."""
# Minimum widths for each column type
@@ -408,10 +450,11 @@ class TasksApp(App):
self._update_sidebar()
def _filter_tasks(self, tasks: list[Task]) -> list[Task]:
"""Filter tasks by current project and tag filters using OR logic.
"""Filter tasks by current project, tag filters, and search query.
- If project filter is set, only show tasks from that project
- If tag filters are set, show tasks that have ANY of the selected tags (OR)
- If search query is set, filter by summary, notes, project, and tags
"""
filtered = tasks
@@ -427,6 +470,18 @@ class TasksApp(App):
if any(tag in t.tags for tag in self.current_tag_filters)
]
# Filter by search query (case-insensitive match on summary, notes, project, tags)
if self.current_search_query:
query = self.current_search_query.lower()
filtered = [
t
for t in filtered
if query in t.summary.lower()
or (t.notes and query in t.notes.lower())
or (t.project and query in t.project.lower())
or any(query in tag.lower() for tag in t.tags)
]
return filtered
def _update_sidebar(self) -> None:
@@ -485,6 +540,8 @@ class TasksApp(App):
status_bar.total_tasks = len(self.tasks)
filters = []
if self.current_search_query:
filters.append(f"\uf002 {self.current_search_query}") # nf-fa-search
if self.current_project_filter:
filters.append(f"project:{self.current_project_filter}")
for tag in self.current_tag_filters:
@@ -754,9 +811,10 @@ class TasksApp(App):
self.notify(f"Sorted by {event.column} ({direction})")
def action_clear_filters(self) -> None:
"""Clear all filters."""
"""Clear all filters including search."""
self.current_project_filter = None
self.current_tag_filters = []
self.current_search_query = ""
# Also clear sidebar selections
try:
@@ -765,6 +823,15 @@ class TasksApp(App):
except Exception:
pass
# Clear and hide search input
try:
search_container = self.query_one("#search-container")
search_input = self.query_one("#search-input", Input)
search_input.value = ""
search_container.add_class("hidden")
except Exception:
pass
self.load_tasks()
self.notify("Filters cleared", severity="information")
@@ -800,6 +867,8 @@ Keybindings:
x - Delete task
w - Toggle filter sidebar
c - Clear all filters
/ - Search tasks
Esc - Clear search
r - Refresh
y - Sync with remote
Enter - View task details
@@ -807,6 +876,56 @@ Keybindings:
"""
self.notify(help_text.strip(), timeout=10)
# Search actions
def action_search(self) -> None:
"""Show search input and focus it."""
search_container = self.query_one("#search-container")
search_container.remove_class("hidden")
search_input = self.query_one("#search-input", Input)
search_input.disabled = False
search_input.focus()
def action_clear_search(self) -> None:
"""Clear search and hide search input."""
search_container = self.query_one("#search-container")
search_input = self.query_one("#search-input", Input)
# Only act if search is visible or there's a query
if not search_container.has_class("hidden") or self.current_search_query:
search_input.value = ""
search_input.disabled = True
self.current_search_query = ""
search_container.add_class("hidden")
self.load_tasks()
# Focus back to table
table = self.query_one("#task-table", DataTable)
table.focus()
def on_input_submitted(self, event: Input.Submitted) -> None:
"""Handle Enter in search input - apply search and focus table."""
if event.input.id != "search-input":
return
query = event.value.strip()
self.current_search_query = query
self.load_tasks()
# Focus back to table
table = self.query_one("#task-table", DataTable)
table.focus()
if query:
self.notify(f"Searching: {query}")
def on_input_changed(self, event: Input.Changed) -> None:
"""Handle live search as user types."""
if event.input.id != "search-input":
return
# Live search - filter as user types
self.current_search_query = event.value.strip()
self.load_tasks()
# Notes actions
def action_toggle_notes(self) -> None:
"""Toggle the notes-only pane visibility."""
@@ -897,6 +1016,18 @@ Keybindings:
if task:
self._update_detail_display(task)
def _on_ipc_message(self, message: IPCMessage) -> None:
"""Handle IPC messages from sync daemon."""
if message.event == "refresh":
# Schedule a reload on the main thread
self.call_from_thread(self.load_tasks)
async def action_quit(self) -> None:
"""Quit the app and clean up IPC listener."""
if hasattr(self, "_ipc_listener"):
self._ipc_listener.stop()
self.exit()
def run_app(backend: Optional[TaskBackend] = None) -> None:
"""Run the Tasks TUI application."""

View File

@@ -27,6 +27,31 @@ from src.utils.mail_utils.helpers import (
)
# Module-level session for reuse
_shared_session: aiohttp.ClientSession | None = None
async def get_shared_session() -> aiohttp.ClientSession:
"""Get or create a shared aiohttp session for connection reuse."""
global _shared_session
if _shared_session is None or _shared_session.closed:
connector = aiohttp.TCPConnector(
limit=20, # Max concurrent connections
limit_per_host=10, # Max connections per host
ttl_dns_cache=300, # Cache DNS for 5 minutes
)
_shared_session = aiohttp.ClientSession(connector=connector)
return _shared_session
async def close_shared_session():
"""Close the shared session when done."""
global _shared_session
if _shared_session and not _shared_session.closed:
await _shared_session.close()
_shared_session = None
async def save_mime_to_maildir_async(
maildir_path,
message,
@@ -136,10 +161,13 @@ async def create_mime_message_async(
# First try the direct body content approach
message_id = message.get("id", "")
# Get shared session for connection reuse
session = await get_shared_session()
try:
# First get the message with body content
body_url = f"https://graph.microsoft.com/v1.0/me/messages/{message_id}?$select=body,bodyPreview"
async with aiohttp.ClientSession() as session:
async with session.get(body_url, headers=headers) as response:
if response.status == 200:
body_data = await response.json()
@@ -176,23 +204,25 @@ async def create_mime_message_async(
f"No direct body content for message {truncate_id(message_id)}, trying MIME content..."
)
await fetch_mime_content(
mime_msg, message_id, headers, progress
mime_msg, message_id, headers, progress, session
)
else:
progress.console.print(
f"Failed to get message body: {response.status}. Trying MIME content..."
)
await fetch_mime_content(mime_msg, message_id, headers, progress)
await fetch_mime_content(
mime_msg, message_id, headers, progress, session
)
except Exception as e:
progress.console.print(
f"Error getting message body: {e}. Trying MIME content..."
)
await fetch_mime_content(mime_msg, message_id, headers, progress)
await fetch_mime_content(mime_msg, message_id, headers, progress, session)
# Handle attachments only if we want to download them
if download_attachments:
await add_attachments_async(
mime_msg, message, headers, attachments_dir, progress
mime_msg, message, headers, attachments_dir, progress, session
)
else:
# Add a header to indicate attachment info was skipped
@@ -201,7 +231,7 @@ async def create_mime_message_async(
return mime_msg
async def fetch_mime_content(mime_msg, message_id, headers, progress):
async def fetch_mime_content(mime_msg, message_id, headers, progress, session=None):
"""
Fetch and add MIME content to a message when direct body access fails.
@@ -210,13 +240,17 @@ async def fetch_mime_content(mime_msg, message_id, headers, progress):
message_id (str): Message ID.
headers (dict): Headers including authentication.
progress: Progress instance for updating progress bars.
session (aiohttp.ClientSession, optional): Shared session to use.
"""
# Fallback to getting the MIME content
message_content_url = (
f"https://graph.microsoft.com/v1.0/me/messages/{message_id}/$value"
)
try:
async with aiohttp.ClientSession() as session:
# Use provided session or get shared session
if session is None:
session = await get_shared_session()
async with session.get(message_content_url, headers=headers) as response:
if response.status == 200:
full_content = await response.text()
@@ -275,7 +309,9 @@ async def fetch_mime_content(mime_msg, message_id, headers, progress):
mime_msg.attach(MIMEText(f"Failed to retrieve message body: {str(e)}", "plain"))
async def add_attachments_async(mime_msg, message, headers, attachments_dir, progress):
async def add_attachments_async(
mime_msg, message, headers, attachments_dir, progress, session=None
):
"""
Add attachments to a MIME message.
@@ -285,6 +321,7 @@ async def add_attachments_async(mime_msg, message, headers, attachments_dir, pro
headers (dict): Headers including authentication.
attachments_dir (str): Path to save attachments.
progress: Progress instance for updating progress bars.
session (aiohttp.ClientSession, optional): Shared session to use.
Returns:
None
@@ -296,7 +333,10 @@ async def add_attachments_async(mime_msg, message, headers, attachments_dir, pro
f"https://graph.microsoft.com/v1.0/me/messages/{message_id}/attachments"
)
async with aiohttp.ClientSession() as session:
# Use provided session or get shared session
if session is None:
session = await get_shared_session()
async with session.get(attachments_url, headers=headers) as response:
if response.status != 200:
return
@@ -316,9 +356,7 @@ async def add_attachments_async(mime_msg, message, headers, attachments_dir, pro
for idx, attachment in enumerate(attachments):
attachment_name = safe_filename(attachment.get("name", "attachment"))
attachment_type = attachment.get(
"contentType", "application/octet-stream"
)
attachment_type = attachment.get("contentType", "application/octet-stream")
# Add attachment info to headers for reference
mime_msg[f"X-Attachment-{idx + 1}-Name"] = attachment_name
@@ -331,9 +369,7 @@ async def add_attachments_async(mime_msg, message, headers, attachments_dir, pro
attachment_content = base64.b64decode(attachment["contentBytes"])
# Save attachment to disk
attachment_path = os.path.join(
message_attachments_dir, attachment_name
)
attachment_path = os.path.join(message_attachments_dir, attachment_name)
with open(attachment_path, "wb") as f:
f.write(attachment_content)