superqode 0.1.5__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.
- superqode/__init__.py +33 -0
- superqode/acp/__init__.py +23 -0
- superqode/acp/client.py +913 -0
- superqode/acp/permission_screen.py +457 -0
- superqode/acp/types.py +480 -0
- superqode/acp_discovery.py +856 -0
- superqode/agent/__init__.py +22 -0
- superqode/agent/edit_strategies.py +334 -0
- superqode/agent/loop.py +892 -0
- superqode/agent/qe_report_templates.py +39 -0
- superqode/agent/system_prompts.py +353 -0
- superqode/agent_output.py +721 -0
- superqode/agent_stream.py +953 -0
- superqode/agents/__init__.py +59 -0
- superqode/agents/acp_registry.py +305 -0
- superqode/agents/client.py +249 -0
- superqode/agents/data/augmentcode.com.toml +51 -0
- superqode/agents/data/cagent.dev.toml +51 -0
- superqode/agents/data/claude.com.toml +60 -0
- superqode/agents/data/codeassistant.dev.toml +51 -0
- superqode/agents/data/codex.openai.com.toml +57 -0
- superqode/agents/data/fastagent.ai.toml +66 -0
- superqode/agents/data/geminicli.com.toml +77 -0
- superqode/agents/data/goose.block.xyz.toml +54 -0
- superqode/agents/data/junie.jetbrains.com.toml +56 -0
- superqode/agents/data/kimi.moonshot.cn.toml +57 -0
- superqode/agents/data/llmlingagent.dev.toml +51 -0
- superqode/agents/data/molt.bot.toml +49 -0
- superqode/agents/data/opencode.ai.toml +60 -0
- superqode/agents/data/stakpak.dev.toml +51 -0
- superqode/agents/data/vtcode.dev.toml +51 -0
- superqode/agents/discovery.py +266 -0
- superqode/agents/messaging.py +160 -0
- superqode/agents/persona.py +166 -0
- superqode/agents/registry.py +421 -0
- superqode/agents/schema.py +72 -0
- superqode/agents/unified.py +367 -0
- superqode/app/__init__.py +111 -0
- superqode/app/constants.py +314 -0
- superqode/app/css.py +366 -0
- superqode/app/models.py +118 -0
- superqode/app/suggester.py +125 -0
- superqode/app/widgets.py +1591 -0
- superqode/app_enhanced.py +399 -0
- superqode/app_main.py +17187 -0
- superqode/approval.py +312 -0
- superqode/atomic.py +296 -0
- superqode/commands/__init__.py +1 -0
- superqode/commands/acp.py +965 -0
- superqode/commands/agents.py +180 -0
- superqode/commands/auth.py +278 -0
- superqode/commands/config.py +374 -0
- superqode/commands/init.py +826 -0
- superqode/commands/providers.py +819 -0
- superqode/commands/qe.py +1145 -0
- superqode/commands/roles.py +380 -0
- superqode/commands/serve.py +172 -0
- superqode/commands/suggestions.py +127 -0
- superqode/commands/superqe.py +460 -0
- superqode/config/__init__.py +51 -0
- superqode/config/loader.py +812 -0
- superqode/config/schema.py +498 -0
- superqode/core/__init__.py +111 -0
- superqode/core/roles.py +281 -0
- superqode/danger.py +386 -0
- superqode/data/superqode-template.yaml +1522 -0
- superqode/design_system.py +1080 -0
- superqode/dialogs/__init__.py +6 -0
- superqode/dialogs/base.py +39 -0
- superqode/dialogs/model.py +130 -0
- superqode/dialogs/provider.py +870 -0
- superqode/diff_view.py +919 -0
- superqode/enterprise.py +21 -0
- superqode/evaluation/__init__.py +25 -0
- superqode/evaluation/adapters.py +93 -0
- superqode/evaluation/behaviors.py +89 -0
- superqode/evaluation/engine.py +209 -0
- superqode/evaluation/scenarios.py +96 -0
- superqode/execution/__init__.py +36 -0
- superqode/execution/linter.py +538 -0
- superqode/execution/modes.py +347 -0
- superqode/execution/resolver.py +283 -0
- superqode/execution/runner.py +642 -0
- superqode/file_explorer.py +811 -0
- superqode/file_viewer.py +471 -0
- superqode/flash.py +183 -0
- superqode/guidance/__init__.py +58 -0
- superqode/guidance/config.py +203 -0
- superqode/guidance/prompts.py +71 -0
- superqode/harness/__init__.py +54 -0
- superqode/harness/accelerator.py +291 -0
- superqode/harness/config.py +319 -0
- superqode/harness/validator.py +147 -0
- superqode/history.py +279 -0
- superqode/integrations/superopt_runner.py +124 -0
- superqode/logging/__init__.py +49 -0
- superqode/logging/adapters.py +219 -0
- superqode/logging/formatter.py +923 -0
- superqode/logging/integration.py +341 -0
- superqode/logging/sinks.py +170 -0
- superqode/logging/unified_log.py +417 -0
- superqode/lsp/__init__.py +26 -0
- superqode/lsp/client.py +544 -0
- superqode/main.py +1069 -0
- superqode/mcp/__init__.py +89 -0
- superqode/mcp/auth_storage.py +380 -0
- superqode/mcp/client.py +1236 -0
- superqode/mcp/config.py +319 -0
- superqode/mcp/integration.py +337 -0
- superqode/mcp/oauth.py +436 -0
- superqode/mcp/oauth_callback.py +385 -0
- superqode/mcp/types.py +290 -0
- superqode/memory/__init__.py +31 -0
- superqode/memory/feedback.py +342 -0
- superqode/memory/store.py +522 -0
- superqode/notifications.py +369 -0
- superqode/optimization/__init__.py +5 -0
- superqode/optimization/config.py +33 -0
- superqode/permissions/__init__.py +25 -0
- superqode/permissions/rules.py +488 -0
- superqode/plan.py +323 -0
- superqode/providers/__init__.py +33 -0
- superqode/providers/gateway/__init__.py +165 -0
- superqode/providers/gateway/base.py +228 -0
- superqode/providers/gateway/litellm_gateway.py +1170 -0
- superqode/providers/gateway/openresponses_gateway.py +436 -0
- superqode/providers/health.py +297 -0
- superqode/providers/huggingface/__init__.py +74 -0
- superqode/providers/huggingface/downloader.py +472 -0
- superqode/providers/huggingface/endpoints.py +442 -0
- superqode/providers/huggingface/hub.py +531 -0
- superqode/providers/huggingface/inference.py +394 -0
- superqode/providers/huggingface/transformers_runner.py +516 -0
- superqode/providers/local/__init__.py +100 -0
- superqode/providers/local/base.py +438 -0
- superqode/providers/local/discovery.py +418 -0
- superqode/providers/local/lmstudio.py +256 -0
- superqode/providers/local/mlx.py +457 -0
- superqode/providers/local/ollama.py +486 -0
- superqode/providers/local/sglang.py +268 -0
- superqode/providers/local/tgi.py +260 -0
- superqode/providers/local/tool_support.py +477 -0
- superqode/providers/local/vllm.py +258 -0
- superqode/providers/manager.py +1338 -0
- superqode/providers/models.py +1016 -0
- superqode/providers/models_dev.py +578 -0
- superqode/providers/openresponses/__init__.py +87 -0
- superqode/providers/openresponses/converters/__init__.py +17 -0
- superqode/providers/openresponses/converters/messages.py +343 -0
- superqode/providers/openresponses/converters/tools.py +268 -0
- superqode/providers/openresponses/schema/__init__.py +56 -0
- superqode/providers/openresponses/schema/models.py +585 -0
- superqode/providers/openresponses/streaming/__init__.py +5 -0
- superqode/providers/openresponses/streaming/parser.py +338 -0
- superqode/providers/openresponses/tools/__init__.py +21 -0
- superqode/providers/openresponses/tools/apply_patch.py +352 -0
- superqode/providers/openresponses/tools/code_interpreter.py +290 -0
- superqode/providers/openresponses/tools/file_search.py +333 -0
- superqode/providers/openresponses/tools/mcp_adapter.py +252 -0
- superqode/providers/registry.py +716 -0
- superqode/providers/usage.py +332 -0
- superqode/pure_mode.py +384 -0
- superqode/qr/__init__.py +23 -0
- superqode/qr/dashboard.py +781 -0
- superqode/qr/generator.py +1018 -0
- superqode/qr/templates.py +135 -0
- superqode/safety/__init__.py +41 -0
- superqode/safety/sandbox.py +413 -0
- superqode/safety/warnings.py +256 -0
- superqode/server/__init__.py +33 -0
- superqode/server/lsp_server.py +775 -0
- superqode/server/web.py +250 -0
- superqode/session/__init__.py +25 -0
- superqode/session/persistence.py +580 -0
- superqode/session/sharing.py +477 -0
- superqode/session.py +475 -0
- superqode/sidebar.py +2991 -0
- superqode/stream_view.py +648 -0
- superqode/styles/__init__.py +3 -0
- superqode/superqe/__init__.py +184 -0
- superqode/superqe/acp_runner.py +1064 -0
- superqode/superqe/constitution/__init__.py +62 -0
- superqode/superqe/constitution/evaluator.py +308 -0
- superqode/superqe/constitution/loader.py +432 -0
- superqode/superqe/constitution/schema.py +250 -0
- superqode/superqe/events.py +591 -0
- superqode/superqe/frameworks/__init__.py +65 -0
- superqode/superqe/frameworks/base.py +234 -0
- superqode/superqe/frameworks/e2e.py +263 -0
- superqode/superqe/frameworks/executor.py +237 -0
- superqode/superqe/frameworks/javascript.py +409 -0
- superqode/superqe/frameworks/python.py +373 -0
- superqode/superqe/frameworks/registry.py +92 -0
- superqode/superqe/mcp_tools/__init__.py +47 -0
- superqode/superqe/mcp_tools/core_tools.py +418 -0
- superqode/superqe/mcp_tools/registry.py +230 -0
- superqode/superqe/mcp_tools/testing_tools.py +167 -0
- superqode/superqe/noise.py +89 -0
- superqode/superqe/orchestrator.py +778 -0
- superqode/superqe/roles.py +609 -0
- superqode/superqe/session.py +713 -0
- superqode/superqe/skills/__init__.py +57 -0
- superqode/superqe/skills/base.py +106 -0
- superqode/superqe/skills/core_skills.py +899 -0
- superqode/superqe/skills/registry.py +90 -0
- superqode/superqe/verifier.py +101 -0
- superqode/superqe_cli.py +76 -0
- superqode/tool_call.py +358 -0
- superqode/tools/__init__.py +93 -0
- superqode/tools/agent_tools.py +496 -0
- superqode/tools/base.py +324 -0
- superqode/tools/batch_tool.py +133 -0
- superqode/tools/diagnostics.py +311 -0
- superqode/tools/edit_tools.py +653 -0
- superqode/tools/enhanced_base.py +515 -0
- superqode/tools/file_tools.py +269 -0
- superqode/tools/file_tracking.py +45 -0
- superqode/tools/lsp_tools.py +610 -0
- superqode/tools/network_tools.py +350 -0
- superqode/tools/permissions.py +400 -0
- superqode/tools/question_tool.py +324 -0
- superqode/tools/search_tools.py +598 -0
- superqode/tools/shell_tools.py +259 -0
- superqode/tools/todo_tools.py +121 -0
- superqode/tools/validation.py +80 -0
- superqode/tools/web_tools.py +639 -0
- superqode/tui.py +1152 -0
- superqode/tui_integration.py +875 -0
- superqode/tui_widgets/__init__.py +27 -0
- superqode/tui_widgets/widgets/__init__.py +18 -0
- superqode/tui_widgets/widgets/progress.py +185 -0
- superqode/tui_widgets/widgets/tool_display.py +188 -0
- superqode/undo_manager.py +574 -0
- superqode/utils/__init__.py +5 -0
- superqode/utils/error_handling.py +323 -0
- superqode/utils/fuzzy.py +257 -0
- superqode/widgets/__init__.py +477 -0
- superqode/widgets/agent_collab.py +390 -0
- superqode/widgets/agent_store.py +936 -0
- superqode/widgets/agent_switcher.py +395 -0
- superqode/widgets/animation_manager.py +284 -0
- superqode/widgets/code_context.py +356 -0
- superqode/widgets/command_palette.py +412 -0
- superqode/widgets/connection_status.py +537 -0
- superqode/widgets/conversation_history.py +470 -0
- superqode/widgets/diff_indicator.py +155 -0
- superqode/widgets/enhanced_status_bar.py +385 -0
- superqode/widgets/enhanced_toast.py +476 -0
- superqode/widgets/file_browser.py +809 -0
- superqode/widgets/file_reference.py +585 -0
- superqode/widgets/issue_timeline.py +340 -0
- superqode/widgets/leader_key.py +264 -0
- superqode/widgets/mode_switcher.py +445 -0
- superqode/widgets/model_picker.py +234 -0
- superqode/widgets/permission_preview.py +1205 -0
- superqode/widgets/prompt.py +358 -0
- superqode/widgets/provider_connect.py +725 -0
- superqode/widgets/pty_shell.py +587 -0
- superqode/widgets/qe_dashboard.py +321 -0
- superqode/widgets/resizable_sidebar.py +377 -0
- superqode/widgets/response_changes.py +218 -0
- superqode/widgets/response_display.py +528 -0
- superqode/widgets/rich_tool_display.py +613 -0
- superqode/widgets/sidebar_panels.py +1180 -0
- superqode/widgets/slash_complete.py +356 -0
- superqode/widgets/split_view.py +612 -0
- superqode/widgets/status_bar.py +273 -0
- superqode/widgets/superqode_display.py +786 -0
- superqode/widgets/thinking_display.py +815 -0
- superqode/widgets/throbber.py +87 -0
- superqode/widgets/toast.py +206 -0
- superqode/widgets/unified_output.py +1073 -0
- superqode/workspace/__init__.py +75 -0
- superqode/workspace/artifacts.py +472 -0
- superqode/workspace/coordinator.py +353 -0
- superqode/workspace/diff_tracker.py +429 -0
- superqode/workspace/git_guard.py +373 -0
- superqode/workspace/git_snapshot.py +526 -0
- superqode/workspace/manager.py +750 -0
- superqode/workspace/snapshot.py +357 -0
- superqode/workspace/watcher.py +535 -0
- superqode/workspace/worktree.py +440 -0
- superqode-0.1.5.dist-info/METADATA +204 -0
- superqode-0.1.5.dist-info/RECORD +288 -0
- superqode-0.1.5.dist-info/WHEEL +5 -0
- superqode-0.1.5.dist-info/entry_points.txt +3 -0
- superqode-0.1.5.dist-info/licenses/LICENSE +648 -0
- superqode-0.1.5.dist-info/top_level.txt +1 -0
superqode/diff_view.py
ADDED
|
@@ -0,0 +1,919 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SuperQode Diff View - Beautiful Code Diff Display
|
|
3
|
+
|
|
4
|
+
A unique diff visualization with:
|
|
5
|
+
- Gradient-styled headers
|
|
6
|
+
- Side-by-side and unified views
|
|
7
|
+
- Syntax highlighting
|
|
8
|
+
- Line-level change indicators
|
|
9
|
+
- Textual widget with synchronized scrolling
|
|
10
|
+
- Auto-detection of split/unified based on terminal width
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import difflib
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from enum import Enum
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import List, Tuple, Optional
|
|
20
|
+
|
|
21
|
+
from rich.console import Console, Group
|
|
22
|
+
from rich.panel import Panel
|
|
23
|
+
from rich.syntax import Syntax
|
|
24
|
+
from rich.table import Table
|
|
25
|
+
from rich.text import Text
|
|
26
|
+
from rich.box import ROUNDED, SIMPLE, MINIMAL
|
|
27
|
+
|
|
28
|
+
# Textual imports for widget-based diff view
|
|
29
|
+
from textual.app import ComposeResult
|
|
30
|
+
from textual.containers import Container, Horizontal, ScrollableContainer
|
|
31
|
+
from textual.widgets import Static
|
|
32
|
+
from textual.reactive import reactive, var
|
|
33
|
+
from textual.message import Message
|
|
34
|
+
from textual.binding import Binding
|
|
35
|
+
from textual import on
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DiffMode(Enum):
|
|
39
|
+
"""Diff display mode."""
|
|
40
|
+
|
|
41
|
+
UNIFIED = "unified"
|
|
42
|
+
SPLIT = "split"
|
|
43
|
+
COMPACT = "compact"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class DiffLine:
|
|
48
|
+
"""A single line in a diff."""
|
|
49
|
+
|
|
50
|
+
line_no_old: Optional[int]
|
|
51
|
+
line_no_new: Optional[int]
|
|
52
|
+
content: str
|
|
53
|
+
change_type: str # '+', '-', ' ', '~' (modified)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class DiffHunk:
|
|
58
|
+
"""A group of related changes."""
|
|
59
|
+
|
|
60
|
+
old_start: int
|
|
61
|
+
old_count: int
|
|
62
|
+
new_start: int
|
|
63
|
+
new_count: int
|
|
64
|
+
lines: List[DiffLine]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class FileDiff:
|
|
69
|
+
"""Complete diff for a file."""
|
|
70
|
+
|
|
71
|
+
path: str
|
|
72
|
+
old_content: str
|
|
73
|
+
new_content: str
|
|
74
|
+
hunks: List[DiffHunk]
|
|
75
|
+
additions: int
|
|
76
|
+
deletions: int
|
|
77
|
+
is_new: bool
|
|
78
|
+
is_deleted: bool
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# SuperQode gradient colors
|
|
82
|
+
DIFF_COLORS = {
|
|
83
|
+
"header_gradient": ["#a855f7", "#ec4899", "#f97316"],
|
|
84
|
+
"addition": "#22c55e",
|
|
85
|
+
"addition_bg": "#22c55e15",
|
|
86
|
+
"deletion": "#ef4444",
|
|
87
|
+
"deletion_bg": "#ef444415",
|
|
88
|
+
"context": "#71717a",
|
|
89
|
+
"line_no": "#52525b",
|
|
90
|
+
"border": "#2a2a2a",
|
|
91
|
+
"highlight_add": "#22c55e30",
|
|
92
|
+
"highlight_del": "#ef444430",
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
# Icons for diff display
|
|
96
|
+
DIFF_ICONS = {
|
|
97
|
+
"file": "📄",
|
|
98
|
+
"new_file": "✨",
|
|
99
|
+
"deleted_file": "🗑️",
|
|
100
|
+
"modified": "📝",
|
|
101
|
+
"addition": "➕",
|
|
102
|
+
"deletion": "➖",
|
|
103
|
+
"unchanged": "│",
|
|
104
|
+
"hunk": "┄",
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def compute_diff(old_content: str, new_content: str, path: str = "file") -> FileDiff:
|
|
109
|
+
"""
|
|
110
|
+
Compute the diff between two versions of content.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
old_content: Original content
|
|
114
|
+
new_content: New content
|
|
115
|
+
path: File path for display
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
FileDiff object with all diff information
|
|
119
|
+
"""
|
|
120
|
+
old_lines = old_content.splitlines(keepends=True)
|
|
121
|
+
new_lines = new_content.splitlines(keepends=True)
|
|
122
|
+
|
|
123
|
+
# Handle edge cases
|
|
124
|
+
is_new = not old_content.strip()
|
|
125
|
+
is_deleted = not new_content.strip()
|
|
126
|
+
|
|
127
|
+
# Get unified diff
|
|
128
|
+
differ = difflib.unified_diff(
|
|
129
|
+
old_lines, new_lines, fromfile=f"a/{path}", tofile=f"b/{path}", lineterm=""
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
hunks: List[DiffHunk] = []
|
|
133
|
+
current_hunk: Optional[DiffHunk] = None
|
|
134
|
+
additions = 0
|
|
135
|
+
deletions = 0
|
|
136
|
+
|
|
137
|
+
old_line_no = 0
|
|
138
|
+
new_line_no = 0
|
|
139
|
+
|
|
140
|
+
for line in differ:
|
|
141
|
+
if line.startswith("@@"):
|
|
142
|
+
# Parse hunk header: @@ -old_start,old_count +new_start,new_count @@
|
|
143
|
+
if current_hunk:
|
|
144
|
+
hunks.append(current_hunk)
|
|
145
|
+
|
|
146
|
+
parts = line.split()
|
|
147
|
+
old_info = parts[1][1:].split(",")
|
|
148
|
+
new_info = parts[2][1:].split(",")
|
|
149
|
+
|
|
150
|
+
old_start = int(old_info[0])
|
|
151
|
+
old_count = int(old_info[1]) if len(old_info) > 1 else 1
|
|
152
|
+
new_start = int(new_info[0])
|
|
153
|
+
new_count = int(new_info[1]) if len(new_info) > 1 else 1
|
|
154
|
+
|
|
155
|
+
current_hunk = DiffHunk(
|
|
156
|
+
old_start=old_start,
|
|
157
|
+
old_count=old_count,
|
|
158
|
+
new_start=new_start,
|
|
159
|
+
new_count=new_count,
|
|
160
|
+
lines=[],
|
|
161
|
+
)
|
|
162
|
+
old_line_no = old_start
|
|
163
|
+
new_line_no = new_start
|
|
164
|
+
|
|
165
|
+
elif line.startswith("---") or line.startswith("+++"):
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
elif current_hunk is not None:
|
|
169
|
+
content = line[1:] if len(line) > 1 else ""
|
|
170
|
+
|
|
171
|
+
if line.startswith("+"):
|
|
172
|
+
current_hunk.lines.append(
|
|
173
|
+
DiffLine(
|
|
174
|
+
line_no_old=None,
|
|
175
|
+
line_no_new=new_line_no,
|
|
176
|
+
content=content.rstrip("\n"),
|
|
177
|
+
change_type="+",
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
new_line_no += 1
|
|
181
|
+
additions += 1
|
|
182
|
+
|
|
183
|
+
elif line.startswith("-"):
|
|
184
|
+
current_hunk.lines.append(
|
|
185
|
+
DiffLine(
|
|
186
|
+
line_no_old=old_line_no,
|
|
187
|
+
line_no_new=None,
|
|
188
|
+
content=content.rstrip("\n"),
|
|
189
|
+
change_type="-",
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
old_line_no += 1
|
|
193
|
+
deletions += 1
|
|
194
|
+
|
|
195
|
+
else:
|
|
196
|
+
current_hunk.lines.append(
|
|
197
|
+
DiffLine(
|
|
198
|
+
line_no_old=old_line_no,
|
|
199
|
+
line_no_new=new_line_no,
|
|
200
|
+
content=content.rstrip("\n"),
|
|
201
|
+
change_type=" ",
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
old_line_no += 1
|
|
205
|
+
new_line_no += 1
|
|
206
|
+
|
|
207
|
+
if current_hunk:
|
|
208
|
+
hunks.append(current_hunk)
|
|
209
|
+
|
|
210
|
+
return FileDiff(
|
|
211
|
+
path=path,
|
|
212
|
+
old_content=old_content,
|
|
213
|
+
new_content=new_content,
|
|
214
|
+
hunks=hunks,
|
|
215
|
+
additions=additions,
|
|
216
|
+
deletions=deletions,
|
|
217
|
+
is_new=is_new,
|
|
218
|
+
is_deleted=is_deleted,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def render_diff_header(diff: FileDiff, console: Console) -> None:
|
|
223
|
+
"""Render a beautiful diff header."""
|
|
224
|
+
# Determine icon and status
|
|
225
|
+
if diff.is_new:
|
|
226
|
+
icon = DIFF_ICONS["new_file"]
|
|
227
|
+
status = "New File"
|
|
228
|
+
status_color = DIFF_COLORS["addition"]
|
|
229
|
+
elif diff.is_deleted:
|
|
230
|
+
icon = DIFF_ICONS["deleted_file"]
|
|
231
|
+
status = "Deleted"
|
|
232
|
+
status_color = DIFF_COLORS["deletion"]
|
|
233
|
+
else:
|
|
234
|
+
icon = DIFF_ICONS["modified"]
|
|
235
|
+
status = "Modified"
|
|
236
|
+
status_color = "#f97316"
|
|
237
|
+
|
|
238
|
+
# Build header text
|
|
239
|
+
header = Text()
|
|
240
|
+
header.append(f" {icon} ", style="bold")
|
|
241
|
+
header.append(diff.path, style="bold white")
|
|
242
|
+
header.append(" ", style="")
|
|
243
|
+
header.append(f"[{status}]", style=f"bold {status_color}")
|
|
244
|
+
header.append(" ", style="")
|
|
245
|
+
header.append(f"+{diff.additions}", style=f"bold {DIFF_COLORS['addition']}")
|
|
246
|
+
header.append(" / ", style="dim")
|
|
247
|
+
header.append(f"-{diff.deletions}", style=f"bold {DIFF_COLORS['deletion']}")
|
|
248
|
+
|
|
249
|
+
console.print(Panel(header, border_style=DIFF_COLORS["border"], box=ROUNDED, padding=(0, 1)))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def render_diff_unified(diff: FileDiff, console: Console, context_lines: int = 3) -> None:
|
|
253
|
+
"""Render diff in unified format."""
|
|
254
|
+
render_diff_header(diff, console)
|
|
255
|
+
|
|
256
|
+
if not diff.hunks:
|
|
257
|
+
console.print(" [dim]No changes[/dim]")
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
for hunk in diff.hunks:
|
|
261
|
+
# Hunk separator
|
|
262
|
+
hunk_header = Text()
|
|
263
|
+
hunk_header.append(f" {DIFF_ICONS['hunk']} ", style="dim cyan")
|
|
264
|
+
hunk_header.append(
|
|
265
|
+
f"@@ -{hunk.old_start},{hunk.old_count} +{hunk.new_start},{hunk.new_count} @@",
|
|
266
|
+
style="dim cyan",
|
|
267
|
+
)
|
|
268
|
+
console.print(hunk_header)
|
|
269
|
+
|
|
270
|
+
# Render lines
|
|
271
|
+
for line in hunk.lines:
|
|
272
|
+
line_text = Text()
|
|
273
|
+
|
|
274
|
+
# Line numbers
|
|
275
|
+
old_no = f"{line.line_no_old:>4}" if line.line_no_old else " "
|
|
276
|
+
new_no = f"{line.line_no_new:>4}" if line.line_no_new else " "
|
|
277
|
+
line_text.append(f" {old_no} {new_no} ", style=DIFF_COLORS["line_no"])
|
|
278
|
+
|
|
279
|
+
# Change indicator and content
|
|
280
|
+
if line.change_type == "+":
|
|
281
|
+
line_text.append("│", style=DIFF_COLORS["addition"])
|
|
282
|
+
line_text.append(f" {line.content}", style=f"on {DIFF_COLORS['addition_bg']}")
|
|
283
|
+
elif line.change_type == "-":
|
|
284
|
+
line_text.append("│", style=DIFF_COLORS["deletion"])
|
|
285
|
+
line_text.append(f" {line.content}", style=f"on {DIFF_COLORS['deletion_bg']}")
|
|
286
|
+
else:
|
|
287
|
+
line_text.append("│", style="dim")
|
|
288
|
+
line_text.append(f" {line.content}", style="")
|
|
289
|
+
|
|
290
|
+
console.print(line_text)
|
|
291
|
+
|
|
292
|
+
console.print()
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def render_diff_split(diff: FileDiff, console: Console, width: int = 80) -> None:
|
|
296
|
+
"""Render diff in side-by-side split format."""
|
|
297
|
+
render_diff_header(diff, console)
|
|
298
|
+
|
|
299
|
+
if not diff.hunks:
|
|
300
|
+
console.print(" [dim]No changes[/dim]")
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
half_width = (width - 10) // 2
|
|
304
|
+
|
|
305
|
+
for hunk in diff.hunks:
|
|
306
|
+
# Collect old and new lines separately
|
|
307
|
+
old_lines: List[Tuple[Optional[int], str, str]] = []
|
|
308
|
+
new_lines: List[Tuple[Optional[int], str, str]] = []
|
|
309
|
+
|
|
310
|
+
for line in hunk.lines:
|
|
311
|
+
if line.change_type == "-":
|
|
312
|
+
old_lines.append((line.line_no_old, line.content, "-"))
|
|
313
|
+
elif line.change_type == "+":
|
|
314
|
+
new_lines.append((line.line_no_new, line.content, "+"))
|
|
315
|
+
else:
|
|
316
|
+
old_lines.append((line.line_no_old, line.content, " "))
|
|
317
|
+
new_lines.append((line.line_no_new, line.content, " "))
|
|
318
|
+
|
|
319
|
+
# Pad to same length
|
|
320
|
+
max_len = max(len(old_lines), len(new_lines))
|
|
321
|
+
while len(old_lines) < max_len:
|
|
322
|
+
old_lines.append((None, "", "/"))
|
|
323
|
+
while len(new_lines) < max_len:
|
|
324
|
+
new_lines.append((None, "", "/"))
|
|
325
|
+
|
|
326
|
+
# Render side by side
|
|
327
|
+
for (old_no, old_content, old_type), (new_no, new_content, new_type) in zip(
|
|
328
|
+
old_lines, new_lines
|
|
329
|
+
):
|
|
330
|
+
line_text = Text()
|
|
331
|
+
|
|
332
|
+
# Old side
|
|
333
|
+
old_no_str = f"{old_no:>4}" if old_no else " "
|
|
334
|
+
line_text.append(f" {old_no_str} ", style=DIFF_COLORS["line_no"])
|
|
335
|
+
|
|
336
|
+
if old_type == "-":
|
|
337
|
+
line_text.append("─", style=DIFF_COLORS["deletion"])
|
|
338
|
+
content = old_content[:half_width].ljust(half_width)
|
|
339
|
+
line_text.append(content, style=f"on {DIFF_COLORS['deletion_bg']}")
|
|
340
|
+
elif old_type == "/":
|
|
341
|
+
line_text.append("╲", style="dim")
|
|
342
|
+
line_text.append("╲" * half_width, style="dim")
|
|
343
|
+
else:
|
|
344
|
+
line_text.append("│", style="dim")
|
|
345
|
+
line_text.append(old_content[:half_width].ljust(half_width), style="")
|
|
346
|
+
|
|
347
|
+
line_text.append(" │ ", style="dim")
|
|
348
|
+
|
|
349
|
+
# New side
|
|
350
|
+
new_no_str = f"{new_no:>4}" if new_no else " "
|
|
351
|
+
line_text.append(f"{new_no_str} ", style=DIFF_COLORS["line_no"])
|
|
352
|
+
|
|
353
|
+
if new_type == "+":
|
|
354
|
+
line_text.append("─", style=DIFF_COLORS["addition"])
|
|
355
|
+
content = new_content[:half_width].ljust(half_width)
|
|
356
|
+
line_text.append(content, style=f"on {DIFF_COLORS['addition_bg']}")
|
|
357
|
+
elif new_type == "/":
|
|
358
|
+
line_text.append("╲", style="dim")
|
|
359
|
+
line_text.append("╲" * half_width, style="dim")
|
|
360
|
+
else:
|
|
361
|
+
line_text.append("│", style="dim")
|
|
362
|
+
line_text.append(new_content[:half_width].ljust(half_width), style="")
|
|
363
|
+
|
|
364
|
+
console.print(line_text)
|
|
365
|
+
|
|
366
|
+
console.print()
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def render_diff_compact(diff: FileDiff, console: Console) -> None:
|
|
370
|
+
"""Render a compact summary of changes."""
|
|
371
|
+
# Determine icon and status
|
|
372
|
+
if diff.is_new:
|
|
373
|
+
icon = DIFF_ICONS["new_file"]
|
|
374
|
+
status_style = f"bold {DIFF_COLORS['addition']}"
|
|
375
|
+
elif diff.is_deleted:
|
|
376
|
+
icon = DIFF_ICONS["deleted_file"]
|
|
377
|
+
status_style = f"bold {DIFF_COLORS['deletion']}"
|
|
378
|
+
else:
|
|
379
|
+
icon = DIFF_ICONS["modified"]
|
|
380
|
+
status_style = "bold #f97316"
|
|
381
|
+
|
|
382
|
+
line = Text()
|
|
383
|
+
line.append(f" {icon} ", style="")
|
|
384
|
+
line.append(diff.path, style=status_style)
|
|
385
|
+
line.append(" ", style="")
|
|
386
|
+
line.append(f"+{diff.additions}", style=f"bold {DIFF_COLORS['addition']}")
|
|
387
|
+
line.append("/", style="dim")
|
|
388
|
+
line.append(f"-{diff.deletions}", style=f"bold {DIFF_COLORS['deletion']}")
|
|
389
|
+
|
|
390
|
+
console.print(line)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def render_diff(
|
|
394
|
+
diff: FileDiff, console: Console, mode: DiffMode = DiffMode.UNIFIED, width: int = 80
|
|
395
|
+
) -> None:
|
|
396
|
+
"""Render a diff with the specified mode."""
|
|
397
|
+
if mode == DiffMode.UNIFIED:
|
|
398
|
+
render_diff_unified(diff, console)
|
|
399
|
+
elif mode == DiffMode.SPLIT:
|
|
400
|
+
render_diff_split(diff, console, width)
|
|
401
|
+
else:
|
|
402
|
+
render_diff_compact(diff, console)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
class DiffViewer:
|
|
406
|
+
"""Interactive diff viewer for multiple files."""
|
|
407
|
+
|
|
408
|
+
def __init__(self, console: Console):
|
|
409
|
+
self.console = console
|
|
410
|
+
self.diffs: List[FileDiff] = []
|
|
411
|
+
self.current_index = 0
|
|
412
|
+
self.mode = DiffMode.UNIFIED
|
|
413
|
+
|
|
414
|
+
def add_diff(self, old_content: str, new_content: str, path: str) -> FileDiff:
|
|
415
|
+
"""Add a file diff to the viewer."""
|
|
416
|
+
diff = compute_diff(old_content, new_content, path)
|
|
417
|
+
self.diffs.append(diff)
|
|
418
|
+
return diff
|
|
419
|
+
|
|
420
|
+
def render_all(self) -> None:
|
|
421
|
+
"""Render all diffs."""
|
|
422
|
+
if not self.diffs:
|
|
423
|
+
self.console.print(" [dim]No changes to display[/dim]")
|
|
424
|
+
return
|
|
425
|
+
|
|
426
|
+
# Summary header
|
|
427
|
+
total_additions = sum(d.additions for d in self.diffs)
|
|
428
|
+
total_deletions = sum(d.deletions for d in self.diffs)
|
|
429
|
+
|
|
430
|
+
header = Text()
|
|
431
|
+
header.append(f" 📊 ", style="bold")
|
|
432
|
+
header.append(f"{len(self.diffs)} file(s) changed", style="bold white")
|
|
433
|
+
header.append(" ", style="")
|
|
434
|
+
header.append(f"+{total_additions}", style=f"bold {DIFF_COLORS['addition']}")
|
|
435
|
+
header.append(" / ", style="dim")
|
|
436
|
+
header.append(f"-{total_deletions}", style=f"bold {DIFF_COLORS['deletion']}")
|
|
437
|
+
|
|
438
|
+
self.console.print(Panel(header, border_style="#a855f7", box=ROUNDED, padding=(0, 1)))
|
|
439
|
+
self.console.print()
|
|
440
|
+
|
|
441
|
+
# Render each diff
|
|
442
|
+
for diff in self.diffs:
|
|
443
|
+
render_diff(diff, self.console, self.mode)
|
|
444
|
+
|
|
445
|
+
def render_summary(self) -> None:
|
|
446
|
+
"""Render a compact summary of all changes."""
|
|
447
|
+
if not self.diffs:
|
|
448
|
+
self.console.print(" [dim]No changes[/dim]")
|
|
449
|
+
return
|
|
450
|
+
|
|
451
|
+
for diff in self.diffs:
|
|
452
|
+
render_diff_compact(diff, self.console)
|
|
453
|
+
|
|
454
|
+
def set_mode(self, mode: DiffMode) -> None:
|
|
455
|
+
"""Set the display mode."""
|
|
456
|
+
self.mode = mode
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
# ============================================================================
|
|
460
|
+
# TEXTUAL WIDGET-BASED DIFF VIEW WITH SYNCHRONIZED SCROLLING
|
|
461
|
+
# ============================================================================
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
class DiffScrollPane(ScrollableContainer):
|
|
465
|
+
"""Scrollable pane for diff content with scroll synchronization."""
|
|
466
|
+
|
|
467
|
+
DEFAULT_CSS = """
|
|
468
|
+
DiffScrollPane {
|
|
469
|
+
width: 1fr;
|
|
470
|
+
height: 100%;
|
|
471
|
+
background: #000000;
|
|
472
|
+
scrollbar-size: 1 1;
|
|
473
|
+
overflow-x: auto;
|
|
474
|
+
overflow-y: scroll;
|
|
475
|
+
}
|
|
476
|
+
"""
|
|
477
|
+
|
|
478
|
+
scroll_link: var[Optional["DiffScrollPane"]] = var(None)
|
|
479
|
+
|
|
480
|
+
def watch_scroll_y(self, old_value: float, new_value: float) -> None:
|
|
481
|
+
"""Synchronize vertical scroll with linked pane."""
|
|
482
|
+
super().watch_scroll_y(old_value, new_value)
|
|
483
|
+
if self.scroll_link and self.scroll_link.scroll_y != new_value:
|
|
484
|
+
self.scroll_link.scroll_y = new_value
|
|
485
|
+
|
|
486
|
+
def watch_scroll_x(self, old_value: float, new_value: float) -> None:
|
|
487
|
+
"""Synchronize horizontal scroll with linked pane."""
|
|
488
|
+
super().watch_scroll_x(old_value, new_value)
|
|
489
|
+
if self.scroll_link and self.scroll_link.scroll_x != new_value:
|
|
490
|
+
self.scroll_link.scroll_x = new_value
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
class DiffLineNumbers(Static):
|
|
494
|
+
"""Widget showing line numbers for a diff pane."""
|
|
495
|
+
|
|
496
|
+
DEFAULT_CSS = """
|
|
497
|
+
DiffLineNumbers {
|
|
498
|
+
width: 5;
|
|
499
|
+
height: auto;
|
|
500
|
+
background: #0a0a0a;
|
|
501
|
+
padding: 0;
|
|
502
|
+
}
|
|
503
|
+
"""
|
|
504
|
+
|
|
505
|
+
def __init__(self, numbers: List[Optional[int]], styles: List[str], **kwargs):
|
|
506
|
+
super().__init__(**kwargs)
|
|
507
|
+
self.numbers = numbers
|
|
508
|
+
self.styles = styles
|
|
509
|
+
|
|
510
|
+
def render(self) -> Text:
|
|
511
|
+
"""Render line numbers with appropriate colors."""
|
|
512
|
+
t = Text()
|
|
513
|
+
for i, (num, style) in enumerate(zip(self.numbers, self.styles)):
|
|
514
|
+
if num is None:
|
|
515
|
+
t.append(" \n", style="#1a1a1a")
|
|
516
|
+
else:
|
|
517
|
+
if style == "+":
|
|
518
|
+
t.append(f"{num:>4} \n", style=f"bold on {DIFF_COLORS['addition_bg']}")
|
|
519
|
+
elif style == "-":
|
|
520
|
+
t.append(f"{num:>4} \n", style=f"bold on {DIFF_COLORS['deletion_bg']}")
|
|
521
|
+
else:
|
|
522
|
+
t.append(f"{num:>4} \n", style=DIFF_COLORS["line_no"])
|
|
523
|
+
return t
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
class DiffAnnotations(Static):
|
|
527
|
+
"""Widget showing +/- annotations for diff lines."""
|
|
528
|
+
|
|
529
|
+
DEFAULT_CSS = """
|
|
530
|
+
DiffAnnotations {
|
|
531
|
+
width: 3;
|
|
532
|
+
height: auto;
|
|
533
|
+
background: #000000;
|
|
534
|
+
padding: 0;
|
|
535
|
+
}
|
|
536
|
+
"""
|
|
537
|
+
|
|
538
|
+
def __init__(self, annotations: List[str], **kwargs):
|
|
539
|
+
super().__init__(**kwargs)
|
|
540
|
+
self.annotations = annotations
|
|
541
|
+
|
|
542
|
+
def render(self) -> Text:
|
|
543
|
+
"""Render annotations with colors."""
|
|
544
|
+
t = Text()
|
|
545
|
+
for ann in self.annotations:
|
|
546
|
+
if ann == "+":
|
|
547
|
+
t.append(f" {ann} \n", style=f"bold {DIFF_COLORS['addition']}")
|
|
548
|
+
elif ann == "-":
|
|
549
|
+
t.append(f" {ann} \n", style=f"bold {DIFF_COLORS['deletion']}")
|
|
550
|
+
elif ann == "/":
|
|
551
|
+
t.append(" ╲ \n", style="#1a1a1a")
|
|
552
|
+
else:
|
|
553
|
+
t.append(" \n", style="")
|
|
554
|
+
return t
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
class DiffCodeContent(Static):
|
|
558
|
+
"""Widget showing code content for a diff pane."""
|
|
559
|
+
|
|
560
|
+
DEFAULT_CSS = """
|
|
561
|
+
DiffCodeContent {
|
|
562
|
+
width: 1fr;
|
|
563
|
+
height: auto;
|
|
564
|
+
background: #000000;
|
|
565
|
+
padding: 0;
|
|
566
|
+
}
|
|
567
|
+
"""
|
|
568
|
+
|
|
569
|
+
def __init__(self, lines: List[Tuple[str, str]], **kwargs):
|
|
570
|
+
"""
|
|
571
|
+
Args:
|
|
572
|
+
lines: List of (content, change_type) tuples
|
|
573
|
+
"""
|
|
574
|
+
super().__init__(**kwargs)
|
|
575
|
+
self.lines = lines
|
|
576
|
+
|
|
577
|
+
def render(self) -> Text:
|
|
578
|
+
"""Render code lines with appropriate styling."""
|
|
579
|
+
t = Text()
|
|
580
|
+
for content, change_type in self.lines:
|
|
581
|
+
if change_type == "+":
|
|
582
|
+
t.append(f"{content}\n", style=f"on {DIFF_COLORS['addition_bg']}")
|
|
583
|
+
elif change_type == "-":
|
|
584
|
+
t.append(f"{content}\n", style=f"on {DIFF_COLORS['deletion_bg']}")
|
|
585
|
+
elif change_type == "/":
|
|
586
|
+
# Hatch pattern for missing lines
|
|
587
|
+
hatch = "╲" * max(1, len(content) if content else 40)
|
|
588
|
+
t.append(f"{hatch}\n", style="#1a1a1a")
|
|
589
|
+
else:
|
|
590
|
+
t.append(f"{content}\n", style="")
|
|
591
|
+
return t
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
class SplitDiffWidget(Container):
|
|
595
|
+
"""
|
|
596
|
+
Interactive split diff view with synchronized scrolling.
|
|
597
|
+
|
|
598
|
+
Features:
|
|
599
|
+
- Side-by-side comparison
|
|
600
|
+
- Synchronized scroll between panes
|
|
601
|
+
- Line annotations with colors (+/-)
|
|
602
|
+
- Auto-detection of split vs unified based on width
|
|
603
|
+
- Toggle between split and unified modes
|
|
604
|
+
"""
|
|
605
|
+
|
|
606
|
+
DEFAULT_CSS = """
|
|
607
|
+
SplitDiffWidget {
|
|
608
|
+
width: 100%;
|
|
609
|
+
height: auto;
|
|
610
|
+
min-height: 5;
|
|
611
|
+
max-height: 30;
|
|
612
|
+
background: #000000;
|
|
613
|
+
border: solid #1a1a1a;
|
|
614
|
+
padding: 0;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
SplitDiffWidget #diff-header {
|
|
618
|
+
height: 2;
|
|
619
|
+
background: #0a0a0a;
|
|
620
|
+
border-bottom: solid #1a1a1a;
|
|
621
|
+
padding: 0 1;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
SplitDiffWidget #diff-content {
|
|
625
|
+
height: 1fr;
|
|
626
|
+
layout: horizontal;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
SplitDiffWidget .diff-pane {
|
|
630
|
+
width: 1fr;
|
|
631
|
+
height: 100%;
|
|
632
|
+
layout: horizontal;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
SplitDiffWidget .diff-pane-left {
|
|
636
|
+
border-right: solid #1a1a1a;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
SplitDiffWidget #unified-content {
|
|
640
|
+
width: 100%;
|
|
641
|
+
height: 100%;
|
|
642
|
+
}
|
|
643
|
+
"""
|
|
644
|
+
|
|
645
|
+
BINDINGS = [
|
|
646
|
+
Binding("d", "toggle_mode", "Toggle split/unified", show=True),
|
|
647
|
+
]
|
|
648
|
+
|
|
649
|
+
split_mode: reactive[bool] = reactive(True)
|
|
650
|
+
auto_detect: var[bool] = var(True)
|
|
651
|
+
|
|
652
|
+
class ModeToggled(Message):
|
|
653
|
+
"""Message when mode is toggled."""
|
|
654
|
+
|
|
655
|
+
def __init__(self, is_split: bool) -> None:
|
|
656
|
+
self.is_split = is_split
|
|
657
|
+
super().__init__()
|
|
658
|
+
|
|
659
|
+
def __init__(self, old_content: str, new_content: str, path: str = "file", **kwargs):
|
|
660
|
+
super().__init__(**kwargs)
|
|
661
|
+
self.old_content = old_content
|
|
662
|
+
self.new_content = new_content
|
|
663
|
+
self.path = path
|
|
664
|
+
self._diff = compute_diff(old_content, new_content, path)
|
|
665
|
+
|
|
666
|
+
def compose(self) -> ComposeResult:
|
|
667
|
+
"""Compose the diff widget."""
|
|
668
|
+
# Header
|
|
669
|
+
yield Static(self._render_header(), id="diff-header")
|
|
670
|
+
|
|
671
|
+
# Content area
|
|
672
|
+
with Container(id="diff-content"):
|
|
673
|
+
if self.split_mode:
|
|
674
|
+
yield from self._compose_split()
|
|
675
|
+
else:
|
|
676
|
+
yield from self._compose_unified()
|
|
677
|
+
|
|
678
|
+
def _render_header(self) -> Text:
|
|
679
|
+
"""Render the diff header."""
|
|
680
|
+
t = Text()
|
|
681
|
+
t.append("\n", style="")
|
|
682
|
+
|
|
683
|
+
# File icon based on status
|
|
684
|
+
if self._diff.is_new:
|
|
685
|
+
t.append(" ✨ ", style="bold #22c55e")
|
|
686
|
+
status = "New File"
|
|
687
|
+
status_color = "#22c55e"
|
|
688
|
+
elif self._diff.is_deleted:
|
|
689
|
+
t.append(" 🗑️ ", style="bold #ef4444")
|
|
690
|
+
status = "Deleted"
|
|
691
|
+
status_color = "#ef4444"
|
|
692
|
+
else:
|
|
693
|
+
t.append(" 📝 ", style="bold #f97316")
|
|
694
|
+
status = "Modified"
|
|
695
|
+
status_color = "#f97316"
|
|
696
|
+
|
|
697
|
+
t.append(self._diff.path, style="bold white")
|
|
698
|
+
t.append(f" [{status}]", style=f"bold {status_color}")
|
|
699
|
+
t.append(" ", style="")
|
|
700
|
+
t.append(f"+{self._diff.additions}", style=f"bold {DIFF_COLORS['addition']}")
|
|
701
|
+
t.append(" / ", style="#52525b")
|
|
702
|
+
t.append(f"-{self._diff.deletions}", style=f"bold {DIFF_COLORS['deletion']}")
|
|
703
|
+
t.append(" ", style="")
|
|
704
|
+
mode_text = "split" if self.split_mode else "unified"
|
|
705
|
+
t.append(f"[{mode_text}]", style="#52525b")
|
|
706
|
+
t.append(" ", style="")
|
|
707
|
+
t.append("d", style="bold #a855f7")
|
|
708
|
+
t.append(" toggle", style="#3f3f46")
|
|
709
|
+
|
|
710
|
+
return t
|
|
711
|
+
|
|
712
|
+
def _compose_split(self) -> ComposeResult:
|
|
713
|
+
"""Compose split view with synchronized scroll."""
|
|
714
|
+
# Collect lines for old and new content
|
|
715
|
+
old_lines: List[Tuple[Optional[int], str, str]] = []
|
|
716
|
+
new_lines: List[Tuple[Optional[int], str, str]] = []
|
|
717
|
+
|
|
718
|
+
for hunk in self._diff.hunks:
|
|
719
|
+
for line in hunk.lines:
|
|
720
|
+
if line.change_type == "-":
|
|
721
|
+
old_lines.append((line.line_no_old, line.content, "-"))
|
|
722
|
+
elif line.change_type == "+":
|
|
723
|
+
new_lines.append((line.line_no_new, line.content, "+"))
|
|
724
|
+
else:
|
|
725
|
+
old_lines.append((line.line_no_old, line.content, " "))
|
|
726
|
+
new_lines.append((line.line_no_new, line.content, " "))
|
|
727
|
+
|
|
728
|
+
# Pad to same length
|
|
729
|
+
max_len = max(len(old_lines), len(new_lines))
|
|
730
|
+
while len(old_lines) < max_len:
|
|
731
|
+
old_lines.append((None, "", "/"))
|
|
732
|
+
while len(new_lines) < max_len:
|
|
733
|
+
new_lines.append((None, "", "/"))
|
|
734
|
+
|
|
735
|
+
# Build the panes
|
|
736
|
+
old_numbers = [x[0] for x in old_lines]
|
|
737
|
+
old_annotations = [x[2] for x in old_lines]
|
|
738
|
+
old_code = [(x[1], x[2]) for x in old_lines]
|
|
739
|
+
|
|
740
|
+
new_numbers = [x[0] for x in new_lines]
|
|
741
|
+
new_annotations = [x[2] for x in new_lines]
|
|
742
|
+
new_code = [(x[1], x[2]) for x in new_lines]
|
|
743
|
+
|
|
744
|
+
# Left pane (old)
|
|
745
|
+
with DiffScrollPane(id="left-pane", classes="diff-pane diff-pane-left") as left_pane:
|
|
746
|
+
yield DiffLineNumbers(old_numbers, old_annotations, id="left-numbers")
|
|
747
|
+
yield DiffAnnotations(old_annotations, id="left-annotations")
|
|
748
|
+
yield DiffCodeContent(old_code, id="left-code")
|
|
749
|
+
|
|
750
|
+
# Right pane (new)
|
|
751
|
+
with DiffScrollPane(id="right-pane", classes="diff-pane") as right_pane:
|
|
752
|
+
yield DiffLineNumbers(new_numbers, new_annotations, id="right-numbers")
|
|
753
|
+
yield DiffAnnotations(new_annotations, id="right-annotations")
|
|
754
|
+
yield DiffCodeContent(new_code, id="right-code")
|
|
755
|
+
|
|
756
|
+
def _compose_unified(self) -> ComposeResult:
|
|
757
|
+
"""Compose unified view."""
|
|
758
|
+
lines: List[Tuple[Optional[int], Optional[int], str, str]] = []
|
|
759
|
+
|
|
760
|
+
for hunk in self._diff.hunks:
|
|
761
|
+
for line in hunk.lines:
|
|
762
|
+
lines.append((line.line_no_old, line.line_no_new, line.content, line.change_type))
|
|
763
|
+
|
|
764
|
+
# Render unified content
|
|
765
|
+
t = Text()
|
|
766
|
+
for old_no, new_no, content, change_type in lines:
|
|
767
|
+
# Line numbers
|
|
768
|
+
old_str = f"{old_no:>4}" if old_no else " "
|
|
769
|
+
new_str = f"{new_no:>4}" if new_no else " "
|
|
770
|
+
t.append(f" {old_str} {new_str} ", style=DIFF_COLORS["line_no"])
|
|
771
|
+
|
|
772
|
+
# Change indicator and content
|
|
773
|
+
if change_type == "+":
|
|
774
|
+
t.append("│", style=DIFF_COLORS["addition"])
|
|
775
|
+
t.append(f" {content}\n", style=f"on {DIFF_COLORS['addition_bg']}")
|
|
776
|
+
elif change_type == "-":
|
|
777
|
+
t.append("│", style=DIFF_COLORS["deletion"])
|
|
778
|
+
t.append(f" {content}\n", style=f"on {DIFF_COLORS['deletion_bg']}")
|
|
779
|
+
else:
|
|
780
|
+
t.append("│", style="#3f3f46")
|
|
781
|
+
t.append(f" {content}\n", style="")
|
|
782
|
+
|
|
783
|
+
with ScrollableContainer(id="unified-content"):
|
|
784
|
+
yield Static(t, id="unified-text")
|
|
785
|
+
|
|
786
|
+
def on_mount(self) -> None:
|
|
787
|
+
"""Link the scroll panes after mount."""
|
|
788
|
+
if self.split_mode:
|
|
789
|
+
self._link_scroll_panes()
|
|
790
|
+
|
|
791
|
+
def _link_scroll_panes(self) -> None:
|
|
792
|
+
"""Link the two scroll panes for synchronized scrolling."""
|
|
793
|
+
try:
|
|
794
|
+
left = self.query_one("#left-pane", DiffScrollPane)
|
|
795
|
+
right = self.query_one("#right-pane", DiffScrollPane)
|
|
796
|
+
left.scroll_link = right
|
|
797
|
+
right.scroll_link = left
|
|
798
|
+
except Exception:
|
|
799
|
+
pass
|
|
800
|
+
|
|
801
|
+
def watch_split_mode(self, split_mode: bool) -> None:
|
|
802
|
+
"""Recompose when mode changes."""
|
|
803
|
+
# Remove old content and recompose
|
|
804
|
+
try:
|
|
805
|
+
content = self.query_one("#diff-content", Container)
|
|
806
|
+
content.remove_children()
|
|
807
|
+
if split_mode:
|
|
808
|
+
for widget in self._compose_split():
|
|
809
|
+
content.mount(widget)
|
|
810
|
+
self._link_scroll_panes()
|
|
811
|
+
else:
|
|
812
|
+
for widget in self._compose_unified():
|
|
813
|
+
content.mount(widget)
|
|
814
|
+
# Update header
|
|
815
|
+
self.query_one("#diff-header", Static).update(self._render_header())
|
|
816
|
+
except Exception:
|
|
817
|
+
pass
|
|
818
|
+
|
|
819
|
+
def action_toggle_mode(self) -> None:
|
|
820
|
+
"""Toggle between split and unified mode."""
|
|
821
|
+
self.split_mode = not self.split_mode
|
|
822
|
+
self.post_message(self.ModeToggled(self.split_mode))
|
|
823
|
+
|
|
824
|
+
def on_resize(self, event) -> None:
|
|
825
|
+
"""Auto-detect best mode based on width."""
|
|
826
|
+
if self.auto_detect:
|
|
827
|
+
# If terminal is narrow, use unified mode
|
|
828
|
+
if event.size.width < 100:
|
|
829
|
+
if self.split_mode:
|
|
830
|
+
self.split_mode = False
|
|
831
|
+
else:
|
|
832
|
+
if not self.split_mode:
|
|
833
|
+
self.split_mode = True
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
class UnifiedDiffWidget(Container):
|
|
837
|
+
"""Simpler unified diff widget for display in conversation."""
|
|
838
|
+
|
|
839
|
+
DEFAULT_CSS = """
|
|
840
|
+
UnifiedDiffWidget {
|
|
841
|
+
width: 100%;
|
|
842
|
+
height: auto;
|
|
843
|
+
max-height: 25;
|
|
844
|
+
background: #0a0a0a;
|
|
845
|
+
border: solid #1a1a1a;
|
|
846
|
+
padding: 0;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
UnifiedDiffWidget #unified-header {
|
|
850
|
+
height: 2;
|
|
851
|
+
background: #0a0a0a;
|
|
852
|
+
border-bottom: solid #1a1a1a;
|
|
853
|
+
padding: 0 1;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
UnifiedDiffWidget #unified-body {
|
|
857
|
+
height: 1fr;
|
|
858
|
+
overflow-y: auto;
|
|
859
|
+
padding: 0;
|
|
860
|
+
}
|
|
861
|
+
"""
|
|
862
|
+
|
|
863
|
+
def __init__(self, diff: FileDiff, **kwargs):
|
|
864
|
+
super().__init__(**kwargs)
|
|
865
|
+
self._diff = diff
|
|
866
|
+
|
|
867
|
+
def compose(self) -> ComposeResult:
|
|
868
|
+
"""Compose the unified diff widget."""
|
|
869
|
+
yield Static(self._render_header(), id="unified-header")
|
|
870
|
+
with ScrollableContainer(id="unified-body"):
|
|
871
|
+
yield Static(self._render_content(), id="unified-content")
|
|
872
|
+
|
|
873
|
+
def _render_header(self) -> Text:
|
|
874
|
+
"""Render header."""
|
|
875
|
+
t = Text()
|
|
876
|
+
t.append("\n", style="")
|
|
877
|
+
|
|
878
|
+
if self._diff.is_new:
|
|
879
|
+
t.append(" ✨ ", style="bold #22c55e")
|
|
880
|
+
elif self._diff.is_deleted:
|
|
881
|
+
t.append(" 🗑️ ", style="bold #ef4444")
|
|
882
|
+
else:
|
|
883
|
+
t.append(" 📝 ", style="bold #f97316")
|
|
884
|
+
|
|
885
|
+
t.append(self._diff.path, style="bold white")
|
|
886
|
+
t.append(" ", style="")
|
|
887
|
+
t.append(f"+{self._diff.additions}", style=f"bold {DIFF_COLORS['addition']}")
|
|
888
|
+
t.append("/", style="#52525b")
|
|
889
|
+
t.append(f"-{self._diff.deletions}", style=f"bold {DIFF_COLORS['deletion']}")
|
|
890
|
+
|
|
891
|
+
return t
|
|
892
|
+
|
|
893
|
+
def _render_content(self) -> Text:
|
|
894
|
+
"""Render unified diff content."""
|
|
895
|
+
t = Text()
|
|
896
|
+
|
|
897
|
+
for hunk in self._diff.hunks:
|
|
898
|
+
# Hunk header
|
|
899
|
+
t.append(
|
|
900
|
+
f" @@ -{hunk.old_start},{hunk.old_count} +{hunk.new_start},{hunk.new_count} @@\n",
|
|
901
|
+
style="#06b6d4",
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
for line in hunk.lines:
|
|
905
|
+
old_str = f"{line.line_no_old:>4}" if line.line_no_old else " "
|
|
906
|
+
new_str = f"{line.line_no_new:>4}" if line.line_no_new else " "
|
|
907
|
+
t.append(f" {old_str} {new_str} ", style=DIFF_COLORS["line_no"])
|
|
908
|
+
|
|
909
|
+
if line.change_type == "+":
|
|
910
|
+
t.append("│", style=DIFF_COLORS["addition"])
|
|
911
|
+
t.append(f" {line.content}\n", style=f"on {DIFF_COLORS['addition_bg']}")
|
|
912
|
+
elif line.change_type == "-":
|
|
913
|
+
t.append("│", style=DIFF_COLORS["deletion"])
|
|
914
|
+
t.append(f" {line.content}\n", style=f"on {DIFF_COLORS['deletion_bg']}")
|
|
915
|
+
else:
|
|
916
|
+
t.append("│", style="#3f3f46")
|
|
917
|
+
t.append(f" {line.content}\n", style="")
|
|
918
|
+
|
|
919
|
+
return t
|