cct-cli 0.7.9.0__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.
- calc_terminal/__init__.py +14 -0
- calc_terminal/__main__.py +14 -0
- calc_terminal/activity.py +1334 -0
- calc_terminal/agent.py +3387 -0
- calc_terminal/agent_runtime.py +519 -0
- calc_terminal/ai_context.py +447 -0
- calc_terminal/ai_modes.py +752 -0
- calc_terminal/ai_personalization.py +286 -0
- calc_terminal/ai_preview_feedback.py +213 -0
- calc_terminal/aicore.py +2572 -0
- calc_terminal/anim.py +367 -0
- calc_terminal/app.py +3685 -0
- calc_terminal/art.py +639 -0
- calc_terminal/atomsim.py +368 -0
- calc_terminal/attachments.py +743 -0
- calc_terminal/benchmark_system.py +414 -0
- calc_terminal/browser/__init__.py +36 -0
- calc_terminal/browser/browser_state.py +346 -0
- calc_terminal/browser/devserver.py +176 -0
- calc_terminal/browser/engine.py +494 -0
- calc_terminal/browser/navigation.py +84 -0
- calc_terminal/browser/preview.py +429 -0
- calc_terminal/browser/preview_entry.py +95 -0
- calc_terminal/browser/project_detector.py +144 -0
- calc_terminal/browser/server.py +449 -0
- calc_terminal/browser/state.py +75 -0
- calc_terminal/browser/watcher.py +99 -0
- calc_terminal/browser_gui/__init__.py +1 -0
- calc_terminal/browser_gui/__main__.py +3 -0
- calc_terminal/browser_gui/launcher.py +173 -0
- calc_terminal/browser_gui/playwright_browser.py +117 -0
- calc_terminal/browser_gui/qt_browser.py +1501 -0
- calc_terminal/browser_gui/webview_browser.py +57 -0
- calc_terminal/capabilities/__init__.py +35 -0
- calc_terminal/capabilities/adapters/__init__.py +33 -0
- calc_terminal/capabilities/adapters/bioinformatics.py +204 -0
- calc_terminal/capabilities/adapters/browser_adapter.py +205 -0
- calc_terminal/capabilities/adapters/filesystem.py +206 -0
- calc_terminal/capabilities/adapters/git_adapter.py +202 -0
- calc_terminal/capabilities/adapters/jupyter_adapter.py +138 -0
- calc_terminal/capabilities/adapters/ml_frameworks.py +158 -0
- calc_terminal/capabilities/adapters/platforms.py +200 -0
- calc_terminal/capabilities/adapters/python_exec.py +93 -0
- calc_terminal/capabilities/adapters/quantum_adapter.py +150 -0
- calc_terminal/capabilities/adapters/scientific_comp.py +123 -0
- calc_terminal/capabilities/adapters/structural_bio.py +161 -0
- calc_terminal/capabilities/adapters/terminal.py +99 -0
- calc_terminal/capabilities/bus.py +178 -0
- calc_terminal/capabilities/discovery.py +207 -0
- calc_terminal/capabilities/schema.py +221 -0
- calc_terminal/cat.ico +0 -0
- calc_terminal/cat_browser.py +2018 -0
- calc_terminal/chat_store.py +703 -0
- calc_terminal/cli.py +1178 -0
- calc_terminal/code_editor.py +640 -0
- calc_terminal/collaboration.py +723 -0
- calc_terminal/commands_data.py +139 -0
- calc_terminal/compatibility_engine.py +352 -0
- calc_terminal/compute/__init__.py +31 -0
- calc_terminal/compute/fabric.py +350 -0
- calc_terminal/config.py +227 -0
- calc_terminal/core/__init__.py +41 -0
- calc_terminal/core/checkpoint.py +156 -0
- calc_terminal/core/input/__init__.py +45 -0
- calc_terminal/core/mode_registry.py +300 -0
- calc_terminal/core/project_graph.py +172 -0
- calc_terminal/core/recovery.py +129 -0
- calc_terminal/core/security_layer.py +112 -0
- calc_terminal/core/task_graph.py +202 -0
- calc_terminal/core/unified_runtime.py +184 -0
- calc_terminal/core/verification.py +257 -0
- calc_terminal/customization.py +1566 -0
- calc_terminal/derivations.py +153 -0
- calc_terminal/device_control.py +263 -0
- calc_terminal/diagnostics/__init__.py +27 -0
- calc_terminal/diagnostics/doctor_engine.py +382 -0
- calc_terminal/diagnostics/self_test.py +247 -0
- calc_terminal/doctor.py +519 -0
- calc_terminal/easter_eggs.py +274 -0
- calc_terminal/editor/__init__.py +1 -0
- calc_terminal/editor/actions.py +263 -0
- calc_terminal/editor/commands.py +160 -0
- calc_terminal/editor/shortcuts.py +226 -0
- calc_terminal/engine.py +259 -0
- calc_terminal/errors.py +120 -0
- calc_terminal/event_stream.py +146 -0
- calc_terminal/eventbus.py +133 -0
- calc_terminal/extensions.py +733 -0
- calc_terminal/fallback_cli.py +1321 -0
- calc_terminal/first_run.py +265 -0
- calc_terminal/fomoji_auth.py +1043 -0
- calc_terminal/formulas.py +82 -0
- calc_terminal/fs_cache.py +121 -0
- calc_terminal/fs_watcher.py +277 -0
- calc_terminal/game.py +193 -0
- calc_terminal/gen1.py +5 -0
- calc_terminal/generators.py +245 -0
- calc_terminal/gestures/__init__.py +42 -0
- calc_terminal/gestures/bindings.py +175 -0
- calc_terminal/gestures/manager.py +477 -0
- calc_terminal/goodbye.py +363 -0
- calc_terminal/gpu3d.py +290 -0
- calc_terminal/graphs.py +358 -0
- calc_terminal/hardware_analyzer.py +440 -0
- calc_terminal/host/__init__.py +30 -0
- calc_terminal/host/browser_manager.py +187 -0
- calc_terminal/host/desktop.py +1386 -0
- calc_terminal/host/launcher.py +395 -0
- calc_terminal/host/terminal.py +279 -0
- calc_terminal/identity.py +216 -0
- calc_terminal/input/__init__.py +54 -0
- calc_terminal/input/capabilities.py +258 -0
- calc_terminal/input/focus.py +87 -0
- calc_terminal/input/gestures.py +64 -0
- calc_terminal/input/pointer.py +114 -0
- calc_terminal/input/touch.py +345 -0
- calc_terminal/keys.py +84 -0
- calc_terminal/live_automation.py +165 -0
- calc_terminal/mathtext.py +433 -0
- calc_terminal/mcp.py +386 -0
- calc_terminal/memory.py +337 -0
- calc_terminal/memory_v2.py +479 -0
- calc_terminal/metrics.py +333 -0
- calc_terminal/mode_detection.py +146 -0
- calc_terminal/model.py +2431 -0
- calc_terminal/model_router.py +665 -0
- calc_terminal/models/__init__.py +0 -0
- calc_terminal/models/active_state.py +187 -0
- calc_terminal/models/dynamic_registry.py +584 -0
- calc_terminal/models/manager.py +781 -0
- calc_terminal/models/model_metadata.json +3526 -0
- calc_terminal/models/profiles.py +194 -0
- calc_terminal/models/registry.py +265 -0
- calc_terminal/models/schema.py +197 -0
- calc_terminal/models/validator.py +287 -0
- calc_terminal/models/verification_engine.py +368 -0
- calc_terminal/native_picker.py +215 -0
- calc_terminal/ollama_catalog.py +279 -0
- calc_terminal/ollama_download.py +233 -0
- calc_terminal/orchestrator.py +304 -0
- calc_terminal/package_research.py +322 -0
- calc_terminal/packages.py +1024 -0
- calc_terminal/pc_specs.py +116 -0
- calc_terminal/permissions.py +334 -0
- calc_terminal/pet.py +106 -0
- calc_terminal/pipeline.py +505 -0
- calc_terminal/platform/__init__.py +491 -0
- calc_terminal/platform/desktop.py +491 -0
- calc_terminal/platform/web.py +781 -0
- calc_terminal/preview/__init__.py +1 -0
- calc_terminal/preview/dev_server.py +303 -0
- calc_terminal/preview/diagnostics.py +131 -0
- calc_terminal/preview/live_reload.py +66 -0
- calc_terminal/preview/manager.py +129 -0
- calc_terminal/project_stats.py +209 -0
- calc_terminal/projects.py +328 -0
- calc_terminal/providers/__init__.py +0 -0
- calc_terminal/providers/adapters/__init__.py +80 -0
- calc_terminal/providers/adapters/anthropic_adapter.py +127 -0
- calc_terminal/providers/adapters/base.py +106 -0
- calc_terminal/providers/adapters/chinese_adapters.py +420 -0
- calc_terminal/providers/adapters/gemini_adapter.py +101 -0
- calc_terminal/providers/adapters/ollama_adapter.py +83 -0
- calc_terminal/providers/adapters/openai_adapter.py +159 -0
- calc_terminal/providers/adapters/other_adapters.py +246 -0
- calc_terminal/providers/anthropic_provider.py +172 -0
- calc_terminal/providers/auto_update.py +416 -0
- calc_terminal/providers/base_provider.py +105 -0
- calc_terminal/providers/discovery_manager.py +207 -0
- calc_terminal/providers/gemini_provider.py +178 -0
- calc_terminal/providers/lifecycle.py +767 -0
- calc_terminal/providers/ollama_adapter.py +707 -0
- calc_terminal/providers/openai_provider.py +254 -0
- calc_terminal/providers/provider_manager.py +1827 -0
- calc_terminal/providers/providers.json +4075 -0
- calc_terminal/reactionsim.py +279 -0
- calc_terminal/registry.py +337 -0
- calc_terminal/report.py +162 -0
- calc_terminal/research/__init__.py +45 -0
- calc_terminal/research/artifact_intel.py +126 -0
- calc_terminal/research/data_lineage.py +123 -0
- calc_terminal/research/experiment_ledger.py +303 -0
- calc_terminal/research/reproducibility.py +131 -0
- calc_terminal/resilience/__init__.py +47 -0
- calc_terminal/resilience/agent_state.py +121 -0
- calc_terminal/resilience/capability_matcher.py +174 -0
- calc_terminal/resilience/circuit_breaker.py +158 -0
- calc_terminal/resilience/failover_engine.py +230 -0
- calc_terminal/resilience/health_monitor.py +192 -0
- calc_terminal/resilience/ollama_adapter.py +125 -0
- calc_terminal/resilience/orchestrator.py +312 -0
- calc_terminal/resilience/types.py +134 -0
- calc_terminal/sandbox.py +98 -0
- calc_terminal/scires.py +558 -0
- calc_terminal/security_scanner.py +126 -0
- calc_terminal/session.py +294 -0
- calc_terminal/sim3d.py +206 -0
- calc_terminal/solver.py +276 -0
- calc_terminal/sound.py +127 -0
- calc_terminal/task_reports.py +287 -0
- calc_terminal/terminal_host.py +201 -0
- calc_terminal/terminal_identity.py +411 -0
- calc_terminal/test_ai_mode_reliability.py +344 -0
- calc_terminal/test_browser.py +368 -0
- calc_terminal/test_code_editor_upgrade.py +485 -0
- calc_terminal/test_customization.py +1148 -0
- calc_terminal/test_customization_ui.py +612 -0
- calc_terminal/test_dynamic_registry.py +304 -0
- calc_terminal/test_extensions.py +436 -0
- calc_terminal/test_overhaul.py +557 -0
- calc_terminal/test_project_detect.py +255 -0
- calc_terminal/test_root_cause_fix.py +527 -0
- calc_terminal/test_stability.py +532 -0
- calc_terminal/test_terminal_identity.py +132 -0
- calc_terminal/test_v079_speed.py +460 -0
- calc_terminal/theme.py +1107 -0
- calc_terminal/timeline.py +139 -0
- calc_terminal/todos.py +246 -0
- calc_terminal/tool_call_normalizer.py +419 -0
- calc_terminal/tui.py +104 -0
- calc_terminal/ui/__init__.py +8 -0
- calc_terminal/ui/activity_panel.py +231 -0
- calc_terminal/ui/activity_stream_panel.py +238 -0
- calc_terminal/ui/animations.py +122 -0
- calc_terminal/ui/app.py +7271 -0
- calc_terminal/ui/attach_panel.py +597 -0
- calc_terminal/ui/attachments.py +424 -0
- calc_terminal/ui/backup_panel.py +810 -0
- calc_terminal/ui/browser_shell.py +887 -0
- calc_terminal/ui/cat_agent.py +357 -0
- calc_terminal/ui/chats_panel.py +899 -0
- calc_terminal/ui/command_palette.py +125 -0
- calc_terminal/ui/command_palette_modal.py +166 -0
- calc_terminal/ui/composer.py +1141 -0
- calc_terminal/ui/context_menu.py +197 -0
- calc_terminal/ui/conversation.py +1435 -0
- calc_terminal/ui/customization_panel.py +1229 -0
- calc_terminal/ui/dashboard.py +404 -0
- calc_terminal/ui/design_system.py +557 -0
- calc_terminal/ui/diff_panel.py +213 -0
- calc_terminal/ui/editor.py +2102 -0
- calc_terminal/ui/empty_state.py +302 -0
- calc_terminal/ui/events.py +487 -0
- calc_terminal/ui/extensions_panel.py +815 -0
- calc_terminal/ui/footer.py +166 -0
- calc_terminal/ui/gestures_panel.py +383 -0
- calc_terminal/ui/goodbye_screen.py +100 -0
- calc_terminal/ui/header.py +1034 -0
- calc_terminal/ui/help_panel.py +254 -0
- calc_terminal/ui/live_activities.py +914 -0
- calc_terminal/ui/mcp_panel.py +570 -0
- calc_terminal/ui/memory_center.py +524 -0
- calc_terminal/ui/mode_colors_panel.py +525 -0
- calc_terminal/ui/nav_screens.py +747 -0
- calc_terminal/ui/ollama_panel.py +536 -0
- calc_terminal/ui/palette.py +221 -0
- calc_terminal/ui/permission_panel.py +269 -0
- calc_terminal/ui/personalization_panel.py +517 -0
- calc_terminal/ui/personalize_center.py +1568 -0
- calc_terminal/ui/preview_panel.py +441 -0
- calc_terminal/ui/resizers.py +402 -0
- calc_terminal/ui/sidebar.py +1285 -0
- calc_terminal/ui/statusbar.py +168 -0
- calc_terminal/ui/theme_css.py +1396 -0
- calc_terminal/ui/thinking.py +226 -0
- calc_terminal/ui/timeline_panel.py +102 -0
- calc_terminal/ui/todo_panel.py +193 -0
- calc_terminal/ui/viewport.py +136 -0
- calc_terminal/ui/vision_panel.py +489 -0
- calc_terminal/ui/welcome_modal.py +343 -0
- calc_terminal/ui/widgets.py +160 -0
- calc_terminal/ui/workspace.py +831 -0
- calc_terminal/viewers/__init__.py +1 -0
- calc_terminal/viewers/document_viewer.py +252 -0
- calc_terminal/viewers/image_viewer.py +241 -0
- calc_terminal/viewers/pdf_viewer.py +203 -0
- calc_terminal/viewers/presentation_viewer.py +164 -0
- calc_terminal/viewers/registry.py +120 -0
- calc_terminal/viewers/spreadsheet_viewer.py +204 -0
- calc_terminal/vision/__init__.py +89 -0
- calc_terminal/vision/analysis.py +194 -0
- calc_terminal/vision/annotations.py +297 -0
- calc_terminal/vision/capture.py +171 -0
- calc_terminal/vision/context.py +231 -0
- calc_terminal/vision/correlation.py +169 -0
- calc_terminal/vision/cursor.py +258 -0
- calc_terminal/vision/events.py +66 -0
- calc_terminal/vision/frame_pipeline.py +259 -0
- calc_terminal/vision/priority.py +218 -0
- calc_terminal/vision/provider.py +180 -0
- calc_terminal/vision/safety.py +149 -0
- calc_terminal/vision/session.py +281 -0
- calc_terminal/vision/verify.py +162 -0
- calc_terminal/vision.py +514 -0
- calc_terminal/vscode_integration.py +113 -0
- calc_terminal/web/__init__.py +8 -0
- calc_terminal/web/cat_runtime.py +710 -0
- calc_terminal/web/server.py +2891 -0
- calc_terminal/web/static/css/app.css +3152 -0
- calc_terminal/web/static/icons/badge-72.png +0 -0
- calc_terminal/web/static/icons/cat.ico +0 -0
- calc_terminal/web/static/icons/icon-128.png +0 -0
- calc_terminal/web/static/icons/icon-144.png +0 -0
- calc_terminal/web/static/icons/icon-152.png +0 -0
- calc_terminal/web/static/icons/icon-192.png +0 -0
- calc_terminal/web/static/icons/icon-384.png +0 -0
- calc_terminal/web/static/icons/icon-512.png +0 -0
- calc_terminal/web/static/icons/icon-72.png +0 -0
- calc_terminal/web/static/icons/icon-96.png +0 -0
- calc_terminal/web/static/icons/icon.svg +34 -0
- calc_terminal/web/static/icons/new-project.png +0 -0
- calc_terminal/web/static/icons/open-project.png +0 -0
- calc_terminal/web/static/index.html +734 -0
- calc_terminal/web/static/js/app.js +2403 -0
- calc_terminal/web/static/manifest.json +88 -0
- calc_terminal/web/static/sw.js +230 -0
- calc_terminal/workflow_engine.py +769 -0
- calc_terminal/workspace.py +593 -0
- calc_terminal/workspace_index.py +385 -0
- cct_cli-0.7.9.0.dist-info/METADATA +210 -0
- cct_cli-0.7.9.0.dist-info/RECORD +325 -0
- cct_cli-0.7.9.0.dist-info/WHEEL +5 -0
- cct_cli-0.7.9.0.dist-info/entry_points.txt +4 -0
- cct_cli-0.7.9.0.dist-info/licenses/LICENSE +21 -0
- cct_cli-0.7.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1285 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CAT UI — sidebar.py (spec v0.7 project structure + Sidebar section).
|
|
3
|
+
|
|
4
|
+
Explorer: Open Folder, the opened folder's file tree, Pinned/Recent
|
|
5
|
+
Projects, and a drag & drop hint area.
|
|
6
|
+
|
|
7
|
+
v0.7.9.0: the Explorer is a LIVE VIEW of the real filesystem — the
|
|
8
|
+
fs watcher (calc_terminal/fs_watcher.py) plus explicit agent tool
|
|
9
|
+
events push refresh_tree(changed_paths) the moment anything on disk
|
|
10
|
+
changes (create/delete/rename/move/modify), and the divider next to
|
|
11
|
+
this panel is a real drag handle (ui/resizers.py).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
if __name__ == "__main__":
|
|
15
|
+
print("This is a library file and is not meant to be run directly.")
|
|
16
|
+
import sys
|
|
17
|
+
sys.exit(1)
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
|
|
21
|
+
from .. import projects
|
|
22
|
+
from .events import FolderOpened, FileOpenRequested, SidebarToggled
|
|
23
|
+
|
|
24
|
+
TEXTUAL_AVAILABLE = True
|
|
25
|
+
try:
|
|
26
|
+
from textual.containers import Vertical, Horizontal, ScrollableContainer
|
|
27
|
+
from textual.widgets import Static, DirectoryTree, Button, Input
|
|
28
|
+
from textual.reactive import reactive
|
|
29
|
+
from textual.screen import Screen
|
|
30
|
+
from textual.binding import Binding
|
|
31
|
+
except Exception:
|
|
32
|
+
TEXTUAL_AVAILABLE = False
|
|
33
|
+
|
|
34
|
+
DEFAULT_WIDTH = 32
|
|
35
|
+
COLLAPSED_WIDTH = 0
|
|
36
|
+
|
|
37
|
+
# File extensions the spec's Drag & Drop section lists — used only to
|
|
38
|
+
# pick an icon glyph in the tree; DirectoryTree still shows every file,
|
|
39
|
+
# this doesn't filter anything out.
|
|
40
|
+
_ICONS = {
|
|
41
|
+
".py": "\U0001f40d", ".cpp": "\u2699", ".c": "\u2699", ".java": "\u2615",
|
|
42
|
+
".html": "\U0001f310", ".css": "\U0001f3a8", ".js": "\U0001f4dc",
|
|
43
|
+
".json": "{}", ".yaml": "\U0001f4c4", ".yml": "\U0001f4c4", ".toml": "\U0001f4c4",
|
|
44
|
+
".txt": "\U0001f4c4", ".md": "\U0001f4dd", ".pdf": "\U0001f4d5",
|
|
45
|
+
".docx": "\U0001f4d8", ".pptx": "\U0001f4d1", ".xlsx": "\U0001f4ca", ".csv": "\U0001f4ca",
|
|
46
|
+
".png": "\U0001f5bc", ".jpg": "\U0001f5bc", ".jpeg": "\U0001f5bc",
|
|
47
|
+
".gif": "\U0001f5bc", ".svg": "\U0001f5bc",
|
|
48
|
+
".zip": "\U0001f5c3", ".7z": "\U0001f5c3",
|
|
49
|
+
".xyz": "\u269b", ".pdb": "\u269b", ".mol": "\u269b", ".cif": "\u269b",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def icon_for(path):
|
|
54
|
+
return _ICONS.get(os.path.splitext(path)[1].lower(), "\U0001f4c4")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if TEXTUAL_AVAILABLE:
|
|
58
|
+
|
|
59
|
+
class _ProjectRow(Horizontal):
|
|
60
|
+
"""One clickable line in Pinned/Recent Projects — opens that
|
|
61
|
+
folder on click. When `on_delete` is given (Recent Projects
|
|
62
|
+
only), a small ✕ is appended to the right of the row; clicking
|
|
63
|
+
it removes just that entry from the history and re-renders the
|
|
64
|
+
block. Without a delete handler this renders exactly as the
|
|
65
|
+
old single-Static row did (same text, same classes, same
|
|
66
|
+
hover/click behaviour) — the standalone Recent Workspaces
|
|
67
|
+
picker screens keep using that form."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, path, widget_id=None, on_delete=None, prefix="\U0001f4c1"):
|
|
70
|
+
label = os.path.basename(path.rstrip(os.sep)) or path
|
|
71
|
+
self.path = path
|
|
72
|
+
self._on_delete = on_delete
|
|
73
|
+
self._label = Static(f" {prefix} {label}", id=widget_id,
|
|
74
|
+
classes="cct-sidebar-project")
|
|
75
|
+
super().__init__(classes="cct-sidebar-project-row")
|
|
76
|
+
self.tooltip = path
|
|
77
|
+
|
|
78
|
+
def compose(self):
|
|
79
|
+
yield self._label
|
|
80
|
+
if self._on_delete is not None:
|
|
81
|
+
yield Static("\u2715", classes="cct-sidebar-project-del")
|
|
82
|
+
|
|
83
|
+
def on_mount(self):
|
|
84
|
+
if self._on_delete is not None:
|
|
85
|
+
self._label.styles.width = "auto"
|
|
86
|
+
|
|
87
|
+
def on_click(self, event):
|
|
88
|
+
if event.widget and "del" in getattr(event.widget, "classes", set()):
|
|
89
|
+
return
|
|
90
|
+
self.post_message(FolderOpened(self.path))
|
|
91
|
+
|
|
92
|
+
class _ConfirmClearRecent(Screen):
|
|
93
|
+
"""Confirmation dialog for the 'Clear Recent Projects' minor
|
|
94
|
+
feature. Same real-Screen-push pattern as _AttachPrompt/
|
|
95
|
+
_PathPrompt (ui/app.py) and PermissionModeMenu (ui/header.py) —
|
|
96
|
+
this package never uses floating popup windows, only a
|
|
97
|
+
centered/anchored Screen push, which behaves like a modal
|
|
98
|
+
(blocks input to everything beneath it) without being one."""
|
|
99
|
+
|
|
100
|
+
CSS = """
|
|
101
|
+
_ConfirmClearRecent { align: center middle; background: $app-background 60%; }
|
|
102
|
+
#cct-clearrecent-box {
|
|
103
|
+
width: 56; height: auto; background: $surface;
|
|
104
|
+
border: tall $border-active $border; padding: 1 2;
|
|
105
|
+
}
|
|
106
|
+
#cct-clearrecent-title { text-style: bold; padding-bottom: 1; }
|
|
107
|
+
#cct-clearrecent-msg { color: $text-muted; padding-bottom: 1; }
|
|
108
|
+
#cct-clearrecent-btns { height: 3; align-horizontal: right; }
|
|
109
|
+
#cct-clearrecent-btns Button { margin-left: 1; border: tall $border-active $border; }
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def compose(self):
|
|
113
|
+
with Vertical(id="cct-clearrecent-box"):
|
|
114
|
+
yield Static("Clear Recent Workspaces?", id="cct-clearrecent-title")
|
|
115
|
+
yield Static(
|
|
116
|
+
"This will remove all recent project entries from the history.\n"
|
|
117
|
+
"Your project folders and files will NOT be deleted.",
|
|
118
|
+
id="cct-clearrecent-msg")
|
|
119
|
+
with Horizontal(id="cct-clearrecent-btns"):
|
|
120
|
+
yield Button("Cancel", id="cct-clearrecent-cancel")
|
|
121
|
+
yield Button("Clear", id="cct-clearrecent-clear", variant="error")
|
|
122
|
+
|
|
123
|
+
def on_button_pressed(self, event):
|
|
124
|
+
self.dismiss(event.button.id == "cct-clearrecent-clear")
|
|
125
|
+
|
|
126
|
+
def on_key(self, event):
|
|
127
|
+
if event.key == "escape":
|
|
128
|
+
self.dismiss(False)
|
|
129
|
+
|
|
130
|
+
class _ClearRecentRow(Static):
|
|
131
|
+
"""'Clear Recent Projects' link, shown at the bottom of the
|
|
132
|
+
Recent Projects section only when that list isn't already
|
|
133
|
+
empty. Opens _ConfirmClearRecent; on confirm, clears only the
|
|
134
|
+
recent-projects JSON store (projects.clear_recent()) and asks
|
|
135
|
+
the owning Explorer to redraw the Pinned/Recent block — it
|
|
136
|
+
never touches 'pinned', the currently open folder, or any file
|
|
137
|
+
on disk."""
|
|
138
|
+
|
|
139
|
+
def __init__(self, on_cleared):
|
|
140
|
+
super().__init__(" \U0001f5d1 Clear Recent Workspaces",
|
|
141
|
+
classes="cct-sidebar-clear-recent")
|
|
142
|
+
self._on_cleared = on_cleared
|
|
143
|
+
|
|
144
|
+
def on_click(self, event):
|
|
145
|
+
self.app.push_screen(_ConfirmClearRecent(), self._handle_result)
|
|
146
|
+
|
|
147
|
+
def _handle_result(self, confirmed):
|
|
148
|
+
if not confirmed:
|
|
149
|
+
return
|
|
150
|
+
projects.clear_recent()
|
|
151
|
+
self._on_cleared()
|
|
152
|
+
|
|
153
|
+
class _RecentWsRow(Horizontal):
|
|
154
|
+
"""One workspace row in the Recent Workspaces picker
|
|
155
|
+
(v0.7.8.1 redesign): a clickable name label, a 📌 pin toggle and
|
|
156
|
+
a ✕ remove button. Pinned workspaces sort first and show a
|
|
157
|
+
filled pin; clicking the label picks the folder."""
|
|
158
|
+
|
|
159
|
+
def __init__(self, path, pinned=False, selected=False, on_click=None,
|
|
160
|
+
on_toggle_pin=None, on_remove=None, search_hit=False):
|
|
161
|
+
super().__init__(classes="cct-rws-row" + (" cct-rws-row-sel" if selected else ""))
|
|
162
|
+
self.path = path
|
|
163
|
+
self._pinned = pinned
|
|
164
|
+
self._on_click = on_click
|
|
165
|
+
self._on_toggle_pin = on_toggle_pin
|
|
166
|
+
self._on_remove = on_remove
|
|
167
|
+
label = os.path.basename(path.rstrip(os.sep)) or path
|
|
168
|
+
pin_mark = "\U0001f4cc " if pinned else " "
|
|
169
|
+
self._label = Static(f"{pin_mark}[b]{label}[/]", classes="cct-rws-label")
|
|
170
|
+
self._label.tooltip = path
|
|
171
|
+
self._search_hit = search_hit
|
|
172
|
+
|
|
173
|
+
def compose(self):
|
|
174
|
+
yield self._label
|
|
175
|
+
yield Static(self.path, classes="cct-rws-path")
|
|
176
|
+
pin_classes = "cct-rws-pin" + (" cct-rws-pinned" if self._pinned else "")
|
|
177
|
+
yield Static("\U0001f4cc", classes=pin_classes)
|
|
178
|
+
yield Static("\u2715", classes="cct-rws-del")
|
|
179
|
+
|
|
180
|
+
def set_selected(self, selected):
|
|
181
|
+
self.set_class(selected, "cct-rws-row-sel")
|
|
182
|
+
|
|
183
|
+
def on_click(self, event):
|
|
184
|
+
w_classes = getattr(event.widget, "classes", set())
|
|
185
|
+
if "cct-rws-pin" in w_classes:
|
|
186
|
+
event.stop()
|
|
187
|
+
if self._on_toggle_pin:
|
|
188
|
+
self._on_toggle_pin(self.path)
|
|
189
|
+
return
|
|
190
|
+
if "cct-rws-del" in w_classes:
|
|
191
|
+
event.stop()
|
|
192
|
+
if self._on_remove:
|
|
193
|
+
self._on_remove(self.path)
|
|
194
|
+
return
|
|
195
|
+
if self._on_click:
|
|
196
|
+
self._on_click(self.path)
|
|
197
|
+
|
|
198
|
+
class RecentWorkspacesScreen(Screen):
|
|
199
|
+
"""Standalone "Recent Workspaces" picker (v0.7.8.1 redesign —
|
|
200
|
+
the primary workspace-management surface). Reachable from the
|
|
201
|
+
header's menu even before any folder is open. Supports:
|
|
202
|
+
|
|
203
|
+
- live search (filters by name or path)
|
|
204
|
+
- pin / unpin (pinned sort first and stick)
|
|
205
|
+
- remove a single entry
|
|
206
|
+
- clear the whole history (confirm dialog)
|
|
207
|
+
- up/down + Enter keyboard navigation
|
|
208
|
+
- Esc closes, opening a row dismisses with that path
|
|
209
|
+
|
|
210
|
+
Selecting a row dismisses with that path; CCTApp opens it the
|
|
211
|
+
same way FolderOpened already does."""
|
|
212
|
+
|
|
213
|
+
CSS = """
|
|
214
|
+
RecentWorkspacesScreen { align: center middle; background: $app-background 60%; }
|
|
215
|
+
#cct-recentws-box {
|
|
216
|
+
width: 84; height: auto; max-height: 88%;
|
|
217
|
+
background: $surface; border: tall $border-active $border;
|
|
218
|
+
padding: 0 0 1 0;
|
|
219
|
+
opacity: 0; offset-y: 1;
|
|
220
|
+
transition: opacity 120ms, offset 150ms;
|
|
221
|
+
}
|
|
222
|
+
#cct-recentws-box.open { opacity: 1; offset-y: 0; }
|
|
223
|
+
#cct-recentws-titlebar {
|
|
224
|
+
height: 3; padding: 1 2 0 2;
|
|
225
|
+
border-bottom: solid $border;
|
|
226
|
+
}
|
|
227
|
+
#cct-recentws-title { text-style: bold; width: 1fr; }
|
|
228
|
+
#cct-recentws-close {
|
|
229
|
+
width: 3; min-width: 3; height: 1;
|
|
230
|
+
color: $text-faint; content-align: center middle;
|
|
231
|
+
}
|
|
232
|
+
#cct-recentws-close:hover { color: $error; }
|
|
233
|
+
#cct-recentws-search { margin: 1 2 0 2; }
|
|
234
|
+
#cct-recentws-count { color: $text-faint; height: 1; padding: 0 2; margin-top: 1; }
|
|
235
|
+
#cct-recentws-list {
|
|
236
|
+
height: auto; max-height: 12; min-height: 2; overflow-y: auto;
|
|
237
|
+
margin: 0 1; padding: 0 1; scrollbar-gutter: stable;
|
|
238
|
+
scrollbar-size: 1 1; scrollbar-color: $border $surface;
|
|
239
|
+
}
|
|
240
|
+
#cct-recentws-empty { color: $text-faint; padding: 2 1; }
|
|
241
|
+
.cct-rws-row {
|
|
242
|
+
height: 1; min-height: 1; max-height: 1;
|
|
243
|
+
border-left: thick transparent;
|
|
244
|
+
padding: 0 1; align-vertical: middle;
|
|
245
|
+
transition: background 80ms, color 80ms;
|
|
246
|
+
}
|
|
247
|
+
.cct-rws-row:hover { background: $surface-alt; color: $text; }
|
|
248
|
+
.cct-rws-row:hover .cct-rws-label { color: $accent; }
|
|
249
|
+
.cct-rws-row:hover .cct-rws-path { color: $text; }
|
|
250
|
+
.cct-rws-row-sel {
|
|
251
|
+
background: $accent 18%;
|
|
252
|
+
border-left: thick $accent;
|
|
253
|
+
text-style: bold;
|
|
254
|
+
}
|
|
255
|
+
.cct-rws-row-sel .cct-rws-label { color: $accent; text-style: bold; }
|
|
256
|
+
.cct-rws-row-sel .cct-rws-path { color: $text; }
|
|
257
|
+
.cct-rws-label {
|
|
258
|
+
width: 24; min-width: 20; max-width: 26;
|
|
259
|
+
text-wrap: nowrap; text-overflow: ellipsis; overflow-x: hidden;
|
|
260
|
+
height: 1; padding: 0 1 0 0; color: $text;
|
|
261
|
+
}
|
|
262
|
+
.cct-rws-path {
|
|
263
|
+
width: 1fr; color: $text-faint;
|
|
264
|
+
text-wrap: nowrap; text-overflow: ellipsis; overflow-x: hidden;
|
|
265
|
+
height: 1; padding: 0 1;
|
|
266
|
+
}
|
|
267
|
+
.cct-rws-pin {
|
|
268
|
+
width: 3; min-width: 3; height: 1;
|
|
269
|
+
color: $text-faint; content-align: center middle;
|
|
270
|
+
}
|
|
271
|
+
.cct-rws-pin:hover { color: $accent; }
|
|
272
|
+
.cct-rws-pinned { color: $warning; }
|
|
273
|
+
.cct-rws-del {
|
|
274
|
+
width: 3; min-width: 3; height: 1;
|
|
275
|
+
color: $text-faint; content-align: center middle;
|
|
276
|
+
}
|
|
277
|
+
.cct-rws-del:hover { color: $error; }
|
|
278
|
+
#cct-recentws-btns {
|
|
279
|
+
height: 4; padding: 0 2;
|
|
280
|
+
margin-top: 1;
|
|
281
|
+
border-top: solid $border;
|
|
282
|
+
align-horizontal: right;
|
|
283
|
+
align-vertical: middle;
|
|
284
|
+
}
|
|
285
|
+
#cct-recentws-btns Button {
|
|
286
|
+
margin-left: 1;
|
|
287
|
+
min-width: 14;
|
|
288
|
+
height: 3;
|
|
289
|
+
border: tall $border-active $border;
|
|
290
|
+
}
|
|
291
|
+
.cct-btn-spacer { width: 1fr; }
|
|
292
|
+
#cct-recentws-hint { color: $text-faint; height: 1; margin-top: 1; padding: 0 2; text-align: center; }
|
|
293
|
+
"""
|
|
294
|
+
|
|
295
|
+
BINDINGS = [
|
|
296
|
+
Binding("escape", "cancel", "Cancel"),
|
|
297
|
+
Binding("down", "move_down_sel", "Next"),
|
|
298
|
+
Binding("up", "move_up_sel", "Previous"),
|
|
299
|
+
]
|
|
300
|
+
|
|
301
|
+
def __init__(self):
|
|
302
|
+
super().__init__()
|
|
303
|
+
self._entries = [] # list of workspace dicts from projects.workspaces()
|
|
304
|
+
self._selected = 0
|
|
305
|
+
|
|
306
|
+
def compose(self):
|
|
307
|
+
with Vertical(id="cct-recentws-box"):
|
|
308
|
+
with Horizontal(id="cct-recentws-titlebar"):
|
|
309
|
+
yield Static("\U0001f553 Recent Workspaces", id="cct-recentws-title")
|
|
310
|
+
yield Static("\u2715", id="cct-recentws-close")
|
|
311
|
+
yield Input(placeholder="Search workspaces\u2026", id="cct-recentws-search")
|
|
312
|
+
yield Static("", id="cct-recentws-count")
|
|
313
|
+
with ScrollableContainer(id="cct-recentws-list"):
|
|
314
|
+
self._render_rows()
|
|
315
|
+
with Horizontal(id="cct-recentws-btns"):
|
|
316
|
+
yield Button("\U0001f5d1 Clear Recent", id="cct-recentws-clear")
|
|
317
|
+
yield Static("", classes="cct-btn-spacer")
|
|
318
|
+
yield Button("Cancel", id="cct-recentws-cancel")
|
|
319
|
+
yield Button(" Open ", id="cct-recentws-open", variant="primary")
|
|
320
|
+
yield Static("Enter to open \u00b7 Up/Down to select \u00b7 Esc to close",
|
|
321
|
+
id="cct-recentws-hint")
|
|
322
|
+
|
|
323
|
+
# --------------------------------------------------------- state --
|
|
324
|
+
def _refresh(self):
|
|
325
|
+
self._render_rows()
|
|
326
|
+
self._update_count()
|
|
327
|
+
|
|
328
|
+
def _render_rows(self):
|
|
329
|
+
try:
|
|
330
|
+
scroll = self.query_one("#cct-recentws-list", ScrollableContainer)
|
|
331
|
+
except Exception:
|
|
332
|
+
return
|
|
333
|
+
query = ""
|
|
334
|
+
try:
|
|
335
|
+
query = self.query_one("#cct-recentws-search", Input).value.strip().lower()
|
|
336
|
+
except Exception:
|
|
337
|
+
pass
|
|
338
|
+
scroll.remove_children()
|
|
339
|
+
all_ws = projects.search(query)
|
|
340
|
+
# v0.7.8.1: pinned workspaces always sort first.
|
|
341
|
+
self._entries = sorted(all_ws, key=lambda w: (not w["pinned"], -w.get("opened_at", 0)))
|
|
342
|
+
if not self._entries:
|
|
343
|
+
scroll.mount(Static(
|
|
344
|
+
"No recent workspaces yet \u2014 open a folder to start.",
|
|
345
|
+
id="cct-recentws-empty"))
|
|
346
|
+
self._selected = 0
|
|
347
|
+
return
|
|
348
|
+
self._selected = min(self._selected, len(self._entries) - 1)
|
|
349
|
+
for i, w in enumerate(self._entries):
|
|
350
|
+
row = _RecentWsRow(
|
|
351
|
+
w["path"], pinned=bool(w.get("pinned")), selected=(i == self._selected),
|
|
352
|
+
on_click=self._pick, on_toggle_pin=self._toggle_pin, on_remove=self._remove)
|
|
353
|
+
scroll.mount(row)
|
|
354
|
+
|
|
355
|
+
def _update_count(self):
|
|
356
|
+
try:
|
|
357
|
+
n = len(self._entries)
|
|
358
|
+
pinned_n = sum(1 for w in self._entries if w.get("pinned"))
|
|
359
|
+
parts = [f"{n} workspace{'s' if n != 1 else ''}"]
|
|
360
|
+
if pinned_n:
|
|
361
|
+
parts.append(f"{pinned_n} pinned")
|
|
362
|
+
self.query_one("#cct-recentws-count", Static).update(
|
|
363
|
+
" \u00b7 ".join(parts))
|
|
364
|
+
except Exception:
|
|
365
|
+
pass
|
|
366
|
+
|
|
367
|
+
# ------------------------------------------------------ actions --
|
|
368
|
+
def _pick(self, path):
|
|
369
|
+
self.dismiss(path)
|
|
370
|
+
|
|
371
|
+
def _toggle_pin(self, path):
|
|
372
|
+
projects.toggle_pin(path)
|
|
373
|
+
self._refresh()
|
|
374
|
+
|
|
375
|
+
def _remove(self, path):
|
|
376
|
+
projects.remove_recent(path)
|
|
377
|
+
self._refresh()
|
|
378
|
+
|
|
379
|
+
def _clear(self):
|
|
380
|
+
def _on_confirmed(confirmed):
|
|
381
|
+
if confirmed:
|
|
382
|
+
projects.clear_recent()
|
|
383
|
+
self._refresh()
|
|
384
|
+
self.app.push_screen(_ConfirmClearRecent(), _on_confirmed)
|
|
385
|
+
|
|
386
|
+
# --------------------------------------------------------- events --
|
|
387
|
+
def on_mount(self):
|
|
388
|
+
self.call_after_refresh(lambda: self.query_one("#cct-recentws-box").add_class("open"))
|
|
389
|
+
self.call_after_refresh(self._refresh)
|
|
390
|
+
try:
|
|
391
|
+
self.query_one("#cct-recentws-search", Input).focus()
|
|
392
|
+
except Exception:
|
|
393
|
+
pass
|
|
394
|
+
|
|
395
|
+
def on_input_changed(self, event):
|
|
396
|
+
if event.input.id == "cct-recentws-search":
|
|
397
|
+
self._selected = 0
|
|
398
|
+
self._refresh()
|
|
399
|
+
|
|
400
|
+
def on_button_pressed(self, event):
|
|
401
|
+
eid = event.button.id
|
|
402
|
+
if eid in ("cct-recentws-cancel", "cct-recentws-close"):
|
|
403
|
+
self.dismiss(None)
|
|
404
|
+
elif eid == "cct-recentws-clear":
|
|
405
|
+
self._clear()
|
|
406
|
+
elif eid == "cct-recentws-open":
|
|
407
|
+
if self._entries:
|
|
408
|
+
self._pick(self._entries[self._selected]["path"])
|
|
409
|
+
|
|
410
|
+
def action_cancel(self):
|
|
411
|
+
self.dismiss(None)
|
|
412
|
+
|
|
413
|
+
def action_move_down_sel(self):
|
|
414
|
+
if self._entries:
|
|
415
|
+
self._selected = min(self._selected + 1, len(self._entries) - 1)
|
|
416
|
+
self._refresh()
|
|
417
|
+
|
|
418
|
+
def action_move_up_sel(self):
|
|
419
|
+
if self._entries:
|
|
420
|
+
self._selected = max(self._selected - 1, 0)
|
|
421
|
+
self._refresh()
|
|
422
|
+
|
|
423
|
+
def on_key(self, event):
|
|
424
|
+
if event.key == "enter" and self._entries and self.app.screen is self:
|
|
425
|
+
self._pick(self._entries[self._selected]["path"])
|
|
426
|
+
|
|
427
|
+
def on_click(self, event):
|
|
428
|
+
if getattr(event.widget, "id", None) == "cct-recentws-close":
|
|
429
|
+
self.dismiss(None)
|
|
430
|
+
return
|
|
431
|
+
if getattr(event, "widget", None) in (self, None):
|
|
432
|
+
self.dismiss(None)
|
|
433
|
+
|
|
434
|
+
class OpenWorkspaceScreen(Screen):
|
|
435
|
+
"""Modern compact workspace picker dialog.
|
|
436
|
+
|
|
437
|
+
Replaces the old _PathPrompt with a VS Code/Cursor-inspired
|
|
438
|
+
dialog. Supports manual path entry, native folder browsing
|
|
439
|
+
(tkinter), recent workspaces, path validation, and keyboard
|
|
440
|
+
shortcuts. Compact ~68-col layout with smooth animations."""
|
|
441
|
+
|
|
442
|
+
CSS = """
|
|
443
|
+
OpenWorkspaceScreen { align: center middle; background: $app-background 70%; }
|
|
444
|
+
#ows-box {
|
|
445
|
+
width: 70; height: auto; max-height: 36;
|
|
446
|
+
background: $surface; border: tall $border-active $border; padding: 0;
|
|
447
|
+
opacity: 0; offset-y: 1;
|
|
448
|
+
transition: opacity 150ms, offset 180ms;
|
|
449
|
+
}
|
|
450
|
+
#ows-box.open { opacity: 1; offset-y: 0; }
|
|
451
|
+
#ows-titlebar {
|
|
452
|
+
height: 3; padding: 1 2 0 2;
|
|
453
|
+
border-bottom: solid $border; margin-bottom: 0;
|
|
454
|
+
}
|
|
455
|
+
#ows-title { text-style: bold; width: 1fr; }
|
|
456
|
+
#ows-close-btn {
|
|
457
|
+
width: 3; min-width: 3;
|
|
458
|
+
content-align: center middle;
|
|
459
|
+
color: $text-faint;
|
|
460
|
+
transition: color 80ms;
|
|
461
|
+
}
|
|
462
|
+
#ows-close-btn:hover { color: $error; }
|
|
463
|
+
#ows-body { padding: 1 2 1 2; }
|
|
464
|
+
#ows-path-label { color: $text-faint; padding-bottom: 0; height: 1; }
|
|
465
|
+
#ows-path-input { margin: 0 0 0 0; }
|
|
466
|
+
#ows-hint { height: 1; padding: 0 0 1 0; }
|
|
467
|
+
#ows-hint.ows-error { color: $error; }
|
|
468
|
+
#ows-hint.ows-success { color: $success; }
|
|
469
|
+
#ows-button-row { height: 3; align: center middle; margin-top: 0; }
|
|
470
|
+
#ows-button-row Button { margin: 0 1; }
|
|
471
|
+
#ows-recent-header {
|
|
472
|
+
color: $text-faint; padding-top: 1; padding-bottom: 0;
|
|
473
|
+
border-top: solid $border; margin-top: 1;
|
|
474
|
+
}
|
|
475
|
+
#ows-recent-body { height: auto; max-height: 10; overflow-y: auto; }
|
|
476
|
+
.ows-recent-row {
|
|
477
|
+
height: 1; color: $text-muted; padding: 0 1;
|
|
478
|
+
border-left: solid transparent;
|
|
479
|
+
transition: background 80ms, color 80ms;
|
|
480
|
+
}
|
|
481
|
+
.ows-recent-row:hover { color: $text; border-left: solid $border-active; }
|
|
482
|
+
#ows-dropzone {
|
|
483
|
+
height: 3; margin-top: 1;
|
|
484
|
+
border: dashed $border; color: $text-faint;
|
|
485
|
+
content-align: center middle;
|
|
486
|
+
}
|
|
487
|
+
"""
|
|
488
|
+
|
|
489
|
+
BINDINGS = [
|
|
490
|
+
Binding("escape", "cancel", "Cancel"),
|
|
491
|
+
Binding("ctrl+v", "paste_path", "Paste"),
|
|
492
|
+
Binding("ctrl+l", "focus_path", "Focus path"),
|
|
493
|
+
Binding("ctrl+o", "browse", "Browse"),
|
|
494
|
+
]
|
|
495
|
+
|
|
496
|
+
def __init__(self):
|
|
497
|
+
super().__init__()
|
|
498
|
+
self._valid_path = None
|
|
499
|
+
|
|
500
|
+
def compose(self):
|
|
501
|
+
with Vertical(id="ows-box"):
|
|
502
|
+
with Horizontal(id="ows-titlebar"):
|
|
503
|
+
yield Static("\U0001f4c2 Open Workspace", id="ows-title")
|
|
504
|
+
yield Button("\u2715", id="ows-close-btn", classes="cct-ctrl")
|
|
505
|
+
with Vertical(id="ows-body"):
|
|
506
|
+
yield Static("Workspace Folder", id="ows-path-label")
|
|
507
|
+
yield Input(placeholder="C:\\Users\\... or /home/user/project", id="ows-path-input")
|
|
508
|
+
yield Static("", id="ows-hint")
|
|
509
|
+
with Horizontal(id="ows-button-row"):
|
|
510
|
+
yield Button("Browse...", id="ows-browse-btn")
|
|
511
|
+
yield Button(" Open ", id="ows-open-btn", variant="primary")
|
|
512
|
+
yield Button("Cancel", id="ows-cancel-btn")
|
|
513
|
+
yield Static("Recent Workspaces", id="ows-recent-header")
|
|
514
|
+
with Vertical(id="ows-recent-body"):
|
|
515
|
+
rows = projects.recent()
|
|
516
|
+
if not rows:
|
|
517
|
+
yield Static(" No recent workspaces yet.", classes="ows-hint", id="ows-recent-empty")
|
|
518
|
+
else:
|
|
519
|
+
for p in rows[:10]:
|
|
520
|
+
yield _ProjectRow(p)
|
|
521
|
+
yield Static("\U0001f4c1 Drop folder here", id="ows-dropzone")
|
|
522
|
+
|
|
523
|
+
def on_mount(self):
|
|
524
|
+
self.call_after_refresh(
|
|
525
|
+
lambda: self.query_one("#ows-box").add_class("open"))
|
|
526
|
+
self.query_one("#ows-path-input", Input).focus()
|
|
527
|
+
|
|
528
|
+
def on_input_submitted(self, event):
|
|
529
|
+
if event.input.id == "ows-path-input":
|
|
530
|
+
self._do_open()
|
|
531
|
+
|
|
532
|
+
def on_button_pressed(self, event):
|
|
533
|
+
eid = event.button.id
|
|
534
|
+
if eid == "ows-browse-btn":
|
|
535
|
+
self._do_browse()
|
|
536
|
+
elif eid == "ows-open-btn":
|
|
537
|
+
self._do_open()
|
|
538
|
+
elif eid in ("ows-cancel-btn", "ows-close-btn"):
|
|
539
|
+
self.dismiss(None)
|
|
540
|
+
|
|
541
|
+
def on_input_changed(self, event):
|
|
542
|
+
if event.input.id == "ows-path-input":
|
|
543
|
+
self._validate()
|
|
544
|
+
|
|
545
|
+
def on_folder_opened(self, event):
|
|
546
|
+
"""Intercept _ProjectRow's FolderOpened events and dismiss."""
|
|
547
|
+
event.stop()
|
|
548
|
+
path = os.path.abspath(os.path.expanduser(event.path))
|
|
549
|
+
if os.path.isdir(path):
|
|
550
|
+
self.dismiss(path)
|
|
551
|
+
|
|
552
|
+
def on_click(self, event):
|
|
553
|
+
# ✕ in titlebar may emit click on inner label, not the Button itself — walk parents
|
|
554
|
+
w = getattr(event, "widget", None)
|
|
555
|
+
cur = w
|
|
556
|
+
while cur is not None:
|
|
557
|
+
if getattr(cur, "id", None) == "ows-close-btn":
|
|
558
|
+
self.dismiss(None)
|
|
559
|
+
return
|
|
560
|
+
cur = getattr(cur, "parent", None)
|
|
561
|
+
if w in (self, None):
|
|
562
|
+
self.dismiss(None)
|
|
563
|
+
|
|
564
|
+
def action_cancel(self):
|
|
565
|
+
self.dismiss(None)
|
|
566
|
+
|
|
567
|
+
def action_paste_path(self):
|
|
568
|
+
clipped = self._get_clipboard()
|
|
569
|
+
if clipped:
|
|
570
|
+
inp = self.query_one("#ows-path-input", Input)
|
|
571
|
+
inp.value = clipped
|
|
572
|
+
self._validate()
|
|
573
|
+
|
|
574
|
+
def action_focus_path(self):
|
|
575
|
+
self.query_one("#ows-path-input", Input).focus()
|
|
576
|
+
|
|
577
|
+
def action_browse(self):
|
|
578
|
+
self._do_browse()
|
|
579
|
+
|
|
580
|
+
def _get_clipboard(self):
|
|
581
|
+
try:
|
|
582
|
+
import tkinter as tk
|
|
583
|
+
root = tk.Tk()
|
|
584
|
+
root.withdraw()
|
|
585
|
+
root.attributes('-topmost', True)
|
|
586
|
+
try:
|
|
587
|
+
return root.clipboard_get()
|
|
588
|
+
except tk.TclError:
|
|
589
|
+
return ""
|
|
590
|
+
finally:
|
|
591
|
+
root.destroy()
|
|
592
|
+
except Exception:
|
|
593
|
+
return ""
|
|
594
|
+
|
|
595
|
+
def _do_browse(self):
|
|
596
|
+
import tkinter as tk
|
|
597
|
+
from tkinter import filedialog
|
|
598
|
+
root = tk.Tk()
|
|
599
|
+
root.withdraw()
|
|
600
|
+
root.attributes('-topmost', True)
|
|
601
|
+
try:
|
|
602
|
+
folder = filedialog.askdirectory(title="Select Workspace Folder")
|
|
603
|
+
finally:
|
|
604
|
+
root.destroy()
|
|
605
|
+
if folder:
|
|
606
|
+
inp = self.query_one("#ows-path-input", Input)
|
|
607
|
+
inp.value = folder
|
|
608
|
+
self._validate()
|
|
609
|
+
|
|
610
|
+
def _validate(self):
|
|
611
|
+
hint = self.query_one("#ows-hint", Static)
|
|
612
|
+
raw = self.query_one("#ows-path-input", Input).value.strip()
|
|
613
|
+
if not raw:
|
|
614
|
+
hint.update("")
|
|
615
|
+
self._valid_path = None
|
|
616
|
+
return
|
|
617
|
+
expanded = os.path.abspath(os.path.expanduser(raw))
|
|
618
|
+
if not os.path.exists(expanded):
|
|
619
|
+
hint.update("\u26a0 Path does not exist")
|
|
620
|
+
self._valid_path = None
|
|
621
|
+
return
|
|
622
|
+
if not os.path.isdir(expanded):
|
|
623
|
+
hint.update("\u26a0 Not a directory")
|
|
624
|
+
self._valid_path = None
|
|
625
|
+
return
|
|
626
|
+
if not os.access(expanded, os.R_OK):
|
|
627
|
+
hint.update("\u26a0 No read permission")
|
|
628
|
+
self._valid_path = None
|
|
629
|
+
return
|
|
630
|
+
name = os.path.basename(expanded) or expanded
|
|
631
|
+
hint.update(f"\u2713 {name} \u2014 folder is valid")
|
|
632
|
+
self._valid_path = expanded
|
|
633
|
+
|
|
634
|
+
def _do_open(self):
|
|
635
|
+
self._validate()
|
|
636
|
+
if self._valid_path:
|
|
637
|
+
self.dismiss(self._valid_path)
|
|
638
|
+
else:
|
|
639
|
+
hint = self.query_one("#ows-hint", Static)
|
|
640
|
+
hint.update("\u26a0 Please enter a valid folder path")
|
|
641
|
+
|
|
642
|
+
class WorkspaceHeader(Vertical):
|
|
643
|
+
"""v0.7.7 Project Header (spec section 9): the top strip of the
|
|
644
|
+
Folder Panel, above the file tree, showing the workspace name,
|
|
645
|
+
git branch, indexed languages/files, and session memory/context
|
|
646
|
+
— the facts the footer and permission pill don't already show
|
|
647
|
+
(model/mode/permission stay in their existing single widgets;
|
|
648
|
+
nothing here duplicates them).
|
|
649
|
+
|
|
650
|
+
Rendered by CCTApp: `set_workspace(path)` for the identity row,
|
|
651
|
+
`set_context(ctx)` with the workspace_index result for the
|
|
652
|
+
detail row. Textual markup only — no nested widgets beyond the
|
|
653
|
+
two Static lines.
|
|
654
|
+
|
|
655
|
+
Deliberately class-only (no fixed widget ids) — the Explorer
|
|
656
|
+
re-creates this widget whenever a folder opens, and Textual's
|
|
657
|
+
remove() lands through the message pump (not instantly), so a
|
|
658
|
+
fixed id would collide with the previous instance's pending
|
|
659
|
+
removal (the exact DuplicateIds race documented in
|
|
660
|
+
Explorer._render_projects). Classes tolerate the brief overlap.
|
|
661
|
+
"""
|
|
662
|
+
|
|
663
|
+
def __init__(self):
|
|
664
|
+
super().__init__(classes="cct-workspace-header")
|
|
665
|
+
self._path = None
|
|
666
|
+
self._ctx = None
|
|
667
|
+
|
|
668
|
+
def compose(self):
|
|
669
|
+
yield Static("\U0001f4c2 No workspace open", classes="cct-wshead-name")
|
|
670
|
+
yield Static("", classes="cct-wshead-detail")
|
|
671
|
+
|
|
672
|
+
def set_workspace(self, path):
|
|
673
|
+
self._path = path
|
|
674
|
+
self._rerender()
|
|
675
|
+
|
|
676
|
+
def set_context(self, ctx):
|
|
677
|
+
self._ctx = ctx
|
|
678
|
+
self._rerender()
|
|
679
|
+
|
|
680
|
+
def _rerender(self):
|
|
681
|
+
try:
|
|
682
|
+
if self._path:
|
|
683
|
+
name = os.path.basename(self._path.rstrip(os.sep)) or self._path
|
|
684
|
+
branch = None
|
|
685
|
+
if self._ctx:
|
|
686
|
+
branch = (self._ctx.get("git") or {}).get("branch")
|
|
687
|
+
suffix = f" \u00b7 \U0001f33f {branch}" if branch else ""
|
|
688
|
+
self.query_one(".cct-wshead-name", Static).update(
|
|
689
|
+
f"[bold]\U0001f4c1 {name}[/bold]{suffix}")
|
|
690
|
+
else:
|
|
691
|
+
self.query_one(".cct-wshead-name", Static).update(
|
|
692
|
+
"\U0001f4c2 No workspace open")
|
|
693
|
+
detail = self._detail_line()
|
|
694
|
+
self.query_one(".cct-wshead-detail", Static).update(detail)
|
|
695
|
+
except Exception:
|
|
696
|
+
pass
|
|
697
|
+
|
|
698
|
+
def _detail_line(self):
|
|
699
|
+
if not self._path:
|
|
700
|
+
return "[dim]Open a folder to index it for the AI.[/dim]"
|
|
701
|
+
from .. import workspace_index
|
|
702
|
+
ctx = self._ctx
|
|
703
|
+
if not ctx or not ctx.get("ok"):
|
|
704
|
+
return "[dim]\U0001f504 indexing workspace\u2026[/dim]"
|
|
705
|
+
bits = []
|
|
706
|
+
if ctx["languages"]:
|
|
707
|
+
bits.append(", ".join(ctx["languages"][:3]))
|
|
708
|
+
bits.append(f"{ctx['file_count']} files")
|
|
709
|
+
if ctx["dependencies"]:
|
|
710
|
+
bits.append(f"{len(ctx['dependencies'])} deps")
|
|
711
|
+
if ctx["package_managers"]:
|
|
712
|
+
bits.append("/".join(ctx["package_managers"][:3]))
|
|
713
|
+
line = " \u00b7 ".join(bits)
|
|
714
|
+
try:
|
|
715
|
+
from .. import memory as _mem
|
|
716
|
+
mem = _mem.stats()
|
|
717
|
+
if isinstance(mem, dict) and mem.get("turns") is not None:
|
|
718
|
+
line += f" \u00b7 \U0001f4be {mem['turns']} mem"
|
|
719
|
+
except Exception:
|
|
720
|
+
pass
|
|
721
|
+
try:
|
|
722
|
+
from .. import aicore as _ai
|
|
723
|
+
usage = _ai.get_session_usage()
|
|
724
|
+
if isinstance(usage, dict) and usage.get("total_tokens"):
|
|
725
|
+
window = usage.get("context_window") or 128000
|
|
726
|
+
pct = int(100 * min(1.0, usage["total_tokens"] / max(1, window)))
|
|
727
|
+
line += f" \u00b7 \u2699 ctx {pct}%"
|
|
728
|
+
except Exception:
|
|
729
|
+
pass
|
|
730
|
+
return f"[dim]{line}[/dim]"
|
|
731
|
+
|
|
732
|
+
class _CollapsedTreeRow(Static):
|
|
733
|
+
"""The multi-workspace 'collapsed' state (spec section 4): when
|
|
734
|
+
a workspace's tree was collapsed, opening it shows this row
|
|
735
|
+
instead of mounting the DirectoryTree. Clicking expands the
|
|
736
|
+
tree in place and clears the collapsed flag."""
|
|
737
|
+
|
|
738
|
+
def __init__(self, path, on_expand):
|
|
739
|
+
self._path = path
|
|
740
|
+
self._on_expand = on_expand
|
|
741
|
+
super().__init__(" \u25b8 (tree collapsed \u2014 click to expand)",
|
|
742
|
+
classes="cct-sidebar-collapsed-row")
|
|
743
|
+
|
|
744
|
+
def on_click(self, event):
|
|
745
|
+
if self._on_expand:
|
|
746
|
+
self._on_expand(self._path)
|
|
747
|
+
|
|
748
|
+
class Explorer(Vertical):
|
|
749
|
+
"""The Explorer sidebar (spec v0.7 Sidebar section). Sections,
|
|
750
|
+
top to bottom: Open Folder button, the open folder's file tree
|
|
751
|
+
(once one is open), Pinned Projects, Recent Projects, and a
|
|
752
|
+
Drag & Drop hint area.
|
|
753
|
+
|
|
754
|
+
`width` is a reactive so `set_width()`/collapse can animate via
|
|
755
|
+
CSS transition (see _COMPONENT_CSS's `transition: width`) rather
|
|
756
|
+
than snapping instantly.
|
|
757
|
+
"""
|
|
758
|
+
|
|
759
|
+
width = reactive(DEFAULT_WIDTH)
|
|
760
|
+
|
|
761
|
+
def __init__(self, id="cct-sidebar"):
|
|
762
|
+
super().__init__(id=id)
|
|
763
|
+
self._workspace_root = None
|
|
764
|
+
self._projects_container = None
|
|
765
|
+
self._header = None
|
|
766
|
+
self._tree_mounted = False
|
|
767
|
+
# Gesture tracking for file triple-click / long-press
|
|
768
|
+
self._last_file_click_time = 0
|
|
769
|
+
self._last_file_click_path = None
|
|
770
|
+
self._file_click_count = 0
|
|
771
|
+
|
|
772
|
+
def compose(self):
|
|
773
|
+
# Title row carries a dedicated collapse/expand toggle
|
|
774
|
+
# (spec v0.7.4: the File Explorer behaves like a real IDE
|
|
775
|
+
# sidebar — visible chevron, open/close at any time). The
|
|
776
|
+
# chevron flips ▼/▶ with the panel's width state.
|
|
777
|
+
with Horizontal(id="cct-sidebar-titlebar"):
|
|
778
|
+
yield Static("[b]Explorer[/b]", id="cct-sidebar-title")
|
|
779
|
+
yield Button("\u25c2", id="cct-sidebar-collapse-btn",
|
|
780
|
+
classes="cct-ctrl cct-sidebar-collapse-btn", tooltip="Collapse Sidebar (Ctrl+B)")
|
|
781
|
+
yield Button("📁 Open Folder", id="cct-open-folder-btn", classes="cct-sidebar-open-btn")
|
|
782
|
+
with ScrollableContainer(id="cct-sidebar-body"):
|
|
783
|
+
yield Static("No folder open.", id="cct-sidebar-empty")
|
|
784
|
+
|
|
785
|
+
def on_mount(self):
|
|
786
|
+
self._render_projects()
|
|
787
|
+
|
|
788
|
+
def _show_explorer_menu(self):
|
|
789
|
+
items = [
|
|
790
|
+
("open_folder", "📁 Open Folder..."),
|
|
791
|
+
("refresh", "🔄 Refresh Explorer"),
|
|
792
|
+
("collapse_all", "📂 Collapse Folders"),
|
|
793
|
+
]
|
|
794
|
+
if self._workspace_root:
|
|
795
|
+
items.append(("close_workspace", "✕ Close Workspace"))
|
|
796
|
+
try:
|
|
797
|
+
from .context_menu import MessageContextMenu
|
|
798
|
+
def on_dismiss(action):
|
|
799
|
+
if action == "open_folder":
|
|
800
|
+
self.post_message(FileOpenRequested("__open_folder_prompt__"))
|
|
801
|
+
elif action == "refresh":
|
|
802
|
+
self.refresh_tree()
|
|
803
|
+
self._render_projects()
|
|
804
|
+
elif action == "collapse_all":
|
|
805
|
+
if self._tree_mounted and hasattr(self, "_tree") and self._tree:
|
|
806
|
+
try:
|
|
807
|
+
self._tree.root.collapse_all()
|
|
808
|
+
except Exception:
|
|
809
|
+
pass
|
|
810
|
+
elif action == "close_workspace":
|
|
811
|
+
try:
|
|
812
|
+
self.close_workspace()
|
|
813
|
+
except Exception:
|
|
814
|
+
pass
|
|
815
|
+
|
|
816
|
+
menu = MessageContextMenu(items, origin=(2, 4))
|
|
817
|
+
self.app.push_screen(menu, callback=on_dismiss)
|
|
818
|
+
except Exception:
|
|
819
|
+
self.refresh_tree()
|
|
820
|
+
self._render_projects()
|
|
821
|
+
|
|
822
|
+
def close_workspace(self):
|
|
823
|
+
"""Closes the current open workspace folder and restores
|
|
824
|
+
the sidebar to its default empty state with recent projects."""
|
|
825
|
+
self._workspace_root = None
|
|
826
|
+
self._tree_mounted = False
|
|
827
|
+
old = self._header
|
|
828
|
+
self._header = None
|
|
829
|
+
if old is not None:
|
|
830
|
+
try:
|
|
831
|
+
old.remove()
|
|
832
|
+
except Exception:
|
|
833
|
+
pass
|
|
834
|
+
try:
|
|
835
|
+
body = self.query_one("#cct-sidebar-body", ScrollableContainer)
|
|
836
|
+
for child in list(body.children):
|
|
837
|
+
child.remove()
|
|
838
|
+
body.mount(Static("No folder open.", id="cct-sidebar-empty"))
|
|
839
|
+
self._render_projects(body)
|
|
840
|
+
except Exception:
|
|
841
|
+
pass
|
|
842
|
+
try:
|
|
843
|
+
shell = self.app.query_one("#cct-workspace")
|
|
844
|
+
if shell:
|
|
845
|
+
shell.workspace_root = None
|
|
846
|
+
shell.sync_resizer()
|
|
847
|
+
shell._relayout()
|
|
848
|
+
except Exception:
|
|
849
|
+
pass
|
|
850
|
+
try:
|
|
851
|
+
from .. import workspace as ws_paths
|
|
852
|
+
old_ws = getattr(self.app, "_workspace_root", None)
|
|
853
|
+
ws_paths.set_active_project(None)
|
|
854
|
+
if hasattr(self.app, "_handle_workspace_chat_switch"):
|
|
855
|
+
self.app._handle_workspace_chat_switch(old_ws, None)
|
|
856
|
+
if hasattr(self.app, "_workspace_root"):
|
|
857
|
+
self.app._workspace_root = None
|
|
858
|
+
if hasattr(self.app, "_current_workspace"):
|
|
859
|
+
self.app._current_workspace = None
|
|
860
|
+
if hasattr(self.app, "_refresh_header_breadcrumb"):
|
|
861
|
+
self.app._refresh_header_breadcrumb()
|
|
862
|
+
if hasattr(self.app, "_refresh_header_right"):
|
|
863
|
+
self.app._refresh_header_right()
|
|
864
|
+
except Exception:
|
|
865
|
+
pass
|
|
866
|
+
try:
|
|
867
|
+
self.post_message(WorkspaceChanged(None))
|
|
868
|
+
except Exception:
|
|
869
|
+
pass
|
|
870
|
+
|
|
871
|
+
def on_button_pressed(self, event):
|
|
872
|
+
if event.button.id == "cct-open-folder-btn":
|
|
873
|
+
self.post_message(FileOpenRequested("__open_folder_prompt__"))
|
|
874
|
+
elif event.button.id == "cct-sidebar-collapse-btn":
|
|
875
|
+
self.toggle()
|
|
876
|
+
self.post_message(SidebarToggled(self.width > 0))
|
|
877
|
+
elif event.button.id == "cct-sidebar-menu-btn":
|
|
878
|
+
self._show_explorer_menu()
|
|
879
|
+
|
|
880
|
+
# ---------------------------------------------------- workspace --
|
|
881
|
+
def open_folder(self, path):
|
|
882
|
+
"""Mounts a real DirectoryTree rooted at `path`, replacing
|
|
883
|
+
whatever the sidebar body currently shows. Also mounts the
|
|
884
|
+
v0.7.7 Project Header (spec section 9) above the tree, and
|
|
885
|
+
honors the workspace's remembered collapse state (spec
|
|
886
|
+
section 4) — a collapsed workspace shows a single expand
|
|
887
|
+
row instead of the tree."""
|
|
888
|
+
path = os.path.abspath(os.path.expanduser(path))
|
|
889
|
+
if not os.path.isdir(path):
|
|
890
|
+
return False
|
|
891
|
+
if path == self._workspace_root and self._tree_mounted:
|
|
892
|
+
return True
|
|
893
|
+
self._workspace_root = path
|
|
894
|
+
projects.record_opened(path)
|
|
895
|
+
try:
|
|
896
|
+
body = self.query_one("#cct-sidebar-body", ScrollableContainer)
|
|
897
|
+
except Exception:
|
|
898
|
+
return True
|
|
899
|
+
for child in list(body.children):
|
|
900
|
+
child.remove()
|
|
901
|
+
self._mount_header(path, body)
|
|
902
|
+
if projects.is_collapsed(path):
|
|
903
|
+
body.mount(_CollapsedTreeRow(path, self._expand_collapsed))
|
|
904
|
+
self._tree_mounted = False
|
|
905
|
+
else:
|
|
906
|
+
body.mount(DirectoryTree(path, classes="cct-filetree"))
|
|
907
|
+
self._tree_mounted = True
|
|
908
|
+
self._render_projects(body)
|
|
909
|
+
return True
|
|
910
|
+
|
|
911
|
+
def _mount_header(self, path, body):
|
|
912
|
+
"""(Re)creates the Project Header. Same object-reference
|
|
913
|
+
pattern as _render_projects: the old instance's remove()
|
|
914
|
+
is scheduled through the message pump, so the new header is
|
|
915
|
+
class-only (no fixed id) to survive the brief overlap."""
|
|
916
|
+
old = self._header
|
|
917
|
+
self._header = None
|
|
918
|
+
if old is not None:
|
|
919
|
+
try:
|
|
920
|
+
old.remove()
|
|
921
|
+
except Exception:
|
|
922
|
+
pass
|
|
923
|
+
header = WorkspaceHeader()
|
|
924
|
+
header.set_workspace(path)
|
|
925
|
+
self._header = header
|
|
926
|
+
body.mount(header)
|
|
927
|
+
|
|
928
|
+
def _expand_collapsed(self, path):
|
|
929
|
+
"""Clicking the collapsed-tree row: clear the flag, remount
|
|
930
|
+
the real tree in place."""
|
|
931
|
+
projects.set_collapsed(path, False)
|
|
932
|
+
self._tree_mounted = False
|
|
933
|
+
self.open_folder(path)
|
|
934
|
+
|
|
935
|
+
def toggle_collapsed(self, path=None):
|
|
936
|
+
"""Collapses/expands the current workspace's tree (spec
|
|
937
|
+
section 4's per-workspace collapse). The state is persisted
|
|
938
|
+
in the workspace history store, so re-opening this folder
|
|
939
|
+
later respects it."""
|
|
940
|
+
path = path or self._workspace_root
|
|
941
|
+
if not path:
|
|
942
|
+
return
|
|
943
|
+
body = self.query_one("#cct-sidebar-body", ScrollableContainer)
|
|
944
|
+
if self._tree_mounted:
|
|
945
|
+
projects.set_collapsed(path, True)
|
|
946
|
+
for child in list(body.children):
|
|
947
|
+
child.remove()
|
|
948
|
+
self._mount_header(path, body)
|
|
949
|
+
body.mount(_CollapsedTreeRow(path, self._expand_collapsed))
|
|
950
|
+
self._tree_mounted = False
|
|
951
|
+
self._render_projects(body)
|
|
952
|
+
else:
|
|
953
|
+
projects.set_collapsed(path, False)
|
|
954
|
+
self._tree_mounted = False
|
|
955
|
+
self.open_folder(path)
|
|
956
|
+
|
|
957
|
+
def set_workspace_header(self, ctx):
|
|
958
|
+
"""v0.7.7: CCTApp hands the workspace_index result here
|
|
959
|
+
once indexing finishes; the Project Header's detail line
|
|
960
|
+
re-renders with real languages/files/deps/git data."""
|
|
961
|
+
if self._header is not None:
|
|
962
|
+
self._header.set_context(ctx)
|
|
963
|
+
|
|
964
|
+
def refresh_tree(self, changed_paths=None):
|
|
965
|
+
"""Live-view refresh (v0.7.9.0): keeps the mounted
|
|
966
|
+
DirectoryTree in sync with the real filesystem. Called by
|
|
967
|
+
the fs-watcher bridge, explicit agent-tool notifications,
|
|
968
|
+
and post-turn diffs.
|
|
969
|
+
|
|
970
|
+
Uses DirectoryTree.reload(), whose own subtree-reload
|
|
971
|
+
machinery preserves every expanded folder and the
|
|
972
|
+
highlighted node, and — importantly — safely discards any
|
|
973
|
+
in-flight internal directory-load workers before rescanning
|
|
974
|
+
(a bare node-level reload can race them and repaint stale
|
|
975
|
+
contents). `changed_paths` is accepted for API symmetry;
|
|
976
|
+
the Editor pane's open-file reload is handled by
|
|
977
|
+
WorkspaceShell.refresh_explorer. No-op if no folder open."""
|
|
978
|
+
del changed_paths # editor refresh handled by the shell
|
|
979
|
+
if self._workspace_root is None:
|
|
980
|
+
return
|
|
981
|
+
try:
|
|
982
|
+
body = self.query_one("#cct-sidebar-body", ScrollableContainer)
|
|
983
|
+
trees = body.query(DirectoryTree)
|
|
984
|
+
if trees:
|
|
985
|
+
trees.first().reload()
|
|
986
|
+
except Exception:
|
|
987
|
+
pass
|
|
988
|
+
|
|
989
|
+
@staticmethod
|
|
990
|
+
def _affected_dirs(changed_paths):
|
|
991
|
+
dirs = set()
|
|
992
|
+
for p in changed_paths or []:
|
|
993
|
+
p = os.path.abspath(str(p))
|
|
994
|
+
dirs.add(p)
|
|
995
|
+
parent = os.path.dirname(p)
|
|
996
|
+
if parent:
|
|
997
|
+
dirs.add(parent)
|
|
998
|
+
return dirs
|
|
999
|
+
|
|
1000
|
+
@staticmethod
|
|
1001
|
+
def _walk_loaded_nodes(tree):
|
|
1002
|
+
"""Yields every already-loaded TreeNode (depth-first). Only
|
|
1003
|
+
loaded children are walked — collapsed branches don't need
|
|
1004
|
+
refreshing since their contents are re-read on expand."""
|
|
1005
|
+
stack = [tree.root]
|
|
1006
|
+
seen = 0
|
|
1007
|
+
while stack and seen < 4096:
|
|
1008
|
+
node = stack.pop()
|
|
1009
|
+
seen += 1
|
|
1010
|
+
yield node
|
|
1011
|
+
try:
|
|
1012
|
+
stack.extend(node.children)
|
|
1013
|
+
except Exception:
|
|
1014
|
+
pass
|
|
1015
|
+
|
|
1016
|
+
def close_folder(self):
|
|
1017
|
+
self._workspace_root = None
|
|
1018
|
+
self._header = None
|
|
1019
|
+
self._tree_mounted = False
|
|
1020
|
+
body = self.query_one("#cct-sidebar-body", ScrollableContainer)
|
|
1021
|
+
for child in list(body.children):
|
|
1022
|
+
child.remove()
|
|
1023
|
+
body.mount(Static("No folder open.", id="cct-sidebar-empty"))
|
|
1024
|
+
self._render_projects(body)
|
|
1025
|
+
|
|
1026
|
+
def _render_projects(self, body=None):
|
|
1027
|
+
"""(Re)draws Pinned Projects, Recent Projects, the Clear
|
|
1028
|
+
Recent Projects link, and the drag & drop hint, inside a
|
|
1029
|
+
dedicated container (class 'cct-sidebar-projects'). Kept
|
|
1030
|
+
separate from the folder-name header / DirectoryTree that
|
|
1031
|
+
open_folder() mounts directly into `body` (both used to
|
|
1032
|
+
share the plain 'cct-sidebar-section' class, which meant
|
|
1033
|
+
there was no way to redraw just this block without also
|
|
1034
|
+
tearing down whichever folder is currently open) — this is
|
|
1035
|
+
what lets Clear Recent Projects call this again in place
|
|
1036
|
+
without touching the open project.
|
|
1037
|
+
|
|
1038
|
+
The container is tracked by direct object reference
|
|
1039
|
+
(self._projects_container), NOT a fixed widget `id` re-used
|
|
1040
|
+
on every redraw. Widget.remove() in Textual is scheduled
|
|
1041
|
+
through the message pump, not instant — mounting a new
|
|
1042
|
+
widget with the same fixed id immediately after calling
|
|
1043
|
+
.remove() on the old one (as this used to do) can race
|
|
1044
|
+
that scheduled removal and raise DuplicateIds, which is
|
|
1045
|
+
exactly the crash this fixes: open_folder() clears `body`
|
|
1046
|
+
(scheduling removal of the previous container along with
|
|
1047
|
+
everything else) and then calls this method again in the
|
|
1048
|
+
same synchronous call stack, before that removal has
|
|
1049
|
+
actually landed."""
|
|
1050
|
+
body = body or self.query_one("#cct-sidebar-body", ScrollableContainer)
|
|
1051
|
+
old = self._projects_container
|
|
1052
|
+
self._projects_container = None
|
|
1053
|
+
if old is not None:
|
|
1054
|
+
try:
|
|
1055
|
+
old.remove()
|
|
1056
|
+
except Exception:
|
|
1057
|
+
# Already gone (e.g. open_folder()/close_folder()
|
|
1058
|
+
# already wiped `body`, including this widget, right
|
|
1059
|
+
# before calling us) — nothing left to clean up.
|
|
1060
|
+
pass
|
|
1061
|
+
container = Vertical(classes="cct-sidebar-projects")
|
|
1062
|
+
self._projects_container = container
|
|
1063
|
+
body.mount(container)
|
|
1064
|
+
# v0.7.7 multi-workspace (spec section 4): one strip of every
|
|
1065
|
+
# known workspace (pinned + favorite + recent, newest first).
|
|
1066
|
+
# Clicking a row switches the Explorer to that folder in
|
|
1067
|
+
# place — the Folder Panel never closes when you browse
|
|
1068
|
+
# multiple workspaces (spec section 3).
|
|
1069
|
+
seen_paths = set()
|
|
1070
|
+
workspaces = projects.workspaces()[:6]
|
|
1071
|
+
if workspaces:
|
|
1072
|
+
container.mount(Static("[b]Workspaces[/b]",
|
|
1073
|
+
classes="cct-sidebar-project-header"))
|
|
1074
|
+
for w in workspaces:
|
|
1075
|
+
p = w["path"]
|
|
1076
|
+
seen_paths.add(os.path.abspath(p))
|
|
1077
|
+
marks = ("\U0001f4cc" if w.get("pinned") else "") \
|
|
1078
|
+
+ ("\u2b50" if w.get("favorite") else "")
|
|
1079
|
+
prefix = (marks + " ") if marks else "\U0001f4c2"
|
|
1080
|
+
container.mount(_ProjectRow(
|
|
1081
|
+
p, prefix=prefix,
|
|
1082
|
+
on_delete=lambda path=p: self._remove_workspace(path)))
|
|
1083
|
+
pinned = [p for p in projects.pinned() if os.path.abspath(p) not in seen_paths]
|
|
1084
|
+
for p in pinned:
|
|
1085
|
+
seen_paths.add(os.path.abspath(p))
|
|
1086
|
+
recent = [p for p in projects.recent() if os.path.abspath(p) not in seen_paths][:6]
|
|
1087
|
+
if pinned:
|
|
1088
|
+
container.mount(Static("[b]Pinned Workspaces[/b]", classes="cct-sidebar-project-header"))
|
|
1089
|
+
for p in pinned:
|
|
1090
|
+
container.mount(_ProjectRow(p))
|
|
1091
|
+
if recent:
|
|
1092
|
+
container.mount(Static("[b]Recent Workspaces[/b]", classes="cct-sidebar-project-header"))
|
|
1093
|
+
for p in recent:
|
|
1094
|
+
# Per-project delete (v0.7.6 Patch 1): the ✕ on a
|
|
1095
|
+
# recent row removes ONLY that entry from the store,
|
|
1096
|
+
# then this block re-renders itself in place — same
|
|
1097
|
+
# in-place pattern _ClearRecentRow already uses, so
|
|
1098
|
+
# it never touches the open folder or 'pinned'.
|
|
1099
|
+
container.mount(_ProjectRow(
|
|
1100
|
+
p, on_delete=lambda path=p: self._delete_recent(path)))
|
|
1101
|
+
container.mount(_ClearRecentRow(self._render_projects))
|
|
1102
|
+
if not self._workspace_root:
|
|
1103
|
+
container.mount(Static(
|
|
1104
|
+
"[dim]Drag & drop files here, or into Chat/Editor,\n"
|
|
1105
|
+
"to attach them for the AI.[/dim]", classes="cct-sidebar-dropzone"))
|
|
1106
|
+
|
|
1107
|
+
def _remove_workspace(self, path):
|
|
1108
|
+
"""Removes a workspace from the history strip (✕ on a
|
|
1109
|
+
Workspaces row) — history only, never files on disk, and
|
|
1110
|
+
never closes the panel."""
|
|
1111
|
+
projects.remove(path)
|
|
1112
|
+
self._render_projects()
|
|
1113
|
+
|
|
1114
|
+
def _delete_recent(self, path):
|
|
1115
|
+
projects.remove_recent(path)
|
|
1116
|
+
self._render_projects()
|
|
1117
|
+
|
|
1118
|
+
# ------------------------------------------------------ open tree --
|
|
1119
|
+
def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected):
|
|
1120
|
+
# Gesture detection: triple-click file → quick actions
|
|
1121
|
+
import time as _t
|
|
1122
|
+
path = str(event.path)
|
|
1123
|
+
now = _t.time()
|
|
1124
|
+
if path == self._last_file_click_path and now - self._last_file_click_time < 0.4:
|
|
1125
|
+
self._file_click_count += 1
|
|
1126
|
+
else:
|
|
1127
|
+
self._file_click_count = 1
|
|
1128
|
+
self._last_file_click_time = now
|
|
1129
|
+
self._last_file_click_path = path
|
|
1130
|
+
if self._file_click_count == 3:
|
|
1131
|
+
self._file_click_count = 0
|
|
1132
|
+
try:
|
|
1133
|
+
from ..gestures.manager import handle_gesture
|
|
1134
|
+
ctx = {"path": path}
|
|
1135
|
+
if handle_gesture("triple_click", "file", app=getattr(self, "app", None), context=ctx):
|
|
1136
|
+
return
|
|
1137
|
+
except Exception:
|
|
1138
|
+
pass
|
|
1139
|
+
# Fallback quick actions note
|
|
1140
|
+
try:
|
|
1141
|
+
self.app._system_note(f"Quick actions for: {path}")
|
|
1142
|
+
except Exception:
|
|
1143
|
+
pass
|
|
1144
|
+
return
|
|
1145
|
+
if self._file_click_count == 2:
|
|
1146
|
+
# Double-click file: check for reveal gesture (long_press fallback)
|
|
1147
|
+
try:
|
|
1148
|
+
from ..gestures.manager import handle_gesture
|
|
1149
|
+
ctx = {"path": path}
|
|
1150
|
+
# Try long_press as reveal
|
|
1151
|
+
if handle_gesture("long_press", "file", app=getattr(self, "app", None), context=ctx):
|
|
1152
|
+
self._file_click_count = 0
|
|
1153
|
+
return
|
|
1154
|
+
except Exception:
|
|
1155
|
+
pass
|
|
1156
|
+
self.post_message(FileOpenRequested(path))
|
|
1157
|
+
|
|
1158
|
+
def on_click(self, event):
|
|
1159
|
+
"""Gesture on file → reveal actions (spec 23): long-press fallback via right-click."""
|
|
1160
|
+
try:
|
|
1161
|
+
# Right-click (button 3) on a file row triggers reveal gesture
|
|
1162
|
+
if getattr(event, "button", 1) == 3:
|
|
1163
|
+
widget = getattr(event, "widget", None)
|
|
1164
|
+
path = getattr(widget, "path", None) or getattr(self, "_last_file_click_path", "")
|
|
1165
|
+
if path:
|
|
1166
|
+
try:
|
|
1167
|
+
from ..gestures.manager import handle_gesture
|
|
1168
|
+
if handle_gesture("long_press", "file", app=getattr(self, "app", None), context={"path": path}):
|
|
1169
|
+
event.stop()
|
|
1170
|
+
return
|
|
1171
|
+
except Exception:
|
|
1172
|
+
pass
|
|
1173
|
+
# Fallback to file context menu
|
|
1174
|
+
origin = getattr(event, "screen_offset", None)
|
|
1175
|
+
origin_xy = (origin.x, origin.y) if origin else (event.x, event.y)
|
|
1176
|
+
self._show_touch_context_menu(path, origin_xy)
|
|
1177
|
+
except Exception:
|
|
1178
|
+
pass
|
|
1179
|
+
|
|
1180
|
+
def on_touch_long_press(self, widget, screen_pos):
|
|
1181
|
+
"""Touch long-press on file sidebar opens context actions."""
|
|
1182
|
+
path = getattr(widget, "path", None) or getattr(self, "_last_file_click_path", "")
|
|
1183
|
+
if not path:
|
|
1184
|
+
try:
|
|
1185
|
+
tree = self.query_one(DirectoryTree)
|
|
1186
|
+
if tree.cursor_node and getattr(tree.cursor_node, "data", None):
|
|
1187
|
+
path = str(tree.cursor_node.data.path)
|
|
1188
|
+
except Exception:
|
|
1189
|
+
pass
|
|
1190
|
+
path = path or self._workspace_root or ""
|
|
1191
|
+
if path:
|
|
1192
|
+
self._show_touch_context_menu(path, screen_pos)
|
|
1193
|
+
|
|
1194
|
+
def _show_touch_context_menu(self, path, origin):
|
|
1195
|
+
try:
|
|
1196
|
+
from .context_menu import MessageContextMenu, FILE_MENU_ITEMS
|
|
1197
|
+
def _on_result(action):
|
|
1198
|
+
if not action:
|
|
1199
|
+
return
|
|
1200
|
+
self._handle_file_action(action, path)
|
|
1201
|
+
self.app.push_screen(MessageContextMenu(FILE_MENU_ITEMS, origin=origin), _on_result)
|
|
1202
|
+
except Exception:
|
|
1203
|
+
pass
|
|
1204
|
+
|
|
1205
|
+
def _handle_file_action(self, action, path):
|
|
1206
|
+
if action == "open":
|
|
1207
|
+
if os.path.isfile(path):
|
|
1208
|
+
self.post_message(FileOpenRequested(path))
|
|
1209
|
+
elif os.path.isdir(path):
|
|
1210
|
+
self.open_folder(path)
|
|
1211
|
+
elif action == "copy_path":
|
|
1212
|
+
try:
|
|
1213
|
+
from .. import code_editor
|
|
1214
|
+
code_editor.copy_to_clipboard(path)
|
|
1215
|
+
self.app._system_note(f"Copied: {path}")
|
|
1216
|
+
except Exception:
|
|
1217
|
+
pass
|
|
1218
|
+
elif action == "delete":
|
|
1219
|
+
try:
|
|
1220
|
+
if os.path.isfile(path):
|
|
1221
|
+
os.remove(path)
|
|
1222
|
+
self.refresh_tree()
|
|
1223
|
+
self.app._system_note(f"Deleted: {os.path.basename(path)}")
|
|
1224
|
+
except Exception as e:
|
|
1225
|
+
self.app._system_note(f"Delete failed: {e}")
|
|
1226
|
+
elif action == "rename":
|
|
1227
|
+
self.app._system_note(f"Selected for rename: {os.path.basename(path)}")
|
|
1228
|
+
elif action in ("new_file", "new_folder"):
|
|
1229
|
+
target_dir = path if os.path.isdir(path) else os.path.dirname(path)
|
|
1230
|
+
self.app._system_note(f"Create in: {target_dir}")
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
# -------------------------------------------------- collapse/resize --
|
|
1234
|
+
def set_width(self, value):
|
|
1235
|
+
previous = self.width
|
|
1236
|
+
self.width = max(COLLAPSED_WIDTH, value)
|
|
1237
|
+
self.styles.width = self.width
|
|
1238
|
+
# At 0 width the panel would still paint its 1px right
|
|
1239
|
+
# border, so the collapsed class drops it — the sidebar
|
|
1240
|
+
# fully disappears instead of leaving a visible sliver.
|
|
1241
|
+
self.set_class(self.width == 0, "cct-sidebar-collapsed")
|
|
1242
|
+
# Keep the title-bar chevron honest: ▼ when open (click
|
|
1243
|
+
# collapses), ▶ when collapsed (click expands again).
|
|
1244
|
+
if self.is_mounted:
|
|
1245
|
+
try:
|
|
1246
|
+
btn = self.query_one("#cct-sidebar-collapse-btn", Button)
|
|
1247
|
+
btn.label = "\u25c2" if self.width > 0 else "\u25b8"
|
|
1248
|
+
except Exception:
|
|
1249
|
+
pass
|
|
1250
|
+
# Announce visibility flips once, so the shell can keep
|
|
1251
|
+
# its resize divider in lockstep (and CCTApp persists
|
|
1252
|
+
# the state). Crossing detection avoids double-posting
|
|
1253
|
+
# on every drag frame.
|
|
1254
|
+
if (previous > 0) != (self.width > 0):
|
|
1255
|
+
self.post_message(SidebarToggled(self.width > 0))
|
|
1256
|
+
|
|
1257
|
+
def _tree_mounted_or_root(self):
|
|
1258
|
+
return self._workspace_root is not None or self._tree_mounted
|
|
1259
|
+
|
|
1260
|
+
def toggle(self):
|
|
1261
|
+
if self.width > 0:
|
|
1262
|
+
self._restore_width = self.width
|
|
1263
|
+
self.set_width(COLLAPSED_WIDTH)
|
|
1264
|
+
else:
|
|
1265
|
+
self.set_width(getattr(self, "_restore_width", DEFAULT_WIDTH))
|
|
1266
|
+
|
|
1267
|
+
def focus_tree(self):
|
|
1268
|
+
"""Give the open folder's DirectoryTree keyboard focus after
|
|
1269
|
+
the sidebar is reopened (spec v0.7.4: "focus or reopen the
|
|
1270
|
+
existing explorer"), so it feels like a focused panel, not a
|
|
1271
|
+
static pane. No-op when no folder is open."""
|
|
1272
|
+
if self._workspace_root is None:
|
|
1273
|
+
return
|
|
1274
|
+
try:
|
|
1275
|
+
body = self.query_one("#cct-sidebar-body", ScrollableContainer)
|
|
1276
|
+
trees = body.query(DirectoryTree)
|
|
1277
|
+
if trees:
|
|
1278
|
+
trees.first().focus()
|
|
1279
|
+
except Exception:
|
|
1280
|
+
pass
|
|
1281
|
+
|
|
1282
|
+
else:
|
|
1283
|
+
Explorer = None
|
|
1284
|
+
RecentWorkspacesScreen = None
|
|
1285
|
+
OpenWorkspaceScreen = None
|