From fcddc495f603aa4eb886af418fa45ef14ee51e79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C5=BEe=20Slosar?= Date: Wed, 15 Apr 2026 15:33:09 -0400 Subject: [PATCH 01/11] Fix BorApp tab access and bump dev version --- bor/__init__.py | 2 +- bor/tabs/base.py | 7 ++----- pyproject.toml | 2 +- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/bor/__init__.py b/bor/__init__.py index 15e910c..ee6e6cb 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-dev" __author__ = "Bor Development Team" 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/pyproject.toml b/pyproject.toml index 26024cf..a083d24 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-dev" description = "Terminal-based email reader using mu and textual" readme = "README.md" requires-python = ">=3.10" From 58ab5212ff8d02ce8d051704d04e6ed853766d60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C5=BEe=20Slosar?= Date: Wed, 15 Apr 2026 15:34:59 -0400 Subject: [PATCH 02/11] Improve editable field kill-line behavior --- CHANGELOG.md | 1 + bor/editing.py | 42 +++++++++ bor/tabs/compose.py | 40 ++++++++- bor/tabs/message_index.py | 9 ++ tests/test_cli.py | 56 ++++++++++++ tests/test_compose.py | 27 +++++- tests/test_search_history.py | 160 +++++++++++++++++++++++++++++++++++ 7 files changed, 333 insertions(+), 2 deletions(-) create mode 100644 bor/editing.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_search_history.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a0db01d..5a97513 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added compose editor shortcuts: `Ctrl+T` now transposes characters around the cursor, and `Ctrl+Backspace` performs backward word deletion matching `Ctrl+W` behavior. ### Fixed +- `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. - Fixed `MarkupError` crash when opening HTML emails whose converted plain text contains URLs with special characters (`"`, `]`, or trailing `)` from Markdown-style links). The URL is now sanitised before embedding it in a Rich `[link="…"]` markup tag, and a fallback renders the body without link markup if any error still occurs. - Kitty inline image previews in the attachments tab now display at the image's natural pixel size instead of being stretched to fill the available terminal width. If the image is larger than the available preview area, it is scaled down to fit while preserving the aspect ratio. Cell pixel dimensions are queried via `TIOCGWINSZ`; a rough 2 px/cell fallback is used when the query fails. - Fixed N/P (next/previous message) navigation breaking after opening attachments (Z) and returning (Q). The new `MessageViewWidget` received the fully-parsed message from `mu.view()` as its reference, whose `Message-ID` header retains angle brackets (``), while messages in the search index from `mu.find()` store msgids without them. The lookup always missed, so N/P silently did nothing. Fixed by stripping angle brackets in `mu.view()` when storing `msgid`, and normalising both sides of the comparison in the navigation actions. diff --git a/bor/editing.py b/bor/editing.py new file mode 100644 index 0000000..8f08802 --- /dev/null +++ b/bor/editing.py @@ -0,0 +1,42 @@ +"""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) -> None: + """Copy text to the system clipboard when clipboard support is available.""" + if pyperclip is None or not text: + return + 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))) + 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/tabs/compose.py b/bor/tabs/compose.py index f927768..684a154 100644 --- a/bor/tabs/compose.py +++ b/bor/tabs/compose.py @@ -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 @@ -325,6 +326,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 +410,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 @@ -500,6 +517,15 @@ 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) + 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.""" # Handle clipboard operations - must prevent default to avoid SIGINT @@ -533,6 +559,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 @@ -647,7 +678,14 @@ def _get_completions(self, path_str: str) -> List[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 diff --git a/bor/tabs/message_index.py b/bor/tabs/message_index.py index 177fb51..a4985ad 100644 --- a/bor/tabs/message_index.py +++ b/bor/tabs/message_index.py @@ -22,6 +22,7 @@ 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 +97,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: diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..7ff8363 --- /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-dev" + 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..8013a10 100644 --- a/tests/test_compose.py +++ b/tests/test_compose.py @@ -2,6 +2,7 @@ import pytest +from bor.editing import kill_input_line, kill_text_line from bor.mu import EmailMessage, EmailAddress from bor.tabs.compose import ComposeWidget, AddressInput, ComposeTextArea import email.utils @@ -351,4 +352,28 @@ 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" diff --git a/tests/test_search_history.py b/tests/test_search_history.py new file mode 100644 index 0000000..39cd6d0 --- /dev/null +++ b/tests/test_search_history.py @@ -0,0 +1,160 @@ +"""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 = "" + 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 From da9143ac3bae5a561db17afe6c667c61c70bdd80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C5=BEe=20Slosar?= Date: Fri, 17 Apr 2026 10:42:45 -0400 Subject: [PATCH 03/11] Add bulk attachment picker in compose --- README.md | 4 + bor/tabs/compose.py | 274 +++++++++++++++++++++++++++++++++- tests/test_app_integration.py | 47 ++++++ tests/test_compose.py | 11 ++ 4 files changed, 333 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ec2e429..ed3d4a6 100644 --- a/README.md +++ b/README.md @@ -179,9 +179,13 @@ bor | Ctrl+L L | Send message | | Ctrl+L D | Save draft | | Ctrl+L X | Cancel | +| Ctrl+L Z | Attach many files from a directory | | Ctrl+I | Insert file | | Ctrl+A | Attach file | +`Ctrl+L Z` first prompts for a directory with tab-completion, then opens a file picker where +`Space` marks files, `Enter` attaches the marked set, `Esc` cancels, and `Ctrl+A` toggles all. + ### Attachments | Key | Action | diff --git a/bor/tabs/compose.py b/bor/tabs/compose.py index 684a154..ddeace1 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 @@ -85,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. @@ -99,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 @@ -163,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 @@ -748,6 +759,90 @@ 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 = self.path.stat().st_size if self.path.exists() else 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. @@ -837,6 +932,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; @@ -874,6 +993,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: @@ -907,8 +1027,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.""" @@ -1522,6 +1649,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() @@ -1530,12 +1661,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() @@ -1582,6 +1728,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) @@ -1618,9 +1821,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") @@ -1634,3 +1842,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/tests/test_app_integration.py b/tests/test_app_integration.py index d7a7724..da6d52a 100644 --- a/tests/test_app_integration.py +++ b/tests/test_app_integration.py @@ -9,6 +9,7 @@ from datetime import datetime from textual.widgets import DataTable, Static +from bor.tabs.compose import ComposeWidget, FilePathInput, BulkAttachmentList # Mock EmailMessage for testing class MockEmailMessage: @@ -434,6 +435,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_compose.py b/tests/test_compose.py index 8013a10..dd92b35 100644 --- a/tests/test_compose.py +++ b/tests/test_compose.py @@ -317,6 +317,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" From 8a9452cfb7ac43a343078b660e3978b5f07f0eb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C5=BEe=20Slosar?= Date: Tue, 21 Apr 2026 05:17:57 -0400 Subject: [PATCH 04/11] see changelog --- CHANGELOG.md | 11 +++++++++- README.md | 8 ++++++-- bor/tabs/compose.py | 48 +++++++++++++++++++++---------------------- tests/test_compose.py | 29 +++++++++++++++++++++++++- 4 files changed, 68 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a97513..7d36a2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ 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-dev] + +### 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. + +### Fixed +- `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 @@ -15,7 +25,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added compose editor shortcuts: `Ctrl+T` now transposes characters around the cursor, and `Ctrl+Backspace` performs backward word deletion matching `Ctrl+W` behavior. ### Fixed -- `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. - Fixed `MarkupError` crash when opening HTML emails whose converted plain text contains URLs with special characters (`"`, `]`, or trailing `)` from Markdown-style links). The URL is now sanitised before embedding it in a Rich `[link="…"]` markup tag, and a fallback renders the body without link markup if any error still occurs. - Kitty inline image previews in the attachments tab now display at the image's natural pixel size instead of being stretched to fill the available terminal width. If the image is larger than the available preview area, it is scaled down to fit while preserving the aspect ratio. Cell pixel dimensions are queried via `TIOCGWINSZ`; a rough 2 px/cell fallback is used when the query fails. - Fixed N/P (next/previous message) navigation breaking after opening attachments (Z) and returning (Q). The new `MessageViewWidget` received the fully-parsed message from `mu.view()` as its reference, whose `Message-ID` header retains angle brackets (``), while messages in the search index from `mu.find()` store msgids without them. The lookup always missed, so N/P silently did nothing. Fixed by stripping angle brackets in `mu.view()` when storing `msgid`, and normalising both sides of the comparison in the navigation actions. diff --git a/README.md b/README.md index ed3d4a6..81569b2 100644 --- a/README.md +++ b/README.md @@ -179,11 +179,15 @@ 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 Z` first prompts for a directory with tab-completion, then opens a file picker where +`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/bor/tabs/compose.py b/bor/tabs/compose.py index ddeace1..1f2e3d5 100644 --- a/bor/tabs/compose.py +++ b/bor/tabs/compose.py @@ -642,8 +642,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]: """ @@ -687,6 +685,20 @@ 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 == "ctrl+k": @@ -698,29 +710,19 @@ def on_key(self, event: events.Key) -> None: 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": @@ -736,8 +738,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)) diff --git a/tests/test_compose.py b/tests/test_compose.py index dd92b35..1cce75c 100644 --- a/tests/test_compose.py +++ b/tests/test_compose.py @@ -4,7 +4,7 @@ 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 @@ -388,3 +388,30 @@ def test_kill_text_line_kills_newline_at_end_of_line() -> None: assert text == "alphabeta" assert cursor == 5 assert killed == "\n" + + +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" From 94869a26907242d1c955a2e4268f431f1209f463 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C5=BEe=20Slosar?= Date: Tue, 28 Apr 2026 16:47:20 -0400 Subject: [PATCH 05/11] Fix kill-line clipboard and reply prompt handling --- bor/editing.py | 12 +++++++++- bor/tabs/compose.py | 7 +++++- bor/tabs/message_index.py | 25 +++++++++++++++------ tests/test_app_integration.py | 41 +++++++++++++++++++++++++++++++++++ tests/test_compose.py | 34 +++++++++++++++++++++++++++++ 5 files changed, 110 insertions(+), 9 deletions(-) diff --git a/bor/editing.py b/bor/editing.py index 8f08802..9ff64f4 100644 --- a/bor/editing.py +++ b/bor/editing.py @@ -11,10 +11,15 @@ pyperclip = None -def copy_to_clipboard(text: str) -> 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) @@ -28,6 +33,11 @@ def kill_input_line(value: str, cursor_position: int) -> tuple[str, int, str]: 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: diff --git a/bor/tabs/compose.py b/bor/tabs/compose.py index 1f2e3d5..931e518 100644 --- a/bor/tabs/compose.py +++ b/bor/tabs/compose.py @@ -453,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: """ @@ -533,12 +534,16 @@ def _kill_line_at_cursor(self) -> None: 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) + 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: diff --git a/bor/tabs/message_index.py b/bor/tabs/message_index.py index a4985ad..5d04d91 100644 --- a/bor/tabs/message_index.py +++ b/bor/tabs/message_index.py @@ -224,10 +224,12 @@ 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 - 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.update("Reply to: (a)ll or (s)ender only?") self.add_class("visible") self.focus() @@ -235,17 +237,22 @@ def ask(self, callback: Callable) -> None: 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") 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() + if self._cancel_callback: + self._cancel_callback() + self._callback = None + self._cancel_callback = None + + event.prevent_default() + event.stop() can_focus = True @@ -734,7 +741,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) @@ -745,6 +752,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/tests/test_app_integration.py b/tests/test_app_integration.py index da6d52a..8fdbe8c 100644 --- a/tests/test_app_integration.py +++ b/tests/test_app_integration.py @@ -10,6 +10,7 @@ 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: @@ -187,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.""" diff --git a/tests/test_compose.py b/tests/test_compose.py index 1cce75c..a435b18 100644 --- a/tests/test_compose.py +++ b/tests/test_compose.py @@ -2,6 +2,7 @@ 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, FilePathInput @@ -390,6 +391,39 @@ def test_kill_text_line_kills_newline_at_end_of_line() -> None: 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( From 4a9f42415ffd49108aca3ff79ee23f04f054abdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C5=BEe=20Slosar?= Date: Mon, 11 May 2026 11:47:02 -0400 Subject: [PATCH 06/11] Fix to broken link parsing --- CHANGELOG.md | 1 + bor/tabs/message.py | 11 ++++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d36a2d..e9865aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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. ### Fixed +- 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. diff --git a/bor/tabs/message.py b/bor/tabs/message.py index c872e59..bd4582e 100644 --- a/bor/tabs/message.py +++ b/bor/tabs/message.py @@ -37,12 +37,21 @@ def _make_body_markup(content: str) -> str: result = [] for i, part in enumerate(parts): if i % 2 == 1: # URL + prefix = "" + if result and result[-1].endswith(r"\["): + result[-1] = result[-1][:-2] + prefix = r"\[" + elif result and result[-1].endswith("["): + result[-1] = result[-1][:-1] + prefix = r"\[" # 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)}') + result.append( + f'[link="{safe_url}"]{prefix}{rich_escape(url)}[/link]{rich_escape(trailing)}' + ) else: result.append(rich_escape(part)) return "".join(result) From a4aa7ee50943a9473536842216bc2175c6c1a377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C5=BEe=20Slosar?= Date: Tue, 19 May 2026 15:50:52 -0400 Subject: [PATCH 07/11] Fix message-viewer hang on messages with zero-width chars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a message whose body contained a zero-width character (e.g. U+200B) at the start of an overlong unbreakable word (a 300+ char URL in this case) froze bor with the CPU pinned, because rich.cells.split_graphemes in Rich ≤14.3.2 entered an infinite loop. Bumped textual>=8.2.7 and rich>=15.0.0, which include the upstream fix. Also drops a dead mu view --format=sexp subprocess call from MuInterface.view() that ran on every open with a 60-second timeout; the result was never read since the file is parsed directly with Python's email module. --- CHANGELOG.md | 3 + bor/mu.py | 3 - bor/tabs/message.py | 145 ++++++++++++++++++++++---------------------- pyproject.toml | 3 +- 4 files changed, 76 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9865aa..18f9871 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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. ### 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. 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/message.py b/bor/tabs/message.py index bd4582e..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,30 +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 - prefix = "" - if result and result[-1].endswith(r"\["): - result[-1] = result[-1][:-2] - prefix = r"\[" - elif result and result[-1].endswith("["): - result[-1] = result[-1][:-1] - prefix = r"\[" - # 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}"]{prefix}{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: @@ -143,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. + + 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) - # From - lines.append(f"[bold]From:[/bold] {rich_escape(str(self.message.from_addr))}") + 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 @@ -212,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): @@ -444,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) @@ -477,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: @@ -485,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/pyproject.toml b/pyproject.toml index a083d24..db2dd6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] From e715a1d55dbfe3b203754166f4c2a2e7fbead165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C5=BEe=20Slosar?= Date: Fri, 22 May 2026 03:25:40 -0400 Subject: [PATCH 08/11] Add option to force kitty graphics support --- CHANGELOG.md | 1 + bor.conf.example | 2 ++ bor/config.py | 1 + bor/tabs/attachments.py | 16 +++++++++++----- documentation/configuration.md | 3 +++ tests/test_attachments.py | 27 +++++++++++++++++++++++++++ tests/test_config.py | 9 +++++++++ 7 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 tests/test_attachments.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 18f9871..25f331c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.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. 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/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/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/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/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_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!" From 5b08d16209747d55bcbf9bbefe693e12afd0130a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C5=BEe=20Slosar?= Date: Fri, 22 May 2026 03:27:56 -0400 Subject: [PATCH 09/11] release version --- CHANGELOG.md | 2 +- bor/__init__.py | 2 +- pyproject.toml | 2 +- tests/test_cli.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25f331c..327040e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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-dev] +## [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. diff --git a/bor/__init__.py b/bor/__init__.py index ee6e6cb..6c37d73 100644 --- a/bor/__init__.py +++ b/bor/__init__.py @@ -5,5 +5,5 @@ and textual for the user interface. """ -__version__ = "0.6.0-dev" +__version__ = "0.6.0" __author__ = "Bor Development Team" diff --git a/pyproject.toml b/pyproject.toml index db2dd6e..4bba03e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "bormail" -version = "0.6.0-dev" +version = "0.6.0" description = "Terminal-based email reader using mu and textual" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/test_cli.py b/tests/test_cli.py index 7ff8363..9eb96ff 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,7 +12,7 @@ def test_cli_version_prints_and_exits(capsys): out = capsys.readouterr().out.strip() assert code == 0 - assert out == "0.6.0-dev" + assert out == "0.6.0" app_cls.assert_not_called() From e85654f925c16d98af6c45d92a67cd2ecf6af42a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C5=BEe=20Slosar?= Date: Fri, 22 May 2026 11:11:06 -0400 Subject: [PATCH 10/11] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- bor/tabs/compose.py | 7 ++++++- tests/test_search_history.py | 3 ++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/bor/tabs/compose.py b/bor/tabs/compose.py index 931e518..a57808d 100644 --- a/bor/tabs/compose.py +++ b/bor/tabs/compose.py @@ -794,7 +794,12 @@ def _refresh(self) -> None: def _render_label(self) -> str: """Return the display string for this item.""" marker = "[x]" if self.selected else "[ ]" - size = self.path.stat().st_size if self.path.exists() else 0 + 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: diff --git a/tests/test_search_history.py b/tests/test_search_history.py index 39cd6d0..96f4875 100644 --- a/tests/test_search_history.py +++ b/tests/test_search_history.py @@ -17,7 +17,8 @@ def make_input(history_file: Path): inp._history = [] inp._history_pos = -1 inp._saved_value = "" - inp._load_history() + with patch.object(_mi, "_HISTORY_FILE", history_file): + inp._load_history() return inp From c48848ff4fcdec52e0878cab4e15a4b276ed866e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C5=BEe=20Slosar?= Date: Fri, 22 May 2026 11:16:12 -0400 Subject: [PATCH 11/11] Fix reply bar focus and compose attach shortcut --- agents/implementation.md | 2 +- bor/tabs/compose.py | 1 - bor/tabs/message_index.py | 19 ++++++++++ tests/test_compose.py | 8 +++++ tests/test_message_index.py | 72 +++++++++++++++++++++++++++++++++++++ 5 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 tests/test_message_index.py 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/tabs/compose.py b/bor/tabs/compose.py index a57808d..20a89ef 100644 --- a/bor/tabs/compose.py +++ b/bor/tabs/compose.py @@ -863,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 = """ diff --git a/bor/tabs/message_index.py b/bor/tabs/message_index.py index 5d04d91..074413c 100644 --- a/bor/tabs/message_index.py +++ b/bor/tabs/message_index.py @@ -16,6 +16,7 @@ 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 @@ -225,27 +226,45 @@ def __init__(self, *args, **kwargs) -> None: 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, 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 self._callback = None self._cancel_callback = None elif key == "escape": self.remove_class("visible") + self._restore_focus() if self._cancel_callback: self._cancel_callback() self._callback = None diff --git a/tests/test_compose.py b/tests/test_compose.py index a435b18..cb368bd 100644 --- a/tests/test_compose.py +++ b/tests/test_compose.py @@ -36,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 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