1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
"""Scrollable commit log for the pgt TUI."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from pygit2 import GIT_SORT_TIME, Commit, GitError, Repository
from unicurses import ( # type: ignore[import-untyped]
A_REVERSE,
KEY_DOWN,
KEY_RESIZE,
KEY_UP,
)
from pygittools.tui.draw import draw_line
_MAX_COMMITS = 500
@dataclass(frozen=True, slots=True)
class _LogEntry:
short_id: str
date: str
subject: str
@dataclass(frozen=True, slots=True)
class LogNavKeyResult:
layout_changed: bool = False
class CommitLogList:
def __init__(self) -> None:
self._entries: list[_LogEntry] = []
self._cursor = 0
self._scroll_offset = 0
def is_at_top(self) -> bool:
return self._cursor == 0
def refresh(self, repo: Repository) -> None:
self._entries = _load_entries(repo)
max_index = max(0, len(self._entries) - 1)
self._cursor = min(self._cursor, max_index)
self._scroll_offset = min(self._scroll_offset, max(0, len(self._entries) - 1))
def draw(self, top_row: int, height: int, width: int, *, highlight: bool) -> None:
if height <= 0:
return
if not self._entries:
draw_line(top_row, 0, "No commits", width)
return
self._ensure_cursor_visible(height)
for view_row in range(height):
index = self._scroll_offset + view_row
if index >= len(self._entries):
break
entry = self._entries[index]
label = _format_entry(entry, width)
attr = A_REVERSE if highlight and index == self._cursor else 0
draw_line(top_row + view_row, 0, label, width, attr)
def handle_nav_key(self, key: int, repo: Repository) -> LogNavKeyResult:
if key == KEY_RESIZE:
return LogNavKeyResult()
if key in (KEY_UP, ord("k"), ord("K")):
self._cursor = max(0, self._cursor - 1)
return LogNavKeyResult()
if key in (KEY_DOWN, ord("j"), ord("J")):
self._cursor = min(max(0, len(self._entries) - 1), self._cursor + 1)
return LogNavKeyResult()
self.refresh(repo)
return LogNavKeyResult()
def status_hint(self) -> str:
if not self._entries:
return "No commits — j/k move"
entry = self._entries[self._cursor]
return f"{entry.short_id} {entry.date} — j/k move"
def _ensure_cursor_visible(self, height: int) -> None:
if self._cursor < self._scroll_offset:
self._scroll_offset = self._cursor
elif self._cursor >= self._scroll_offset + height:
self._scroll_offset = self._cursor - height + 1
def _load_entries(repo: Repository) -> list[_LogEntry]:
try:
head = repo.head.target
except GitError:
return []
entries: list[_LogEntry] = []
for commit in repo.walk(head, GIT_SORT_TIME):
if not isinstance(commit, Commit):
continue
subject = commit.message.splitlines()[0] if commit.message else "(no message)"
entries.append(
_LogEntry(
short_id=str(commit.id)[:7],
date=_format_commit_date(commit),
subject=subject,
),
)
if len(entries) >= _MAX_COMMITS:
break
return entries
def _format_commit_date(commit: Commit) -> str:
return datetime.fromtimestamp(commit.commit_time, tz=UTC).strftime("%Y-%m-%d")
def _format_entry(entry: _LogEntry, width: int) -> str:
prefix = f"{entry.short_id} {entry.date} "
remaining = max(0, width - len(prefix))
subject = entry.subject
if len(subject) > remaining:
subject = subject[: max(0, remaining - 1)] + "…"
return f"{prefix}{subject}"[:width]