diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md new file mode 100644 index 0000000..94edcf8 --- /dev/null +++ b/PROJECT_PLAN.md @@ -0,0 +1,587 @@ +# LUK Project Plan + +This document outlines planned improvements across the LUK applications (Mail, Calendar, Sync, Tasks). + +--- + +## Bug Fixes + +### Tasks App - Table Not Displaying (FIXED) +**Priority:** Critical +**File:** `src/tasks/app.py` + +The tasks app was not showing the task table due to a CSS grid layout issue. The grid layout with mixed `dock` and `grid` positioning caused the main content area to have 0 height. + +**Fix:** Changed from grid layout to horizontal layout with docked header/footer. + +--- + +## Sync App + +### Performance Improvements + +#### 1. Parallelize Message Downloads +**Priority:** High +**Files:** `src/services/microsoft_graph/mail.py`, `src/services/microsoft_graph/client.py` + +Currently, message downloads are sequential (`for` loop with `await`). This is a significant bottleneck. + +**Current code pattern:** +```python +for msg_id in message_ids: + content = await client.get_message(msg_id) + await save_to_maildir(content) +``` + +**Proposed changes:** +1. Increase semaphore limit in `client.py` from 2 to 5 concurrent HTTP requests +2. Batch parallel downloads using `asyncio.gather()` with batches of 5-10 messages +3. Batch sync state writes every N messages instead of after each message (currently an I/O bottleneck in archive sync) + +**Example implementation:** +```python +BATCH_SIZE = 5 + +async def fetch_batch(batch_ids): + return await asyncio.gather(*[client.get_message(id) for id in batch_ids]) + +for i in range(0, len(message_ids), BATCH_SIZE): + batch = message_ids[i:i + BATCH_SIZE] + results = await fetch_batch(batch) + for content in results: + await save_to_maildir(content) + # Write sync state every batch instead of every message + save_sync_state() +``` + +#### 2. Optimize Maildir Writes +**Priority:** Medium +**File:** `src/utils/mail_utils/maildir.py` + +The `save_mime_to_maildir_async` function is a potential bottleneck. Consider: +- Batching file writes +- Using thread pool for I/O operations + +--- + +### CLI Improvements + +#### 3. Default to TUI Mode +**Priority:** Medium +**File:** `src/cli/sync.py` + +Currently `luk sync` requires subcommands. Change to: +- `luk sync` → Opens TUI (interactive mode) by default +- `luk sync --once` / `luk sync -1` → One-shot sync +- `luk sync --daemon` / `luk sync -d` → Daemon mode +- `luk sync status` → Show sync status + +--- + +### UI Consistency + +#### 4. Navigation and Styling +**Priority:** Low +**File:** `src/cli/sync_dashboard.py` + +- Add `j`/`k` keybindings for list navigation (vim-style) +- Use `border: round` in TCSS for consistency with other apps +- Add `.border_title` styling for list containers + +#### 5. Notifications Toggle +**Priority:** Low +**Files:** `src/cli/sync_dashboard.py`, `src/utils/notifications.py` + +Add a UI switch to enable/disable desktop notifications during sync. + +--- + +## Inter-Process Communication (IPC) + +### Overview +**Priority:** High +**Goal:** Enable real-time updates between apps when data changes: +- Mail app refreshes when sync downloads new messages +- Tasks app refreshes when syncing or when tasks are added from mail app +- Calendar app reloads when sync updates events + +### Platform Options + +#### macOS Options + +1. **Distributed Notifications (NSDistributedNotificationCenter)** + - Native macOS IPC mechanism + - Simple pub/sub model + - Lightweight, no server needed + - Python: Use `pyobjc` (`Foundation.NSDistributedNotificationCenter`) + - Example: `CFNotificationCenterPostNotification()` + +2. **Unix Domain Sockets** + - Works on both macOS and Linux + - Requires a listener process (daemon or embedded in sync) + - More complex but more flexible + - Can send structured data (JSON messages) + +3. **Named Pipes (FIFO)** + - Simple, works on both platforms + - One-way communication + - Less suitable for bidirectional messaging + +4. **File-based Signaling** + - Write a "dirty" file that apps watch via `watchdog` or `fsevents` + - Simple but has latency + - Works cross-platform + +#### Linux Options + +1. **D-Bus** + - Standard Linux IPC + - Python: Use `dbus-python` or `pydbus` + - Powerful but more complex + - Not available on macOS by default + +2. **Unix Domain Sockets** + - Same as macOS, fully compatible + - Recommended for cross-platform compatibility + +3. **systemd Socket Activation** + - If running as a systemd service + - Clean integration with Linux init + +### Recommended Approach + +Use **Unix Domain Sockets** for cross-platform compatibility: + +```python +# Socket path +SOCKET_PATH = Path.home() / ".cache" / "luk" / "ipc.sock" + +# Message types +class IPCMessage: + MAIL_UPDATED = "mail.updated" + TASKS_UPDATED = "tasks.updated" + CALENDAR_UPDATED = "calendar.updated" + +# Sync daemon sends notifications +async def notify_mail_updated(folder: str): + await send_ipc_message({"type": IPCMessage.MAIL_UPDATED, "folder": folder}) + +# Mail app listens +async def listen_for_updates(): + async for message in ipc_listener(): + if message["type"] == IPCMessage.MAIL_UPDATED: + self.load_messages() +``` + +**Implementation files:** +- `src/utils/ipc.py` - IPC client/server utilities +- `src/cli/sync_daemon.py` - Add notification sending +- `src/mail/app.py` - Add listener for mail updates +- `src/tasks/app.py` - Add listener for task updates +- `src/calendar/app.py` - Add listener for calendar updates + +--- + +## Calendar App + +### Visual Improvements + +#### 1. Current Time Hour Line Styling +**Priority:** High +**File:** `src/calendar/widgets/WeekGrid.py` + +The current time indicator hour line should have a subtle contrasting background color to make it more visible. + +#### 2. Cursor Hour Header Highlighting +**Priority:** Medium +**File:** `src/calendar/widgets/WeekGrid.py` + +The hour header at cursor position should have a brighter background, similar to how the day header is highlighted when selected. + +--- + +### Layout Improvements + +#### 3. Responsive Detail Panel +**Priority:** Medium +**Files:** `src/calendar/app.py`, `src/calendar/widgets/` + +When the terminal is wider than X characters (e.g., 120), show a side-docked detail panel for the selected event instead of a modal/overlay. + +#### 4. Sidebar Mini-Calendar +**Priority:** Medium +**Files:** `src/calendar/app.py`, `src/calendar/widgets/MonthCalendar.py` + +When the sidebar is toggled on, display a mini-calendar in the top-left corner showing: +- Current day highlighted +- The week(s) currently visible in the main WeekGrid pane +- Click/navigate to jump to a specific date + +**Implementation:** +- Reuse existing `MonthCalendar` widget in compact mode +- Add reactive property to sync selected week with main pane +- Add to sidebar composition when toggled + +--- + +### Microsoft Graph Integration + +#### 5. Calendar Invites Sidebar +**Priority:** Medium +**Files:** `src/calendar/app.py`, `src/services/microsoft_graph/calendar.py` + +Display a list of pending calendar invites from Microsoft Graph API in a sidebar panel: +- List pending invites (meeting requests) +- Show invite details (organizer, time, subject) +- Accept/Decline/Tentative actions +- No sync needed - fetch on-demand from API + +**API Endpoints:** +- `GET /me/calendar/events?$filter=responseStatus/response eq 'notResponded'` +- `POST /me/events/{id}/accept` +- `POST /me/events/{id}/decline` +- `POST /me/events/{id}/tentativelyAccept` + +**UI:** +- Toggle with keybinding (e.g., `i` for invites) +- Sidebar shows list of pending invites +- Detail view shows full invite info +- Action buttons/keys for response + +--- + +### Search Feature + +#### 6. Calendar Search +**Priority:** Medium +**Files:** `src/calendar/app.py`, `src/services/khal/client.py` (if khal supports search) + +Add search functionality if the underlying backend (khal) supports it: +- `/` keybinding to open search input +- Search by event title, description, location +- Display search results in a modal or replace main view +- Navigate to selected event + +**Check:** Does `khal search` command exist? + +--- + +### Help System + +#### 7. Help Toast (Keep Current Implementation) +**Priority:** Low +**File:** `src/calendar/app.py` + +The `?` key shows a help toast using `self.notify()`. This pattern should be implemented in other apps (Mail, Tasks, Sync) for consistency. + +--- + +## Mail App + +### Layout Fixes + +#### 1. Remove Envelope Icon/Checkbox Gap +**Priority:** High +**File:** `src/mail/widgets/EnvelopeListItem.py` + +There's a 1-character space between the envelope icon and checkbox that should be removed for tighter layout. + +--- + +### Theme Improvements + +#### 2. Replace Hardcoded RGB Colors +**Priority:** High +**File:** `src/mail/email_viewer.tcss` + +Multiple hardcoded RGB values should use Textual theme variables for better theming support: + +| Line | Current | Replacement | +|------|---------|-------------| +| 6 | `border: round rgb(117, 106, 129)` | `border: round $border` | +| 46 | `background: rgb(55, 53, 57)` | `background: $surface` | +| 52, 57 | RGB label colors | Theme variables | +| 69 | `background: rgb(64, 62, 65)` | `background: $panel` | +| 169, 225, 228, 272, 274 | Various RGB colors | Theme variables | + +--- + +### Keybindings + +#### 3. Add Refresh Keybinding +**Priority:** Medium +**File:** `src/mail/app.py` + +Add `r` keybinding to refresh/reload the message list. + +#### 4. Add Mark Read/Unread Action +**Priority:** Medium +**Files:** `src/mail/app.py`, `src/mail/actions/` (new file) + +Add action to toggle read/unread status on selected message(s). + +--- + +### Search Feature + +#### 5. Mail Search +**Priority:** Medium +**Files:** `src/mail/app.py`, backend integration + +Add search functionality if the underlying mail backend supports it: +- `/` keybinding to open search input +- Search by subject, sender, body +- Display results in message list +- Check: Does himalaya or configured backend support search? + +--- + +### UI Enhancements + +#### 6. Folder Message Counts +**Priority:** Medium +**Files:** `src/mail/app.py`, `src/mail/widgets/` + +Display total message count next to each folder name (e.g., "Inbox (42)"). + +#### 7. Sort Setting in Config/UI +**Priority:** Low +**Files:** `src/mail/config.py`, `src/mail/app.py` + +Add configurable sort order (date, sender, subject) with UI toggle. + +--- + +### Message Display + +#### 8. URL Compression in Markdown View +**Priority:** Medium +**Files:** `src/mail/widgets/ContentContainer.py`, `src/mail/screens/LinkPanel.py` + +Compress long URLs in the markdown view to ~50 characters with a nerdfont icon. The `_shorten_url` algorithm in `LinkPanel.py` can be reused. + +**Considerations:** +- Cache processed markdown to avoid re-processing on scroll +- Store URL mapping for click handling + +#### 9. Remove Emoji from Border Title +**Priority:** Low +**File:** `src/mail/widgets/ContentContainer.py` or `EnvelopeHeader.py` + +Remove the envelope emoji prefix before message ID in border titles. + +#### 10. Enhance Subject Styling +**Priority:** Medium +**File:** `src/mail/widgets/EnvelopeHeader.py` + +- Move subject line to the top of the header +- Make it bolder/brighter for better visual hierarchy + +--- + +## Tasks App + +### Search Feature + +#### 1. Task Search +**Priority:** Medium +**Files:** `src/tasks/app.py`, `src/services/dstask/client.py` + +Add search functionality: +- `/` keybinding to open search input +- Search by task summary, notes, project, tags +- Display matching tasks +- Check: dstask likely supports filtering which can be used for search + +**Implementation:** +- Add search input widget (TextInput) +- Filter tasks locally or via dstask command +- Update table to show only matching tasks +- Clear search with Escape + +--- + +### Help System + +#### 2. Implement Help Toast +**Priority:** Low +**File:** `src/tasks/app.py` + +Add `?` keybinding to show help toast (matching Calendar app pattern). +**Note:** This is already implemented in the current code. + +--- + +## Cross-App Improvements + +### 1. Consistent Help System +Implement `?` key help toast in all apps using `self.notify()`: +- Mail: `src/mail/app.py` +- Tasks: `src/tasks/app.py` (already has it) +- Sync: `src/cli/sync_dashboard.py` + +### 2. Consistent Navigation +Add vim-style `j`/`k` navigation to all list views across apps. + +### 3. Consistent Border Styling +Use `border: round` and `.border_title` styling consistently in all TCSS files. + +### 4. Consistent Search Interface +Implement `/` keybinding for search across all apps with similar UX: +- `/` opens search input +- Enter executes search +- Escape clears/closes search +- Results displayed in main view or filtered list + +--- + +## Implementation Priority + +### 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 + +### Phase 2: Medium Priority +1. Sync: Default to TUI mode +2. Calendar: Cursor hour header highlighting +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 + +### Phase 3: Low Priority +1. Sync: UI consistency (j/k navigation, borders) +2. Sync: Notifications toggle +3. Calendar: Help toast (already implemented, replicate to other apps) +4. Mail: Remove emoji from border title +5. Mail: Sort setting in config/UI +6. Cross-app: Consistent help, navigation, and styling + +--- + +## Technical Notes + +### IPC Implementation Details + +For macOS-first with Linux compatibility: + +```python +# src/utils/ipc.py +import asyncio +import json +from pathlib import Path + +SOCKET_PATH = Path.home() / ".cache" / "luk" / "ipc.sock" + +class IPCServer: + """Server for sending notifications to listening apps.""" + + async def broadcast(self, message: dict): + """Send message to all connected clients.""" + pass + +class IPCClient: + """Client for receiving notifications in apps.""" + + async def listen(self, callback): + """Listen for messages and call callback.""" + pass +``` + +### Backend Search Capabilities + +| Backend | Search Support | +|---------|---------------| +| dstask | Filter by project/tag, summary search via shell | +| himalaya | Check `himalaya search` command | +| khal | Check `khal search` command | +| Microsoft Graph | Full text search via `$search` parameter | + +--- + +## Notes + +- All UI improvements should be tested with different terminal sizes +- Theme changes should be tested with multiple Textual themes +- Performance improvements should include before/after benchmarks +- New keybindings should be documented in each app's help toast +- IPC should gracefully handle missing socket (apps work standalone) +- Search should be responsive and not block UI + +--- + +## Library Updates & Python Version Review + +### Priority: Medium (Scheduled Review) + +Periodically review the latest releases of heavily-used libraries to identify: +- Bug fixes that address issues we've encountered +- New features that could improve the codebase +- Deprecation warnings that need to be addressed +- Security updates + +### Key Libraries to Review + +| Library | Current Use | Review Focus | +|---------|-------------|--------------| +| **Textual** | All TUI apps | New widgets, performance improvements, theming changes, CSS features | +| **aiohttp** | Microsoft Graph API client | Async improvements, connection pooling | +| **msal** | Microsoft authentication | Token caching, auth flow improvements | +| **rich** | Console output (via Textual) | New formatting options | +| **orjson** | Fast JSON parsing | Performance improvements | +| **pyobjc** (macOS) | Notifications | API changes, compatibility | + +### Textual Changelog Review Checklist + +When reviewing Textual releases, check for: +1. **New widgets** - Could replace custom implementations +2. **CSS features** - New selectors, pseudo-classes, properties +3. **Theming updates** - New theme variables, design token changes +4. **Performance** - Rendering optimizations, memory improvements +5. **Breaking changes** - Deprecated APIs, signature changes +6. **Worker improvements** - Background task handling + +### Python Version Upgrade + +#### Current Status +- Check `.python-version` and `pyproject.toml` for current Python version +- Evaluate upgrade to Python 3.13 or 3.14 when stable + +#### Python 3.13 Features to Consider +- Improved error messages +- Type system enhancements (`typing` module improvements) +- Performance optimizations (PEP 709 - inline comprehensions) + +#### Python 3.14 Considerations +- **Status:** Currently in alpha/beta (as of Dec 2024) +- **Expected stable release:** October 2025 +- **Recommendation:** Wait for stable release before adopting +- **Pre-release testing:** Can test compatibility in CI/CD before adoption + +#### Upgrade Checklist +1. [ ] Review Python release notes for breaking changes +2. [ ] Check library compatibility (especially `pyobjc`, `textual`, `msal`) +3. [ ] Update `.python-version` (mise/pyenv) +4. [ ] Update `pyproject.toml` `requires-python` field +5. [ ] Run full test suite +6. [ ] Test on both macOS and Linux (if applicable) +7. [ ] Update CI/CD Python version + +### Action Items + +1. **Quarterly Review** - Schedule quarterly reviews of library changelogs +2. **Dependabot/Renovate** - Consider adding automated dependency update PRs +3. **Changelog Reading** - Before updating, read changelogs for breaking changes +4. **Test Coverage** - Ensure adequate test coverage before major updates + diff --git a/src/calendar/widgets/WeekGrid.py b/src/calendar/widgets/WeekGrid.py index ad1c019..8d64512 100644 --- a/src/calendar/widgets/WeekGrid.py +++ b/src/calendar/widgets/WeekGrid.py @@ -373,8 +373,8 @@ class WeekGridBody(ScrollView): # Style time label - highlight current time, dim outside work hours if is_current_time_row: - secondary_color = self._get_theme_color("secondary") - time_style = Style(color=secondary_color, bold=True) + error_color = self._get_theme_color("error") + time_style = Style(color=error_color, bold=True) elif ( row_index < self._work_day_start * rows_per_hour or row_index >= self._work_day_end * rows_per_hour @@ -388,13 +388,19 @@ class WeekGridBody(ScrollView): # Event cells for each day for col_idx, day_col in enumerate(self._days): - cell_text, cell_style = self._render_event_cell(day_col, row_index, col_idx) + cell_text, cell_style = self._render_event_cell( + day_col, row_index, col_idx, is_current_time_row + ) segments.append(Segment(cell_text, cell_style)) return Strip(segments) def _render_event_cell( - self, day_col: DayColumn, row_index: int, col_idx: int + self, + day_col: DayColumn, + row_index: int, + col_idx: int, + is_current_time_row: bool = False, ) -> Tuple[str, Style]: """Render a single cell for a day/time slot.""" events_at_row = day_col.grid[row_index] if row_index < len(day_col.grid) else [] @@ -404,10 +410,16 @@ class WeekGridBody(ScrollView): is_cursor = col_idx == self.cursor_col and row_index == self.cursor_row + # Get colors for current time line + error_color = self._get_theme_color("error") if is_current_time_row else None + if not events_at_row: # Empty cell if is_cursor: return ">" + " " * (day_col_width - 1), Style(reverse=True) + elif is_current_time_row: + # Current time indicator line + return "─" * day_col_width, Style(color=error_color, bold=True) else: # Grid line style if row_index % rows_per_hour == 0: diff --git a/src/cli/__init__.py b/src/cli/__init__.py index 6d8a5af..ee2f6e9 100644 --- a/src/cli/__init__.py +++ b/src/cli/__init__.py @@ -1,37 +1,55 @@ # CLI module for the application +# Uses lazy imports to speed up startup time import click - -from .sync import sync -from .drive import drive -from .email import email -from .calendar import calendar -from .ticktick import ticktick -from .godspeed import godspeed -from .gitlab_monitor import gitlab_monitor -from .tasks import tasks +import importlib -@click.group() +class LazyGroup(click.Group): + """A click Group that lazily loads subcommands.""" + + def __init__(self, *args, lazy_subcommands=None, **kwargs): + super().__init__(*args, **kwargs) + self._lazy_subcommands = lazy_subcommands or {} + + def list_commands(self, ctx): + base = super().list_commands(ctx) + lazy = list(self._lazy_subcommands.keys()) + return sorted(base + lazy) + + def get_command(self, ctx, cmd_name): + if cmd_name in self._lazy_subcommands: + return self._load_command(cmd_name) + return super().get_command(ctx, cmd_name) + + def _load_command(self, cmd_name): + module_path, attr_name = self._lazy_subcommands[cmd_name] + # Handle relative imports + if module_path.startswith("."): + module = importlib.import_module(module_path, package="src.cli") + else: + module = importlib.import_module(module_path) + return getattr(module, attr_name) + + +# Create CLI with lazy loading - commands only imported when invoked +@click.group( + cls=LazyGroup, + lazy_subcommands={ + "sync": (".sync", "sync"), + "drive": (".drive", "drive"), + "email": (".email", "email"), + "mail": (".email", "email"), # alias + "calendar": (".calendar", "calendar"), + "ticktick": (".ticktick", "ticktick"), + "tt": (".ticktick", "ticktick"), # alias + "godspeed": (".godspeed", "godspeed"), + "gs": (".godspeed", "godspeed"), # alias + "gitlab_monitor": (".gitlab_monitor", "gitlab_monitor"), + "glm": (".gitlab_monitor", "gitlab_monitor"), # alias + "tasks": (".tasks", "tasks"), + }, +) def cli(): - """Root command for the CLI.""" + """LUK - Local Unix Kit for productivity.""" pass - - -cli.add_command(sync) -cli.add_command(drive) -cli.add_command(email) -cli.add_command(calendar) -cli.add_command(ticktick) -cli.add_command(godspeed) -cli.add_command(gitlab_monitor) -cli.add_command(tasks) - -# Add 'mail' as an alias for email -cli.add_command(email, name="mail") -# Add 'tt' as a short alias for ticktick -cli.add_command(ticktick, name="tt") -# Add 'gs' as a short alias for godspeed -cli.add_command(godspeed, name="gs") -# Add 'glm' as a short alias for gitlab_monitor -cli.add_command(gitlab_monitor, name="glm") diff --git a/src/cli/sync_daemon.py b/src/cli/sync_daemon.py index 544c791..b1bb886 100644 --- a/src/cli/sync_daemon.py +++ b/src/cli/sync_daemon.py @@ -14,6 +14,7 @@ from typing import Optional, Dict, Any from src.cli.sync import _sync_outlook_data, should_run_godspeed_sync, should_run_sweep from src.cli.sync import run_godspeed_sync, run_task_sweep, load_sync_state +from src.utils.ipc import notify_all, notify_refresh class SyncDaemon: @@ -247,6 +248,13 @@ class SyncDaemon: notify=self.config.get("notify", False), ) self.logger.info("Sync completed successfully") + + # Notify all running TUI apps to refresh their data + results = await notify_all({"source": "sync_daemon"}) + notified = [app for app, success in results.items() if success] + if notified: + self.logger.info(f"Notified apps to refresh: {', '.join(notified)}") + except Exception as e: self.logger.error(f"Sync failed: {e}") diff --git a/src/mail/app.py b/src/mail/app.py index 6f4e26b..6b426ea 100644 --- a/src/mail/app.py +++ b/src/mail/app.py @@ -10,6 +10,7 @@ from .actions.delete import delete_current 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 +from src.utils.ipc import IPCListener, IPCMessage from textual.containers import Container, ScrollableContainer, Vertical, Horizontal from textual.timer import Timer from textual.binding import Binding @@ -149,7 +150,7 @@ class EmailViewerApp(App): async def on_mount(self) -> None: self.alert_timer: Timer | None = None # Timer to throttle alerts self.theme = get_theme_name() - self.title = "MaildirGTD" + self.title = "LUK Mail" self.query_one("#main_content").border_title = self.status_title sort_indicator = "↑" if self.sort_order_ascending else "↓" self.query_one("#envelopes_list").border_title = f"1️⃣ Emails {sort_indicator}" @@ -157,6 +158,10 @@ class EmailViewerApp(App): self.query_one("#folders_list").border_title = "3️⃣ Folders" + # Start IPC listener for refresh notifications from sync daemon + self._ipc_listener = IPCListener("mail", self._on_ipc_message) + self._ipc_listener.start() + self.fetch_accounts() self.fetch_folders() worker = self.fetch_envelopes() @@ -164,6 +169,12 @@ class EmailViewerApp(App): self.query_one("#envelopes_list").focus() self.action_oldest() + 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.fetch_envelopes) + def compute_status_title(self): metadata = self.message_store.get_metadata(self.current_message_id) message_date = metadata["date"] if metadata else "N/A" @@ -820,6 +831,9 @@ class EmailViewerApp(App): self.query_one("#envelopes_list").focus() def action_quit(self) -> None: + # Stop IPC listener before exiting + if hasattr(self, "_ipc_listener"): + self._ipc_listener.stop() self.exit() def action_toggle_selection(self) -> None: diff --git a/src/mail/email_viewer.tcss b/src/mail/email_viewer.tcss index a6b58f8..a735d2a 100644 --- a/src/mail/email_viewer.tcss +++ b/src/mail/email_viewer.tcss @@ -3,7 +3,7 @@ #main_content, .list_view { scrollbar-size: 1 1; - border: round rgb(117, 106, 129); + border: round $border; height: 1fr; } @@ -43,18 +43,18 @@ #main_content:focus, .list_view:focus { border: round $secondary; - background: rgb(55, 53, 57); + background: $surface; border-title-style: bold; } Label#task_prompt { padding: 1; - color: rgb(128,128,128); + color: $text-muted; } Label#task_prompt_label { padding: 1; - color: rgb(255, 216, 102); + color: $warning; } Label#message_label { @@ -66,7 +66,7 @@ StatusTitle { width: 100%; height: 1; color: $text; - background: rgb(64, 62, 65); + background: $panel; content-align: center middle; } @@ -113,8 +113,8 @@ EnvelopeListItem .envelope-row-3 { } EnvelopeListItem .status-icon { - width: 3; - padding: 0 1 0 0; + width: 2; + padding: 0; color: $text-muted; } @@ -124,7 +124,7 @@ EnvelopeListItem .status-icon.unread { EnvelopeListItem .checkbox { width: 2; - padding: 0 1 0 0; + padding: 0; } EnvelopeListItem .sender-name { @@ -166,11 +166,11 @@ EnvelopeListItem.selected { GroupHeader { height: 1; width: 1fr; - background: rgb(64, 62, 65); + background: $panel; } GroupHeader .group-header-label { - color: rgb(160, 160, 160); + color: $text-muted; text-style: bold; padding: 0 1; width: 1fr; @@ -222,10 +222,10 @@ GroupHeader .group-header-label { #envelopes_list { ListItem:odd { - background: rgb(45, 45, 46); + background: $surface; } ListItem:even { - background: rgb(50, 50, 56); + background: $surface-darken-1; } & > ListItem { @@ -269,9 +269,9 @@ GroupHeader .group-header-label { } Label.group_header { - color: rgb(140, 140, 140); + color: $text-muted; text-style: bold; - background: rgb(64, 62, 65); + background: $panel; width: 100%; padding: 0 1; } @@ -300,6 +300,3 @@ ContentContainer { width: 100%; height: 1fr; } -.checkbox { - padding-right: 1; -} diff --git a/src/mail/widgets/EnvelopeListItem.py b/src/mail/widgets/EnvelopeListItem.py index 8ecb0ba..d10591a 100644 --- a/src/mail/widgets/EnvelopeListItem.py +++ b/src/mail/widgets/EnvelopeListItem.py @@ -44,12 +44,17 @@ class EnvelopeListItem(Static): EnvelopeListItem .status-icon { width: 2; - padding: 0 1 0 0; + padding: 0; } EnvelopeListItem .checkbox { width: 2; - padding: 0 1 0 0; + padding: 0; + } + + EnvelopeListItem .checkbox { + width: 2; + padding: 0; } EnvelopeListItem .sender-name { diff --git a/src/services/microsoft_graph/client.py b/src/services/microsoft_graph/client.py index f86436c..ee0f361 100644 --- a/src/services/microsoft_graph/client.py +++ b/src/services/microsoft_graph/client.py @@ -13,8 +13,8 @@ logging.getLogger("aiohttp.access").setLevel(logging.ERROR) logging.getLogger("urllib3").setLevel(logging.ERROR) logging.getLogger("asyncio").setLevel(logging.ERROR) -# Define a global semaphore for throttling - reduced for better compliance -semaphore = asyncio.Semaphore(2) +# Define a global semaphore for throttling - increased for better parallelization +semaphore = asyncio.Semaphore(5) async def _handle_throttling_retry(func, *args, max_retries=3): diff --git a/src/services/microsoft_graph/mail.py b/src/services/microsoft_graph/mail.py index c283a39..deeeddb 100644 --- a/src/services/microsoft_graph/mail.py +++ b/src/services/microsoft_graph/mail.py @@ -110,26 +110,47 @@ async def fetch_mail_async( progress.update(task_id, total=len(messages_to_download), completed=0) downloaded_count = 0 - for message in messages_to_download: + # Download messages in parallel batches for better performance + BATCH_SIZE = 5 + + for i in range(0, len(messages_to_download), BATCH_SIZE): # Check if task was cancelled/disabled if is_cancelled and is_cancelled(): progress.console.print("Task cancelled, stopping inbox fetch") break - progress.console.print( - f"Processing message: {message.get('subject', 'No Subject')}", end="\r" - ) - await save_mime_to_maildir_async( - maildir_path, - message, - attachments_dir, - headers, - progress, - dry_run, - download_attachments, - ) - progress.update(task_id, advance=1) - downloaded_count += 1 + batch = messages_to_download[i : i + BATCH_SIZE] + + # Create tasks for parallel download + async def download_message(message): + progress.console.print( + f"Processing message: {message.get('subject', 'No Subject')[:50]}", + end="\r", + ) + await save_mime_to_maildir_async( + maildir_path, + message, + attachments_dir, + headers, + progress, + dry_run, + download_attachments, + ) + return 1 + + # Execute batch in parallel + tasks = [download_message(msg) for msg in batch] + results = await asyncio.gather(*tasks, return_exceptions=True) + + # Count successful downloads + batch_success = sum(1 for r in results if r == 1) + downloaded_count += batch_success + progress.update(task_id, advance=len(batch)) + + # Log any errors + for idx, result in enumerate(results): + if isinstance(result, Exception): + progress.console.print(f"Error downloading message: {result}") progress.update(task_id, completed=downloaded_count) progress.console.print(f"\nFinished downloading {downloaded_count} new messages.") @@ -461,37 +482,57 @@ async def fetch_archive_mail_async( # Update progress to reflect only the messages we actually need to download progress.update(task_id, total=len(messages_to_download), completed=0) - # Load sync state once, we'll update it incrementally + # Load sync state once, we'll update it after each batch for resilience synced_ids = _load_archive_sync_state(maildir_path) if not dry_run else set() downloaded_count = 0 - for message in messages_to_download: + # Download messages in parallel batches for better performance + BATCH_SIZE = 5 + + for i in range(0, len(messages_to_download), BATCH_SIZE): # Check if task was cancelled/disabled if is_cancelled and is_cancelled(): progress.console.print("Task cancelled, stopping archive fetch") break - progress.console.print( - f"Processing archived message: {message.get('subject', 'No Subject')[:50]}", - end="\r", - ) - # Save to .Archive folder instead of main maildir - await save_mime_to_maildir_async( - archive_dir, # Use archive_dir instead of maildir_path - message, - attachments_dir, - headers, - progress, - dry_run, - download_attachments, - ) - progress.update(task_id, advance=1) - downloaded_count += 1 + batch = messages_to_download[i : i + BATCH_SIZE] + batch_msg_ids = [] - # Update sync state after each message for resilience - # This ensures we don't try to re-upload this message in archive_mail_async - if not dry_run: - synced_ids.add(message["id"]) + # Create tasks for parallel download + async def download_message(message): + progress.console.print( + f"Processing archived message: {message.get('subject', 'No Subject')[:50]}", + end="\r", + ) + # Save to .Archive folder instead of main maildir + await save_mime_to_maildir_async( + archive_dir, # Use archive_dir instead of maildir_path + message, + attachments_dir, + headers, + progress, + dry_run, + download_attachments, + ) + return message["id"] + + # Execute batch in parallel + tasks = [download_message(msg) for msg in batch] + results = await asyncio.gather(*tasks, return_exceptions=True) + + # Process results and collect successful message IDs + for result in results: + if isinstance(result, Exception): + progress.console.print(f"Error downloading archived message: {result}") + elif result: + batch_msg_ids.append(result) + downloaded_count += 1 + + progress.update(task_id, advance=len(batch)) + + # Update sync state after each batch (not each message) for resilience + performance + if not dry_run and batch_msg_ids: + synced_ids.update(batch_msg_ids) _save_archive_sync_state(maildir_path, synced_ids) progress.update(task_id, completed=downloaded_count) diff --git a/src/tasks/app.py b/src/tasks/app.py index b996f39..99692be 100644 --- a/src/tasks/app.py +++ b/src/tasks/app.py @@ -46,24 +46,20 @@ class TasksApp(App): CSS = """ Screen { - layout: grid; - grid-size: 2; - grid-columns: auto 1fr; - grid-rows: auto 1fr auto auto; + layout: horizontal; } Header { - column-span: 2; + dock: top; } Footer { - column-span: 2; + dock: bottom; } #sidebar { width: 28; height: 100%; - row-span: 1; } #sidebar.hidden { @@ -116,7 +112,6 @@ class TasksApp(App): background: $surface; color: $text-muted; padding: 0 1; - column-span: 2; } #detail-pane { @@ -124,7 +119,6 @@ class TasksApp(App): height: 50%; border-top: solid $primary; background: $surface; - column-span: 2; } #detail-pane.hidden { @@ -154,7 +148,6 @@ class TasksApp(App): border-top: solid $primary; padding: 1; background: $surface; - column-span: 2; } #notes-pane.hidden { diff --git a/src/utils/ipc.py b/src/utils/ipc.py new file mode 100644 index 0000000..61251eb --- /dev/null +++ b/src/utils/ipc.py @@ -0,0 +1,318 @@ +"""Inter-Process Communication using Unix Domain Sockets. + +This module provides a simple pub/sub mechanism for cross-app notifications. +The sync daemon can broadcast messages when data changes, and TUI apps can +listen for these messages to refresh their displays. + +Usage: + # In sync daemon (publisher): + from src.utils.ipc import notify_refresh + await notify_refresh("mail") # Notify mail app to refresh + await notify_refresh("calendar") # Notify calendar app to refresh + await notify_refresh("tasks") # Notify tasks app to refresh + + # In TUI apps (subscriber): + from src.utils.ipc import IPCListener + + class MyApp(App): + def on_mount(self): + self.ipc_listener = IPCListener("mail", self.on_refresh) + self.ipc_listener.start() + + def on_unmount(self): + self.ipc_listener.stop() + + async def on_refresh(self, message): + # Refresh the app's data + await self.refresh_data() +""" + +import asyncio +import json +import os +import socket +import threading +from pathlib import Path +from typing import Callable, Optional, Any, Dict + +# Socket paths for each app type +SOCKET_DIR = Path("~/.local/share/luk/ipc").expanduser() +SOCKET_PATHS = { + "mail": SOCKET_DIR / "mail.sock", + "calendar": SOCKET_DIR / "calendar.sock", + "tasks": SOCKET_DIR / "tasks.sock", +} + + +def ensure_socket_dir(): + """Ensure the socket directory exists.""" + SOCKET_DIR.mkdir(parents=True, exist_ok=True) + + +def get_socket_path(app_type: str) -> Path: + """Get the socket path for a given app type.""" + if app_type not in SOCKET_PATHS: + raise ValueError( + f"Unknown app type: {app_type}. Must be one of: {list(SOCKET_PATHS.keys())}" + ) + return SOCKET_PATHS[app_type] + + +class IPCMessage: + """A message sent via IPC.""" + + def __init__(self, event: str, data: Optional[Dict[str, Any]] = None): + self.event = event + self.data = data or {} + + def to_json(self) -> str: + return json.dumps({"event": self.event, "data": self.data}) + + @classmethod + def from_json(cls, json_str: str) -> "IPCMessage": + parsed = json.loads(json_str) + return cls(event=parsed["event"], data=parsed.get("data", {})) + + +async def notify_refresh(app_type: str, data: Optional[Dict[str, Any]] = None) -> bool: + """Send a refresh notification to a specific app. + + Args: + app_type: The type of app to notify ("mail", "calendar", "tasks") + data: Optional data to include with the notification + + Returns: + True if the notification was sent successfully, False otherwise + """ + socket_path = get_socket_path(app_type) + + if not socket_path.exists(): + # No listener, that's okay + return False + + try: + message = IPCMessage("refresh", data) + + # Connect to the socket and send the message + reader, writer = await asyncio.open_unix_connection(str(socket_path)) + + writer.write((message.to_json() + "\n").encode()) + await writer.drain() + + writer.close() + await writer.wait_closed() + + return True + except (ConnectionRefusedError, FileNotFoundError, OSError): + # Socket exists but no one is listening, or other error + return False + except Exception: + return False + + +async def notify_all(data: Optional[Dict[str, Any]] = None) -> Dict[str, bool]: + """Send a refresh notification to all apps. + + Args: + data: Optional data to include with the notification + + Returns: + Dictionary of app_type -> success status + """ + results = {} + for app_type in SOCKET_PATHS: + results[app_type] = await notify_refresh(app_type, data) + return results + + +class IPCListener: + """Listens for IPC messages on a Unix socket. + + Usage: + listener = IPCListener("mail", on_message_callback) + listener.start() + # ... later ... + listener.stop() + """ + + def __init__( + self, + app_type: str, + callback: Callable[[IPCMessage], Any], + ): + """Initialize the IPC listener. + + Args: + app_type: The type of app ("mail", "calendar", "tasks") + callback: Function to call when a message is received. + Can be sync or async. + """ + self.app_type = app_type + self.callback = callback + self.socket_path = get_socket_path(app_type) + self._server: Optional[asyncio.AbstractServer] = None + self._running = False + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._thread: Optional[threading.Thread] = None + + async def _handle_client( + self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ): + """Handle an incoming client connection.""" + try: + data = await reader.readline() + if data: + message_str = data.decode().strip() + if message_str: + message = IPCMessage.from_json(message_str) + + # Call the callback (handle both sync and async) + result = self.callback(message) + if asyncio.iscoroutine(result): + await result + except Exception: + pass # Ignore errors from malformed messages + finally: + writer.close() + try: + await writer.wait_closed() + except Exception: + pass + + async def _run_server(self): + """Run the Unix socket server.""" + ensure_socket_dir() + + # Remove stale socket file if it exists + if self.socket_path.exists(): + self.socket_path.unlink() + + self._server = await asyncio.start_unix_server( + self._handle_client, path=str(self.socket_path) + ) + + # Set socket permissions (readable/writable by owner only) + os.chmod(self.socket_path, 0o600) + + async with self._server: + await self._server.serve_forever() + + def _run_in_thread(self): + """Run the event loop in a separate thread.""" + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + try: + self._loop.run_until_complete(self._run_server()) + except asyncio.CancelledError: + pass + finally: + self._loop.close() + + def start(self): + """Start listening for IPC messages in a background thread.""" + if self._running: + return + + self._running = True + self._thread = threading.Thread(target=self._run_in_thread, daemon=True) + self._thread.start() + + def stop(self): + """Stop listening for IPC messages.""" + if not self._running: + return + + self._running = False + + # Cancel the server + if self._server and self._loop: + self._loop.call_soon_threadsafe(self._server.close) + + # Stop the event loop + if self._loop: + self._loop.call_soon_threadsafe(self._loop.stop) + + # Wait for thread to finish + if self._thread: + self._thread.join(timeout=1.0) + + # Clean up socket file + if self.socket_path.exists(): + try: + self.socket_path.unlink() + except Exception: + pass + + +class AsyncIPCListener: + """Async version of IPCListener for use within an existing event loop. + + Usage in a Textual app: + class MyApp(App): + async def on_mount(self): + self.ipc_listener = AsyncIPCListener("mail", self.on_refresh) + await self.ipc_listener.start() + + async def on_unmount(self): + await self.ipc_listener.stop() + + async def on_refresh(self, message): + self.refresh_data() + """ + + def __init__( + self, + app_type: str, + callback: Callable[[IPCMessage], Any], + ): + self.app_type = app_type + self.callback = callback + self.socket_path = get_socket_path(app_type) + self._server: Optional[asyncio.AbstractServer] = None + + async def _handle_client( + self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ): + """Handle an incoming client connection.""" + try: + data = await reader.readline() + if data: + message_str = data.decode().strip() + if message_str: + message = IPCMessage.from_json(message_str) + + result = self.callback(message) + if asyncio.iscoroutine(result): + await result + except Exception: + pass + finally: + writer.close() + try: + await writer.wait_closed() + except Exception: + pass + + async def start(self): + """Start the Unix socket server.""" + ensure_socket_dir() + + if self.socket_path.exists(): + self.socket_path.unlink() + + self._server = await asyncio.start_unix_server( + self._handle_client, path=str(self.socket_path) + ) + os.chmod(self.socket_path, 0o600) + + async def stop(self): + """Stop the server and clean up.""" + if self._server: + self._server.close() + await self._server.wait_closed() + + if self.socket_path.exists(): + try: + self.socket_path.unlink() + except Exception: + pass