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
123
124
125
126
127
128
129
130
131
132
133
"""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]
KEY_DOWN,
KEY_RESIZE,
KEY_UP,
)
from pygittools.tui.draw import clear_row, draw_text
from pygittools.tui.git_colors import current_theme, log_entry_attrs
_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_text(top_row, 0, "No commits", width, current_theme().log_date)
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]
focused = highlight and index == self._cursor
_draw_entry(top_row + view_row, width, entry, focused=focused, highlight=highlight)
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 _draw_entry(row: int, width: int, entry: _LogEntry, *, focused: bool, highlight: bool) -> None:
clear_row(row)
hash_attr, date_attr, subject_attr = log_entry_attrs(focused=focused, highlight=highlight)
col = draw_text(row, 0, f"{entry.short_id} ", width, hash_attr)
col = draw_text(row, col, f"{entry.date} ", width, date_attr)
remaining = max(0, width - col)
subject = entry.subject
if len(subject) > remaining:
subject = subject[: max(0, remaining - 1)] + "…"
draw_text(row, col, subject, width, subject_attr)
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]