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 pathlib import Path
from app.history import SessionHistory
from app.navigator import Navigator from app.navigator import Navigator
from app.ui import TerminalUI 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.""" """Run the interactive directory browser and return the selected path."""
initial_path = (start_path or Path.cwd()).expanduser().resolve() 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() terminal_ui = ui or TerminalUI()
return terminal_ui.run(navigator) return terminal_ui.run(navigator)

View File

@@ -1,15 +1,46 @@
from __future__ import annotations from __future__ import annotations
import os
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path 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) @dataclass(slots=True)
class SessionHistory: class SessionHistory:
"""In-memory session history for visited directories.""" """In-memory session history for visited directories."""
_entries: list[Path] = field(default_factory=list) _entries: list[Path] = field(default_factory=list)
_index: int = -1 _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: def visit(self, path: Path) -> Path:
"""Record a directory visit and discard forward history when needed.""" """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 = self._entries[: self._index + 1]
self._entries.append(resolved_path) self._entries.append(resolved_path)
self._trim_to_limit()
self._index = len(self._entries) - 1 self._index = len(self._entries) - 1
self._persist()
return resolved_path return resolved_path
@property @property
@@ -66,3 +99,48 @@ class SessionHistory:
"""Return the full history as an immutable sequence.""" """Return the full history as an immutable sequence."""
return tuple(self._entries) 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 collections.abc import Iterator
from contextlib import contextmanager from contextlib import contextmanager
from dataclasses import dataclass from dataclasses import dataclass
from importlib import metadata
from pathlib import Path from pathlib import Path
from typing import TextIO from typing import TextIO
@@ -20,6 +21,18 @@ from app.opener import (
ESCAPE_KEY = 27 ESCAPE_KEY = 27
BACKSPACE_KEYS = (curses.KEY_BACKSPACE, 127, 8) 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) @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( def clamp_scroll_offset(
selected_index: int, scroll_offset: int, visible_count: int, total_count: int selected_index: int, scroll_offset: int, visible_count: int, total_count: int
) -> int: ) -> int:
@@ -116,11 +153,17 @@ class TerminalUI:
self._history_selected_index = 0 self._history_selected_index = 0
self._tree_scroll_offset = 0 self._tree_scroll_offset = 0
self._history_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_mode = False
self._filter_query = "" self._filter_query = ""
self._filter_selected_index = 0 self._filter_selected_index = 0
self._filter_scroll_offset = 0 self._filter_scroll_offset = 0
self._status_message: str | None = None 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: def run(self, navigator: Navigator) -> Path:
"""Start the interactive session and return the final directory 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: def _run_session(self, stdscr: curses.window, navigator: Navigator) -> Path:
curses.curs_set(0) curses.curs_set(0)
stdscr.keypad(True) stdscr.keypad(True)
self._initialize_colors()
original_path = navigator.current_path original_path = navigator.current_path
while True: while True:
@@ -160,6 +204,7 @@ class TerminalUI:
return tree_result return tree_result
def _render(self, stdscr: curses.window, navigator: Navigator) -> None: def _render(self, stdscr: curses.window, navigator: Navigator) -> None:
stdscr.attrset(self._normal_attr)
stdscr.erase() stdscr.erase()
height, width = stdscr.getmaxyx() height, width = stdscr.getmaxyx()
@@ -168,23 +213,21 @@ class TerminalUI:
stdscr.refresh() stdscr.refresh()
return return
hidden_status = "on" if navigator.show_hidden else "off" status_line = build_status_line(
file_status = "on" if navigator.show_files else "off" navigator, filter_mode=self._filter_mode, filter_query=self._filter_query
path_text = (
f"{navigator.current_path} [hidden: {hidden_status}] [files: {file_status}]"
) )
stdscr.addnstr(0, 0, path_text, max(0, width - 1)) path_line = build_path_line(navigator.current_path)
self._render_status_line(
start_row = 2 stdscr,
if self._filter_mode:
stdscr.addnstr(
1,
0, 0,
f"Filter: {self._filter_query}", width,
max(0, width - 1), status_line,
curses.A_BOLD, filter_active=self._filter_mode,
) )
start_row = 3 stdscr.addnstr(
1, 0, path_line, max(0, width - 1), self._normal_attr | curses.A_DIM
)
start_row = 2
lines, selected_index = self._build_tree_lines_for_render(navigator) lines, selected_index = self._build_tree_lines_for_render(navigator)
footer_rows = 1 if self._status_message else 0 footer_rows = 1 if self._status_message else 0
@@ -208,7 +251,13 @@ class TerminalUI:
scroll_offset = self._tree_scroll_offset scroll_offset = self._tree_scroll_offset
if self._filter_mode and self._filter_query and not lines: 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: else:
for row, line in enumerate( for row, line in enumerate(
lines[scroll_offset : scroll_offset + visible_count], lines[scroll_offset : scroll_offset + visible_count],
@@ -217,7 +266,11 @@ class TerminalUI:
if row >= height: if row >= height:
break 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) stdscr.addnstr(row, 0, line.text, max(0, width - 1), attributes)
if self._status_message and height > 0: if self._status_message and height > 0:
@@ -226,7 +279,7 @@ class TerminalUI:
0, 0,
self._status_message, self._status_message,
max(0, width - 1), max(0, width - 1),
curses.A_DIM, self._normal_attr | curses.A_DIM,
) )
stdscr.refresh() stdscr.refresh()
@@ -258,42 +311,91 @@ class TerminalUI:
def _render_history_mode( def _render_history_mode(
self, stdscr: curses.window, navigator: Navigator, width: int, height: int self, stdscr: curses.window, navigator: Navigator, width: int, height: int
) -> None: ) -> None:
history_lines = build_history_lines( stdscr.attrset(self._normal_attr)
navigator.history.entries(), self._history_selected_index 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 footer_rows = 1 if self._status_message else 0
visible_count = max(0, height - start_row - 1 - footer_rows) visible_count = max(0, height - start_row - 1 - footer_rows)
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( self._history_scroll_offset = clamp_scroll_offset(
self._history_selected_index, selected_index,
self._history_scroll_offset, self._history_scroll_offset,
visible_count, visible_count,
len(history_lines), len(history_lines),
) )
scroll_offset = self._history_scroll_offset
stdscr.addnstr(start_row, 0, "History:", max(0, width - 1), curses.A_BOLD) stdscr.addnstr(
start_row,
0,
"History:",
max(0, width - 1),
self._normal_attr | curses.A_BOLD,
)
for index, line in enumerate( for index, line in enumerate(
history_lines[ history_lines[scroll_offset : scroll_offset + visible_count],
self._history_scroll_offset : self._history_scroll_offset
+ visible_count
],
start=1, start=1,
): ):
row = start_row + index row = start_row + index
if row >= height: if row >= height:
break 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) 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: if self._status_message and height > 0:
stdscr.addnstr( stdscr.addnstr(
height - 1, height - 1,
0, 0,
self._status_message, self._status_message,
max(0, width - 1), max(0, width - 1),
curses.A_DIM, self._normal_attr | curses.A_DIM,
) )
def _toggle_history_mode(self, navigator: Navigator) -> None: def _toggle_history_mode(self, navigator: Navigator) -> None:
@@ -302,14 +404,172 @@ class TerminalUI:
if self._history_mode: if self._history_mode:
self._history_selected_index = self._get_current_history_index(navigator) self._history_selected_index = self._get_current_history_index(navigator)
self._history_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
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( def _handle_history_key(
self, key: int, navigator: Navigator, stdscr: curses.window | None = None self, key: int, navigator: Navigator, stdscr: curses.window | None = None
) -> None: ) -> None:
history_entries = navigator.history.entries() 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")): if key in (ESCAPE_KEY, ord("h")):
self._history_mode = False 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 return
if key == curses.KEY_UP: if key == curses.KEY_UP:
@@ -329,7 +589,7 @@ class TerminalUI:
return return
if key == curses.KEY_PPAGE: 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 = move_selection(
self._history_selected_index, self._history_selected_index,
step=step, step=step,
@@ -338,7 +598,7 @@ class TerminalUI:
return return
if key == curses.KEY_NPAGE: 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 = move_selection(
self._history_selected_index, self._history_selected_index,
step=step, step=step,
@@ -357,6 +617,10 @@ class TerminalUI:
if key in (curses.KEY_ENTER, 10, 13) and history_entries: if key in (curses.KEY_ENTER, 10, 13) and history_entries:
navigator.select_history_entry(self._history_selected_index) navigator.select_history_entry(self._history_selected_index)
self._history_mode = False 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 self._tree_scroll_offset = 0
def _handle_filter_key( def _handle_filter_key(
@@ -576,6 +840,41 @@ class TerminalUI:
if query in entry.name.casefold() 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: def _page_step(self, stdscr: curses.window | None, start_row: int) -> int:
if stdscr is None: if stdscr is None:
return 10 return 10

View File

@@ -2,7 +2,7 @@ from pathlib import Path
import pytest 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: 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): with pytest.raises(IndexError):
history.select(0) 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.navigator import Navigator, VisibleEntry
from app.ui import ( from app.ui import (
ESCAPE_KEY,
HistoryLine, HistoryLine,
RenderLine, RenderLine,
TerminalUI, TerminalUI,
build_history_lines, build_history_lines,
build_render_lines, build_render_lines,
build_status_line,
clamp_scroll_offset, clamp_scroll_offset,
format_entry, format_entry,
move_selection, move_selection,
@@ -147,6 +149,62 @@ def test_history_mode_enter_selects_entry_and_exits(tmp_path: Path) -> None:
assert navigator.selected_index == 0 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: def test_history_mode_toggle_off_keeps_current_directory(tmp_path: Path) -> None:
root = tmp_path / "root" root = tmp_path / "root"
root.mkdir() root.mkdir()
@@ -264,6 +322,30 @@ def test_filter_matching_is_case_insensitive(tmp_path: Path) -> None:
assert matches == [1] 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: def test_filter_mode_uses_g_and_g_upper_as_query_characters(tmp_path: Path) -> None:
root = tmp_path / "root" root = tmp_path / "root"
root.mkdir() root.mkdir()