nit-cli 0.2.0__py3-none-any.whl
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.
- nit/__init__.py +6 -0
- nit/app.py +656 -0
- nit/cli.py +66 -0
- nit/comments.py +102 -0
- nit/diff_parser.py +126 -0
- nit/git.py +88 -0
- nit/models.py +41 -0
- nit_cli-0.2.0.dist-info/METADATA +121 -0
- nit_cli-0.2.0.dist-info/RECORD +13 -0
- nit_cli-0.2.0.dist-info/WHEEL +5 -0
- nit_cli-0.2.0.dist-info/entry_points.txt +2 -0
- nit_cli-0.2.0.dist-info/licenses/LICENSE +674 -0
- nit_cli-0.2.0.dist-info/top_level.txt +1 -0
nit/__init__.py
ADDED
nit/app.py
ADDED
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
from collections import Counter
|
|
7
|
+
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
from textual.app import App, ComposeResult
|
|
10
|
+
from textual.binding import Binding
|
|
11
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
12
|
+
from textual.reactive import reactive
|
|
13
|
+
from textual.widgets import Footer, Input, Label, Static, Tree
|
|
14
|
+
|
|
15
|
+
from . import comments as comments_mod
|
|
16
|
+
from . import git
|
|
17
|
+
from .cli import CLIArgs
|
|
18
|
+
from .diff_parser import parse_diff
|
|
19
|
+
from .models import DiffLine as DiffLineModel
|
|
20
|
+
from .models import FileDiff, ReviewComment
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
STATUS_COLORS = {
|
|
25
|
+
"added": ("A", "green"),
|
|
26
|
+
"deleted": ("D", "red"),
|
|
27
|
+
"renamed": ("R", "cyan"),
|
|
28
|
+
"modified": ("M", "yellow"),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _file_label(fd: FileDiff, comment_count: int) -> Text:
|
|
33
|
+
letter, color = STATUS_COLORS.get(fd.status, ("M", "yellow"))
|
|
34
|
+
label = Text()
|
|
35
|
+
label.append(letter, style=f"bold {color}")
|
|
36
|
+
label.append(f" {os.path.basename(fd.path)}")
|
|
37
|
+
if comment_count:
|
|
38
|
+
label.append(f" ({comment_count})", style="dim")
|
|
39
|
+
return label
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# --- Diff mode enum ---
|
|
43
|
+
|
|
44
|
+
DIFF_MODES = ["branch", "unstaged", "all"]
|
|
45
|
+
DIFF_MODE_LABELS = {
|
|
46
|
+
"branch": "branch diff",
|
|
47
|
+
"unstaged": "unstaged",
|
|
48
|
+
"all": "all uncommitted",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# --- Widgets ---
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class FileTree(Tree):
|
|
56
|
+
"""Sidebar file tree with visible cursor."""
|
|
57
|
+
|
|
58
|
+
SCOPED_CSS = False
|
|
59
|
+
DEFAULT_CSS = """
|
|
60
|
+
FileTree {
|
|
61
|
+
background: $background;
|
|
62
|
+
}
|
|
63
|
+
FileTree > .tree--cursor {
|
|
64
|
+
background: $panel;
|
|
65
|
+
text-style: bold;
|
|
66
|
+
}
|
|
67
|
+
FileTree > .tree--highlight {
|
|
68
|
+
background: $accent 50%;
|
|
69
|
+
}
|
|
70
|
+
FileTree > .tree--highlight-line {
|
|
71
|
+
background: $accent 20%;
|
|
72
|
+
}
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
ICON_NODE = ""
|
|
76
|
+
ICON_NODE_EXPANDED = ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class DiffLineWidget(Static):
|
|
80
|
+
"""A single line in the diff view."""
|
|
81
|
+
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class CommentBlock(Static):
|
|
86
|
+
"""An inline comment block rendered between diff lines."""
|
|
87
|
+
|
|
88
|
+
DEFAULT_CSS = """
|
|
89
|
+
CommentBlock {
|
|
90
|
+
background: $surface;
|
|
91
|
+
color: $text;
|
|
92
|
+
margin: 0 2 0 6;
|
|
93
|
+
padding: 0 1;
|
|
94
|
+
border: round $warning;
|
|
95
|
+
width: 1fr;
|
|
96
|
+
}
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class InlineCommentInput(Input):
|
|
101
|
+
"""Inline input that appears below a diff line for entering comments."""
|
|
102
|
+
|
|
103
|
+
BINDINGS = [
|
|
104
|
+
Binding("escape", "cancel", "Cancel", priority=True),
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
DEFAULT_CSS = """
|
|
108
|
+
InlineCommentInput {
|
|
109
|
+
margin: 0 2 0 6;
|
|
110
|
+
border: round $accent;
|
|
111
|
+
width: 1fr;
|
|
112
|
+
}
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def action_cancel(self) -> None:
|
|
116
|
+
diff_view = next(a for a in self.ancestors if isinstance(a, DiffView))
|
|
117
|
+
diff_view.hide_comment_input()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class DiffView(VerticalScroll):
|
|
121
|
+
"""Scrollable diff view with cursor tracking."""
|
|
122
|
+
|
|
123
|
+
DEFAULT_CSS = """
|
|
124
|
+
DiffView {
|
|
125
|
+
width: 1fr;
|
|
126
|
+
}
|
|
127
|
+
DiffLineWidget {
|
|
128
|
+
width: 1fr;
|
|
129
|
+
padding: 0 1;
|
|
130
|
+
}
|
|
131
|
+
DiffLineWidget.add {
|
|
132
|
+
background-tint: $success 25%;
|
|
133
|
+
color: $success;
|
|
134
|
+
}
|
|
135
|
+
DiffLineWidget.remove {
|
|
136
|
+
background-tint: $error 25%;
|
|
137
|
+
color: $error;
|
|
138
|
+
}
|
|
139
|
+
DiffLineWidget.context {
|
|
140
|
+
color: $text-muted;
|
|
141
|
+
}
|
|
142
|
+
DiffLineWidget.hunk-header {
|
|
143
|
+
color: $accent;
|
|
144
|
+
text-style: bold;
|
|
145
|
+
}
|
|
146
|
+
DiffLineWidget.cursor {
|
|
147
|
+
background: $panel;
|
|
148
|
+
}
|
|
149
|
+
DiffLineWidget.add.cursor {
|
|
150
|
+
background: $panel;
|
|
151
|
+
color: $success;
|
|
152
|
+
}
|
|
153
|
+
DiffLineWidget.remove.cursor {
|
|
154
|
+
background: $panel;
|
|
155
|
+
color: $error;
|
|
156
|
+
}
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
cursor_index: reactive[int] = reactive(0)
|
|
160
|
+
|
|
161
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
162
|
+
super().__init__(*args, **kwargs)
|
|
163
|
+
self.diff_lines: list[DiffLineModel] = []
|
|
164
|
+
self._line_widgets: list[DiffLineWidget] = []
|
|
165
|
+
self._active_input: InlineCommentInput | None = None
|
|
166
|
+
|
|
167
|
+
def clear_diff(self) -> None:
|
|
168
|
+
self.diff_lines = []
|
|
169
|
+
self._line_widgets = []
|
|
170
|
+
self.cursor_index = 0
|
|
171
|
+
self.remove_children()
|
|
172
|
+
|
|
173
|
+
def load_file_diff(
|
|
174
|
+
self,
|
|
175
|
+
file_diff: FileDiff,
|
|
176
|
+
file_comments: list[ReviewComment],
|
|
177
|
+
restore_cursor: int = 0,
|
|
178
|
+
) -> None:
|
|
179
|
+
self.remove_children()
|
|
180
|
+
self.diff_lines = []
|
|
181
|
+
self._line_widgets = []
|
|
182
|
+
|
|
183
|
+
# Build a lookup of comments by line number
|
|
184
|
+
comment_map: dict[int | None, list[ReviewComment]] = {}
|
|
185
|
+
for c in file_comments:
|
|
186
|
+
key = c.new_line_no if c.new_line_no is not None else c.old_line_no
|
|
187
|
+
comment_map.setdefault(key, []).append(c)
|
|
188
|
+
|
|
189
|
+
if file_diff.is_binary:
|
|
190
|
+
w = DiffLineWidget("Binary file")
|
|
191
|
+
self.mount(w)
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
for hunk in file_diff.hunks:
|
|
195
|
+
for dl in hunk.lines:
|
|
196
|
+
self.diff_lines.append(dl)
|
|
197
|
+
line_no_str = self._format_line_no(dl)
|
|
198
|
+
prefix = self._format_prefix(dl)
|
|
199
|
+
text = f"{line_no_str}{prefix}{dl.content}"
|
|
200
|
+
w = DiffLineWidget(text)
|
|
201
|
+
w.add_class(dl.line_type.replace("_", "-"))
|
|
202
|
+
self._line_widgets.append(w)
|
|
203
|
+
self.mount(w)
|
|
204
|
+
|
|
205
|
+
# Render inline comments for this line
|
|
206
|
+
line_key = dl.new_line_no if dl.new_line_no is not None else dl.old_line_no
|
|
207
|
+
for c in comment_map.get(line_key, []):
|
|
208
|
+
if comments_mod.comment_matches_line(c, dl):
|
|
209
|
+
block = CommentBlock(f"-- {c.comment}")
|
|
210
|
+
self.mount(block)
|
|
211
|
+
|
|
212
|
+
start = min(restore_cursor, len(self._line_widgets) - 1) if self._line_widgets else 0
|
|
213
|
+
self.cursor_index = start
|
|
214
|
+
if self._line_widgets:
|
|
215
|
+
self._line_widgets[start].add_class("cursor")
|
|
216
|
+
|
|
217
|
+
def _format_line_no(self, dl: DiffLineModel) -> str:
|
|
218
|
+
old = str(dl.old_line_no) if dl.old_line_no is not None else ""
|
|
219
|
+
new = str(dl.new_line_no) if dl.new_line_no is not None else ""
|
|
220
|
+
if dl.line_type == "hunk_header":
|
|
221
|
+
return ""
|
|
222
|
+
return f"{old:>4} {new:>4} \u2502 "
|
|
223
|
+
|
|
224
|
+
def _format_prefix(self, dl: DiffLineModel) -> str:
|
|
225
|
+
if dl.line_type == "add":
|
|
226
|
+
return "+"
|
|
227
|
+
elif dl.line_type == "remove":
|
|
228
|
+
return "-"
|
|
229
|
+
elif dl.line_type == "hunk_header":
|
|
230
|
+
return ""
|
|
231
|
+
return " "
|
|
232
|
+
|
|
233
|
+
def watch_cursor_index(self, old: int, new: int) -> None:
|
|
234
|
+
if not self._line_widgets:
|
|
235
|
+
return
|
|
236
|
+
if 0 <= old < len(self._line_widgets):
|
|
237
|
+
self._line_widgets[old].remove_class("cursor")
|
|
238
|
+
if 0 <= new < len(self._line_widgets):
|
|
239
|
+
self._line_widgets[new].add_class("cursor")
|
|
240
|
+
self._line_widgets[new].scroll_visible()
|
|
241
|
+
|
|
242
|
+
def move_cursor(self, delta: int) -> None:
|
|
243
|
+
if not self._line_widgets:
|
|
244
|
+
return
|
|
245
|
+
new_idx = max(0, min(len(self._line_widgets) - 1, self.cursor_index + delta))
|
|
246
|
+
self.cursor_index = new_idx
|
|
247
|
+
|
|
248
|
+
def jump_to_next_hunk(self, forward: bool = True) -> None:
|
|
249
|
+
if not self.diff_lines:
|
|
250
|
+
return
|
|
251
|
+
start = self.cursor_index
|
|
252
|
+
step = 1 if forward else -1
|
|
253
|
+
idx = start + step
|
|
254
|
+
while 0 <= idx < len(self.diff_lines):
|
|
255
|
+
if self.diff_lines[idx].line_type == "hunk_header":
|
|
256
|
+
self.cursor_index = idx
|
|
257
|
+
return
|
|
258
|
+
idx += step
|
|
259
|
+
|
|
260
|
+
def jump_to_next_comment(self, forward: bool = True) -> None:
|
|
261
|
+
"""Jump to the next/previous line that has a comment."""
|
|
262
|
+
app: NitApp = self.app # type: ignore[assignment]
|
|
263
|
+
if not self.diff_lines or not app.current_file:
|
|
264
|
+
return
|
|
265
|
+
file_comments = [c for c in app.comments if c.file_path == app.current_file.path]
|
|
266
|
+
if not file_comments:
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
commented_lines: set[tuple[int | None, int | None]] = set()
|
|
270
|
+
for c in file_comments:
|
|
271
|
+
commented_lines.add((c.new_line_no, c.old_line_no))
|
|
272
|
+
|
|
273
|
+
start = self.cursor_index
|
|
274
|
+
step = 1 if forward else -1
|
|
275
|
+
idx = start + step
|
|
276
|
+
while 0 <= idx < len(self.diff_lines):
|
|
277
|
+
dl = self.diff_lines[idx]
|
|
278
|
+
if (dl.new_line_no, dl.old_line_no) in commented_lines:
|
|
279
|
+
self.cursor_index = idx
|
|
280
|
+
return
|
|
281
|
+
idx += step
|
|
282
|
+
|
|
283
|
+
def show_comment_input(self) -> None:
|
|
284
|
+
if self._active_input is not None:
|
|
285
|
+
return
|
|
286
|
+
if not self._line_widgets or self.cursor_index >= len(self._line_widgets):
|
|
287
|
+
return
|
|
288
|
+
widget = self._line_widgets[self.cursor_index]
|
|
289
|
+
self._active_input = InlineCommentInput(
|
|
290
|
+
placeholder="comment (enter to submit, esc to cancel)"
|
|
291
|
+
)
|
|
292
|
+
self.mount(self._active_input, after=widget)
|
|
293
|
+
self._active_input.focus()
|
|
294
|
+
|
|
295
|
+
def hide_comment_input(self) -> None:
|
|
296
|
+
if self._active_input is not None:
|
|
297
|
+
self._active_input.remove()
|
|
298
|
+
self._active_input = None
|
|
299
|
+
self.focus()
|
|
300
|
+
|
|
301
|
+
def get_current_line(self) -> DiffLineModel | None:
|
|
302
|
+
if 0 <= self.cursor_index < len(self.diff_lines):
|
|
303
|
+
return self.diff_lines[self.cursor_index]
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
def get_hunk_context(self, center_idx: int, radius: int = 2) -> list[str]:
|
|
307
|
+
start = max(0, center_idx - radius)
|
|
308
|
+
end = min(len(self.diff_lines), center_idx + radius + 1)
|
|
309
|
+
return [self.diff_lines[i].content for i in range(start, end)]
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# --- App ---
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class NitApp(App):
|
|
316
|
+
TITLE = "nit"
|
|
317
|
+
COMMANDS = set()
|
|
318
|
+
ENABLE_COMMAND_PALETTE = False
|
|
319
|
+
theme = "nord"
|
|
320
|
+
CSS = """
|
|
321
|
+
#frame {
|
|
322
|
+
border: round $accent;
|
|
323
|
+
height: 1fr;
|
|
324
|
+
width: 1fr;
|
|
325
|
+
}
|
|
326
|
+
#status-bar {
|
|
327
|
+
dock: top;
|
|
328
|
+
height: 1;
|
|
329
|
+
}
|
|
330
|
+
.status-segment {
|
|
331
|
+
padding: 0 1;
|
|
332
|
+
text-style: bold;
|
|
333
|
+
width: 1fr;
|
|
334
|
+
}
|
|
335
|
+
#seg-branch {
|
|
336
|
+
background: $accent;
|
|
337
|
+
color: $text;
|
|
338
|
+
}
|
|
339
|
+
#seg-mode {
|
|
340
|
+
background: $secondary;
|
|
341
|
+
color: $text;
|
|
342
|
+
}
|
|
343
|
+
#seg-files {
|
|
344
|
+
background: $primary;
|
|
345
|
+
color: $text;
|
|
346
|
+
}
|
|
347
|
+
#seg-comments {
|
|
348
|
+
background: $warning;
|
|
349
|
+
color: $text;
|
|
350
|
+
}
|
|
351
|
+
#layout {
|
|
352
|
+
width: 100%;
|
|
353
|
+
height: 1fr;
|
|
354
|
+
}
|
|
355
|
+
#sidebar {
|
|
356
|
+
width: 40;
|
|
357
|
+
dock: left;
|
|
358
|
+
border: round $border;
|
|
359
|
+
overflow-x: hidden;
|
|
360
|
+
}
|
|
361
|
+
DiffView {
|
|
362
|
+
border: round $border;
|
|
363
|
+
}
|
|
364
|
+
"""
|
|
365
|
+
|
|
366
|
+
BINDINGS = [
|
|
367
|
+
Binding("q", "quit", "Quit"),
|
|
368
|
+
Binding("j", "cursor_down", "Down", show=False),
|
|
369
|
+
Binding("k", "cursor_up", "Up", show=False),
|
|
370
|
+
Binding("J", "next_hunk", "Next hunk", show=False),
|
|
371
|
+
Binding("K", "prev_hunk", "Prev hunk", show=False),
|
|
372
|
+
Binding("n", "next_file", "Next file"),
|
|
373
|
+
Binding("p", "prev_file", "Prev file"),
|
|
374
|
+
Binding("c", "comment", "Comment"),
|
|
375
|
+
Binding("d", "delete_comment", "Delete comment"),
|
|
376
|
+
Binding("m", "cycle_mode", "Mode"),
|
|
377
|
+
Binding("r", "refresh", "Refresh"),
|
|
378
|
+
Binding("right_square_bracket", "next_comment", "]", show=False),
|
|
379
|
+
Binding("left_square_bracket", "prev_comment", "[", show=False),
|
|
380
|
+
]
|
|
381
|
+
|
|
382
|
+
diff_mode: reactive[str] = reactive("branch")
|
|
383
|
+
|
|
384
|
+
def __init__(self, cli_args: CLIArgs | None = None, *args, **kwargs) -> None:
|
|
385
|
+
super().__init__(*args, **kwargs)
|
|
386
|
+
self._cli_args = cli_args or CLIArgs()
|
|
387
|
+
self.current_file: FileDiff | None = None
|
|
388
|
+
self.file_diffs: list[FileDiff] = []
|
|
389
|
+
self.comments: list[ReviewComment] = []
|
|
390
|
+
self.repo_root = None
|
|
391
|
+
self.branch = ""
|
|
392
|
+
self.base = ""
|
|
393
|
+
self._file_index: int = 0
|
|
394
|
+
|
|
395
|
+
def compose(self) -> ComposeResult:
|
|
396
|
+
with Vertical(id="frame"):
|
|
397
|
+
with Horizontal(id="status-bar"):
|
|
398
|
+
yield Label("", id="seg-branch", classes="status-segment")
|
|
399
|
+
yield Label("", id="seg-mode", classes="status-segment")
|
|
400
|
+
yield Label("", id="seg-files", classes="status-segment")
|
|
401
|
+
yield Label("", id="seg-comments", classes="status-segment")
|
|
402
|
+
with Horizontal(id="layout"):
|
|
403
|
+
sidebar_tree = FileTree("Files", id="sidebar")
|
|
404
|
+
sidebar_tree.guide_depth = 2
|
|
405
|
+
yield sidebar_tree
|
|
406
|
+
yield DiffView(id="diff-view")
|
|
407
|
+
yield Footer()
|
|
408
|
+
|
|
409
|
+
def on_mount(self) -> None:
|
|
410
|
+
try:
|
|
411
|
+
self.repo_root = git.get_repo_root()
|
|
412
|
+
except Exception:
|
|
413
|
+
self.notify("Not a git repository", severity="error")
|
|
414
|
+
self.exit()
|
|
415
|
+
return
|
|
416
|
+
try:
|
|
417
|
+
self.branch = git.get_current_branch(self.repo_root)
|
|
418
|
+
except Exception:
|
|
419
|
+
self.branch = ""
|
|
420
|
+
self.base = git.get_main_branch(self.repo_root)
|
|
421
|
+
if self._cli_args.mode:
|
|
422
|
+
self.diff_mode = self._cli_args.mode
|
|
423
|
+
try:
|
|
424
|
+
self.comments = comments_mod.load_comments(self.repo_root)
|
|
425
|
+
except Exception:
|
|
426
|
+
logger.warning("Failed to load comments, starting with empty list")
|
|
427
|
+
self.notify("Could not load .nit.json — starting with no comments", severity="warning")
|
|
428
|
+
self.comments = []
|
|
429
|
+
self._load_diff()
|
|
430
|
+
|
|
431
|
+
def _load_diff(self) -> None:
|
|
432
|
+
if self.repo_root is None:
|
|
433
|
+
return
|
|
434
|
+
cwd = self.repo_root
|
|
435
|
+
path_filter = self._cli_args.path_filter
|
|
436
|
+
|
|
437
|
+
try:
|
|
438
|
+
if self._cli_args.commit_range:
|
|
439
|
+
raw = git.get_commit_range_diff(self._cli_args.commit_range, cwd, path_filter)
|
|
440
|
+
elif self.diff_mode == "branch":
|
|
441
|
+
raw = git.get_branch_diff(cwd, path_filter)
|
|
442
|
+
elif self.diff_mode == "unstaged":
|
|
443
|
+
raw = git.get_unstaged_diff(cwd, path_filter)
|
|
444
|
+
else:
|
|
445
|
+
raw = git.get_all_uncommitted_diff(cwd, path_filter)
|
|
446
|
+
except subprocess.CalledProcessError as e:
|
|
447
|
+
msg = (e.stderr or "").strip() or "Failed to load diff"
|
|
448
|
+
logger.warning("Git diff failed: %s", msg)
|
|
449
|
+
self.notify(msg, severity="error")
|
|
450
|
+
raw = ""
|
|
451
|
+
|
|
452
|
+
self.file_diffs = parse_diff(raw)
|
|
453
|
+
self._update_file_list()
|
|
454
|
+
self._update_status()
|
|
455
|
+
|
|
456
|
+
def _build_file_tree(self) -> None:
|
|
457
|
+
"""Populate the sidebar tree with files grouped by directory."""
|
|
458
|
+
tree = self.query_one("#sidebar", FileTree)
|
|
459
|
+
tree.clear()
|
|
460
|
+
tree.show_root = False
|
|
461
|
+
|
|
462
|
+
# Group files by directory
|
|
463
|
+
dirs: dict[str, list[tuple[int, FileDiff]]] = {}
|
|
464
|
+
for i, fd in enumerate(self.file_diffs):
|
|
465
|
+
dirname = os.path.dirname(fd.path) or "."
|
|
466
|
+
dirs.setdefault(dirname, []).append((i, fd))
|
|
467
|
+
|
|
468
|
+
comment_counts = Counter(c.file_path for c in self.comments)
|
|
469
|
+
|
|
470
|
+
dir_nodes: dict[str, object] = {}
|
|
471
|
+
for dirname in sorted(dirs):
|
|
472
|
+
parts = dirname.split("/")
|
|
473
|
+
current = tree.root
|
|
474
|
+
for depth, part in enumerate(parts):
|
|
475
|
+
key = "/".join(parts[: depth + 1])
|
|
476
|
+
if key not in dir_nodes:
|
|
477
|
+
label = Text(f"{part}/", style="dim")
|
|
478
|
+
node = current.add(label, expand=True)
|
|
479
|
+
node.data = None
|
|
480
|
+
dir_nodes[key] = node
|
|
481
|
+
current = dir_nodes[key]
|
|
482
|
+
|
|
483
|
+
for i, fd in dirs[dirname]:
|
|
484
|
+
label = _file_label(fd, comment_counts[fd.path])
|
|
485
|
+
leaf = current.add_leaf(label)
|
|
486
|
+
leaf.data = i
|
|
487
|
+
|
|
488
|
+
def _update_file_list(self) -> None:
|
|
489
|
+
self._build_file_tree()
|
|
490
|
+
if self.file_diffs:
|
|
491
|
+
restore_idx = 0
|
|
492
|
+
if self.current_file:
|
|
493
|
+
for i, fd in enumerate(self.file_diffs):
|
|
494
|
+
if fd.path == self.current_file.path:
|
|
495
|
+
restore_idx = i
|
|
496
|
+
break
|
|
497
|
+
self._file_index = restore_idx
|
|
498
|
+
self._select_file(restore_idx)
|
|
499
|
+
else:
|
|
500
|
+
self.current_file = None
|
|
501
|
+
self.query_one("#diff-view", DiffView).clear_diff()
|
|
502
|
+
|
|
503
|
+
def _refresh_file_labels(self) -> None:
|
|
504
|
+
"""Rebuild tree labels without changing selection."""
|
|
505
|
+
self._build_file_tree()
|
|
506
|
+
|
|
507
|
+
def _update_status(self) -> None:
|
|
508
|
+
if self._cli_args.commit_range:
|
|
509
|
+
mode_label = self._cli_args.commit_range
|
|
510
|
+
else:
|
|
511
|
+
mode_label = DIFF_MODE_LABELS.get(self.diff_mode, self.diff_mode)
|
|
512
|
+
n_comments = len(self.comments)
|
|
513
|
+
n_files = len(self.file_diffs)
|
|
514
|
+
self.query_one("#seg-branch", Label).update(f"⎇ {self.branch}")
|
|
515
|
+
self.query_one("#seg-mode", Label).update(f"⇄ {mode_label}")
|
|
516
|
+
self.query_one("#seg-files", Label).update(f"▤ {n_files} files")
|
|
517
|
+
self.query_one("#seg-comments", Label).update(f"✎ {n_comments} comments")
|
|
518
|
+
|
|
519
|
+
def _highlight_tree_node(self, file_index: int) -> None:
|
|
520
|
+
"""Move tree cursor to the node matching the given file index."""
|
|
521
|
+
tree = self.query_one("#sidebar", FileTree)
|
|
522
|
+
for line in range(tree.last_line + 1):
|
|
523
|
+
node = tree.get_node_at_line(line)
|
|
524
|
+
if node and node.data == file_index:
|
|
525
|
+
tree.cursor_line = line
|
|
526
|
+
tree.scroll_to_line(line)
|
|
527
|
+
break
|
|
528
|
+
|
|
529
|
+
def _select_file(self, index: int) -> None:
|
|
530
|
+
if 0 <= index < len(self.file_diffs):
|
|
531
|
+
self.current_file = self.file_diffs[index]
|
|
532
|
+
file_comments = [c for c in self.comments if c.file_path == self.current_file.path]
|
|
533
|
+
self.query_one("#diff-view", DiffView).load_file_diff(self.current_file, file_comments)
|
|
534
|
+
self._highlight_tree_node(index)
|
|
535
|
+
self.query_one("#diff-view", DiffView).focus()
|
|
536
|
+
|
|
537
|
+
def on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
|
|
538
|
+
if event.node.data is not None:
|
|
539
|
+
self._file_index = event.node.data
|
|
540
|
+
self._select_file(event.node.data)
|
|
541
|
+
|
|
542
|
+
# --- Actions ---
|
|
543
|
+
|
|
544
|
+
def action_cursor_down(self) -> None:
|
|
545
|
+
self.query_one("#diff-view", DiffView).move_cursor(1)
|
|
546
|
+
|
|
547
|
+
def action_cursor_up(self) -> None:
|
|
548
|
+
self.query_one("#diff-view", DiffView).move_cursor(-1)
|
|
549
|
+
|
|
550
|
+
def action_next_hunk(self) -> None:
|
|
551
|
+
self.query_one("#diff-view", DiffView).jump_to_next_hunk(forward=True)
|
|
552
|
+
|
|
553
|
+
def action_prev_hunk(self) -> None:
|
|
554
|
+
self.query_one("#diff-view", DiffView).jump_to_next_hunk(forward=False)
|
|
555
|
+
|
|
556
|
+
def action_next_file(self) -> None:
|
|
557
|
+
if self._file_index < len(self.file_diffs) - 1:
|
|
558
|
+
self._file_index += 1
|
|
559
|
+
self._select_file(self._file_index)
|
|
560
|
+
|
|
561
|
+
def action_prev_file(self) -> None:
|
|
562
|
+
if self._file_index > 0:
|
|
563
|
+
self._file_index -= 1
|
|
564
|
+
self._select_file(self._file_index)
|
|
565
|
+
|
|
566
|
+
def action_next_comment(self) -> None:
|
|
567
|
+
self.query_one("#diff-view", DiffView).jump_to_next_comment(forward=True)
|
|
568
|
+
|
|
569
|
+
def action_prev_comment(self) -> None:
|
|
570
|
+
self.query_one("#diff-view", DiffView).jump_to_next_comment(forward=False)
|
|
571
|
+
|
|
572
|
+
def action_cycle_mode(self) -> None:
|
|
573
|
+
if self._cli_args.commit_range:
|
|
574
|
+
return
|
|
575
|
+
idx = DIFF_MODES.index(self.diff_mode)
|
|
576
|
+
self.diff_mode = DIFF_MODES[(idx + 1) % len(DIFF_MODES)]
|
|
577
|
+
self._load_diff()
|
|
578
|
+
|
|
579
|
+
def action_refresh(self) -> None:
|
|
580
|
+
if self.repo_root:
|
|
581
|
+
self.comments = comments_mod.load_comments(self.repo_root)
|
|
582
|
+
self._load_diff()
|
|
583
|
+
|
|
584
|
+
def action_comment(self) -> None:
|
|
585
|
+
diff_view = self.query_one("#diff-view", DiffView)
|
|
586
|
+
line = diff_view.get_current_line()
|
|
587
|
+
if line is None or line.line_type == "hunk_header":
|
|
588
|
+
return
|
|
589
|
+
if self.current_file is None:
|
|
590
|
+
return
|
|
591
|
+
diff_view.show_comment_input()
|
|
592
|
+
|
|
593
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
594
|
+
if not isinstance(event.input, InlineCommentInput):
|
|
595
|
+
return
|
|
596
|
+
diff_view = self.query_one("#diff-view", DiffView)
|
|
597
|
+
text = event.value.strip()
|
|
598
|
+
if text and self.current_file and self.repo_root:
|
|
599
|
+
line = diff_view.get_current_line()
|
|
600
|
+
if line:
|
|
601
|
+
context = diff_view.get_hunk_context(diff_view.cursor_index)
|
|
602
|
+
comment = comments_mod.make_comment(
|
|
603
|
+
self.current_file.path,
|
|
604
|
+
line,
|
|
605
|
+
text,
|
|
606
|
+
context,
|
|
607
|
+
diff_mode=self.diff_mode,
|
|
608
|
+
)
|
|
609
|
+
self.comments.append(comment)
|
|
610
|
+
comments_mod.save_comments(self.repo_root, self.comments, self.branch, self.base)
|
|
611
|
+
saved_cursor = diff_view.cursor_index
|
|
612
|
+
diff_view.hide_comment_input()
|
|
613
|
+
if self.current_file:
|
|
614
|
+
file_comments = [c for c in self.comments if c.file_path == self.current_file.path]
|
|
615
|
+
diff_view.load_file_diff(self.current_file, file_comments, restore_cursor=saved_cursor)
|
|
616
|
+
self._update_status()
|
|
617
|
+
self._refresh_file_labels()
|
|
618
|
+
|
|
619
|
+
def action_delete_comment(self) -> None:
|
|
620
|
+
diff_view = self.query_one("#diff-view", DiffView)
|
|
621
|
+
line = diff_view.get_current_line()
|
|
622
|
+
if line is None or self.current_file is None or self.repo_root is None:
|
|
623
|
+
return
|
|
624
|
+
# Find and remove comments matching this line
|
|
625
|
+
before = len(self.comments)
|
|
626
|
+
self.comments = [
|
|
627
|
+
c
|
|
628
|
+
for c in self.comments
|
|
629
|
+
if not (
|
|
630
|
+
c.file_path == self.current_file.path and comments_mod.comment_matches_line(c, line)
|
|
631
|
+
)
|
|
632
|
+
]
|
|
633
|
+
if len(self.comments) < before:
|
|
634
|
+
saved_cursor = diff_view.cursor_index
|
|
635
|
+
comments_mod.save_comments(self.repo_root, self.comments, self.branch, self.base)
|
|
636
|
+
file_comments = [c for c in self.comments if c.file_path == self.current_file.path]
|
|
637
|
+
diff_view.load_file_diff(self.current_file, file_comments, restore_cursor=saved_cursor)
|
|
638
|
+
self._update_status()
|
|
639
|
+
self._refresh_file_labels()
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def main() -> None:
|
|
643
|
+
from .cli import parse_args
|
|
644
|
+
|
|
645
|
+
args = parse_args()
|
|
646
|
+
logging.basicConfig(
|
|
647
|
+
level=logging.DEBUG if args.verbose else logging.WARNING,
|
|
648
|
+
format="%(name)s: %(message)s",
|
|
649
|
+
stream=__import__("sys").stderr,
|
|
650
|
+
)
|
|
651
|
+
app = NitApp(cli_args=args)
|
|
652
|
+
app.run()
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
if __name__ == "__main__":
|
|
656
|
+
main()
|