synapse-cli-agent 0.1.13__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.
- synapse/__init__.py +13 -0
- synapse/__main__.py +6 -0
- synapse/app/__init__.py +1 -0
- synapse/app/agent.py +492 -0
- synapse/app/agent_md.py +107 -0
- synapse/cli.py +750 -0
- synapse/commands/__init__.py +1 -0
- synapse/commands/compression.py +573 -0
- synapse/commands/helpers.py +22 -0
- synapse/commands/mcp.py +406 -0
- synapse/commands/model.py +173 -0
- synapse/commands/result.py +34 -0
- synapse/commands/sessions.py +443 -0
- synapse/commands/slash_cmds.py +521 -0
- synapse/commands/slash_complete.py +816 -0
- synapse/commands/theme.py +99 -0
- synapse/config.py +27 -0
- synapse/content/__init__.py +1 -0
- synapse/content/input_history.py +122 -0
- synapse/content/multimodal.py +733 -0
- synapse/content/prompts.py +249 -0
- synapse/content/skills_catalog.py +128 -0
- synapse/integrations/__init__.py +1 -0
- synapse/integrations/checkpoint_seed.py +281 -0
- synapse/integrations/codex_history.py +375 -0
- synapse/integrations/codex_import.py +393 -0
- synapse/integrations/codex_sessions.py +629 -0
- synapse/integrations/describe_image.py +370 -0
- synapse/integrations/http_clients.py +199 -0
- synapse/integrations/llm_openai_compat.py +90 -0
- synapse/integrations/llm_openai_websocket.py +187 -0
- synapse/integrations/mcp_client.py +646 -0
- synapse/integrations/vision_middleware.py +62 -0
- synapse/models/__init__.py +5 -0
- synapse/models/config.py +240 -0
- synapse/models/helpers.py +206 -0
- synapse/models/profile.py +59 -0
- synapse/models/registry.py +722 -0
- synapse/models_registry.py +7 -0
- synapse/observability/__init__.py +1 -0
- synapse/observability/startup_trace.py +127 -0
- synapse/runtime/__init__.py +1 -0
- synapse/runtime/async_runtime.py +176 -0
- synapse/runtime/backends.py +458 -0
- synapse/runtime/context_compact.py +249 -0
- synapse/runtime/execute_capture.py +48 -0
- synapse/runtime/fs_permissions.py +79 -0
- synapse/runtime/harness.py +57 -0
- synapse/runtime/hitl.py +197 -0
- synapse/runtime/interaction_ledger.py +82 -0
- synapse/runtime/middleware.py +802 -0
- synapse/runtime/model_request_compression_middleware.py +745 -0
- synapse/runtime/pathing.py +146 -0
- synapse/runtime/safety.py +184 -0
- synapse/runtime/steer.py +240 -0
- synapse/runtime/subagents.py +207 -0
- synapse/runtime/tool_ignore.py +221 -0
- synapse/runtime/tool_output_eval.py +118 -0
- synapse/runtime/tool_output_middleware.py +585 -0
- synapse/runtime/tool_output_usage_middleware.py +60 -0
- synapse/sessions/__init__.py +31 -0
- synapse/sessions/cancel_repair.py +208 -0
- synapse/sessions/session_recap.py +174 -0
- synapse/sessions/store.py +695 -0
- synapse/sessions/transcript.py +754 -0
- synapse/settings/__init__.py +5 -0
- synapse/settings/config_paths.py +184 -0
- synapse/settings/schema.py +464 -0
- synapse/tool_output/__init__.py +59 -0
- synapse/tool_output/detection.py +170 -0
- synapse/tool_output/metrics.py +32 -0
- synapse/tool_output/models.py +173 -0
- synapse/tool_output/pipeline.py +330 -0
- synapse/tool_output/repository.py +721 -0
- synapse/tool_output/transformers.py +648 -0
- synapse/tools/__init__.py +5 -0
- synapse/tools/session_tools.py +204 -0
- synapse/ui/__init__.py +10 -0
- synapse/ui/bottombar/__init__.py +73 -0
- synapse/ui/bottombar/components/__init__.py +143 -0
- synapse/ui/bottombar/components/key_hints.py +30 -0
- synapse/ui/bottombar/components/mcp.py +64 -0
- synapse/ui/bottombar/components/mode.py +24 -0
- synapse/ui/bottombar/components/model.py +28 -0
- synapse/ui/bottombar/components/thread.py +29 -0
- synapse/ui/bottombar/context.py +36 -0
- synapse/ui/bottombar/core.py +74 -0
- synapse/ui/dialogs/__init__.py +25 -0
- synapse/ui/dialogs/base.py +362 -0
- synapse/ui/dialogs/codex_session_list.py +84 -0
- synapse/ui/dialogs/compression_diagnostics.py +210 -0
- synapse/ui/dialogs/git_explore.py +702 -0
- synapse/ui/dialogs/mcp_panel.py +407 -0
- synapse/ui/dialogs/model_picker.py +128 -0
- synapse/ui/dialogs/safety_panel.py +63 -0
- synapse/ui/dialogs/session_list.py +98 -0
- synapse/ui/dialogs/theme_designer.py +863 -0
- synapse/ui/dialogs/theme_picker.py +113 -0
- synapse/ui/git_explore/__init__.py +31 -0
- synapse/ui/git_explore/engine.py +82 -0
- synapse/ui/git_explore/provider.py +242 -0
- synapse/ui/git_explore/unified.py +85 -0
- synapse/ui/rendering.py +350 -0
- synapse/ui/sink.py +70 -0
- synapse/ui/steer_widget.py +367 -0
- synapse/ui/stream.py +1207 -0
- synapse/ui/stream_events.py +421 -0
- synapse/ui/stream_runtime.py +252 -0
- synapse/ui/theme.py +1154 -0
- synapse/ui/timeline.py +621 -0
- synapse/ui/topbar/__init__.py +97 -0
- synapse/ui/topbar/components/__init__.py +150 -0
- synapse/ui/topbar/components/branch.py +41 -0
- synapse/ui/topbar/components/title.py +24 -0
- synapse/ui/topbar/components/tool_output.py +24 -0
- synapse/ui/topbar/components/usage.py +24 -0
- synapse/ui/topbar/components/workspace.py +32 -0
- synapse/ui/topbar/context.py +32 -0
- synapse/ui/topbar/core.py +979 -0
- synapse/ui/topbar/git_changes_popover.py +178 -0
- synapse/ui/topbar/git_chrome.py +475 -0
- synapse/ui/topbar/tool_output_popover.py +84 -0
- synapse/ui/topbar/widget.py +474 -0
- synapse/ui/tui.py +5717 -0
- synapse/ui/turn_rail.py +71 -0
- synapse/ui/user_turn.py +83 -0
- synapse/ui/welcome.py +261 -0
- synapse_cli_agent-0.1.13.dist-info/METADATA +412 -0
- synapse_cli_agent-0.1.13.dist-info/RECORD +131 -0
- synapse_cli_agent-0.1.13.dist-info/WHEEL +4 -0
- synapse_cli_agent-0.1.13.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
"""Git Explore dialog — file list + DiffView (opened from topbar branch)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import gc
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
from textual import work
|
|
12
|
+
from textual.app import ComposeResult
|
|
13
|
+
from textual.await_complete import AwaitComplete
|
|
14
|
+
from textual.binding import Binding
|
|
15
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
16
|
+
from textual.events import Click
|
|
17
|
+
from textual.message import Message
|
|
18
|
+
from textual.screen import ModalScreen
|
|
19
|
+
from textual.widgets import Static
|
|
20
|
+
|
|
21
|
+
from synapse.ui.git_explore import (
|
|
22
|
+
DIFF_MODES,
|
|
23
|
+
HAS_DIFF_VIEW,
|
|
24
|
+
DiffMode,
|
|
25
|
+
DiffPayload,
|
|
26
|
+
fallback_renderable,
|
|
27
|
+
load_file_diff,
|
|
28
|
+
make_diff_view,
|
|
29
|
+
)
|
|
30
|
+
from synapse.ui.topbar.git_chrome import (
|
|
31
|
+
GitChangedFile,
|
|
32
|
+
probe_git_changed_files,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _clear_textual_style_cache_refs() -> None:
|
|
37
|
+
"""Release detached widgets retained by Textual's instance-keyed style LRU."""
|
|
38
|
+
try:
|
|
39
|
+
from textual._styles_cache import StylesCache
|
|
40
|
+
|
|
41
|
+
cache_clear = getattr(StylesCache.get_inner_outer, "cache_clear", None)
|
|
42
|
+
if callable(cache_clear):
|
|
43
|
+
cache_clear()
|
|
44
|
+
except Exception: # noqa: BLE001
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ExploreFileRow(Static):
|
|
49
|
+
"""One selectable changed-file row (theme-safe styles for ANSI)."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, item: GitChangedFile, *, selected: bool = False) -> None:
|
|
52
|
+
self.item = item
|
|
53
|
+
self._selected = selected
|
|
54
|
+
# Build content before Static init so no active App is required.
|
|
55
|
+
super().__init__(self._build_text())
|
|
56
|
+
if selected:
|
|
57
|
+
self.add_class("-selected")
|
|
58
|
+
|
|
59
|
+
def set_selected(self, on: bool) -> None:
|
|
60
|
+
self._selected = on
|
|
61
|
+
if on:
|
|
62
|
+
self.add_class("-selected")
|
|
63
|
+
else:
|
|
64
|
+
self.remove_class("-selected")
|
|
65
|
+
try:
|
|
66
|
+
self.update(self._build_text())
|
|
67
|
+
except Exception: # noqa: BLE001
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
def _build_text(self) -> Text:
|
|
71
|
+
"""Render status + path + stats with named styles (ANSI-safe)."""
|
|
72
|
+
item = self.item
|
|
73
|
+
status = (item.status or "M")[:1]
|
|
74
|
+
path = (item.path or "").replace("\\", "/")
|
|
75
|
+
if len(path) > 34:
|
|
76
|
+
path = "…" + path[-33:]
|
|
77
|
+
|
|
78
|
+
if item.is_untracked or status == "?":
|
|
79
|
+
st_style = "dim"
|
|
80
|
+
elif status == "A":
|
|
81
|
+
st_style = "bold green"
|
|
82
|
+
elif status == "D":
|
|
83
|
+
st_style = "bold red"
|
|
84
|
+
else:
|
|
85
|
+
st_style = "bold yellow"
|
|
86
|
+
|
|
87
|
+
out = Text()
|
|
88
|
+
out.append(f"{status} ", style=st_style)
|
|
89
|
+
out.append(path, style="default")
|
|
90
|
+
if item.is_untracked:
|
|
91
|
+
out.append(" ?", style="dim")
|
|
92
|
+
elif item.lines_added or item.lines_deleted:
|
|
93
|
+
out.append(" ")
|
|
94
|
+
if item.lines_added:
|
|
95
|
+
out.append(f"+{item.lines_added}", style="green")
|
|
96
|
+
if item.lines_deleted:
|
|
97
|
+
if item.lines_added:
|
|
98
|
+
out.append(" ")
|
|
99
|
+
out.append(f"-{item.lines_deleted}", style="red")
|
|
100
|
+
elif status == "D":
|
|
101
|
+
out.append(" del", style="red")
|
|
102
|
+
elif status == "A":
|
|
103
|
+
out.append(" new", style="green")
|
|
104
|
+
return out
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class GitExploreScreen(ModalScreen[None]):
|
|
108
|
+
"""Near-full-screen git changes explorer."""
|
|
109
|
+
|
|
110
|
+
class OpenRequested(Message):
|
|
111
|
+
def __init__(self, path: str | None = None) -> None:
|
|
112
|
+
super().__init__()
|
|
113
|
+
self.path = path
|
|
114
|
+
|
|
115
|
+
DEFAULT_CSS = """
|
|
116
|
+
GitExploreScreen {
|
|
117
|
+
align: center middle;
|
|
118
|
+
background: $theme-bg 60%;
|
|
119
|
+
}
|
|
120
|
+
GitExploreScreen > #ge-window {
|
|
121
|
+
width: 96%;
|
|
122
|
+
height: 92%;
|
|
123
|
+
max-width: 160;
|
|
124
|
+
max-height: 48;
|
|
125
|
+
background: $theme-bg;
|
|
126
|
+
border: round $theme-user;
|
|
127
|
+
border-title-color: $theme-fg;
|
|
128
|
+
border-title-background: $theme-top;
|
|
129
|
+
border-title-style: bold;
|
|
130
|
+
border-title-align: left;
|
|
131
|
+
border-subtitle-color: $theme-muted;
|
|
132
|
+
border-subtitle-align: right;
|
|
133
|
+
layout: vertical;
|
|
134
|
+
padding: 0;
|
|
135
|
+
}
|
|
136
|
+
#ge-header {
|
|
137
|
+
height: 1;
|
|
138
|
+
width: 1fr;
|
|
139
|
+
padding: 0 1;
|
|
140
|
+
color: $theme-dim;
|
|
141
|
+
background: $theme-bar;
|
|
142
|
+
}
|
|
143
|
+
#ge-body {
|
|
144
|
+
height: 1fr;
|
|
145
|
+
width: 1fr;
|
|
146
|
+
layout: horizontal;
|
|
147
|
+
}
|
|
148
|
+
#ge-file-list {
|
|
149
|
+
width: 42;
|
|
150
|
+
min-width: 28;
|
|
151
|
+
max-width: 52;
|
|
152
|
+
height: 1fr;
|
|
153
|
+
border-right: solid $theme-border;
|
|
154
|
+
padding: 0 0;
|
|
155
|
+
overflow-y: auto;
|
|
156
|
+
overflow-x: hidden;
|
|
157
|
+
background: $theme-bg;
|
|
158
|
+
scrollbar-size: 1 1;
|
|
159
|
+
}
|
|
160
|
+
#ge-file-list ExploreFileRow {
|
|
161
|
+
height: 1;
|
|
162
|
+
width: 1fr;
|
|
163
|
+
padding: 0 1;
|
|
164
|
+
overflow: hidden;
|
|
165
|
+
text-overflow: ellipsis;
|
|
166
|
+
}
|
|
167
|
+
#ge-file-list ExploreFileRow.-selected {
|
|
168
|
+
background: $theme-bar;
|
|
169
|
+
text-style: bold;
|
|
170
|
+
}
|
|
171
|
+
#ge-diff-scroll {
|
|
172
|
+
width: 1fr;
|
|
173
|
+
height: 1fr;
|
|
174
|
+
padding: 0 0;
|
|
175
|
+
overflow-y: auto;
|
|
176
|
+
overflow-x: auto;
|
|
177
|
+
background: $theme-bg;
|
|
178
|
+
scrollbar-size: 1 1;
|
|
179
|
+
}
|
|
180
|
+
#ge-diff-scroll DiffView {
|
|
181
|
+
width: 1fr;
|
|
182
|
+
height: auto;
|
|
183
|
+
min-height: 1fr;
|
|
184
|
+
}
|
|
185
|
+
#ge-diff-body {
|
|
186
|
+
height: auto;
|
|
187
|
+
width: auto;
|
|
188
|
+
color: $theme-fg;
|
|
189
|
+
padding: 0 1;
|
|
190
|
+
}
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
BINDINGS = [
|
|
194
|
+
Binding("escape", "close", "Close", show=False, priority=True),
|
|
195
|
+
Binding("q", "close", "Close", show=False, priority=True),
|
|
196
|
+
Binding("up", "file_prev", "Prev", show=False, priority=True),
|
|
197
|
+
Binding("k", "file_prev", "Prev", show=False, priority=True),
|
|
198
|
+
Binding("down", "file_next", "Next", show=False, priority=True),
|
|
199
|
+
Binding("j", "file_next", "Next", show=False, priority=True),
|
|
200
|
+
Binding("left", "mode_prev", "Mode-", show=False, priority=True),
|
|
201
|
+
Binding("right", "mode_next", "Mode+", show=False, priority=True),
|
|
202
|
+
Binding("1", "mode_working", "Working", show=False, priority=True),
|
|
203
|
+
Binding("2", "mode_staged", "Staged", show=False, priority=True),
|
|
204
|
+
Binding("3", "mode_unstaged", "Unstaged", show=False, priority=True),
|
|
205
|
+
Binding("r", "reload", "Reload", show=False, priority=True),
|
|
206
|
+
Binding("u", "toggle_split", "Split", show=False, priority=True),
|
|
207
|
+
Binding("space", "toggle_split", "Split", show=False, priority=True),
|
|
208
|
+
Binding("a", "toggle_annotations", "Annot", show=False, priority=True),
|
|
209
|
+
Binding("pageup", "diff_page_up", "PgUp", show=False, priority=True),
|
|
210
|
+
Binding("pagedown", "diff_page_down", "PgDn", show=False, priority=True),
|
|
211
|
+
]
|
|
212
|
+
|
|
213
|
+
def __init__(
|
|
214
|
+
self,
|
|
215
|
+
workspace: Path | str,
|
|
216
|
+
*,
|
|
217
|
+
initial_path: str | None = None,
|
|
218
|
+
branch_label: str | None = None,
|
|
219
|
+
colors: dict[str, str] | None = None,
|
|
220
|
+
split: bool = True,
|
|
221
|
+
annotations: bool = True,
|
|
222
|
+
) -> None:
|
|
223
|
+
super().__init__()
|
|
224
|
+
self._workspace = Path(workspace)
|
|
225
|
+
self._initial_path = (initial_path or "").strip() or None
|
|
226
|
+
self._branch_label = (branch_label or "").strip()
|
|
227
|
+
self._colors = colors or {}
|
|
228
|
+
self._mode: DiffMode = "working"
|
|
229
|
+
self._files: list[GitChangedFile] = []
|
|
230
|
+
self._selected_idx: int = 0
|
|
231
|
+
self._load_token: int = 0
|
|
232
|
+
self._split = bool(split)
|
|
233
|
+
self._annotations = bool(annotations)
|
|
234
|
+
self._last_payload: DiffPayload | None = None
|
|
235
|
+
self._closing_diff_widgets: list[Any] = []
|
|
236
|
+
# Keep this distinct from Textual MessagePump._closed.
|
|
237
|
+
self._dismiss_started: bool = False
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def title_text(self) -> str:
|
|
241
|
+
branch = self._branch_label or "git"
|
|
242
|
+
n = len(self._files)
|
|
243
|
+
return f"Git Explore · {branch} · {n} file{'s' if n != 1 else ''}"
|
|
244
|
+
|
|
245
|
+
def compose(self) -> ComposeResult:
|
|
246
|
+
with Vertical(id="ge-window"):
|
|
247
|
+
yield Static("", id="ge-header")
|
|
248
|
+
with Horizontal(id="ge-body"):
|
|
249
|
+
yield VerticalScroll(id="ge-file-list")
|
|
250
|
+
with VerticalScroll(id="ge-diff-scroll"):
|
|
251
|
+
yield Static("select a file", id="ge-diff-body")
|
|
252
|
+
|
|
253
|
+
def on_mount(self) -> None:
|
|
254
|
+
win = self.query_one("#ge-window")
|
|
255
|
+
win.border_title = f"◆ {self.title_text}"
|
|
256
|
+
win.border_subtitle = (
|
|
257
|
+
"j/k files · ←/→ mode · u split/unified · a ann · r reload · esc"
|
|
258
|
+
)
|
|
259
|
+
self._reload_files()
|
|
260
|
+
self.set_focus(self.query_one("#ge-file-list"))
|
|
261
|
+
|
|
262
|
+
def _mode_tabs(self) -> str:
|
|
263
|
+
parts: list[str] = []
|
|
264
|
+
for m in DIFF_MODES:
|
|
265
|
+
if m == self._mode:
|
|
266
|
+
parts.append(f"[{m}]")
|
|
267
|
+
else:
|
|
268
|
+
parts.append(f" {m} ")
|
|
269
|
+
return " ".join(parts)
|
|
270
|
+
|
|
271
|
+
def _view_flags_text(self) -> Text:
|
|
272
|
+
out = Text()
|
|
273
|
+
if self._split:
|
|
274
|
+
out.append("[split]", style="bold cyan")
|
|
275
|
+
out.append(" unified", style="dim")
|
|
276
|
+
else:
|
|
277
|
+
out.append(" split", style="dim")
|
|
278
|
+
out.append("[unified]", style="bold cyan")
|
|
279
|
+
out.append(" ")
|
|
280
|
+
if self._annotations:
|
|
281
|
+
out.append("[ann]", style="bold")
|
|
282
|
+
else:
|
|
283
|
+
out.append("ann", style="dim")
|
|
284
|
+
engine = "diff-view" if HAS_DIFF_VIEW else "fallback"
|
|
285
|
+
out.append(f" · {engine}", style="dim")
|
|
286
|
+
return out
|
|
287
|
+
|
|
288
|
+
def _paint_header(self) -> None:
|
|
289
|
+
header = self.query_one("#ge-header", Static)
|
|
290
|
+
n = len(self._files)
|
|
291
|
+
line = Text()
|
|
292
|
+
line.append(self._mode_tabs())
|
|
293
|
+
line.append(" · ")
|
|
294
|
+
if n == 0:
|
|
295
|
+
line.append("working tree clean", style="dim")
|
|
296
|
+
else:
|
|
297
|
+
item = self._files[self._selected_idx] if self._files else None
|
|
298
|
+
focus = item.path if item else ""
|
|
299
|
+
line.append(f"{n} changed")
|
|
300
|
+
if focus:
|
|
301
|
+
line.append(" · ")
|
|
302
|
+
line.append(focus, style="bold")
|
|
303
|
+
line.append(" · ")
|
|
304
|
+
line.append_text(self._view_flags_text())
|
|
305
|
+
header.update(line)
|
|
306
|
+
|
|
307
|
+
def _reload_files(self) -> None:
|
|
308
|
+
self._files = probe_git_changed_files(self._workspace, limit=200)
|
|
309
|
+
if self._initial_path:
|
|
310
|
+
for i, f in enumerate(self._files):
|
|
311
|
+
if f.path == self._initial_path:
|
|
312
|
+
self._selected_idx = i
|
|
313
|
+
break
|
|
314
|
+
else:
|
|
315
|
+
self._selected_idx = 0
|
|
316
|
+
self._initial_path = None
|
|
317
|
+
else:
|
|
318
|
+
self._selected_idx = min(self._selected_idx, max(0, len(self._files) - 1))
|
|
319
|
+
self._mount_file_rows()
|
|
320
|
+
self._paint_header()
|
|
321
|
+
self._update_title()
|
|
322
|
+
if self._files:
|
|
323
|
+
self._request_diff()
|
|
324
|
+
else:
|
|
325
|
+
self._last_payload = None
|
|
326
|
+
self._show_fallback(Text("working tree clean", style="dim"))
|
|
327
|
+
|
|
328
|
+
def _update_title(self) -> None:
|
|
329
|
+
try:
|
|
330
|
+
win = self.query_one("#ge-window")
|
|
331
|
+
win.border_title = f"◆ {self.title_text}"
|
|
332
|
+
except Exception: # noqa: BLE001
|
|
333
|
+
pass
|
|
334
|
+
|
|
335
|
+
def _alive(self) -> bool:
|
|
336
|
+
"""False after dismiss/unmount starts; blocks late mounts/workers."""
|
|
337
|
+
if self._dismiss_started:
|
|
338
|
+
return False
|
|
339
|
+
try:
|
|
340
|
+
return bool(self.is_attached)
|
|
341
|
+
except Exception: # noqa: BLE001
|
|
342
|
+
return False
|
|
343
|
+
|
|
344
|
+
def _release_heavy_state(self) -> None:
|
|
345
|
+
"""Drop file texts / lists held on the screen instance."""
|
|
346
|
+
self._last_payload = None
|
|
347
|
+
self._files = []
|
|
348
|
+
self._selected_idx = 0
|
|
349
|
+
self._load_token += 1 # invalidate in-flight apply callbacks
|
|
350
|
+
|
|
351
|
+
def _clear_diff_view_caches(self, widget: Any) -> None:
|
|
352
|
+
"""Best-effort wipe of an unmounted DiffView's large code caches."""
|
|
353
|
+
if widget is None:
|
|
354
|
+
return
|
|
355
|
+
for attr, empty in (
|
|
356
|
+
("code_original", ""),
|
|
357
|
+
("code_modified", ""),
|
|
358
|
+
("_highlighted_code_lines", None),
|
|
359
|
+
("_grouped_opcodes", None),
|
|
360
|
+
):
|
|
361
|
+
try:
|
|
362
|
+
if hasattr(widget, attr):
|
|
363
|
+
setattr(widget, attr, empty)
|
|
364
|
+
except Exception: # noqa: BLE001
|
|
365
|
+
pass
|
|
366
|
+
for attr in (
|
|
367
|
+
"_number_styles",
|
|
368
|
+
"_annotation_styles",
|
|
369
|
+
"_line_styles",
|
|
370
|
+
"_edge_styles",
|
|
371
|
+
):
|
|
372
|
+
try:
|
|
373
|
+
bucket = getattr(widget, attr, None)
|
|
374
|
+
if isinstance(bucket, dict):
|
|
375
|
+
bucket.clear()
|
|
376
|
+
except Exception: # noqa: BLE001
|
|
377
|
+
pass
|
|
378
|
+
|
|
379
|
+
def _begin_close(self) -> None:
|
|
380
|
+
"""Invalidate late work and release payloads before Textual removes the screen."""
|
|
381
|
+
if self._dismiss_started:
|
|
382
|
+
return
|
|
383
|
+
try:
|
|
384
|
+
self._closing_diff_widgets = list(self._diff_host().children)
|
|
385
|
+
except Exception: # noqa: BLE001
|
|
386
|
+
self._closing_diff_widgets = []
|
|
387
|
+
self._dismiss_started = True
|
|
388
|
+
self._release_heavy_state()
|
|
389
|
+
|
|
390
|
+
def dismiss(self, result: Any = None) -> AwaitComplete:
|
|
391
|
+
# Double-click / repeated outside clicks can re-enter dismiss after the
|
|
392
|
+
# screen is already gone; Textual then raises ScreenStackError.
|
|
393
|
+
if self._dismiss_started or not self.is_attached:
|
|
394
|
+
return AwaitComplete()
|
|
395
|
+
try:
|
|
396
|
+
if self.app.screen is not self:
|
|
397
|
+
self._begin_close()
|
|
398
|
+
return AwaitComplete()
|
|
399
|
+
except Exception: # noqa: BLE001
|
|
400
|
+
self._begin_close()
|
|
401
|
+
return AwaitComplete()
|
|
402
|
+
self._begin_close()
|
|
403
|
+
return super().dismiss(result)
|
|
404
|
+
|
|
405
|
+
def on_unmount(self) -> None:
|
|
406
|
+
"""Final safety net when the screen leaves the stack."""
|
|
407
|
+
self._begin_close()
|
|
408
|
+
for widget in self._closing_diff_widgets:
|
|
409
|
+
self._clear_diff_view_caches(widget)
|
|
410
|
+
self._closing_diff_widgets.clear()
|
|
411
|
+
_clear_textual_style_cache_refs()
|
|
412
|
+
try:
|
|
413
|
+
asyncio.get_running_loop().call_soon(gc.collect)
|
|
414
|
+
except RuntimeError:
|
|
415
|
+
gc.collect()
|
|
416
|
+
|
|
417
|
+
def _mount_file_rows(self) -> None:
|
|
418
|
+
if not self._alive():
|
|
419
|
+
return
|
|
420
|
+
self.run_worker(
|
|
421
|
+
self._replace_file_rows(),
|
|
422
|
+
exclusive=True,
|
|
423
|
+
group="ge-file-rows",
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
async def _replace_file_rows(self) -> None:
|
|
427
|
+
if not self._alive():
|
|
428
|
+
return
|
|
429
|
+
panel = self.query_one("#ge-file-list", VerticalScroll)
|
|
430
|
+
await panel.remove_children()
|
|
431
|
+
_clear_textual_style_cache_refs()
|
|
432
|
+
if not self._alive():
|
|
433
|
+
return
|
|
434
|
+
if not self._files:
|
|
435
|
+
await panel.mount(Static(Text("no changes", style="dim")))
|
|
436
|
+
return
|
|
437
|
+
rows = [
|
|
438
|
+
ExploreFileRow(item, selected=(i == self._selected_idx))
|
|
439
|
+
for i, item in enumerate(self._files)
|
|
440
|
+
]
|
|
441
|
+
if rows and self._alive():
|
|
442
|
+
await panel.mount_all(rows)
|
|
443
|
+
|
|
444
|
+
def _sync_selection(self) -> None:
|
|
445
|
+
if not self._alive():
|
|
446
|
+
return
|
|
447
|
+
panel = self.query_one("#ge-file-list", VerticalScroll)
|
|
448
|
+
rows = list(panel.query(ExploreFileRow))
|
|
449
|
+
for i, row in enumerate(rows):
|
|
450
|
+
row.set_selected(i == self._selected_idx)
|
|
451
|
+
if 0 <= self._selected_idx < len(rows):
|
|
452
|
+
try:
|
|
453
|
+
panel.scroll_to_widget(rows[self._selected_idx], animate=False)
|
|
454
|
+
except Exception: # noqa: BLE001
|
|
455
|
+
pass
|
|
456
|
+
self._paint_header()
|
|
457
|
+
self._request_diff()
|
|
458
|
+
|
|
459
|
+
def _diff_host(self) -> VerticalScroll:
|
|
460
|
+
return self.query_one("#ge-diff-scroll", VerticalScroll)
|
|
461
|
+
|
|
462
|
+
async def _replace_diff_children(self, *widgets: Any) -> None:
|
|
463
|
+
if not self._alive():
|
|
464
|
+
for widget in widgets:
|
|
465
|
+
self._clear_diff_view_caches(widget)
|
|
466
|
+
return
|
|
467
|
+
host = self._diff_host()
|
|
468
|
+
outgoing = list(host.children)
|
|
469
|
+
await host.remove_children()
|
|
470
|
+
|
|
471
|
+
# Textual 8.2.8 retains each Widget StylesCache as an lru_cache key.
|
|
472
|
+
# Once the old tree is detached, release its DiffView data and LRU keys.
|
|
473
|
+
for child in outgoing:
|
|
474
|
+
self._clear_diff_view_caches(child)
|
|
475
|
+
_clear_textual_style_cache_refs()
|
|
476
|
+
|
|
477
|
+
if not self._alive():
|
|
478
|
+
for widget in widgets:
|
|
479
|
+
self._clear_diff_view_caches(widget)
|
|
480
|
+
return
|
|
481
|
+
if widgets:
|
|
482
|
+
await host.mount(*widgets)
|
|
483
|
+
try:
|
|
484
|
+
host.scroll_home(animate=False)
|
|
485
|
+
except Exception: # noqa: BLE001
|
|
486
|
+
pass
|
|
487
|
+
|
|
488
|
+
def _show_fallback(self, content: Any) -> None:
|
|
489
|
+
if not self._alive():
|
|
490
|
+
return
|
|
491
|
+
host = self._diff_host()
|
|
492
|
+
try:
|
|
493
|
+
body = host.query_one("#ge-diff-body", Static)
|
|
494
|
+
except Exception: # noqa: BLE001
|
|
495
|
+
body = None
|
|
496
|
+
if body is not None and len(host.children) == 1:
|
|
497
|
+
body.update(content)
|
|
498
|
+
try:
|
|
499
|
+
host.scroll_home(animate=False)
|
|
500
|
+
except Exception: # noqa: BLE001
|
|
501
|
+
pass
|
|
502
|
+
return
|
|
503
|
+
self.run_worker(
|
|
504
|
+
self._replace_diff_children(Static(content, id="ge-diff-body")),
|
|
505
|
+
exclusive=True,
|
|
506
|
+
group="ge-diff-mount",
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
def _show_diff_view(self, view: Any) -> None:
|
|
510
|
+
if not self._alive():
|
|
511
|
+
self._clear_diff_view_caches(view)
|
|
512
|
+
return
|
|
513
|
+
self.run_worker(
|
|
514
|
+
self._replace_diff_children(view),
|
|
515
|
+
exclusive=True,
|
|
516
|
+
group="ge-diff-mount",
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
def _mount_payload(self, payload: DiffPayload) -> None:
|
|
520
|
+
if not self._alive():
|
|
521
|
+
return
|
|
522
|
+
# Keep only the active file payload; previous one is replaced (not stacked).
|
|
523
|
+
self._last_payload = payload
|
|
524
|
+
if payload.error or payload.binary:
|
|
525
|
+
self._show_fallback(fallback_renderable(payload, colors=self._colors))
|
|
526
|
+
return
|
|
527
|
+
view = make_diff_view(
|
|
528
|
+
payload,
|
|
529
|
+
split=self._split,
|
|
530
|
+
annotations=self._annotations,
|
|
531
|
+
colors=self._colors,
|
|
532
|
+
)
|
|
533
|
+
if view is None:
|
|
534
|
+
self._show_fallback(fallback_renderable(payload, colors=self._colors))
|
|
535
|
+
return
|
|
536
|
+
self._show_diff_view(view)
|
|
537
|
+
|
|
538
|
+
def _request_diff(self) -> None:
|
|
539
|
+
if not self._alive() or not self._files:
|
|
540
|
+
return
|
|
541
|
+
idx = self._selected_idx
|
|
542
|
+
if not (0 <= idx < len(self._files)):
|
|
543
|
+
return
|
|
544
|
+
item = self._files[idx]
|
|
545
|
+
self._load_token += 1
|
|
546
|
+
token = self._load_token
|
|
547
|
+
# Drop previous payload early when switching files to free memory sooner.
|
|
548
|
+
self._last_payload = None
|
|
549
|
+
self._show_fallback(Text(f"loading {item.path}…", style="dim"))
|
|
550
|
+
self._load_diff_worker(
|
|
551
|
+
token,
|
|
552
|
+
item.path,
|
|
553
|
+
item.is_untracked,
|
|
554
|
+
self._mode,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
@work(thread=True, exclusive=True, group="git-explore-diff")
|
|
558
|
+
def _load_diff_worker(
|
|
559
|
+
self,
|
|
560
|
+
token: int,
|
|
561
|
+
path: str,
|
|
562
|
+
is_untracked: bool,
|
|
563
|
+
mode: DiffMode,
|
|
564
|
+
) -> None:
|
|
565
|
+
if self._dismiss_started or token != self._load_token:
|
|
566
|
+
return
|
|
567
|
+
try:
|
|
568
|
+
payload = load_file_diff(
|
|
569
|
+
self._workspace,
|
|
570
|
+
path,
|
|
571
|
+
mode=mode,
|
|
572
|
+
is_untracked=is_untracked,
|
|
573
|
+
)
|
|
574
|
+
except Exception as exc: # noqa: BLE001
|
|
575
|
+
payload = DiffPayload(
|
|
576
|
+
path=path,
|
|
577
|
+
text_a="",
|
|
578
|
+
text_b="",
|
|
579
|
+
mode=mode,
|
|
580
|
+
error=f"diff failed: {exc}",
|
|
581
|
+
)
|
|
582
|
+
if self._dismiss_started or token != self._load_token:
|
|
583
|
+
return
|
|
584
|
+
try:
|
|
585
|
+
self.app.call_from_thread(self._apply_payload, token, payload)
|
|
586
|
+
except Exception: # noqa: BLE001
|
|
587
|
+
# App/screen already gone — drop payload reference.
|
|
588
|
+
del payload
|
|
589
|
+
|
|
590
|
+
def _apply_payload(self, token: int, payload: DiffPayload) -> None:
|
|
591
|
+
if self._dismiss_started or token != self._load_token or not self._alive():
|
|
592
|
+
return
|
|
593
|
+
try:
|
|
594
|
+
self._mount_payload(payload)
|
|
595
|
+
except Exception as exc: # noqa: BLE001
|
|
596
|
+
if not self._alive():
|
|
597
|
+
return
|
|
598
|
+
self._show_fallback(
|
|
599
|
+
Text(
|
|
600
|
+
f"diff failed: {exc}",
|
|
601
|
+
style=self._colors.get("orange", "#f4b183"),
|
|
602
|
+
)
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
def _set_mode(self, mode: DiffMode) -> None:
|
|
606
|
+
if not self._alive() or mode == self._mode:
|
|
607
|
+
return
|
|
608
|
+
self._mode = mode
|
|
609
|
+
self._paint_header()
|
|
610
|
+
self._request_diff()
|
|
611
|
+
|
|
612
|
+
def _remount_last_payload(self) -> None:
|
|
613
|
+
if not self._alive():
|
|
614
|
+
return
|
|
615
|
+
if self._last_payload is None:
|
|
616
|
+
self._request_diff()
|
|
617
|
+
return
|
|
618
|
+
self._paint_header()
|
|
619
|
+
self._mount_payload(self._last_payload)
|
|
620
|
+
|
|
621
|
+
def action_close(self) -> None:
|
|
622
|
+
self.dismiss(None)
|
|
623
|
+
|
|
624
|
+
def action_file_prev(self) -> None:
|
|
625
|
+
if not self._files:
|
|
626
|
+
return
|
|
627
|
+
if self._selected_idx > 0:
|
|
628
|
+
self._selected_idx -= 1
|
|
629
|
+
self._sync_selection()
|
|
630
|
+
|
|
631
|
+
def action_file_next(self) -> None:
|
|
632
|
+
if not self._files:
|
|
633
|
+
return
|
|
634
|
+
if self._selected_idx < len(self._files) - 1:
|
|
635
|
+
self._selected_idx += 1
|
|
636
|
+
self._sync_selection()
|
|
637
|
+
|
|
638
|
+
def action_mode_prev(self) -> None:
|
|
639
|
+
i = DIFF_MODES.index(self._mode)
|
|
640
|
+
self._set_mode(DIFF_MODES[(i - 1) % len(DIFF_MODES)])
|
|
641
|
+
|
|
642
|
+
def action_mode_next(self) -> None:
|
|
643
|
+
i = DIFF_MODES.index(self._mode)
|
|
644
|
+
self._set_mode(DIFF_MODES[(i + 1) % len(DIFF_MODES)])
|
|
645
|
+
|
|
646
|
+
def action_mode_working(self) -> None:
|
|
647
|
+
self._set_mode("working")
|
|
648
|
+
|
|
649
|
+
def action_mode_staged(self) -> None:
|
|
650
|
+
self._set_mode("staged")
|
|
651
|
+
|
|
652
|
+
def action_mode_unstaged(self) -> None:
|
|
653
|
+
self._set_mode("unstaged")
|
|
654
|
+
|
|
655
|
+
def action_reload(self) -> None:
|
|
656
|
+
self._reload_files()
|
|
657
|
+
|
|
658
|
+
def action_toggle_split(self) -> None:
|
|
659
|
+
self._split = not self._split
|
|
660
|
+
self._remount_last_payload()
|
|
661
|
+
|
|
662
|
+
def action_toggle_annotations(self) -> None:
|
|
663
|
+
self._annotations = not self._annotations
|
|
664
|
+
self._remount_last_payload()
|
|
665
|
+
|
|
666
|
+
def action_diff_page_up(self) -> None:
|
|
667
|
+
try:
|
|
668
|
+
self._diff_host().scroll_page_up(animate=False)
|
|
669
|
+
except Exception: # noqa: BLE001
|
|
670
|
+
pass
|
|
671
|
+
|
|
672
|
+
def action_diff_page_down(self) -> None:
|
|
673
|
+
try:
|
|
674
|
+
self._diff_host().scroll_page_down(animate=False)
|
|
675
|
+
except Exception: # noqa: BLE001
|
|
676
|
+
pass
|
|
677
|
+
|
|
678
|
+
def on_click(self, event: Click) -> None:
|
|
679
|
+
widget = event.widget
|
|
680
|
+
if isinstance(widget, ExploreFileRow):
|
|
681
|
+
panel = self.query_one("#ge-file-list", VerticalScroll)
|
|
682
|
+
rows = list(panel.query(ExploreFileRow))
|
|
683
|
+
try:
|
|
684
|
+
idx = rows.index(widget)
|
|
685
|
+
except ValueError:
|
|
686
|
+
return
|
|
687
|
+
if idx != self._selected_idx:
|
|
688
|
+
self._selected_idx = idx
|
|
689
|
+
self._sync_selection()
|
|
690
|
+
event.stop()
|
|
691
|
+
return
|
|
692
|
+
try:
|
|
693
|
+
win = self.query_one("#ge-window")
|
|
694
|
+
except Exception: # noqa: BLE001
|
|
695
|
+
return
|
|
696
|
+
node = widget
|
|
697
|
+
while node is not None:
|
|
698
|
+
if node is win:
|
|
699
|
+
return
|
|
700
|
+
node = getattr(node, "parent", None)
|
|
701
|
+
if widget is self:
|
|
702
|
+
self.dismiss(None)
|