checkpoint: 0.3.0 ui header, history persistence and history filter

This commit is contained in:
2026-04-06 00:42:42 +02:00
parent 6f78f6d9ca
commit beab56393a
5 changed files with 543 additions and 38 deletions

View File

@@ -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)

View File

@@ -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:]

View File

@@ -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

View File

@@ -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

View File

@@ -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()