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.
Files changed (288) hide show
  1. superqode/__init__.py +33 -0
  2. superqode/acp/__init__.py +23 -0
  3. superqode/acp/client.py +913 -0
  4. superqode/acp/permission_screen.py +457 -0
  5. superqode/acp/types.py +480 -0
  6. superqode/acp_discovery.py +856 -0
  7. superqode/agent/__init__.py +22 -0
  8. superqode/agent/edit_strategies.py +334 -0
  9. superqode/agent/loop.py +892 -0
  10. superqode/agent/qe_report_templates.py +39 -0
  11. superqode/agent/system_prompts.py +353 -0
  12. superqode/agent_output.py +721 -0
  13. superqode/agent_stream.py +953 -0
  14. superqode/agents/__init__.py +59 -0
  15. superqode/agents/acp_registry.py +305 -0
  16. superqode/agents/client.py +249 -0
  17. superqode/agents/data/augmentcode.com.toml +51 -0
  18. superqode/agents/data/cagent.dev.toml +51 -0
  19. superqode/agents/data/claude.com.toml +60 -0
  20. superqode/agents/data/codeassistant.dev.toml +51 -0
  21. superqode/agents/data/codex.openai.com.toml +57 -0
  22. superqode/agents/data/fastagent.ai.toml +66 -0
  23. superqode/agents/data/geminicli.com.toml +77 -0
  24. superqode/agents/data/goose.block.xyz.toml +54 -0
  25. superqode/agents/data/junie.jetbrains.com.toml +56 -0
  26. superqode/agents/data/kimi.moonshot.cn.toml +57 -0
  27. superqode/agents/data/llmlingagent.dev.toml +51 -0
  28. superqode/agents/data/molt.bot.toml +49 -0
  29. superqode/agents/data/opencode.ai.toml +60 -0
  30. superqode/agents/data/stakpak.dev.toml +51 -0
  31. superqode/agents/data/vtcode.dev.toml +51 -0
  32. superqode/agents/discovery.py +266 -0
  33. superqode/agents/messaging.py +160 -0
  34. superqode/agents/persona.py +166 -0
  35. superqode/agents/registry.py +421 -0
  36. superqode/agents/schema.py +72 -0
  37. superqode/agents/unified.py +367 -0
  38. superqode/app/__init__.py +111 -0
  39. superqode/app/constants.py +314 -0
  40. superqode/app/css.py +366 -0
  41. superqode/app/models.py +118 -0
  42. superqode/app/suggester.py +125 -0
  43. superqode/app/widgets.py +1591 -0
  44. superqode/app_enhanced.py +399 -0
  45. superqode/app_main.py +17187 -0
  46. superqode/approval.py +312 -0
  47. superqode/atomic.py +296 -0
  48. superqode/commands/__init__.py +1 -0
  49. superqode/commands/acp.py +965 -0
  50. superqode/commands/agents.py +180 -0
  51. superqode/commands/auth.py +278 -0
  52. superqode/commands/config.py +374 -0
  53. superqode/commands/init.py +826 -0
  54. superqode/commands/providers.py +819 -0
  55. superqode/commands/qe.py +1145 -0
  56. superqode/commands/roles.py +380 -0
  57. superqode/commands/serve.py +172 -0
  58. superqode/commands/suggestions.py +127 -0
  59. superqode/commands/superqe.py +460 -0
  60. superqode/config/__init__.py +51 -0
  61. superqode/config/loader.py +812 -0
  62. superqode/config/schema.py +498 -0
  63. superqode/core/__init__.py +111 -0
  64. superqode/core/roles.py +281 -0
  65. superqode/danger.py +386 -0
  66. superqode/data/superqode-template.yaml +1522 -0
  67. superqode/design_system.py +1080 -0
  68. superqode/dialogs/__init__.py +6 -0
  69. superqode/dialogs/base.py +39 -0
  70. superqode/dialogs/model.py +130 -0
  71. superqode/dialogs/provider.py +870 -0
  72. superqode/diff_view.py +919 -0
  73. superqode/enterprise.py +21 -0
  74. superqode/evaluation/__init__.py +25 -0
  75. superqode/evaluation/adapters.py +93 -0
  76. superqode/evaluation/behaviors.py +89 -0
  77. superqode/evaluation/engine.py +209 -0
  78. superqode/evaluation/scenarios.py +96 -0
  79. superqode/execution/__init__.py +36 -0
  80. superqode/execution/linter.py +538 -0
  81. superqode/execution/modes.py +347 -0
  82. superqode/execution/resolver.py +283 -0
  83. superqode/execution/runner.py +642 -0
  84. superqode/file_explorer.py +811 -0
  85. superqode/file_viewer.py +471 -0
  86. superqode/flash.py +183 -0
  87. superqode/guidance/__init__.py +58 -0
  88. superqode/guidance/config.py +203 -0
  89. superqode/guidance/prompts.py +71 -0
  90. superqode/harness/__init__.py +54 -0
  91. superqode/harness/accelerator.py +291 -0
  92. superqode/harness/config.py +319 -0
  93. superqode/harness/validator.py +147 -0
  94. superqode/history.py +279 -0
  95. superqode/integrations/superopt_runner.py +124 -0
  96. superqode/logging/__init__.py +49 -0
  97. superqode/logging/adapters.py +219 -0
  98. superqode/logging/formatter.py +923 -0
  99. superqode/logging/integration.py +341 -0
  100. superqode/logging/sinks.py +170 -0
  101. superqode/logging/unified_log.py +417 -0
  102. superqode/lsp/__init__.py +26 -0
  103. superqode/lsp/client.py +544 -0
  104. superqode/main.py +1069 -0
  105. superqode/mcp/__init__.py +89 -0
  106. superqode/mcp/auth_storage.py +380 -0
  107. superqode/mcp/client.py +1236 -0
  108. superqode/mcp/config.py +319 -0
  109. superqode/mcp/integration.py +337 -0
  110. superqode/mcp/oauth.py +436 -0
  111. superqode/mcp/oauth_callback.py +385 -0
  112. superqode/mcp/types.py +290 -0
  113. superqode/memory/__init__.py +31 -0
  114. superqode/memory/feedback.py +342 -0
  115. superqode/memory/store.py +522 -0
  116. superqode/notifications.py +369 -0
  117. superqode/optimization/__init__.py +5 -0
  118. superqode/optimization/config.py +33 -0
  119. superqode/permissions/__init__.py +25 -0
  120. superqode/permissions/rules.py +488 -0
  121. superqode/plan.py +323 -0
  122. superqode/providers/__init__.py +33 -0
  123. superqode/providers/gateway/__init__.py +165 -0
  124. superqode/providers/gateway/base.py +228 -0
  125. superqode/providers/gateway/litellm_gateway.py +1170 -0
  126. superqode/providers/gateway/openresponses_gateway.py +436 -0
  127. superqode/providers/health.py +297 -0
  128. superqode/providers/huggingface/__init__.py +74 -0
  129. superqode/providers/huggingface/downloader.py +472 -0
  130. superqode/providers/huggingface/endpoints.py +442 -0
  131. superqode/providers/huggingface/hub.py +531 -0
  132. superqode/providers/huggingface/inference.py +394 -0
  133. superqode/providers/huggingface/transformers_runner.py +516 -0
  134. superqode/providers/local/__init__.py +100 -0
  135. superqode/providers/local/base.py +438 -0
  136. superqode/providers/local/discovery.py +418 -0
  137. superqode/providers/local/lmstudio.py +256 -0
  138. superqode/providers/local/mlx.py +457 -0
  139. superqode/providers/local/ollama.py +486 -0
  140. superqode/providers/local/sglang.py +268 -0
  141. superqode/providers/local/tgi.py +260 -0
  142. superqode/providers/local/tool_support.py +477 -0
  143. superqode/providers/local/vllm.py +258 -0
  144. superqode/providers/manager.py +1338 -0
  145. superqode/providers/models.py +1016 -0
  146. superqode/providers/models_dev.py +578 -0
  147. superqode/providers/openresponses/__init__.py +87 -0
  148. superqode/providers/openresponses/converters/__init__.py +17 -0
  149. superqode/providers/openresponses/converters/messages.py +343 -0
  150. superqode/providers/openresponses/converters/tools.py +268 -0
  151. superqode/providers/openresponses/schema/__init__.py +56 -0
  152. superqode/providers/openresponses/schema/models.py +585 -0
  153. superqode/providers/openresponses/streaming/__init__.py +5 -0
  154. superqode/providers/openresponses/streaming/parser.py +338 -0
  155. superqode/providers/openresponses/tools/__init__.py +21 -0
  156. superqode/providers/openresponses/tools/apply_patch.py +352 -0
  157. superqode/providers/openresponses/tools/code_interpreter.py +290 -0
  158. superqode/providers/openresponses/tools/file_search.py +333 -0
  159. superqode/providers/openresponses/tools/mcp_adapter.py +252 -0
  160. superqode/providers/registry.py +716 -0
  161. superqode/providers/usage.py +332 -0
  162. superqode/pure_mode.py +384 -0
  163. superqode/qr/__init__.py +23 -0
  164. superqode/qr/dashboard.py +781 -0
  165. superqode/qr/generator.py +1018 -0
  166. superqode/qr/templates.py +135 -0
  167. superqode/safety/__init__.py +41 -0
  168. superqode/safety/sandbox.py +413 -0
  169. superqode/safety/warnings.py +256 -0
  170. superqode/server/__init__.py +33 -0
  171. superqode/server/lsp_server.py +775 -0
  172. superqode/server/web.py +250 -0
  173. superqode/session/__init__.py +25 -0
  174. superqode/session/persistence.py +580 -0
  175. superqode/session/sharing.py +477 -0
  176. superqode/session.py +475 -0
  177. superqode/sidebar.py +2991 -0
  178. superqode/stream_view.py +648 -0
  179. superqode/styles/__init__.py +3 -0
  180. superqode/superqe/__init__.py +184 -0
  181. superqode/superqe/acp_runner.py +1064 -0
  182. superqode/superqe/constitution/__init__.py +62 -0
  183. superqode/superqe/constitution/evaluator.py +308 -0
  184. superqode/superqe/constitution/loader.py +432 -0
  185. superqode/superqe/constitution/schema.py +250 -0
  186. superqode/superqe/events.py +591 -0
  187. superqode/superqe/frameworks/__init__.py +65 -0
  188. superqode/superqe/frameworks/base.py +234 -0
  189. superqode/superqe/frameworks/e2e.py +263 -0
  190. superqode/superqe/frameworks/executor.py +237 -0
  191. superqode/superqe/frameworks/javascript.py +409 -0
  192. superqode/superqe/frameworks/python.py +373 -0
  193. superqode/superqe/frameworks/registry.py +92 -0
  194. superqode/superqe/mcp_tools/__init__.py +47 -0
  195. superqode/superqe/mcp_tools/core_tools.py +418 -0
  196. superqode/superqe/mcp_tools/registry.py +230 -0
  197. superqode/superqe/mcp_tools/testing_tools.py +167 -0
  198. superqode/superqe/noise.py +89 -0
  199. superqode/superqe/orchestrator.py +778 -0
  200. superqode/superqe/roles.py +609 -0
  201. superqode/superqe/session.py +713 -0
  202. superqode/superqe/skills/__init__.py +57 -0
  203. superqode/superqe/skills/base.py +106 -0
  204. superqode/superqe/skills/core_skills.py +899 -0
  205. superqode/superqe/skills/registry.py +90 -0
  206. superqode/superqe/verifier.py +101 -0
  207. superqode/superqe_cli.py +76 -0
  208. superqode/tool_call.py +358 -0
  209. superqode/tools/__init__.py +93 -0
  210. superqode/tools/agent_tools.py +496 -0
  211. superqode/tools/base.py +324 -0
  212. superqode/tools/batch_tool.py +133 -0
  213. superqode/tools/diagnostics.py +311 -0
  214. superqode/tools/edit_tools.py +653 -0
  215. superqode/tools/enhanced_base.py +515 -0
  216. superqode/tools/file_tools.py +269 -0
  217. superqode/tools/file_tracking.py +45 -0
  218. superqode/tools/lsp_tools.py +610 -0
  219. superqode/tools/network_tools.py +350 -0
  220. superqode/tools/permissions.py +400 -0
  221. superqode/tools/question_tool.py +324 -0
  222. superqode/tools/search_tools.py +598 -0
  223. superqode/tools/shell_tools.py +259 -0
  224. superqode/tools/todo_tools.py +121 -0
  225. superqode/tools/validation.py +80 -0
  226. superqode/tools/web_tools.py +639 -0
  227. superqode/tui.py +1152 -0
  228. superqode/tui_integration.py +875 -0
  229. superqode/tui_widgets/__init__.py +27 -0
  230. superqode/tui_widgets/widgets/__init__.py +18 -0
  231. superqode/tui_widgets/widgets/progress.py +185 -0
  232. superqode/tui_widgets/widgets/tool_display.py +188 -0
  233. superqode/undo_manager.py +574 -0
  234. superqode/utils/__init__.py +5 -0
  235. superqode/utils/error_handling.py +323 -0
  236. superqode/utils/fuzzy.py +257 -0
  237. superqode/widgets/__init__.py +477 -0
  238. superqode/widgets/agent_collab.py +390 -0
  239. superqode/widgets/agent_store.py +936 -0
  240. superqode/widgets/agent_switcher.py +395 -0
  241. superqode/widgets/animation_manager.py +284 -0
  242. superqode/widgets/code_context.py +356 -0
  243. superqode/widgets/command_palette.py +412 -0
  244. superqode/widgets/connection_status.py +537 -0
  245. superqode/widgets/conversation_history.py +470 -0
  246. superqode/widgets/diff_indicator.py +155 -0
  247. superqode/widgets/enhanced_status_bar.py +385 -0
  248. superqode/widgets/enhanced_toast.py +476 -0
  249. superqode/widgets/file_browser.py +809 -0
  250. superqode/widgets/file_reference.py +585 -0
  251. superqode/widgets/issue_timeline.py +340 -0
  252. superqode/widgets/leader_key.py +264 -0
  253. superqode/widgets/mode_switcher.py +445 -0
  254. superqode/widgets/model_picker.py +234 -0
  255. superqode/widgets/permission_preview.py +1205 -0
  256. superqode/widgets/prompt.py +358 -0
  257. superqode/widgets/provider_connect.py +725 -0
  258. superqode/widgets/pty_shell.py +587 -0
  259. superqode/widgets/qe_dashboard.py +321 -0
  260. superqode/widgets/resizable_sidebar.py +377 -0
  261. superqode/widgets/response_changes.py +218 -0
  262. superqode/widgets/response_display.py +528 -0
  263. superqode/widgets/rich_tool_display.py +613 -0
  264. superqode/widgets/sidebar_panels.py +1180 -0
  265. superqode/widgets/slash_complete.py +356 -0
  266. superqode/widgets/split_view.py +612 -0
  267. superqode/widgets/status_bar.py +273 -0
  268. superqode/widgets/superqode_display.py +786 -0
  269. superqode/widgets/thinking_display.py +815 -0
  270. superqode/widgets/throbber.py +87 -0
  271. superqode/widgets/toast.py +206 -0
  272. superqode/widgets/unified_output.py +1073 -0
  273. superqode/workspace/__init__.py +75 -0
  274. superqode/workspace/artifacts.py +472 -0
  275. superqode/workspace/coordinator.py +353 -0
  276. superqode/workspace/diff_tracker.py +429 -0
  277. superqode/workspace/git_guard.py +373 -0
  278. superqode/workspace/git_snapshot.py +526 -0
  279. superqode/workspace/manager.py +750 -0
  280. superqode/workspace/snapshot.py +357 -0
  281. superqode/workspace/watcher.py +535 -0
  282. superqode/workspace/worktree.py +440 -0
  283. superqode-0.1.5.dist-info/METADATA +204 -0
  284. superqode-0.1.5.dist-info/RECORD +288 -0
  285. superqode-0.1.5.dist-info/WHEEL +5 -0
  286. superqode-0.1.5.dist-info/entry_points.txt +3 -0
  287. superqode-0.1.5.dist-info/licenses/LICENSE +648 -0
  288. superqode-0.1.5.dist-info/top_level.txt +1 -0
@@ -0,0 +1,809 @@
1
+ """File browser modal widget with fuzzy search and preview."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Callable
9
+
10
+ from textual import on, work
11
+ from textual.app import ComposeResult
12
+ from textual.binding import Binding
13
+ from textual.containers import Container, Horizontal, Vertical, VerticalScroll
14
+ from textual.message import Message
15
+ from textual.reactive import reactive
16
+ from textual.widget import Widget
17
+ from textual.widgets import Button, Input, Static
18
+
19
+ from superqode.utils.fuzzy import FuzzySearch, PathFuzzySearch
20
+
21
+
22
+ @dataclass
23
+ class FileItem:
24
+ """A file or directory item."""
25
+
26
+ path: Path
27
+ name: str
28
+ is_dir: bool
29
+ size: int = 0
30
+ extension: str = ""
31
+ relative_path: str = ""
32
+
33
+
34
+ class FileListItem(Static):
35
+ """A single file item in the browser list."""
36
+
37
+ DEFAULT_CSS = """
38
+ FileListItem {
39
+ height: 1;
40
+ padding: 0 1;
41
+ layout: horizontal;
42
+ }
43
+
44
+ FileListItem:hover {
45
+ background: $primary-darken-2;
46
+ }
47
+
48
+ FileListItem.selected {
49
+ background: $primary;
50
+ }
51
+
52
+ FileListItem.directory {
53
+ color: $secondary;
54
+ }
55
+
56
+ FileListItem .file-icon {
57
+ width: 3;
58
+ }
59
+
60
+ FileListItem .file-name {
61
+ width: 1fr;
62
+ }
63
+
64
+ FileListItem.selected .file-name {
65
+ color: $text;
66
+ text-style: bold;
67
+ }
68
+
69
+ FileListItem .file-size {
70
+ width: 10;
71
+ color: $text-muted;
72
+ text-align: right;
73
+ }
74
+
75
+ FileListItem .file-path {
76
+ width: 30;
77
+ color: $text-muted;
78
+ text-style: dim;
79
+ }
80
+ """
81
+
82
+ class Selected(Message):
83
+ """Message when item is selected."""
84
+
85
+ def __init__(self, item: FileItem) -> None:
86
+ self.item = item
87
+ super().__init__()
88
+
89
+ selected: reactive[bool] = reactive(False)
90
+
91
+ def __init__(self, item: FileItem, show_path: bool = True, **kwargs) -> None:
92
+ super().__init__(**kwargs)
93
+ self.item = item
94
+ self.show_path = show_path
95
+
96
+ def compose(self) -> ComposeResult:
97
+ # Icon
98
+ if self.item.is_dir:
99
+ icon = "📁"
100
+ else:
101
+ # File type icons
102
+ ext_icons = {
103
+ ".py": "🐍",
104
+ ".js": "📜",
105
+ ".ts": "📘",
106
+ ".json": "📋",
107
+ ".yaml": "⚙️",
108
+ ".yml": "⚙️",
109
+ ".md": "📝",
110
+ ".txt": "📄",
111
+ ".html": "🌐",
112
+ ".css": "🎨",
113
+ ".sh": "🔧",
114
+ ".toml": "⚙️",
115
+ }
116
+ icon = ext_icons.get(self.item.extension, "📄")
117
+
118
+ yield Static(icon, classes="file-icon")
119
+ yield Static(self.item.name, classes="file-name")
120
+
121
+ if self.show_path and self.item.relative_path:
122
+ # Show parent directory
123
+ parent = str(Path(self.item.relative_path).parent)
124
+ if parent != ".":
125
+ yield Static(parent, classes="file-path")
126
+
127
+ # Size for files
128
+ if not self.item.is_dir and self.item.size > 0:
129
+ size_str = self._format_size(self.item.size)
130
+ yield Static(size_str, classes="file-size")
131
+
132
+ def _format_size(self, size: int) -> str:
133
+ """Format file size for display."""
134
+ if size < 1024:
135
+ return f"{size} B"
136
+ elif size < 1024 * 1024:
137
+ return f"{size / 1024:.1f} KB"
138
+ else:
139
+ return f"{size / (1024 * 1024):.1f} MB"
140
+
141
+ def on_mount(self) -> None:
142
+ self.set_class(self.item.is_dir, "directory")
143
+
144
+ def watch_selected(self, selected: bool) -> None:
145
+ self.set_class(selected, "selected")
146
+
147
+ def on_click(self) -> None:
148
+ self.post_message(self.Selected(self.item))
149
+
150
+
151
+ class FilePreview(Static):
152
+ """File content preview panel."""
153
+
154
+ DEFAULT_CSS = """
155
+ FilePreview {
156
+ width: 100%;
157
+ height: 100%;
158
+ background: $surface-darken-1;
159
+ border: round $primary-darken-2;
160
+ padding: 1;
161
+ }
162
+
163
+ FilePreview #preview-header {
164
+ height: 1;
165
+ color: $primary;
166
+ text-style: bold;
167
+ border-bottom: solid $primary-darken-2;
168
+ margin-bottom: 1;
169
+ }
170
+
171
+ FilePreview #preview-content {
172
+ height: 1fr;
173
+ color: $text;
174
+ }
175
+
176
+ FilePreview .preview-line {
177
+ height: 1;
178
+ }
179
+
180
+ FilePreview .line-number {
181
+ width: 4;
182
+ color: $text-muted;
183
+ text-align: right;
184
+ padding-right: 1;
185
+ }
186
+
187
+ FilePreview .line-content {
188
+ width: 1fr;
189
+ }
190
+
191
+ FilePreview .preview-error {
192
+ color: $error;
193
+ text-style: italic;
194
+ }
195
+
196
+ FilePreview .preview-binary {
197
+ color: $warning;
198
+ text-style: italic;
199
+ }
200
+ """
201
+
202
+ def __init__(self, **kwargs) -> None:
203
+ super().__init__(**kwargs)
204
+ self.current_file: Path | None = None
205
+
206
+ def compose(self) -> ComposeResult:
207
+ yield Static("No file selected", id="preview-header")
208
+ yield VerticalScroll(id="preview-content")
209
+
210
+ def show_file(self, path: Path, max_lines: int = 50) -> None:
211
+ """Show preview of a file."""
212
+ self.current_file = path
213
+
214
+ header = self.query_one("#preview-header", Static)
215
+ content = self.query_one("#preview-content", VerticalScroll)
216
+ content.remove_children()
217
+
218
+ if not path.exists():
219
+ header.update(f"File not found: {path.name}")
220
+ return
221
+
222
+ if path.is_dir():
223
+ header.update(f"📁 {path.name}/")
224
+ # Show directory contents
225
+ try:
226
+ items = sorted(path.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower()))
227
+ for item in items[:20]:
228
+ icon = "📁" if item.is_dir() else "📄"
229
+ content.mount(Static(f"{icon} {item.name}"))
230
+ if len(list(path.iterdir())) > 20:
231
+ content.mount(Static(f"... and more", classes="preview-line"))
232
+ except PermissionError:
233
+ content.mount(Static("Permission denied", classes="preview-error"))
234
+ return
235
+
236
+ header.update(f"📄 {path.name}")
237
+
238
+ # Check if file is binary
239
+ try:
240
+ with open(path, "rb") as f:
241
+ chunk = f.read(1024)
242
+ if b"\x00" in chunk:
243
+ content.mount(
244
+ Static("Binary file - preview not available", classes="preview-binary")
245
+ )
246
+ return
247
+ except Exception as e:
248
+ content.mount(Static(f"Error reading file: {e}", classes="preview-error"))
249
+ return
250
+
251
+ # Read and display text content
252
+ try:
253
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
254
+ lines = f.readlines()
255
+
256
+ for i, line in enumerate(lines[:max_lines], 1):
257
+ line = line.rstrip("\n\r")
258
+ if len(line) > 80:
259
+ line = line[:77] + "..."
260
+ # Escape Rich markup
261
+ line = line.replace("[", r"\[")
262
+ with Horizontal(classes="preview-line"):
263
+ content.mount(Static(f"{i:3}", classes="line-number"))
264
+ content.mount(Static(line, classes="line-content"))
265
+
266
+ if len(lines) > max_lines:
267
+ content.mount(
268
+ Static(f"... {len(lines) - max_lines} more lines", classes="preview-line")
269
+ )
270
+
271
+ except Exception as e:
272
+ content.mount(Static(f"Error: {e}", classes="preview-error"))
273
+
274
+ def clear(self) -> None:
275
+ """Clear the preview."""
276
+ self.current_file = None
277
+ header = self.query_one("#preview-header", Static)
278
+ header.update("No file selected")
279
+ content = self.query_one("#preview-content", VerticalScroll)
280
+ content.remove_children()
281
+
282
+ class FileBrowser(Widget):
283
+ """
284
+ Interactive file browser modal with fuzzy search.
285
+
286
+ Features:
287
+ - Fuzzy file search
288
+ - Directory navigation
289
+ - File preview
290
+ - Keyboard navigation
291
+ - Bookmarks and recent files
292
+ """
293
+
294
+ is_visible: reactive[bool] = reactive(False)
295
+
296
+ DEFAULT_CSS = """
297
+ FileBrowser {
298
+ layer: overlay;
299
+ align: center middle;
300
+ width: 90%;
301
+ height: 85%;
302
+ max-width: 120;
303
+ max-height: 40;
304
+ background: $surface;
305
+ border: tall $primary;
306
+ display: none;
307
+ }
308
+
309
+ FileBrowser.visible {
310
+ display: block;
311
+ }
312
+
313
+ FileBrowser #browser-header {
314
+ dock: top;
315
+ height: 3;
316
+ background: $primary-darken-2;
317
+ padding: 1;
318
+ }
319
+
320
+ FileBrowser #browser-title {
321
+ text-style: bold;
322
+ color: $secondary;
323
+ }
324
+
325
+ FileBrowser #browser-path {
326
+ color: $text-muted;
327
+ text-style: dim;
328
+ }
329
+
330
+ FileBrowser #search-container {
331
+ dock: top;
332
+ height: 3;
333
+ padding: 1;
334
+ background: $surface-darken-1;
335
+ layout: horizontal;
336
+ }
337
+
338
+ FileBrowser #search-input {
339
+ width: 1fr;
340
+ }
341
+
342
+ FileBrowser #browser-main {
343
+ height: 1fr;
344
+ layout: horizontal;
345
+ }
346
+
347
+ FileBrowser #file-list-container {
348
+ width: 1fr;
349
+ height: 100%;
350
+ border-right: solid $primary-darken-2;
351
+ }
352
+
353
+ FileBrowser #file-list {
354
+ height: 100%;
355
+ }
356
+
357
+ FileBrowser #preview-container {
358
+ width: 45%;
359
+ height: 100%;
360
+ padding: 1;
361
+ }
362
+
363
+ FileBrowser #browser-footer {
364
+ dock: bottom;
365
+ height: 1;
366
+ background: $surface-darken-1;
367
+ color: $text-muted;
368
+ padding: 0 1;
369
+ }
370
+
371
+ FileBrowser .empty-message {
372
+ padding: 2;
373
+ color: $text-muted;
374
+ text-style: italic;
375
+ text-align: center;
376
+ }
377
+
378
+ FileBrowser #quick-actions {
379
+ dock: top;
380
+ height: 2;
381
+ padding: 0 1;
382
+ layout: horizontal;
383
+ background: $surface-darken-1;
384
+ border-bottom: solid $primary-darken-2;
385
+ }
386
+
387
+ FileBrowser #quick-actions Button {
388
+ margin-right: 1;
389
+ min-width: 8;
390
+ }
391
+ """
392
+
393
+ BINDINGS = [
394
+ Binding("escape", "close", "Close"),
395
+ Binding("enter", "select", "Select"),
396
+ Binding("up", "move_up", "Up"),
397
+ Binding("down", "move_down", "Down"),
398
+ Binding("ctrl+u", "go_up", "Parent Dir"),
399
+ Binding("ctrl+h", "go_home", "Home"),
400
+ Binding("ctrl+b", "toggle_bookmarks", "Bookmarks"),
401
+ Binding("ctrl+r", "toggle_recent", "Recent"),
402
+ ]
403
+
404
+ class FileSelected(Message):
405
+ """Message when a file is selected."""
406
+
407
+ def __init__(self, path: Path) -> None:
408
+ self.path = path
409
+ super().__init__()
410
+
411
+ class Closed(Message):
412
+ """Message when browser is closed."""
413
+
414
+ pass
415
+
416
+ # State
417
+ visible: reactive[bool] = reactive(False)
418
+ selected_index: reactive[int] = reactive(0)
419
+
420
+ def __init__(
421
+ self,
422
+ root_path: Path | None = None,
423
+ on_select: Callable[[Path], None] | None = None,
424
+ **kwargs,
425
+ ) -> None:
426
+ super().__init__(**kwargs)
427
+ self.root_path = root_path or Path.cwd()
428
+ self.current_path = self.root_path
429
+ self._on_select = on_select
430
+ self._items: list[FileItem] = []
431
+ self._filtered_items: list[FileItem] = []
432
+ self._search_query = ""
433
+ self._show_bookmarks = False
434
+ self._show_recent = False
435
+ self.fuzzy = PathFuzzySearch()
436
+ self._render_counter = 0 # Unique ID counter to prevent duplicates
437
+
438
+ def compose(self) -> ComposeResult:
439
+ # Header
440
+ with Vertical(id="browser-header"):
441
+ yield Static("📁 File Browser", id="browser-title")
442
+ yield Static(str(self.current_path), id="browser-path")
443
+
444
+ # Quick actions
445
+ with Horizontal(id="quick-actions"):
446
+ yield Button("⬆ Parent", id="btn-parent")
447
+ yield Button("🏠 Home", id="btn-home")
448
+ yield Button("🔖 Bookmarks", id="btn-bookmarks")
449
+ yield Button("📋 Recent", id="btn-recent")
450
+
451
+ # Search
452
+ with Horizontal(id="search-container"):
453
+ yield Input(placeholder="Search files... (fuzzy matching)", id="search-input")
454
+
455
+ # Main content
456
+ with Horizontal(id="browser-main"):
457
+ with Container(id="file-list-container"):
458
+ yield VerticalScroll(id="file-list")
459
+ with Container(id="preview-container"):
460
+ yield FilePreview(id="file-preview")
461
+
462
+ # Footer
463
+ yield Static(
464
+ "↑↓ Navigate │ Enter Select │ Ctrl+U Parent │ Esc Close",
465
+ id="browser-footer",
466
+ )
467
+
468
+ def show(self, path: Path | None = None) -> None:
469
+ """Show the file browser."""
470
+ if path:
471
+ self.current_path = path
472
+ self.is_visible = True
473
+ self.add_class("visible")
474
+ self._load_directory()
475
+
476
+ # Focus search
477
+ self.query_one("#search-input", Input).focus()
478
+
479
+ def hide(self) -> None:
480
+ """Hide file browser."""
481
+ self.is_visible = False
482
+ self.remove_class("visible")
483
+ self.post_message(self.Closed())
484
+
485
+ def _load_directory(self) -> None:
486
+ """Load the current directory contents."""
487
+ self._items = []
488
+ self._show_bookmarks = False
489
+ self._show_recent = False
490
+
491
+ # Update path display
492
+ path_display = self.query_one("#browser-path", Static)
493
+ path_display.update(str(self.current_path))
494
+
495
+ try:
496
+ # Get directory contents
497
+ entries = sorted(
498
+ self.current_path.iterdir(),
499
+ key=lambda x: (not x.is_dir(), x.name.lower()),
500
+ )
501
+
502
+ # Filter out hidden and ignored files
503
+ from superqode.file_explorer import PathFilter
504
+
505
+ path_filter = PathFilter.from_git_root(self.root_path)
506
+
507
+ for entry in entries:
508
+ # Skip hidden files (starting with .)
509
+ if entry.name.startswith("."):
510
+ continue
511
+
512
+ # Skip ignored files
513
+ try:
514
+ rel_path = entry.relative_to(self.root_path)
515
+ if path_filter.match(rel_path):
516
+ continue
517
+ except ValueError:
518
+ pass
519
+
520
+ try:
521
+ size = entry.stat().st_size if entry.is_file() else 0
522
+ except OSError:
523
+ size = 0
524
+
525
+ self._items.append(
526
+ FileItem(
527
+ path=entry,
528
+ name=entry.name,
529
+ is_dir=entry.is_dir(),
530
+ size=size,
531
+ extension=entry.suffix.lower() if entry.is_file() else "",
532
+ relative_path=str(entry.relative_to(self.root_path)),
533
+ )
534
+ )
535
+
536
+ except PermissionError:
537
+ pass
538
+
539
+ self._apply_filter()
540
+
541
+ def _load_bookmarks(self) -> None:
542
+ """Load bookmarked files."""
543
+ from superqode.file_explorer import Bookmarks
544
+
545
+ self._items = []
546
+ self._show_bookmarks = True
547
+ self._show_recent = False
548
+
549
+ path_display = self.query_one("#browser-path", Static)
550
+ path_display.update("🔖 Bookmarks")
551
+
552
+ bookmarks = Bookmarks()
553
+ for name, path in bookmarks.get_bookmarks().items():
554
+ if path.exists():
555
+ try:
556
+ size = path.stat().st_size if path.is_file() else 0
557
+ except OSError:
558
+ size = 0
559
+
560
+ self._items.append(
561
+ FileItem(
562
+ path=path,
563
+ name=f"{name} → {path.name}",
564
+ is_dir=path.is_dir(),
565
+ size=size,
566
+ extension=path.suffix.lower() if path.is_file() else "",
567
+ relative_path=str(path),
568
+ )
569
+ )
570
+
571
+ self._apply_filter()
572
+
573
+ def _load_recent(self) -> None:
574
+ """Load recent files."""
575
+ from superqode.file_explorer import RecentFiles
576
+
577
+ self._items = []
578
+ self._show_bookmarks = False
579
+ self._show_recent = True
580
+
581
+ path_display = self.query_one("#browser-path", Static)
582
+ path_display.update("📋 Recent Files")
583
+
584
+ recent = RecentFiles()
585
+ for path in recent.get_recent_files(limit=20):
586
+ if path.exists():
587
+ try:
588
+ size = path.stat().st_size if path.is_file() else 0
589
+ except OSError:
590
+ size = 0
591
+
592
+ self._items.append(
593
+ FileItem(
594
+ path=path,
595
+ name=path.name,
596
+ is_dir=path.is_dir(),
597
+ size=size,
598
+ extension=path.suffix.lower() if path.is_file() else "",
599
+ relative_path=str(path),
600
+ )
601
+ )
602
+
603
+ self._apply_filter()
604
+
605
+ def _apply_filter(self) -> None:
606
+ """Apply search filter to items."""
607
+ if self._search_query:
608
+ # Fuzzy search
609
+ items = [(item.name, item) for item in self._items]
610
+ results = self.fuzzy.search_with_data(self._search_query, items, max_results=50)
611
+ self._filtered_items = [item for _, item in results]
612
+ else:
613
+ self._filtered_items = self._items
614
+
615
+ self.selected_index = 0
616
+ self._render_items()
617
+
618
+ def _render_items(self) -> None:
619
+ """Render the file list."""
620
+ self._render_counter += 1
621
+ render_id = self._render_counter
622
+
623
+ file_list = self.query_one("#file-list", VerticalScroll)
624
+ file_list.remove_children()
625
+
626
+ if not self._filtered_items:
627
+ file_list.mount(
628
+ Static(
629
+ "No files found.\nTry a different search.",
630
+ classes="empty-message",
631
+ )
632
+ )
633
+ return
634
+
635
+ show_path = self._show_bookmarks or self._show_recent or bool(self._search_query)
636
+
637
+ for i, item in enumerate(self._filtered_items):
638
+ # Use render counter in ID to ensure uniqueness across renders
639
+ list_item = FileListItem(item, show_path=show_path, id=f"file-{render_id}-{i}")
640
+ list_item.selected = i == self.selected_index
641
+ file_list.mount(list_item)
642
+
643
+ # Update preview
644
+ self._update_preview()
645
+
646
+ def _update_selection(self) -> None:
647
+ """Update visual selection state."""
648
+ for i, item in enumerate(self.query("#file-list FileListItem")):
649
+ if isinstance(item, FileListItem):
650
+ item.selected = i == self.selected_index
651
+
652
+ self._update_preview()
653
+
654
+ def _update_preview(self) -> None:
655
+ """Update the file preview."""
656
+ preview = self.query_one("#file-preview", FilePreview)
657
+
658
+ if self._filtered_items and 0 <= self.selected_index < len(self._filtered_items):
659
+ item = self._filtered_items[self.selected_index]
660
+ preview.show_file(item.path)
661
+ else:
662
+ preview.clear()
663
+
664
+ # === Actions ===
665
+
666
+ def action_close(self) -> None:
667
+ """Close the browser."""
668
+ self.hide()
669
+
670
+ def action_select(self) -> None:
671
+ """Select the current item."""
672
+ if not self._filtered_items:
673
+ return
674
+
675
+ if 0 <= self.selected_index < len(self._filtered_items):
676
+ item = self._filtered_items[self.selected_index]
677
+
678
+ if item.is_dir:
679
+ # Navigate into directory
680
+ self.current_path = item.path
681
+ self._search_query = ""
682
+ self.query_one("#search-input", Input).value = ""
683
+ self._load_directory()
684
+ else:
685
+ # Select file
686
+ self.post_message(self.FileSelected(item.path))
687
+ if self._on_select:
688
+ self._on_select(item.path)
689
+ self.hide()
690
+
691
+ def action_move_up(self) -> None:
692
+ """Move selection up."""
693
+ if self._filtered_items and self.selected_index > 0:
694
+ self.selected_index -= 1
695
+ self._update_selection()
696
+
697
+ def action_move_down(self) -> None:
698
+ """Move selection down."""
699
+ if self._filtered_items and self.selected_index < len(self._filtered_items) - 1:
700
+ self.selected_index += 1
701
+ self._update_selection()
702
+
703
+ def action_go_up(self) -> None:
704
+ """Go to parent directory."""
705
+ if self.current_path != self.root_path:
706
+ self.current_path = self.current_path.parent
707
+ self._search_query = ""
708
+ self.query_one("#search-input", Input).value = ""
709
+ self._load_directory()
710
+
711
+ def action_go_home(self) -> None:
712
+ """Go to root directory."""
713
+ self.current_path = self.root_path
714
+ self._search_query = ""
715
+ self.query_one("#search-input", Input).value = ""
716
+ self._load_directory()
717
+
718
+ def action_toggle_bookmarks(self) -> None:
719
+ """Toggle bookmarks view."""
720
+ if self._show_bookmarks:
721
+ self._load_directory()
722
+ else:
723
+ self._load_bookmarks()
724
+
725
+ def action_toggle_recent(self) -> None:
726
+ """Toggle recent files view."""
727
+ if self._show_recent:
728
+ self._load_directory()
729
+ else:
730
+ self._load_recent()
731
+
732
+ # === Event handlers ===
733
+
734
+ @on(Input.Changed, "#search-input")
735
+ def on_search_changed(self, event: Input.Changed) -> None:
736
+ """Handle search input changes."""
737
+ self._search_query = event.value
738
+
739
+ if self._show_bookmarks or self._show_recent:
740
+ # Just filter current list
741
+ self._apply_filter()
742
+ elif event.value:
743
+ # Do project-wide fuzzy search
744
+ self._search_project(event.value)
745
+ else:
746
+ # Show current directory
747
+ self._load_directory()
748
+
749
+ @work(exclusive=True)
750
+ async def _search_project(self, query: str) -> None:
751
+ """Search files across the project."""
752
+ import asyncio
753
+
754
+ def do_search():
755
+ from superqode.file_explorer import fuzzy_find_files
756
+
757
+ results = fuzzy_find_files(query, max_results=50)
758
+ items = []
759
+ for path, rel_path, score in results:
760
+ try:
761
+ size = path.stat().st_size if path.is_file() else 0
762
+ except OSError:
763
+ size = 0
764
+
765
+ items.append(
766
+ FileItem(
767
+ path=path,
768
+ name=path.name,
769
+ is_dir=path.is_dir(),
770
+ size=size,
771
+ extension=path.suffix.lower() if path.is_file() else "",
772
+ relative_path=rel_path,
773
+ )
774
+ )
775
+ return items
776
+
777
+ self._items = await asyncio.to_thread(do_search)
778
+ self._filtered_items = self._items
779
+ self.selected_index = 0
780
+ self._render_items()
781
+
782
+ @on(Button.Pressed, "#btn-parent")
783
+ def on_parent_pressed(self, event: Button.Pressed) -> None:
784
+ self.action_go_up()
785
+
786
+ @on(Button.Pressed, "#btn-home")
787
+ def on_home_pressed(self, event: Button.Pressed) -> None:
788
+ self.action_go_home()
789
+
790
+ @on(Button.Pressed, "#btn-bookmarks")
791
+ def on_bookmarks_pressed(self, event: Button.Pressed) -> None:
792
+ self.action_toggle_bookmarks()
793
+
794
+ @on(Button.Pressed, "#btn-recent")
795
+ def on_recent_pressed(self, event: Button.Pressed) -> None:
796
+ self.action_toggle_recent()
797
+
798
+ @on(FileListItem.Selected)
799
+ def on_item_selected(self, event: FileListItem.Selected) -> None:
800
+ """Handle item click."""
801
+ # Find index
802
+ for i, item in enumerate(self._filtered_items):
803
+ if item.path == event.item.path:
804
+ self.selected_index = i
805
+ self._update_selection()
806
+ break
807
+
808
+ # Double-click behavior (select on click)
809
+ self.action_select()