diff --git a/CHANGELOG.md b/CHANGELOG.md index a0db01d..327040e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.6.0] + +### Added +- `Ctrl+L Z` in compose now supports bulk attachment selection. After choosing a directory, Bor opens a keyboard-friendly picker where `Space` toggles files, `Enter` attaches the selected set, `Esc` cancels, and `Ctrl+A` toggles all files. +- Added `attachments.force_kitty_support` to force Kitty graphics support even when `TERM` is not `xterm-kitty`. + +### Fixed +- Fixed message viewer freezing with 100% CPU on messages containing a zero-width character (e.g. U+200B) at the start of an overlong unbreakable "word" such as a 300+ char URL. Root cause was an infinite loop in `rich.cells.split_graphemes` in Rich ≤14.3.2; bumped the minimum required versions to `textual>=8.2.7` and `rich>=15.0.0`, which include the fix. +- Removed an unused `mu view --format=sexp` subprocess call from `MuInterface.view()` that ran on every message open with a 60-second timeout; the result was never read (the email file is parsed directly with Python's `email` module). +- Eliminated a whole class of `MarkupError` crashes in the message viewer (e.g. on messages with `View [ https://... ]`-style links or `[bracketed]` text in the subject). The message body and header rendering no longer concatenate user content into Rich/Textual markup strings; instead they build a `rich.text.Text` programmatically with `.append()`, so user-supplied content can never be re-parsed as markup. Clickable URL links are preserved via styled spans rather than `[link="..."]` tags. +- Fixed a message-view `MarkupError` crash when opening messages whose plain-text body contains a bracketed URL such as `[https://...]`; Bor now keeps the visible bracket while generating valid Textual link markup. +- `Ctrl+K` now behaves more like emacs in editable fields across the app: it still kills from the cursor to the end of the current input line, and now also copies the killed text to the system clipboard for later paste/yank. +- The shared file-path prompt used by compose now has bash-like tab completion behavior: a single match completes immediately, multiple matches are shown without forcing one choice, and the input expands only to the longest shared prefix. +- Fixed a brittle `BorApp` type assertion in tab widgets that could fail in some runtime and test contexts even when the app instance was valid. + ## [0.5.0] ### Added diff --git a/README.md b/README.md index ec2e429..81569b2 100644 --- a/README.md +++ b/README.md @@ -179,8 +179,16 @@ bor | Ctrl+L L | Send message | | Ctrl+L D | Save draft | | Ctrl+L X | Cancel | +| Ctrl+L A | Attach file | +| Ctrl+L Z | Attach many files from a directory | | Ctrl+I | Insert file | -| Ctrl+A | Attach file | + +`Ctrl+L A` opens a file-path prompt with bash-like tab completion: one match completes +immediately, multiple matches are listed without forcing a choice, and the input expands only +to the longest shared prefix. + +`Ctrl+L Z` first prompts for a directory with the same tab-completion behavior, then opens a file picker where +`Space` marks files, `Enter` attaches the marked set, `Esc` cancels, and `Ctrl+A` toggles all. ### Attachments diff --git a/agents/implementation.md b/agents/implementation.md index 49c8ad9..f6873a6 100644 --- a/agents/implementation.md +++ b/agents/implementation.md @@ -129,7 +129,7 @@ Alt+N switching handled via unicode character detection as in terminal_editor.py - Ctrl+L X: Cancel - Ctrl+S: Search - Ctrl+I: Insert file -- Ctrl+A: Attach file +- Ctrl+L A: Attach file - Tab: Autocomplete ## Message Flags Display diff --git a/bor.conf.example b/bor.conf.example index 48a1609..d28bc66 100644 --- a/bor.conf.example +++ b/bor.conf.example @@ -121,6 +121,8 @@ open_links_in_browser = true save_directory = "~/Downloads" # Use kitty icat for image preview (if available) use_kitty_icat = true +# Force kitty graphics support even when TERM is not xterm-kitty +force_kitty_support = false [editor] # External editor for compose (optional) diff --git a/bor/__init__.py b/bor/__init__.py index 15e910c..6c37d73 100644 --- a/bor/__init__.py +++ b/bor/__init__.py @@ -5,5 +5,5 @@ and textual for the user interface. """ -__version__ = "0.5.0" +__version__ = "0.6.0" __author__ = "Bor Development Team" diff --git a/bor/config.py b/bor/config.py index b7f0fd7..f9dc62d 100644 --- a/bor/config.py +++ b/bor/config.py @@ -113,6 +113,7 @@ class AttachmentsConfig: """Attachments handling configuration.""" save_directory: str = "~/Downloads" use_kitty_icat: bool = True + force_kitty_support: bool = False @dataclass diff --git a/bor/editing.py b/bor/editing.py new file mode 100644 index 0000000..9ff64f4 --- /dev/null +++ b/bor/editing.py @@ -0,0 +1,52 @@ +"""Shared text-editing helpers for Bor widgets.""" + +from __future__ import annotations + +from typing import Optional + + +try: + import pyperclip +except ImportError: # pragma: no cover - optional dependency + pyperclip = None + + +def copy_to_clipboard(text: str, append: bool = False) -> None: + """Copy text to the system clipboard when clipboard support is available.""" + if pyperclip is None or not text: + return + if append: + try: + text = pyperclip.paste() + text + except Exception: + pass + pyperclip.copy(text) + + +def kill_input_line(value: str, cursor_position: int) -> tuple[str, int, str]: + """Kill from the cursor to the end of a single-line input.""" + cursor_position = max(0, min(cursor_position, len(value))) + killed_text = value[cursor_position:] + return value[:cursor_position], cursor_position, killed_text + + +def kill_text_line(text: str, cursor_index: int) -> tuple[str, int, str]: + """Kill from the cursor to the end of the current line, emacs-style.""" + cursor_index = max(0, min(cursor_index, len(text))) + + if cursor_index == len(text) and cursor_index > 0 and text[cursor_index - 1] == "\n": + killed_text = "\n" + return text[: cursor_index - 1], cursor_index - 1, killed_text + + newline_index = text.find("\n", cursor_index) + + if newline_index == -1: + killed_text = text[cursor_index:] + return text[:cursor_index], cursor_index, killed_text + + if cursor_index == newline_index: + killed_text = "\n" + return text[:cursor_index] + text[newline_index + 1 :], cursor_index, killed_text + + killed_text = text[cursor_index:newline_index] + return text[:cursor_index] + text[newline_index:], cursor_index, killed_text diff --git a/bor/mu.py b/bor/mu.py index 0fcb5c0..b00123f 100644 --- a/bor/mu.py +++ b/bor/mu.py @@ -568,9 +568,6 @@ def view(self, path: str, mark_as_read: bool = True, msgid: str = "") -> Optiona if not Path(path).exists(): return None - # Use mu view for metadata - result = self._run_mu(["view", path, "--format=sexp"]) - # Parse the email file directly for full content try: with open(path, "rb") as f: diff --git a/bor/tabs/attachments.py b/bor/tabs/attachments.py index 5974751..c415d97 100644 --- a/bor/tabs/attachments.py +++ b/bor/tabs/attachments.py @@ -23,7 +23,7 @@ from bor.tabs.base import BaseTab from bor.mu import EmailMessage -from bor.config import get_config +from bor.config import Config, get_config class AttachmentItem(ListItem): @@ -107,6 +107,13 @@ def _kitty_image_size_params(img_w: int, img_h: int, avail_cols: int, avail_rows return f",r={avail_rows}" +def _supports_kitty_graphics(config: Optional[Config] = None) -> bool: + """Return whether Kitty graphics support should be used.""" + if config is None: + config = get_config() + return config.attachments.force_kitty_support or os.environ.get("TERM") == "xterm-kitty" + + class AttachmentPreview(ScrollableContainer): """Widget to preview attachment content.""" @@ -198,8 +205,7 @@ def _render_kitty_image(self) -> None: if not self._current_image_path or not self._current_image_path.exists(): return - # Check if we're in kitty - if os.environ.get("TERM") != "xterm-kitty": + if not _supports_kitty_graphics(): return try: @@ -312,7 +318,7 @@ def _render_kitty_image(self) -> None: def clear_image(self) -> None: """Clear any displayed Kitty image.""" self._current_image_path = None - if os.environ.get("TERM") == "xterm-kitty": + if _supports_kitty_graphics(): try: # Clear all images using Kitty graphics protocol # a=d means delete, d=A means all images @@ -524,7 +530,7 @@ def _preview_attachment(self, index: int) -> None: def _check_kitty(self) -> bool: """Check if we're running in kitty terminal.""" - return os.environ.get("TERM") == "xterm-kitty" + return _supports_kitty_graphics() def _open_with_kitty_icat(self, index: int) -> None: """ diff --git a/bor/tabs/base.py b/bor/tabs/base.py index 561b1a2..0a7aca9 100644 --- a/bor/tabs/base.py +++ b/bor/tabs/base.py @@ -6,7 +6,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from textual.widget import Widget @@ -30,10 +30,7 @@ def __init__(self, *args, **kwargs) -> None: @property def bor_app(self) -> "BorApp": """Get the Bor application instance.""" - from bor.app import BorApp - app = self.app - assert isinstance(app, BorApp) - return app + return cast("BorApp", self.app) def close_tab(self) -> None: """Close this tab.""" diff --git a/bor/tabs/compose.py b/bor/tabs/compose.py index f927768..20a89ef 100644 --- a/bor/tabs/compose.py +++ b/bor/tabs/compose.py @@ -26,7 +26,7 @@ from textual.app import ComposeResult from textual.binding import Binding from textual.containers import Container, Vertical, Horizontal -from textual.widgets import Input, TextArea, Static, Label +from textual.widgets import Input, TextArea, Static, Label, ListView, ListItem from textual.reactive import reactive from textual.message import Message @@ -39,6 +39,7 @@ from bor.tabs.base import BaseTab from bor.mu import EmailMessage, EmailAddress from bor.config import get_config, load_mailrc_aliases +from bor.editing import copy_to_clipboard, kill_input_line, kill_text_line # Custom messages for Ctrl+L commands @@ -84,6 +85,11 @@ class AttachFile(ComposeCommand): pass +class AttachFilesFromDirectory(ComposeCommand): + """Command to attach multiple files from a directory.""" + pass + + class CtrlLMixin: """ Mixin for handling Ctrl+L command sequences. @@ -98,6 +104,7 @@ class CtrlLMixin: - S: Go to Subject field - E: Go to Editor - A: Attach file + - Z: Attach many files from a directory """ _ctrl_l_pressed: bool = False @@ -162,6 +169,11 @@ def handle_ctrl_l_key(self, event: events.Key) -> bool: event.prevent_default() event.stop() return True + elif key == "z": + self.post_message(AttachFilesFromDirectory()) + event.prevent_default() + event.stop() + return True # Unknown sequence - ignore return True @@ -325,6 +337,14 @@ def _on_key(self, event: events.Key) -> None: event.prevent_default() event.stop() return + elif event.key == "ctrl+k": + new_value, new_cursor, killed_text = kill_input_line(self.value, self.cursor_position) + copy_to_clipboard(killed_text) + self.value = new_value + self.cursor_position = new_cursor + event.prevent_default() + event.stop() + return # Check Ctrl+L sequences first if self.handle_ctrl_l_key(event): @@ -401,6 +421,14 @@ def _on_key(self, event: events.Key) -> None: event.prevent_default() event.stop() return + elif event.key == "ctrl+k": + new_value, new_cursor, killed_text = kill_input_line(self.value, self.cursor_position) + copy_to_clipboard(killed_text) + self.value = new_value + self.cursor_position = new_cursor + event.prevent_default() + event.stop() + return if self.handle_ctrl_l_key(event): return @@ -425,6 +453,7 @@ def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self._aliases: dict = {} self._ctrl_l_pressed: bool = False + self._append_next_kill: bool = False def set_aliases(self, aliases: dict) -> None: """ @@ -500,8 +529,21 @@ def _transpose_at_cursor(text: str, cursor_index: int) -> tuple[str, int]: chars[left_index], chars[right_index] = chars[right_index], chars[left_index] return "".join(chars), new_cursor + def _kill_line_at_cursor(self) -> None: + """Kill from the cursor to the end of the current line and copy it.""" + row, col = self.cursor_location + cursor_index = self._cursor_to_index(self.text, row, col) + new_text, new_index, killed_text = kill_text_line(self.text, cursor_index) + copy_to_clipboard(killed_text, append=self._append_next_kill) + self._append_next_kill = bool(killed_text) + self.text = new_text + self.cursor_location = self._index_to_cursor(new_text, new_index) + def _on_key(self, event: events.Key) -> None: """Handle key events for text completion and commands.""" + if event.key != "ctrl+k": + self._append_next_kill = False + # Handle clipboard operations - must prevent default to avoid SIGINT if event.key == "ctrl+c": if HAS_PYPERCLIP: @@ -533,6 +575,11 @@ def _on_key(self, event: events.Key) -> None: event.prevent_default() event.stop() return + elif event.key == "ctrl+k": + self._kill_line_at_cursor() + event.prevent_default() + event.stop() + return if event.key in {"ctrl+backspace", "ctrl+w"}: row, col = self.cursor_location @@ -600,8 +647,6 @@ def __init__(self, *args, **kwargs) -> None: """Initialize file path input.""" super().__init__(*args, **kwargs) self._completions: List[str] = [] - self._completion_index: int = 0 - self._last_prefix: str = "" def _get_completions(self, path_str: str) -> List[str]: """ @@ -645,33 +690,44 @@ def _get_completions(self, path_str: str) -> List[str]: return completions + @staticmethod + def _longest_shared_completion_prefix(path_str: str, completions: List[str]) -> str: + """Return the farthest bash-style completion prefix we can safely insert.""" + if not completions: + return path_str + + if len(completions) == 1: + return completions[0] + + shared_prefix = os.path.commonprefix(completions) + if shared_prefix.startswith(path_str): + return shared_prefix + return path_str + def on_key(self, event: events.Key) -> None: """Handle key events for completion.""" - if event.key == "tab": + if event.key == "ctrl+k": + new_value, new_cursor, killed_text = kill_input_line(self.value, self.cursor_position) + copy_to_clipboard(killed_text) + self.value = new_value + self.cursor_position = new_cursor + event.prevent_default() + event.stop() + elif event.key == "tab": current_value = self.value - - # Check if we're cycling through existing completions - if current_value == self._last_prefix or (self._completions and current_value in self._completions): - if self._completions: - self._completion_index = (self._completion_index + 1) % len(self._completions) - self.value = self._completions[self._completion_index] - else: - # Get new completions - self._completions = self._get_completions(current_value) - self._completion_index = 0 - self._last_prefix = current_value - - if self._completions: - self.value = self._completions[0] - + self._completions = self._get_completions(current_value) + + if self._completions: + self.value = self._longest_shared_completion_prefix( + current_value, self._completions + ) + # Move cursor to end of line self.cursor_position = len(self.value) - + # Post message to update completions display - self.post_message(FilePathInput.CompletionsChanged( - self._completions, self._completion_index - )) - + self.post_message(FilePathInput.CompletionsChanged(self._completions, -1)) + event.prevent_default() event.stop() elif event.key == "escape": @@ -687,8 +743,6 @@ def on_key(self, event: events.Key) -> None: else: # Reset completions when typing self._completions = [] - self._completion_index = 0 - self._last_prefix = "" # Clear completions display self.post_message(FilePathInput.CompletionsChanged([], 0)) @@ -710,6 +764,95 @@ def __init__(self, completions: List[str], current_index: int) -> None: super().__init__() +class BulkAttachmentItem(ListItem): + """List item representing a file that may be attached.""" + + def __init__(self, path: Path, selected: bool = False, **kwargs) -> None: + super().__init__(**kwargs) + self.path = path + self.selected = selected + + def compose(self) -> ComposeResult: + """Create the list item.""" + yield Static(self._render_label()) + + def toggle(self) -> None: + """Toggle whether this file is selected for attachment.""" + self.selected = not self.selected + self._refresh() + + def set_selected(self, selected: bool) -> None: + """Set the selected state.""" + if self.selected != selected: + self.selected = selected + self._refresh() + + def _refresh(self) -> None: + """Refresh the rendered label.""" + self.query_one(Static).update(self._render_label()) + + def _render_label(self) -> str: + """Return the display string for this item.""" + marker = "[x]" if self.selected else "[ ]" + size = 0 + if self.path.exists(): + try: + size = self.path.stat().st_size + except OSError: + size = 0 + if size < 1024: + size_text = f"{size} B" + elif size < 1024 * 1024: + size_text = f"{size / 1024:.1f} KB" + else: + size_text = f"{size / (1024 * 1024):.1f} MB" + return f"{marker} {self.path.name} ({size_text})" + + +class BulkAttachmentList(ListView): + """Keyboard-friendly file picker for bulk attachment selection.""" + + class Toggled(Message): + """Posted when the highlighted item is toggled.""" + + def __init__(self, item: BulkAttachmentItem) -> None: + self.item = item + super().__init__() + + class Cancelled(Message): + """Posted when the picker is cancelled.""" + + pass + + class SelectAllToggled(Message): + """Posted when the user requests a select-all toggle.""" + + pass + + def on_key(self, event: events.Key) -> None: + """Handle picker-specific key bindings.""" + if event.key == "space": + item = self.highlighted_child + if isinstance(item, BulkAttachmentItem): + item.toggle() + self.post_message(self.Toggled(item)) + event.prevent_default() + event.stop() + return + + if event.key == "ctrl+a": + self.post_message(self.SelectAllToggled()) + event.prevent_default() + event.stop() + return + + if event.key == "escape": + self.post_message(self.Cancelled()) + event.prevent_default() + event.stop() + return + + class ComposeWidget(BaseTab): """ Compose widget for email composition. @@ -720,7 +863,6 @@ class ComposeWidget(BaseTab): BINDINGS = [ Binding("ctrl+s", "search", "Search", show=False), Binding("ctrl+i", "insert_file", "Insert File"), - Binding("ctrl+a", "attach_file", "Attach File"), ] DEFAULT_CSS = """ @@ -799,6 +941,30 @@ class ComposeWidget(BaseTab): border: solid $surface-darken-1; } + ComposeWidget .bulk-attachment-picker { + height: auto; + max-height: 18; + padding: 0 1 1 1; + background: $panel; + border: solid $accent; + } + + ComposeWidget .bulk-attachment-picker.hidden { + display: none; + } + + ComposeWidget .bulk-attachment-picker-title { + height: auto; + padding: 0 0 1 0; + color: $text; + } + + ComposeWidget #bulk-attachment-list { + height: 12; + border: solid $surface-darken-1; + background: $surface; + } + ComposeWidget .status-bar { height: 1; dock: bottom; @@ -836,6 +1002,7 @@ def __init__( self._text_aliases: dict = {} self._last_attachment_dir: Path = Path.home() # Track last used directory self._file_path_mode: Optional[str] = None + self._bulk_picker_directory: Optional[Path] = None self._draft_deleted: bool = False def compose(self) -> ComposeResult: @@ -869,8 +1036,15 @@ def compose(self) -> ComposeResult: yield Label("", id="file-path-input-label", classes="attachment-input-label") yield FilePathInput(id="attachment-path-input", classes="attachment-path-input") + with Vertical(classes="bulk-attachment-picker hidden", id="bulk-attachment-picker"): + yield Label("", id="bulk-attachment-picker-title", classes="bulk-attachment-picker-title") + yield BulkAttachmentList(id="bulk-attachment-list") + with Horizontal(classes="status-bar"): - yield Label("Ctrl+L: L=Send D=Draft X=Cancel | T/C/B/S/E=Jump to field | Tab=Next", id="status") + yield Label( + "Ctrl+L: L=Send D=Draft X=Cancel A=Attach Z=Bulk Attach | T/C/B/S/E=Jump | Tab=Next", + id="status", + ) def on_mount(self) -> None: """Handle widget mount.""" @@ -1484,6 +1658,10 @@ def on_attach_file(self, event: AttachFile) -> None: """Handle attach file command (Ctrl-L A).""" self.action_attach_file() + def on_attach_files_from_directory(self, event: AttachFilesFromDirectory) -> None: + """Handle bulk attach command (Ctrl-L Z).""" + self.action_attach_files_from_directory() + def on_file_path_input_submitted(self, event: FilePathInput.Submitted) -> None: """Handle file path submitted for attachment or insertion.""" path = Path(event.path).expanduser() @@ -1492,12 +1670,27 @@ def on_file_path_input_submitted(self, event: FilePathInput.Submitted) -> None: # Hide the input bar self.query_one("#attachment-input-bar", Vertical).add_class("hidden") + if mode == "bulk_attach_dir": + if not path.exists(): + self.notify(f"Directory not found: {path}", severity="error") + self.query_one("#body-input").focus() + return + + if not path.is_dir(): + self.notify(f"Not a directory: {path}", severity="error") + self.query_one("#body-input").focus() + return + + self._last_attachment_dir = path + self._show_bulk_attachment_picker(path) + return + # Validate the file if not path.exists(): self.notify(f"File not found: {path}", severity="error") self.query_one("#body-input").focus() return - + if not path.is_file(): self.notify(f"Not a file: {path}", severity="error") self.query_one("#body-input").focus() @@ -1544,6 +1737,63 @@ def on_file_path_input_cancelled(self, event: FilePathInput.Cancelled) -> None: # Return focus to body self.query_one("#body-input").focus() + def on_bulk_attachment_list_toggled(self, event: BulkAttachmentList.Toggled) -> None: + """Update picker instructions after a file is toggled.""" + self._update_bulk_picker_title() + + def on_bulk_attachment_list_cancelled(self, event: BulkAttachmentList.Cancelled) -> None: + """Close the bulk picker without attaching files.""" + self._hide_bulk_attachment_picker() + self.query_one("#body-input", ComposeTextArea).focus() + + def on_bulk_attachment_list_select_all_toggled( + self, event: BulkAttachmentList.SelectAllToggled + ) -> None: + """Toggle all files in the picker.""" + list_view = self.query_one("#bulk-attachment-list", BulkAttachmentList) + items = [ + item for item in list_view.children if isinstance(item, BulkAttachmentItem) + ] + should_select = any(not item.selected for item in items) + for item in items: + item.set_selected(should_select) + self._update_bulk_picker_title() + + def on_list_view_selected(self, event: ListView.Selected) -> None: + """Handle confirming the bulk attachment picker.""" + if not isinstance(event.list_view, BulkAttachmentList): + return + + selected_paths = self._get_selected_bulk_attachment_paths() + if not selected_paths: + self.notify("No files selected", severity="warning") + return + + added = [] + duplicates = [] + for path in selected_paths: + if path in self.attachments: + duplicates.append(path.name) + continue + self.attachments.append(path) + added.append(path.name) + + self._update_attachment_bar() + self._last_attachment_dir = self._bulk_picker_directory or self._last_attachment_dir + self._hide_bulk_attachment_picker() + self.query_one("#body-input", ComposeTextArea).focus() + + if added: + self.notify(f"Attached {len(added)} file(s)") + elif duplicates: + self.notify("Selected files were already attached", severity="warning") + + if duplicates and added: + self.notify( + f"Skipped {len(duplicates)} already attached file(s)", + severity="warning", + ) + def on_file_path_input_completions_changed(self, event: FilePathInput.CompletionsChanged) -> None: """Handle completions list change.""" label = self.query_one("#attachment-completions", Label) @@ -1580,9 +1830,14 @@ def action_attach_file(self) -> None: """Attach a file.""" self._show_file_path_input(mode="attach", label="Attach file: ") + def action_attach_files_from_directory(self) -> None: + """Attach multiple files from a selected directory.""" + self._show_file_path_input(mode="bulk_attach_dir", label="Attach files from directory: ") + def _show_file_path_input(self, mode: str, label: str) -> None: """Show the path input UI used for attach/insert actions.""" self._file_path_mode = mode + self._hide_bulk_attachment_picker() # Show the attachment input bar self.query_one("#attachment-input-bar", Vertical).remove_class("hidden") @@ -1596,3 +1851,63 @@ def _show_file_path_input(self, mode: str, label: str) -> None: path_input.value = str(self._last_attachment_dir) + "/" path_input.cursor_position = len(path_input.value) path_input.focus() + + @staticmethod + def _list_bulk_attachable_files(directory: Path) -> List[Path]: + """Return the direct child files in a directory, sorted by name.""" + files: List[Path] = [] + try: + for entry in sorted(directory.iterdir(), key=lambda path: path.name.lower()): + if entry.is_file(): + files.append(entry) + except PermissionError: + return [] + return files + + def _show_bulk_attachment_picker(self, directory: Path) -> None: + """Populate and show the bulk attachment picker for a directory.""" + files = self._list_bulk_attachable_files(directory) + if not files: + self.notify(f"No files found in {directory}", severity="warning") + self._file_path_mode = None + self.query_one("#body-input", ComposeTextArea).focus() + return + + self._bulk_picker_directory = directory + self._file_path_mode = None + + picker = self.query_one("#bulk-attachment-picker", Vertical) + list_view = self.query_one("#bulk-attachment-list", BulkAttachmentList) + list_view.clear() + for path in files: + list_view.append(BulkAttachmentItem(path)) + + picker.remove_class("hidden") + self._update_bulk_picker_title() + list_view.index = 0 + list_view.focus() + + def _hide_bulk_attachment_picker(self) -> None: + """Hide the bulk attachment picker.""" + self.query_one("#bulk-attachment-picker", Vertical).add_class("hidden") + self._bulk_picker_directory = None + + def _get_selected_bulk_attachment_paths(self) -> List[Path]: + """Return the currently selected paths in the bulk picker.""" + list_view = self.query_one("#bulk-attachment-list", BulkAttachmentList) + return [ + item.path + for item in list_view.children + if isinstance(item, BulkAttachmentItem) and item.selected + ] + + def _update_bulk_picker_title(self) -> None: + """Refresh the picker title and key hints.""" + title = self.query_one("#bulk-attachment-picker-title", Label) + count = len(self._get_selected_bulk_attachment_paths()) + directory = self._bulk_picker_directory or self._last_attachment_dir + title.update( + "Select files from " + f"{directory} | Space=toggle Enter=attach Esc=cancel Ctrl+A=toggle all | " + f"{count} selected" + ) diff --git a/bor/tabs/message.py b/bor/tabs/message.py index c872e59..b3414ea 100644 --- a/bor/tabs/message.py +++ b/bor/tabs/message.py @@ -14,7 +14,7 @@ from pathlib import Path from typing import Callable, Optional -from rich.markup import escape as rich_escape +from rich.text import Text from textual import events from textual.app import ComposeResult @@ -31,21 +31,35 @@ _URL_RE = re.compile(r'(https?://\S+)') -def _make_body_markup(content: str) -> str: - """Escape Rich markup in plain-text body and wrap URLs in clickable link tags.""" +def _make_body_text(content: str) -> Text: + """Build a Rich Text for the message body with clickable URL spans. + + The Text is constructed programmatically via `.append()` so user content + is never parsed as Rich/Textual markup. This avoids markup-injection + crashes on messages containing characters the parser treats specially + (stray `[`, `[/...]` look-alikes, quotes inside URLs, etc.). + """ + text = Text() parts = _URL_RE.split(content) - result = [] for i, part in enumerate(parts): + if not part: + continue if i % 2 == 1: # URL - # Strip common trailing punctuation plus chars that close Markdown/HTML constructs url = part.rstrip('.,;:!?)]>') trailing = part[len(url):] - # Escape chars that would break Rich's [link="url"] attribute syntax - safe_url = url.replace('"', '%22').replace(']', '%5D') - result.append(f'[link="{safe_url}"]{rich_escape(url)}[/link]{rich_escape(trailing)}') + # If the prior chunk ends with `[` (style: `[https://...]`), pull + # the bracket into the link span so it's part of the clickable + # affordance, matching the prior behavior. + if text.plain.endswith("["): + text.right_crop(1) + text.append("[" + url, style=f"link {url}") + else: + text.append(url, style=f"link {url}") + if trailing: + text.append(trailing) else: - result.append(rich_escape(part)) - return "".join(result) + text.append(part) + return text def html_to_text(html: str) -> str: @@ -134,66 +148,70 @@ def update_message(self, message: EmailMessage) -> None: except Exception: pass - def _format_headers(self) -> str: - """Format headers for display.""" - lines = [] + def _format_headers(self) -> Text: + """Format headers as a Rich Text. - # From - lines.append(f"[bold]From:[/bold] {rich_escape(str(self.message.from_addr))}") + Built programmatically via `.append()` so user-supplied header values + (subjects, names, message-ids, etc.) can never be interpreted as + markup. Static.update accepts a Text directly. + """ + text = Text() + first = True + + def add_row(label: str, value: str) -> None: + nonlocal first + if not first: + text.append("\n") + first = False + text.append(label, style="bold") + text.append(value) + + add_row("From: ", str(self.message.from_addr)) # Show effective reply address when it differs from From if self.message.list_post_addr and self.message.list_post_addr.email: - # Mailing list: show the list address (this is where 'r' will send) if self.message.list_post_addr.email != self.message.from_addr.email: - lines.append(f"[bold]List:[/bold] {rich_escape(self.message.list_post_addr.email)}") + add_row("List: ", self.message.list_post_addr.email) elif (self.message.reply_to_addr and self.message.reply_to_addr.email != self.message.from_addr.email): - lines.append(f"[bold]Reply-To:[/bold] {rich_escape(str(self.message.reply_to_addr))}") + add_row("Reply-To: ", str(self.message.reply_to_addr)) - # To - to_list = rich_escape(", ".join(str(addr) for addr in self.message.to_addrs)) - lines.append(f"[bold]To:[/bold] {to_list}") + add_row("To: ", ", ".join(str(addr) for addr in self.message.to_addrs)) - # CC if self.message.cc_addrs: - cc_list = rich_escape(", ".join(str(addr) for addr in self.message.cc_addrs)) - lines.append(f"[bold]CC:[/bold] {cc_list}") + add_row("CC: ", ", ".join(str(addr) for addr in self.message.cc_addrs)) - # BCC (only in full header mode) if self.show_full and self.message.bcc_addrs: - bcc_list = rich_escape(", ".join(str(addr) for addr in self.message.bcc_addrs)) - lines.append(f"[bold]BCC:[/bold] {bcc_list}") + add_row("BCC: ", ", ".join(str(addr) for addr in self.message.bcc_addrs)) - # Date date_str = "" if self.message.date: date_str = self.message.date.strftime("%Y-%m-%d %H:%M:%S %Z") - lines.append(f"[bold]Date:[/bold] {rich_escape(date_str)}") + add_row("Date: ", date_str) - # Subject - lines.append(f"[bold]Subject:[/bold] {rich_escape(self.message.subject)}") + add_row("Subject: ", self.message.subject) - # Attachments count if self.message.attachments: count = len(self.message.attachments) - lines.append(f"[bold]Attach:[/bold] {count} attachment(s)") + add_row("Attach: ", f"{count} attachment(s)") - # Full / rich headers if self.show_full: - lines.append("") # visual separator + # Blank visual separator: finish previous line + one empty line. + text.append("\n\n") + first = True # next add_row should not prepend another newline if self.message.msgid: - lines.append(f"[bold]Message-ID:[/bold] {rich_escape(self.message.msgid)}") + add_row("Message-ID: ", self.message.msgid) if self.message.in_reply_to: - lines.append(f"[bold]In-Reply-To:[/bold] {rich_escape(self.message.in_reply_to)}") + add_row("In-Reply-To: ", self.message.in_reply_to) if self.message.references: n = len(self.message.references) sample = " ".join(self.message.references[-2:]) suffix = f" (… {n} total)" if n > 2 else "" - lines.append(f"[bold]References:[/bold] {rich_escape(sample)}{suffix}") + add_row("References: ", sample + suffix) if self.message.priority and self.message.priority != "normal": - lines.append(f"[bold]Priority:[/bold] {rich_escape(self.message.priority)}") + add_row("Priority: ", self.message.priority) if self.message.size: size = self.message.size @@ -203,24 +221,22 @@ def _format_headers(self) -> str: size_str = f"{size / 1024:.1f} KB" else: size_str = f"{size} B" - lines.append(f"[bold]Size:[/bold] {size_str}") + add_row("Size: ", size_str) if self.message.maildir: - lines.append(f"[bold]Folder:[/bold] {rich_escape(self.message.maildir)}") + add_row("Folder: ", self.message.maildir) if self.message.flags: - lines.append(f"[bold]Flags:[/bold] {rich_escape(', '.join(self.message.flags))}") + add_row("Flags: ", ", ".join(self.message.flags)) if self.message.tags: - lines.append(f"[bold]Tags:[/bold] {rich_escape(', '.join(self.message.tags))}") + add_row("Tags: ", ", ".join(self.message.tags)) - # Extra headers captured from the raw file for hdr_name, val in self.message.extra_headers.items(): - # Truncate very long values (e.g. Authentication-Results) display_val = val if len(val) <= 100 else val[:97] + "…" - lines.append(f"[bold]{rich_escape(hdr_name)}:[/bold] {rich_escape(display_val)}") + add_row(f"{hdr_name}: ", display_val) - return "\n".join(lines) + return text class MessageBody(ScrollableContainer): @@ -435,31 +451,24 @@ def on_mount(self) -> None: def _load_message(self) -> None: """Load the full message content.""" mu = self.bor_app.mu - # Pass msgid in case the path is stale (e.g., after marking as read) self._full_message = mu.view(self._message_ref.path, msgid=self._message_ref.msgid) if self._full_message: - # Update the message reference path in case it changed (e.g., marked as read) - # This keeps _current_messages in sync if self._full_message.path != self._message_ref.path: self._message_ref.path = self._full_message.path - - # Update flags - message was marked as read + if "unread" in self._message_ref.flags: self._message_ref.flags.remove("unread") if "new" in self._message_ref.flags: self._message_ref.flags.remove("new") if "seen" not in self._message_ref.flags: self._message_ref.flags.append("seen") - - # Track this message index as read for later refresh + self._read_message_indices.add(self.bor_app._current_index) - - # Update header + header = self.query_one("#msg-header", MessageHeader) header.update_message(self._full_message) - # Update attachment info attach_info = self.query_one("#attachment-info", Static) if self._full_message.attachments: count = len(self._full_message.attachments) @@ -468,7 +477,6 @@ def _load_message(self) -> None: else: attach_info.display = False - # Get body content if self._full_message.body_txt: self._content = self._full_message.body_txt elif self._full_message.body_html: @@ -476,19 +484,17 @@ def _load_message(self) -> None: else: self._content = "(No message content)" - # Update body body = self.query_one("#msg-body", Static) try: - body.update(_make_body_markup(self._content)) + body.update(_make_body_text(self._content)) except Exception: - body.update(rich_escape(self._content)) + body.update(Text(self._content)) - # Update tab title - title = self._full_message.subject[:20] + "..." if len(self._full_message.subject) > 20 else self._full_message.subject + subject = self._full_message.subject + title = subject[:20] + "..." if len(subject) > 20 else subject self.update_tab_title(title) else: - body = self.query_one("#msg-body", Static) - body.update("Error: Could not load message") + self.query_one("#msg-body", Static).update("Error: Could not load message") # Navigation actions diff --git a/bor/tabs/message_index.py b/bor/tabs/message_index.py index 177fb51..074413c 100644 --- a/bor/tabs/message_index.py +++ b/bor/tabs/message_index.py @@ -16,12 +16,14 @@ from textual.binding import Binding from textual.containers import Container, Vertical, Horizontal from textual.message import Message +from textual.widget import Widget from textual.widgets import DataTable, Input, Static, Label from textual.coordinate import Coordinate from bor.tabs.base import BaseTab from bor.mu import EmailMessage from bor.config import get_config +from bor.editing import copy_to_clipboard, kill_input_line _HISTORY_FILE = Path.home() / ".local" / "share" / "bor" / "search_history" @@ -96,6 +98,14 @@ def on_key(self, event: events.Key) -> None: event.prevent_default() event.stop() + elif event.key == "ctrl+k": + new_value, new_cursor, killed_text = kill_input_line(self.value, self.cursor_position) + copy_to_clipboard(killed_text) + self.value = new_value + self.cursor_position = new_cursor + event.prevent_default() + event.stop() + elif event.key == "up": if self._history: if self._history_pos == -1: @@ -215,28 +225,53 @@ def __init__(self, *args, **kwargs) -> None: """Initialize reply bar.""" super().__init__(*args, **kwargs) self._callback: Optional[Callable] = None + self._cancel_callback: Optional[Callable[[], None]] = None + self._previous_focus: Optional[Widget] = None - def ask(self, callback: Callable) -> None: + def ask(self, callback: Callable, cancel_callback: Optional[Callable[[], None]] = None) -> None: """Show reply options prompt.""" self._callback = callback + self._cancel_callback = cancel_callback + self._previous_focus = self.app.focused self.update("Reply to: (a)ll or (s)ender only?") self.add_class("visible") self.focus() + def _restore_focus(self) -> None: + """Return focus to the widget that opened the reply prompt.""" + try: + if self._previous_focus is not None and self._previous_focus is not self: + self._previous_focus.focus() + return + except Exception: + pass + + try: + self.screen.query_one(DataTable).focus() + except Exception: + pass + def on_key(self, event: events.Key) -> None: """Handle key events.""" key = event.key.lower() if event.key else "" - + if key in ("a", "s"): self.remove_class("visible") + self._restore_focus() if self._callback: self._callback(key == "a") # True for reply all, False for sender only - event.prevent_default() - event.stop() + self._callback = None + self._cancel_callback = None elif key == "escape": self.remove_class("visible") - event.prevent_default() - event.stop() + self._restore_focus() + if self._cancel_callback: + self._cancel_callback() + self._callback = None + self._cancel_callback = None + + event.prevent_default() + event.stop() can_focus = True @@ -725,7 +760,7 @@ def action_reply(self) -> None: if full_msg and (full_msg.cc_addrs or len(full_msg.to_addrs) > 1): reply_bar = self.query_one("#reply-bar", ReplyBar) self._pending_reply_msg = full_msg - reply_bar.ask(self._do_reply) + reply_bar.ask(self._do_reply, self._cancel_reply) else: self.bor_app.open_compose(reply_to=full_msg or msg) @@ -736,6 +771,10 @@ def _do_reply(self, reply_all: bool = False) -> None: self.bor_app.open_compose(reply_to=msg, reply_all=reply_all) self._pending_reply_msg = None + def _cancel_reply(self) -> None: + """Clear any pending reply state after dismissing the reply prompt.""" + self._pending_reply_msg = None + def action_forward(self) -> None: """Forward the selected message.""" msg = self._get_current_message() diff --git a/documentation/configuration.md b/documentation/configuration.md index aad398a..7c70358 100644 --- a/documentation/configuration.md +++ b/documentation/configuration.md @@ -184,6 +184,9 @@ save_directory = "~/Downloads" # Use kitty icat for image preview (requires kitty terminal) use_kitty_icat = true + +# Force kitty graphics support even when TERM is not xterm-kitty +force_kitty_support = false ``` ### [aliases] diff --git a/pyproject.toml b/pyproject.toml index 26024cf..4bba03e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "bormail" -version = "0.5.0" +version = "0.6.0" description = "Terminal-based email reader using mu and textual" readme = "README.md" requires-python = ">=3.10" @@ -28,7 +28,8 @@ classifiers = [ ] dependencies = [ - "textual>=0.40.0", + "textual>=8.2.7", + "rich>=15.0.0", "tomli>=2.0.0;python_version<'3.11'", "pyperclip>=1.8.0", ] diff --git a/tests/test_app_integration.py b/tests/test_app_integration.py index d7a7724..8fdbe8c 100644 --- a/tests/test_app_integration.py +++ b/tests/test_app_integration.py @@ -9,6 +9,8 @@ from datetime import datetime from textual.widgets import DataTable, Static +from bor.tabs.compose import ComposeWidget, FilePathInput, BulkAttachmentList +from bor.tabs.message_index import ReplyBar # Mock EmailMessage for testing class MockEmailMessage: @@ -186,6 +188,46 @@ async def test_p_key_moves_up(self, mock_mu_interface, mock_config): await pilot.press("p") # Should not crash + @pytest.mark.asyncio + async def test_reply_prompt_ignores_other_keys_until_confirmed_or_canceled( + self, mock_mu_interface, mock_config + ): + """Reply prompt should trap unrelated keys and allow escape to cancel.""" + multi_recipient_msg = MockEmailMessage( + subject="Viewed Message", + to_addrs=[ + MagicMock(email="one@test.com", __str__=lambda s: "one@test.com"), + MagicMock(email="two@test.com", __str__=lambda s: "two@test.com"), + ], + cc_addrs=[], + ) + mock_mu_interface.view.return_value = multi_recipient_msg + + with patch('bor.app.get_config', return_value=mock_config): + with patch('bor.app.MuInterface', return_value=mock_mu_interface): + from bor.app import BorApp + + app = BorApp() + app.open_compose = MagicMock() + + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("r") + await pilot.pause() + + reply_bar = app.query_one("#reply-bar", ReplyBar) + assert reply_bar.has_class("visible") + + await pilot.press("x") + await pilot.pause() + assert reply_bar.has_class("visible") + app.open_compose.assert_not_called() + + await pilot.press("escape") + await pilot.pause() + assert not reply_bar.has_class("visible") + app.open_compose.assert_not_called() + class TestMessageView: """Test message viewing.""" @@ -434,6 +476,52 @@ async def test_returning_from_compose_refreshes_index(self, mock_mu_interface, m assert mock_mu_interface.find.call_count > initial_find_calls +class TestComposeBulkAttachments: + """Test bulk attachment flow in compose.""" + + @pytest.mark.asyncio + async def test_ctrl_l_ctrl_z_attaches_marked_files( + self, mock_mu_interface, mock_config, tmp_path + ): + """Ctrl+L Ctrl+Z should open a directory picker and attach marked files.""" + first = tmp_path / "alpha.txt" + second = tmp_path / "beta.txt" + ignored = tmp_path / "subdir" + first.write_text("alpha", encoding="utf-8") + second.write_text("beta", encoding="utf-8") + ignored.mkdir() + + with patch('bor.app.get_config', return_value=mock_config): + with patch('bor.app.MuInterface', return_value=mock_mu_interface): + from bor.app import BorApp + + app = BorApp() + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("c") + await pilot.pause() + + await pilot.press("ctrl+l", "z") + await pilot.pause() + + path_input = app.query_one("#attachment-path-input", FilePathInput) + assert path_input.has_focus + path_input.value = str(tmp_path) + "/" + path_input.cursor_position = len(path_input.value) + + await pilot.press("enter") + await pilot.pause() + + widget = app.query_one(ComposeWidget) + picker = app.query_one("#bulk-attachment-list", BulkAttachmentList) + assert picker.has_focus + + await pilot.press("space", "down", "space", "enter") + await pilot.pause() + + assert [path.name for path in widget.attachments] == ["alpha.txt", "beta.txt"] + + class TestQuit: """Test quit functionality.""" diff --git a/tests/test_attachments.py b/tests/test_attachments.py new file mode 100644 index 0000000..0d93ee4 --- /dev/null +++ b/tests/test_attachments.py @@ -0,0 +1,27 @@ +"""Tests for attachment preview behavior.""" + +from bor.config import Config +from bor.tabs.attachments import _supports_kitty_graphics + + +def test_kitty_graphics_detected_from_term(monkeypatch): + """Kitty graphics are enabled automatically in kitty.""" + monkeypatch.setenv("TERM", "xterm-kitty") + + assert _supports_kitty_graphics(Config()) is True + + +def test_kitty_graphics_disabled_for_other_terms_by_default(monkeypatch): + """Kitty graphics stay disabled outside kitty by default.""" + monkeypatch.setenv("TERM", "xterm-256color") + + assert _supports_kitty_graphics(Config()) is False + + +def test_kitty_graphics_can_be_forced(monkeypatch): + """Configuration can force Kitty graphics regardless of TERM.""" + monkeypatch.setenv("TERM", "xterm-256color") + config = Config() + config.attachments.force_kitty_support = True + + assert _supports_kitty_graphics(config) is True diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..9eb96ff --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,56 @@ +"""Tests for Bor command-line options.""" + +from unittest.mock import patch + +from bor.app import main + + +def test_cli_version_prints_and_exits(capsys): + """--version should print the version and avoid launching the TUI.""" + with patch("bor.app.BorApp") as app_cls: + code = main(["--version"]) + + out = capsys.readouterr().out.strip() + assert code == 0 + assert out == "0.6.0" + app_cls.assert_not_called() + + +def test_cli_set_override_applies_config_before_app_init(): + """--set override should be visible when BorApp initializes.""" + captured = {} + + class FakeApp: + def __init__(self): + from bor.config import get_config + + captured["max_messages"] = get_config().general.max_messages + + def run(self): + return None + + with patch("bor.app.BorApp", FakeApp): + code = main(["--set", "general.max_messages=77"]) + + assert code == 0 + assert captured["max_messages"] == 77 + + +def test_cli_theme_override_applies_before_app_init(): + """--set general.theme should be visible when BorApp initializes.""" + captured = {} + + class FakeApp: + def __init__(self): + from bor.config import get_config + + captured["theme"] = get_config().general.theme + + def run(self): + return None + + with patch("bor.app.BorApp", FakeApp): + code = main(["--set", "general.theme=nord"]) + + assert code == 0 + assert captured["theme"] == "nord" diff --git a/tests/test_compose.py b/tests/test_compose.py index 251d854..cb368bd 100644 --- a/tests/test_compose.py +++ b/tests/test_compose.py @@ -2,8 +2,10 @@ import pytest +import bor.tabs.compose as compose_module +from bor.editing import kill_input_line, kill_text_line from bor.mu import EmailMessage, EmailAddress -from bor.tabs.compose import ComposeWidget, AddressInput, ComposeTextArea +from bor.tabs.compose import ComposeWidget, AddressInput, ComposeTextArea, FilePathInput import email.utils @@ -34,6 +36,14 @@ def test_compose_references_deduplicates_ids() -> None: assert references == " " +def test_compose_widget_does_not_bind_ctrl_a_to_attach() -> None: + """Ctrl+A is reserved for focused text inputs; attachment uses Ctrl+L A.""" + binding_keys = {binding.key for binding in ComposeWidget.BINDINGS} + + assert "ctrl+a" not in binding_keys + assert "ctrl+i" in binding_keys + + def test_smtp_address_extraction_with_commas() -> None: """Test that addresses with commas in display names are extracted correctly for SMTP.""" # Simulate what would be in the To/CC/BCC headers after formatting @@ -316,6 +326,17 @@ def test_read_insert_file_rejects_non_utf8(tmp_path: Path) -> None: ComposeWidget._read_insert_file(source) +def test_list_bulk_attachable_files_returns_sorted_files_only(tmp_path: Path) -> None: + """Bulk attach helper should list direct child files, sorted by name.""" + (tmp_path / "z-last.txt").write_text("z", encoding="utf-8") + (tmp_path / "A-first.txt").write_text("a", encoding="utf-8") + (tmp_path / "subdir").mkdir() + + files = ComposeWidget._list_bulk_attachable_files(tmp_path) + + assert [path.name for path in files] == ["A-first.txt", "z-last.txt"] + + def test_delete_previous_word_removes_word_and_whitespace() -> None: """Backward delete should remove previous word and separator spacing.""" source = "hello world" @@ -351,4 +372,88 @@ def test_transpose_does_not_cross_newline() -> None: source = "ab\ncd" text, index = ComposeTextArea._transpose_at_cursor(source, 2) assert text == source - assert index == 2 \ No newline at end of file + assert index == 2 + + +def test_kill_input_line_returns_killed_suffix() -> None: + """Ctrl+K on single-line inputs should kill to end and return copied text.""" + text, cursor, killed = kill_input_line("hello world", 6) + assert text == "hello " + assert cursor == 6 + assert killed == "world" + + +def test_kill_text_line_kills_to_end_of_current_line() -> None: + """Ctrl+K in editor text should kill only the remainder of the current line.""" + text, cursor, killed = kill_text_line("alpha\nbeta\ngamma", 7) + assert text == "alpha\nb\ngamma" + assert cursor == 7 + assert killed == "eta" + + +def test_kill_text_line_kills_newline_at_end_of_line() -> None: + """Ctrl+K at end of line should remove the newline, matching emacs behavior.""" + text, cursor, killed = kill_text_line("alpha\nbeta", 5) + assert text == "alphabeta" + assert cursor == 5 + assert killed == "\n" + + +def test_kill_text_line_kills_trailing_empty_line() -> None: + """Ctrl+K on a final empty line should delete that line's newline.""" + text, cursor, killed = kill_text_line("alpha\n", 6) + assert text == "alpha" + assert cursor == 5 + assert killed == "\n" + + +def test_compose_kill_line_appends_consecutive_kills_to_clipboard( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Consecutive editor kills should accumulate in clipboard order.""" + clipboard = "" + + def fake_copy(text: str, append: bool = False) -> None: + nonlocal clipboard + clipboard = clipboard + text if append else text + + monkeypatch.setattr(compose_module, "copy_to_clipboard", fake_copy) + + editor = ComposeTextArea() + editor.text = "alpha\nbeta\n" + editor.cursor_location = (0, 0) + + editor._kill_line_at_cursor() + editor._kill_line_at_cursor() + editor._kill_line_at_cursor() + editor._kill_line_at_cursor() + + assert clipboard == "alpha\nbeta\n" + assert editor.text == "" + + +def test_file_path_completion_returns_single_match() -> None: + """Tab should complete immediately when there is exactly one match.""" + completed = FilePathInput._longest_shared_completion_prefix( + "/tmp/al", + ["/tmp/alpha.txt"], + ) + assert completed == "/tmp/alpha.txt" + + +def test_file_path_completion_extends_to_shared_prefix() -> None: + """Tab should extend only to the shared prefix across multiple matches.""" + completed = FilePathInput._longest_shared_completion_prefix( + "/tmp/al", + ["/tmp/alpha.txt", "/tmp/alpine.txt"], + ) + assert completed == "/tmp/alp" + + +def test_file_path_completion_keeps_input_when_matches_diverge() -> None: + """Tab should leave the input unchanged when matches share no extra prefix.""" + completed = FilePathInput._longest_shared_completion_prefix( + "/tmp/a", + ["/tmp/alpha.txt", "/tmp/archive.txt"], + ) + assert completed == "/tmp/a" diff --git a/tests/test_config.py b/tests/test_config.py index 5a731cd..30f8959 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -22,6 +22,7 @@ def test_default_config(): assert config.general.theme == "textual-dark" assert config.folders.inbox == "/INBOX" assert config.smtp.port == 587 + assert config.attachments.force_kitty_support is False def test_config_from_dict(): @@ -34,12 +35,16 @@ def test_config_from_dict(): "folders": { "inbox": "/Mail/Inbox", }, + "attachments": { + "force_kitty_support": True, + }, } config = Config.from_dict(data) assert config.general.max_messages == 200 assert config.general.date_format == "%d/%m/%Y" assert config.folders.inbox == "/Mail/Inbox" + assert config.attachments.force_kitty_support is True # Default values should be preserved assert config.folders.archive == "/Archive" @@ -66,6 +71,9 @@ def test_load_config_from_file(): server = "mail.example.com" port = 465 +[attachments] +force_kitty_support = true + [aliases] t = "Thanks!" """ @@ -81,6 +89,7 @@ def test_load_config_from_file(): assert config.folders.inbox == "/MyInbox" assert config.smtp.server == "mail.example.com" assert config.smtp.port == 465 + assert config.attachments.force_kitty_support is True assert config.aliases.get("t") == "Thanks!" diff --git a/tests/test_message_index.py b/tests/test_message_index.py new file mode 100644 index 0000000..8706d4c --- /dev/null +++ b/tests/test_message_index.py @@ -0,0 +1,72 @@ +"""Tests for message index UI helpers.""" + +from __future__ import annotations + +import pytest +from textual.app import App, ComposeResult +from textual.containers import Vertical +from textual.widgets import DataTable + +from bor.tabs.message_index import ReplyBar + + +class ReplyBarTestApp(App[None]): + """Minimal app for exercising ReplyBar focus behavior.""" + + def __init__(self) -> None: + super().__init__() + self.reply_all: bool | None = None + + def compose(self) -> ComposeResult: + with Vertical(): + yield DataTable(id="message-table") + yield ReplyBar("", id="reply-bar") + + def on_mount(self) -> None: + table = self.query_one(DataTable) + table.add_column("Subject") + table.add_row("Test message") + table.focus() + + def ask_reply(self) -> None: + self.query_one(ReplyBar).ask(self.set_reply_all) + + def set_reply_all(self, reply_all: bool) -> None: + self.reply_all = reply_all + + +@pytest.mark.asyncio +async def test_reply_bar_escape_restores_previous_focus() -> None: + app = ReplyBarTestApp() + + async with app.run_test() as pilot: + await pilot.pause() + table = app.query_one(DataTable) + + app.ask_reply() + await pilot.pause() + assert app.focused is app.query_one(ReplyBar) + + await pilot.press("escape") + await pilot.pause() + + assert app.focused is table + assert app.reply_all is None + + +@pytest.mark.asyncio +async def test_reply_bar_accept_restores_previous_focus_and_calls_callback() -> None: + app = ReplyBarTestApp() + + async with app.run_test() as pilot: + await pilot.pause() + table = app.query_one(DataTable) + + app.ask_reply() + await pilot.pause() + + await pilot.press("a") + await pilot.pause() + + assert app.focused is table + assert app.reply_all is True diff --git a/tests/test_search_history.py b/tests/test_search_history.py new file mode 100644 index 0000000..96f4875 --- /dev/null +++ b/tests/test_search_history.py @@ -0,0 +1,161 @@ +"""Tests for search history in SearchInput.""" + +from pathlib import Path +from unittest.mock import patch + +import bor.tabs.message_index as _mi + + +def make_input(history_file: Path): + """Return a bare SearchInput with history loaded from history_file. + + The caller is responsible for keeping a patch on _mi._HISTORY_FILE active + if they want persistence calls to also use the redirected file. + """ + from bor.tabs.message_index import SearchInput + inp = SearchInput.__new__(SearchInput) + inp._history = [] + inp._history_pos = -1 + inp._saved_value = "" + with patch.object(_mi, "_HISTORY_FILE", history_file): + inp._load_history() + return inp + + +def test_save_to_history_appends(tmp_path): + hf = tmp_path / "search_history" + with patch.object(_mi, "_HISTORY_FILE", hf): + inp = make_input(hf) + inp._save_to_history("from:alice") + inp._save_to_history("maildir:/INBOX") + assert inp._history == ["from:alice", "maildir:/INBOX"] + + +def test_save_to_history_deduplicates(tmp_path): + hf = tmp_path / "search_history" + with patch.object(_mi, "_HISTORY_FILE", hf): + inp = make_input(hf) + inp._save_to_history("from:alice") + inp._save_to_history("maildir:/INBOX") + inp._save_to_history("from:alice") + assert inp._history == ["maildir:/INBOX", "from:alice"] + + +def test_save_to_history_ignores_blank(tmp_path): + hf = tmp_path / "search_history" + with patch.object(_mi, "_HISTORY_FILE", hf): + inp = make_input(hf) + inp._save_to_history(" ") + assert inp._history == [] + + +def test_save_to_history_trims_to_max(tmp_path): + hf = tmp_path / "search_history" + with patch.object(_mi, "_HISTORY_FILE", hf), patch.object(_mi, "_MAX_HISTORY", 3): + inp = make_input(hf) + for i in range(5): + inp._save_to_history(f"query{i}") + assert inp._history == ["query2", "query3", "query4"] + + +def test_persist_and_reload(tmp_path): + hf = tmp_path / "search_history" + with patch.object(_mi, "_HISTORY_FILE", hf): + inp = make_input(hf) + inp._save_to_history("from:alice") + inp._save_to_history("maildir:/INBOX") + # Simulate a fresh instance loading from the same file + inp2 = make_input(hf) + assert inp2._history == ["from:alice", "maildir:/INBOX"] + + +def test_up_navigates_to_newest_entry(tmp_path): + hf = tmp_path / "search_history" + inp = make_input(hf) + inp._history = ["oldest", "middle", "newest"] + inp._history_pos = -1 + inp._saved_value = "" + + # Simulate Up key: start browsing from newest + if inp._history: + if inp._history_pos == -1: + inp._saved_value = "" + inp._history_pos = len(inp._history) - 1 + elif inp._history_pos > 0: + inp._history_pos -= 1 + assert inp._history_pos == 2 + assert inp._history[inp._history_pos] == "newest" + + +def test_up_twice_reaches_older_entry(tmp_path): + hf = tmp_path / "search_history" + inp = make_input(hf) + inp._history = ["oldest", "middle", "newest"] + + # First Up + inp._saved_value = "current" + inp._history_pos = len(inp._history) - 1 # → newest + + # Second Up + if inp._history_pos > 0: + inp._history_pos -= 1 + assert inp._history[inp._history_pos] == "middle" + + +def test_up_at_oldest_stays(tmp_path): + hf = tmp_path / "search_history" + inp = make_input(hf) + inp._history = ["only"] + inp._history_pos = 0 # already at oldest + + # Up from oldest should stay + if inp._history_pos > 0: + inp._history_pos -= 1 + assert inp._history_pos == 0 + assert inp._history[inp._history_pos] == "only" + + +def test_down_from_newest_restores_saved_value(tmp_path): + hf = tmp_path / "search_history" + inp = make_input(hf) + inp._history = ["oldest", "newest"] + inp._history_pos = 1 # at newest + inp._saved_value = "my draft" + + # Down past newest → restore saved value + if inp._history_pos < len(inp._history) - 1: + inp._history_pos += 1 + result = inp._history[inp._history_pos] + else: + inp._history_pos = -1 + result = inp._saved_value + + assert inp._history_pos == -1 + assert result == "my draft" + + +def test_down_no_op_when_not_browsing(tmp_path): + hf = tmp_path / "search_history" + inp = make_input(hf) + inp._history = ["a", "b"] + inp._history_pos = -1 # not browsing + + # Down should be a no-op + if inp._history_pos != -1: + inp._history_pos += 1 + assert inp._history_pos == -1 + + +def test_reset_clears_browsing_state(tmp_path): + hf = tmp_path / "search_history" + inp = make_input(hf) + inp._history = ["from:alice"] + inp._history_pos = 0 + inp._saved_value = "draft" + + inp._history_pos = -1 + inp._saved_value = "" + + assert inp._history_pos == -1 + assert inp._saved_value == "" + assert inp._history == ["from:alice"] # list is preserved