From beab56393a357104d2a6a31307797218cb290923 Mon Sep 17 00:00:00 2001 From: Saky Date: Mon, 6 Apr 2026 00:42:42 +0200 Subject: [PATCH] checkpoint: 0.3.0 ui header, history persistence and history filter --- src/app/cli.py | 3 +- src/app/history.py | 78 +++++++++ src/app/ui.py | 371 ++++++++++++++++++++++++++++++++++++++---- tests/test_history.py | 47 +++++- tests/test_ui.py | 82 ++++++++++ 5 files changed, 543 insertions(+), 38 deletions(-) diff --git a/src/app/cli.py b/src/app/cli.py index c676b2b..2161dac 100644 --- a/src/app/cli.py +++ b/src/app/cli.py @@ -2,6 +2,7 @@ from __future__ import annotations from pathlib import Path +from app.history import SessionHistory from app.navigator import Navigator from app.ui import TerminalUI @@ -10,6 +11,6 @@ def run_cli(start_path: Path | None = None, ui: TerminalUI | None = None) -> Pat """Run the interactive directory browser and return the selected path.""" initial_path = (start_path or Path.cwd()).expanduser().resolve() - navigator = Navigator(initial_path) + navigator = Navigator(initial_path, history=SessionHistory.persistent()) terminal_ui = ui or TerminalUI() return terminal_ui.run(navigator) diff --git a/src/app/history.py b/src/app/history.py index 16fbf2a..b325841 100644 --- a/src/app/history.py +++ b/src/app/history.py @@ -1,15 +1,46 @@ from __future__ import annotations +import os from dataclasses import dataclass, field from pathlib import Path +def default_history_path() -> Path: + """Return the default on-disk history location.""" + + override = os.environ.get("CD_BROWSER_HISTORY_FILE") + if override: + return Path(override).expanduser() + + if os.name == "nt": + base = Path(os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming"))) + else: + base = Path(os.environ.get("XDG_STATE_HOME", str(Path.home() / ".local/state"))) + + return base / "cd-browser" / "history.txt" + + @dataclass(slots=True) class SessionHistory: """In-memory session history for visited directories.""" _entries: list[Path] = field(default_factory=list) _index: int = -1 + storage_path: Path | None = None + max_entries: int = 500 + + @classmethod + def persistent( + cls, storage_path: Path | None = None, max_entries: int = 500 + ) -> SessionHistory: + """Create a history instance backed by an on-disk file.""" + + history = cls( + storage_path=storage_path or default_history_path(), + max_entries=max_entries, + ) + history._load() + return history def visit(self, path: Path) -> Path: """Record a directory visit and discard forward history when needed.""" @@ -23,7 +54,9 @@ class SessionHistory: self._entries = self._entries[: self._index + 1] self._entries.append(resolved_path) + self._trim_to_limit() self._index = len(self._entries) - 1 + self._persist() return resolved_path @property @@ -66,3 +99,48 @@ class SessionHistory: """Return the full history as an immutable sequence.""" return tuple(self._entries) + + def _load(self) -> None: + if self.storage_path is None: + return + + try: + lines = self.storage_path.read_text(encoding="utf-8").splitlines() + except FileNotFoundError: + return + except OSError: + return + + loaded_entries: list[Path] = [] + for line in lines: + raw = line.strip() + if not raw: + continue + + resolved = Path(raw).expanduser().resolve() + if loaded_entries and loaded_entries[-1] == resolved: + continue + + loaded_entries.append(resolved) + + self._entries = loaded_entries + self._trim_to_limit() + self._index = len(self._entries) - 1 + + def _persist(self) -> None: + if self.storage_path is None: + return + + try: + self.storage_path.parent.mkdir(parents=True, exist_ok=True) + contents = "".join(f"{entry}\n" for entry in self._entries) + self.storage_path.write_text(contents, encoding="utf-8") + except OSError: + return + + def _trim_to_limit(self) -> None: + limit = max(1, self.max_entries) + if len(self._entries) <= limit: + return + + self._entries = self._entries[-limit:] diff --git a/src/app/ui.py b/src/app/ui.py index 2d1917a..5286422 100644 --- a/src/app/ui.py +++ b/src/app/ui.py @@ -6,6 +6,7 @@ import sys from collections.abc import Iterator from contextlib import contextmanager from dataclasses import dataclass +from importlib import metadata from pathlib import Path from typing import TextIO @@ -20,6 +21,18 @@ from app.opener import ( ESCAPE_KEY = 27 BACKSPACE_KEYS = (curses.KEY_BACKSPACE, 127, 8) +DEFAULT_APP_VERSION = "0.3.0" +FILTER_COLOR_PAIR = 1 +NORMAL_COLOR_PAIR = 2 + + +def _app_version() -> str: + """Return installed package version when available.""" + + try: + return metadata.version("cd-browser") + except metadata.PackageNotFoundError: + return DEFAULT_APP_VERSION @dataclass(frozen=True, slots=True) @@ -88,6 +101,30 @@ def build_history_lines( ] +def build_status_line( + navigator: Navigator, filter_mode: bool, filter_query: str +) -> str: + """Build first header line with version and live session toggles.""" + + hidden_status = "on" if navigator.show_hidden else "off" + file_status = "on" if navigator.show_files else "off" + if filter_mode: + filter_segment = f"filter: {filter_query}" if filter_query else "filter:" + else: + filter_segment = "filter: off" + history_count = len(navigator.history.entries()) + return ( + f"cd-browser v{_app_version()} | hist: {history_count} | " + f"{filter_segment} | hidden: {hidden_status} | files: {file_status}" + ) + + +def build_path_line(path: Path) -> str: + """Build second header line with current path.""" + + return f"path: {path}" + + def clamp_scroll_offset( selected_index: int, scroll_offset: int, visible_count: int, total_count: int ) -> int: @@ -116,11 +153,17 @@ class TerminalUI: self._history_selected_index = 0 self._tree_scroll_offset = 0 self._history_scroll_offset = 0 + self._history_filter_mode = False + self._history_filter_query = "" + self._history_filter_selected_index = 0 + self._history_filter_scroll_offset = 0 self._filter_mode = False self._filter_query = "" self._filter_selected_index = 0 self._filter_scroll_offset = 0 self._status_message: str | None = None + self._normal_attr = curses.A_NORMAL + self._filter_label_attr = curses.A_BOLD def run(self, navigator: Navigator) -> Path: """Start the interactive session and return the final directory path.""" @@ -132,6 +175,7 @@ class TerminalUI: def _run_session(self, stdscr: curses.window, navigator: Navigator) -> Path: curses.curs_set(0) stdscr.keypad(True) + self._initialize_colors() original_path = navigator.current_path while True: @@ -160,6 +204,7 @@ class TerminalUI: return tree_result def _render(self, stdscr: curses.window, navigator: Navigator) -> None: + stdscr.attrset(self._normal_attr) stdscr.erase() height, width = stdscr.getmaxyx() @@ -168,23 +213,21 @@ class TerminalUI: stdscr.refresh() return - hidden_status = "on" if navigator.show_hidden else "off" - file_status = "on" if navigator.show_files else "off" - path_text = ( - f"{navigator.current_path} [hidden: {hidden_status}] [files: {file_status}]" + status_line = build_status_line( + navigator, filter_mode=self._filter_mode, filter_query=self._filter_query + ) + path_line = build_path_line(navigator.current_path) + self._render_status_line( + stdscr, + 0, + width, + status_line, + filter_active=self._filter_mode, + ) + stdscr.addnstr( + 1, 0, path_line, max(0, width - 1), self._normal_attr | curses.A_DIM ) - stdscr.addnstr(0, 0, path_text, max(0, width - 1)) - start_row = 2 - if self._filter_mode: - stdscr.addnstr( - 1, - 0, - f"Filter: {self._filter_query}", - max(0, width - 1), - curses.A_BOLD, - ) - start_row = 3 lines, selected_index = self._build_tree_lines_for_render(navigator) footer_rows = 1 if self._status_message else 0 @@ -208,7 +251,13 @@ class TerminalUI: scroll_offset = self._tree_scroll_offset if self._filter_mode and self._filter_query and not lines: - stdscr.addnstr(start_row, 0, "No matches", max(0, width - 1), curses.A_DIM) + stdscr.addnstr( + start_row, + 0, + "No matches", + max(0, width - 1), + self._normal_attr | curses.A_DIM, + ) else: for row, line in enumerate( lines[scroll_offset : scroll_offset + visible_count], @@ -217,7 +266,11 @@ class TerminalUI: if row >= height: break - attributes = curses.A_REVERSE if line.is_selected else curses.A_NORMAL + attributes = ( + self._normal_attr | curses.A_REVERSE + if line.is_selected + else self._normal_attr + ) stdscr.addnstr(row, 0, line.text, max(0, width - 1), attributes) if self._status_message and height > 0: @@ -226,7 +279,7 @@ class TerminalUI: 0, self._status_message, max(0, width - 1), - curses.A_DIM, + self._normal_attr | curses.A_DIM, ) stdscr.refresh() @@ -258,42 +311,91 @@ class TerminalUI: def _render_history_mode( self, stdscr: curses.window, navigator: Navigator, width: int, height: int ) -> None: - history_lines = build_history_lines( - navigator.history.entries(), self._history_selected_index + stdscr.attrset(self._normal_attr) + status_line = build_status_line( + navigator, + filter_mode=self._history_filter_mode, + filter_query=self._history_filter_query, ) - start_row = 1 + path_line = build_path_line(navigator.current_path) + self._render_status_line( + stdscr, + 0, + width, + status_line, + filter_active=self._history_filter_mode, + ) + stdscr.addnstr( + 1, 0, path_line, max(0, width - 1), self._normal_attr | curses.A_DIM + ) + + history_entries = navigator.history.entries() + history_lines, selected_index = self._build_history_lines_for_render( + history_entries + ) + start_row = 2 footer_rows = 1 if self._status_message else 0 visible_count = max(0, height - start_row - 1 - footer_rows) - self._history_scroll_offset = clamp_scroll_offset( - self._history_selected_index, - self._history_scroll_offset, - visible_count, - len(history_lines), + if self._history_filter_mode: + self._history_filter_scroll_offset = clamp_scroll_offset( + selected_index, + self._history_filter_scroll_offset, + visible_count, + len(history_lines), + ) + scroll_offset = self._history_filter_scroll_offset + else: + self._history_scroll_offset = clamp_scroll_offset( + selected_index, + self._history_scroll_offset, + visible_count, + len(history_lines), + ) + scroll_offset = self._history_scroll_offset + + stdscr.addnstr( + start_row, + 0, + "History:", + max(0, width - 1), + self._normal_attr | curses.A_BOLD, ) - stdscr.addnstr(start_row, 0, "History:", max(0, width - 1), curses.A_BOLD) - for index, line in enumerate( - history_lines[ - self._history_scroll_offset : self._history_scroll_offset - + visible_count - ], + history_lines[scroll_offset : scroll_offset + visible_count], start=1, ): row = start_row + index if row >= height: break - attributes = curses.A_REVERSE if line.is_selected else curses.A_NORMAL + attributes = ( + self._normal_attr | curses.A_REVERSE + if line.is_selected + else self._normal_attr + ) stdscr.addnstr(row, 0, line.text, max(0, width - 1), attributes) + if ( + self._history_filter_mode + and self._history_filter_query + and not history_lines + ): + stdscr.addnstr( + start_row + 1, + 0, + "No matches", + max(0, width - 1), + self._normal_attr | curses.A_DIM, + ) + if self._status_message and height > 0: stdscr.addnstr( height - 1, 0, self._status_message, max(0, width - 1), - curses.A_DIM, + self._normal_attr | curses.A_DIM, ) def _toggle_history_mode(self, navigator: Navigator) -> None: @@ -302,14 +404,172 @@ class TerminalUI: if self._history_mode: self._history_selected_index = self._get_current_history_index(navigator) self._history_scroll_offset = 0 + self._history_filter_mode = False + self._history_filter_query = "" + self._history_filter_selected_index = 0 + self._history_filter_scroll_offset = 0 + return + + self._history_filter_mode = False + self._history_filter_query = "" + self._history_filter_selected_index = 0 + self._history_filter_scroll_offset = 0 + + def _initialize_colors(self) -> None: + self._normal_attr = curses.A_NORMAL + self._filter_label_attr = curses.A_BOLD + if not curses.has_colors(): + return + + try: + curses.start_color() + curses.use_default_colors() + curses.init_pair(NORMAL_COLOR_PAIR, curses.COLOR_WHITE, -1) + curses.init_pair(FILTER_COLOR_PAIR, curses.COLOR_GREEN, -1) + self._normal_attr = curses.color_pair(NORMAL_COLOR_PAIR) + self._filter_label_attr = ( + curses.color_pair(FILTER_COLOR_PAIR) | curses.A_BOLD + ) + except curses.error: + self._normal_attr = curses.A_NORMAL + self._filter_label_attr = curses.A_BOLD + + def _render_status_line( + self, + stdscr: curses.window, + row: int, + width: int, + status_line: str, + filter_active: bool, + ) -> None: + max_width = max(0, width - 1) + if not filter_active: + stdscr.attrset(self._normal_attr) + stdscr.addnstr(row, 0, status_line, max_width) + stdscr.attrset(self._normal_attr) + return + + prefix, marker, suffix = status_line.partition("filter:") + if not marker: + stdscr.attrset(self._normal_attr) + stdscr.addnstr(row, 0, status_line, max_width) + stdscr.attrset(self._normal_attr) + return + + x = 0 + stdscr.attrset(self._normal_attr) + stdscr.addnstr(row, x, prefix, max_width) + x += min(len(prefix), max_width) + if x >= max_width: + return + + marker_width = max_width - x + stdscr.attrset(self._filter_label_attr) + stdscr.addnstr(row, x, marker, marker_width) + x += min(len(marker), marker_width) + if x >= max_width: + return + + stdscr.attrset(self._normal_attr) + stdscr.addnstr(row, x, suffix, max_width - x) + stdscr.attrset(self._normal_attr) def _handle_history_key( self, key: int, navigator: Navigator, stdscr: curses.window | None = None ) -> None: history_entries = navigator.history.entries() + matches = self._matching_history_indices(history_entries) + + if self._history_filter_mode: + if key == ESCAPE_KEY: + self._history_filter_mode = False + self._history_filter_query = "" + self._history_filter_selected_index = 0 + self._history_filter_scroll_offset = 0 + return + + if key in (curses.KEY_ENTER, 10, 13): + if not matches: + return + + selected_entry_index = matches[self._history_filter_selected_index] + navigator.select_history_entry(selected_entry_index) + self._history_mode = False + self._history_filter_mode = False + self._history_filter_query = "" + self._history_filter_selected_index = 0 + self._history_filter_scroll_offset = 0 + self._tree_scroll_offset = 0 + return + + if key == curses.KEY_UP: + self._history_filter_selected_index = move_selection( + self._history_filter_selected_index, + step=-1, + entry_count=len(matches), + ) + return + + if key == curses.KEY_DOWN: + self._history_filter_selected_index = move_selection( + self._history_filter_selected_index, + step=1, + entry_count=len(matches), + ) + return + + if key == curses.KEY_PPAGE: + step = -self._page_step(stdscr, start_row=3) + self._history_filter_selected_index = move_selection( + self._history_filter_selected_index, + step=step, + entry_count=len(matches), + ) + return + + if key == curses.KEY_NPAGE: + step = self._page_step(stdscr, start_row=3) + self._history_filter_selected_index = move_selection( + self._history_filter_selected_index, + step=step, + entry_count=len(matches), + ) + return + + if key == curses.KEY_HOME: + self._history_filter_selected_index = 0 + return + + if key == curses.KEY_END and matches: + self._history_filter_selected_index = len(matches) - 1 + return + + if key in BACKSPACE_KEYS: + self._history_filter_query = self._history_filter_query[:-1] + self._history_filter_selected_index = 0 + self._history_filter_scroll_offset = 0 + return + + if 32 <= key <= 126: + self._history_filter_query += chr(key) + self._history_filter_selected_index = 0 + self._history_filter_scroll_offset = 0 + + return if key in (ESCAPE_KEY, ord("h")): self._history_mode = False + self._history_filter_mode = False + self._history_filter_query = "" + self._history_filter_selected_index = 0 + self._history_filter_scroll_offset = 0 + return + + if key == ord("/"): + self._history_filter_mode = True + self._history_filter_query = "" + self._history_filter_selected_index = 0 + self._history_filter_scroll_offset = 0 return if key == curses.KEY_UP: @@ -329,7 +589,7 @@ class TerminalUI: return if key == curses.KEY_PPAGE: - step = -self._page_step(stdscr, start_row=1) + step = -self._page_step(stdscr, start_row=3) self._history_selected_index = move_selection( self._history_selected_index, step=step, @@ -338,7 +598,7 @@ class TerminalUI: return if key == curses.KEY_NPAGE: - step = self._page_step(stdscr, start_row=1) + step = self._page_step(stdscr, start_row=3) self._history_selected_index = move_selection( self._history_selected_index, step=step, @@ -357,6 +617,10 @@ class TerminalUI: if key in (curses.KEY_ENTER, 10, 13) and history_entries: navigator.select_history_entry(self._history_selected_index) self._history_mode = False + self._history_filter_mode = False + self._history_filter_query = "" + self._history_filter_selected_index = 0 + self._history_filter_scroll_offset = 0 self._tree_scroll_offset = 0 def _handle_filter_key( @@ -576,6 +840,41 @@ class TerminalUI: if query in entry.name.casefold() ] + def _matching_history_indices(self, entries: tuple[Path, ...]) -> list[int]: + if not self._history_filter_query: + return list(range(len(entries))) + + query = self._history_filter_query.casefold() + return [ + index + for index, entry in enumerate(entries) + if query in str(entry).casefold() + ] + + def _build_history_lines_for_render( + self, entries: tuple[Path, ...] + ) -> tuple[list[HistoryLine], int]: + if not self._history_filter_mode: + return build_history_lines(entries, self._history_selected_index), ( + self._history_selected_index + ) + + matches = self._matching_history_indices(entries) + if not matches: + self._history_filter_selected_index = 0 + return [], 0 + + self._history_filter_selected_index = max( + 0, min(self._history_filter_selected_index, len(matches) - 1) + ) + return [ + HistoryLine( + text=str(entries[entry_index]), + is_selected=match_index == self._history_filter_selected_index, + ) + for match_index, entry_index in enumerate(matches) + ], self._history_filter_selected_index + def _page_step(self, stdscr: curses.window | None, start_row: int) -> int: if stdscr is None: return 10 diff --git a/tests/test_history.py b/tests/test_history.py index f33333a..ecf6450 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -2,7 +2,7 @@ from pathlib import Path import pytest -from app.history import SessionHistory +from app.history import SessionHistory, default_history_path def test_visit_records_current_directory(tmp_path: Path) -> None: @@ -82,3 +82,48 @@ def test_select_raises_for_invalid_index() -> None: with pytest.raises(IndexError): history.select(0) + + +def test_persistent_history_loads_entries_from_disk(tmp_path: Path) -> None: + first = tmp_path / "first" + second = tmp_path / "second" + for directory in (first, second): + directory.mkdir() + history_file = tmp_path / "history.txt" + history_file.write_text(f"{first}\n{second}\n", encoding="utf-8") + + history = SessionHistory.persistent(storage_path=history_file) + + assert history.entries() == (first.resolve(), second.resolve()) + assert history.current == second.resolve() + + +def test_persistent_history_visit_writes_to_disk_and_applies_limit( + tmp_path: Path, +) -> None: + first = tmp_path / "first" + second = tmp_path / "second" + third = tmp_path / "third" + for directory in (first, second, third): + directory.mkdir() + history_file = tmp_path / "state" / "history.txt" + + history = SessionHistory.persistent(storage_path=history_file, max_entries=2) + history.visit(first) + history.visit(second) + history.visit(third) + + assert history.entries() == (second.resolve(), third.resolve()) + assert history_file.read_text(encoding="utf-8").splitlines() == [ + str(second.resolve()), + str(third.resolve()), + ] + + +def test_default_history_path_uses_override_env( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + override = tmp_path / "custom-history.txt" + monkeypatch.setenv("CD_BROWSER_HISTORY_FILE", str(override)) + + assert default_history_path() == override diff --git a/tests/test_ui.py b/tests/test_ui.py index e003963..6a85032 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -5,11 +5,13 @@ import pytest from app.navigator import Navigator, VisibleEntry from app.ui import ( + ESCAPE_KEY, HistoryLine, RenderLine, TerminalUI, build_history_lines, build_render_lines, + build_status_line, clamp_scroll_offset, format_entry, move_selection, @@ -147,6 +149,62 @@ def test_history_mode_enter_selects_entry_and_exits(tmp_path: Path) -> None: assert navigator.selected_index == 0 +def test_history_mode_slash_activates_history_filter() -> None: + ui = TerminalUI() + navigator = Navigator(Path.cwd()) + ui._history_mode = True + + ui._handle_history_key(ord("/"), navigator) + + assert ui._history_filter_mode is True + assert ui._history_filter_query == "" + + +def test_history_filter_enter_selects_filtered_entry_and_exits_history( + tmp_path: Path, +) -> None: + root = tmp_path / "root" + alpha = root / "alpha" + beta = root / "beta" + root.mkdir() + alpha.mkdir() + beta.mkdir() + + navigator = Navigator(root) + navigator.set_selected_index(1) + navigator.enter_selected_directory() + navigator.go_to_parent_directory() + navigator.set_selected_index(2) + navigator.enter_selected_directory() + + ui = TerminalUI() + ui._toggle_history_mode(navigator) + ui._history_filter_mode = True + ui._history_filter_query = "alpha" + + ui._handle_history_key(10, navigator) + + assert navigator.current_path == alpha.resolve() + assert ui._history_mode is False + assert ui._history_filter_mode is False + + +def test_history_filter_escape_disables_only_history_filter(tmp_path: Path) -> None: + root = tmp_path / "root" + root.mkdir() + navigator = Navigator(root) + ui = TerminalUI() + ui._toggle_history_mode(navigator) + ui._history_filter_mode = True + ui._history_filter_query = "ro" + + ui._handle_history_key(ESCAPE_KEY, navigator) + + assert ui._history_mode is True + assert ui._history_filter_mode is False + assert ui._history_filter_query == "" + + def test_history_mode_toggle_off_keeps_current_directory(tmp_path: Path) -> None: root = tmp_path / "root" root.mkdir() @@ -264,6 +322,30 @@ def test_filter_matching_is_case_insensitive(tmp_path: Path) -> None: assert matches == [1] +def test_build_status_line_shows_filter_off_when_filter_mode_is_disabled( + tmp_path: Path, +) -> None: + root = tmp_path / "root" + root.mkdir() + navigator = Navigator(root) + + status_line = build_status_line(navigator, filter_mode=False, filter_query="") + + assert "filter: off" in status_line + + +def test_build_status_line_shows_empty_filter_without_off_when_filter_is_active( + tmp_path: Path, +) -> None: + root = tmp_path / "root" + root.mkdir() + navigator = Navigator(root) + + status_line = build_status_line(navigator, filter_mode=True, filter_query="") + + assert "filter: |" in status_line + + def test_filter_mode_uses_g_and_g_upper_as_query_characters(tmp_path: Path) -> None: root = tmp_path / "root" root.mkdir()