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,113 @@
|
|
|
1
|
+
"""Theme picker dialog — invoked by /theme."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
|
|
9
|
+
from synapse.ui.dialogs.base import DialogBase, OptionItem, SectionHeader
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ThemePickerDialog(DialogBase):
|
|
13
|
+
"""Browse built-in + custom themes with live preview."""
|
|
14
|
+
|
|
15
|
+
_title_icon = "◈"
|
|
16
|
+
|
|
17
|
+
def __init__(self, settings: Any, project_root: Any = None) -> None:
|
|
18
|
+
super().__init__()
|
|
19
|
+
self._settings = settings
|
|
20
|
+
self._project_root = project_root
|
|
21
|
+
try:
|
|
22
|
+
from synapse.ui.theme import (
|
|
23
|
+
get_theme,
|
|
24
|
+
list_themes,
|
|
25
|
+
reload_theme_catalog,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
reload_theme_catalog(project_root)
|
|
29
|
+
self._themes = list_themes()
|
|
30
|
+
self._current = (getattr(settings, "theme", None) or get_theme().name)
|
|
31
|
+
except Exception: # noqa: BLE001
|
|
32
|
+
self._themes = []
|
|
33
|
+
self._current = "cursor-dark"
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def title_text(self) -> str:
|
|
37
|
+
return "Select Theme"
|
|
38
|
+
|
|
39
|
+
def compose_body(self) -> ComposeResult:
|
|
40
|
+
yield SectionHeader("Themes")
|
|
41
|
+
items: list[OptionItem] = []
|
|
42
|
+
for t in self._themes:
|
|
43
|
+
kind = _theme_meta(t)
|
|
44
|
+
items.append(
|
|
45
|
+
OptionItem(
|
|
46
|
+
key=t.name,
|
|
47
|
+
label=f"{t.name:22} {t.label}",
|
|
48
|
+
detail="",
|
|
49
|
+
selected=(t.name == self._current),
|
|
50
|
+
meta=kind,
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
self._items = items
|
|
54
|
+
|
|
55
|
+
def on_mount(self) -> None:
|
|
56
|
+
super().on_mount()
|
|
57
|
+
body = self.query_one("#dialog-body")
|
|
58
|
+
body.set_options(self._items, mark=" ")
|
|
59
|
+
# Apply the selected theme on mount as a preview.
|
|
60
|
+
if self._themes:
|
|
61
|
+
cur = self._current or self._themes[0].name
|
|
62
|
+
self.on_option_row_clicked(cur)
|
|
63
|
+
|
|
64
|
+
def _on_selected(self, key: str | None) -> None:
|
|
65
|
+
if key:
|
|
66
|
+
self.dismiss(("theme", key))
|
|
67
|
+
else:
|
|
68
|
+
self.dismiss(None)
|
|
69
|
+
|
|
70
|
+
def on_option_row_clicked(self, key: str) -> None:
|
|
71
|
+
"""Preview on hover / click before applying."""
|
|
72
|
+
try:
|
|
73
|
+
from synapse.ui.theme import set_theme
|
|
74
|
+
except Exception: # noqa: BLE001
|
|
75
|
+
return
|
|
76
|
+
# Apply preview only (no persist).
|
|
77
|
+
try:
|
|
78
|
+
set_theme(key, workspace=self._project_root, persist=False)
|
|
79
|
+
# Notify app to refresh CSS.
|
|
80
|
+
app = self.app
|
|
81
|
+
if hasattr(app, "apply_theme"):
|
|
82
|
+
app.apply_theme(key, persist=False, announce=False) # type: ignore[union-attr]
|
|
83
|
+
except Exception: # noqa: BLE001
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _theme_meta(theme: object) -> str:
|
|
88
|
+
"""Right-side hint: ansi | light | dark."""
|
|
89
|
+
try:
|
|
90
|
+
from synapse.ui.theme import theme_kind
|
|
91
|
+
|
|
92
|
+
return theme_kind(theme) # type: ignore[arg-type]
|
|
93
|
+
except Exception: # noqa: BLE001
|
|
94
|
+
pass
|
|
95
|
+
if bool(getattr(theme, "ansi", False)):
|
|
96
|
+
return "ansi"
|
|
97
|
+
bg = str(getattr(theme, "bg", "") or "")
|
|
98
|
+
if bg.strip().casefold() in {"transparent", "ansi_default", "default"}:
|
|
99
|
+
return "ansi"
|
|
100
|
+
return "light" if _is_light(bg) else "dark"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _is_light(hex_color: str) -> bool:
|
|
104
|
+
"""Rough luminance check: light bg > 0.5."""
|
|
105
|
+
c = hex_color.lstrip("#")
|
|
106
|
+
if len(c) < 6:
|
|
107
|
+
return False
|
|
108
|
+
try:
|
|
109
|
+
r, g, b = int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16)
|
|
110
|
+
except ValueError:
|
|
111
|
+
return False
|
|
112
|
+
lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255.0
|
|
113
|
+
return lum > 0.5
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Git explore: file list + textual-diff-view (Rich fallback)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from synapse.ui.git_explore.engine import (
|
|
6
|
+
HAS_DIFF_VIEW,
|
|
7
|
+
fallback_renderable,
|
|
8
|
+
make_diff_view,
|
|
9
|
+
status_line,
|
|
10
|
+
)
|
|
11
|
+
from synapse.ui.git_explore.provider import (
|
|
12
|
+
DIFF_MODES,
|
|
13
|
+
DiffMode,
|
|
14
|
+
DiffPayload,
|
|
15
|
+
language_hint_for_path,
|
|
16
|
+
load_file_diff,
|
|
17
|
+
)
|
|
18
|
+
from synapse.ui.git_explore.unified import render_unified_diff
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"DIFF_MODES",
|
|
22
|
+
"DiffMode",
|
|
23
|
+
"DiffPayload",
|
|
24
|
+
"HAS_DIFF_VIEW",
|
|
25
|
+
"fallback_renderable",
|
|
26
|
+
"language_hint_for_path",
|
|
27
|
+
"load_file_diff",
|
|
28
|
+
"make_diff_view",
|
|
29
|
+
"render_unified_diff",
|
|
30
|
+
"status_line",
|
|
31
|
+
]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Diff engine: textual-diff-view primary, Rich unified fallback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
|
|
9
|
+
from synapse.ui.git_explore.provider import DiffPayload
|
|
10
|
+
from synapse.ui.git_explore.unified import render_unified_diff
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from textual_diff_view import DiffView as _DiffView
|
|
14
|
+
|
|
15
|
+
HAS_DIFF_VIEW = True
|
|
16
|
+
except Exception: # noqa: BLE001
|
|
17
|
+
_DiffView = None # type: ignore[assignment,misc]
|
|
18
|
+
HAS_DIFF_VIEW = False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def make_diff_view(
|
|
22
|
+
payload: DiffPayload,
|
|
23
|
+
*,
|
|
24
|
+
split: bool = True,
|
|
25
|
+
annotations: bool = True,
|
|
26
|
+
colors: dict[str, str] | None = None,
|
|
27
|
+
widget_id: str = "ge-diff-view",
|
|
28
|
+
) -> Any:
|
|
29
|
+
"""Build a mountable widget for ``payload``.
|
|
30
|
+
|
|
31
|
+
Prefer ``textual_diff_view.DiffView`` when available and content is text.
|
|
32
|
+
Otherwise return a Rich-backed ``Static``-ready renderable wrapped as payload
|
|
33
|
+
metadata for the caller to place in a Static (or a prebuilt Static).
|
|
34
|
+
"""
|
|
35
|
+
colors = colors or {}
|
|
36
|
+
if payload.error or payload.binary or not HAS_DIFF_VIEW or _DiffView is None:
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
# Paths are labels only; content comes from strings.
|
|
40
|
+
path_a = f"a/{payload.path}"
|
|
41
|
+
path_b = f"b/{payload.path}"
|
|
42
|
+
if payload.mode == "staged":
|
|
43
|
+
path_a = f"HEAD/{payload.path}"
|
|
44
|
+
path_b = f"index/{payload.path}"
|
|
45
|
+
elif payload.mode == "unstaged":
|
|
46
|
+
path_a = f"index/{payload.path}"
|
|
47
|
+
path_b = f"worktree/{payload.path}"
|
|
48
|
+
else:
|
|
49
|
+
path_a = f"HEAD/{payload.path}"
|
|
50
|
+
path_b = f"worktree/{payload.path}"
|
|
51
|
+
|
|
52
|
+
return _DiffView(
|
|
53
|
+
path_a,
|
|
54
|
+
path_b,
|
|
55
|
+
payload.text_a or "",
|
|
56
|
+
payload.text_b or "",
|
|
57
|
+
split=bool(split),
|
|
58
|
+
annotations=bool(annotations),
|
|
59
|
+
auto_split=False,
|
|
60
|
+
wrap=False,
|
|
61
|
+
id=widget_id,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def fallback_renderable(payload: DiffPayload, *, colors: dict[str, str] | None = None) -> Any:
|
|
66
|
+
"""Rich Group/Text for cases where DiffView is unavailable or unsuitable."""
|
|
67
|
+
colors = colors or {}
|
|
68
|
+
return render_unified_diff(
|
|
69
|
+
payload,
|
|
70
|
+
color_meta=colors.get("dim", "#9aa0a6"),
|
|
71
|
+
color_hunk=colors.get("hunk", "#8ab4f8"),
|
|
72
|
+
color_add=colors.get("added", "#81c995"),
|
|
73
|
+
color_del=colors.get("deleted", "#f28b82"),
|
|
74
|
+
color_ctx=colors.get("fg", "#e8eaed"),
|
|
75
|
+
color_warn=colors.get("orange", "#f4b183"),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def status_line(*, split: bool, annotations: bool, engine: str) -> Text:
|
|
80
|
+
layout = "split" if split else "unified"
|
|
81
|
+
ann = "ann:on" if annotations else "ann:off"
|
|
82
|
+
return Text(f"{layout} · {ann} · {engine}", style="dim")
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Load old/new text for a changed file (working / staged / unstaged)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
DiffMode = Literal["working", "staged", "unstaged"]
|
|
11
|
+
DIFF_MODES: tuple[DiffMode, ...] = ("working", "staged", "unstaged")
|
|
12
|
+
|
|
13
|
+
# Soft limits for TUI readability (bytes / lines of each side).
|
|
14
|
+
_MAX_BYTES = 512 * 1024
|
|
15
|
+
_MAX_LINES = 8_000
|
|
16
|
+
|
|
17
|
+
_EXT_LANG: dict[str, str] = {
|
|
18
|
+
".py": "python",
|
|
19
|
+
".pyi": "python",
|
|
20
|
+
".js": "javascript",
|
|
21
|
+
".jsx": "javascript",
|
|
22
|
+
".ts": "typescript",
|
|
23
|
+
".tsx": "typescript",
|
|
24
|
+
".json": "json",
|
|
25
|
+
".md": "markdown",
|
|
26
|
+
".toml": "toml",
|
|
27
|
+
".yaml": "yaml",
|
|
28
|
+
".yml": "yaml",
|
|
29
|
+
".rs": "rust",
|
|
30
|
+
".go": "go",
|
|
31
|
+
".java": "java",
|
|
32
|
+
".c": "c",
|
|
33
|
+
".h": "c",
|
|
34
|
+
".cpp": "cpp",
|
|
35
|
+
".cc": "cpp",
|
|
36
|
+
".cs": "csharp",
|
|
37
|
+
".rb": "ruby",
|
|
38
|
+
".sh": "bash",
|
|
39
|
+
".bash": "bash",
|
|
40
|
+
".zsh": "bash",
|
|
41
|
+
".css": "css",
|
|
42
|
+
".html": "html",
|
|
43
|
+
".xml": "xml",
|
|
44
|
+
".sql": "sql",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class DiffPayload:
|
|
50
|
+
"""Old/new text for one path under a compare mode."""
|
|
51
|
+
|
|
52
|
+
path: str
|
|
53
|
+
text_a: str
|
|
54
|
+
text_b: str
|
|
55
|
+
mode: DiffMode = "working"
|
|
56
|
+
language_hint: str | None = None
|
|
57
|
+
binary: bool = False
|
|
58
|
+
truncated: bool = False
|
|
59
|
+
error: str | None = None
|
|
60
|
+
missing_a: bool = False
|
|
61
|
+
missing_b: bool = False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def language_hint_for_path(path: str) -> str | None:
|
|
65
|
+
ext = Path(path or "").suffix.lower()
|
|
66
|
+
return _EXT_LANG.get(ext)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _run_git_bytes(args: list[str], *, cwd: Path, timeout: float = 2.0) -> bytes | None:
|
|
70
|
+
try:
|
|
71
|
+
proc = subprocess.run(
|
|
72
|
+
["git", *args],
|
|
73
|
+
cwd=str(cwd),
|
|
74
|
+
capture_output=True,
|
|
75
|
+
timeout=timeout,
|
|
76
|
+
check=False,
|
|
77
|
+
)
|
|
78
|
+
except Exception: # noqa: BLE001
|
|
79
|
+
return None
|
|
80
|
+
if proc.returncode != 0:
|
|
81
|
+
return None
|
|
82
|
+
return proc.stdout or b""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _run_git_text(args: list[str], *, cwd: Path, timeout: float = 2.0) -> str | None:
|
|
86
|
+
raw = _run_git_bytes(args, cwd=cwd, timeout=timeout)
|
|
87
|
+
if raw is None:
|
|
88
|
+
return None
|
|
89
|
+
try:
|
|
90
|
+
return raw.decode("utf-8")
|
|
91
|
+
except UnicodeDecodeError:
|
|
92
|
+
return raw.decode("utf-8", errors="replace")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _is_binary(data: bytes) -> bool:
|
|
96
|
+
if not data:
|
|
97
|
+
return False
|
|
98
|
+
# NUL in first 8KiB is a strong binary signal.
|
|
99
|
+
sample = data[:8192]
|
|
100
|
+
return b"\x00" in sample
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _decode_and_cap(data: bytes | None) -> tuple[str, bool, bool]:
|
|
104
|
+
"""Return ``(text, binary, truncated)``."""
|
|
105
|
+
if data is None:
|
|
106
|
+
return "", False, False
|
|
107
|
+
if _is_binary(data):
|
|
108
|
+
return "", True, False
|
|
109
|
+
truncated = False
|
|
110
|
+
body = data
|
|
111
|
+
if len(body) > _MAX_BYTES:
|
|
112
|
+
body = body[:_MAX_BYTES]
|
|
113
|
+
truncated = True
|
|
114
|
+
try:
|
|
115
|
+
text = body.decode("utf-8")
|
|
116
|
+
except UnicodeDecodeError:
|
|
117
|
+
text = body.decode("utf-8", errors="replace")
|
|
118
|
+
lines = text.splitlines(keepends=True)
|
|
119
|
+
if len(lines) > _MAX_LINES:
|
|
120
|
+
text = "".join(lines[:_MAX_LINES])
|
|
121
|
+
if not text.endswith("\n"):
|
|
122
|
+
text += "\n"
|
|
123
|
+
truncated = True
|
|
124
|
+
return text, False, truncated
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _read_worktree(cwd: Path, path: str) -> tuple[str, bool, bool, bool]:
|
|
128
|
+
"""Return ``(text, binary, truncated, missing)``."""
|
|
129
|
+
full = cwd / path
|
|
130
|
+
try:
|
|
131
|
+
if not full.is_file():
|
|
132
|
+
return "", False, False, True
|
|
133
|
+
data = full.read_bytes()
|
|
134
|
+
except Exception: # noqa: BLE001
|
|
135
|
+
return "", False, False, True
|
|
136
|
+
text, binary, truncated = _decode_and_cap(data)
|
|
137
|
+
return text, binary, truncated, False
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _read_blob(cwd: Path, spec: str) -> tuple[str, bool, bool, bool]:
|
|
141
|
+
"""Read ``git show <spec>``. Returns ``(text, binary, truncated, missing)``."""
|
|
142
|
+
raw = _run_git_bytes(["show", spec], cwd=cwd, timeout=2.0)
|
|
143
|
+
if raw is None:
|
|
144
|
+
return "", False, False, True
|
|
145
|
+
text, binary, truncated = _decode_and_cap(raw)
|
|
146
|
+
return text, binary, truncated, False
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def load_file_diff(
|
|
150
|
+
cwd: Path | str,
|
|
151
|
+
path: str,
|
|
152
|
+
*,
|
|
153
|
+
mode: DiffMode = "working",
|
|
154
|
+
is_untracked: bool = False,
|
|
155
|
+
) -> DiffPayload:
|
|
156
|
+
"""Load left/right text for ``path`` under ``mode``.
|
|
157
|
+
|
|
158
|
+
Semantics:
|
|
159
|
+
- working: HEAD:path vs worktree
|
|
160
|
+
- staged: HEAD:path vs index (:path)
|
|
161
|
+
- unstaged: index (:path) vs worktree
|
|
162
|
+
- untracked: empty vs worktree (any mode)
|
|
163
|
+
"""
|
|
164
|
+
root = Path(cwd)
|
|
165
|
+
rel = (path or "").replace("\\", "/").lstrip("./")
|
|
166
|
+
if not rel:
|
|
167
|
+
return DiffPayload(
|
|
168
|
+
path=path or "",
|
|
169
|
+
text_a="",
|
|
170
|
+
text_b="",
|
|
171
|
+
mode=mode,
|
|
172
|
+
error="empty path",
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
lang = language_hint_for_path(rel)
|
|
176
|
+
truncated = False
|
|
177
|
+
binary = False
|
|
178
|
+
|
|
179
|
+
if is_untracked:
|
|
180
|
+
text_b, bin_b, trunc_b, miss_b = _read_worktree(root, rel)
|
|
181
|
+
binary = bin_b
|
|
182
|
+
truncated = trunc_b
|
|
183
|
+
if binary:
|
|
184
|
+
return DiffPayload(
|
|
185
|
+
path=rel,
|
|
186
|
+
text_a="",
|
|
187
|
+
text_b="",
|
|
188
|
+
mode=mode,
|
|
189
|
+
language_hint=lang,
|
|
190
|
+
binary=True,
|
|
191
|
+
missing_a=True,
|
|
192
|
+
missing_b=miss_b,
|
|
193
|
+
)
|
|
194
|
+
return DiffPayload(
|
|
195
|
+
path=rel,
|
|
196
|
+
text_a="",
|
|
197
|
+
text_b=text_b,
|
|
198
|
+
mode=mode,
|
|
199
|
+
language_hint=lang,
|
|
200
|
+
truncated=truncated,
|
|
201
|
+
missing_a=True,
|
|
202
|
+
missing_b=miss_b,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
if mode == "staged":
|
|
206
|
+
text_a, bin_a, trunc_a, miss_a = _read_blob(root, f"HEAD:{rel}")
|
|
207
|
+
text_b, bin_b, trunc_b, miss_b = _read_blob(root, f":{rel}")
|
|
208
|
+
elif mode == "unstaged":
|
|
209
|
+
text_a, bin_a, trunc_a, miss_a = _read_blob(root, f":{rel}")
|
|
210
|
+
# If not in index, fall back to HEAD as left side.
|
|
211
|
+
if miss_a:
|
|
212
|
+
text_a, bin_a, trunc_a, miss_a = _read_blob(root, f"HEAD:{rel}")
|
|
213
|
+
text_b, bin_b, trunc_b, miss_b = _read_worktree(root, rel)
|
|
214
|
+
else: # working
|
|
215
|
+
text_a, bin_a, trunc_a, miss_a = _read_blob(root, f"HEAD:{rel}")
|
|
216
|
+
text_b, bin_b, trunc_b, miss_b = _read_worktree(root, rel)
|
|
217
|
+
|
|
218
|
+
binary = bin_a or bin_b
|
|
219
|
+
truncated = trunc_a or trunc_b
|
|
220
|
+
if binary:
|
|
221
|
+
return DiffPayload(
|
|
222
|
+
path=rel,
|
|
223
|
+
text_a="",
|
|
224
|
+
text_b="",
|
|
225
|
+
mode=mode,
|
|
226
|
+
language_hint=lang,
|
|
227
|
+
binary=True,
|
|
228
|
+
truncated=truncated,
|
|
229
|
+
missing_a=miss_a,
|
|
230
|
+
missing_b=miss_b,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
return DiffPayload(
|
|
234
|
+
path=rel,
|
|
235
|
+
text_a=text_a,
|
|
236
|
+
text_b=text_b,
|
|
237
|
+
mode=mode,
|
|
238
|
+
language_hint=lang,
|
|
239
|
+
truncated=truncated,
|
|
240
|
+
missing_a=miss_a,
|
|
241
|
+
missing_b=miss_b,
|
|
242
|
+
)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Fallback unified-diff renderer (Rich Text)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
|
|
7
|
+
from rich.console import Group
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
|
|
10
|
+
from synapse.ui.git_explore.provider import DiffPayload
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def render_unified_diff(
|
|
14
|
+
payload: DiffPayload,
|
|
15
|
+
*,
|
|
16
|
+
color_meta: str = "#9aa0a6",
|
|
17
|
+
color_hunk: str = "#8ab4f8",
|
|
18
|
+
color_add: str = "#81c995",
|
|
19
|
+
color_del: str = "#f28b82",
|
|
20
|
+
color_ctx: str = "#e8eaed",
|
|
21
|
+
color_warn: str = "#f4b183",
|
|
22
|
+
context: int = 3,
|
|
23
|
+
) -> Group | Text:
|
|
24
|
+
"""Render a ``DiffPayload`` as colored unified diff lines."""
|
|
25
|
+
if payload.error:
|
|
26
|
+
return Text(payload.error, style=color_warn)
|
|
27
|
+
if payload.binary:
|
|
28
|
+
return Text(f"binary file: {payload.path}", style=color_meta)
|
|
29
|
+
|
|
30
|
+
a_lines = (payload.text_a or "").splitlines(keepends=True)
|
|
31
|
+
b_lines = (payload.text_b or "").splitlines(keepends=True)
|
|
32
|
+
|
|
33
|
+
if not a_lines and not b_lines:
|
|
34
|
+
if payload.missing_a and payload.missing_b:
|
|
35
|
+
return Text("file not found on either side", style=color_meta)
|
|
36
|
+
return Text("no content", style=color_meta)
|
|
37
|
+
|
|
38
|
+
if payload.mode == "staged":
|
|
39
|
+
from_label = f"HEAD/{payload.path}"
|
|
40
|
+
to_label = f"index/{payload.path}"
|
|
41
|
+
elif payload.mode == "unstaged":
|
|
42
|
+
from_label = f"index/{payload.path}"
|
|
43
|
+
to_label = f"worktree/{payload.path}"
|
|
44
|
+
else:
|
|
45
|
+
from_label = f"HEAD/{payload.path}"
|
|
46
|
+
to_label = f"worktree/{payload.path}"
|
|
47
|
+
|
|
48
|
+
rows: list[Text] = []
|
|
49
|
+
if payload.truncated:
|
|
50
|
+
rows.append(Text("… truncated for display …", style=color_warn))
|
|
51
|
+
|
|
52
|
+
if payload.missing_a and not payload.missing_b:
|
|
53
|
+
rows.append(Text(f"new file · {payload.mode}", style=color_meta))
|
|
54
|
+
elif payload.missing_b and not payload.missing_a:
|
|
55
|
+
rows.append(Text(f"deleted file · {payload.mode}", style=color_meta))
|
|
56
|
+
else:
|
|
57
|
+
rows.append(Text(f"{payload.mode} · {payload.path}", style=color_meta))
|
|
58
|
+
|
|
59
|
+
diff_iter = difflib.unified_diff(
|
|
60
|
+
a_lines,
|
|
61
|
+
b_lines,
|
|
62
|
+
fromfile=from_label,
|
|
63
|
+
tofile=to_label,
|
|
64
|
+
n=max(0, int(context)),
|
|
65
|
+
lineterm="",
|
|
66
|
+
)
|
|
67
|
+
produced = False
|
|
68
|
+
for line in diff_iter:
|
|
69
|
+
produced = True
|
|
70
|
+
raw = line.rstrip("\n")
|
|
71
|
+
if raw.startswith("+++") or raw.startswith("---"):
|
|
72
|
+
rows.append(Text(raw, style=color_meta))
|
|
73
|
+
elif raw.startswith("@@"):
|
|
74
|
+
rows.append(Text(raw, style=color_hunk))
|
|
75
|
+
elif raw.startswith("+"):
|
|
76
|
+
rows.append(Text(raw, style=color_add))
|
|
77
|
+
elif raw.startswith("-"):
|
|
78
|
+
rows.append(Text(raw, style=color_del))
|
|
79
|
+
else:
|
|
80
|
+
rows.append(Text(raw, style=color_ctx))
|
|
81
|
+
|
|
82
|
+
if not produced:
|
|
83
|
+
rows.append(Text("no differences in this mode", style=color_meta))
|
|
84
|
+
|
|
85
|
+
return Group(*rows)
|