checkpoint: 0.3.0 ui header, history persistence and history filter
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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:]
|
||||||
|
|||||||
371
src/app/ui.py
371
src/app/ui.py
@@ -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}]"
|
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
|
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)
|
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)
|
||||||
self._history_scroll_offset = clamp_scroll_offset(
|
if self._history_filter_mode:
|
||||||
self._history_selected_index,
|
self._history_filter_scroll_offset = clamp_scroll_offset(
|
||||||
self._history_scroll_offset,
|
selected_index,
|
||||||
visible_count,
|
self._history_filter_scroll_offset,
|
||||||
len(history_lines),
|
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(
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user