vgt 0.0.1a0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vgt-0.0.1a0/PKG-INFO +29 -0
- vgt-0.0.1a0/README.md +1 -0
- vgt-0.0.1a0/main.py +286 -0
- vgt-0.0.1a0/render.py +203 -0
- vgt-0.0.1a0/setup.cfg +4 -0
- vgt-0.0.1a0/setup.py +38 -0
- vgt-0.0.1a0/vgt.egg-info/PKG-INFO +29 -0
- vgt-0.0.1a0/vgt.egg-info/SOURCES.txt +10 -0
- vgt-0.0.1a0/vgt.egg-info/dependency_links.txt +1 -0
- vgt-0.0.1a0/vgt.egg-info/entry_points.txt +2 -0
- vgt-0.0.1a0/vgt.egg-info/requires.txt +4 -0
- vgt-0.0.1a0/vgt.egg-info/top_level.txt +2 -0
vgt-0.0.1a0/PKG-INFO
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vgt
|
|
3
|
+
Version: 0.0.1a0
|
|
4
|
+
Summary: TUI git viewer
|
|
5
|
+
Home-page: https://www.mlx-code.com
|
|
6
|
+
Author: J Joe
|
|
7
|
+
Author-email: albersj66@gmail.com
|
|
8
|
+
License: Apache-2.0
|
|
9
|
+
Project-URL: Source, https://github.com/JosefAlbers/mlx-code
|
|
10
|
+
Project-URL: Issues, https://github.com/JosefAlbers/mlx-code/issues
|
|
11
|
+
Project-URL: Documentation, https://www.mlx-code.com/
|
|
12
|
+
Requires-Python: >=3.12.8
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: textual>=8.2.7
|
|
15
|
+
Requires-Dist: rich>=15.0.0
|
|
16
|
+
Provides-Extra: all
|
|
17
|
+
Dynamic: author
|
|
18
|
+
Dynamic: author-email
|
|
19
|
+
Dynamic: description
|
|
20
|
+
Dynamic: description-content-type
|
|
21
|
+
Dynamic: home-page
|
|
22
|
+
Dynamic: license
|
|
23
|
+
Dynamic: project-url
|
|
24
|
+
Dynamic: provides-extra
|
|
25
|
+
Dynamic: requires-dist
|
|
26
|
+
Dynamic: requires-python
|
|
27
|
+
Dynamic: summary
|
|
28
|
+
|
|
29
|
+
view git
|
vgt-0.0.1a0/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
view git
|
vgt-0.0.1a0/main.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Optional
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
from render import Node, TreeApp, dump_text
|
|
9
|
+
EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
|
|
10
|
+
|
|
11
|
+
class Git:
|
|
12
|
+
|
|
13
|
+
def __init__(self, cwd: str):
|
|
14
|
+
self.cwd = cwd
|
|
15
|
+
self._run('rev-parse', '--git-dir')
|
|
16
|
+
|
|
17
|
+
def _run(self, *args: str) -> str:
|
|
18
|
+
r = subprocess.run(['git', *args], cwd=self.cwd, capture_output=True, text=True)
|
|
19
|
+
if r.returncode != 0:
|
|
20
|
+
raise RuntimeError(f'git {' '.join(args)}: {r.stderr.strip()}')
|
|
21
|
+
return r.stdout
|
|
22
|
+
|
|
23
|
+
def current_branch(self) -> Optional[str]:
|
|
24
|
+
out = self._run('branch', '--show-current').strip()
|
|
25
|
+
return out or None
|
|
26
|
+
|
|
27
|
+
def branch_tips(self) -> dict[str, str]:
|
|
28
|
+
out = self._run('for-each-ref', '--format=%(refname:short)%09%(objectname)', 'refs/heads/')
|
|
29
|
+
return dict((line.split('\t') for line in out.splitlines() if line.strip()))
|
|
30
|
+
|
|
31
|
+
def load_commits(self) -> dict[str, 'Commit']:
|
|
32
|
+
out = self._run('log', '--branches', '--date=iso-strict', '--format=%H%x09%P%x09%an%x09%ad%x09%s%x09%b%x1e')
|
|
33
|
+
commits: dict[str, Commit] = {}
|
|
34
|
+
for rec in out.split('\x1e'):
|
|
35
|
+
rec = rec.strip('\n')
|
|
36
|
+
if not rec:
|
|
37
|
+
continue
|
|
38
|
+
sha, parents, author, date, subject, body = rec.lstrip('\n').split('\t', 5)
|
|
39
|
+
commits[sha] = Commit(sha, parents.split(), author, date, subject, body)
|
|
40
|
+
return commits
|
|
41
|
+
|
|
42
|
+
def diff(self, sha: str, parent: Optional[str]) -> str:
|
|
43
|
+
base = parent or EMPTY_TREE
|
|
44
|
+
return self._run('diff', '--no-color', base, sha)
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class Commit:
|
|
48
|
+
sha: str
|
|
49
|
+
parents: list[str]
|
|
50
|
+
author: str
|
|
51
|
+
date: str
|
|
52
|
+
subject: str
|
|
53
|
+
body: str
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def short(self) -> str:
|
|
57
|
+
return self.sha[:7]
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class Branch:
|
|
61
|
+
name: str
|
|
62
|
+
tip: str
|
|
63
|
+
parent: Optional[str] = None
|
|
64
|
+
fork_point: Optional[str] = None
|
|
65
|
+
merged: bool = False
|
|
66
|
+
own_commits: list[str] = field(default_factory=list)
|
|
67
|
+
grafted: list[str] = field(default_factory=list)
|
|
68
|
+
children: list[str] = field(default_factory=list)
|
|
69
|
+
|
|
70
|
+
def first_parent_chain(commits: dict[str, Commit], tip: str) -> list[str]:
|
|
71
|
+
chain, cur, seen = ([], tip, set())
|
|
72
|
+
while cur and cur not in seen and (cur in commits):
|
|
73
|
+
seen.add(cur)
|
|
74
|
+
chain.append(cur)
|
|
75
|
+
parents = commits[cur].parents
|
|
76
|
+
cur = parents[0] if parents else None
|
|
77
|
+
return chain
|
|
78
|
+
|
|
79
|
+
def pick_trunk(git: Git, tips: dict[str, str]) -> str:
|
|
80
|
+
current = git.current_branch()
|
|
81
|
+
if current and current in tips:
|
|
82
|
+
return current
|
|
83
|
+
for candidate in ('main', 'master'):
|
|
84
|
+
if candidate in tips:
|
|
85
|
+
return candidate
|
|
86
|
+
return sorted(tips)[0]
|
|
87
|
+
|
|
88
|
+
def infer_hierarchy(commits: dict[str, Commit], tips: dict[str, str], trunk: str) -> dict[str, Branch]:
|
|
89
|
+
branches = {name: Branch(name=name, tip=tip) for name, tip in tips.items()}
|
|
90
|
+
chains = {name: first_parent_chain(commits, b.tip) for name, b in branches.items()}
|
|
91
|
+
index_in = {name: {sha: i for i, sha in enumerate(chain)} for name, chain in chains.items()}
|
|
92
|
+
trunk_owned = set(chains[trunk])
|
|
93
|
+
branches[trunk].own_commits = chains[trunk]
|
|
94
|
+
absorbs: dict[str, set[str]] = {name: set() for name in branches}
|
|
95
|
+
for name, chain in chains.items():
|
|
96
|
+
for sha in chain:
|
|
97
|
+
for p in commits[sha].parents[1:]:
|
|
98
|
+
for other, other_tip in tips.items():
|
|
99
|
+
if other != name and p == other_tip:
|
|
100
|
+
absorbs[name].add(other)
|
|
101
|
+
changed = True
|
|
102
|
+
while changed:
|
|
103
|
+
changed = False
|
|
104
|
+
for name, others in absorbs.items():
|
|
105
|
+
grown = others | {a for o in others for a in absorbs[o]}
|
|
106
|
+
if grown != others:
|
|
107
|
+
absorbs[name] = grown
|
|
108
|
+
changed = True
|
|
109
|
+
non_trunk_contenders: dict[str, list[str]] = {}
|
|
110
|
+
for name, chain in chains.items():
|
|
111
|
+
if name == trunk:
|
|
112
|
+
continue
|
|
113
|
+
for sha in chain:
|
|
114
|
+
non_trunk_contenders.setdefault(sha, []).append(name)
|
|
115
|
+
|
|
116
|
+
def owner_ignoring_trunk(sha: str) -> Optional[str]:
|
|
117
|
+
names = non_trunk_contenders[sha]
|
|
118
|
+
if len(names) == 1:
|
|
119
|
+
return names[0]
|
|
120
|
+
at_zero = [n for n in names if index_in[n][sha] == 0]
|
|
121
|
+
if len(at_zero) == 1:
|
|
122
|
+
return at_zero[0]
|
|
123
|
+
absorbing = [n for n in names if all((o == n or o in absorbs[n] for o in names))]
|
|
124
|
+
if absorbing:
|
|
125
|
+
return sorted(absorbing)[0]
|
|
126
|
+
min_index = min((index_in[n][sha] for n in names))
|
|
127
|
+
tied = [n for n in names if index_in[n][sha] == min_index]
|
|
128
|
+
return tied[0] if len(tied) == 1 else None
|
|
129
|
+
|
|
130
|
+
def owner(sha: str) -> Optional[str]:
|
|
131
|
+
return trunk if sha in trunk_owned else owner_ignoring_trunk(sha)
|
|
132
|
+
for name, binfo in branches.items():
|
|
133
|
+
if name == trunk:
|
|
134
|
+
continue
|
|
135
|
+
chain = chains[name]
|
|
136
|
+
boundary = next((i for i, sha in enumerate(chain) if owner(sha) not in (name, None)), len(chain))
|
|
137
|
+
boundary_ignoring_trunk = next((i for i, sha in enumerate(chain) if owner_ignoring_trunk(sha) != name), len(chain))
|
|
138
|
+
binfo.own_commits = chain[:boundary]
|
|
139
|
+
if boundary < len(chain):
|
|
140
|
+
binfo.fork_point = chain[boundary]
|
|
141
|
+
binfo.parent = owner(binfo.fork_point)
|
|
142
|
+
if boundary == 0 and boundary_ignoring_trunk > 0:
|
|
143
|
+
binfo.grafted = chain[:boundary_ignoring_trunk]
|
|
144
|
+
for name, binfo in branches.items():
|
|
145
|
+
if binfo.parent:
|
|
146
|
+
branches[binfo.parent].children.append(name)
|
|
147
|
+
owned_by: dict[str, str] = {sha: name for name, b in branches.items() for sha in b.own_commits}
|
|
148
|
+
for c in commits.values():
|
|
149
|
+
for p in c.parents[1:]:
|
|
150
|
+
owner_name = owned_by.get(p)
|
|
151
|
+
if owner_name:
|
|
152
|
+
branches[owner_name].merged = True
|
|
153
|
+
return branches
|
|
154
|
+
|
|
155
|
+
def root_branches(branches: dict[str, Branch]) -> list[str]:
|
|
156
|
+
return sorted((name for name, b in branches.items() if b.parent is None))
|
|
157
|
+
|
|
158
|
+
def merge_source_branch(c: Commit, branches: dict[str, Branch]) -> Optional[str]:
|
|
159
|
+
for p in c.parents[1:]:
|
|
160
|
+
for name, b in branches.items():
|
|
161
|
+
if p == b.tip or p in b.own_commits:
|
|
162
|
+
return name
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
def branch_detail(b: Branch, commits: dict[str, Commit]) -> Text:
|
|
166
|
+
t = Text()
|
|
167
|
+
t.append('Branch: ', style='bold cyan')
|
|
168
|
+
t.append(b.name + '\n\n', style='bold')
|
|
169
|
+
t.append('Tip: ', style='dim')
|
|
170
|
+
t.append(f'{b.tip[:7]} {commits[b.tip].subject}\n')
|
|
171
|
+
t.append('Parent: ', style='dim')
|
|
172
|
+
t.append(f'{b.parent}\n' if b.parent else '(root -- no parent found)\n')
|
|
173
|
+
if b.fork_point:
|
|
174
|
+
t.append('Forked: ', style='dim')
|
|
175
|
+
t.append(f'{b.fork_point[:7]} {commits[b.fork_point].subject}\n')
|
|
176
|
+
t.append('Merged: ', style='dim')
|
|
177
|
+
t.append('yes (real merge commit)\n' if b.merged else 'no\n')
|
|
178
|
+
if b.grafted:
|
|
179
|
+
t.append('Note: ', style='dim')
|
|
180
|
+
t.append(f'{len(b.grafted)} commit(s) fast-forwarded into its parent, no merge commit\n')
|
|
181
|
+
t.append('Commits: ', style='dim')
|
|
182
|
+
t.append(f'{len(b.own_commits)} of its own\n')
|
|
183
|
+
return t
|
|
184
|
+
|
|
185
|
+
def commit_detail(c: Commit, git: Git) -> Text:
|
|
186
|
+
t = Text()
|
|
187
|
+
t.append('Commit: ', style='bold yellow')
|
|
188
|
+
t.append(c.sha + '\n\n', style='bold')
|
|
189
|
+
t.append('Author: ', style='dim')
|
|
190
|
+
t.append(f'{c.author} ({c.date})\n')
|
|
191
|
+
t.append('Parents: ', style='dim')
|
|
192
|
+
t.append(', '.join((p[:7] for p in c.parents)) or '(none -- root commit)')
|
|
193
|
+
t.append('\n\n')
|
|
194
|
+
t.append(c.subject + '\n', style='bold')
|
|
195
|
+
if c.body.strip():
|
|
196
|
+
t.append(c.body.strip() + '\n')
|
|
197
|
+
t.append('\n')
|
|
198
|
+
diff = git.diff(c.sha, c.parents[0] if c.parents else None)
|
|
199
|
+
for line in diff.splitlines():
|
|
200
|
+
style = 'green' if line.startswith('+') and (not line.startswith('+++')) else 'red' if line.startswith('-') and (not line.startswith('---')) else 'cyan' if line.startswith('@@') else 'white'
|
|
201
|
+
t.append(line + '\n', style=style)
|
|
202
|
+
return t
|
|
203
|
+
|
|
204
|
+
def build_nodes(branches: dict[str, Branch], commits: dict[str, Commit], roots: list[str], current_branch: Optional[str], git: Git) -> list[Node]:
|
|
205
|
+
|
|
206
|
+
def commit_node(sha: str, children: list[Node]) -> Node:
|
|
207
|
+
c = commits[sha]
|
|
208
|
+
merge_from = merge_source_branch(c, branches) if len(c.parents) > 1 else None
|
|
209
|
+
label = Text()
|
|
210
|
+
label.append('● ', style='magenta' if merge_from else 'yellow')
|
|
211
|
+
label.append(c.short + ' ')
|
|
212
|
+
label.append(c.subject)
|
|
213
|
+
if merge_from:
|
|
214
|
+
label.append(f' (⑂ {merge_from})', style='dim')
|
|
215
|
+
return Node(id=f'commit:{sha}', label=label, children=children, detail=lambda: commit_detail(c, git))
|
|
216
|
+
|
|
217
|
+
def graft_node(name: str) -> Node:
|
|
218
|
+
b = branches[name]
|
|
219
|
+
label = Text()
|
|
220
|
+
label.append('〇 ', style='dim cyan')
|
|
221
|
+
label.append(name, style='dim')
|
|
222
|
+
label.append(' (fast-forwarded, no merge commit)', style='dim italic')
|
|
223
|
+
children = [commit_node(sha, []) for sha in reversed(b.grafted)]
|
|
224
|
+
return Node(id=f'graft:{name}', label=label, children=children, detail=lambda: branch_detail(b, commits))
|
|
225
|
+
|
|
226
|
+
def branch_node(name: str) -> Node:
|
|
227
|
+
b = branches[name]
|
|
228
|
+
label = Text()
|
|
229
|
+
label.append('⑂ ', style='cyan')
|
|
230
|
+
label.append(name, style='bold' if name == current_branch else '')
|
|
231
|
+
if b.merged:
|
|
232
|
+
label.append(' *', style='yellow')
|
|
233
|
+
if name == current_branch:
|
|
234
|
+
label.append(' [HEAD]', style='bold green')
|
|
235
|
+
by_fork: dict[str, list[str]] = {}
|
|
236
|
+
grafts_by_tip: dict[str, str] = {}
|
|
237
|
+
for child in b.children:
|
|
238
|
+
cb = branches[child]
|
|
239
|
+
if cb.grafted:
|
|
240
|
+
grafts_by_tip[cb.grafted[0]] = child
|
|
241
|
+
else:
|
|
242
|
+
by_fork.setdefault(cb.fork_point, []).append(child)
|
|
243
|
+
children: list[Node] = []
|
|
244
|
+
commit_list = list(reversed(b.own_commits))
|
|
245
|
+
i, n = (0, len(commit_list))
|
|
246
|
+
while i < n:
|
|
247
|
+
sha = commit_list[i]
|
|
248
|
+
if sha in grafts_by_tip:
|
|
249
|
+
gname = grafts_by_tip[sha]
|
|
250
|
+
children.append(graft_node(gname))
|
|
251
|
+
i += len(branches[gname].grafted)
|
|
252
|
+
continue
|
|
253
|
+
kids = [branch_node(c) for c in by_fork.get(sha, [])]
|
|
254
|
+
children.append(commit_node(sha, kids))
|
|
255
|
+
i += 1
|
|
256
|
+
return Node(id=f'branch:{name}', label=label, children=children, detail=lambda: branch_detail(b, commits))
|
|
257
|
+
return [branch_node(name) for name in roots]
|
|
258
|
+
|
|
259
|
+
def load_repo(path: str) -> tuple[Git, dict[str, Branch], dict[str, Commit], list[str], Optional[str]]:
|
|
260
|
+
git = Git(path)
|
|
261
|
+
tips = git.branch_tips()
|
|
262
|
+
commits = git.load_commits()
|
|
263
|
+
trunk = pick_trunk(git, tips)
|
|
264
|
+
branches = infer_hierarchy(commits, tips, trunk)
|
|
265
|
+
roots = root_branches(branches)
|
|
266
|
+
current = git.current_branch()
|
|
267
|
+
return (git, branches, commits, roots, current)
|
|
268
|
+
|
|
269
|
+
def main() -> int:
|
|
270
|
+
args = sys.argv[1:]
|
|
271
|
+
dump = '--dump' in args
|
|
272
|
+
args = [a for a in args if a != '--dump']
|
|
273
|
+
path = args[0] if args else '.'
|
|
274
|
+
try:
|
|
275
|
+
git, branches, commits, roots, current = load_repo(path)
|
|
276
|
+
except RuntimeError as e:
|
|
277
|
+
print(f'Error: {e}', file=sys.stderr)
|
|
278
|
+
return 1
|
|
279
|
+
nodes = build_nodes(branches, commits, roots, current, git)
|
|
280
|
+
if dump:
|
|
281
|
+
Console().print(dump_text(nodes))
|
|
282
|
+
return 0
|
|
283
|
+
TreeApp(nodes, expanded={n.id for n in nodes}).run()
|
|
284
|
+
return 0
|
|
285
|
+
if __name__ == '__main__':
|
|
286
|
+
sys.exit(main())
|
vgt-0.0.1a0/render.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import Callable, Optional, Union
|
|
4
|
+
from rich.text import Text
|
|
5
|
+
from textual.app import App, ComposeResult
|
|
6
|
+
from textual.containers import Horizontal, VerticalScroll
|
|
7
|
+
from textual.widgets import Static
|
|
8
|
+
from textual.reactive import reactive
|
|
9
|
+
from textual.message import Message
|
|
10
|
+
Detail = Union[str, Text, Callable[[], Union[str, Text]], None]
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Node:
|
|
14
|
+
id: str
|
|
15
|
+
label: Union[str, Text]
|
|
16
|
+
children: list['Node'] = field(default_factory=list)
|
|
17
|
+
detail: Detail = None
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class FlatRow:
|
|
21
|
+
node: Node
|
|
22
|
+
depth: int
|
|
23
|
+
ancestor_last: list[bool]
|
|
24
|
+
is_last: bool
|
|
25
|
+
|
|
26
|
+
def flatten(roots: list[Node], expanded: set[str]) -> list[FlatRow]:
|
|
27
|
+
rows: list[FlatRow] = []
|
|
28
|
+
|
|
29
|
+
def emit(node: Node, depth: int, ancestor_last: list[bool], is_last: bool) -> None:
|
|
30
|
+
rows.append(FlatRow(node, depth, list(ancestor_last), is_last))
|
|
31
|
+
if node.id not in expanded or not node.children:
|
|
32
|
+
return
|
|
33
|
+
child_ancestor = ancestor_last + [is_last]
|
|
34
|
+
for i, child in enumerate(node.children):
|
|
35
|
+
emit(child, depth + 1, child_ancestor, i == len(node.children) - 1)
|
|
36
|
+
for i, r in enumerate(roots):
|
|
37
|
+
emit(r, 0, [], i == len(roots) - 1)
|
|
38
|
+
return rows
|
|
39
|
+
|
|
40
|
+
def render_row_line(row: FlatRow, is_cursor: bool=False, max_width: Optional[int]=None) -> Text:
|
|
41
|
+
text = Text()
|
|
42
|
+
for last in row.ancestor_last:
|
|
43
|
+
text.append(' ' if last else '│ ')
|
|
44
|
+
if row.depth > 0:
|
|
45
|
+
text.append('└─ ' if row.is_last else '├─ ')
|
|
46
|
+
text.append(row.node.label if isinstance(row.node.label, Text) else Text(row.node.label))
|
|
47
|
+
if max_width is not None and text.cell_len > max_width:
|
|
48
|
+
text.truncate(max_width, overflow='ellipsis')
|
|
49
|
+
if is_cursor:
|
|
50
|
+
text.stylize('reverse')
|
|
51
|
+
return text
|
|
52
|
+
|
|
53
|
+
def all_ids(roots: list[Node]) -> set[str]:
|
|
54
|
+
ids: set[str] = set()
|
|
55
|
+
|
|
56
|
+
def walk(n: Node) -> None:
|
|
57
|
+
ids.add(n.id)
|
|
58
|
+
for c in n.children:
|
|
59
|
+
walk(c)
|
|
60
|
+
for r in roots:
|
|
61
|
+
walk(r)
|
|
62
|
+
return ids
|
|
63
|
+
|
|
64
|
+
def dump_text(roots: list[Node], expanded: Optional[set[str]]=None) -> Text:
|
|
65
|
+
rows = flatten(roots, expanded if expanded is not None else all_ids(roots))
|
|
66
|
+
out = Text()
|
|
67
|
+
for row in rows:
|
|
68
|
+
out.append(render_row_line(row))
|
|
69
|
+
out.append('\n')
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
def _resolve(detail: Detail) -> Union[str, Text]:
|
|
73
|
+
value = detail() if callable(detail) else detail
|
|
74
|
+
return value if value is not None else ''
|
|
75
|
+
|
|
76
|
+
class TreeWidget(Static):
|
|
77
|
+
can_focus = True
|
|
78
|
+
cursor_index: reactive[int] = reactive(0)
|
|
79
|
+
viewport_y: reactive[int] = reactive(0)
|
|
80
|
+
|
|
81
|
+
class CursorMoved(Message):
|
|
82
|
+
|
|
83
|
+
def __init__(self, node: Optional[Node]) -> None:
|
|
84
|
+
self.node = node
|
|
85
|
+
super().__init__()
|
|
86
|
+
|
|
87
|
+
def __init__(self, roots: list[Node], expanded: Optional[set[str]]=None, **kwargs):
|
|
88
|
+
super().__init__(**kwargs)
|
|
89
|
+
self.roots = roots
|
|
90
|
+
self.expanded: set[str] = expanded if expanded is not None else set()
|
|
91
|
+
self.rows: list[FlatRow] = []
|
|
92
|
+
|
|
93
|
+
def on_mount(self) -> None:
|
|
94
|
+
self.recompute()
|
|
95
|
+
self.focus()
|
|
96
|
+
|
|
97
|
+
def recompute(self) -> None:
|
|
98
|
+
prev_id = self.rows[self.cursor_index].node.id if self.rows and 0 <= self.cursor_index < len(self.rows) else None
|
|
99
|
+
self.rows = flatten(self.roots, self.expanded)
|
|
100
|
+
if prev_id is not None:
|
|
101
|
+
for i, r in enumerate(self.rows):
|
|
102
|
+
if r.node.id == prev_id:
|
|
103
|
+
self.cursor_index = i
|
|
104
|
+
break
|
|
105
|
+
else:
|
|
106
|
+
self.cursor_index = min(self.cursor_index, len(self.rows) - 1)
|
|
107
|
+
self._ensure_visible()
|
|
108
|
+
self.refresh()
|
|
109
|
+
self._emit_cursor()
|
|
110
|
+
|
|
111
|
+
def _emit_cursor(self) -> None:
|
|
112
|
+
self.post_message(self.CursorMoved(self.rows[self.cursor_index].node if self.rows else None))
|
|
113
|
+
|
|
114
|
+
def _ensure_visible(self) -> None:
|
|
115
|
+
height = max(self.size.height, 1)
|
|
116
|
+
if self.cursor_index < self.viewport_y:
|
|
117
|
+
self.viewport_y = self.cursor_index
|
|
118
|
+
elif self.cursor_index >= self.viewport_y + height:
|
|
119
|
+
self.viewport_y = self.cursor_index - height + 1
|
|
120
|
+
|
|
121
|
+
def render(self) -> Text:
|
|
122
|
+
if not self.rows:
|
|
123
|
+
return Text('(empty)', style='dim')
|
|
124
|
+
height = max(self.size.height, 1)
|
|
125
|
+
width = max(self.size.width, 1)
|
|
126
|
+
visible = self.rows[self.viewport_y:self.viewport_y + height]
|
|
127
|
+
lines = [render_row_line(r, is_cursor=self.viewport_y + i == self.cursor_index, max_width=width) for i, r in enumerate(visible)]
|
|
128
|
+
return Text('\n').join(lines)
|
|
129
|
+
|
|
130
|
+
def move(self, delta: int) -> None:
|
|
131
|
+
if not self.rows:
|
|
132
|
+
return
|
|
133
|
+
self.cursor_index = max(0, min(len(self.rows) - 1, self.cursor_index + delta))
|
|
134
|
+
self._ensure_visible()
|
|
135
|
+
self.refresh()
|
|
136
|
+
self._emit_cursor()
|
|
137
|
+
|
|
138
|
+
def collapse_or_up(self) -> None:
|
|
139
|
+
if not self.rows:
|
|
140
|
+
return
|
|
141
|
+
row = self.rows[self.cursor_index]
|
|
142
|
+
if row.node.children and row.node.id in self.expanded:
|
|
143
|
+
self.expanded.discard(row.node.id)
|
|
144
|
+
self.recompute()
|
|
145
|
+
return
|
|
146
|
+
target_depth = row.depth - 1
|
|
147
|
+
for i in range(self.cursor_index - 1, -1, -1):
|
|
148
|
+
if self.rows[i].depth == target_depth:
|
|
149
|
+
self.cursor_index = i
|
|
150
|
+
self._ensure_visible()
|
|
151
|
+
self.refresh()
|
|
152
|
+
self._emit_cursor()
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
def expand_row(self) -> None:
|
|
156
|
+
if not self.rows:
|
|
157
|
+
return
|
|
158
|
+
row = self.rows[self.cursor_index]
|
|
159
|
+
if row.node.children:
|
|
160
|
+
self.expanded.add(row.node.id)
|
|
161
|
+
self.recompute()
|
|
162
|
+
|
|
163
|
+
class ContentWidget(VerticalScroll):
|
|
164
|
+
|
|
165
|
+
def compose(self) -> ComposeResult:
|
|
166
|
+
yield Static(id='content-text')
|
|
167
|
+
|
|
168
|
+
def show(self, node: Optional[Node]) -> None:
|
|
169
|
+
text = _resolve(node.detail) if node else '(nothing selected)'
|
|
170
|
+
if isinstance(text, str):
|
|
171
|
+
text = Text(text)
|
|
172
|
+
self.query_one('#content-text', Static).update(text)
|
|
173
|
+
self.scroll_home(animate=False)
|
|
174
|
+
CSS = '\nScreen { layout: horizontal; }\n#tree { width: 2fr; height: 100%; border: solid $primary; padding: 0 1; }\n#content { width: 3fr; height: 100%; border: solid $accent; padding: 0 1; overflow-y: auto; }\n#content-text { width: 100%; height: auto; }\n'
|
|
175
|
+
|
|
176
|
+
class TreeApp(App):
|
|
177
|
+
CSS = CSS
|
|
178
|
+
BINDINGS = [('j', 'move_down', 'Down'), ('k', 'move_up', 'Up'), ('h', 'collapse', 'Collapse/parent'), ('l', 'expand', 'Expand'), ('q', 'quit', 'Quit')]
|
|
179
|
+
|
|
180
|
+
def __init__(self, roots: list[Node], expanded: Optional[set[str]]=None):
|
|
181
|
+
super().__init__()
|
|
182
|
+
self.roots = roots
|
|
183
|
+
self.initial_expanded = expanded
|
|
184
|
+
|
|
185
|
+
def compose(self) -> ComposeResult:
|
|
186
|
+
with Horizontal():
|
|
187
|
+
yield TreeWidget(self.roots, self.initial_expanded, id='tree')
|
|
188
|
+
yield ContentWidget(id='content')
|
|
189
|
+
|
|
190
|
+
def on_tree_widget_cursor_moved(self, event: TreeWidget.CursorMoved) -> None:
|
|
191
|
+
self.query_one('#content', ContentWidget).show(event.node)
|
|
192
|
+
|
|
193
|
+
def action_move_down(self) -> None:
|
|
194
|
+
self.query_one('#tree', TreeWidget).move(1)
|
|
195
|
+
|
|
196
|
+
def action_move_up(self) -> None:
|
|
197
|
+
self.query_one('#tree', TreeWidget).move(-1)
|
|
198
|
+
|
|
199
|
+
def action_collapse(self) -> None:
|
|
200
|
+
self.query_one('#tree', TreeWidget).collapse_or_up()
|
|
201
|
+
|
|
202
|
+
def action_expand(self) -> None:
|
|
203
|
+
self.query_one('#tree', TreeWidget).expand_row()
|
vgt-0.0.1a0/setup.cfg
ADDED
vgt-0.0.1a0/setup.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from setuptools import setup, find_packages
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
long_description = (Path(__file__).parent / "README.md").read_text(encoding="utf-8")
|
|
5
|
+
|
|
6
|
+
setup(
|
|
7
|
+
name="vgt",
|
|
8
|
+
url="https://www.mlx-code.com",
|
|
9
|
+
project_urls={
|
|
10
|
+
"Source": "https://github.com/JosefAlbers/mlx-code",
|
|
11
|
+
"Issues": "https://github.com/JosefAlbers/mlx-code/issues",
|
|
12
|
+
"Documentation": "https://www.mlx-code.com/",
|
|
13
|
+
},
|
|
14
|
+
author_email="albersj66@gmail.com",
|
|
15
|
+
author="J Joe",
|
|
16
|
+
license="Apache-2.0",
|
|
17
|
+
version="0.0.1a0",
|
|
18
|
+
readme="README.md",
|
|
19
|
+
description="TUI git viewer",
|
|
20
|
+
long_description=long_description,
|
|
21
|
+
long_description_content_type="text/markdown",
|
|
22
|
+
python_requires=">=3.12.8",
|
|
23
|
+
install_requires=[
|
|
24
|
+
"textual>=8.2.7",
|
|
25
|
+
"rich>=15.0.0",
|
|
26
|
+
],
|
|
27
|
+
extras_require={
|
|
28
|
+
"all": [
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
# packages=find_packages(),
|
|
32
|
+
py_modules=["main", "render"],
|
|
33
|
+
entry_points={
|
|
34
|
+
"console_scripts": [
|
|
35
|
+
"vgt=main:main",
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vgt
|
|
3
|
+
Version: 0.0.1a0
|
|
4
|
+
Summary: TUI git viewer
|
|
5
|
+
Home-page: https://www.mlx-code.com
|
|
6
|
+
Author: J Joe
|
|
7
|
+
Author-email: albersj66@gmail.com
|
|
8
|
+
License: Apache-2.0
|
|
9
|
+
Project-URL: Source, https://github.com/JosefAlbers/mlx-code
|
|
10
|
+
Project-URL: Issues, https://github.com/JosefAlbers/mlx-code/issues
|
|
11
|
+
Project-URL: Documentation, https://www.mlx-code.com/
|
|
12
|
+
Requires-Python: >=3.12.8
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: textual>=8.2.7
|
|
15
|
+
Requires-Dist: rich>=15.0.0
|
|
16
|
+
Provides-Extra: all
|
|
17
|
+
Dynamic: author
|
|
18
|
+
Dynamic: author-email
|
|
19
|
+
Dynamic: description
|
|
20
|
+
Dynamic: description-content-type
|
|
21
|
+
Dynamic: home-page
|
|
22
|
+
Dynamic: license
|
|
23
|
+
Dynamic: project-url
|
|
24
|
+
Dynamic: provides-extra
|
|
25
|
+
Dynamic: requires-dist
|
|
26
|
+
Dynamic: requires-python
|
|
27
|
+
Dynamic: summary
|
|
28
|
+
|
|
29
|
+
view git
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|