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/sidebar.py
ADDED
|
@@ -0,0 +1,2991 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SuperQode Enhanced Sidebar - Colorful File Browser with Preview
|
|
3
|
+
|
|
4
|
+
Features:
|
|
5
|
+
- File type icons (Python, JS, etc.)
|
|
6
|
+
- Gradient colored folders
|
|
7
|
+
- File preview on selection
|
|
8
|
+
- Syntax highlighted content
|
|
9
|
+
- File info display
|
|
10
|
+
- Collapsible panels (Plan, Files, Preview)
|
|
11
|
+
- Git status indicator
|
|
12
|
+
- Quick file search (Ctrl+F)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import subprocess
|
|
18
|
+
import asyncio
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Optional, Callable, List
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
|
|
23
|
+
from textual.app import ComposeResult
|
|
24
|
+
from textual.containers import Container, Vertical, Horizontal, ScrollableContainer
|
|
25
|
+
from textual.widgets import Static, DirectoryTree, Tree, Label, Input, Collapsible
|
|
26
|
+
from textual.widgets.tree import TreeNode
|
|
27
|
+
from textual.widgets._directory_tree import DirEntry
|
|
28
|
+
from textual.reactive import reactive
|
|
29
|
+
from textual.message import Message
|
|
30
|
+
from textual import on, work
|
|
31
|
+
from textual.binding import Binding
|
|
32
|
+
|
|
33
|
+
from rich.text import Text
|
|
34
|
+
from rich.syntax import Syntax
|
|
35
|
+
from rich.panel import Panel
|
|
36
|
+
from rich.box import ROUNDED
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ============================================================================
|
|
40
|
+
# FILE TYPE ICONS - Nerd Font style icons with colors
|
|
41
|
+
# ============================================================================
|
|
42
|
+
|
|
43
|
+
FILE_ICONS = {
|
|
44
|
+
# Python
|
|
45
|
+
".py": ("🐍", "#3776ab"),
|
|
46
|
+
".pyw": ("🐍", "#3776ab"),
|
|
47
|
+
".pyi": ("🐍", "#3776ab"),
|
|
48
|
+
".pyx": ("🐍", "#3776ab"),
|
|
49
|
+
".ipynb": ("📓", "#f37626"),
|
|
50
|
+
# JavaScript/TypeScript
|
|
51
|
+
".js": ("📜", "#f7df1e"),
|
|
52
|
+
".jsx": ("⚛️", "#61dafb"),
|
|
53
|
+
".ts": ("💠", "#3178c6"),
|
|
54
|
+
".tsx": ("⚛️", "#61dafb"),
|
|
55
|
+
".mjs": ("📜", "#f7df1e"),
|
|
56
|
+
".cjs": ("📜", "#f7df1e"),
|
|
57
|
+
".vue": ("💚", "#42b883"),
|
|
58
|
+
".svelte": ("🔥", "#ff3e00"),
|
|
59
|
+
# Web
|
|
60
|
+
".html": ("🌐", "#e34f26"),
|
|
61
|
+
".htm": ("🌐", "#e34f26"),
|
|
62
|
+
".css": ("🎨", "#1572b6"),
|
|
63
|
+
".scss": ("🎨", "#cc6699"),
|
|
64
|
+
".sass": ("🎨", "#cc6699"),
|
|
65
|
+
".less": ("🎨", "#1d365d"),
|
|
66
|
+
# Data formats
|
|
67
|
+
".json": ("📋", "#cbcb41"),
|
|
68
|
+
".yaml": ("⚙️", "#cb171e"),
|
|
69
|
+
".yml": ("⚙️", "#cb171e"),
|
|
70
|
+
".toml": ("⚙️", "#9c4121"),
|
|
71
|
+
".xml": ("📰", "#e37933"),
|
|
72
|
+
".csv": ("📊", "#217346"),
|
|
73
|
+
# Shell
|
|
74
|
+
".sh": ("💻", "#4eaa25"),
|
|
75
|
+
".bash": ("💻", "#4eaa25"),
|
|
76
|
+
".zsh": ("💻", "#4eaa25"),
|
|
77
|
+
".fish": ("🐟", "#4eaa25"),
|
|
78
|
+
".ps1": ("💻", "#012456"),
|
|
79
|
+
".bat": ("💻", "#c1f12e"),
|
|
80
|
+
".cmd": ("💻", "#c1f12e"),
|
|
81
|
+
# Systems languages
|
|
82
|
+
".c": ("🔷", "#555555"),
|
|
83
|
+
".h": ("🔷", "#555555"),
|
|
84
|
+
".cpp": ("🔷", "#f34b7d"),
|
|
85
|
+
".hpp": ("🔷", "#f34b7d"),
|
|
86
|
+
".cc": ("🔷", "#f34b7d"),
|
|
87
|
+
".cxx": ("🔷", "#f34b7d"),
|
|
88
|
+
".rs": ("🦀", "#dea584"),
|
|
89
|
+
".go": ("🐹", "#00add8"),
|
|
90
|
+
".java": ("☕", "#b07219"),
|
|
91
|
+
".kt": ("🟣", "#a97bff"),
|
|
92
|
+
".kts": ("🟣", "#a97bff"),
|
|
93
|
+
".scala": ("🔴", "#c22d40"),
|
|
94
|
+
".swift": ("🍎", "#f05138"),
|
|
95
|
+
# Other languages
|
|
96
|
+
".rb": ("💎", "#cc342d"),
|
|
97
|
+
".php": ("🐘", "#777bb4"),
|
|
98
|
+
".pl": ("🐪", "#0298c3"),
|
|
99
|
+
".lua": ("🌙", "#000080"),
|
|
100
|
+
".r": ("📊", "#198ce7"),
|
|
101
|
+
".R": ("📊", "#198ce7"),
|
|
102
|
+
".jl": ("🔮", "#9558b2"),
|
|
103
|
+
".ex": ("💧", "#6e4a7e"),
|
|
104
|
+
".exs": ("💧", "#6e4a7e"),
|
|
105
|
+
".erl": ("📡", "#b83998"),
|
|
106
|
+
".hs": ("λ", "#5e5086"),
|
|
107
|
+
".ml": ("🐫", "#dc6b1f"),
|
|
108
|
+
".fs": ("🔷", "#b845fc"),
|
|
109
|
+
".clj": ("🟢", "#63b132"),
|
|
110
|
+
".lisp": ("🟢", "#3fb68b"),
|
|
111
|
+
# Config files
|
|
112
|
+
".ini": ("⚙️", "#6d8086"),
|
|
113
|
+
".cfg": ("⚙️", "#6d8086"),
|
|
114
|
+
".conf": ("⚙️", "#6d8086"),
|
|
115
|
+
".env": ("🔐", "#ecd53f"),
|
|
116
|
+
".gitignore": ("🚫", "#f05032"),
|
|
117
|
+
".dockerignore": ("🚫", "#2496ed"),
|
|
118
|
+
".editorconfig": ("⚙️", "#6d8086"),
|
|
119
|
+
# Documentation
|
|
120
|
+
".md": ("📝", "#083fa1"),
|
|
121
|
+
".markdown": ("📝", "#083fa1"),
|
|
122
|
+
".rst": ("📝", "#141414"),
|
|
123
|
+
".txt": ("📄", "#6d8086"),
|
|
124
|
+
".log": ("📋", "#6d8086"),
|
|
125
|
+
".pdf": ("📕", "#ff0000"),
|
|
126
|
+
# Database
|
|
127
|
+
".sql": ("🗄️", "#e38c00"),
|
|
128
|
+
".sqlite": ("🗄️", "#003b57"),
|
|
129
|
+
".db": ("🗄️", "#003b57"),
|
|
130
|
+
# Build/Config
|
|
131
|
+
".dockerfile": ("🐳", "#2496ed"),
|
|
132
|
+
".docker": ("🐳", "#2496ed"),
|
|
133
|
+
# Images
|
|
134
|
+
".png": ("🖼️", "#a4c639"),
|
|
135
|
+
".jpg": ("🖼️", "#a4c639"),
|
|
136
|
+
".jpeg": ("🖼️", "#a4c639"),
|
|
137
|
+
".gif": ("🖼️", "#a4c639"),
|
|
138
|
+
".svg": ("🎨", "#ffb13b"),
|
|
139
|
+
".ico": ("🖼️", "#a4c639"),
|
|
140
|
+
".webp": ("🖼️", "#a4c639"),
|
|
141
|
+
# Archives
|
|
142
|
+
".zip": ("📦", "#6d8086"),
|
|
143
|
+
".tar": ("📦", "#6d8086"),
|
|
144
|
+
".gz": ("📦", "#6d8086"),
|
|
145
|
+
".rar": ("📦", "#6d8086"),
|
|
146
|
+
".7z": ("📦", "#6d8086"),
|
|
147
|
+
# Other
|
|
148
|
+
".diff": ("📊", "#41b883"),
|
|
149
|
+
".patch": ("📊", "#41b883"),
|
|
150
|
+
".graphql": ("💜", "#e10098"),
|
|
151
|
+
".proto": ("📡", "#6d8086"),
|
|
152
|
+
".tf": ("🟣", "#844fba"),
|
|
153
|
+
".hcl": ("🟣", "#844fba"),
|
|
154
|
+
".lock": ("🔒", "#6d8086"),
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
# Special filenames
|
|
158
|
+
SPECIAL_FILES = {
|
|
159
|
+
"Makefile": ("🔧", "#6d8086"),
|
|
160
|
+
"Dockerfile": ("🐳", "#2496ed"),
|
|
161
|
+
"Vagrantfile": ("📦", "#1868f2"),
|
|
162
|
+
"Gemfile": ("💎", "#cc342d"),
|
|
163
|
+
"Rakefile": ("💎", "#cc342d"),
|
|
164
|
+
"CMakeLists.txt": ("🔧", "#064f8c"),
|
|
165
|
+
"package.json": ("📦", "#cb3837"),
|
|
166
|
+
"package-lock.json": ("🔒", "#cb3837"),
|
|
167
|
+
"yarn.lock": ("🔒", "#2c8ebb"),
|
|
168
|
+
"pnpm-lock.yaml": ("🔒", "#f9ad00"),
|
|
169
|
+
"requirements.txt": ("📋", "#3776ab"),
|
|
170
|
+
"pyproject.toml": ("🐍", "#3776ab"),
|
|
171
|
+
"setup.py": ("🐍", "#3776ab"),
|
|
172
|
+
"setup.cfg": ("🐍", "#3776ab"),
|
|
173
|
+
"Cargo.toml": ("🦀", "#dea584"),
|
|
174
|
+
"Cargo.lock": ("🔒", "#dea584"),
|
|
175
|
+
"go.mod": ("🐹", "#00add8"),
|
|
176
|
+
"go.sum": ("🔒", "#00add8"),
|
|
177
|
+
".gitignore": ("🚫", "#f05032"),
|
|
178
|
+
".gitattributes": ("📋", "#f05032"),
|
|
179
|
+
".prettierrc": ("🎨", "#f7b93e"),
|
|
180
|
+
".eslintrc": ("🔍", "#4b32c3"),
|
|
181
|
+
".eslintrc.js": ("🔍", "#4b32c3"),
|
|
182
|
+
".eslintrc.json": ("🔍", "#4b32c3"),
|
|
183
|
+
"tsconfig.json": ("💠", "#3178c6"),
|
|
184
|
+
"jsconfig.json": ("📜", "#f7df1e"),
|
|
185
|
+
"README.md": ("📖", "#083fa1"),
|
|
186
|
+
"LICENSE": ("📜", "#6d8086"),
|
|
187
|
+
"CHANGELOG.md": ("📋", "#083fa1"),
|
|
188
|
+
"CONTRIBUTING.md": ("🤝", "#083fa1"),
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
# Folder icons with gradient colors
|
|
192
|
+
FOLDER_ICONS = {
|
|
193
|
+
"src": ("📁", "#a855f7"),
|
|
194
|
+
"source": ("📁", "#a855f7"),
|
|
195
|
+
"lib": ("📚", "#ec4899"),
|
|
196
|
+
"libs": ("📚", "#ec4899"),
|
|
197
|
+
"test": ("🧪", "#22c55e"),
|
|
198
|
+
"tests": ("🧪", "#22c55e"),
|
|
199
|
+
"spec": ("🧪", "#22c55e"),
|
|
200
|
+
"specs": ("🧪", "#22c55e"),
|
|
201
|
+
"__tests__": ("🧪", "#22c55e"),
|
|
202
|
+
"docs": ("📖", "#06b6d4"),
|
|
203
|
+
"doc": ("📖", "#06b6d4"),
|
|
204
|
+
"documentation": ("📖", "#06b6d4"),
|
|
205
|
+
"config": ("⚙️", "#f97316"),
|
|
206
|
+
"configs": ("⚙️", "#f97316"),
|
|
207
|
+
"settings": ("⚙️", "#f97316"),
|
|
208
|
+
"public": ("🌐", "#3b82f6"),
|
|
209
|
+
"static": ("🌐", "#3b82f6"),
|
|
210
|
+
"assets": ("🎨", "#eab308"),
|
|
211
|
+
"images": ("🖼️", "#a4c639"),
|
|
212
|
+
"img": ("🖼️", "#a4c639"),
|
|
213
|
+
"icons": ("🎯", "#f43f5e"),
|
|
214
|
+
"styles": ("🎨", "#ec4899"),
|
|
215
|
+
"css": ("🎨", "#1572b6"),
|
|
216
|
+
"scripts": ("💻", "#4eaa25"),
|
|
217
|
+
"bin": ("⚡", "#f59e0b"),
|
|
218
|
+
"build": ("🔨", "#6d8086"),
|
|
219
|
+
"dist": ("📦", "#6d8086"),
|
|
220
|
+
"out": ("📦", "#6d8086"),
|
|
221
|
+
"output": ("📦", "#6d8086"),
|
|
222
|
+
"node_modules": ("📦", "#cb3837"),
|
|
223
|
+
"vendor": ("📦", "#6d8086"),
|
|
224
|
+
"packages": ("📦", "#6d8086"),
|
|
225
|
+
".git": ("📂", "#f05032"),
|
|
226
|
+
".github": ("🐙", "#181717"),
|
|
227
|
+
".vscode": ("💙", "#007acc"),
|
|
228
|
+
".idea": ("🧠", "#000000"),
|
|
229
|
+
"components": ("🧩", "#61dafb"),
|
|
230
|
+
"pages": ("📄", "#000000"),
|
|
231
|
+
"views": ("👁️", "#42b883"),
|
|
232
|
+
"models": ("🗃️", "#ff6b6b"),
|
|
233
|
+
"controllers": ("🎮", "#4ecdc4"),
|
|
234
|
+
"services": ("⚡", "#f7df1e"),
|
|
235
|
+
"utils": ("🔧", "#6d8086"),
|
|
236
|
+
"helpers": ("🤝", "#6d8086"),
|
|
237
|
+
"hooks": ("🪝", "#61dafb"),
|
|
238
|
+
"api": ("🔌", "#009688"),
|
|
239
|
+
"routes": ("🛤️", "#ff5722"),
|
|
240
|
+
"middleware": ("🔗", "#9c27b0"),
|
|
241
|
+
"migrations": ("📊", "#e38c00"),
|
|
242
|
+
"seeds": ("🌱", "#4caf50"),
|
|
243
|
+
"fixtures": ("📌", "#795548"),
|
|
244
|
+
"mocks": ("🎭", "#9e9e9e"),
|
|
245
|
+
"__pycache__": ("📦", "#3776ab"),
|
|
246
|
+
".pytest_cache": ("🧪", "#22c55e"),
|
|
247
|
+
"venv": ("🐍", "#3776ab"),
|
|
248
|
+
".venv": ("🐍", "#3776ab"),
|
|
249
|
+
"env": ("🔐", "#ecd53f"),
|
|
250
|
+
".env": ("🔐", "#ecd53f"),
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
# Default icons
|
|
254
|
+
DEFAULT_FILE_ICON = ("📄", "#6d8086")
|
|
255
|
+
DEFAULT_FOLDER_ICON = ("📁", "#a855f7")
|
|
256
|
+
DEFAULT_FOLDER_OPEN_ICON = ("📂", "#ec4899")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def get_file_icon(path: Path) -> tuple[str, str]:
|
|
260
|
+
"""Get icon and color for a file."""
|
|
261
|
+
name = path.name
|
|
262
|
+
ext = path.suffix.lower()
|
|
263
|
+
|
|
264
|
+
# Check special filenames first
|
|
265
|
+
if name in SPECIAL_FILES:
|
|
266
|
+
return SPECIAL_FILES[name]
|
|
267
|
+
|
|
268
|
+
# Check extension
|
|
269
|
+
if ext in FILE_ICONS:
|
|
270
|
+
return FILE_ICONS[ext]
|
|
271
|
+
|
|
272
|
+
return DEFAULT_FILE_ICON
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def get_folder_icon(name: str, is_open: bool = False) -> tuple[str, str]:
|
|
276
|
+
"""Get icon and color for a folder."""
|
|
277
|
+
name_lower = name.lower()
|
|
278
|
+
|
|
279
|
+
if name_lower in FOLDER_ICONS:
|
|
280
|
+
icon, color = FOLDER_ICONS[name_lower]
|
|
281
|
+
# Use open folder variant if expanded
|
|
282
|
+
if is_open and icon == "📁":
|
|
283
|
+
icon = "📂"
|
|
284
|
+
return icon, color
|
|
285
|
+
|
|
286
|
+
return DEFAULT_FOLDER_OPEN_ICON if is_open else DEFAULT_FOLDER_ICON
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ============================================================================
|
|
290
|
+
# LANGUAGE DETECTION FOR SYNTAX HIGHLIGHTING
|
|
291
|
+
# ============================================================================
|
|
292
|
+
|
|
293
|
+
LANGUAGE_MAP = {
|
|
294
|
+
".py": "python",
|
|
295
|
+
".pyw": "python",
|
|
296
|
+
".pyi": "python",
|
|
297
|
+
".js": "javascript",
|
|
298
|
+
".jsx": "jsx",
|
|
299
|
+
".mjs": "javascript",
|
|
300
|
+
".ts": "typescript",
|
|
301
|
+
".tsx": "tsx",
|
|
302
|
+
".html": "html",
|
|
303
|
+
".htm": "html",
|
|
304
|
+
".css": "css",
|
|
305
|
+
".scss": "scss",
|
|
306
|
+
".sass": "sass",
|
|
307
|
+
".less": "less",
|
|
308
|
+
".json": "json",
|
|
309
|
+
".yaml": "yaml",
|
|
310
|
+
".yml": "yaml",
|
|
311
|
+
".toml": "toml",
|
|
312
|
+
".xml": "xml",
|
|
313
|
+
".svg": "xml",
|
|
314
|
+
".sh": "bash",
|
|
315
|
+
".bash": "bash",
|
|
316
|
+
".zsh": "bash",
|
|
317
|
+
".c": "c",
|
|
318
|
+
".h": "c",
|
|
319
|
+
".cpp": "cpp",
|
|
320
|
+
".hpp": "cpp",
|
|
321
|
+
".rs": "rust",
|
|
322
|
+
".go": "go",
|
|
323
|
+
".java": "java",
|
|
324
|
+
".rb": "ruby",
|
|
325
|
+
".php": "php",
|
|
326
|
+
".swift": "swift",
|
|
327
|
+
".kt": "kotlin",
|
|
328
|
+
".scala": "scala",
|
|
329
|
+
".md": "markdown",
|
|
330
|
+
".rst": "rst",
|
|
331
|
+
".sql": "sql",
|
|
332
|
+
".graphql": "graphql",
|
|
333
|
+
".dockerfile": "dockerfile",
|
|
334
|
+
".tf": "terraform",
|
|
335
|
+
".hcl": "hcl",
|
|
336
|
+
".vue": "vue",
|
|
337
|
+
".svelte": "svelte",
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def detect_language(path: Path) -> str:
|
|
342
|
+
"""Detect programming language from file path."""
|
|
343
|
+
name = path.name.lower()
|
|
344
|
+
ext = path.suffix.lower()
|
|
345
|
+
|
|
346
|
+
# Special filenames
|
|
347
|
+
if name == "dockerfile":
|
|
348
|
+
return "dockerfile"
|
|
349
|
+
if name == "makefile":
|
|
350
|
+
return "makefile"
|
|
351
|
+
if name in ("gemfile", "rakefile", "vagrantfile"):
|
|
352
|
+
return "ruby"
|
|
353
|
+
|
|
354
|
+
return LANGUAGE_MAP.get(ext, "text")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# ============================================================================
|
|
358
|
+
# CUSTOM DIRECTORY TREE WITH ICONS
|
|
359
|
+
# ============================================================================
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class ColorfulDirectoryTree(DirectoryTree):
|
|
363
|
+
"""Enhanced DirectoryTree with colorful file type icons."""
|
|
364
|
+
|
|
365
|
+
BINDINGS = [
|
|
366
|
+
Binding("o", "open_file", "Open in view", show=True),
|
|
367
|
+
]
|
|
368
|
+
|
|
369
|
+
class FileOpenRequested(Message):
|
|
370
|
+
"""Message sent when a file should be opened in main view."""
|
|
371
|
+
|
|
372
|
+
def __init__(self, path: Path) -> None:
|
|
373
|
+
self.path = path
|
|
374
|
+
super().__init__()
|
|
375
|
+
|
|
376
|
+
def render_label(self, node: TreeNode, base_style, style) -> Text:
|
|
377
|
+
"""Render a label with file type icon."""
|
|
378
|
+
path = node.data.path if node.data else None
|
|
379
|
+
|
|
380
|
+
if path is None:
|
|
381
|
+
return Text(str(node.label))
|
|
382
|
+
|
|
383
|
+
label = Text()
|
|
384
|
+
|
|
385
|
+
if path.is_dir():
|
|
386
|
+
# Folder with icon
|
|
387
|
+
is_open = node.is_expanded
|
|
388
|
+
icon, color = get_folder_icon(path.name, is_open)
|
|
389
|
+
label.append(f"{icon} ", style=f"bold {color}")
|
|
390
|
+
label.append(path.name, style=f"{color}")
|
|
391
|
+
else:
|
|
392
|
+
# File with icon
|
|
393
|
+
icon, color = get_file_icon(path)
|
|
394
|
+
label.append(f"{icon} ", style=color)
|
|
395
|
+
label.append(path.name, style="white")
|
|
396
|
+
|
|
397
|
+
return label
|
|
398
|
+
|
|
399
|
+
def filter_paths(self, paths):
|
|
400
|
+
"""Filter out hidden and ignored paths."""
|
|
401
|
+
ignore_patterns = {
|
|
402
|
+
"__pycache__",
|
|
403
|
+
".git",
|
|
404
|
+
".svn",
|
|
405
|
+
".hg",
|
|
406
|
+
"node_modules",
|
|
407
|
+
".pytest_cache",
|
|
408
|
+
".mypy_cache",
|
|
409
|
+
".ruff_cache",
|
|
410
|
+
".tox",
|
|
411
|
+
".nox",
|
|
412
|
+
".coverage",
|
|
413
|
+
"dist",
|
|
414
|
+
"build",
|
|
415
|
+
"*.egg-info",
|
|
416
|
+
".eggs",
|
|
417
|
+
"venv",
|
|
418
|
+
".venv",
|
|
419
|
+
"env",
|
|
420
|
+
".env",
|
|
421
|
+
".DS_Store",
|
|
422
|
+
"Thumbs.db",
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
for path in paths:
|
|
426
|
+
name = path.name
|
|
427
|
+
# Skip hidden files (except some config files)
|
|
428
|
+
if name.startswith(".") and name not in {".github", ".gitignore", ".env", ".vscode"}:
|
|
429
|
+
continue
|
|
430
|
+
# Skip ignored patterns
|
|
431
|
+
if name in ignore_patterns:
|
|
432
|
+
continue
|
|
433
|
+
if any(name.endswith(p.replace("*", "")) for p in ignore_patterns if "*" in p):
|
|
434
|
+
continue
|
|
435
|
+
yield path
|
|
436
|
+
|
|
437
|
+
def action_open_file(self) -> None:
|
|
438
|
+
"""Open the selected file in main view."""
|
|
439
|
+
node = self.cursor_node
|
|
440
|
+
if node and node.data and hasattr(node.data, "path"):
|
|
441
|
+
path = node.data.path
|
|
442
|
+
if path.is_file():
|
|
443
|
+
self.post_message(self.FileOpenRequested(path))
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
# ============================================================================
|
|
447
|
+
# FILE PREVIEW PANEL - Scrollable with user-friendly hints
|
|
448
|
+
# ============================================================================
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
class FilePreviewScroll(ScrollableContainer):
|
|
452
|
+
"""Scrollable container for file preview."""
|
|
453
|
+
|
|
454
|
+
DEFAULT_CSS = """
|
|
455
|
+
FilePreviewScroll {
|
|
456
|
+
height: 100%;
|
|
457
|
+
background: #000000;
|
|
458
|
+
scrollbar-size: 1 1;
|
|
459
|
+
}
|
|
460
|
+
"""
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
class FilePreview(Container):
|
|
464
|
+
"""Panel showing file content preview with syntax highlighting."""
|
|
465
|
+
|
|
466
|
+
DEFAULT_CSS = """
|
|
467
|
+
FilePreview {
|
|
468
|
+
height: 100%;
|
|
469
|
+
background: #000000;
|
|
470
|
+
padding: 0;
|
|
471
|
+
layout: vertical;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
FilePreview #preview-header {
|
|
475
|
+
height: 3;
|
|
476
|
+
background: #000000;
|
|
477
|
+
border-bottom: solid #1a1a1a;
|
|
478
|
+
padding: 0 1;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
FilePreview #preview-hints {
|
|
482
|
+
height: 2;
|
|
483
|
+
background: #000000;
|
|
484
|
+
border-top: solid #1a1a1a;
|
|
485
|
+
padding: 0 1;
|
|
486
|
+
text-align: center;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
FilePreview #preview-content {
|
|
490
|
+
height: 1fr;
|
|
491
|
+
background: #000000;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
FilePreview .preview-syntax {
|
|
495
|
+
height: auto;
|
|
496
|
+
padding: 1;
|
|
497
|
+
}
|
|
498
|
+
"""
|
|
499
|
+
|
|
500
|
+
BINDINGS = [
|
|
501
|
+
Binding("escape", "close_preview", "Close", show=False),
|
|
502
|
+
Binding("q", "close_preview", "Close", show=False),
|
|
503
|
+
Binding("e", "edit_file", "Edit", show=False),
|
|
504
|
+
]
|
|
505
|
+
|
|
506
|
+
current_file: reactive[Optional[Path]] = reactive(None)
|
|
507
|
+
|
|
508
|
+
class PreviewClosed(Message):
|
|
509
|
+
"""Message sent when preview is closed."""
|
|
510
|
+
|
|
511
|
+
pass
|
|
512
|
+
|
|
513
|
+
class EditRequested(Message):
|
|
514
|
+
"""Message sent when user wants to edit the file."""
|
|
515
|
+
|
|
516
|
+
def __init__(self, path: Path) -> None:
|
|
517
|
+
self.path = path
|
|
518
|
+
super().__init__()
|
|
519
|
+
|
|
520
|
+
def __init__(self, **kwargs):
|
|
521
|
+
super().__init__(**kwargs)
|
|
522
|
+
self._content_cache: dict[Path, str] = {}
|
|
523
|
+
|
|
524
|
+
def compose(self) -> ComposeResult:
|
|
525
|
+
"""Compose the preview layout."""
|
|
526
|
+
# Header
|
|
527
|
+
yield Static(self._render_header(), id="preview-header")
|
|
528
|
+
|
|
529
|
+
# Scrollable content area
|
|
530
|
+
with FilePreviewScroll(id="preview-content"):
|
|
531
|
+
yield Static(self._render_content(), id="preview-syntax", classes="preview-syntax")
|
|
532
|
+
|
|
533
|
+
# User-friendly hints at bottom
|
|
534
|
+
yield Static(self._render_hints(), id="preview-hints")
|
|
535
|
+
|
|
536
|
+
def watch_current_file(self, path: Optional[Path]) -> None:
|
|
537
|
+
"""Update display when file changes."""
|
|
538
|
+
try:
|
|
539
|
+
self.query_one("#preview-header", Static).update(self._render_header())
|
|
540
|
+
self.query_one("#preview-syntax", Static).update(self._render_content())
|
|
541
|
+
self.query_one("#preview-hints", Static).update(self._render_hints())
|
|
542
|
+
# Scroll to top when new file selected
|
|
543
|
+
scroll = self.query_one("#preview-content", FilePreviewScroll)
|
|
544
|
+
scroll.scroll_home(animate=False)
|
|
545
|
+
except Exception:
|
|
546
|
+
pass
|
|
547
|
+
|
|
548
|
+
def _render_header(self) -> Text:
|
|
549
|
+
"""Render the header with file info."""
|
|
550
|
+
t = Text()
|
|
551
|
+
|
|
552
|
+
if self.current_file is None:
|
|
553
|
+
t.append("\n 📄 ", style="bold #a855f7")
|
|
554
|
+
t.append("No file selected", style="#71717a")
|
|
555
|
+
return t
|
|
556
|
+
|
|
557
|
+
path = self.current_file
|
|
558
|
+
icon, color = get_file_icon(path)
|
|
559
|
+
|
|
560
|
+
t.append(f"\n {icon} ", style=f"bold {color}")
|
|
561
|
+
t.append(path.name, style=f"bold white")
|
|
562
|
+
|
|
563
|
+
# File info
|
|
564
|
+
try:
|
|
565
|
+
size = path.stat().st_size
|
|
566
|
+
size_str = self._format_size(size)
|
|
567
|
+
t.append(f" [{size_str}]", style="#71717a")
|
|
568
|
+
except Exception:
|
|
569
|
+
pass
|
|
570
|
+
|
|
571
|
+
return t
|
|
572
|
+
|
|
573
|
+
def _render_hints(self) -> Text:
|
|
574
|
+
"""Render user-friendly hints."""
|
|
575
|
+
t = Text()
|
|
576
|
+
t.append("\n", style="")
|
|
577
|
+
|
|
578
|
+
if self.current_file is not None:
|
|
579
|
+
# File is open - show file-specific hints
|
|
580
|
+
t.append("↑↓", style="bold #ec4899")
|
|
581
|
+
t.append(" scroll ", style="#71717a")
|
|
582
|
+
t.append("e", style="bold #22c55e")
|
|
583
|
+
t.append(" edit ", style="#71717a")
|
|
584
|
+
t.append("o", style="bold #06b6d4")
|
|
585
|
+
t.append(" open in chat ", style="#71717a")
|
|
586
|
+
t.append("q", style="bold #f59e0b")
|
|
587
|
+
t.append(" close", style="#71717a")
|
|
588
|
+
else:
|
|
589
|
+
# No file - show navigation hints
|
|
590
|
+
t.append("↑↓", style="bold #ec4899")
|
|
591
|
+
t.append(" navigate ", style="#71717a")
|
|
592
|
+
t.append("Enter", style="bold #ec4899")
|
|
593
|
+
t.append(" select ", style="#71717a")
|
|
594
|
+
t.append("Ctrl+B", style="bold #f59e0b")
|
|
595
|
+
t.append(" close sidebar", style="#71717a")
|
|
596
|
+
|
|
597
|
+
return t
|
|
598
|
+
|
|
599
|
+
def _render_content(self) -> Text | Syntax:
|
|
600
|
+
"""Render the file content."""
|
|
601
|
+
if self.current_file is None:
|
|
602
|
+
return self._render_empty()
|
|
603
|
+
|
|
604
|
+
return self._render_file_content(self.current_file)
|
|
605
|
+
|
|
606
|
+
def _render_empty(self) -> Text:
|
|
607
|
+
"""Render empty state."""
|
|
608
|
+
t = Text()
|
|
609
|
+
t.append("\n\n", style="")
|
|
610
|
+
t.append(" 👆 ", style="bold #a855f7")
|
|
611
|
+
t.append("Select a file from the tree\n\n", style="#71717a")
|
|
612
|
+
t.append(" 📁 ", style="#ec4899")
|
|
613
|
+
t.append("Click folders to expand\n", style="#52525b")
|
|
614
|
+
t.append(" 📄 ", style="#ec4899")
|
|
615
|
+
t.append("Click files to preview\n", style="#52525b")
|
|
616
|
+
return t
|
|
617
|
+
|
|
618
|
+
def _render_file_content(self, path: Path) -> Text | Syntax:
|
|
619
|
+
"""Render file content with syntax highlighting."""
|
|
620
|
+
# Check if binary
|
|
621
|
+
if self._is_binary(path):
|
|
622
|
+
t = Text()
|
|
623
|
+
t.append("\n 🔒 ", style="bold #f59e0b")
|
|
624
|
+
t.append("Binary file\n\n", style="#f59e0b")
|
|
625
|
+
t.append(f" Size: {self._format_size(path.stat().st_size)}\n", style="#71717a")
|
|
626
|
+
t.append(" Cannot display binary content\n", style="#52525b")
|
|
627
|
+
return t
|
|
628
|
+
|
|
629
|
+
# Read content
|
|
630
|
+
try:
|
|
631
|
+
if path in self._content_cache:
|
|
632
|
+
text = self._content_cache[path]
|
|
633
|
+
else:
|
|
634
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
635
|
+
# Cache small files
|
|
636
|
+
if len(text) < 100000:
|
|
637
|
+
self._content_cache[path] = text
|
|
638
|
+
|
|
639
|
+
# Syntax highlight - show ALL content (scrollable)
|
|
640
|
+
language = detect_language(path)
|
|
641
|
+
syntax = Syntax(
|
|
642
|
+
text,
|
|
643
|
+
language,
|
|
644
|
+
theme="monokai",
|
|
645
|
+
line_numbers=True,
|
|
646
|
+
word_wrap=True,
|
|
647
|
+
background_color="#000000",
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
return syntax
|
|
651
|
+
|
|
652
|
+
except Exception as e:
|
|
653
|
+
t = Text()
|
|
654
|
+
t.append(f"\n ❌ ", style="bold #ef4444")
|
|
655
|
+
t.append("Error reading file\n\n", style="#ef4444")
|
|
656
|
+
t.append(f" {str(e)}\n", style="#71717a")
|
|
657
|
+
return t
|
|
658
|
+
|
|
659
|
+
def _is_binary(self, path: Path) -> bool:
|
|
660
|
+
"""Check if file is binary."""
|
|
661
|
+
try:
|
|
662
|
+
with open(path, "rb") as f:
|
|
663
|
+
chunk = f.read(8192)
|
|
664
|
+
return b"\x00" in chunk
|
|
665
|
+
except Exception:
|
|
666
|
+
return False
|
|
667
|
+
|
|
668
|
+
def _format_size(self, size: int) -> str:
|
|
669
|
+
"""Format file size."""
|
|
670
|
+
for unit in ["B", "KB", "MB", "GB"]:
|
|
671
|
+
if size < 1024:
|
|
672
|
+
return f"{size:.1f} {unit}" if unit != "B" else f"{size} {unit}"
|
|
673
|
+
size /= 1024
|
|
674
|
+
return f"{size:.1f} TB"
|
|
675
|
+
|
|
676
|
+
def set_file(self, path: Path) -> None:
|
|
677
|
+
"""Set the file to preview."""
|
|
678
|
+
self.current_file = path
|
|
679
|
+
|
|
680
|
+
def clear(self) -> None:
|
|
681
|
+
"""Clear the preview."""
|
|
682
|
+
self.current_file = None
|
|
683
|
+
|
|
684
|
+
def action_close_preview(self) -> None:
|
|
685
|
+
"""Close the current file preview."""
|
|
686
|
+
if self.current_file is not None:
|
|
687
|
+
self.current_file = None
|
|
688
|
+
self.post_message(self.PreviewClosed())
|
|
689
|
+
|
|
690
|
+
def action_edit_file(self) -> None:
|
|
691
|
+
"""Open the file in the default editor."""
|
|
692
|
+
if self.current_file is not None:
|
|
693
|
+
self.post_message(self.EditRequested(self.current_file))
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
# ============================================================================
|
|
697
|
+
# ENHANCED SIDEBAR WITH FILE BROWSER AND PREVIEW
|
|
698
|
+
# ============================================================================
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
class EnhancedSidebar(Container):
|
|
702
|
+
"""
|
|
703
|
+
Enhanced sidebar with colorful file browser and preview panel.
|
|
704
|
+
|
|
705
|
+
Features:
|
|
706
|
+
- Colorful file type icons
|
|
707
|
+
- Gradient folder colors
|
|
708
|
+
- Scrollable file preview with syntax highlighting
|
|
709
|
+
- Simple keyboard shortcuts
|
|
710
|
+
"""
|
|
711
|
+
|
|
712
|
+
DEFAULT_CSS = """
|
|
713
|
+
EnhancedSidebar {
|
|
714
|
+
width: 100%;
|
|
715
|
+
height: 100%;
|
|
716
|
+
layout: vertical;
|
|
717
|
+
background: #000000;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
EnhancedSidebar #sidebar-header {
|
|
721
|
+
height: 3;
|
|
722
|
+
background: #000000;
|
|
723
|
+
border-bottom: solid #1a1a1a;
|
|
724
|
+
padding: 0 1;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
EnhancedSidebar #sidebar-content {
|
|
728
|
+
height: 1fr;
|
|
729
|
+
layout: horizontal;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
EnhancedSidebar #file-tree-container {
|
|
733
|
+
width: 1fr;
|
|
734
|
+
min-width: 25;
|
|
735
|
+
max-width: 35;
|
|
736
|
+
height: 100%;
|
|
737
|
+
background: #000000;
|
|
738
|
+
border-right: solid #1a1a1a;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
EnhancedSidebar #file-tree {
|
|
742
|
+
height: 100%;
|
|
743
|
+
background: #000000;
|
|
744
|
+
scrollbar-size: 1 1;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
EnhancedSidebar #preview-container {
|
|
748
|
+
width: 2fr;
|
|
749
|
+
height: 100%;
|
|
750
|
+
background: #000000;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
EnhancedSidebar #file-preview {
|
|
754
|
+
height: 100%;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
EnhancedSidebar .sidebar-title {
|
|
758
|
+
text-align: center;
|
|
759
|
+
color: #a855f7;
|
|
760
|
+
text-style: bold;
|
|
761
|
+
padding: 1 0;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
EnhancedSidebar ColorfulDirectoryTree {
|
|
765
|
+
background: #000000;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
EnhancedSidebar ColorfulDirectoryTree > .tree--guides {
|
|
769
|
+
color: #1a1a1a;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
EnhancedSidebar ColorfulDirectoryTree > .tree--cursor {
|
|
773
|
+
background: #3f3f46;
|
|
774
|
+
color: #ec4899;
|
|
775
|
+
text-style: bold;
|
|
776
|
+
border-left: tall #a855f7;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
EnhancedSidebar ColorfulDirectoryTree:focus > .tree--cursor {
|
|
780
|
+
background: #52525b;
|
|
781
|
+
color: #ec4899;
|
|
782
|
+
text-style: bold;
|
|
783
|
+
border-left: tall #a855f7;
|
|
784
|
+
}
|
|
785
|
+
"""
|
|
786
|
+
|
|
787
|
+
class FileOpened(Message):
|
|
788
|
+
"""Message sent when a file should be opened/viewed."""
|
|
789
|
+
|
|
790
|
+
def __init__(self, path: Path) -> None:
|
|
791
|
+
self.path = path
|
|
792
|
+
super().__init__()
|
|
793
|
+
|
|
794
|
+
def __init__(
|
|
795
|
+
self,
|
|
796
|
+
path: Path | str = ".",
|
|
797
|
+
name: str | None = None,
|
|
798
|
+
id: str | None = None,
|
|
799
|
+
classes: str | None = None,
|
|
800
|
+
):
|
|
801
|
+
super().__init__(name=name, id=id, classes=classes)
|
|
802
|
+
self.root_path = Path(path).resolve()
|
|
803
|
+
|
|
804
|
+
def compose(self) -> ComposeResult:
|
|
805
|
+
"""Compose the sidebar layout."""
|
|
806
|
+
# Header with close hint
|
|
807
|
+
with Container(id="sidebar-header"):
|
|
808
|
+
yield Static(self._render_header(), classes="sidebar-title")
|
|
809
|
+
|
|
810
|
+
# Content: Tree + Preview
|
|
811
|
+
with Horizontal(id="sidebar-content"):
|
|
812
|
+
# File tree
|
|
813
|
+
with Container(id="file-tree-container"):
|
|
814
|
+
yield ColorfulDirectoryTree(self.root_path, id="file-tree")
|
|
815
|
+
|
|
816
|
+
# Preview panel
|
|
817
|
+
with Container(id="preview-container"):
|
|
818
|
+
yield FilePreview(id="file-preview")
|
|
819
|
+
|
|
820
|
+
def _render_header(self) -> Text:
|
|
821
|
+
"""Render the sidebar header with hints."""
|
|
822
|
+
t = Text()
|
|
823
|
+
t.append("📁 ", style="bold #ec4899")
|
|
824
|
+
t.append(self.root_path.name or "Files", style="bold #a855f7")
|
|
825
|
+
t.append(" ", style="")
|
|
826
|
+
t.append("Ctrl+B", style="bold #71717a")
|
|
827
|
+
t.append(" close", style="#52525b")
|
|
828
|
+
return t
|
|
829
|
+
|
|
830
|
+
@on(DirectoryTree.FileSelected)
|
|
831
|
+
def on_file_selected(self, event: DirectoryTree.FileSelected) -> None:
|
|
832
|
+
"""Handle file selection - show preview."""
|
|
833
|
+
event.stop()
|
|
834
|
+
path = event.path
|
|
835
|
+
preview = self.query_one("#file-preview", FilePreview)
|
|
836
|
+
preview.set_file(path)
|
|
837
|
+
|
|
838
|
+
@on(ColorfulDirectoryTree.FileOpenRequested)
|
|
839
|
+
def on_file_open_requested(self, event: ColorfulDirectoryTree.FileOpenRequested) -> None:
|
|
840
|
+
"""Handle file open request - forward to parent."""
|
|
841
|
+
event.stop()
|
|
842
|
+
self.post_message(self.FileOpened(event.path))
|
|
843
|
+
|
|
844
|
+
@on(Tree.NodeHighlighted)
|
|
845
|
+
def on_node_highlighted(self, event: Tree.NodeHighlighted) -> None:
|
|
846
|
+
"""Update preview when navigating with keyboard."""
|
|
847
|
+
node = event.node
|
|
848
|
+
if node.data and hasattr(node.data, "path"):
|
|
849
|
+
path = node.data.path
|
|
850
|
+
if path.is_file():
|
|
851
|
+
preview = self.query_one("#file-preview", FilePreview)
|
|
852
|
+
preview.set_file(path)
|
|
853
|
+
|
|
854
|
+
@on(FilePreview.EditRequested)
|
|
855
|
+
def on_edit_requested(self, event: FilePreview.EditRequested) -> None:
|
|
856
|
+
"""Handle edit request - open file in default editor."""
|
|
857
|
+
event.stop()
|
|
858
|
+
import subprocess
|
|
859
|
+
import os
|
|
860
|
+
import platform
|
|
861
|
+
|
|
862
|
+
path = event.path
|
|
863
|
+
|
|
864
|
+
# Try to open in default editor
|
|
865
|
+
try:
|
|
866
|
+
system = platform.system()
|
|
867
|
+
if system == "Darwin": # macOS
|
|
868
|
+
subprocess.Popen(["open", str(path)])
|
|
869
|
+
elif system == "Windows":
|
|
870
|
+
os.startfile(str(path))
|
|
871
|
+
else: # Linux
|
|
872
|
+
# Try common editors
|
|
873
|
+
editor = os.environ.get("EDITOR", "xdg-open")
|
|
874
|
+
subprocess.Popen([editor, str(path)])
|
|
875
|
+
except Exception:
|
|
876
|
+
# Fallback: try $EDITOR or vim/nano
|
|
877
|
+
editor = os.environ.get("EDITOR", "nano")
|
|
878
|
+
try:
|
|
879
|
+
subprocess.Popen([editor, str(path)])
|
|
880
|
+
except Exception:
|
|
881
|
+
pass
|
|
882
|
+
|
|
883
|
+
def action_focus_tree(self) -> None:
|
|
884
|
+
"""Focus the file tree."""
|
|
885
|
+
self.query_one("#file-tree", ColorfulDirectoryTree).focus()
|
|
886
|
+
|
|
887
|
+
def refresh_tree(self) -> None:
|
|
888
|
+
"""Refresh the file tree."""
|
|
889
|
+
tree = self.query_one("#file-tree", ColorfulDirectoryTree)
|
|
890
|
+
tree.reload()
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
# ============================================================================
|
|
894
|
+
# COMPACT SIDEBAR (Tree only, no preview)
|
|
895
|
+
# ============================================================================
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
class CompactSidebar(Container):
|
|
899
|
+
"""Compact sidebar with just the file tree."""
|
|
900
|
+
|
|
901
|
+
DEFAULT_CSS = """
|
|
902
|
+
CompactSidebar {
|
|
903
|
+
width: 32;
|
|
904
|
+
height: 100%;
|
|
905
|
+
background: #000000;
|
|
906
|
+
border-right: solid #1a1a1a;
|
|
907
|
+
padding: 1;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
CompactSidebar #compact-header {
|
|
911
|
+
height: 2;
|
|
912
|
+
text-align: center;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
CompactSidebar #compact-tree {
|
|
916
|
+
height: 1fr;
|
|
917
|
+
background: #000000;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
CompactSidebar ColorfulDirectoryTree {
|
|
921
|
+
background: #000000;
|
|
922
|
+
}
|
|
923
|
+
"""
|
|
924
|
+
|
|
925
|
+
class FileSelected(Message):
|
|
926
|
+
"""Message sent when a file is selected."""
|
|
927
|
+
|
|
928
|
+
def __init__(self, path: Path) -> None:
|
|
929
|
+
self.path = path
|
|
930
|
+
super().__init__()
|
|
931
|
+
|
|
932
|
+
def __init__(
|
|
933
|
+
self,
|
|
934
|
+
path: Path | str = ".",
|
|
935
|
+
name: str | None = None,
|
|
936
|
+
id: str | None = None,
|
|
937
|
+
classes: str | None = None,
|
|
938
|
+
):
|
|
939
|
+
super().__init__(name=name, id=id, classes=classes)
|
|
940
|
+
self.root_path = Path(path).resolve()
|
|
941
|
+
|
|
942
|
+
def compose(self) -> ComposeResult:
|
|
943
|
+
"""Compose the compact sidebar."""
|
|
944
|
+
header = Text()
|
|
945
|
+
header.append("📁 ", style="bold #ec4899")
|
|
946
|
+
header.append("Files", style="bold #a855f7")
|
|
947
|
+
yield Static(header, id="compact-header")
|
|
948
|
+
yield ColorfulDirectoryTree(self.root_path, id="compact-tree")
|
|
949
|
+
|
|
950
|
+
@on(ColorfulDirectoryTree.FileSelected)
|
|
951
|
+
def on_file_selected(self, event: ColorfulDirectoryTree.FileSelected) -> None:
|
|
952
|
+
"""Forward file selection."""
|
|
953
|
+
event.stop()
|
|
954
|
+
self.post_message(self.FileSelected(event.path))
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
# ============================================================================
|
|
958
|
+
# GIT STATUS INDICATOR
|
|
959
|
+
# ============================================================================
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
@dataclass
|
|
963
|
+
class GitStatusInfo:
|
|
964
|
+
"""Git repository status information."""
|
|
965
|
+
|
|
966
|
+
branch: str = ""
|
|
967
|
+
modified: int = 0
|
|
968
|
+
staged: int = 0
|
|
969
|
+
untracked: int = 0
|
|
970
|
+
is_repo: bool = False
|
|
971
|
+
ahead: int = 0
|
|
972
|
+
behind: int = 0
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def get_git_status(path: Path) -> GitStatusInfo:
|
|
976
|
+
"""Get git status for a directory (runs in thread)."""
|
|
977
|
+
info = GitStatusInfo()
|
|
978
|
+
|
|
979
|
+
try:
|
|
980
|
+
# Check if it's a git repo
|
|
981
|
+
result = subprocess.run(
|
|
982
|
+
["git", "rev-parse", "--is-inside-work-tree"],
|
|
983
|
+
cwd=path,
|
|
984
|
+
capture_output=True,
|
|
985
|
+
text=True,
|
|
986
|
+
timeout=5,
|
|
987
|
+
)
|
|
988
|
+
if result.returncode != 0:
|
|
989
|
+
return info
|
|
990
|
+
|
|
991
|
+
info.is_repo = True
|
|
992
|
+
|
|
993
|
+
# Get branch name
|
|
994
|
+
result = subprocess.run(
|
|
995
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
996
|
+
cwd=path,
|
|
997
|
+
capture_output=True,
|
|
998
|
+
text=True,
|
|
999
|
+
timeout=5,
|
|
1000
|
+
)
|
|
1001
|
+
if result.returncode == 0:
|
|
1002
|
+
info.branch = result.stdout.strip()
|
|
1003
|
+
|
|
1004
|
+
# Get status counts
|
|
1005
|
+
result = subprocess.run(
|
|
1006
|
+
["git", "status", "--porcelain"], cwd=path, capture_output=True, text=True, timeout=5
|
|
1007
|
+
)
|
|
1008
|
+
if result.returncode == 0:
|
|
1009
|
+
for line in result.stdout.strip().split("\n"):
|
|
1010
|
+
if not line:
|
|
1011
|
+
continue
|
|
1012
|
+
status = line[:2]
|
|
1013
|
+
if status[0] in "MADRCU": # Staged
|
|
1014
|
+
info.staged += 1
|
|
1015
|
+
if status[1] in "MD": # Modified
|
|
1016
|
+
info.modified += 1
|
|
1017
|
+
if status == "??": # Untracked
|
|
1018
|
+
info.untracked += 1
|
|
1019
|
+
|
|
1020
|
+
# Get ahead/behind
|
|
1021
|
+
result = subprocess.run(
|
|
1022
|
+
["git", "rev-list", "--left-right", "--count", f"{info.branch}...@{{u}}"],
|
|
1023
|
+
cwd=path,
|
|
1024
|
+
capture_output=True,
|
|
1025
|
+
text=True,
|
|
1026
|
+
timeout=5,
|
|
1027
|
+
)
|
|
1028
|
+
if result.returncode == 0:
|
|
1029
|
+
parts = result.stdout.strip().split()
|
|
1030
|
+
if len(parts) == 2:
|
|
1031
|
+
info.ahead = int(parts[0])
|
|
1032
|
+
info.behind = int(parts[1])
|
|
1033
|
+
|
|
1034
|
+
except Exception:
|
|
1035
|
+
pass
|
|
1036
|
+
|
|
1037
|
+
return info
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
class GitStatusWidget(Static):
|
|
1041
|
+
"""Widget showing git status in sidebar header."""
|
|
1042
|
+
|
|
1043
|
+
DEFAULT_CSS = """
|
|
1044
|
+
GitStatusWidget {
|
|
1045
|
+
height: 2;
|
|
1046
|
+
width: 100%;
|
|
1047
|
+
padding: 0 1;
|
|
1048
|
+
background: #0a0a0a;
|
|
1049
|
+
border-bottom: solid #1a1a1a;
|
|
1050
|
+
}
|
|
1051
|
+
"""
|
|
1052
|
+
|
|
1053
|
+
status: reactive[GitStatusInfo] = reactive(GitStatusInfo)
|
|
1054
|
+
_loading: bool = True
|
|
1055
|
+
|
|
1056
|
+
def __init__(self, path: Path, **kwargs):
|
|
1057
|
+
super().__init__(**kwargs)
|
|
1058
|
+
self.root_path = path
|
|
1059
|
+
self._loading = True
|
|
1060
|
+
|
|
1061
|
+
def on_mount(self) -> None:
|
|
1062
|
+
"""Start fetching git status."""
|
|
1063
|
+
self.refresh_status()
|
|
1064
|
+
|
|
1065
|
+
@work(thread=True)
|
|
1066
|
+
def refresh_status(self) -> None:
|
|
1067
|
+
"""Fetch git status in background thread."""
|
|
1068
|
+
status = get_git_status(self.root_path)
|
|
1069
|
+
# Use app.call_from_thread to safely update from worker thread
|
|
1070
|
+
self.app.call_from_thread(self._update_status, status)
|
|
1071
|
+
|
|
1072
|
+
def _update_status(self, status: GitStatusInfo) -> None:
|
|
1073
|
+
"""Update status from thread."""
|
|
1074
|
+
self._loading = False
|
|
1075
|
+
self.status = status
|
|
1076
|
+
|
|
1077
|
+
def watch_status(self, status: GitStatusInfo) -> None:
|
|
1078
|
+
"""Update display when status changes."""
|
|
1079
|
+
self.refresh()
|
|
1080
|
+
|
|
1081
|
+
def render(self) -> Text:
|
|
1082
|
+
"""Render git status line."""
|
|
1083
|
+
t = Text()
|
|
1084
|
+
|
|
1085
|
+
# Loading state
|
|
1086
|
+
if self._loading:
|
|
1087
|
+
t.append("\n", style="")
|
|
1088
|
+
t.append(" ⎇ ", style="bold #a855f7")
|
|
1089
|
+
t.append("Loading git status...", style="#52525b italic")
|
|
1090
|
+
return t
|
|
1091
|
+
|
|
1092
|
+
status = self.status
|
|
1093
|
+
|
|
1094
|
+
t.append("\n", style="")
|
|
1095
|
+
|
|
1096
|
+
if not status.is_repo:
|
|
1097
|
+
t.append(" 📁 ", style="#71717a")
|
|
1098
|
+
t.append("Not a git repository", style="#52525b")
|
|
1099
|
+
return t
|
|
1100
|
+
|
|
1101
|
+
# Branch icon and name
|
|
1102
|
+
t.append(" ⎇ ", style="bold #a855f7")
|
|
1103
|
+
t.append(status.branch[:20], style="bold #a855f7")
|
|
1104
|
+
|
|
1105
|
+
# Status counts with icons
|
|
1106
|
+
if status.staged > 0:
|
|
1107
|
+
t.append(f" ✓{status.staged}", style="bold #22c55e")
|
|
1108
|
+
if status.modified > 0:
|
|
1109
|
+
t.append(f" ●{status.modified}", style="bold #f97316")
|
|
1110
|
+
if status.untracked > 0:
|
|
1111
|
+
t.append(f" +{status.untracked}", style="#71717a")
|
|
1112
|
+
|
|
1113
|
+
# Ahead/behind with arrows
|
|
1114
|
+
if status.ahead > 0:
|
|
1115
|
+
t.append(f" ↑{status.ahead}", style="bold #06b6d4")
|
|
1116
|
+
if status.behind > 0:
|
|
1117
|
+
t.append(f" ↓{status.behind}", style="bold #ec4899")
|
|
1118
|
+
|
|
1119
|
+
# Show clean state if nothing to commit
|
|
1120
|
+
if status.staged == 0 and status.modified == 0 and status.untracked == 0:
|
|
1121
|
+
t.append(" ✓ clean", style="#22c55e")
|
|
1122
|
+
|
|
1123
|
+
return t
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
# ============================================================================
|
|
1127
|
+
# PLAN/TASK PANEL
|
|
1128
|
+
# ============================================================================
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
@dataclass
|
|
1132
|
+
class TaskItem:
|
|
1133
|
+
"""A single task in the plan."""
|
|
1134
|
+
|
|
1135
|
+
content: str
|
|
1136
|
+
status: str = "pending" # pending, in_progress, completed
|
|
1137
|
+
priority: str = "medium" # low, medium, high
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
class PlanPanel(Container):
|
|
1141
|
+
"""Panel showing current agent plan/tasks."""
|
|
1142
|
+
|
|
1143
|
+
DEFAULT_CSS = """
|
|
1144
|
+
PlanPanel {
|
|
1145
|
+
height: auto;
|
|
1146
|
+
max-height: 15;
|
|
1147
|
+
background: #000000;
|
|
1148
|
+
padding: 0 1;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
PlanPanel .task-item {
|
|
1152
|
+
height: 1;
|
|
1153
|
+
padding: 0;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
PlanPanel .task-pending {
|
|
1157
|
+
color: #71717a;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
PlanPanel .task-in-progress {
|
|
1161
|
+
color: #f97316;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
PlanPanel .task-completed {
|
|
1165
|
+
color: #22c55e;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
PlanPanel .empty-state {
|
|
1169
|
+
color: #52525b;
|
|
1170
|
+
text-style: italic;
|
|
1171
|
+
padding: 1;
|
|
1172
|
+
}
|
|
1173
|
+
"""
|
|
1174
|
+
|
|
1175
|
+
tasks: reactive[List[TaskItem]] = reactive(list)
|
|
1176
|
+
|
|
1177
|
+
def compose(self) -> ComposeResult:
|
|
1178
|
+
"""Compose the plan panel."""
|
|
1179
|
+
yield Static(self._render_tasks(), id="plan-content")
|
|
1180
|
+
|
|
1181
|
+
def watch_tasks(self, tasks: List[TaskItem]) -> None:
|
|
1182
|
+
"""Update when tasks change."""
|
|
1183
|
+
try:
|
|
1184
|
+
self.query_one("#plan-content", Static).update(self._render_tasks())
|
|
1185
|
+
except Exception:
|
|
1186
|
+
pass
|
|
1187
|
+
|
|
1188
|
+
def _render_tasks(self) -> Text:
|
|
1189
|
+
"""Render task list."""
|
|
1190
|
+
t = Text()
|
|
1191
|
+
|
|
1192
|
+
if not self.tasks:
|
|
1193
|
+
t.append(" No active tasks\n", style="italic #52525b")
|
|
1194
|
+
t.append(" Start a conversation to see plan", style="#3f3f46")
|
|
1195
|
+
return t
|
|
1196
|
+
|
|
1197
|
+
for task in self.tasks[:8]: # Show max 8 tasks
|
|
1198
|
+
# Status icon
|
|
1199
|
+
if task.status == "completed":
|
|
1200
|
+
t.append(" ✓ ", style="bold #22c55e")
|
|
1201
|
+
elif task.status == "in_progress":
|
|
1202
|
+
t.append(" ● ", style="bold #f97316")
|
|
1203
|
+
else:
|
|
1204
|
+
t.append(" ○ ", style="#71717a")
|
|
1205
|
+
|
|
1206
|
+
# Task content (truncated)
|
|
1207
|
+
content = task.content[:40] + "..." if len(task.content) > 40 else task.content
|
|
1208
|
+
|
|
1209
|
+
if task.status == "completed":
|
|
1210
|
+
t.append(content, style="#52525b")
|
|
1211
|
+
elif task.status == "in_progress":
|
|
1212
|
+
t.append(content, style="#f97316")
|
|
1213
|
+
else:
|
|
1214
|
+
t.append(content, style="#a1a1aa")
|
|
1215
|
+
|
|
1216
|
+
t.append("\n")
|
|
1217
|
+
|
|
1218
|
+
if len(self.tasks) > 8:
|
|
1219
|
+
t.append(f" +{len(self.tasks) - 8} more tasks...", style="#52525b")
|
|
1220
|
+
|
|
1221
|
+
return t
|
|
1222
|
+
|
|
1223
|
+
def set_tasks(self, tasks: List[TaskItem]) -> None:
|
|
1224
|
+
"""Update the task list."""
|
|
1225
|
+
self.tasks = tasks
|
|
1226
|
+
|
|
1227
|
+
def add_task(self, content: str, status: str = "pending") -> None:
|
|
1228
|
+
"""Add a new task."""
|
|
1229
|
+
self.tasks = self.tasks + [TaskItem(content=content, status=status)]
|
|
1230
|
+
|
|
1231
|
+
def update_task_status(self, index: int, status: str) -> None:
|
|
1232
|
+
"""Update a task's status."""
|
|
1233
|
+
if 0 <= index < len(self.tasks):
|
|
1234
|
+
tasks = list(self.tasks)
|
|
1235
|
+
tasks[index] = TaskItem(
|
|
1236
|
+
content=tasks[index].content, status=status, priority=tasks[index].priority
|
|
1237
|
+
)
|
|
1238
|
+
self.tasks = tasks
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
# ============================================================================
|
|
1242
|
+
# FILE SEARCH
|
|
1243
|
+
# ============================================================================
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
class FileSearchResults(Container):
|
|
1247
|
+
"""Container showing file search results."""
|
|
1248
|
+
|
|
1249
|
+
DEFAULT_CSS = """
|
|
1250
|
+
FileSearchResults {
|
|
1251
|
+
height: auto;
|
|
1252
|
+
max-height: 12;
|
|
1253
|
+
background: #000000;
|
|
1254
|
+
padding: 0;
|
|
1255
|
+
display: none;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
FileSearchResults.visible {
|
|
1259
|
+
display: block;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
FileSearchResults .search-result {
|
|
1263
|
+
height: 1;
|
|
1264
|
+
padding: 0 1;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
FileSearchResults .search-result:hover {
|
|
1268
|
+
background: #1a1a1a;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
FileSearchResults .search-result.selected {
|
|
1272
|
+
background: #a855f720;
|
|
1273
|
+
}
|
|
1274
|
+
"""
|
|
1275
|
+
|
|
1276
|
+
results: reactive[List[Path]] = reactive(list)
|
|
1277
|
+
selected_index: reactive[int] = reactive(0)
|
|
1278
|
+
|
|
1279
|
+
class FileSelected(Message):
|
|
1280
|
+
"""Message when a search result is selected."""
|
|
1281
|
+
|
|
1282
|
+
def __init__(self, path: Path) -> None:
|
|
1283
|
+
self.path = path
|
|
1284
|
+
super().__init__()
|
|
1285
|
+
|
|
1286
|
+
def compose(self) -> ComposeResult:
|
|
1287
|
+
"""Compose search results."""
|
|
1288
|
+
yield Static(self._render_results(), id="search-results-content")
|
|
1289
|
+
|
|
1290
|
+
def watch_results(self, results: List[Path]) -> None:
|
|
1291
|
+
"""Update when results change."""
|
|
1292
|
+
self.selected_index = 0
|
|
1293
|
+
try:
|
|
1294
|
+
self.query_one("#search-results-content", Static).update(self._render_results())
|
|
1295
|
+
except Exception:
|
|
1296
|
+
pass
|
|
1297
|
+
|
|
1298
|
+
def watch_selected_index(self, index: int) -> None:
|
|
1299
|
+
"""Update when selection changes."""
|
|
1300
|
+
try:
|
|
1301
|
+
self.query_one("#search-results-content", Static).update(self._render_results())
|
|
1302
|
+
except Exception:
|
|
1303
|
+
pass
|
|
1304
|
+
|
|
1305
|
+
def _render_results(self) -> Text:
|
|
1306
|
+
"""Render search results."""
|
|
1307
|
+
t = Text()
|
|
1308
|
+
|
|
1309
|
+
if not self.results:
|
|
1310
|
+
t.append(" No matches found", style="italic #52525b")
|
|
1311
|
+
return t
|
|
1312
|
+
|
|
1313
|
+
for i, path in enumerate(self.results[:10]):
|
|
1314
|
+
# Selection indicator
|
|
1315
|
+
if i == self.selected_index:
|
|
1316
|
+
t.append("▸ ", style="bold #a855f7")
|
|
1317
|
+
else:
|
|
1318
|
+
t.append(" ", style="")
|
|
1319
|
+
|
|
1320
|
+
# File icon
|
|
1321
|
+
icon, color = get_file_icon(path)
|
|
1322
|
+
t.append(f"{icon} ", style=color)
|
|
1323
|
+
|
|
1324
|
+
# Path (relative, truncated)
|
|
1325
|
+
rel_path = str(path)[-45:] if len(str(path)) > 45 else str(path)
|
|
1326
|
+
if len(str(path)) > 45:
|
|
1327
|
+
rel_path = "..." + rel_path
|
|
1328
|
+
|
|
1329
|
+
if i == self.selected_index:
|
|
1330
|
+
t.append(rel_path, style="bold white")
|
|
1331
|
+
else:
|
|
1332
|
+
t.append(rel_path, style="#a1a1aa")
|
|
1333
|
+
|
|
1334
|
+
t.append("\n")
|
|
1335
|
+
|
|
1336
|
+
if len(self.results) > 10:
|
|
1337
|
+
t.append(f" +{len(self.results) - 10} more results", style="#52525b")
|
|
1338
|
+
|
|
1339
|
+
return t
|
|
1340
|
+
|
|
1341
|
+
def move_selection(self, delta: int) -> None:
|
|
1342
|
+
"""Move selection up or down."""
|
|
1343
|
+
if self.results:
|
|
1344
|
+
new_index = (self.selected_index + delta) % min(len(self.results), 10)
|
|
1345
|
+
self.selected_index = new_index
|
|
1346
|
+
|
|
1347
|
+
def get_selected(self) -> Optional[Path]:
|
|
1348
|
+
"""Get the selected path."""
|
|
1349
|
+
if self.results and 0 <= self.selected_index < len(self.results):
|
|
1350
|
+
return self.results[self.selected_index]
|
|
1351
|
+
return None
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
class FileSearch(Container):
|
|
1355
|
+
"""File search widget with fuzzy matching."""
|
|
1356
|
+
|
|
1357
|
+
DEFAULT_CSS = """
|
|
1358
|
+
FileSearch {
|
|
1359
|
+
height: auto;
|
|
1360
|
+
background: #000000;
|
|
1361
|
+
padding: 0;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
FileSearch #search-input {
|
|
1365
|
+
height: 1;
|
|
1366
|
+
background: #0a0a0a;
|
|
1367
|
+
border: none;
|
|
1368
|
+
padding: 0 1;
|
|
1369
|
+
margin: 0;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
FileSearch #search-input:focus {
|
|
1373
|
+
border: none;
|
|
1374
|
+
}
|
|
1375
|
+
"""
|
|
1376
|
+
|
|
1377
|
+
BINDINGS = [
|
|
1378
|
+
Binding("escape", "close_search", "Close", show=False),
|
|
1379
|
+
Binding("up", "move_up", "Up", show=False),
|
|
1380
|
+
Binding("down", "move_down", "Down", show=False),
|
|
1381
|
+
Binding("enter", "select_file", "Select", show=False),
|
|
1382
|
+
]
|
|
1383
|
+
|
|
1384
|
+
class FileSelected(Message):
|
|
1385
|
+
"""Message when a file is selected from search."""
|
|
1386
|
+
|
|
1387
|
+
def __init__(self, path: Path) -> None:
|
|
1388
|
+
self.path = path
|
|
1389
|
+
super().__init__()
|
|
1390
|
+
|
|
1391
|
+
class SearchClosed(Message):
|
|
1392
|
+
"""Message when search is closed."""
|
|
1393
|
+
|
|
1394
|
+
pass
|
|
1395
|
+
|
|
1396
|
+
def __init__(self, root_path: Path, **kwargs):
|
|
1397
|
+
super().__init__(**kwargs)
|
|
1398
|
+
self.root_path = root_path
|
|
1399
|
+
self._all_files: List[Path] = []
|
|
1400
|
+
self._files_loaded = False
|
|
1401
|
+
|
|
1402
|
+
def compose(self) -> ComposeResult:
|
|
1403
|
+
"""Compose the search widget."""
|
|
1404
|
+
yield Input(placeholder="🔍 Search files...", id="search-input")
|
|
1405
|
+
yield FileSearchResults(id="search-results")
|
|
1406
|
+
|
|
1407
|
+
def on_mount(self) -> None:
|
|
1408
|
+
"""Load files on mount."""
|
|
1409
|
+
self._load_files()
|
|
1410
|
+
|
|
1411
|
+
@work(thread=True)
|
|
1412
|
+
def _load_files(self) -> None:
|
|
1413
|
+
"""Load all files in background."""
|
|
1414
|
+
files = []
|
|
1415
|
+
try:
|
|
1416
|
+
for path in self.root_path.rglob("*"):
|
|
1417
|
+
if path.is_file():
|
|
1418
|
+
# Skip hidden and ignored
|
|
1419
|
+
parts = path.parts
|
|
1420
|
+
if any(
|
|
1421
|
+
p.startswith(".") or p in {"node_modules", "__pycache__", "venv", ".venv"}
|
|
1422
|
+
for p in parts
|
|
1423
|
+
):
|
|
1424
|
+
continue
|
|
1425
|
+
files.append(path)
|
|
1426
|
+
if len(files) > 5000: # Limit for performance
|
|
1427
|
+
break
|
|
1428
|
+
except Exception:
|
|
1429
|
+
pass
|
|
1430
|
+
|
|
1431
|
+
self._all_files = files
|
|
1432
|
+
self._files_loaded = True
|
|
1433
|
+
|
|
1434
|
+
@on(Input.Changed, "#search-input")
|
|
1435
|
+
def on_search_changed(self, event: Input.Changed) -> None:
|
|
1436
|
+
"""Handle search input changes."""
|
|
1437
|
+
query = event.value.lower().strip()
|
|
1438
|
+
results_widget = self.query_one("#search-results", FileSearchResults)
|
|
1439
|
+
|
|
1440
|
+
if not query:
|
|
1441
|
+
results_widget.results = []
|
|
1442
|
+
results_widget.remove_class("visible")
|
|
1443
|
+
return
|
|
1444
|
+
|
|
1445
|
+
# Fuzzy match files
|
|
1446
|
+
matches = []
|
|
1447
|
+
for path in self._all_files:
|
|
1448
|
+
name = path.name.lower()
|
|
1449
|
+
rel_path = str(path.relative_to(self.root_path)).lower()
|
|
1450
|
+
|
|
1451
|
+
# Simple fuzzy match: all query chars appear in order
|
|
1452
|
+
if self._fuzzy_match(query, name) or self._fuzzy_match(query, rel_path):
|
|
1453
|
+
matches.append(path)
|
|
1454
|
+
if len(matches) >= 50:
|
|
1455
|
+
break
|
|
1456
|
+
|
|
1457
|
+
results_widget.results = matches
|
|
1458
|
+
if matches:
|
|
1459
|
+
results_widget.add_class("visible")
|
|
1460
|
+
else:
|
|
1461
|
+
results_widget.remove_class("visible")
|
|
1462
|
+
|
|
1463
|
+
def _fuzzy_match(self, query: str, target: str) -> bool:
|
|
1464
|
+
"""Simple fuzzy matching."""
|
|
1465
|
+
query_idx = 0
|
|
1466
|
+
for char in target:
|
|
1467
|
+
if query_idx < len(query) and char == query[query_idx]:
|
|
1468
|
+
query_idx += 1
|
|
1469
|
+
return query_idx == len(query)
|
|
1470
|
+
|
|
1471
|
+
def action_close_search(self) -> None:
|
|
1472
|
+
"""Close the search."""
|
|
1473
|
+
self.query_one("#search-input", Input).value = ""
|
|
1474
|
+
self.query_one("#search-results", FileSearchResults).results = []
|
|
1475
|
+
self.query_one("#search-results", FileSearchResults).remove_class("visible")
|
|
1476
|
+
self.post_message(self.SearchClosed())
|
|
1477
|
+
|
|
1478
|
+
def action_move_up(self) -> None:
|
|
1479
|
+
"""Move selection up."""
|
|
1480
|
+
self.query_one("#search-results", FileSearchResults).move_selection(-1)
|
|
1481
|
+
|
|
1482
|
+
def action_move_down(self) -> None:
|
|
1483
|
+
"""Move selection down."""
|
|
1484
|
+
self.query_one("#search-results", FileSearchResults).move_selection(1)
|
|
1485
|
+
|
|
1486
|
+
def action_select_file(self) -> None:
|
|
1487
|
+
"""Select the current file."""
|
|
1488
|
+
results = self.query_one("#search-results", FileSearchResults)
|
|
1489
|
+
path = results.get_selected()
|
|
1490
|
+
if path:
|
|
1491
|
+
self.post_message(self.FileSelected(path))
|
|
1492
|
+
self.action_close_search()
|
|
1493
|
+
|
|
1494
|
+
|
|
1495
|
+
# ============================================================================
|
|
1496
|
+
# CODEBASE SEARCH (Content Search / Grep)
|
|
1497
|
+
# ============================================================================
|
|
1498
|
+
|
|
1499
|
+
|
|
1500
|
+
@dataclass
|
|
1501
|
+
class CodeSearchResult:
|
|
1502
|
+
"""A single code search result."""
|
|
1503
|
+
|
|
1504
|
+
path: Path
|
|
1505
|
+
line_no: int
|
|
1506
|
+
line_content: str
|
|
1507
|
+
match_start: int
|
|
1508
|
+
match_end: int
|
|
1509
|
+
|
|
1510
|
+
|
|
1511
|
+
def search_codebase(root_path: Path, query: str, max_results: int = 100) -> List[CodeSearchResult]:
|
|
1512
|
+
"""Search through file contents (grep-like)."""
|
|
1513
|
+
results = []
|
|
1514
|
+
query_lower = query.lower()
|
|
1515
|
+
|
|
1516
|
+
# File extensions to search
|
|
1517
|
+
code_extensions = {
|
|
1518
|
+
".py",
|
|
1519
|
+
".js",
|
|
1520
|
+
".ts",
|
|
1521
|
+
".jsx",
|
|
1522
|
+
".tsx",
|
|
1523
|
+
".java",
|
|
1524
|
+
".c",
|
|
1525
|
+
".cpp",
|
|
1526
|
+
".h",
|
|
1527
|
+
".hpp",
|
|
1528
|
+
".go",
|
|
1529
|
+
".rs",
|
|
1530
|
+
".rb",
|
|
1531
|
+
".php",
|
|
1532
|
+
".swift",
|
|
1533
|
+
".kt",
|
|
1534
|
+
".scala",
|
|
1535
|
+
".cs",
|
|
1536
|
+
".vb",
|
|
1537
|
+
".html",
|
|
1538
|
+
".css",
|
|
1539
|
+
".scss",
|
|
1540
|
+
".sass",
|
|
1541
|
+
".less",
|
|
1542
|
+
".json",
|
|
1543
|
+
".yaml",
|
|
1544
|
+
".yml",
|
|
1545
|
+
".xml",
|
|
1546
|
+
".md",
|
|
1547
|
+
".txt",
|
|
1548
|
+
".sh",
|
|
1549
|
+
".bash",
|
|
1550
|
+
".zsh",
|
|
1551
|
+
".fish",
|
|
1552
|
+
".sql",
|
|
1553
|
+
".toml",
|
|
1554
|
+
".ini",
|
|
1555
|
+
".cfg",
|
|
1556
|
+
".conf",
|
|
1557
|
+
".env",
|
|
1558
|
+
".gitignore",
|
|
1559
|
+
".dockerignore",
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
try:
|
|
1563
|
+
for path in root_path.rglob("*"):
|
|
1564
|
+
if not path.is_file():
|
|
1565
|
+
continue
|
|
1566
|
+
|
|
1567
|
+
# Skip hidden and ignored directories
|
|
1568
|
+
parts = path.parts
|
|
1569
|
+
if any(
|
|
1570
|
+
p.startswith(".")
|
|
1571
|
+
and p not in {".env", ".gitignore", ".dockerignore"}
|
|
1572
|
+
or p in {"node_modules", "__pycache__", "venv", ".venv", "dist", "build"}
|
|
1573
|
+
for p in parts
|
|
1574
|
+
):
|
|
1575
|
+
continue
|
|
1576
|
+
|
|
1577
|
+
# Only search code files
|
|
1578
|
+
if path.suffix.lower() not in code_extensions:
|
|
1579
|
+
continue
|
|
1580
|
+
|
|
1581
|
+
# Skip large files
|
|
1582
|
+
try:
|
|
1583
|
+
if path.stat().st_size > 500000: # 500KB limit
|
|
1584
|
+
continue
|
|
1585
|
+
except Exception:
|
|
1586
|
+
continue
|
|
1587
|
+
|
|
1588
|
+
# Search file content
|
|
1589
|
+
try:
|
|
1590
|
+
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
|
1591
|
+
for line_no, line in enumerate(f, 1):
|
|
1592
|
+
line_lower = line.lower()
|
|
1593
|
+
idx = line_lower.find(query_lower)
|
|
1594
|
+
if idx != -1:
|
|
1595
|
+
results.append(
|
|
1596
|
+
CodeSearchResult(
|
|
1597
|
+
path=path,
|
|
1598
|
+
line_no=line_no,
|
|
1599
|
+
line_content=line.rstrip()[:200], # Limit line length
|
|
1600
|
+
match_start=idx,
|
|
1601
|
+
match_end=idx + len(query),
|
|
1602
|
+
)
|
|
1603
|
+
)
|
|
1604
|
+
if len(results) >= max_results:
|
|
1605
|
+
return results
|
|
1606
|
+
except Exception:
|
|
1607
|
+
continue
|
|
1608
|
+
|
|
1609
|
+
except Exception:
|
|
1610
|
+
pass
|
|
1611
|
+
|
|
1612
|
+
return results
|
|
1613
|
+
|
|
1614
|
+
|
|
1615
|
+
class CodeSearchResults(Container):
|
|
1616
|
+
"""Container showing code search results."""
|
|
1617
|
+
|
|
1618
|
+
DEFAULT_CSS = """
|
|
1619
|
+
CodeSearchResults {
|
|
1620
|
+
height: auto;
|
|
1621
|
+
max-height: 20;
|
|
1622
|
+
background: #000000;
|
|
1623
|
+
padding: 0;
|
|
1624
|
+
display: none;
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
CodeSearchResults.visible {
|
|
1628
|
+
display: block;
|
|
1629
|
+
}
|
|
1630
|
+
"""
|
|
1631
|
+
|
|
1632
|
+
results: reactive[List[CodeSearchResult]] = reactive(list)
|
|
1633
|
+
selected_index: reactive[int] = reactive(0)
|
|
1634
|
+
|
|
1635
|
+
class ResultSelected(Message):
|
|
1636
|
+
"""Message when a search result is selected."""
|
|
1637
|
+
|
|
1638
|
+
def __init__(self, result: CodeSearchResult) -> None:
|
|
1639
|
+
self.result = result
|
|
1640
|
+
super().__init__()
|
|
1641
|
+
|
|
1642
|
+
def compose(self) -> ComposeResult:
|
|
1643
|
+
"""Compose search results."""
|
|
1644
|
+
yield Static(self._render_results(), id="code-results-content")
|
|
1645
|
+
|
|
1646
|
+
def watch_results(self, results: List[CodeSearchResult]) -> None:
|
|
1647
|
+
"""Update when results change."""
|
|
1648
|
+
self.selected_index = 0
|
|
1649
|
+
try:
|
|
1650
|
+
self.query_one("#code-results-content", Static).update(self._render_results())
|
|
1651
|
+
except Exception:
|
|
1652
|
+
pass
|
|
1653
|
+
|
|
1654
|
+
def watch_selected_index(self, index: int) -> None:
|
|
1655
|
+
"""Update when selection changes."""
|
|
1656
|
+
try:
|
|
1657
|
+
self.query_one("#code-results-content", Static).update(self._render_results())
|
|
1658
|
+
except Exception:
|
|
1659
|
+
pass
|
|
1660
|
+
|
|
1661
|
+
def _render_results(self) -> Text:
|
|
1662
|
+
"""Render search results."""
|
|
1663
|
+
t = Text()
|
|
1664
|
+
|
|
1665
|
+
if not self.results:
|
|
1666
|
+
t.append(" No matches found", style="italic #52525b")
|
|
1667
|
+
return t
|
|
1668
|
+
|
|
1669
|
+
# Group by file
|
|
1670
|
+
current_file = None
|
|
1671
|
+
display_count = 0
|
|
1672
|
+
|
|
1673
|
+
for i, result in enumerate(self.results[:30]): # Show max 30 results
|
|
1674
|
+
# File header
|
|
1675
|
+
if result.path != current_file:
|
|
1676
|
+
if current_file is not None:
|
|
1677
|
+
t.append("\n", style="")
|
|
1678
|
+
current_file = result.path
|
|
1679
|
+
|
|
1680
|
+
# File icon and path
|
|
1681
|
+
icon, color = get_file_icon(result.path)
|
|
1682
|
+
rel_path = (
|
|
1683
|
+
str(result.path)[-50:] if len(str(result.path)) > 50 else str(result.path)
|
|
1684
|
+
)
|
|
1685
|
+
if len(str(result.path)) > 50:
|
|
1686
|
+
rel_path = "..." + rel_path
|
|
1687
|
+
t.append(f" {icon} ", style=color)
|
|
1688
|
+
t.append(rel_path + "\n", style="bold #a1a1aa")
|
|
1689
|
+
|
|
1690
|
+
# Result line
|
|
1691
|
+
if i == self.selected_index:
|
|
1692
|
+
t.append(" ▸ ", style="bold #a855f7")
|
|
1693
|
+
else:
|
|
1694
|
+
t.append(" ", style="")
|
|
1695
|
+
|
|
1696
|
+
# Line number
|
|
1697
|
+
t.append(f"{result.line_no:>4}:", style="#52525b")
|
|
1698
|
+
|
|
1699
|
+
# Line content with highlight
|
|
1700
|
+
line = result.line_content[:80]
|
|
1701
|
+
if result.match_start < len(line):
|
|
1702
|
+
# Before match
|
|
1703
|
+
t.append(line[: result.match_start], style="#71717a")
|
|
1704
|
+
# Match (highlighted)
|
|
1705
|
+
match_end = min(result.match_end, len(line))
|
|
1706
|
+
t.append(line[result.match_start : match_end], style="bold #fbbf24 on #1a1a1a")
|
|
1707
|
+
# After match
|
|
1708
|
+
t.append(line[match_end:], style="#71717a")
|
|
1709
|
+
else:
|
|
1710
|
+
t.append(line, style="#71717a")
|
|
1711
|
+
|
|
1712
|
+
t.append("\n", style="")
|
|
1713
|
+
display_count += 1
|
|
1714
|
+
|
|
1715
|
+
if len(self.results) > 30:
|
|
1716
|
+
t.append(f"\n +{len(self.results) - 30} more results", style="#52525b")
|
|
1717
|
+
|
|
1718
|
+
return t
|
|
1719
|
+
|
|
1720
|
+
def move_selection(self, delta: int) -> None:
|
|
1721
|
+
"""Move selection up or down."""
|
|
1722
|
+
if self.results:
|
|
1723
|
+
max_idx = min(len(self.results), 30) - 1
|
|
1724
|
+
new_index = max(0, min(self.selected_index + delta, max_idx))
|
|
1725
|
+
self.selected_index = new_index
|
|
1726
|
+
|
|
1727
|
+
def get_selected(self) -> Optional[CodeSearchResult]:
|
|
1728
|
+
"""Get the selected result."""
|
|
1729
|
+
if self.results and 0 <= self.selected_index < len(self.results):
|
|
1730
|
+
return self.results[self.selected_index]
|
|
1731
|
+
return None
|
|
1732
|
+
|
|
1733
|
+
|
|
1734
|
+
class CodebaseSearch(Container):
|
|
1735
|
+
"""Codebase search widget - grep through file contents."""
|
|
1736
|
+
|
|
1737
|
+
DEFAULT_CSS = """
|
|
1738
|
+
CodebaseSearch {
|
|
1739
|
+
height: auto;
|
|
1740
|
+
background: #000000;
|
|
1741
|
+
padding: 0;
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
CodebaseSearch #code-search-input {
|
|
1745
|
+
height: 1;
|
|
1746
|
+
background: #0a0a0a;
|
|
1747
|
+
border: none;
|
|
1748
|
+
padding: 0 1;
|
|
1749
|
+
margin: 0;
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
CodebaseSearch #code-search-input:focus {
|
|
1753
|
+
border: none;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
CodebaseSearch #search-status {
|
|
1757
|
+
height: 1;
|
|
1758
|
+
padding: 0 1;
|
|
1759
|
+
color: #52525b;
|
|
1760
|
+
}
|
|
1761
|
+
"""
|
|
1762
|
+
|
|
1763
|
+
BINDINGS = [
|
|
1764
|
+
Binding("escape", "close_search", "Close", show=False),
|
|
1765
|
+
Binding("up", "move_up", "Up", show=False),
|
|
1766
|
+
Binding("down", "move_down", "Down", show=False),
|
|
1767
|
+
Binding("enter", "select_result", "Select", show=False),
|
|
1768
|
+
]
|
|
1769
|
+
|
|
1770
|
+
_searching: bool = False
|
|
1771
|
+
|
|
1772
|
+
class ResultSelected(Message):
|
|
1773
|
+
"""Message when a result is selected."""
|
|
1774
|
+
|
|
1775
|
+
def __init__(self, path: Path, line_no: int) -> None:
|
|
1776
|
+
self.path = path
|
|
1777
|
+
self.line_no = line_no
|
|
1778
|
+
super().__init__()
|
|
1779
|
+
|
|
1780
|
+
class SearchClosed(Message):
|
|
1781
|
+
"""Message when search is closed."""
|
|
1782
|
+
|
|
1783
|
+
pass
|
|
1784
|
+
|
|
1785
|
+
def __init__(self, root_path: Path, **kwargs):
|
|
1786
|
+
super().__init__(**kwargs)
|
|
1787
|
+
self.root_path = root_path
|
|
1788
|
+
self._searching = False
|
|
1789
|
+
self._last_query = ""
|
|
1790
|
+
|
|
1791
|
+
def compose(self) -> ComposeResult:
|
|
1792
|
+
"""Compose the search widget."""
|
|
1793
|
+
yield Input(placeholder="🔎 Search in files...", id="code-search-input")
|
|
1794
|
+
yield Static("", id="search-status")
|
|
1795
|
+
yield CodeSearchResults(id="code-search-results")
|
|
1796
|
+
|
|
1797
|
+
@on(Input.Changed, "#code-search-input")
|
|
1798
|
+
def on_search_changed(self, event: Input.Changed) -> None:
|
|
1799
|
+
"""Handle search input changes."""
|
|
1800
|
+
query = event.value.strip()
|
|
1801
|
+
|
|
1802
|
+
if not query or len(query) < 2:
|
|
1803
|
+
self.query_one("#code-search-results", CodeSearchResults).results = []
|
|
1804
|
+
self.query_one("#code-search-results", CodeSearchResults).remove_class("visible")
|
|
1805
|
+
self.query_one("#search-status", Static).update("")
|
|
1806
|
+
return
|
|
1807
|
+
|
|
1808
|
+
if query != self._last_query:
|
|
1809
|
+
self._last_query = query
|
|
1810
|
+
self._do_search(query)
|
|
1811
|
+
|
|
1812
|
+
@work(thread=True)
|
|
1813
|
+
def _do_search(self, query: str) -> None:
|
|
1814
|
+
"""Perform search in background."""
|
|
1815
|
+
self._searching = True
|
|
1816
|
+
self.app.call_from_thread(self._update_status, "Searching...")
|
|
1817
|
+
|
|
1818
|
+
results = search_codebase(self.root_path, query)
|
|
1819
|
+
|
|
1820
|
+
self.app.call_from_thread(self._show_results, results)
|
|
1821
|
+
|
|
1822
|
+
def _update_status(self, status: str) -> None:
|
|
1823
|
+
"""Update status text."""
|
|
1824
|
+
try:
|
|
1825
|
+
self.query_one("#search-status", Static).update(status)
|
|
1826
|
+
except Exception:
|
|
1827
|
+
pass
|
|
1828
|
+
|
|
1829
|
+
def _show_results(self, results: List[CodeSearchResult]) -> None:
|
|
1830
|
+
"""Show search results."""
|
|
1831
|
+
self._searching = False
|
|
1832
|
+
try:
|
|
1833
|
+
results_widget = self.query_one("#code-search-results", CodeSearchResults)
|
|
1834
|
+
results_widget.results = results
|
|
1835
|
+
|
|
1836
|
+
if results:
|
|
1837
|
+
results_widget.add_class("visible")
|
|
1838
|
+
self._update_status(f"Found {len(results)} matches")
|
|
1839
|
+
else:
|
|
1840
|
+
results_widget.remove_class("visible")
|
|
1841
|
+
self._update_status("No matches found")
|
|
1842
|
+
except Exception:
|
|
1843
|
+
pass
|
|
1844
|
+
|
|
1845
|
+
def action_close_search(self) -> None:
|
|
1846
|
+
"""Close the search."""
|
|
1847
|
+
self.query_one("#code-search-input", Input).value = ""
|
|
1848
|
+
self.query_one("#code-search-results", CodeSearchResults).results = []
|
|
1849
|
+
self.query_one("#code-search-results", CodeSearchResults).remove_class("visible")
|
|
1850
|
+
self.query_one("#search-status", Static).update("")
|
|
1851
|
+
self._last_query = ""
|
|
1852
|
+
self.post_message(self.SearchClosed())
|
|
1853
|
+
|
|
1854
|
+
def action_move_up(self) -> None:
|
|
1855
|
+
"""Move selection up."""
|
|
1856
|
+
self.query_one("#code-search-results", CodeSearchResults).move_selection(-1)
|
|
1857
|
+
|
|
1858
|
+
def action_move_down(self) -> None:
|
|
1859
|
+
"""Move selection down."""
|
|
1860
|
+
self.query_one("#code-search-results", CodeSearchResults).move_selection(1)
|
|
1861
|
+
|
|
1862
|
+
def action_select_result(self) -> None:
|
|
1863
|
+
"""Select the current result."""
|
|
1864
|
+
results = self.query_one("#code-search-results", CodeSearchResults)
|
|
1865
|
+
result = results.get_selected()
|
|
1866
|
+
if result:
|
|
1867
|
+
self.post_message(self.ResultSelected(result.path, result.line_no))
|
|
1868
|
+
self.action_close_search()
|
|
1869
|
+
|
|
1870
|
+
|
|
1871
|
+
# ============================================================================
|
|
1872
|
+
# TABBED SIDEBAR (Clean Tab-based Design)
|
|
1873
|
+
# ============================================================================
|
|
1874
|
+
|
|
1875
|
+
|
|
1876
|
+
class SidebarTabs(Container):
|
|
1877
|
+
"""Tab bar for switching between sidebar views.
|
|
1878
|
+
|
|
1879
|
+
Tabs: Files, Code, Changes, Search, Agent, Context, Diff, History
|
|
1880
|
+
Uses minimal SuperQode icons instead of emojis.
|
|
1881
|
+
"""
|
|
1882
|
+
|
|
1883
|
+
DEFAULT_CSS = """
|
|
1884
|
+
SidebarTabs {
|
|
1885
|
+
height: 2;
|
|
1886
|
+
width: 100%;
|
|
1887
|
+
layout: horizontal;
|
|
1888
|
+
background: #000000;
|
|
1889
|
+
border-bottom: solid #1a1a1a;
|
|
1890
|
+
overflow-x: auto;
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
SidebarTabs .tab {
|
|
1894
|
+
width: auto;
|
|
1895
|
+
min-width: 4;
|
|
1896
|
+
height: 100%;
|
|
1897
|
+
content-align: center middle;
|
|
1898
|
+
text-align: center;
|
|
1899
|
+
background: #000000;
|
|
1900
|
+
color: #71717a;
|
|
1901
|
+
padding: 0 1;
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
SidebarTabs .tab:hover {
|
|
1905
|
+
background: #0a0a0a;
|
|
1906
|
+
color: #a1a1aa;
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
SidebarTabs .tab.active {
|
|
1910
|
+
background: #0a0a0a;
|
|
1911
|
+
color: #a855f7;
|
|
1912
|
+
border-bottom: solid #a855f7;
|
|
1913
|
+
}
|
|
1914
|
+
"""
|
|
1915
|
+
|
|
1916
|
+
# All available tabs with SuperQode icons - Colorful symbols
|
|
1917
|
+
TABS = {
|
|
1918
|
+
"files": "📁", # Files
|
|
1919
|
+
"code": "◇", # Code preview
|
|
1920
|
+
"changes": "⟳", # Git changes
|
|
1921
|
+
"search": "⌕", # Search
|
|
1922
|
+
"agent": "◈", # Agent info
|
|
1923
|
+
"context": "↳", # Context
|
|
1924
|
+
"diff": "±", # Diff
|
|
1925
|
+
"history": "◇", # History
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
# Tab hints for hover
|
|
1929
|
+
TAB_HINTS = {
|
|
1930
|
+
"files": "Project Files",
|
|
1931
|
+
"code": "Code Preview",
|
|
1932
|
+
"changes": "Git Changes",
|
|
1933
|
+
"search": "Search Files",
|
|
1934
|
+
"agent": "Agent Info",
|
|
1935
|
+
"context": "Context",
|
|
1936
|
+
"diff": "File Diff",
|
|
1937
|
+
"history": "History",
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
active_tab: reactive[str] = reactive("files")
|
|
1941
|
+
|
|
1942
|
+
class TabChanged(Message):
|
|
1943
|
+
"""Message when tab changes."""
|
|
1944
|
+
|
|
1945
|
+
def __init__(self, tab: str) -> None:
|
|
1946
|
+
self.tab = tab
|
|
1947
|
+
super().__init__()
|
|
1948
|
+
|
|
1949
|
+
def compose(self) -> ComposeResult:
|
|
1950
|
+
"""Compose tab bar with all tabs."""
|
|
1951
|
+
# Primary tabs (always shown) - Colorful symbols with hints
|
|
1952
|
+
yield Static(self.TABS["files"], id="tab-files", classes="tab active")
|
|
1953
|
+
yield Static(self.TABS["code"], id="tab-code", classes="tab")
|
|
1954
|
+
yield Static(self.TABS["changes"], id="tab-changes", classes="tab")
|
|
1955
|
+
yield Static(self.TABS["search"], id="tab-search", classes="tab")
|
|
1956
|
+
yield Static(self.TABS["agent"], id="tab-agent", classes="tab")
|
|
1957
|
+
yield Static(self.TABS["context"], id="tab-context", classes="tab")
|
|
1958
|
+
yield Static(self.TABS["diff"], id="tab-diff", classes="tab")
|
|
1959
|
+
yield Static(self.TABS["history"], id="tab-history", classes="tab")
|
|
1960
|
+
|
|
1961
|
+
def on_mount(self) -> None:
|
|
1962
|
+
"""Set tooltips after widgets are mounted."""
|
|
1963
|
+
for tab_name in self.TABS:
|
|
1964
|
+
try:
|
|
1965
|
+
tab_widget = self.query_one(f"#tab-{tab_name}", Static)
|
|
1966
|
+
hint = self.TAB_HINTS.get(tab_name, "")
|
|
1967
|
+
if hint:
|
|
1968
|
+
tab_widget.tooltip = hint
|
|
1969
|
+
except Exception:
|
|
1970
|
+
pass
|
|
1971
|
+
|
|
1972
|
+
def on_click(self, event) -> None:
|
|
1973
|
+
"""Handle tab clicks."""
|
|
1974
|
+
widget_id = event.widget.id
|
|
1975
|
+
if widget_id and widget_id.startswith("tab-"):
|
|
1976
|
+
tab_name = widget_id[4:] # Remove "tab-" prefix
|
|
1977
|
+
if tab_name in self.TABS:
|
|
1978
|
+
self.active_tab = tab_name
|
|
1979
|
+
|
|
1980
|
+
def watch_active_tab(self, tab: str) -> None:
|
|
1981
|
+
"""Update tab styles when active tab changes."""
|
|
1982
|
+
try:
|
|
1983
|
+
# Remove active class from all tabs
|
|
1984
|
+
for tab_name in self.TABS:
|
|
1985
|
+
try:
|
|
1986
|
+
tab_widget = self.query_one(f"#tab-{tab_name}", Static)
|
|
1987
|
+
tab_widget.remove_class("active")
|
|
1988
|
+
except Exception:
|
|
1989
|
+
pass
|
|
1990
|
+
|
|
1991
|
+
# Add active class to selected tab
|
|
1992
|
+
try:
|
|
1993
|
+
active_widget = self.query_one(f"#tab-{tab}", Static)
|
|
1994
|
+
active_widget.add_class("active")
|
|
1995
|
+
except Exception:
|
|
1996
|
+
pass
|
|
1997
|
+
|
|
1998
|
+
self.post_message(self.TabChanged(tab))
|
|
1999
|
+
except Exception:
|
|
2000
|
+
pass
|
|
2001
|
+
|
|
2002
|
+
def select_tab(self, tab: str) -> None:
|
|
2003
|
+
"""Programmatically select a tab."""
|
|
2004
|
+
if tab in self.TABS:
|
|
2005
|
+
self.active_tab = tab
|
|
2006
|
+
|
|
2007
|
+
|
|
2008
|
+
# ============================================================================
|
|
2009
|
+
# GIT CHANGES VIEW
|
|
2010
|
+
# ============================================================================
|
|
2011
|
+
|
|
2012
|
+
|
|
2013
|
+
@dataclass
|
|
2014
|
+
class GitChange:
|
|
2015
|
+
"""A single git change entry."""
|
|
2016
|
+
|
|
2017
|
+
path: str
|
|
2018
|
+
status: str # M=modified, A=added, D=deleted, ?=untracked, R=renamed
|
|
2019
|
+
staged: bool = False
|
|
2020
|
+
|
|
2021
|
+
|
|
2022
|
+
def get_git_changes(root_path: Path) -> List[GitChange]:
|
|
2023
|
+
"""Get list of changed files from git."""
|
|
2024
|
+
changes = []
|
|
2025
|
+
try:
|
|
2026
|
+
result = subprocess.run(
|
|
2027
|
+
["git", "status", "--porcelain"],
|
|
2028
|
+
cwd=root_path,
|
|
2029
|
+
capture_output=True,
|
|
2030
|
+
text=True,
|
|
2031
|
+
timeout=5,
|
|
2032
|
+
)
|
|
2033
|
+
if result.returncode == 0:
|
|
2034
|
+
for line in result.stdout.strip().split("\n"):
|
|
2035
|
+
if not line:
|
|
2036
|
+
continue
|
|
2037
|
+
status = line[:2]
|
|
2038
|
+
path = line[3:]
|
|
2039
|
+
|
|
2040
|
+
# Parse status
|
|
2041
|
+
staged = status[0] != " " and status[0] != "?"
|
|
2042
|
+
if status[0] in "MADRCU":
|
|
2043
|
+
changes.append(GitChange(path=path, status=status[0], staged=True))
|
|
2044
|
+
if status[1] in "MD":
|
|
2045
|
+
changes.append(GitChange(path=path, status=status[1], staged=False))
|
|
2046
|
+
if status == "??":
|
|
2047
|
+
changes.append(GitChange(path=path, status="?", staged=False))
|
|
2048
|
+
except Exception:
|
|
2049
|
+
pass
|
|
2050
|
+
return changes
|
|
2051
|
+
|
|
2052
|
+
|
|
2053
|
+
def get_file_diff(root_path: Path, file_path: str, staged: bool = False) -> str:
|
|
2054
|
+
"""Get diff for a specific file."""
|
|
2055
|
+
try:
|
|
2056
|
+
cmd = ["git", "diff"]
|
|
2057
|
+
if staged:
|
|
2058
|
+
cmd.append("--cached")
|
|
2059
|
+
cmd.append("--")
|
|
2060
|
+
cmd.append(file_path)
|
|
2061
|
+
|
|
2062
|
+
result = subprocess.run(cmd, cwd=root_path, capture_output=True, text=True, timeout=10)
|
|
2063
|
+
if result.returncode == 0:
|
|
2064
|
+
return result.stdout
|
|
2065
|
+
except Exception:
|
|
2066
|
+
pass
|
|
2067
|
+
return ""
|
|
2068
|
+
|
|
2069
|
+
|
|
2070
|
+
class GitChangesPanel(Container):
|
|
2071
|
+
"""Panel showing git changes with diffs."""
|
|
2072
|
+
|
|
2073
|
+
DEFAULT_CSS = """
|
|
2074
|
+
GitChangesPanel {
|
|
2075
|
+
height: 100%;
|
|
2076
|
+
width: 100%;
|
|
2077
|
+
background: #000000;
|
|
2078
|
+
layout: vertical;
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
GitChangesPanel #changes-header {
|
|
2082
|
+
height: 2;
|
|
2083
|
+
background: #0a0a0a;
|
|
2084
|
+
border-bottom: solid #1a1a1a;
|
|
2085
|
+
padding: 0 1;
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
GitChangesPanel #changes-list {
|
|
2089
|
+
height: 1fr;
|
|
2090
|
+
background: #000000;
|
|
2091
|
+
overflow-y: auto;
|
|
2092
|
+
scrollbar-size: 1 1;
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
GitChangesPanel .change-item {
|
|
2096
|
+
height: 1;
|
|
2097
|
+
padding: 0 1;
|
|
2098
|
+
background: #000000;
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
GitChangesPanel .change-item:hover {
|
|
2102
|
+
background: #0a0a0a;
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
GitChangesPanel .change-item.selected {
|
|
2106
|
+
background: #a855f720;
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
GitChangesPanel #diff-view {
|
|
2110
|
+
height: 1fr;
|
|
2111
|
+
background: #000000;
|
|
2112
|
+
border-top: solid #1a1a1a;
|
|
2113
|
+
overflow-y: auto;
|
|
2114
|
+
overflow-x: auto;
|
|
2115
|
+
scrollbar-size: 1 1;
|
|
2116
|
+
display: none;
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
GitChangesPanel #diff-view.visible {
|
|
2120
|
+
display: block;
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
GitChangesPanel #no-changes {
|
|
2124
|
+
height: 100%;
|
|
2125
|
+
content-align: center middle;
|
|
2126
|
+
text-align: center;
|
|
2127
|
+
color: #52525b;
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
GitChangesPanel #loading {
|
|
2131
|
+
height: 100%;
|
|
2132
|
+
content-align: center middle;
|
|
2133
|
+
text-align: center;
|
|
2134
|
+
color: #71717a;
|
|
2135
|
+
}
|
|
2136
|
+
"""
|
|
2137
|
+
|
|
2138
|
+
changes: reactive[List[GitChange]] = reactive(list)
|
|
2139
|
+
selected_index: reactive[int] = reactive(-1)
|
|
2140
|
+
_loading: bool = True
|
|
2141
|
+
|
|
2142
|
+
class FileSelected(Message):
|
|
2143
|
+
"""Message when a file is selected for diff viewing."""
|
|
2144
|
+
|
|
2145
|
+
def __init__(self, path: str, staged: bool) -> None:
|
|
2146
|
+
self.path = path
|
|
2147
|
+
self.staged = staged
|
|
2148
|
+
super().__init__()
|
|
2149
|
+
|
|
2150
|
+
def __init__(self, root_path: Path, **kwargs):
|
|
2151
|
+
super().__init__(**kwargs)
|
|
2152
|
+
self.root_path = root_path
|
|
2153
|
+
self._loading = True
|
|
2154
|
+
self._current_diff = ""
|
|
2155
|
+
|
|
2156
|
+
def compose(self) -> ComposeResult:
|
|
2157
|
+
"""Compose the changes panel."""
|
|
2158
|
+
yield Static(self._render_header(), id="changes-header")
|
|
2159
|
+
yield Static("Loading changes...", id="loading")
|
|
2160
|
+
with ScrollableContainer(id="changes-list"):
|
|
2161
|
+
yield Static("", id="changes-content")
|
|
2162
|
+
with ScrollableContainer(id="diff-view"):
|
|
2163
|
+
yield Static("", id="diff-content")
|
|
2164
|
+
yield Static("✓ No changes\nWorking tree clean", id="no-changes")
|
|
2165
|
+
|
|
2166
|
+
def on_mount(self) -> None:
|
|
2167
|
+
"""Load changes on mount."""
|
|
2168
|
+
self.refresh_changes()
|
|
2169
|
+
|
|
2170
|
+
@work(thread=True)
|
|
2171
|
+
def refresh_changes(self) -> None:
|
|
2172
|
+
"""Refresh git changes in background."""
|
|
2173
|
+
changes = get_git_changes(self.root_path)
|
|
2174
|
+
self.app.call_from_thread(self._update_changes, changes)
|
|
2175
|
+
|
|
2176
|
+
def _update_changes(self, changes: List[GitChange]) -> None:
|
|
2177
|
+
"""Update changes from thread."""
|
|
2178
|
+
self._loading = False
|
|
2179
|
+
self.changes = changes
|
|
2180
|
+
|
|
2181
|
+
# Reset selection if current selection is out of bounds
|
|
2182
|
+
if self.selected_index >= len(self.changes):
|
|
2183
|
+
self.selected_index = -1
|
|
2184
|
+
|
|
2185
|
+
self._update_ui()
|
|
2186
|
+
|
|
2187
|
+
def _update_ui(self) -> None:
|
|
2188
|
+
"""Update UI based on current state."""
|
|
2189
|
+
try:
|
|
2190
|
+
loading = self.query_one("#loading", Static)
|
|
2191
|
+
no_changes = self.query_one("#no-changes", Static)
|
|
2192
|
+
changes_list = self.query_one("#changes-list", ScrollableContainer)
|
|
2193
|
+
diff_view = self.query_one("#diff-view", ScrollableContainer)
|
|
2194
|
+
|
|
2195
|
+
loading.display = False
|
|
2196
|
+
|
|
2197
|
+
if not self.changes:
|
|
2198
|
+
no_changes.display = True
|
|
2199
|
+
changes_list.display = False
|
|
2200
|
+
# Hide diff view when no changes
|
|
2201
|
+
diff_view.remove_class("visible")
|
|
2202
|
+
self.selected_index = -1
|
|
2203
|
+
else:
|
|
2204
|
+
no_changes.display = False
|
|
2205
|
+
changes_list.display = True
|
|
2206
|
+
self.query_one("#changes-content", Static).update(self._render_changes())
|
|
2207
|
+
|
|
2208
|
+
# If we have a valid selection, ensure diff is loaded
|
|
2209
|
+
if 0 <= self.selected_index < len(self.changes):
|
|
2210
|
+
change = self.changes[self.selected_index]
|
|
2211
|
+
self._load_diff(change.path, change.staged)
|
|
2212
|
+
else:
|
|
2213
|
+
# Clear diff view if no valid selection
|
|
2214
|
+
diff_view.remove_class("visible")
|
|
2215
|
+
except Exception:
|
|
2216
|
+
pass
|
|
2217
|
+
|
|
2218
|
+
def _render_header(self) -> Text:
|
|
2219
|
+
"""Render the header."""
|
|
2220
|
+
t = Text()
|
|
2221
|
+
t.append("\n", style="")
|
|
2222
|
+
t.append("📊 Git Changes", style="bold #a855f7")
|
|
2223
|
+
t.append(" ", style="")
|
|
2224
|
+
t.append("r", style="bold #52525b")
|
|
2225
|
+
t.append(" refresh", style="#3f3f46")
|
|
2226
|
+
return t
|
|
2227
|
+
|
|
2228
|
+
def _render_changes(self) -> Text:
|
|
2229
|
+
"""Render the changes list."""
|
|
2230
|
+
t = Text()
|
|
2231
|
+
|
|
2232
|
+
# Group by staged/unstaged
|
|
2233
|
+
staged = [c for c in self.changes if c.staged]
|
|
2234
|
+
unstaged = [c for c in self.changes if not c.staged]
|
|
2235
|
+
|
|
2236
|
+
if staged:
|
|
2237
|
+
t.append(" Staged Changes\n", style="bold #22c55e")
|
|
2238
|
+
for i, change in enumerate(staged):
|
|
2239
|
+
self._render_change_item(t, change, i)
|
|
2240
|
+
|
|
2241
|
+
if unstaged:
|
|
2242
|
+
if staged:
|
|
2243
|
+
t.append("\n", style="")
|
|
2244
|
+
t.append(" Unstaged Changes\n", style="bold #f97316")
|
|
2245
|
+
for i, change in enumerate(unstaged, len(staged)):
|
|
2246
|
+
self._render_change_item(t, change, i)
|
|
2247
|
+
|
|
2248
|
+
return t
|
|
2249
|
+
|
|
2250
|
+
def _render_change_item(self, t: Text, change: GitChange, index: int) -> None:
|
|
2251
|
+
"""Render a single change item."""
|
|
2252
|
+
# Selection indicator
|
|
2253
|
+
if index == self.selected_index:
|
|
2254
|
+
t.append(" ▸ ", style="bold #a855f7")
|
|
2255
|
+
else:
|
|
2256
|
+
t.append(" ", style="")
|
|
2257
|
+
|
|
2258
|
+
# Status icon
|
|
2259
|
+
if change.status == "M":
|
|
2260
|
+
t.append("● ", style="bold #f97316")
|
|
2261
|
+
elif change.status == "A":
|
|
2262
|
+
t.append("+ ", style="bold #22c55e")
|
|
2263
|
+
elif change.status == "D":
|
|
2264
|
+
t.append("- ", style="bold #ef4444")
|
|
2265
|
+
elif change.status == "?":
|
|
2266
|
+
t.append("? ", style="#71717a")
|
|
2267
|
+
elif change.status == "R":
|
|
2268
|
+
t.append("→ ", style="bold #06b6d4")
|
|
2269
|
+
else:
|
|
2270
|
+
t.append(" ", style="")
|
|
2271
|
+
|
|
2272
|
+
# File path - show full path (no truncation)
|
|
2273
|
+
path = change.path
|
|
2274
|
+
|
|
2275
|
+
if index == self.selected_index:
|
|
2276
|
+
t.append(path, style="bold white")
|
|
2277
|
+
else:
|
|
2278
|
+
t.append(path, style="#a1a1aa")
|
|
2279
|
+
|
|
2280
|
+
t.append("\n", style="")
|
|
2281
|
+
|
|
2282
|
+
def watch_changes(self, changes: List[GitChange]) -> None:
|
|
2283
|
+
"""Update when changes change."""
|
|
2284
|
+
self._update_ui()
|
|
2285
|
+
|
|
2286
|
+
def watch_selected_index(self, index: int) -> None:
|
|
2287
|
+
"""Update when selection changes."""
|
|
2288
|
+
try:
|
|
2289
|
+
self.query_one("#changes-content", Static).update(self._render_changes())
|
|
2290
|
+
|
|
2291
|
+
# Load diff for selected file
|
|
2292
|
+
if 0 <= index < len(self.changes):
|
|
2293
|
+
change = self.changes[index]
|
|
2294
|
+
self._load_diff(change.path, change.staged)
|
|
2295
|
+
except Exception:
|
|
2296
|
+
pass
|
|
2297
|
+
|
|
2298
|
+
@work(thread=True)
|
|
2299
|
+
def _load_diff(self, path: str, staged: bool) -> None:
|
|
2300
|
+
"""Load diff for a file in background."""
|
|
2301
|
+
diff = get_file_diff(self.root_path, path, staged)
|
|
2302
|
+
self.app.call_from_thread(self._show_diff, diff)
|
|
2303
|
+
|
|
2304
|
+
def _show_diff(self, diff: str) -> None:
|
|
2305
|
+
"""Show diff content."""
|
|
2306
|
+
try:
|
|
2307
|
+
diff_view = self.query_one("#diff-view", ScrollableContainer)
|
|
2308
|
+
diff_content = self.query_one("#diff-content", Static)
|
|
2309
|
+
|
|
2310
|
+
if diff:
|
|
2311
|
+
diff_view.add_class("visible")
|
|
2312
|
+
diff_content.update(self._render_diff(diff))
|
|
2313
|
+
else:
|
|
2314
|
+
diff_view.remove_class("visible")
|
|
2315
|
+
except Exception:
|
|
2316
|
+
pass
|
|
2317
|
+
|
|
2318
|
+
def _render_diff(self, diff: str) -> Text:
|
|
2319
|
+
"""Render diff content with colors."""
|
|
2320
|
+
t = Text()
|
|
2321
|
+
|
|
2322
|
+
for line in diff.split("\n"):
|
|
2323
|
+
if line.startswith("+++") or line.startswith("---"):
|
|
2324
|
+
t.append(line + "\n", style="bold #71717a")
|
|
2325
|
+
elif line.startswith("@@"):
|
|
2326
|
+
t.append(line + "\n", style="bold #06b6d4")
|
|
2327
|
+
elif line.startswith("+"):
|
|
2328
|
+
t.append(line + "\n", style="#22c55e on #22c55e15")
|
|
2329
|
+
elif line.startswith("-"):
|
|
2330
|
+
t.append(line + "\n", style="#ef4444 on #ef444415")
|
|
2331
|
+
else:
|
|
2332
|
+
t.append(line + "\n", style="#a1a1aa")
|
|
2333
|
+
|
|
2334
|
+
return t
|
|
2335
|
+
|
|
2336
|
+
def on_click(self, event) -> None:
|
|
2337
|
+
"""Handle clicks on change items."""
|
|
2338
|
+
# Calculate which item was clicked based on y position
|
|
2339
|
+
try:
|
|
2340
|
+
changes_list = self.query_one("#changes-list", ScrollableContainer)
|
|
2341
|
+
if changes_list in event.widget.ancestors or event.widget == changes_list:
|
|
2342
|
+
# Rough calculation of clicked index
|
|
2343
|
+
y = event.y - 3 # Offset for header
|
|
2344
|
+
if 0 <= y < len(self.changes) + 4: # Account for section headers
|
|
2345
|
+
self.selected_index = max(0, min(y - 1, len(self.changes) - 1))
|
|
2346
|
+
except Exception:
|
|
2347
|
+
pass
|
|
2348
|
+
|
|
2349
|
+
def action_refresh(self) -> None:
|
|
2350
|
+
"""Refresh changes."""
|
|
2351
|
+
self._loading = True
|
|
2352
|
+
try:
|
|
2353
|
+
self.query_one("#loading", Static).display = True
|
|
2354
|
+
except Exception:
|
|
2355
|
+
pass
|
|
2356
|
+
self.refresh_changes()
|
|
2357
|
+
|
|
2358
|
+
def select_next(self) -> None:
|
|
2359
|
+
"""Select next change."""
|
|
2360
|
+
if self.changes:
|
|
2361
|
+
self.selected_index = (self.selected_index + 1) % len(self.changes)
|
|
2362
|
+
|
|
2363
|
+
def select_prev(self) -> None:
|
|
2364
|
+
"""Select previous change."""
|
|
2365
|
+
if self.changes:
|
|
2366
|
+
self.selected_index = (self.selected_index - 1) % len(self.changes)
|
|
2367
|
+
|
|
2368
|
+
def highlight_files(self, files: List[str]) -> None:
|
|
2369
|
+
"""Highlight specified files in the changes list.
|
|
2370
|
+
|
|
2371
|
+
This method should be called after refresh_changes() completes.
|
|
2372
|
+
It will select the first matching file and load its diff.
|
|
2373
|
+
"""
|
|
2374
|
+
# Clear previous selection
|
|
2375
|
+
self.selected_index = -1
|
|
2376
|
+
|
|
2377
|
+
# Find and select the first file to highlight
|
|
2378
|
+
for i, change in enumerate(self.changes):
|
|
2379
|
+
if change.path in files:
|
|
2380
|
+
self.selected_index = i
|
|
2381
|
+
# This will trigger watch_selected_index which loads the diff
|
|
2382
|
+
break
|
|
2383
|
+
|
|
2384
|
+
# If no match found but we have changes, select the first one
|
|
2385
|
+
if self.selected_index == -1 and self.changes:
|
|
2386
|
+
self.selected_index = 0
|
|
2387
|
+
|
|
2388
|
+
|
|
2389
|
+
class CollapsibleSidebar(Container):
|
|
2390
|
+
"""
|
|
2391
|
+
Clean tabbed sidebar with Files, Code, and Changes views.
|
|
2392
|
+
|
|
2393
|
+
Features:
|
|
2394
|
+
- Git status indicator (always visible)
|
|
2395
|
+
- File search (Ctrl+F)
|
|
2396
|
+
- Tab switching: Files | Code | Changes
|
|
2397
|
+
- Click file to view code in sidebar
|
|
2398
|
+
- Git diff view for changed files
|
|
2399
|
+
- Dark black background
|
|
2400
|
+
"""
|
|
2401
|
+
|
|
2402
|
+
DEFAULT_CSS = """
|
|
2403
|
+
CollapsibleSidebar {
|
|
2404
|
+
width: 100%;
|
|
2405
|
+
height: 100%;
|
|
2406
|
+
layout: vertical;
|
|
2407
|
+
background: #000000;
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
CollapsibleSidebar #sidebar-header {
|
|
2411
|
+
height: auto;
|
|
2412
|
+
background: #000000;
|
|
2413
|
+
padding: 0;
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
CollapsibleSidebar .sidebar-title {
|
|
2417
|
+
height: 2;
|
|
2418
|
+
padding: 0 1;
|
|
2419
|
+
text-align: left;
|
|
2420
|
+
background: #000000;
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
CollapsibleSidebar #git-status {
|
|
2424
|
+
height: 2;
|
|
2425
|
+
background: #000000;
|
|
2426
|
+
border-bottom: solid #1a1a1a;
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
CollapsibleSidebar #file-search {
|
|
2430
|
+
background: #000000;
|
|
2431
|
+
border-bottom: solid #1a1a1a;
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
CollapsibleSidebar #file-search.-hidden {
|
|
2435
|
+
display: none;
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
CollapsibleSidebar #sidebar-content {
|
|
2439
|
+
height: 1fr;
|
|
2440
|
+
background: #000000;
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
CollapsibleSidebar #files-view {
|
|
2444
|
+
height: 100%;
|
|
2445
|
+
width: 100%;
|
|
2446
|
+
background: #000000;
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
CollapsibleSidebar #files-view.-hidden {
|
|
2450
|
+
display: none;
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
CollapsibleSidebar #code-view {
|
|
2454
|
+
height: 100%;
|
|
2455
|
+
width: 100%;
|
|
2456
|
+
background: #000000;
|
|
2457
|
+
display: none;
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
CollapsibleSidebar #code-view.visible {
|
|
2461
|
+
display: block;
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
CollapsibleSidebar #changes-view {
|
|
2465
|
+
height: 100%;
|
|
2466
|
+
width: 100%;
|
|
2467
|
+
background: #000000;
|
|
2468
|
+
display: none;
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
CollapsibleSidebar #changes-view.visible {
|
|
2472
|
+
display: block;
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
CollapsibleSidebar #search-view {
|
|
2476
|
+
height: 100%;
|
|
2477
|
+
width: 100%;
|
|
2478
|
+
background: #000000;
|
|
2479
|
+
display: none;
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
CollapsibleSidebar #search-view.visible {
|
|
2483
|
+
display: block;
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
CollapsibleSidebar #file-tree {
|
|
2487
|
+
height: 100%;
|
|
2488
|
+
background: #000000;
|
|
2489
|
+
scrollbar-size: 1 1;
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
CollapsibleSidebar #file-preview {
|
|
2493
|
+
height: 100%;
|
|
2494
|
+
background: #000000;
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
CollapsibleSidebar ColorfulDirectoryTree {
|
|
2498
|
+
background: #000000;
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
CollapsibleSidebar ColorfulDirectoryTree > .tree--guides {
|
|
2502
|
+
color: #1a1a1a;
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
CollapsibleSidebar ColorfulDirectoryTree > .tree--cursor {
|
|
2506
|
+
background: #3f3f46;
|
|
2507
|
+
color: #ec4899;
|
|
2508
|
+
text-style: bold;
|
|
2509
|
+
border-left: tall #a855f7;
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
CollapsibleSidebar ColorfulDirectoryTree:focus > .tree--cursor {
|
|
2513
|
+
background: #52525b;
|
|
2514
|
+
color: #ec4899;
|
|
2515
|
+
text-style: bold;
|
|
2516
|
+
border-left: tall #a855f7;
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
CollapsibleSidebar FilePreview {
|
|
2520
|
+
background: #000000;
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
CollapsibleSidebar #preview-header {
|
|
2524
|
+
background: #000000;
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
CollapsibleSidebar #preview-content {
|
|
2528
|
+
background: #000000;
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
CollapsibleSidebar #preview-hints {
|
|
2532
|
+
background: #000000;
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
CollapsibleSidebar GitChangesPanel {
|
|
2536
|
+
background: #000000;
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
/* New panel views - hidden by default */
|
|
2540
|
+
CollapsibleSidebar #agent-view {
|
|
2541
|
+
height: 100%;
|
|
2542
|
+
width: 100%;
|
|
2543
|
+
background: #000000;
|
|
2544
|
+
display: none;
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
CollapsibleSidebar #agent-view.visible {
|
|
2548
|
+
display: block;
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
CollapsibleSidebar #context-view {
|
|
2552
|
+
height: 100%;
|
|
2553
|
+
width: 100%;
|
|
2554
|
+
background: #000000;
|
|
2555
|
+
display: none;
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
CollapsibleSidebar #context-view.visible {
|
|
2559
|
+
display: block;
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2562
|
+
|
|
2563
|
+
CollapsibleSidebar #diff-view {
|
|
2564
|
+
height: 100%;
|
|
2565
|
+
width: 100%;
|
|
2566
|
+
background: #000000;
|
|
2567
|
+
display: none;
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
CollapsibleSidebar #diff-view.visible {
|
|
2571
|
+
display: block;
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
CollapsibleSidebar #history-view {
|
|
2575
|
+
height: 100%;
|
|
2576
|
+
width: 100%;
|
|
2577
|
+
background: #000000;
|
|
2578
|
+
display: none;
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
CollapsibleSidebar #history-view.visible {
|
|
2582
|
+
display: block;
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
/* QE Dashboard View */
|
|
2586
|
+
CollapsibleSidebar #qe-view {
|
|
2587
|
+
height: 100%;
|
|
2588
|
+
width: 100%;
|
|
2589
|
+
background: #000000;
|
|
2590
|
+
display: none;
|
|
2591
|
+
layout: vertical;
|
|
2592
|
+
padding: 1;
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
CollapsibleSidebar #qe-view.visible {
|
|
2596
|
+
display: block;
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
CollapsibleSidebar #qe-dashboard {
|
|
2600
|
+
height: auto;
|
|
2601
|
+
width: 100%;
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
CollapsibleSidebar #qe-dashboard-fallback {
|
|
2605
|
+
height: 100%;
|
|
2606
|
+
width: 100%;
|
|
2607
|
+
content-align: center middle;
|
|
2608
|
+
text-align: center;
|
|
2609
|
+
color: #71717a;
|
|
2610
|
+
}
|
|
2611
|
+
"""
|
|
2612
|
+
|
|
2613
|
+
BINDINGS = [
|
|
2614
|
+
Binding("ctrl+f", "toggle_search", "Search", show=True),
|
|
2615
|
+
Binding("escape", "dismiss", "Close", show=False),
|
|
2616
|
+
Binding("f", "show_files", "Files", show=False),
|
|
2617
|
+
Binding("c", "show_code", "Code", show=False),
|
|
2618
|
+
Binding("g", "show_changes", "Changes", show=False),
|
|
2619
|
+
Binding("s", "show_search", "Search", show=False),
|
|
2620
|
+
Binding("r", "refresh_changes", "Refresh", show=False),
|
|
2621
|
+
Binding("a", "show_agent", "Agent", show=False),
|
|
2622
|
+
Binding("x", "show_context", "Context", show=False),
|
|
2623
|
+
Binding("d", "show_diff", "Diff", show=False),
|
|
2624
|
+
Binding("h", "show_history", "History", show=False),
|
|
2625
|
+
]
|
|
2626
|
+
|
|
2627
|
+
current_view: reactive[str] = reactive("files")
|
|
2628
|
+
|
|
2629
|
+
# All available views
|
|
2630
|
+
VIEWS = ["files", "code", "changes", "search", "agent", "context", "diff", "history"]
|
|
2631
|
+
|
|
2632
|
+
class FileOpened(Message):
|
|
2633
|
+
"""Message sent when a file should be opened/viewed."""
|
|
2634
|
+
|
|
2635
|
+
def __init__(self, path: Path) -> None:
|
|
2636
|
+
self.path = path
|
|
2637
|
+
super().__init__()
|
|
2638
|
+
|
|
2639
|
+
class Dismiss(Message):
|
|
2640
|
+
"""Message to dismiss/close the sidebar."""
|
|
2641
|
+
|
|
2642
|
+
pass
|
|
2643
|
+
|
|
2644
|
+
def __init__(
|
|
2645
|
+
self,
|
|
2646
|
+
path: Path | str = ".",
|
|
2647
|
+
name: str | None = None,
|
|
2648
|
+
id: str | None = None,
|
|
2649
|
+
classes: str | None = None,
|
|
2650
|
+
):
|
|
2651
|
+
super().__init__(name=name, id=id, classes=classes)
|
|
2652
|
+
self.root_path = Path(path).resolve()
|
|
2653
|
+
self._current_file: Optional[Path] = None
|
|
2654
|
+
|
|
2655
|
+
def compose(self) -> ComposeResult:
|
|
2656
|
+
"""Compose the sidebar layout with all panels."""
|
|
2657
|
+
# Import new panels (lazy import to avoid circular deps)
|
|
2658
|
+
from superqode.widgets.sidebar_panels import (
|
|
2659
|
+
AgentPanel,
|
|
2660
|
+
ContextPanel,
|
|
2661
|
+
TerminalPanel,
|
|
2662
|
+
DiffPanel,
|
|
2663
|
+
HistoryPanel,
|
|
2664
|
+
)
|
|
2665
|
+
|
|
2666
|
+
# Header with title
|
|
2667
|
+
with Container(id="sidebar-header"):
|
|
2668
|
+
yield Static(self._render_title(), classes="sidebar-title")
|
|
2669
|
+
yield GitStatusWidget(self.root_path, id="git-status")
|
|
2670
|
+
|
|
2671
|
+
# File search (hidden by default)
|
|
2672
|
+
yield FileSearch(self.root_path, id="file-search", classes="-hidden")
|
|
2673
|
+
|
|
2674
|
+
# Tab bar
|
|
2675
|
+
yield SidebarTabs(id="sidebar-tabs")
|
|
2676
|
+
|
|
2677
|
+
# Content area
|
|
2678
|
+
with Container(id="sidebar-content"):
|
|
2679
|
+
# Files view (default)
|
|
2680
|
+
with Container(id="files-view"):
|
|
2681
|
+
yield ColorfulDirectoryTree(self.root_path, id="file-tree")
|
|
2682
|
+
|
|
2683
|
+
# Code view (hidden by default)
|
|
2684
|
+
with Container(id="code-view"):
|
|
2685
|
+
yield FilePreview(id="file-preview")
|
|
2686
|
+
|
|
2687
|
+
# Changes view (hidden by default)
|
|
2688
|
+
with Container(id="changes-view"):
|
|
2689
|
+
yield GitChangesPanel(self.root_path, id="git-changes")
|
|
2690
|
+
|
|
2691
|
+
# Search view (hidden by default)
|
|
2692
|
+
with Container(id="search-view"):
|
|
2693
|
+
yield CodebaseSearch(self.root_path, id="codebase-search")
|
|
2694
|
+
|
|
2695
|
+
# NEW: Agent panel
|
|
2696
|
+
with Container(id="agent-view"):
|
|
2697
|
+
yield AgentPanel(id="agent-panel")
|
|
2698
|
+
|
|
2699
|
+
# NEW: Context panel
|
|
2700
|
+
with Container(id="context-view"):
|
|
2701
|
+
yield ContextPanel(id="context-panel")
|
|
2702
|
+
|
|
2703
|
+
# NEW: Diff panel
|
|
2704
|
+
with Container(id="diff-view"):
|
|
2705
|
+
yield DiffPanel(id="diff-panel")
|
|
2706
|
+
|
|
2707
|
+
# NEW: History panel
|
|
2708
|
+
with Container(id="history-view"):
|
|
2709
|
+
yield HistoryPanel(id="history-panel")
|
|
2710
|
+
|
|
2711
|
+
# QE dashboard panel is not shown in OSS.
|
|
2712
|
+
|
|
2713
|
+
def _render_title(self) -> Text:
|
|
2714
|
+
"""Render the sidebar title."""
|
|
2715
|
+
t = Text()
|
|
2716
|
+
t.append("\n", style="")
|
|
2717
|
+
t.append("📁 ", style="bold #ec4899")
|
|
2718
|
+
t.append(self.root_path.name or "Project", style="bold #a855f7")
|
|
2719
|
+
t.append(" ", style="")
|
|
2720
|
+
t.append("Ctrl+B", style="#52525b")
|
|
2721
|
+
t.append(" close ", style="#3f3f46")
|
|
2722
|
+
t.append("Ctrl+F", style="#52525b")
|
|
2723
|
+
t.append(" search", style="#3f3f46")
|
|
2724
|
+
return t
|
|
2725
|
+
|
|
2726
|
+
def watch_current_view(self, view: str) -> None:
|
|
2727
|
+
"""Switch between all sidebar views."""
|
|
2728
|
+
try:
|
|
2729
|
+
tabs = self.query_one("#sidebar-tabs", SidebarTabs)
|
|
2730
|
+
|
|
2731
|
+
# Hide all views first
|
|
2732
|
+
all_views = [
|
|
2733
|
+
"files-view",
|
|
2734
|
+
"code-view",
|
|
2735
|
+
"changes-view",
|
|
2736
|
+
"search-view",
|
|
2737
|
+
"agent-view",
|
|
2738
|
+
"context-view",
|
|
2739
|
+
"diff-view",
|
|
2740
|
+
"history-view",
|
|
2741
|
+
]
|
|
2742
|
+
|
|
2743
|
+
for view_id in all_views:
|
|
2744
|
+
try:
|
|
2745
|
+
v = self.query_one(f"#{view_id}", Container)
|
|
2746
|
+
v.add_class("-hidden")
|
|
2747
|
+
v.remove_class("visible")
|
|
2748
|
+
except Exception:
|
|
2749
|
+
pass
|
|
2750
|
+
|
|
2751
|
+
# Show selected view
|
|
2752
|
+
view_id = f"{view}-view"
|
|
2753
|
+
try:
|
|
2754
|
+
selected = self.query_one(f"#{view_id}", Container)
|
|
2755
|
+
selected.remove_class("-hidden")
|
|
2756
|
+
selected.add_class("visible")
|
|
2757
|
+
tabs.active_tab = view
|
|
2758
|
+
except Exception:
|
|
2759
|
+
pass
|
|
2760
|
+
|
|
2761
|
+
# View-specific actions
|
|
2762
|
+
if view == "changes":
|
|
2763
|
+
# Refresh changes when switching to changes tab
|
|
2764
|
+
try:
|
|
2765
|
+
self.query_one("#git-changes", GitChangesPanel).refresh_changes()
|
|
2766
|
+
except Exception:
|
|
2767
|
+
pass
|
|
2768
|
+
elif view == "search":
|
|
2769
|
+
# Focus the search input
|
|
2770
|
+
try:
|
|
2771
|
+
self.query_one("#codebase-search", CodebaseSearch).query_one(
|
|
2772
|
+
"#code-search-input", Input
|
|
2773
|
+
).focus()
|
|
2774
|
+
except Exception:
|
|
2775
|
+
pass
|
|
2776
|
+
except Exception:
|
|
2777
|
+
pass
|
|
2778
|
+
|
|
2779
|
+
@on(SidebarTabs.TabChanged)
|
|
2780
|
+
def on_tab_changed(self, event: SidebarTabs.TabChanged) -> None:
|
|
2781
|
+
"""Handle tab changes."""
|
|
2782
|
+
event.stop()
|
|
2783
|
+
self.current_view = event.tab
|
|
2784
|
+
|
|
2785
|
+
def action_toggle_search(self) -> None:
|
|
2786
|
+
"""Toggle file search visibility."""
|
|
2787
|
+
search = self.query_one("#file-search", FileSearch)
|
|
2788
|
+
if search.has_class("-hidden"):
|
|
2789
|
+
search.remove_class("-hidden")
|
|
2790
|
+
search.query_one("#search-input", Input).focus()
|
|
2791
|
+
else:
|
|
2792
|
+
search.add_class("-hidden")
|
|
2793
|
+
|
|
2794
|
+
def action_dismiss(self) -> None:
|
|
2795
|
+
"""Dismiss the sidebar."""
|
|
2796
|
+
self.post_message(self.Dismiss())
|
|
2797
|
+
|
|
2798
|
+
def action_show_files(self) -> None:
|
|
2799
|
+
"""Show files view."""
|
|
2800
|
+
self.current_view = "files"
|
|
2801
|
+
|
|
2802
|
+
def action_show_code(self) -> None:
|
|
2803
|
+
"""Show code view."""
|
|
2804
|
+
self.current_view = "code"
|
|
2805
|
+
|
|
2806
|
+
def action_show_changes(self) -> None:
|
|
2807
|
+
"""Show changes view."""
|
|
2808
|
+
self.current_view = "changes"
|
|
2809
|
+
|
|
2810
|
+
def action_show_search(self) -> None:
|
|
2811
|
+
"""Show search view."""
|
|
2812
|
+
self.current_view = "search"
|
|
2813
|
+
|
|
2814
|
+
def action_refresh_changes(self) -> None:
|
|
2815
|
+
"""Refresh git changes."""
|
|
2816
|
+
try:
|
|
2817
|
+
self.query_one("#git-changes", GitChangesPanel).refresh_changes()
|
|
2818
|
+
self.query_one("#git-status", GitStatusWidget).refresh_status()
|
|
2819
|
+
except Exception:
|
|
2820
|
+
pass
|
|
2821
|
+
|
|
2822
|
+
def action_show_agent(self) -> None:
|
|
2823
|
+
"""Show agent panel."""
|
|
2824
|
+
self.current_view = "agent"
|
|
2825
|
+
|
|
2826
|
+
def action_show_context(self) -> None:
|
|
2827
|
+
"""Show context panel."""
|
|
2828
|
+
self.current_view = "context"
|
|
2829
|
+
|
|
2830
|
+
def action_show_diff(self) -> None:
|
|
2831
|
+
"""Show diff panel."""
|
|
2832
|
+
self.current_view = "diff"
|
|
2833
|
+
|
|
2834
|
+
def action_show_history(self) -> None:
|
|
2835
|
+
"""Show history panel."""
|
|
2836
|
+
self.current_view = "history"
|
|
2837
|
+
|
|
2838
|
+
# Panel access methods
|
|
2839
|
+
def get_agent_panel(self):
|
|
2840
|
+
"""Get the agent panel widget."""
|
|
2841
|
+
try:
|
|
2842
|
+
from superqode.widgets.sidebar_panels import AgentPanel
|
|
2843
|
+
|
|
2844
|
+
return self.query_one("#agent-panel", AgentPanel)
|
|
2845
|
+
except Exception:
|
|
2846
|
+
return None
|
|
2847
|
+
|
|
2848
|
+
def get_context_panel(self):
|
|
2849
|
+
"""Get the context panel widget."""
|
|
2850
|
+
try:
|
|
2851
|
+
from superqode.widgets.sidebar_panels import ContextPanel
|
|
2852
|
+
|
|
2853
|
+
return self.query_one("#context-panel", ContextPanel)
|
|
2854
|
+
except Exception:
|
|
2855
|
+
return None
|
|
2856
|
+
|
|
2857
|
+
def get_terminal_panel(self):
|
|
2858
|
+
"""Get the terminal panel widget."""
|
|
2859
|
+
try:
|
|
2860
|
+
from superqode.widgets.sidebar_panels import TerminalPanel
|
|
2861
|
+
|
|
2862
|
+
return self.query_one("#terminal-panel", TerminalPanel)
|
|
2863
|
+
except Exception:
|
|
2864
|
+
return None
|
|
2865
|
+
|
|
2866
|
+
def get_diff_panel(self):
|
|
2867
|
+
"""Get the diff panel widget."""
|
|
2868
|
+
try:
|
|
2869
|
+
from superqode.widgets.sidebar_panels import DiffPanel
|
|
2870
|
+
|
|
2871
|
+
return self.query_one("#diff-panel", DiffPanel)
|
|
2872
|
+
except Exception:
|
|
2873
|
+
return None
|
|
2874
|
+
|
|
2875
|
+
def get_history_panel(self):
|
|
2876
|
+
"""Get the history panel widget."""
|
|
2877
|
+
try:
|
|
2878
|
+
from superqode.widgets.sidebar_panels import HistoryPanel
|
|
2879
|
+
|
|
2880
|
+
return self.query_one("#history-panel", HistoryPanel)
|
|
2881
|
+
except Exception:
|
|
2882
|
+
return None
|
|
2883
|
+
|
|
2884
|
+
@on(DirectoryTree.FileSelected)
|
|
2885
|
+
def on_file_selected(self, event: DirectoryTree.FileSelected) -> None:
|
|
2886
|
+
"""Handle file selection - show in code view."""
|
|
2887
|
+
event.stop()
|
|
2888
|
+
path = event.path
|
|
2889
|
+
self._current_file = path
|
|
2890
|
+
|
|
2891
|
+
# Set file in preview
|
|
2892
|
+
preview = self.query_one("#file-preview", FilePreview)
|
|
2893
|
+
preview.set_file(path)
|
|
2894
|
+
|
|
2895
|
+
# Switch to code view
|
|
2896
|
+
self.current_view = "code"
|
|
2897
|
+
|
|
2898
|
+
@on(ColorfulDirectoryTree.FileOpenRequested)
|
|
2899
|
+
def on_file_open_requested(self, event: ColorfulDirectoryTree.FileOpenRequested) -> None:
|
|
2900
|
+
"""Handle file open request - forward to parent."""
|
|
2901
|
+
event.stop()
|
|
2902
|
+
self.post_message(self.FileOpened(event.path))
|
|
2903
|
+
|
|
2904
|
+
@on(FileSearch.FileSelected)
|
|
2905
|
+
def on_search_file_selected(self, event: FileSearch.FileSelected) -> None:
|
|
2906
|
+
"""Handle file selection from search."""
|
|
2907
|
+
event.stop()
|
|
2908
|
+
path = event.path
|
|
2909
|
+
self._current_file = path
|
|
2910
|
+
|
|
2911
|
+
# Set file in preview and switch to code view
|
|
2912
|
+
preview = self.query_one("#file-preview", FilePreview)
|
|
2913
|
+
preview.set_file(path)
|
|
2914
|
+
self.current_view = "code"
|
|
2915
|
+
|
|
2916
|
+
@on(FileSearch.SearchClosed)
|
|
2917
|
+
def on_search_closed(self, event: FileSearch.SearchClosed) -> None:
|
|
2918
|
+
"""Handle search close - hide search widget."""
|
|
2919
|
+
event.stop()
|
|
2920
|
+
self.query_one("#file-search", FileSearch).add_class("-hidden")
|
|
2921
|
+
if self.current_view == "files":
|
|
2922
|
+
self.query_one("#file-tree", ColorfulDirectoryTree).focus()
|
|
2923
|
+
|
|
2924
|
+
@on(CodebaseSearch.ResultSelected)
|
|
2925
|
+
def on_codebase_search_result_selected(self, event: CodebaseSearch.ResultSelected) -> None:
|
|
2926
|
+
"""Handle codebase search result selection - open file at line."""
|
|
2927
|
+
event.stop()
|
|
2928
|
+
path = event.path
|
|
2929
|
+
self._current_file = path
|
|
2930
|
+
|
|
2931
|
+
# Set file in preview and switch to code view
|
|
2932
|
+
preview = self.query_one("#file-preview", FilePreview)
|
|
2933
|
+
preview.set_file(path)
|
|
2934
|
+
self.current_view = "code"
|
|
2935
|
+
|
|
2936
|
+
@on(CodebaseSearch.SearchClosed)
|
|
2937
|
+
def on_codebase_search_closed(self, event: CodebaseSearch.SearchClosed) -> None:
|
|
2938
|
+
"""Handle codebase search close."""
|
|
2939
|
+
event.stop()
|
|
2940
|
+
# Stay on search view but could switch to files
|
|
2941
|
+
pass
|
|
2942
|
+
|
|
2943
|
+
@on(FilePreview.EditRequested)
|
|
2944
|
+
def on_edit_requested(self, event: FilePreview.EditRequested) -> None:
|
|
2945
|
+
"""Handle edit request - open file in default editor."""
|
|
2946
|
+
event.stop()
|
|
2947
|
+
import os
|
|
2948
|
+
import platform
|
|
2949
|
+
|
|
2950
|
+
path = event.path
|
|
2951
|
+
|
|
2952
|
+
try:
|
|
2953
|
+
system = platform.system()
|
|
2954
|
+
if system == "Darwin":
|
|
2955
|
+
subprocess.Popen(["open", str(path)])
|
|
2956
|
+
elif system == "Windows":
|
|
2957
|
+
os.startfile(str(path))
|
|
2958
|
+
else:
|
|
2959
|
+
editor = os.environ.get("EDITOR", "xdg-open")
|
|
2960
|
+
subprocess.Popen([editor, str(path)])
|
|
2961
|
+
except Exception:
|
|
2962
|
+
editor = os.environ.get("EDITOR", "nano")
|
|
2963
|
+
try:
|
|
2964
|
+
subprocess.Popen([editor, str(path)])
|
|
2965
|
+
except Exception:
|
|
2966
|
+
pass
|
|
2967
|
+
|
|
2968
|
+
@on(FilePreview.PreviewClosed)
|
|
2969
|
+
def on_preview_closed(self, event: FilePreview.PreviewClosed) -> None:
|
|
2970
|
+
"""Handle preview close - switch back to files."""
|
|
2971
|
+
event.stop()
|
|
2972
|
+
self.current_view = "files"
|
|
2973
|
+
|
|
2974
|
+
def refresh_tree(self) -> None:
|
|
2975
|
+
"""Refresh the file tree."""
|
|
2976
|
+
tree = self.query_one("#file-tree", ColorfulDirectoryTree)
|
|
2977
|
+
tree.reload()
|
|
2978
|
+
|
|
2979
|
+
def refresh_git_status(self) -> None:
|
|
2980
|
+
"""Refresh git status."""
|
|
2981
|
+
git_widget = self.query_one("#git-status", GitStatusWidget)
|
|
2982
|
+
git_widget.refresh_status()
|
|
2983
|
+
|
|
2984
|
+
def set_tasks(self, tasks: List[TaskItem]) -> None:
|
|
2985
|
+
"""Update tasks (for compatibility)."""
|
|
2986
|
+
pass # Plan panel removed in this version
|
|
2987
|
+
|
|
2988
|
+
def focus_tree(self) -> None:
|
|
2989
|
+
"""Focus the file tree."""
|
|
2990
|
+
self.current_view = "files"
|
|
2991
|
+
self.query_one("#file-tree", ColorfulDirectoryTree).focus()
|