repolens-cli 0.1.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.
- repolens/__init__.py +1 -0
- repolens/ai_client.py +230 -0
- repolens/analyzer.py +242 -0
- repolens/cli.py +117 -0
- repolens/fetcher.py +198 -0
- repolens/graph.py +126 -0
- repolens/models.py +52 -0
- repolens/scanner.py +69 -0
- repolens/tui/__init__.py +0 -0
- repolens/tui/app.py +951 -0
- repolens_cli-0.1.0.dist-info/METADATA +88 -0
- repolens_cli-0.1.0.dist-info/RECORD +15 -0
- repolens_cli-0.1.0.dist-info/WHEEL +4 -0
- repolens_cli-0.1.0.dist-info/entry_points.txt +2 -0
- repolens_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
repolens/tui/app.py
ADDED
|
@@ -0,0 +1,951 @@
|
|
|
1
|
+
"""RepoLens TUI — Textual-based terminal interface."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import threading
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
from textual import on, work
|
|
10
|
+
from textual.app import App, ComposeResult
|
|
11
|
+
from textual.binding import Binding
|
|
12
|
+
from textual.containers import Horizontal, ScrollableContainer, Vertical
|
|
13
|
+
from textual.reactive import reactive
|
|
14
|
+
from textual.screen import ModalScreen
|
|
15
|
+
from textual.widgets import (
|
|
16
|
+
Button,
|
|
17
|
+
Footer,
|
|
18
|
+
Header,
|
|
19
|
+
Input,
|
|
20
|
+
Label,
|
|
21
|
+
Markdown,
|
|
22
|
+
Static,
|
|
23
|
+
Tree,
|
|
24
|
+
)
|
|
25
|
+
from textual.widgets.tree import TreeNode
|
|
26
|
+
|
|
27
|
+
from ..models import FileAnalysis, FunctionNode, GraphStats, RepoAnalysis
|
|
28
|
+
from .. import ai_client, graph as graph_mod
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ── CSS ───────────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
CSS = """
|
|
34
|
+
Screen {
|
|
35
|
+
background: #1a1a2e;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
Header {
|
|
39
|
+
background: #0d1b2a;
|
|
40
|
+
color: #a8d8ea;
|
|
41
|
+
text-style: bold;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
Footer {
|
|
45
|
+
background: #0d1b2a;
|
|
46
|
+
color: #4a5568;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* ── Sidebar ── */
|
|
50
|
+
#sidebar {
|
|
51
|
+
width: 28;
|
|
52
|
+
min-width: 14;
|
|
53
|
+
max-width: 60;
|
|
54
|
+
border-right: solid #1e3a5f;
|
|
55
|
+
background: #0d1b2a;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#sidebar.-focused-pane {
|
|
59
|
+
border-right: solid #a8d8ea;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#sidebar-label {
|
|
63
|
+
height: 2;
|
|
64
|
+
background: #0d1b2a;
|
|
65
|
+
color: #a8d8ea;
|
|
66
|
+
text-style: bold;
|
|
67
|
+
padding: 0 1;
|
|
68
|
+
border-bottom: solid #1e3a5f;
|
|
69
|
+
content-align: left middle;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
#file-tree {
|
|
73
|
+
height: 1fr;
|
|
74
|
+
background: #0d1b2a;
|
|
75
|
+
padding: 0 0;
|
|
76
|
+
scrollbar-color: #1e3a5f;
|
|
77
|
+
scrollbar-background: #0d1b2a;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* ── Main area ── */
|
|
81
|
+
#main-area {
|
|
82
|
+
background: #1a1a2e;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
#tab-bar {
|
|
86
|
+
height: 3;
|
|
87
|
+
background: #0d1b2a;
|
|
88
|
+
padding: 0 1;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.tab-btn {
|
|
92
|
+
background: #0d1b2a;
|
|
93
|
+
border: none;
|
|
94
|
+
color: #4a5568;
|
|
95
|
+
min-width: 16;
|
|
96
|
+
height: 3;
|
|
97
|
+
padding: 1 2 0 2;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.tab-btn:hover {
|
|
101
|
+
background: #1e3a5f;
|
|
102
|
+
color: #cbd5e0;
|
|
103
|
+
padding: 1 2 0 2;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.tab-btn.-active {
|
|
107
|
+
background: #1e3a5f;
|
|
108
|
+
border: solid #a8d8ea;
|
|
109
|
+
color: #a8d8ea;
|
|
110
|
+
text-style: bold;
|
|
111
|
+
padding: 0 2;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
#content-area {
|
|
115
|
+
height: 1fr;
|
|
116
|
+
padding: 1 3;
|
|
117
|
+
background: #1a1a2e;
|
|
118
|
+
overflow: scroll scroll;
|
|
119
|
+
border-top: solid #1e3a5f;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
#content-area.-focused-pane {
|
|
123
|
+
border: solid #a8d8ea;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
#stats-bar {
|
|
127
|
+
height: 2;
|
|
128
|
+
background: #0d1b2a;
|
|
129
|
+
border-top: solid #1e3a5f;
|
|
130
|
+
padding: 0 3;
|
|
131
|
+
color: #4a5568;
|
|
132
|
+
content-align: left middle;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* AI chat screen */
|
|
136
|
+
AIScreen {
|
|
137
|
+
align: center middle;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
#ai-dialog {
|
|
141
|
+
width: 86%;
|
|
142
|
+
height: 86%;
|
|
143
|
+
border: solid #0f3460;
|
|
144
|
+
background: #16213e;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
#ai-header {
|
|
148
|
+
height: 3;
|
|
149
|
+
width: 100%;
|
|
150
|
+
background: #0f3460;
|
|
151
|
+
padding: 0 2;
|
|
152
|
+
color: #a8d8ea;
|
|
153
|
+
text-style: bold;
|
|
154
|
+
content-align: left middle;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#chat-history {
|
|
158
|
+
height: 1fr;
|
|
159
|
+
width: 100%;
|
|
160
|
+
padding: 1 2;
|
|
161
|
+
overflow-y: auto;
|
|
162
|
+
background: #16213e;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.msg-user {
|
|
166
|
+
color: #a8d8ea;
|
|
167
|
+
text-style: bold;
|
|
168
|
+
margin-top: 1;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.msg-user-text {
|
|
172
|
+
color: #e2e8f0;
|
|
173
|
+
margin-left: 4;
|
|
174
|
+
margin-bottom: 1;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.msg-ai-label {
|
|
178
|
+
color: #68d391;
|
|
179
|
+
text-style: bold;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.msg-thinking {
|
|
183
|
+
color: #718096;
|
|
184
|
+
text-style: italic;
|
|
185
|
+
margin-left: 4;
|
|
186
|
+
margin-bottom: 1;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.msg-divider {
|
|
190
|
+
color: #2d3748;
|
|
191
|
+
margin-top: 1;
|
|
192
|
+
margin-bottom: 1;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
#ai-input-bar {
|
|
196
|
+
height: 5;
|
|
197
|
+
width: 100%;
|
|
198
|
+
dock: bottom;
|
|
199
|
+
background: #0f3460;
|
|
200
|
+
padding: 1 1;
|
|
201
|
+
align: left middle;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
#ai-input {
|
|
205
|
+
width: 1fr;
|
|
206
|
+
border: solid #4a5568;
|
|
207
|
+
background: #1a1a2e;
|
|
208
|
+
color: #e2e8f0;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
#ai-input:focus {
|
|
212
|
+
border: solid #a8d8ea;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
#btn-close-chat {
|
|
216
|
+
width: 12;
|
|
217
|
+
height: 3;
|
|
218
|
+
margin-left: 1;
|
|
219
|
+
background: #1a1a2e;
|
|
220
|
+
border: solid #4a5568;
|
|
221
|
+
color: #a0aec0;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
#btn-close-chat:hover {
|
|
225
|
+
background: #2d3748;
|
|
226
|
+
color: #e2e8f0;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/* Dep content */
|
|
230
|
+
.section-title {
|
|
231
|
+
color: #a8d8ea;
|
|
232
|
+
text-style: bold;
|
|
233
|
+
margin-top: 1;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.circular {
|
|
237
|
+
color: #fc8181;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.hub {
|
|
241
|
+
color: #f6ad55;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.entry {
|
|
245
|
+
color: #68d391;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.file-path {
|
|
249
|
+
color: #90cdf4;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
.import-arrow {
|
|
253
|
+
color: #718096;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
.count-badge {
|
|
257
|
+
color: #f6ad55;
|
|
258
|
+
}
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ── AI Chat Modal ─────────────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
class AIScreen(ModalScreen):
|
|
265
|
+
BINDINGS = [Binding("escape", "dismiss", "Close")]
|
|
266
|
+
|
|
267
|
+
def __init__(self, analysis: RepoAnalysis, mode: str = "ask") -> None:
|
|
268
|
+
super().__init__()
|
|
269
|
+
self._analysis = analysis
|
|
270
|
+
self._mode = mode
|
|
271
|
+
self._history: list[dict] = []
|
|
272
|
+
self._thinking = False
|
|
273
|
+
self._thinking_widget: Optional[Markdown] = None # tracked by ref, no ID games
|
|
274
|
+
|
|
275
|
+
def compose(self) -> ComposeResult:
|
|
276
|
+
with Vertical(id="ai-dialog"):
|
|
277
|
+
if self._mode == "onboard":
|
|
278
|
+
yield Label(" Onboarding Guide", id="ai-header")
|
|
279
|
+
else:
|
|
280
|
+
yield Label(" Ask AI — multi-turn chat (Esc to close)", id="ai-header")
|
|
281
|
+
|
|
282
|
+
yield ScrollableContainer(id="chat-history")
|
|
283
|
+
|
|
284
|
+
if self._mode == "ask":
|
|
285
|
+
with Horizontal(id="ai-input-bar"):
|
|
286
|
+
yield Input(
|
|
287
|
+
placeholder="Ask a follow-up… (Enter to send)",
|
|
288
|
+
id="ai-input",
|
|
289
|
+
)
|
|
290
|
+
yield Button("Close", id="btn-close-chat")
|
|
291
|
+
|
|
292
|
+
def on_mount(self) -> None:
|
|
293
|
+
if self._mode == "onboard":
|
|
294
|
+
self._run_onboard()
|
|
295
|
+
else:
|
|
296
|
+
self.query_one("#ai-input", Input).focus()
|
|
297
|
+
|
|
298
|
+
# ── Chat history rendering ────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
def _append_user_bubble(self, question: str) -> None:
|
|
301
|
+
container = self.query_one("#chat-history", ScrollableContainer)
|
|
302
|
+
container.mount(Label("You", classes="msg-user"))
|
|
303
|
+
container.mount(Label(question, classes="msg-user-text"))
|
|
304
|
+
container.scroll_end(animate=False)
|
|
305
|
+
|
|
306
|
+
def _append_thinking(self) -> None:
|
|
307
|
+
container = self.query_one("#chat-history", ScrollableContainer)
|
|
308
|
+
container.mount(Label("RepoLens AI", classes="msg-ai-label"))
|
|
309
|
+
self._thinking_widget = Markdown("_thinking…_", classes="msg-thinking")
|
|
310
|
+
container.mount(self._thinking_widget)
|
|
311
|
+
container.scroll_end(animate=False)
|
|
312
|
+
|
|
313
|
+
def _replace_thinking(self, answer: str) -> None:
|
|
314
|
+
if self._thinking_widget is not None:
|
|
315
|
+
self._thinking_widget.update(answer)
|
|
316
|
+
self._thinking_widget = None
|
|
317
|
+
container = self.query_one("#chat-history", ScrollableContainer)
|
|
318
|
+
container.mount(Label("─" * 60, classes="msg-divider"))
|
|
319
|
+
container.scroll_end(animate=False)
|
|
320
|
+
|
|
321
|
+
# ── Onboarding mode ───────────────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
@work(thread=True)
|
|
324
|
+
def _run_onboard(self) -> None:
|
|
325
|
+
container = self.app.call_from_thread(self._start_onboard_ui)
|
|
326
|
+
try:
|
|
327
|
+
result = ai_client.generate_onboarding(self._analysis)
|
|
328
|
+
except Exception as exc:
|
|
329
|
+
result = f"**Error:** {exc}"
|
|
330
|
+
self.app.call_from_thread(self._finish_onboard_ui, result)
|
|
331
|
+
|
|
332
|
+
def _start_onboard_ui(self) -> None:
|
|
333
|
+
container = self.query_one("#chat-history", ScrollableContainer)
|
|
334
|
+
container.mount(Label("RepoLens AI", classes="msg-ai-label"))
|
|
335
|
+
self._thinking_widget = Markdown("_Generating onboarding guide…_", classes="msg-thinking")
|
|
336
|
+
container.mount(self._thinking_widget)
|
|
337
|
+
|
|
338
|
+
def _finish_onboard_ui(self, text: str) -> None:
|
|
339
|
+
self._replace_thinking(text)
|
|
340
|
+
|
|
341
|
+
# ── Ask / follow-up ───────────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
@on(Input.Submitted, "#ai-input")
|
|
344
|
+
def _on_submitted(self, event: Input.Submitted) -> None:
|
|
345
|
+
question = event.value.strip()
|
|
346
|
+
if not question or self._thinking:
|
|
347
|
+
return
|
|
348
|
+
self._thinking = True
|
|
349
|
+
self.query_one("#ai-input", Input).value = ""
|
|
350
|
+
self._append_user_bubble(question)
|
|
351
|
+
self._append_thinking()
|
|
352
|
+
self._run_ask(question)
|
|
353
|
+
|
|
354
|
+
@work(thread=True)
|
|
355
|
+
def _run_ask(self, question: str) -> None:
|
|
356
|
+
try:
|
|
357
|
+
answer = ai_client.ask(self._analysis, question, history=list(self._history))
|
|
358
|
+
except Exception as exc:
|
|
359
|
+
answer = f"**Error:** {exc}"
|
|
360
|
+
# Update history for next turn
|
|
361
|
+
self._history.append({"role": "user", "content": question})
|
|
362
|
+
self._history.append({"role": "assistant", "content": answer})
|
|
363
|
+
self.app.call_from_thread(self._on_answer_ready, answer)
|
|
364
|
+
|
|
365
|
+
def _on_answer_ready(self, answer: str) -> None:
|
|
366
|
+
self._replace_thinking(answer)
|
|
367
|
+
self._thinking = False
|
|
368
|
+
self.query_one("#ai-input", Input).focus()
|
|
369
|
+
|
|
370
|
+
@on(Button.Pressed, "#btn-close-chat")
|
|
371
|
+
def _close(self) -> None:
|
|
372
|
+
self.dismiss()
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# ── Main App ──────────────────────────────────────────────────────────────────
|
|
376
|
+
|
|
377
|
+
class RepoLensApp(App):
|
|
378
|
+
TITLE = "RepoLens"
|
|
379
|
+
CSS = CSS
|
|
380
|
+
|
|
381
|
+
BINDINGS = [
|
|
382
|
+
Binding("q", "quit", "Quit"),
|
|
383
|
+
Binding("1", "tab_deps", "Deps"),
|
|
384
|
+
Binding("2", "tab_calls", "Calls"),
|
|
385
|
+
Binding("3", "tab_graph", "Full Graph"),
|
|
386
|
+
Binding("a", "ask_ai", "Ask AI"),
|
|
387
|
+
Binding("o", "onboard", "Onboard"),
|
|
388
|
+
Binding("f", "focus_next_pane", "Focus pane"),
|
|
389
|
+
Binding("]", "sidebar_grow", "Sidebar ▶"),
|
|
390
|
+
Binding("[", "sidebar_shrink", "◀ Sidebar"),
|
|
391
|
+
Binding("j", "cursor_down", show=False),
|
|
392
|
+
Binding("k", "cursor_up", show=False),
|
|
393
|
+
]
|
|
394
|
+
|
|
395
|
+
current_tab: reactive[str] = reactive("deps")
|
|
396
|
+
selected_file: reactive[Optional[str]] = reactive(None)
|
|
397
|
+
sidebar_width: reactive[int] = reactive(28)
|
|
398
|
+
_focus_on_content: bool = False
|
|
399
|
+
|
|
400
|
+
def __init__(self, analysis: RepoAnalysis) -> None:
|
|
401
|
+
super().__init__()
|
|
402
|
+
self._analysis = analysis
|
|
403
|
+
|
|
404
|
+
def compose(self) -> ComposeResult:
|
|
405
|
+
yield Header(show_clock=True)
|
|
406
|
+
with Horizontal():
|
|
407
|
+
with Vertical(id="sidebar"):
|
|
408
|
+
yield Label(" FILES", id="sidebar-label")
|
|
409
|
+
yield Tree(".", id="file-tree")
|
|
410
|
+
with Vertical(id="main-area"):
|
|
411
|
+
with Horizontal(id="tab-bar"):
|
|
412
|
+
yield Button("1 Dependencies", classes="tab-btn -active", id="tab-deps-btn")
|
|
413
|
+
yield Button("2 Call Graph", classes="tab-btn", id="tab-calls-btn")
|
|
414
|
+
yield Button("3 Full Graph", classes="tab-btn", id="tab-graph-btn")
|
|
415
|
+
yield ScrollableContainer(
|
|
416
|
+
Static("", id="content"),
|
|
417
|
+
id="content-area",
|
|
418
|
+
)
|
|
419
|
+
yield Static("", id="stats-bar")
|
|
420
|
+
yield Footer()
|
|
421
|
+
|
|
422
|
+
def on_mount(self) -> None:
|
|
423
|
+
self._populate_file_tree()
|
|
424
|
+
self._update_stats_bar()
|
|
425
|
+
self._render_content()
|
|
426
|
+
|
|
427
|
+
# ── File Tree ─────────────────────────────────────────────────────────────
|
|
428
|
+
|
|
429
|
+
def _populate_file_tree(self) -> None:
|
|
430
|
+
tree = self.query_one("#file-tree", Tree)
|
|
431
|
+
tree.root.expand()
|
|
432
|
+
stats = self._analysis.stats
|
|
433
|
+
root_path = Path(self._analysis.root).name
|
|
434
|
+
|
|
435
|
+
# Build directory hierarchy
|
|
436
|
+
dir_nodes: dict[str, TreeNode] = {}
|
|
437
|
+
|
|
438
|
+
def get_dir_node(parts: list[str]) -> TreeNode:
|
|
439
|
+
key = "/".join(parts)
|
|
440
|
+
if key in dir_nodes:
|
|
441
|
+
return dir_nodes[key]
|
|
442
|
+
if len(parts) == 1:
|
|
443
|
+
node = tree.root.add(f" {parts[0]}/", expand=True)
|
|
444
|
+
else:
|
|
445
|
+
parent = get_dir_node(parts[:-1])
|
|
446
|
+
node = parent.add(f" {parts[-1]}/", expand=True)
|
|
447
|
+
dir_nodes[key] = node
|
|
448
|
+
return node
|
|
449
|
+
|
|
450
|
+
for file_node in self._analysis.files:
|
|
451
|
+
parts = file_node.path.split("/")
|
|
452
|
+
in_deg = stats.in_degree.get(file_node.path, 0)
|
|
453
|
+
|
|
454
|
+
lang_icon = {
|
|
455
|
+
"python": "🐍",
|
|
456
|
+
"javascript": "",
|
|
457
|
+
"typescript": "",
|
|
458
|
+
"go": "",
|
|
459
|
+
"rust": "",
|
|
460
|
+
}.get(file_node.language, "")
|
|
461
|
+
|
|
462
|
+
label = parts[-1]
|
|
463
|
+
if in_deg > 0:
|
|
464
|
+
label += f" ({in_deg} importers)"
|
|
465
|
+
|
|
466
|
+
is_circular = any(file_node.path in c for c in stats.circular_deps)
|
|
467
|
+
is_hub = in_deg >= 5
|
|
468
|
+
|
|
469
|
+
rich_label = Text(label)
|
|
470
|
+
if is_circular:
|
|
471
|
+
rich_label.stylize("bold red")
|
|
472
|
+
elif is_hub:
|
|
473
|
+
rich_label.stylize("bold yellow")
|
|
474
|
+
|
|
475
|
+
if len(parts) == 1:
|
|
476
|
+
leaf = tree.root.add_leaf(label)
|
|
477
|
+
else:
|
|
478
|
+
parent = get_dir_node(parts[:-1])
|
|
479
|
+
leaf = parent.add_leaf(label)
|
|
480
|
+
|
|
481
|
+
leaf.data = file_node.path # store path for selection
|
|
482
|
+
|
|
483
|
+
tree.root.label = Text(f" {root_path} ({len(self._analysis.files)} files)")
|
|
484
|
+
|
|
485
|
+
# ── Stats Bar ─────────────────────────────────────────────────────────────
|
|
486
|
+
|
|
487
|
+
def _update_stats_bar(self) -> None:
|
|
488
|
+
stats = self._analysis.stats
|
|
489
|
+
n_files = len(self._analysis.files)
|
|
490
|
+
n_funcs = len(stats.functions)
|
|
491
|
+
n_cycles = len(stats.circular_deps)
|
|
492
|
+
cycle_str = (
|
|
493
|
+
f" [red]! {n_cycles} circular dep{'s' if n_cycles != 1 else ''}[/]"
|
|
494
|
+
if n_cycles else
|
|
495
|
+
" [green]no circular deps[/]"
|
|
496
|
+
)
|
|
497
|
+
text = (
|
|
498
|
+
f" [bold]{n_files}[/] files"
|
|
499
|
+
f" [bold]{n_funcs}[/] functions"
|
|
500
|
+
f"{cycle_str}"
|
|
501
|
+
f" [bold]{len(stats.entry_points)}[/] entry points"
|
|
502
|
+
f" [dim][ / ] resize [f] switch pane[/]"
|
|
503
|
+
)
|
|
504
|
+
self.query_one("#stats-bar", Static).update(text)
|
|
505
|
+
|
|
506
|
+
# ── Tab switching ─────────────────────────────────────────────────────────
|
|
507
|
+
|
|
508
|
+
def _set_active_tab(self, tab: str) -> None:
|
|
509
|
+
self.current_tab = tab
|
|
510
|
+
for btn_id in ("tab-deps-btn", "tab-calls-btn", "tab-graph-btn"):
|
|
511
|
+
btn = self.query_one(f"#{btn_id}", Button)
|
|
512
|
+
btn.remove_class("-active")
|
|
513
|
+
self.query_one(f"#tab-{tab}-btn", Button).add_class("-active")
|
|
514
|
+
self._render_content()
|
|
515
|
+
|
|
516
|
+
@on(Button.Pressed, "#tab-deps-btn")
|
|
517
|
+
def _tab_deps(self) -> None:
|
|
518
|
+
self._set_active_tab("deps")
|
|
519
|
+
|
|
520
|
+
@on(Button.Pressed, "#tab-calls-btn")
|
|
521
|
+
def _tab_calls(self) -> None:
|
|
522
|
+
self._set_active_tab("calls")
|
|
523
|
+
|
|
524
|
+
@on(Button.Pressed, "#tab-graph-btn")
|
|
525
|
+
def _tab_graph(self) -> None:
|
|
526
|
+
self._set_active_tab("graph")
|
|
527
|
+
|
|
528
|
+
def action_tab_deps(self) -> None:
|
|
529
|
+
self._set_active_tab("deps")
|
|
530
|
+
|
|
531
|
+
def action_tab_calls(self) -> None:
|
|
532
|
+
self._set_active_tab("calls")
|
|
533
|
+
|
|
534
|
+
def action_tab_graph(self) -> None:
|
|
535
|
+
self._set_active_tab("graph")
|
|
536
|
+
|
|
537
|
+
# ── Tree selection ────────────────────────────────────────────────────────
|
|
538
|
+
|
|
539
|
+
@on(Tree.NodeHighlighted, "#file-tree")
|
|
540
|
+
def _on_file_highlighted(self, event: Tree.NodeHighlighted) -> None:
|
|
541
|
+
# data is set on file leaves; directory nodes have no data → show overview
|
|
542
|
+
self.selected_file = event.node.data or None
|
|
543
|
+
self._render_content()
|
|
544
|
+
|
|
545
|
+
# ── Content rendering ─────────────────────────────────────────────────────
|
|
546
|
+
|
|
547
|
+
def _render_content(self) -> None:
|
|
548
|
+
content = self.query_one("#content", Static)
|
|
549
|
+
if self.selected_file and self.selected_file.endswith(".md"):
|
|
550
|
+
content.update(self._render_doc_file())
|
|
551
|
+
return
|
|
552
|
+
if self.current_tab == "deps":
|
|
553
|
+
content.update(self._render_deps())
|
|
554
|
+
elif self.current_tab == "calls":
|
|
555
|
+
content.update(self._render_calls())
|
|
556
|
+
elif self.current_tab == "graph":
|
|
557
|
+
content.update(self._render_full_graph())
|
|
558
|
+
|
|
559
|
+
def _render_doc_file(self) -> Text:
|
|
560
|
+
t = Text()
|
|
561
|
+
file_node = next((f for f in self._analysis.files if f.path == self.selected_file), None)
|
|
562
|
+
if not file_node or not file_node.content:
|
|
563
|
+
t.append(" (empty)", style="#4a5568")
|
|
564
|
+
return t
|
|
565
|
+
t.append(f"\n {self.selected_file}\n\n", style="bold cyan")
|
|
566
|
+
t.append(file_node.content, style="#e2e8f0")
|
|
567
|
+
return t
|
|
568
|
+
|
|
569
|
+
# ── Tree-drawing helpers ──────────────────────────────────────────────────
|
|
570
|
+
|
|
571
|
+
@staticmethod
|
|
572
|
+
def _branch(t: Text, indent: str, items: list[tuple[str, str, str, str]]) -> None:
|
|
573
|
+
"""Append tree-branch lines to *t*.
|
|
574
|
+
|
|
575
|
+
items: list of (prefix_label, prefix_style, body_label, body_style)
|
|
576
|
+
Uses ├──→ for all but the last item, └──→ for the last.
|
|
577
|
+
"""
|
|
578
|
+
for i, (pre_label, pre_style, body_label, body_style) in enumerate(items):
|
|
579
|
+
is_last = i == len(items) - 1
|
|
580
|
+
connector = "└── " if is_last else "├── "
|
|
581
|
+
t.append(indent + connector, style="#4a5568")
|
|
582
|
+
t.append(pre_label, style=pre_style)
|
|
583
|
+
if body_label:
|
|
584
|
+
t.append(body_label, style=body_style)
|
|
585
|
+
t.append("\n")
|
|
586
|
+
|
|
587
|
+
@staticmethod
|
|
588
|
+
def _branch_arrow(
|
|
589
|
+
t: Text,
|
|
590
|
+
indent: str,
|
|
591
|
+
items: list[tuple[str, str]], # (label, style)
|
|
592
|
+
arrow: str = "──→",
|
|
593
|
+
) -> None:
|
|
594
|
+
"""Append arrow-branch lines (├──→ / └──→) for dependency lists."""
|
|
595
|
+
for i, (label, style) in enumerate(items):
|
|
596
|
+
is_last = i == len(items) - 1
|
|
597
|
+
connector = f"└{arrow} " if is_last else f"├{arrow} "
|
|
598
|
+
t.append(indent + connector, style="#4a5568")
|
|
599
|
+
t.append(label + "\n", style=style)
|
|
600
|
+
|
|
601
|
+
# ── Deps view ─────────────────────────────────────────────────────────────
|
|
602
|
+
|
|
603
|
+
def _render_deps(self) -> Text:
|
|
604
|
+
stats = self._analysis.stats
|
|
605
|
+
t = Text()
|
|
606
|
+
|
|
607
|
+
if self.selected_file:
|
|
608
|
+
fa = self._analysis.file_analyses.get(self.selected_file)
|
|
609
|
+
t.append(f"\n {self.selected_file}\n", style="bold cyan")
|
|
610
|
+
|
|
611
|
+
if fa:
|
|
612
|
+
deps = stats.import_edges.get(self.selected_file, [])
|
|
613
|
+
importers = graph_mod.importers_of(self.selected_file, stats)
|
|
614
|
+
has_funcs = bool(fa.functions)
|
|
615
|
+
has_classes = bool(fa.classes)
|
|
616
|
+
|
|
617
|
+
# ── IMPORTS ──────────────────────────────────────────────────
|
|
618
|
+
section_connector = "├── " if (importers or has_funcs or has_classes) else "└── "
|
|
619
|
+
t.append(f" {section_connector}", style="#4a5568")
|
|
620
|
+
t.append("IMPORTS\n", style="bold #a8d8ea")
|
|
621
|
+
|
|
622
|
+
if deps:
|
|
623
|
+
cont = "│ " if (importers or has_funcs or has_classes) else " "
|
|
624
|
+
dep_items = []
|
|
625
|
+
for dep in deps:
|
|
626
|
+
in_deg = stats.in_degree.get(dep, 0)
|
|
627
|
+
is_circ = any(self.selected_file in c and dep in c for c in stats.circular_deps)
|
|
628
|
+
label = dep
|
|
629
|
+
if in_deg > 0:
|
|
630
|
+
label += f" (used by {in_deg} files)"
|
|
631
|
+
if is_circ:
|
|
632
|
+
label += " ⚠ CIRCULAR"
|
|
633
|
+
dep_items.append((label, "bold red" if is_circ else "#90cdf4"))
|
|
634
|
+
self._branch_arrow(t, f" {cont}", dep_items, arrow="──→")
|
|
635
|
+
else:
|
|
636
|
+
cont = "│ " if (importers or has_funcs or has_classes) else " "
|
|
637
|
+
t.append(f" {cont} (no local imports)\n", style="#4a5568")
|
|
638
|
+
|
|
639
|
+
# ── IMPORTED BY ───────────────────────────────────────────────
|
|
640
|
+
if importers or has_funcs or has_classes:
|
|
641
|
+
section_connector = "├── " if (has_funcs or has_classes) else "└── "
|
|
642
|
+
t.append(f" {section_connector}", style="#4a5568")
|
|
643
|
+
t.append("IMPORTED BY\n", style="bold #a8d8ea")
|
|
644
|
+
cont = "│ " if (has_funcs or has_classes) else " "
|
|
645
|
+
if importers:
|
|
646
|
+
imp_items = [(imp, "#90cdf4") for imp in importers]
|
|
647
|
+
self._branch_arrow(t, f" {cont}", imp_items, arrow="──←")
|
|
648
|
+
else:
|
|
649
|
+
t.append(f" {cont} (entry point — nothing imports this)\n", style="#68d391")
|
|
650
|
+
|
|
651
|
+
# ── FUNCTIONS ────────────────────────────────────────────────
|
|
652
|
+
if has_funcs:
|
|
653
|
+
section_connector = "├── " if has_classes else "└── "
|
|
654
|
+
t.append(f" {section_connector}", style="#4a5568")
|
|
655
|
+
t.append("FUNCTIONS\n", style="bold #a8d8ea")
|
|
656
|
+
cont = "│ " if has_classes else " "
|
|
657
|
+
fns = fa.functions[:20]
|
|
658
|
+
for i, fn in enumerate(fns):
|
|
659
|
+
is_last_fn = i == len(fns) - 1
|
|
660
|
+
fn_conn = "└── " if is_last_fn else "├── "
|
|
661
|
+
fn_cont = " " if is_last_fn else "│ "
|
|
662
|
+
# function name + line number
|
|
663
|
+
t.append(f" {cont}{fn_conn}", style="#4a5568")
|
|
664
|
+
t.append(fn.name, style="bold #e2e8f0")
|
|
665
|
+
t.append(f" line {fn.line_start}\n", style="#718096")
|
|
666
|
+
# docstring directly under its function, labelled
|
|
667
|
+
if fn.docstring:
|
|
668
|
+
t.append(f" {cont}{fn_cont} ", style="#4a5568")
|
|
669
|
+
t.append('"""', style="#4a5568")
|
|
670
|
+
t.append(f" {fn.docstring}\n", style="italic #a0aec0")
|
|
671
|
+
|
|
672
|
+
# ── CLASSES ──────────────────────────────────────────────────
|
|
673
|
+
if has_classes:
|
|
674
|
+
t.append(" └── ", style="#4a5568")
|
|
675
|
+
t.append("CLASSES\n", style="bold #a8d8ea")
|
|
676
|
+
cls_items = [("", "", cls, "#e2e8f0") for cls in fa.classes]
|
|
677
|
+
self._branch(t, " ", cls_items)
|
|
678
|
+
|
|
679
|
+
else:
|
|
680
|
+
# ── Overview ─────────────────────────────────────────────────────
|
|
681
|
+
t.append("\n DEPENDENCY OVERVIEW\n", style="bold #a8d8ea")
|
|
682
|
+
|
|
683
|
+
if stats.circular_deps:
|
|
684
|
+
t.append("\n ⚠ CIRCULAR DEPENDENCIES\n", style="bold red")
|
|
685
|
+
for cycle in stats.circular_deps:
|
|
686
|
+
t.append(" │\n", style="#4a5568")
|
|
687
|
+
chain = " ──→ ".join(cycle) + " ──→ " + cycle[0]
|
|
688
|
+
t.append(f" └── {chain}\n", style="red")
|
|
689
|
+
|
|
690
|
+
t.append("\n MOST IMPORTED FILES\n", style="bold #a8d8ea")
|
|
691
|
+
hub = [(p, c) for p, c in stats.hub_files[:10] if c > 0]
|
|
692
|
+
for i, (path, count) in enumerate(hub):
|
|
693
|
+
is_last = i == len(hub) - 1
|
|
694
|
+
conn = "└── " if is_last else "├── "
|
|
695
|
+
bar = "▪" * min(count, 15)
|
|
696
|
+
t.append(f" {conn}", style="#4a5568")
|
|
697
|
+
t.append(f"{bar} {count:>2} ", style="#f6ad55")
|
|
698
|
+
t.append(path + "\n", style="#90cdf4")
|
|
699
|
+
|
|
700
|
+
t.append("\n ENTRY POINTS\n", style="bold #a8d8ea")
|
|
701
|
+
eps = stats.entry_points[:15]
|
|
702
|
+
for i, ep in enumerate(eps):
|
|
703
|
+
is_last = i == len(eps) - 1
|
|
704
|
+
conn = "└──> " if is_last else "├──> "
|
|
705
|
+
t.append(f" {conn}", style="#4a5568")
|
|
706
|
+
t.append(ep + "\n", style="#68d391")
|
|
707
|
+
|
|
708
|
+
return t
|
|
709
|
+
|
|
710
|
+
# ── Call graph view ───────────────────────────────────────────────────────
|
|
711
|
+
|
|
712
|
+
def _render_calls(self) -> Text:
|
|
713
|
+
stats = self._analysis.stats
|
|
714
|
+
t = Text()
|
|
715
|
+
|
|
716
|
+
if self.selected_file:
|
|
717
|
+
fa = self._analysis.file_analyses.get(self.selected_file)
|
|
718
|
+
t.append(f"\n {self.selected_file}\n", style="bold cyan")
|
|
719
|
+
|
|
720
|
+
if fa and fa.functions:
|
|
721
|
+
for fn_idx, fn in enumerate(fa.functions[:30]):
|
|
722
|
+
fid = f"{self.selected_file}::{fn.name}"
|
|
723
|
+
fn_obj = stats.functions.get(fid)
|
|
724
|
+
callees = graph_mod.callees_of(fid, stats) if fn_obj else []
|
|
725
|
+
callers = graph_mod.callers_of(fid, stats) if fn_obj else []
|
|
726
|
+
|
|
727
|
+
is_last_fn = fn_idx == len(fa.functions[:30]) - 1
|
|
728
|
+
fn_conn = "└── " if is_last_fn else "├── "
|
|
729
|
+
fn_cont = " " if is_last_fn else "│ "
|
|
730
|
+
|
|
731
|
+
# Function header
|
|
732
|
+
t.append(f" {fn_conn}", style="#4a5568")
|
|
733
|
+
t.append(f"fn {fn.name}", style="bold #e2e8f0")
|
|
734
|
+
t.append(f" line {fn.line_start}\n", style="#718096")
|
|
735
|
+
|
|
736
|
+
# Docstring — labelled inline under the function name
|
|
737
|
+
if fn.docstring:
|
|
738
|
+
t.append(f" {fn_cont} ", style="#4a5568")
|
|
739
|
+
t.append('"""', style="#4a5568")
|
|
740
|
+
t.append(f" {fn.docstring}\n", style="italic #a0aec0")
|
|
741
|
+
|
|
742
|
+
has_calls = bool(callees)
|
|
743
|
+
has_callers = bool(callers)
|
|
744
|
+
|
|
745
|
+
# what this function calls
|
|
746
|
+
if has_calls:
|
|
747
|
+
sub_conn = "├── " if has_callers else "└── "
|
|
748
|
+
sub_cont = "│ " if has_callers else " "
|
|
749
|
+
t.append(f" {fn_cont}{sub_conn}", style="#4a5568")
|
|
750
|
+
t.append(f"calls {len(callees)} function(s)\n", style="#a0aec0")
|
|
751
|
+
call_items = [(c, "#90cdf4") for c in callees[:8]]
|
|
752
|
+
self._branch_arrow(t, f" {fn_cont}{sub_cont}", call_items, arrow="──→")
|
|
753
|
+
|
|
754
|
+
# what calls this function
|
|
755
|
+
if has_callers:
|
|
756
|
+
t.append(f" {fn_cont}└── ", style="#4a5568")
|
|
757
|
+
t.append(f"called by {len(callers)} function(s)\n", style="#a0aec0")
|
|
758
|
+
caller_items = [(c, "#68d391") for c in callers[:8]]
|
|
759
|
+
self._branch_arrow(t, f" {fn_cont} ", caller_items, arrow="──←")
|
|
760
|
+
|
|
761
|
+
if not is_last_fn:
|
|
762
|
+
t.append(f" │\n", style="#4a5568")
|
|
763
|
+
else:
|
|
764
|
+
t.append(" └── (no functions found)\n", style="#718096")
|
|
765
|
+
|
|
766
|
+
else:
|
|
767
|
+
self._render_call_overview(t, stats)
|
|
768
|
+
|
|
769
|
+
return t
|
|
770
|
+
|
|
771
|
+
def _render_call_overview(self, t: Text, stats: "GraphStats") -> None:
|
|
772
|
+
all_fns = list(stats.functions.values())
|
|
773
|
+
total = len(all_fns)
|
|
774
|
+
t.append(f"\n CALL GRAPH · {total} functions\n", style="bold #a8d8ea")
|
|
775
|
+
|
|
776
|
+
if not all_fns:
|
|
777
|
+
t.append(" No functions found.\n", style="#718096")
|
|
778
|
+
return
|
|
779
|
+
|
|
780
|
+
# ── Ranked table ─────────────────────────────────────────────────────
|
|
781
|
+
by_total = sorted(all_fns, key=lambda f: len(f.callers) + len(f.calls), reverse=True)[:20]
|
|
782
|
+
max_callers = max((len(f.callers) for f in by_total), default=1) or 1
|
|
783
|
+
max_calls = max((len(f.calls) for f in by_total), default=1) or 1
|
|
784
|
+
|
|
785
|
+
t.append("\n MOST CONNECTED FUNCTIONS\n", style="bold #a8d8ea")
|
|
786
|
+
# column header
|
|
787
|
+
t.append(" " + "─" * 72 + "\n", style="#2d3748")
|
|
788
|
+
t.append(
|
|
789
|
+
f" {'#':<4}{'function':<26}{'callers':<22}{'calls':<22}{'file'}\n",
|
|
790
|
+
style="#4a5568",
|
|
791
|
+
)
|
|
792
|
+
t.append(" " + "─" * 72 + "\n", style="#2d3748")
|
|
793
|
+
|
|
794
|
+
for rank, fn in enumerate(by_total, 1):
|
|
795
|
+
n_callers = len(fn.callers)
|
|
796
|
+
n_calls = len(fn.calls)
|
|
797
|
+
|
|
798
|
+
# colour-code by role
|
|
799
|
+
if n_callers == 0:
|
|
800
|
+
fn_style = "#68d391" # green — entry / standalone
|
|
801
|
+
elif n_calls == 0:
|
|
802
|
+
fn_style = "#fc8181" # red — sink / leaf
|
|
803
|
+
elif n_callers >= 4:
|
|
804
|
+
fn_style = "#f6ad55" # orange — hot hub
|
|
805
|
+
else:
|
|
806
|
+
fn_style = "#e2e8f0" # white — normal
|
|
807
|
+
|
|
808
|
+
# proportional bars (max 10 chars each)
|
|
809
|
+
caller_bar = "▪" * round(n_callers / max_callers * 10)
|
|
810
|
+
calls_bar = "▪" * round(n_calls / max_calls * 10)
|
|
811
|
+
|
|
812
|
+
caller_col = f"{caller_bar:<10} {n_callers}"
|
|
813
|
+
calls_col = f"{calls_bar:<10} {n_calls}"
|
|
814
|
+
|
|
815
|
+
fname = fn.name[:24]
|
|
816
|
+
fpath = fn.file_path
|
|
817
|
+
|
|
818
|
+
t.append(f" {rank:<4}", style="#4a5568")
|
|
819
|
+
t.append(f"{fname:<26}", style=fn_style)
|
|
820
|
+
t.append(f"{caller_col:<22}", style="#a8d8ea")
|
|
821
|
+
t.append(f"{calls_col:<22}", style="#90cdf4")
|
|
822
|
+
t.append(f"{fpath}\n", style="#4a5568")
|
|
823
|
+
|
|
824
|
+
t.append(" " + "─" * 72 + "\n", style="#2d3748")
|
|
825
|
+
|
|
826
|
+
# ── Legend ────────────────────────────────────────────────────────────
|
|
827
|
+
t.append("\n LEGEND ", style="#4a5568")
|
|
828
|
+
t.append("* ", style="#68d391"); t.append("entry (nothing calls it) ", style="#718096")
|
|
829
|
+
t.append("* ", style="#f6ad55"); t.append("hub (called 4+ times) ", style="#718096")
|
|
830
|
+
t.append("* ", style="#fc8181"); t.append("leaf (calls nothing)\n", style="#718096")
|
|
831
|
+
|
|
832
|
+
# ── Entry functions ───────────────────────────────────────────────────
|
|
833
|
+
entries = [f for f in all_fns if not f.callers][:10]
|
|
834
|
+
if entries:
|
|
835
|
+
t.append("\n ENTRY FUNCTIONS (nothing calls these — start reading here)\n",
|
|
836
|
+
style="bold #a8d8ea")
|
|
837
|
+
entry_items = [(fn.name, "#68d391", f" {fn.file_path}", "#4a5568") for fn in entries]
|
|
838
|
+
self._branch(t, " ", entry_items)
|
|
839
|
+
|
|
840
|
+
# ── Hottest hubs ──────────────────────────────────────────────────────
|
|
841
|
+
hubs = [f for f in all_fns if len(f.callers) >= 4]
|
|
842
|
+
if hubs:
|
|
843
|
+
hubs.sort(key=lambda f: len(f.callers), reverse=True)
|
|
844
|
+
t.append("\n HOT HUBS (called most frequently — high-impact functions)\n",
|
|
845
|
+
style="bold #a8d8ea")
|
|
846
|
+
hub_items = [
|
|
847
|
+
(fn.name, "#f6ad55", f" called {len(fn.callers)}× · {fn.file_path}", "#4a5568")
|
|
848
|
+
for fn in hubs[:8]
|
|
849
|
+
]
|
|
850
|
+
self._branch(t, " ", hub_items)
|
|
851
|
+
|
|
852
|
+
# ── Full graph view ───────────────────────────────────────────────────────
|
|
853
|
+
|
|
854
|
+
def _render_full_graph(self) -> Text:
|
|
855
|
+
stats = self._analysis.stats
|
|
856
|
+
t = Text()
|
|
857
|
+
t.append("\n FULL IMPORT GRAPH\n", style="bold #a8d8ea")
|
|
858
|
+
|
|
859
|
+
entries = [(src, deps) for src, deps in sorted(stats.import_edges.items()) if deps]
|
|
860
|
+
|
|
861
|
+
if not entries:
|
|
862
|
+
t.append(" └── No inter-file imports found.\n", style="#718096")
|
|
863
|
+
return t
|
|
864
|
+
|
|
865
|
+
for src_idx, (src, deps) in enumerate(entries):
|
|
866
|
+
is_last_src = src_idx == len(entries) - 1
|
|
867
|
+
src_conn = "└── " if is_last_src else "├── "
|
|
868
|
+
src_cont = " " if is_last_src else "│ "
|
|
869
|
+
|
|
870
|
+
is_circ = any(src in c for c in stats.circular_deps)
|
|
871
|
+
t.append(f"\n {src_conn}", style="#4a5568")
|
|
872
|
+
t.append(src, style="bold red" if is_circ else "bold #90cdf4")
|
|
873
|
+
if is_circ:
|
|
874
|
+
t.append(" ⚠ CIRCULAR", style="bold red")
|
|
875
|
+
t.append("\n")
|
|
876
|
+
|
|
877
|
+
dep_items = []
|
|
878
|
+
for dep in deps:
|
|
879
|
+
dep_circ = any(dep in c for c in stats.circular_deps)
|
|
880
|
+
in_deg = stats.in_degree.get(dep, 0)
|
|
881
|
+
label = dep + (f" (used by {in_deg} files)" if in_deg > 1 else "")
|
|
882
|
+
dep_items.append((label, "red" if dep_circ else "#a0aec0"))
|
|
883
|
+
self._branch_arrow(t, f" {src_cont}", dep_items, arrow="──→")
|
|
884
|
+
|
|
885
|
+
return t
|
|
886
|
+
|
|
887
|
+
# ── Sidebar resize ────────────────────────────────────────────────────────
|
|
888
|
+
|
|
889
|
+
def watch_sidebar_width(self, width: int) -> None:
|
|
890
|
+
self.query_one("#sidebar").styles.width = width
|
|
891
|
+
|
|
892
|
+
def action_sidebar_grow(self) -> None:
|
|
893
|
+
self.sidebar_width = min(self.sidebar_width + 2, 60)
|
|
894
|
+
|
|
895
|
+
def action_sidebar_shrink(self) -> None:
|
|
896
|
+
self.sidebar_width = max(self.sidebar_width - 2, 14)
|
|
897
|
+
|
|
898
|
+
# ── Pane focus switching ──────────────────────────────────────────────────
|
|
899
|
+
|
|
900
|
+
def action_focus_next_pane(self) -> None:
|
|
901
|
+
self._focus_on_content = not self._focus_on_content
|
|
902
|
+
if self._focus_on_content:
|
|
903
|
+
area = self.query_one("#content-area", ScrollableContainer)
|
|
904
|
+
area.focus()
|
|
905
|
+
area.add_class("-focused-pane")
|
|
906
|
+
self.query_one("#sidebar").remove_class("-focused-pane")
|
|
907
|
+
else:
|
|
908
|
+
self.query_one("#file-tree", Tree).focus()
|
|
909
|
+
self.query_one("#sidebar").add_class("-focused-pane")
|
|
910
|
+
self.query_one("#content-area", ScrollableContainer).remove_class("-focused-pane")
|
|
911
|
+
|
|
912
|
+
# ── Override j/k to go to correct widget ─────────────────────────────────
|
|
913
|
+
|
|
914
|
+
def action_cursor_down(self) -> None:
|
|
915
|
+
if self._focus_on_content:
|
|
916
|
+
self.query_one("#content-area", ScrollableContainer).scroll_down()
|
|
917
|
+
else:
|
|
918
|
+
self.query_one("#file-tree", Tree).action_cursor_down()
|
|
919
|
+
|
|
920
|
+
def action_cursor_up(self) -> None:
|
|
921
|
+
if self._focus_on_content:
|
|
922
|
+
self.query_one("#content-area", ScrollableContainer).scroll_up()
|
|
923
|
+
else:
|
|
924
|
+
self.query_one("#file-tree", Tree).action_cursor_up()
|
|
925
|
+
|
|
926
|
+
# ── Actions ───────────────────────────────────────────────────────────────
|
|
927
|
+
|
|
928
|
+
def action_ask_ai(self) -> None:
|
|
929
|
+
if not ai_client.is_configured():
|
|
930
|
+
self.notify(
|
|
931
|
+
"No AI provider configured. Set GEMINI_API_KEY, OPENAI_API_KEY, GROQ_API_KEY, or ANTHROPIC_API_KEY.",
|
|
932
|
+
title="AI not configured",
|
|
933
|
+
severity="warning",
|
|
934
|
+
timeout=6,
|
|
935
|
+
)
|
|
936
|
+
return
|
|
937
|
+
self.push_screen(AIScreen(self._analysis, mode="ask"))
|
|
938
|
+
|
|
939
|
+
def action_onboard(self) -> None:
|
|
940
|
+
if not ai_client.is_configured():
|
|
941
|
+
self.notify(
|
|
942
|
+
"No AI provider configured. Set GEMINI_API_KEY, OPENAI_API_KEY, GROQ_API_KEY, or ANTHROPIC_API_KEY.",
|
|
943
|
+
title="AI not configured",
|
|
944
|
+
severity="warning",
|
|
945
|
+
timeout=6,
|
|
946
|
+
)
|
|
947
|
+
return
|
|
948
|
+
self.push_screen(AIScreen(self._analysis, mode="onboard"))
|
|
949
|
+
|
|
950
|
+
def action_refresh_view(self) -> None:
|
|
951
|
+
self._render_content()
|