Add Clide project structure and initial implementation
Set up the TUI IDE wrapper for Claude Code CLI with: - Core app structure using Textual framework - Panel architecture (sidebar, workspace, claude, context) - Theme system with 22 built-in themes (Summer Night default) - Pydantic models for configuration and data - Makefile for development commands - Project documentation and specs Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -174,3 +174,10 @@ cython_debug/
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
|
||||
# Clide specific
|
||||
.config/
|
||||
snapshot_report.html
|
||||
|
||||
# Claude Code
|
||||
.claude/
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Clide is a TUI IDE wrapper for Claude Code CLI, designed to be Claude-centric with VSCode-familiar keybindings. See `docs/tui-ide-spec.md` for full specification.
|
||||
|
||||
### Design Principles
|
||||
- **Claude-centric**: Claude Code is the primary workspace, always visible
|
||||
- **Contextual panels**: Editor/Diff/Terminal appear only when needed
|
||||
- **VSCode-familiar**: Keybindings follow VSCode conventions
|
||||
- **Responsive**: Works on 13" laptop to widescreen monitors
|
||||
- **State preservation**: Hiding panels preserves all state (never destroy widgets)
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Component | Library | Version |
|
||||
|-----------|---------|---------|
|
||||
| Runtime | Python | 3.12+ |
|
||||
| TUI Framework | Textual | latest |
|
||||
| CLI | Typer | latest |
|
||||
| Data Validation | Pydantic | v2 (strict mode) |
|
||||
| Settings | pydantic-settings | latest |
|
||||
| Testing | pytest + pytest-asyncio + pytest-textual-snapshot | latest |
|
||||
| Extensions | pluggy | latest |
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
make setup # Create venv, install deps
|
||||
make run # Run application
|
||||
make test # Run all tests
|
||||
make test-single # Run single test (TEST=path::test_name)
|
||||
make typecheck # Run mypy
|
||||
make lint # Run ruff check
|
||||
make format # Run ruff format
|
||||
make build # Build for current platform
|
||||
```
|
||||
|
||||
## Panel Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┬─────────────────────────┬──────────────────┐
|
||||
│ panel-sidebar │ panel-workspace (60%) │ panel-context │
|
||||
│ │ [Editor][Diff][Terminal]│ │
|
||||
│ [Files][Git] │ (hidden when inactive) │ [Problems][TODOs]│
|
||||
│ [Tree] ├─────────────────────────┤ [Jira] │
|
||||
│ │ │ │
|
||||
│ (content area) │ panel-claude │ (content area) │
|
||||
│ │ (40% when workspace │ │
|
||||
│ │ visible, else 100%) │ │
|
||||
├─────────────────┤ ├──────────────────┤
|
||||
│ ⎇ main ▾ │ │ [⚠ 3][✓12][Jira]│
|
||||
└─────────────────┴─────────────────────────┴──────────────────┘
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
clide/
|
||||
├── src/clide/
|
||||
│ ├── __init__.py
|
||||
│ ├── __main__.py
|
||||
│ ├── app.py # Main App, layout, keybindings
|
||||
│ ├── cli.py # Typer entry point
|
||||
│ │
|
||||
│ ├── controllers/ # Domain logic (no UI)
|
||||
│ │ ├── base.py # BaseController ABC
|
||||
│ │ ├── git.py # Git status, staging, branches
|
||||
│ │ ├── editor.py # Open files, cursor state
|
||||
│ │ ├── diff.py # Diff generation, accept/reject
|
||||
│ │ ├── problems.py # Linter aggregation
|
||||
│ │ ├── todos.py # TODO/FIXME scanning
|
||||
│ │ └── jira.py # Jira CLI integration
|
||||
│ │
|
||||
│ ├── widgets/
|
||||
│ │ ├── panels/ # Main layout containers
|
||||
│ │ │ ├── sidebar.py # Left sidebar with tabs
|
||||
│ │ │ ├── workspace.py # Editor/Diff/Terminal tabs
|
||||
│ │ │ ├── claude.py # Claude interaction panel
|
||||
│ │ │ └── context.py # Right context panel
|
||||
│ │ │
|
||||
│ │ └── components/ # Reusable UI pieces
|
||||
│ │ ├── files_view.py # DirectoryTree wrapper
|
||||
│ │ ├── git_changes.py # Staged/unstaged file list
|
||||
│ │ ├── git_graph.py # Branch visualization
|
||||
│ │ ├── branch_status.py # Current branch + popout
|
||||
│ │ ├── editor_pane.py # TextArea with syntax
|
||||
│ │ ├── diff_pane.py # Side-by-side/unified diff
|
||||
│ │ ├── terminal_pane.py # PTY terminal
|
||||
│ │ ├── problems_view.py # Linter errors list
|
||||
│ │ ├── todos_view.py # TODO comments list
|
||||
│ │ └── jira_view.py # Jira output display
|
||||
│ │
|
||||
│ ├── models/ # Pydantic data models
|
||||
│ │ ├── config.py # App settings (ClideSettings)
|
||||
│ │ ├── git.py # GitStatus, GitChange, GitBranch
|
||||
│ │ ├── editor.py # EditorState, FileBuffer
|
||||
│ │ ├── diff.py # DiffContent, DiffHunk
|
||||
│ │ ├── problems.py # Problem, Severity
|
||||
│ │ ├── todos.py # TodoItem, TodoType
|
||||
│ │ └── theme.py # ThemeColors, ThemeDefinition
|
||||
│ │
|
||||
│ ├── services/ # Background task logic
|
||||
│ │ ├── process_service.py # Generic subprocess mgmt
|
||||
│ │ ├── git_service.py # Git command execution
|
||||
│ │ ├── file_service.py # File I/O, language detection
|
||||
│ │ ├── linter_service.py # Run linters, parse output
|
||||
│ │ └── todo_scanner.py # Grep for TODOs
|
||||
│ │
|
||||
│ ├── themes/ # Theme system
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── registry.py # Theme registry, get_theme()
|
||||
│ │ ├── loader.py # Load custom themes from .config
|
||||
│ │ └── builtin/ # 22 built-in themes
|
||||
│ │ ├── __init__.py # Auto-register all themes
|
||||
│ │ ├── summer_night.py # DEFAULT - Summer Night dark
|
||||
│ │ ├── summer_day.py # Summer Day light
|
||||
│ │ ├── one_dark.py # Atom One Dark
|
||||
│ │ ├── dracula.py # Dracula theme
|
||||
│ │ ├── nord.py # Nord color palette
|
||||
│ │ ├── gruvbox_dark.py # Gruvbox dark
|
||||
│ │ ├── gruvbox_light.py # Gruvbox light
|
||||
│ │ └── ... # More themes
|
||||
│ │
|
||||
│ ├── extensions/ # Plugin system
|
||||
│ │ ├── hookspecs.py
|
||||
│ │ ├── manager.py
|
||||
│ │ └── builtin/
|
||||
│ │
|
||||
│ └── helpers/
|
||||
│ ├── async_utils.py
|
||||
│ ├── path_utils.py
|
||||
│ └── terminal_utils.py
|
||||
│
|
||||
├── tests/
|
||||
│ ├── conftest.py
|
||||
│ ├── harnesses/
|
||||
│ │ ├── app_harness.py
|
||||
│ │ └── controller_harness.py
|
||||
│ ├── unit/
|
||||
│ ├── integration/
|
||||
│ └── snapshots/
|
||||
│
|
||||
├── .config/ # User config (gitignored)
|
||||
│ ├── settings.toml # User settings
|
||||
│ └── themes/ # Custom user themes
|
||||
│ └── my-theme.toml
|
||||
├── docs/
|
||||
│ ├── tui-ide-spec.md # Full UI/UX specification
|
||||
│ └── ARCHITECTURE.md # Framework best practices
|
||||
├── pyproject.toml
|
||||
└── Makefile
|
||||
```
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### Panel Visibility (Hide, Don't Destroy)
|
||||
|
||||
```python
|
||||
def toggle_workspace(self, visible: bool) -> None:
|
||||
workspace = self.query_one("#panel-workspace")
|
||||
workspace.display = visible # Preserves all child state
|
||||
|
||||
claude = self.query_one("#panel-claude")
|
||||
claude.styles.height = "40%" if visible else "100%"
|
||||
```
|
||||
|
||||
### Background Tasks
|
||||
|
||||
Use `@work` decorator for non-blocking operations:
|
||||
|
||||
```python
|
||||
@work(thread=True)
|
||||
def refresh_git_status(self) -> None:
|
||||
result = subprocess.run(["git", "status", "--porcelain"], ...)
|
||||
self.call_from_thread(self.update_git_view, result.stdout)
|
||||
```
|
||||
|
||||
### Reactive State
|
||||
|
||||
```python
|
||||
class ClideApp(App):
|
||||
current_file: reactive[str | None] = reactive(None)
|
||||
workspace_visible: reactive[bool] = reactive(False)
|
||||
problem_count: reactive[int] = reactive(0)
|
||||
todo_count: reactive[int] = reactive(0)
|
||||
compact_mode: reactive[bool] = reactive(False)
|
||||
```
|
||||
|
||||
### Pydantic Models (Strict + Frozen)
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
class GitChange(BaseModel):
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
path: str
|
||||
status: Literal["added", "modified", "deleted", "untracked", "renamed"]
|
||||
staged: bool
|
||||
```
|
||||
|
||||
### Controller → Widget Communication
|
||||
|
||||
Controllers emit Textual messages; widgets subscribe:
|
||||
|
||||
```python
|
||||
# In controller
|
||||
class GitStatusUpdated(Message):
|
||||
def __init__(self, status: GitStatus) -> None:
|
||||
self.status = status
|
||||
super().__init__()
|
||||
|
||||
self.post_message(GitStatusUpdated(status))
|
||||
|
||||
# In widget
|
||||
def on_git_status_updated(self, event: GitStatusUpdated) -> None:
|
||||
self.refresh_view(event.status)
|
||||
```
|
||||
|
||||
## Theme System
|
||||
|
||||
### Default Theme: Summer Night
|
||||
|
||||
Based on [jackw01/summer-night-vscode-theme](https://github.com/jackw01/summer-night-vscode-theme):
|
||||
|
||||
```python
|
||||
SUMMER_NIGHT = ThemeColors(
|
||||
primary="#00a3d2", # cyan
|
||||
secondary="#00a9b9", # teal
|
||||
accent="#fa5f8b", # pink
|
||||
background="#21262f", # mono_8
|
||||
surface="#393e48", # mono_7
|
||||
panel="#292e38",
|
||||
foreground="#e2e8f5", # mono_1
|
||||
success="#00ab9a", # green
|
||||
warning="#d08447", # orange
|
||||
error="#f06c6f", # red
|
||||
)
|
||||
```
|
||||
|
||||
### Built-in Themes (22 total)
|
||||
|
||||
| Category | Themes |
|
||||
|----------|--------|
|
||||
| Core | summer-night (default), summer-day |
|
||||
| Popular | one-dark, one-dark-pro, one-light, dracula, nord, gruvbox-dark, gruvbox-light |
|
||||
| GitKraken | one-dark-teal, gamma |
|
||||
| Seasonal - Winter | winter-is-coming, monokai-winter |
|
||||
| Seasonal - Fall | fall, dark-autumn |
|
||||
| Seasonal - Halloween | all-hallows-eve, halloween |
|
||||
| Seasonal - Christmas | christmas, santa-baby |
|
||||
| Hacker | pro-hacker, hacker-style |
|
||||
| Bonus | houston |
|
||||
|
||||
### Theme Definition
|
||||
|
||||
```python
|
||||
class ThemeColors(BaseModel):
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
primary: str # Main accent
|
||||
secondary: str # Secondary accent
|
||||
accent: str # Highlight accent
|
||||
background: str # Main background
|
||||
surface: str # Elevated surfaces
|
||||
panel: str # Panel backgrounds
|
||||
foreground: str # Primary text
|
||||
success: str # Success/green
|
||||
warning: str # Warning/yellow
|
||||
error: str # Error/red
|
||||
|
||||
class ThemeDefinition(BaseModel):
|
||||
name: str # Identifier (e.g., "summer-night")
|
||||
display_name: str # Human-readable name
|
||||
dark: bool # Dark or light theme
|
||||
colors: ThemeColors
|
||||
```
|
||||
|
||||
### Custom Themes
|
||||
|
||||
Users can add themes in `.config/themes/`:
|
||||
|
||||
```toml
|
||||
# .config/themes/my-theme.toml
|
||||
name = "my-theme"
|
||||
display_name = "My Custom Theme"
|
||||
dark = true
|
||||
|
||||
[colors]
|
||||
primary = "#007acc"
|
||||
secondary = "#3c3c3c"
|
||||
accent = "#0e639c"
|
||||
background = "#1e1e1e"
|
||||
surface = "#252526"
|
||||
panel = "#2d2d30"
|
||||
foreground = "#d4d4d4"
|
||||
success = "#4ec9b0"
|
||||
warning = "#dcdcaa"
|
||||
error = "#f44747"
|
||||
```
|
||||
|
||||
### Theme Switching
|
||||
|
||||
- Keybinding: `Ctrl+K Ctrl+T`
|
||||
- Settings: `theme = "summer-night"` in ClideSettings
|
||||
- Runtime: `app.theme = "dracula"`
|
||||
|
||||
## Keybindings (VSCode-style)
|
||||
|
||||
| Action | Binding |
|
||||
|--------|---------|
|
||||
| Command palette | `Ctrl+Shift+P` |
|
||||
| Quick open | `Ctrl+P` |
|
||||
| Toggle left sidebar | `Ctrl+B` |
|
||||
| Toggle right sidebar | `Ctrl+Shift+B` |
|
||||
| Toggle terminal | `` Ctrl+` `` |
|
||||
| Focus Claude | `Ctrl+1` |
|
||||
| Focus Editor | `Ctrl+2` |
|
||||
| Focus Terminal | `Ctrl+3` |
|
||||
| Toggle compact mode | `Ctrl+Shift+C` |
|
||||
| Git panel | `Ctrl+Shift+G` |
|
||||
| Problems panel | `Ctrl+Shift+M` |
|
||||
| Select theme | `Ctrl+K Ctrl+T` |
|
||||
|
||||
## Configuration
|
||||
|
||||
Settings are loaded from multiple sources (in priority order):
|
||||
1. Environment variables (`CLIDE_*`)
|
||||
2. `.config/settings.toml`
|
||||
3. Defaults in ClideSettings
|
||||
|
||||
```python
|
||||
class ClideSettings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="CLIDE_",
|
||||
env_file=".env",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
theme: str = "summer-night"
|
||||
jira_enabled: bool = False
|
||||
jira_cli_path: str = "jira"
|
||||
|
||||
panels: PanelConfig = PanelConfig()
|
||||
keybindings: KeybindingsConfig = KeybindingsConfig()
|
||||
```
|
||||
|
||||
## Documentation Links
|
||||
|
||||
### Core Stack
|
||||
- [Textual Documentation](https://textual.textualize.io/)
|
||||
- [Textual Testing Guide](https://textual.textualize.io/guide/testing/)
|
||||
- [Textual Themes Guide](https://textual.textualize.io/guide/design/)
|
||||
- [Typer Documentation](https://typer.tiangolo.com/)
|
||||
- [Pydantic v2 Documentation](https://docs.pydantic.dev/latest/)
|
||||
- [Pydantic Settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/)
|
||||
|
||||
### Testing
|
||||
- [pytest-asyncio](https://pytest-asyncio.readthedocs.io/en/latest/)
|
||||
- [pytest-textual-snapshot](https://github.com/Textualize/pytest-textual-snapshot)
|
||||
|
||||
### Extensions
|
||||
- [Pluggy Documentation](https://pluggy.readthedocs.io/)
|
||||
|
||||
### Build
|
||||
- [PyInstaller Documentation](https://pyinstaller.org/)
|
||||
- [Gitea Actions](https://docs.gitea.com/usage/actions/overview)
|
||||
|
||||
### Theme References
|
||||
- [Summer Night VSCode Theme](https://github.com/jackw01/summer-night-vscode-theme)
|
||||
@@ -1,10 +0,0 @@
|
||||
This is free and unencumbered software released into the public domain.
|
||||
|
||||
Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means.
|
||||
|
||||
In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and
|
||||
successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
For more information, please refer to <http://unlicense.org/>
|
||||
@@ -0,0 +1,95 @@
|
||||
.PHONY: setup run test test-single typecheck lint format build build-all clean help
|
||||
|
||||
PYTHON := python3.12
|
||||
VENV := .venv
|
||||
BIN := $(VENV)/bin
|
||||
TEST ?= tests/
|
||||
|
||||
# Colors for output
|
||||
BLUE := \033[0;34m
|
||||
GREEN := \033[0;32m
|
||||
RESET := \033[0m
|
||||
|
||||
help:
|
||||
@echo "$(BLUE)Clide Development Commands$(RESET)"
|
||||
@echo ""
|
||||
@echo "$(GREEN)setup$(RESET) Create venv and install dependencies"
|
||||
@echo "$(GREEN)run$(RESET) Run the application"
|
||||
@echo "$(GREEN)test$(RESET) Run all tests"
|
||||
@echo "$(GREEN)test-single$(RESET) Run single test (TEST=path::test_name)"
|
||||
@echo "$(GREEN)typecheck$(RESET) Run mypy type checking"
|
||||
@echo "$(GREEN)lint$(RESET) Run ruff linter"
|
||||
@echo "$(GREEN)format$(RESET) Run ruff formatter"
|
||||
@echo "$(GREEN)build$(RESET) Build executable for current platform"
|
||||
@echo "$(GREEN)clean$(RESET) Remove build artifacts and caches"
|
||||
|
||||
setup:
|
||||
@echo "Creating virtual environment..."
|
||||
$(PYTHON) -m venv $(VENV)
|
||||
@echo "Installing dependencies..."
|
||||
$(BIN)/pip install --upgrade pip
|
||||
$(BIN)/pip install -e ".[dev,build]"
|
||||
@echo "Installing pre-commit hooks..."
|
||||
$(BIN)/pre-commit install || true
|
||||
@echo "$(GREEN)Setup complete! Activate with: source $(VENV)/bin/activate$(RESET)"
|
||||
|
||||
run:
|
||||
$(BIN)/python -m clide
|
||||
|
||||
test:
|
||||
$(BIN)/pytest $(TEST)
|
||||
|
||||
test-single:
|
||||
$(BIN)/pytest $(TEST) -v
|
||||
|
||||
test-cov:
|
||||
$(BIN)/pytest --cov=src/clide --cov-report=html --cov-report=term
|
||||
|
||||
test-snapshots:
|
||||
$(BIN)/pytest tests/snapshots/
|
||||
|
||||
test-snapshots-update:
|
||||
$(BIN)/pytest tests/snapshots/ --snapshot-update
|
||||
|
||||
typecheck:
|
||||
$(BIN)/mypy src/
|
||||
|
||||
lint:
|
||||
$(BIN)/ruff check src/ tests/
|
||||
|
||||
format:
|
||||
$(BIN)/ruff format src/ tests/
|
||||
$(BIN)/ruff check --fix src/ tests/
|
||||
|
||||
build:
|
||||
$(BIN)/pyinstaller clide.spec --clean
|
||||
|
||||
build-onefile:
|
||||
$(BIN)/pyinstaller \
|
||||
--name clide \
|
||||
--onefile \
|
||||
--clean \
|
||||
--noconfirm \
|
||||
src/clide/__main__.py
|
||||
|
||||
clean:
|
||||
rm -rf $(VENV)
|
||||
rm -rf dist/ build/
|
||||
rm -rf .pytest_cache/ .mypy_cache/ .ruff_cache/
|
||||
rm -rf htmlcov/ .coverage
|
||||
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type f -name "*.pyc" -delete 2>/dev/null || true
|
||||
|
||||
# CI targets (for GitHub Actions)
|
||||
ci-lint:
|
||||
pip install ruff mypy
|
||||
ruff check src/ tests/
|
||||
mypy src/
|
||||
|
||||
ci-test:
|
||||
pip install -e ".[dev]"
|
||||
pytest --cov=src/clide --cov-report=xml
|
||||
|
||||
ci-build:
|
||||
pip install -e ".[build]"
|
||||
pyinstaller clide.spec --clean
|
||||
@@ -1,3 +1,10 @@
|
||||
# clide
|
||||
|
||||
CLI Claude
|
||||
Clide - a Claude CLI UI
|
||||
|
||||
A TUI cli IDE for claude code cli. Features file navigator/git panel left bar, main panel with integrated terminal running claude, right panel for integration of IMAGIN.studio img cli Atlassian JIRA/CONFLUENCE tooling.
|
||||
|
||||
- Python 3.12 based
|
||||
- Pydantic strict data handling
|
||||
- Typer as cli integration
|
||||
- Textual for TUI
|
||||
@@ -0,0 +1,415 @@
|
||||
# Clide Architecture Documentation
|
||||
|
||||
Comprehensive documentation of architecture patterns, best practices, and implementation guidelines.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Textual TUI Framework](#textual-tui-framework)
|
||||
- [Typer CLI Framework](#typer-cli-framework)
|
||||
- [Pydantic Data Validation](#pydantic-data-validation)
|
||||
- [Extension System](#extension-system)
|
||||
- [Testing Strategy](#testing-strategy)
|
||||
- [Build and Distribution](#build-and-distribution)
|
||||
|
||||
---
|
||||
|
||||
## Textual TUI Framework
|
||||
|
||||
Textual models TUIs as a reactive tree of widgets, similar to React's component tree but grid-based on character cells.
|
||||
|
||||
### Key Concepts
|
||||
|
||||
**Widgets and Containers**
|
||||
- Widgets are the building blocks of the UI
|
||||
- Containers are widgets that hold other widgets
|
||||
- Default layout stacks widgets vertically from top of screen
|
||||
|
||||
**Reactive Programming**
|
||||
- State changes trigger automatic UI updates
|
||||
- No manual refresh loops needed
|
||||
- Use reactive attributes for state management
|
||||
|
||||
**Event-Driven Model**
|
||||
- Define callbacks for key presses, mouse clicks, timer ticks
|
||||
- Actions are functions callable via keystroke or text link
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Use Immutable Objects**
|
||||
- Prefer tuples, NamedTuples, or frozen dataclasses
|
||||
- Easier to reason about, cache, and test
|
||||
- Enables side-effect-free code
|
||||
|
||||
2. **Separate Styles**
|
||||
- Keep CSS in `.tcss` files, not inline
|
||||
- Python code stays clean and focused on logic
|
||||
|
||||
3. **Async-First**
|
||||
- Textual is async under the hood
|
||||
- Use `async`/`await` for I/O operations
|
||||
- Can integrate with async libraries if needed
|
||||
|
||||
### Layout Management
|
||||
|
||||
```python
|
||||
# Grid layout example
|
||||
CSS = """
|
||||
Screen {
|
||||
layout: grid;
|
||||
grid-size: 3 1;
|
||||
grid-columns: 1fr 2fr 1fr;
|
||||
}
|
||||
"""
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
- [Textual Documentation](https://textual.textualize.io/)
|
||||
- [Textual Tutorial](https://textual.textualize.io/tutorial/)
|
||||
- [Real Python Textual Guide](https://realpython.com/python-textual/)
|
||||
- [Textual GitHub](https://github.com/Textualize/textual)
|
||||
|
||||
---
|
||||
|
||||
## Typer CLI Framework
|
||||
|
||||
Typer is built on Click with Python type hints for automatic argument parsing.
|
||||
|
||||
### Project Structure Pattern
|
||||
|
||||
```
|
||||
app/
|
||||
├── __init__.py
|
||||
├── main.py # Root Typer app
|
||||
├── commands/ # Subcommand modules
|
||||
│ ├── users.py
|
||||
│ └── tasks.py
|
||||
└── helpers/ # Shared utilities
|
||||
└── validate.py
|
||||
```
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Organize Commands**
|
||||
- Use `add_typer()` to group commands
|
||||
- Avoid giant files with dozens of commands
|
||||
- Each command function should orchestrate, not contain all logic
|
||||
|
||||
2. **Entry Point Support**
|
||||
- Add `__main__.py` for `python -m` support
|
||||
- Define entry points in pyproject.toml for CLI scripts
|
||||
|
||||
3. **Standard Exit Codes**
|
||||
- `0` for success
|
||||
- Non-zero for errors
|
||||
- Crucial for CI/CD integration
|
||||
|
||||
4. **Type Hints for Validation**
|
||||
- Use Enum for dropdown-style restrictions
|
||||
- Type hints provide editor autocompletion
|
||||
|
||||
### Subcommand Example
|
||||
|
||||
```python
|
||||
# commands/users.py
|
||||
import typer
|
||||
|
||||
app = typer.Typer()
|
||||
|
||||
@app.command()
|
||||
def create(name: str):
|
||||
"""Create a new user."""
|
||||
...
|
||||
|
||||
# main.py
|
||||
from commands import users
|
||||
|
||||
main_app = typer.Typer()
|
||||
main_app.add_typer(users.app, name="users")
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
- [Typer Documentation](https://typer.tiangolo.com/)
|
||||
- [Typer Subcommands](https://typer.tiangolo.com/tutorial/subcommands/)
|
||||
- [Building a Package](https://typer.tiangolo.com/tutorial/package/)
|
||||
|
||||
---
|
||||
|
||||
## Pydantic Data Validation
|
||||
|
||||
Pydantic v2 with strict mode ensures type safety and validation.
|
||||
|
||||
### Strict Mode Configuration
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
class MyModel(BaseModel):
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
name: str
|
||||
count: int # Will reject "123" string
|
||||
```
|
||||
|
||||
### Settings Management
|
||||
|
||||
Settings have moved to `pydantic-settings` package:
|
||||
|
||||
```python
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
class AppSettings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="APP_",
|
||||
env_file=".env",
|
||||
env_nested_delimiter="__",
|
||||
)
|
||||
|
||||
database_url: str
|
||||
debug: bool = False
|
||||
```
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Use `frozen=True` for Immutability**
|
||||
- Prevents accidental mutation
|
||||
- Enables hashing for use as dict keys
|
||||
|
||||
2. **Explicit Strict Types**
|
||||
- `StrictInt`, `StrictStr` for field-level strictness
|
||||
- Or use `model_config` for model-wide strictness
|
||||
|
||||
3. **Validation vs Parsing**
|
||||
- Strict mode rejects type coercion
|
||||
- JSON parsing allows some conversion (ISO8601 → datetime)
|
||||
|
||||
### References
|
||||
|
||||
- [Pydantic v2 Documentation](https://docs.pydantic.dev/latest/)
|
||||
- [Pydantic Settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/)
|
||||
- [Pydantic Configuration](https://docs.pydantic.dev/latest/api/config/)
|
||||
- [Migration Guide](https://docs.pydantic.dev/latest/migration/)
|
||||
|
||||
---
|
||||
|
||||
## Extension System
|
||||
|
||||
The plugin system uses Pluggy for hook-based extensibility.
|
||||
|
||||
### Pluggy Concepts
|
||||
|
||||
1. **Hook Specifications** - Define the interface extensions implement
|
||||
2. **Hook Implementations** - Extension code implementing hooks
|
||||
3. **Plugin Manager** - Discovers and calls implementations
|
||||
|
||||
### Architecture
|
||||
|
||||
```python
|
||||
# hookspecs.py - Define hooks
|
||||
import pluggy
|
||||
|
||||
hookspec = pluggy.HookspecMarker("clide")
|
||||
hookimpl = pluggy.HookimplMarker("clide")
|
||||
|
||||
class ClideHookSpec:
|
||||
@hookspec
|
||||
def register_panel(self) -> dict: ...
|
||||
|
||||
# extension.py - Implement hooks
|
||||
class MyExtension:
|
||||
@hookimpl
|
||||
def register_panel(self) -> dict:
|
||||
return {"name": "custom", "widget": CustomWidget}
|
||||
```
|
||||
|
||||
### Distribution
|
||||
|
||||
Extensions can be distributed as packages using entry points:
|
||||
|
||||
```toml
|
||||
# pyproject.toml of extension package
|
||||
[project.entry-points."clide.extensions"]
|
||||
my_extension = "my_package:MyExtension"
|
||||
```
|
||||
|
||||
### Hook Execution Order
|
||||
|
||||
- Multiple implementations called in LIFO (Last In, First Out) order
|
||||
- Use `hookimpl(tryfirst=True)` or `hookimpl(trylast=True)` for ordering
|
||||
|
||||
### Alternatives
|
||||
|
||||
- **Stevedore** - Better for driver/extension patterns, uses entry points
|
||||
- Choose Pluggy for hook-based systems (like pytest uses)
|
||||
|
||||
### References
|
||||
|
||||
- [Pluggy Documentation](https://pluggy.readthedocs.io/)
|
||||
- [Stevedore Documentation](https://docs.openstack.org/stevedore/latest/)
|
||||
- [Creating Plugins with Stevedore](https://docs.openstack.org/stevedore/latest/user/tutorial/creating_plugins.html)
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### pytest-asyncio
|
||||
|
||||
Configure auto mode for automatic async test discovery:
|
||||
|
||||
```toml
|
||||
# pyproject.toml
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
```
|
||||
|
||||
### Async Test Patterns
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
# Auto mode - no decorator needed
|
||||
async def test_async_operation():
|
||||
result = await some_async_function()
|
||||
assert result == expected
|
||||
|
||||
# Async fixtures
|
||||
@pytest.fixture
|
||||
async def database_connection():
|
||||
conn = await create_connection()
|
||||
yield conn
|
||||
await conn.close()
|
||||
```
|
||||
|
||||
### Async Mocking
|
||||
|
||||
```python
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
async def test_with_mock():
|
||||
mock_service = AsyncMock(return_value={"status": "ok"})
|
||||
result = await mock_service()
|
||||
assert result["status"] == "ok"
|
||||
```
|
||||
|
||||
### Snapshot Testing
|
||||
|
||||
Visual regression with pytest-textual-snapshot:
|
||||
|
||||
```python
|
||||
def test_layout(snap_compare):
|
||||
assert snap_compare("app.py", terminal_size=(120, 40))
|
||||
|
||||
def test_with_interaction(snap_compare):
|
||||
async def setup(pilot):
|
||||
await pilot.press("tab", "enter")
|
||||
|
||||
assert snap_compare("app.py", run_before=setup)
|
||||
```
|
||||
|
||||
Update snapshots after intentional changes:
|
||||
```bash
|
||||
pytest tests/snapshots/ --snapshot-update
|
||||
```
|
||||
|
||||
### Test Harness Pattern
|
||||
|
||||
Harnesses provide isolated test environments:
|
||||
|
||||
```python
|
||||
class AppHarness:
|
||||
async def start(self) -> tuple[App, Pilot]:
|
||||
"""Start app with mocked dependencies."""
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Clean shutdown."""
|
||||
```
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Always use `@pytest.mark.asyncio`** (or auto mode)
|
||||
2. **Use async fixtures** for async setup/teardown
|
||||
3. **Mock external services** - don't hit real APIs
|
||||
4. **Choose appropriate fixture scopes** for performance
|
||||
5. **Avoid blocking the event loop** in async tests
|
||||
|
||||
### References
|
||||
|
||||
- [pytest-asyncio Documentation](https://pytest-asyncio.readthedocs.io/en/latest/)
|
||||
- [pytest-textual-snapshot](https://github.com/Textualize/pytest-textual-snapshot)
|
||||
- [Textual Testing Guide](https://textual.textualize.io/guide/testing/)
|
||||
- [pytest Fixtures](https://docs.pytest.org/en/stable/how-to/fixtures.html)
|
||||
|
||||
---
|
||||
|
||||
## Build and Distribution
|
||||
|
||||
### PyInstaller Limitations
|
||||
|
||||
**Critical: PyInstaller cannot cross-compile.**
|
||||
- Build on the target OS
|
||||
- Use CI/CD for multi-platform builds
|
||||
|
||||
### CI/CD Multi-Platform Build
|
||||
|
||||
Use Gitea Actions (or compatible CI) for multi-platform builds:
|
||||
|
||||
```yaml
|
||||
# .gitea/workflows/build.yml
|
||||
name: Build
|
||||
on: [push, tag]
|
||||
|
||||
jobs:
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- run: pip install -e ".[build]"
|
||||
- run: pyinstaller clide.spec --clean
|
||||
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- run: pip install -e ".[build]"
|
||||
- run: pyinstaller clide.spec --clean
|
||||
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- run: pip install -e ".[build]"
|
||||
- run: pyinstaller clide.spec --clean
|
||||
```
|
||||
|
||||
### Optimization Tips
|
||||
|
||||
1. **Use `--onefile`** for single executable
|
||||
2. **Apply `--strip`** to reduce binary size
|
||||
3. **Use UPX compression** (460 MB → ~130 MB possible)
|
||||
4. **Exclude unused modules** with `--exclude-module`
|
||||
5. **Lazy imports** for large libraries
|
||||
|
||||
### Platform-Specific Output
|
||||
|
||||
- **Windows**: `.exe` or MSIX installer
|
||||
- **macOS**: `.app` bundle in `.dmg`
|
||||
- **Linux**: AppImage or native package
|
||||
|
||||
### Linux Compatibility
|
||||
|
||||
Build on the oldest target distro version. Newer systems may produce incompatible binaries.
|
||||
|
||||
### References
|
||||
|
||||
- [PyInstaller Documentation](https://pyinstaller.org/)
|
||||
- [Building the Bootloader](https://pyinstaller.org/en/latest/bootloader-building.html)
|
||||
- [Gitea Actions](https://docs.gitea.com/usage/actions/overview)
|
||||
@@ -0,0 +1,503 @@
|
||||
# TUI IDE Specification
|
||||
|
||||
A terminal-based IDE built with Textual, designed to wrap Claude Code and integrate project management tooling (Jira/Confluence via CLI).
|
||||
|
||||
## Design Principles
|
||||
|
||||
- **Claude-centric**: Claude Code is the primary workspace, always visible
|
||||
- **Contextual panels**: Editor/Diff/Terminal appear only when needed
|
||||
- **VSCode-familiar**: Keybindings and interaction patterns follow VSCode conventions
|
||||
- **Responsive**: Works on 13" laptop and widescreen monitors
|
||||
- **No vim magic**: Standard keyboard navigation, no modal editing
|
||||
|
||||
---
|
||||
|
||||
## Panel Structure
|
||||
|
||||
### Layout Overview
|
||||
|
||||
```
|
||||
┌─────────────────┬─────────────────────────┬──────────────────┐
|
||||
│ panel-sidebar │ panel-workspace (60%) │ panel-context │
|
||||
│ │ [Editor][Diff][Terminal]│ │
|
||||
│ [Files][Git] │ (hidden when inactive) │ (content area) │
|
||||
│ [Tree] ├─────────────────────────┤ │
|
||||
│ │ │ │
|
||||
│ (content area) │ panel-claude │ │
|
||||
│ │ (40% when workspace │ │
|
||||
│ │ visible, else 100%) │ │
|
||||
├─────────────────┤ ├──────────────────┤
|
||||
│ branch-status │ │[⚠ 3][✓12][Jira] │
|
||||
│ ⎇ main ▾ │ │ context-tabs │
|
||||
└─────────────────┴─────────────────────────┴──────────────────┘
|
||||
```
|
||||
|
||||
### Panel Definitions
|
||||
|
||||
```python
|
||||
PANELS = {
|
||||
# Left sidebar
|
||||
"sidebar": "panel-sidebar",
|
||||
"sidebar-files": "panel-sidebar-files",
|
||||
"sidebar-git": "panel-sidebar-git",
|
||||
"sidebar-tree": "panel-sidebar-tree",
|
||||
"branch-status": "panel-branch-status",
|
||||
|
||||
# Center
|
||||
"claude": "panel-claude",
|
||||
"workspace": "panel-workspace",
|
||||
"editor": "panel-editor",
|
||||
"diff": "panel-diff",
|
||||
"terminal": "panel-terminal",
|
||||
|
||||
# Right context
|
||||
"context": "panel-context",
|
||||
"context-jira": "panel-context-jira",
|
||||
"context-problems": "panel-context-problems",
|
||||
"context-todos": "panel-context-todos",
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Left Sidebar (`panel-sidebar`)
|
||||
|
||||
### Tabs
|
||||
|
||||
| Tab | Content | Widget |
|
||||
|-----|---------|--------|
|
||||
| Files | Project file tree | `DirectoryTree` |
|
||||
| Git | Staged/Unstaged changes | `GitChangesView` (custom) |
|
||||
| Tree | Merge/branch graph | `GitGraphView` (custom) |
|
||||
|
||||
### Git Tab Details
|
||||
|
||||
Two collapsible sections:
|
||||
- **Staged**: Files in index, ready to commit
|
||||
- **Unstaged**: Modified/untracked files
|
||||
|
||||
Each file item shows:
|
||||
- Status icon: `+` added, `~` modified, `-` deleted, `?` untracked, `→` renamed
|
||||
- File path (relative)
|
||||
|
||||
**Interactions:**
|
||||
- Click file → opens in Editor panel
|
||||
- Double-click or keybind → stage/unstage file
|
||||
- Right-click or keybind → show context menu (discard, diff, etc.)
|
||||
|
||||
### Tree Tab Details
|
||||
|
||||
Renders `git log --graph --oneline --decorate --all` with visual styling.
|
||||
|
||||
**Polish item**: Consider custom rendering with box-drawing characters for a cleaner look:
|
||||
```
|
||||
●──┬── main: Latest commit message
|
||||
│ ●── feature: Feature work
|
||||
●──┴── Merge branch 'feature'
|
||||
◆───── Tagged release v1.0
|
||||
```
|
||||
|
||||
Use canvas or rich text with:
|
||||
```python
|
||||
GRAPH_CHARS = {
|
||||
'commit': '●',
|
||||
'merge': '◆',
|
||||
'line': '│',
|
||||
'branch': '├──',
|
||||
'join': '┴──',
|
||||
}
|
||||
```
|
||||
|
||||
### Branch Status Bar
|
||||
|
||||
Fixed at bottom of sidebar. Shows current branch with popout toggle.
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ ⎇ main ▾ │ ← Click or keybind to expand
|
||||
└─────────────────┘
|
||||
│
|
||||
▼ (popout overlay)
|
||||
┌─────────────────┐
|
||||
│ Recent branches │
|
||||
│ ○ main │
|
||||
│ ○ feature/xyz │
|
||||
│ ○ develop │
|
||||
├─────────────────┤
|
||||
│ [Checkout] [New]│
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Center Column
|
||||
|
||||
### Claude Panel (`panel-claude`)
|
||||
|
||||
The primary workspace. Displays Claude Code interaction.
|
||||
|
||||
**Default state**: 100% height of center column
|
||||
**With workspace**: 40% height (bottom)
|
||||
|
||||
**Content:**
|
||||
- Streaming markdown responses (use `Markdown` or `RichLog` widget)
|
||||
- Visual distinction between:
|
||||
- Claude's responses
|
||||
- Tool calls / file operations
|
||||
- User input
|
||||
- Input area at bottom
|
||||
|
||||
### Workspace Panel (`panel-workspace`)
|
||||
|
||||
Tabbed container for Editor, Diff, and Terminal. **Hidden by default.**
|
||||
|
||||
**Important**: Hiding is not closing. All panels retain state when hidden:
|
||||
- Editor: Open files, cursor position, scroll position, unsaved changes
|
||||
- Diff: Current diff content, scroll position
|
||||
- Terminal: Active session, command history, output buffer
|
||||
|
||||
Use `display: none` for visibility, never destroy/recreate widgets.
|
||||
|
||||
**Visibility triggers:**
|
||||
|
||||
| Trigger | Result |
|
||||
|---------|--------|
|
||||
| Click file in sidebar | Show workspace, focus Editor tab |
|
||||
| Claude proposes changes | Show workspace, focus Diff tab |
|
||||
| User presses `` Ctrl+` `` | Show workspace, focus Terminal tab |
|
||||
| User runs command | Show workspace, focus Terminal tab |
|
||||
| Close all tabs / Escape | Hide workspace, Claude reclaims space |
|
||||
|
||||
**Height**: 60% of center column when visible
|
||||
|
||||
#### Editor Tab
|
||||
|
||||
- `TextArea` widget with syntax highlighting
|
||||
- Language detection from file extension
|
||||
- Theme: Follow terminal theme or user preference
|
||||
|
||||
#### Diff Tab
|
||||
|
||||
- Side-by-side or unified diff view
|
||||
- Syntax highlighting for changed content
|
||||
- Accept/Reject buttons for Claude-proposed changes
|
||||
|
||||
#### Terminal Tab
|
||||
|
||||
- Proper PTY integration for full terminal emulation
|
||||
- Or simpler command runner with output display (decide based on complexity)
|
||||
- Working directory tied to project root
|
||||
|
||||
---
|
||||
|
||||
## Right Sidebar (`panel-context`)
|
||||
|
||||
### Content Area
|
||||
|
||||
Switches based on selected bottom tab. Shows one of:
|
||||
- Jira view (default)
|
||||
- Problems view
|
||||
- TODOs view
|
||||
|
||||
### Bottom Tab Bar (`context-tabs`)
|
||||
|
||||
```
|
||||
┌──────────────────┐
|
||||
│ [⚠ 3][✓12][Jira]│
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
Tabs show inline counts that update reactively.
|
||||
|
||||
| Tab | Icon | Content |
|
||||
|-----|------|---------|
|
||||
| Problems | ⚠ | Linter errors, warnings (count badge) |
|
||||
| TODOs | ✓ | TODO/FIXME comments from codebase (count badge) |
|
||||
| Jira | Jira | Output from your CLI tool (default) |
|
||||
|
||||
### Jira View
|
||||
|
||||
Renders markdown output from your CLI tool. Refreshes on:
|
||||
- Panel focus
|
||||
- Manual refresh keybind
|
||||
- Configurable interval
|
||||
|
||||
### Problems View
|
||||
|
||||
Aggregates from linters (eslint, ruff, etc.). Shows:
|
||||
- File path
|
||||
- Line number
|
||||
- Severity icon
|
||||
- Message
|
||||
|
||||
Click → opens file in Editor at that line.
|
||||
|
||||
### TODOs View
|
||||
|
||||
Grep results for `TODO`, `FIXME`, `HACK`, `XXX`. Shows:
|
||||
- File path
|
||||
- Line number
|
||||
- Comment text
|
||||
|
||||
Click → opens file in Editor at that line.
|
||||
|
||||
---
|
||||
|
||||
## Responsiveness
|
||||
|
||||
### CSS Breakpoints
|
||||
|
||||
```css
|
||||
/* Widescreen (default) */
|
||||
#panel-sidebar { width: 20%; min-width: 25; }
|
||||
#panel-context { width: 25%; min-width: 30; }
|
||||
#panel-claude { width: 1fr; }
|
||||
|
||||
/* Medium terminals */
|
||||
@media (width < 120) {
|
||||
#panel-sidebar { width: 18%; }
|
||||
#panel-context { width: 22%; }
|
||||
}
|
||||
|
||||
/* Narrow terminals (laptop, split screen) */
|
||||
@media (width < 100) {
|
||||
#panel-sidebar { display: none; }
|
||||
#panel-context { width: 25%; }
|
||||
}
|
||||
|
||||
@media (width < 80) {
|
||||
#panel-context { display: none; }
|
||||
#panel-claude { width: 100%; }
|
||||
}
|
||||
```
|
||||
|
||||
### Compact Mode
|
||||
|
||||
Toggle with `Ctrl+Shift+C`. Hides both sidebars, maximizes Claude + workspace.
|
||||
|
||||
```css
|
||||
.compact #panel-sidebar { display: none; }
|
||||
.compact #panel-context { display: none; }
|
||||
```
|
||||
|
||||
### Fullscreen Mode
|
||||
|
||||
Any panel can go fullscreen with `F11` (when focused). Press `Escape` to exit.
|
||||
|
||||
```css
|
||||
.fullscreen {
|
||||
dock: top;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
layer: fullscreen;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Keybindings
|
||||
|
||||
Following VSCode conventions where possible.
|
||||
|
||||
### Global
|
||||
|
||||
| Action | Binding |
|
||||
|--------|---------|
|
||||
| Command palette | `Ctrl+Shift+P` |
|
||||
| Quick open file | `Ctrl+P` |
|
||||
| Toggle left sidebar | `Ctrl+B` |
|
||||
| Toggle right sidebar | `Ctrl+Shift+B` |
|
||||
| Toggle terminal | `` Ctrl+` `` |
|
||||
| Toggle compact mode | `Ctrl+Shift+C` |
|
||||
| Fullscreen focused panel | `F11` |
|
||||
| Exit fullscreen | `Escape` |
|
||||
|
||||
### Navigation
|
||||
|
||||
| Action | Binding |
|
||||
|--------|---------|
|
||||
| Focus Claude panel | `Ctrl+1` |
|
||||
| Focus Editor | `Ctrl+2` |
|
||||
| Focus Terminal | `Ctrl+3` |
|
||||
| Focus sidebar | `Ctrl+0` |
|
||||
| Next tab (in tabbed panels) | `Ctrl+Tab` |
|
||||
| Previous tab | `Ctrl+Shift+Tab` |
|
||||
| Close current tab/editor | `Ctrl+W` |
|
||||
|
||||
### Git
|
||||
|
||||
| Action | Binding |
|
||||
|--------|---------|
|
||||
| Open Git panel | `Ctrl+Shift+G` |
|
||||
| Stage file | `Ctrl+Enter` (in git view) |
|
||||
| Unstage file | `Ctrl+Backspace` (in git view) |
|
||||
|
||||
### Search & Problems
|
||||
|
||||
| Action | Binding |
|
||||
|--------|---------|
|
||||
| Find in file | `Ctrl+F` |
|
||||
| Find in project | `Ctrl+Shift+F` |
|
||||
| Go to problems | `Ctrl+Shift+M` |
|
||||
| Next problem | `F8` |
|
||||
| Previous problem | `Shift+F8` |
|
||||
|
||||
### Editor
|
||||
|
||||
| Action | Binding |
|
||||
|--------|---------|
|
||||
| Save | `Ctrl+S` |
|
||||
| Undo | `Ctrl+Z` |
|
||||
| Redo | `Ctrl+Shift+Z` |
|
||||
| Go to line | `Ctrl+G` |
|
||||
|
||||
---
|
||||
|
||||
## Panel Communication
|
||||
|
||||
Panels should feel connected, like a normal IDE.
|
||||
|
||||
### File Navigation
|
||||
|
||||
```
|
||||
Sidebar file click
|
||||
│
|
||||
▼
|
||||
Workspace appears (if hidden)
|
||||
│
|
||||
▼
|
||||
Editor tab focused
|
||||
│
|
||||
▼
|
||||
File loaded in Editor
|
||||
```
|
||||
|
||||
### Problems/TODOs Navigation
|
||||
|
||||
```
|
||||
Click problem/todo item
|
||||
│
|
||||
▼
|
||||
Workspace appears (if hidden)
|
||||
│
|
||||
▼
|
||||
Editor tab focused
|
||||
│
|
||||
▼
|
||||
File opened at specific line
|
||||
│
|
||||
▼
|
||||
Line highlighted/scrolled into view
|
||||
```
|
||||
|
||||
### Claude Diff Flow
|
||||
|
||||
```
|
||||
Claude proposes file changes
|
||||
│
|
||||
▼
|
||||
Workspace appears
|
||||
│
|
||||
▼
|
||||
Diff tab focused
|
||||
│
|
||||
▼
|
||||
Changes displayed with Accept/Reject
|
||||
│
|
||||
├─► Accept: Apply changes, optionally close diff
|
||||
│
|
||||
└─► Reject: Discard, close diff
|
||||
```
|
||||
|
||||
### Git File Actions
|
||||
|
||||
```
|
||||
Click file in Git tab
|
||||
│
|
||||
▼
|
||||
Workspace appears
|
||||
│
|
||||
▼
|
||||
Diff tab shows unstaged changes
|
||||
│
|
||||
▼
|
||||
Stage/unstage from diff view
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Recommended Textual Widgets
|
||||
|
||||
| Component | Widget |
|
||||
|-----------|--------|
|
||||
| File browser | `DirectoryTree` |
|
||||
| Claude output | `Markdown` or `RichLog` (for streaming) |
|
||||
| Editor | `TextArea` (syntax highlighting built-in) |
|
||||
| Tabbed panels | `TabbedContent`, `TabPane` |
|
||||
| Panel switching | `ContentSwitcher` |
|
||||
| Problems/TODOs list | `ListView` with `ListItem` |
|
||||
| Git graph | `RichLog` or custom canvas widget |
|
||||
| Command palette | `CommandPalette` (built-in) |
|
||||
|
||||
### Background Tasks
|
||||
|
||||
Use Textual's `@work` decorator for:
|
||||
- Git status refresh
|
||||
- Linter execution
|
||||
- TODO scanning
|
||||
- Jira CLI calls
|
||||
|
||||
```python
|
||||
@work(thread=True)
|
||||
def refresh_git_status(self) -> None:
|
||||
result = subprocess.run(["git", "status", "--porcelain"], ...)
|
||||
self.call_from_thread(self.update_git_view, result.stdout)
|
||||
```
|
||||
|
||||
### State Management
|
||||
|
||||
**Core principle**: Hiding is not closing. All panels persist state when hidden.
|
||||
|
||||
```python
|
||||
class IDEApp(App):
|
||||
current_file: reactive[str | None] = reactive(None)
|
||||
workspace_visible: reactive[bool] = reactive(False)
|
||||
problem_count: reactive[int] = reactive(0)
|
||||
todo_count: reactive[int] = reactive(0)
|
||||
compact_mode: reactive[bool] = reactive(False)
|
||||
```
|
||||
|
||||
**Panel visibility pattern** — toggle `display`, don't destroy:
|
||||
|
||||
```python
|
||||
def toggle_workspace(self, visible: bool) -> None:
|
||||
workspace = self.query_one("#panel-workspace")
|
||||
workspace.display = visible # Retains all child state
|
||||
|
||||
# Adjust Claude panel height
|
||||
claude = self.query_one("#panel-claude")
|
||||
claude.styles.height = "40%" if visible else "100%"
|
||||
```
|
||||
|
||||
**State to preserve per panel:**
|
||||
|
||||
| Panel | Preserved State |
|
||||
|-------|-----------------|
|
||||
| Editor | Open files, cursor positions, scroll, unsaved changes, undo history |
|
||||
| Diff | Current diff content, scroll position, accept/reject state |
|
||||
| Terminal | PTY session, command history, output buffer, working directory |
|
||||
| Sidebar tabs | Scroll position, expanded/collapsed sections, selection |
|
||||
| Context tabs | Scroll position, selected item |
|
||||
| Git views | Expanded sections, selected files |
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- **Session persistence**: Remember open files, panel sizes, last git state
|
||||
- **Multiple projects**: Workspace switcher
|
||||
- **Claude history**: Browse past conversations
|
||||
- **Custom themes**: User-selectable color schemes
|
||||
- **Plugin system**: User-defined panels/integrations
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "clide"
|
||||
version = "0.1.0"
|
||||
description = "A TUI CLI IDE for Claude Code CLI"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = "MIT"
|
||||
authors = [
|
||||
{ name = "Your Name", email = "you@example.com" }
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Environment :: Console",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Software Development :: User Interfaces",
|
||||
]
|
||||
dependencies = [
|
||||
"textual>=0.50.0",
|
||||
"pyte>=0.8.0",
|
||||
"typer>=0.12.0",
|
||||
"pydantic>=2.0.0",
|
||||
"pydantic-settings>=2.0.0",
|
||||
"pluggy>=1.4.0",
|
||||
"rich>=13.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
"pytest-textual-snapshot>=0.4.0",
|
||||
"pytest-cov>=4.0.0",
|
||||
"mypy>=1.8.0",
|
||||
"ruff>=0.3.0",
|
||||
"pre-commit>=3.0.0",
|
||||
]
|
||||
build = [
|
||||
"pyinstaller>=6.0.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
clide = "clide.cli:app"
|
||||
|
||||
[project.entry-points."clide.extensions"]
|
||||
# Built-in extensions registered here
|
||||
# example = "clide.extensions.builtin.example:ExampleExtension"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/clide"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
addopts = [
|
||||
"-v",
|
||||
"--tb=short",
|
||||
]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
strict = true
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
exclude = ["tests", "dist", "build"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 100
|
||||
src = ["src", "tests"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", # pycodestyle errors
|
||||
"W", # pycodestyle warnings
|
||||
"F", # Pyflakes
|
||||
"I", # isort
|
||||
"B", # flake8-bugbear
|
||||
"C4", # flake8-comprehensions
|
||||
"UP", # pyupgrade
|
||||
"ARG", # flake8-unused-arguments
|
||||
"SIM", # flake8-simplify
|
||||
"TCH", # flake8-type-checking
|
||||
"PTH", # flake8-use-pathlib
|
||||
"ASYNC", # flake8-async
|
||||
]
|
||||
ignore = [
|
||||
"E501", # line too long (handled by formatter)
|
||||
]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = ["clide"]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["src/clide"]
|
||||
branch = true
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"if TYPE_CHECKING:",
|
||||
"raise NotImplementedError",
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Clide - A TUI CLI IDE for Claude Code CLI."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Entry point for python -m clide."""
|
||||
|
||||
from clide.cli import app
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
@@ -0,0 +1,507 @@
|
||||
"""Main Textual Application for Clide."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Container, Horizontal, Vertical
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Footer, Header
|
||||
|
||||
from clide.controllers.diff import DiffController
|
||||
from clide.controllers.editor import EditorController
|
||||
from clide.controllers.git import GitController
|
||||
from clide.controllers.jira import JiraController
|
||||
from clide.controllers.problems import ProblemsController
|
||||
from clide.controllers.todos import TodosController
|
||||
from clide.extensions.manager import ExtensionManager
|
||||
from clide.models.config import ClideSettings
|
||||
from clide.themes.registry import get_all_themes, get_theme
|
||||
from clide.widgets.panels.claude import ClaudePanel
|
||||
from clide.widgets.panels.context import ContextPanel
|
||||
from clide.widgets.panels.sidebar import SidebarPanel
|
||||
from clide.widgets.panels.workspace import WorkspacePanel
|
||||
|
||||
|
||||
class ClideApp(App[None]):
|
||||
"""Clide TUI Application - Claude Code IDE.
|
||||
|
||||
Panel architecture:
|
||||
- Sidebar (left): Files, Git, Tree tabs + branch status
|
||||
- Center: Workspace (Editor/Diff/Terminal) + Claude panel
|
||||
- Context (right): Problems, TODOs, Jira tabs
|
||||
|
||||
Workspace is hidden by default. Claude takes full height when
|
||||
workspace is hidden, 40% when visible.
|
||||
"""
|
||||
|
||||
TITLE = "Clide"
|
||||
SUB_TITLE = "Claude Code IDE"
|
||||
|
||||
CSS: ClassVar[str] = """
|
||||
/* Main layout */
|
||||
Screen {
|
||||
layout: horizontal;
|
||||
}
|
||||
|
||||
#main-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
layout: horizontal;
|
||||
}
|
||||
|
||||
#center-column {
|
||||
width: 1fr;
|
||||
height: 100%;
|
||||
layout: vertical;
|
||||
}
|
||||
|
||||
/* Panel styling */
|
||||
SidebarPanel {
|
||||
width: 20%;
|
||||
min-width: 25;
|
||||
}
|
||||
|
||||
ContextPanel {
|
||||
width: 25%;
|
||||
min-width: 30;
|
||||
}
|
||||
|
||||
/* Workspace + Claude layout */
|
||||
WorkspacePanel {
|
||||
height: 60%;
|
||||
}
|
||||
|
||||
WorkspacePanel.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
ClaudePanel {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
ClaudePanel.with-workspace {
|
||||
height: 40%;
|
||||
}
|
||||
|
||||
/* Compact mode - applied when .compact class is on #main-container */
|
||||
#main-container.compact SidebarPanel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#main-container.compact ContextPanel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Fullscreen mode */
|
||||
.fullscreen {
|
||||
dock: top;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
layer: fullscreen;
|
||||
}
|
||||
"""
|
||||
|
||||
# VSCode-style keybindings
|
||||
BINDINGS: ClassVar[list[Binding]] = [
|
||||
# Global
|
||||
Binding("ctrl+q", "quit", "Quit"),
|
||||
Binding("ctrl+shift+p", "command_palette", "Commands"),
|
||||
Binding("ctrl+p", "quick_open", "Quick Open"),
|
||||
Binding("ctrl+b", "toggle_sidebar", "Toggle Sidebar"),
|
||||
Binding("ctrl+shift+b", "toggle_context", "Toggle Context"),
|
||||
Binding("ctrl+`", "toggle_terminal", "Toggle Terminal"),
|
||||
Binding("ctrl+shift+c", "toggle_compact", "Compact Mode"),
|
||||
Binding("f11", "toggle_fullscreen", "Fullscreen"),
|
||||
Binding("escape", "escape", "Escape", show=False),
|
||||
# Navigation
|
||||
Binding("ctrl+1", "focus_claude", "Focus Claude", show=False),
|
||||
Binding("ctrl+2", "focus_editor", "Focus Editor", show=False),
|
||||
Binding("ctrl+3", "focus_terminal", "Focus Terminal", show=False),
|
||||
Binding("ctrl+0", "focus_sidebar", "Focus Sidebar", show=False),
|
||||
Binding("ctrl+w", "close_tab", "Close Tab", show=False),
|
||||
# Git
|
||||
Binding("ctrl+shift+g", "open_git", "Git", show=False),
|
||||
# Problems
|
||||
Binding("ctrl+shift+m", "open_problems", "Problems", show=False),
|
||||
Binding("f8", "next_problem", "Next Problem", show=False),
|
||||
Binding("shift+f8", "prev_problem", "Prev Problem", show=False),
|
||||
# Editor
|
||||
Binding("ctrl+s", "save_file", "Save", show=False),
|
||||
Binding("ctrl+g", "goto_line", "Go to Line", show=False),
|
||||
# Theme
|
||||
Binding("ctrl+k ctrl+t", "select_theme", "Select Theme", show=False),
|
||||
]
|
||||
|
||||
# Reactive state
|
||||
current_file: reactive[Path | None] = reactive(None)
|
||||
workspace_visible: reactive[bool] = reactive(False)
|
||||
compact_mode: reactive[bool] = reactive(False)
|
||||
fullscreen_panel: reactive[str | None] = reactive(None)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workdir: Path | None = None,
|
||||
settings: ClideSettings | None = None,
|
||||
test_mode: bool = False,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.workdir = workdir or Path.cwd()
|
||||
self.settings = settings or ClideSettings()
|
||||
self._test_mode = test_mode
|
||||
|
||||
# Extension manager
|
||||
self.extension_manager = ExtensionManager()
|
||||
|
||||
# Controllers
|
||||
self.git_controller = GitController(self.workdir)
|
||||
self.editor_controller = EditorController()
|
||||
self.diff_controller = DiffController(self.workdir)
|
||||
self.problems_controller = ProblemsController(self.workdir)
|
||||
self.todos_controller = TodosController(self.workdir)
|
||||
self.jira_controller = JiraController(
|
||||
enabled=self.settings.jira_enabled,
|
||||
)
|
||||
|
||||
# Register themes
|
||||
self._register_themes()
|
||||
|
||||
def _register_themes(self) -> None:
|
||||
"""Register all themes with Textual."""
|
||||
for theme_meta in get_all_themes():
|
||||
theme_def = get_theme(theme_meta.name)
|
||||
if theme_def:
|
||||
self.register_theme(theme_def.to_textual_theme())
|
||||
|
||||
# Set initial theme
|
||||
self.theme = self.settings.theme
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create the main layout."""
|
||||
yield Header()
|
||||
|
||||
with Horizontal(id="main-container"):
|
||||
# Left sidebar
|
||||
yield SidebarPanel(workdir=self.workdir)
|
||||
|
||||
# Center column with workspace and claude
|
||||
with Vertical(id="center-column"):
|
||||
yield WorkspacePanel(workdir=self.workdir)
|
||||
yield ClaudePanel(
|
||||
workdir=self.workdir,
|
||||
auto_start=not self._test_mode,
|
||||
)
|
||||
|
||||
# Right context panel
|
||||
yield ContextPanel(
|
||||
jira_enabled=self.settings.jira_enabled,
|
||||
)
|
||||
|
||||
yield Footer()
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
"""Initialize application on mount."""
|
||||
# Load extensions
|
||||
self.extension_manager.load_extensions()
|
||||
await self.extension_manager.trigger_app_startup(self)
|
||||
|
||||
# Initial data refresh
|
||||
await self._refresh_git()
|
||||
await self._refresh_problems()
|
||||
await self._refresh_todos()
|
||||
if self.settings.jira_enabled:
|
||||
await self._refresh_jira()
|
||||
|
||||
# Focus Claude panel
|
||||
self.action_focus_claude()
|
||||
|
||||
# Reactive watchers
|
||||
def watch_workspace_visible(self, visible: bool) -> None:
|
||||
"""Update panels when workspace visibility changes."""
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
claude = self.query_one(ClaudePanel)
|
||||
|
||||
workspace.visible = visible
|
||||
claude.workspace_visible = visible
|
||||
|
||||
def watch_compact_mode(self, compact: bool) -> None:
|
||||
"""Toggle compact mode class."""
|
||||
container = self.query_one("#main-container")
|
||||
if compact:
|
||||
container.add_class("compact")
|
||||
else:
|
||||
container.remove_class("compact")
|
||||
|
||||
# Data refresh methods
|
||||
async def _refresh_git(self) -> None:
|
||||
"""Refresh git status."""
|
||||
status = await self.git_controller.get_status()
|
||||
if status:
|
||||
sidebar = self.query_one(SidebarPanel)
|
||||
sidebar.update_git_status(status.staged, status.unstaged)
|
||||
sidebar.current_branch = status.branch
|
||||
|
||||
branches = await self.git_controller.get_branches()
|
||||
if branches:
|
||||
sidebar = self.query_one(SidebarPanel)
|
||||
sidebar.update_branches([b.name for b in branches])
|
||||
|
||||
commits = await self.git_controller.get_log(limit=50)
|
||||
if commits:
|
||||
sidebar = self.query_one(SidebarPanel)
|
||||
sidebar.update_git_graph(commits)
|
||||
|
||||
async def _refresh_problems(self) -> None:
|
||||
"""Refresh linter problems."""
|
||||
problems = await self.problems_controller.run_all()
|
||||
context = self.query_one(ContextPanel)
|
||||
context.update_problems(problems)
|
||||
|
||||
async def _refresh_todos(self) -> None:
|
||||
"""Refresh TODOs."""
|
||||
todos = await self.todos_controller.scan()
|
||||
context = self.query_one(ContextPanel)
|
||||
context.update_todos(todos)
|
||||
|
||||
async def _refresh_jira(self) -> None:
|
||||
"""Refresh Jira content."""
|
||||
context = self.query_one(ContextPanel)
|
||||
context.set_jira_loading()
|
||||
content = await self.jira_controller.get_content()
|
||||
if content:
|
||||
context.update_jira(content)
|
||||
else:
|
||||
context.set_jira_error("Failed to load Jira content")
|
||||
|
||||
# Action methods
|
||||
def action_toggle_sidebar(self) -> None:
|
||||
"""Toggle sidebar visibility."""
|
||||
sidebar = self.query_one(SidebarPanel)
|
||||
sidebar.visible = not sidebar.visible
|
||||
|
||||
def action_toggle_context(self) -> None:
|
||||
"""Toggle context panel visibility."""
|
||||
context = self.query_one(ContextPanel)
|
||||
context.visible = not context.visible
|
||||
|
||||
def action_toggle_terminal(self) -> None:
|
||||
"""Toggle terminal (shows workspace with terminal tab)."""
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
if self.workspace_visible and workspace.active_tab == "terminal":
|
||||
self.workspace_visible = False
|
||||
else:
|
||||
workspace.show_terminal()
|
||||
self.workspace_visible = True
|
||||
|
||||
def action_toggle_compact(self) -> None:
|
||||
"""Toggle compact mode."""
|
||||
self.compact_mode = not self.compact_mode
|
||||
|
||||
def action_toggle_fullscreen(self) -> None:
|
||||
"""Toggle fullscreen for focused panel."""
|
||||
# TODO: Implement fullscreen toggle
|
||||
pass
|
||||
|
||||
def action_escape(self) -> None:
|
||||
"""Handle escape key."""
|
||||
if self.fullscreen_panel:
|
||||
self.fullscreen_panel = None
|
||||
elif self.workspace_visible:
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
if not workspace.has_unsaved_changes():
|
||||
self.workspace_visible = False
|
||||
|
||||
def action_focus_claude(self) -> None:
|
||||
"""Focus Claude panel."""
|
||||
claude = self.query_one(ClaudePanel)
|
||||
claude.focus_terminal()
|
||||
|
||||
def action_focus_editor(self) -> None:
|
||||
"""Focus editor."""
|
||||
self.workspace_visible = True
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
workspace.focus_tab("editor")
|
||||
|
||||
def action_focus_terminal(self) -> None:
|
||||
"""Focus terminal."""
|
||||
self.workspace_visible = True
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
workspace.show_terminal()
|
||||
|
||||
def action_focus_sidebar(self) -> None:
|
||||
"""Focus sidebar."""
|
||||
sidebar = self.query_one(SidebarPanel)
|
||||
sidebar.visible = True
|
||||
sidebar.focus()
|
||||
|
||||
def action_close_tab(self) -> None:
|
||||
"""Close current tab/editor."""
|
||||
# TODO: Implement tab closing
|
||||
pass
|
||||
|
||||
def action_open_git(self) -> None:
|
||||
"""Open git panel."""
|
||||
sidebar = self.query_one(SidebarPanel)
|
||||
sidebar.visible = True
|
||||
sidebar.focus_tab("sidebar-git")
|
||||
|
||||
def action_open_problems(self) -> None:
|
||||
"""Open problems panel."""
|
||||
context = self.query_one(ContextPanel)
|
||||
context.visible = True
|
||||
context.focus_problems()
|
||||
|
||||
def action_next_problem(self) -> None:
|
||||
"""Go to next problem."""
|
||||
# TODO: Implement problem navigation
|
||||
pass
|
||||
|
||||
def action_prev_problem(self) -> None:
|
||||
"""Go to previous problem."""
|
||||
# TODO: Implement problem navigation
|
||||
pass
|
||||
|
||||
def action_save_file(self) -> None:
|
||||
"""Save current file."""
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
# EditorPane handles save internally
|
||||
pass
|
||||
|
||||
def action_goto_line(self) -> None:
|
||||
"""Go to line dialog."""
|
||||
# TODO: Implement go to line
|
||||
pass
|
||||
|
||||
def action_quick_open(self) -> None:
|
||||
"""Quick file open."""
|
||||
# TODO: Implement quick open
|
||||
pass
|
||||
|
||||
def action_select_theme(self) -> None:
|
||||
"""Open theme selector."""
|
||||
# TODO: Implement theme selector via command palette
|
||||
pass
|
||||
|
||||
# Event handlers for panel messages
|
||||
async def on_sidebar_panel_file_selected(
|
||||
self,
|
||||
event: SidebarPanel.FileSelected,
|
||||
) -> None:
|
||||
"""Handle file selection from sidebar."""
|
||||
self.current_file = event.path
|
||||
self.workspace_visible = True
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
workspace.open_file(event.path)
|
||||
|
||||
async def on_sidebar_panel_git_file_selected(
|
||||
self,
|
||||
event: SidebarPanel.GitFileSelected,
|
||||
) -> None:
|
||||
"""Handle git file selection - show diff."""
|
||||
diff = await self.diff_controller.get_file_diff(
|
||||
str(event.path),
|
||||
staged=event.staged,
|
||||
)
|
||||
if diff:
|
||||
self.workspace_visible = True
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
workspace.show_diff(diff)
|
||||
|
||||
async def on_sidebar_panel_branch_changed(
|
||||
self,
|
||||
event: SidebarPanel.BranchChanged,
|
||||
) -> None:
|
||||
"""Handle branch change."""
|
||||
success = await self.git_controller.checkout_branch(event.branch)
|
||||
if success:
|
||||
await self._refresh_git()
|
||||
|
||||
async def on_context_panel_problem_clicked(
|
||||
self,
|
||||
event: ContextPanel.ProblemClicked,
|
||||
) -> None:
|
||||
"""Handle problem click - open file at line."""
|
||||
problem = event.problem
|
||||
self.workspace_visible = True
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
workspace.open_file(problem.file_path, line=problem.line)
|
||||
|
||||
async def on_context_panel_todo_clicked(
|
||||
self,
|
||||
event: ContextPanel.TodoClicked,
|
||||
) -> None:
|
||||
"""Handle TODO click - open file at line."""
|
||||
item = event.item
|
||||
self.workspace_visible = True
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
workspace.open_file(item.file_path, line=item.line)
|
||||
|
||||
async def on_context_panel_jira_refresh_requested(
|
||||
self,
|
||||
event: ContextPanel.JiraRefreshRequested,
|
||||
) -> None:
|
||||
"""Handle Jira refresh request."""
|
||||
await self._refresh_jira()
|
||||
|
||||
async def on_workspace_panel_file_saved(
|
||||
self,
|
||||
event: WorkspacePanel.FileSaved,
|
||||
) -> None:
|
||||
"""Handle file save - refresh problems and git."""
|
||||
await self._refresh_git()
|
||||
await self._refresh_problems()
|
||||
|
||||
async def on_workspace_panel_diff_accepted(
|
||||
self,
|
||||
event: WorkspacePanel.DiffAccepted,
|
||||
) -> None:
|
||||
"""Handle diff accept."""
|
||||
await self.diff_controller.accept_proposal(event.file_path)
|
||||
await self._refresh_git()
|
||||
await self._refresh_problems()
|
||||
|
||||
async def on_workspace_panel_diff_rejected(
|
||||
self,
|
||||
event: WorkspacePanel.DiffRejected,
|
||||
) -> None:
|
||||
"""Handle diff reject."""
|
||||
await self.diff_controller.reject_proposal(event.file_path)
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
workspace.clear_diff()
|
||||
|
||||
async def on_workspace_panel_command_submitted(
|
||||
self,
|
||||
event: WorkspacePanel.CommandSubmitted,
|
||||
) -> None:
|
||||
"""Handle terminal command - run and show output."""
|
||||
from clide.services.process_service import ProcessService
|
||||
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
result = await ProcessService.run_async(
|
||||
event.command,
|
||||
cwd=self.workdir,
|
||||
shell=True,
|
||||
)
|
||||
if result.stdout:
|
||||
workspace.write_terminal_output(result.stdout)
|
||||
if result.stderr:
|
||||
workspace.write_terminal_error(result.stderr)
|
||||
|
||||
# Refresh after command
|
||||
await self._refresh_git()
|
||||
|
||||
def on_claude_panel_claude_started(
|
||||
self,
|
||||
event: ClaudePanel.ClaudeStarted,
|
||||
) -> None:
|
||||
"""Handle Claude Code started."""
|
||||
self.notify("Claude Code started", severity="information")
|
||||
|
||||
def on_claude_panel_claude_exited(
|
||||
self,
|
||||
event: ClaudePanel.ClaudeExited,
|
||||
) -> None:
|
||||
"""Handle Claude Code exited."""
|
||||
if event.return_code != 0:
|
||||
self.notify(f"Claude Code exited with code {event.return_code}", severity="warning")
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Typer CLI entry point for Clide."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Optional
|
||||
|
||||
import typer
|
||||
|
||||
from clide import __version__
|
||||
|
||||
app = typer.Typer(
|
||||
name="clide",
|
||||
help="A TUI CLI IDE for Claude Code CLI",
|
||||
add_completion=True,
|
||||
no_args_is_help=False,
|
||||
)
|
||||
|
||||
|
||||
def version_callback(value: bool) -> None:
|
||||
"""Print version and exit."""
|
||||
if value:
|
||||
typer.echo(f"clide {__version__}")
|
||||
raise typer.Exit()
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def main(
|
||||
ctx: typer.Context,
|
||||
version: Annotated[
|
||||
Optional[bool],
|
||||
typer.Option("--version", "-v", callback=version_callback, is_eager=True),
|
||||
] = None,
|
||||
workdir: Annotated[
|
||||
Optional[Path],
|
||||
typer.Option("--workdir", "-w", help="Working directory to open"),
|
||||
] = None,
|
||||
) -> None:
|
||||
"""Launch Clide TUI application."""
|
||||
if ctx.invoked_subcommand is None:
|
||||
from clide.app import ClideApp
|
||||
|
||||
app_instance = ClideApp(workdir=workdir)
|
||||
app_instance.run()
|
||||
|
||||
|
||||
@app.command()
|
||||
def config() -> None:
|
||||
"""Open configuration editor."""
|
||||
typer.echo("Configuration editor not yet implemented")
|
||||
|
||||
|
||||
@app.command()
|
||||
def extensions() -> None:
|
||||
"""List installed extensions."""
|
||||
typer.echo("Extension manager not yet implemented")
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Domain controllers for Clide."""
|
||||
|
||||
from clide.controllers.base import controller, ControllerMixin
|
||||
from clide.controllers.git import GitController
|
||||
from clide.controllers.editor import EditorController
|
||||
from clide.controllers.diff import DiffController
|
||||
from clide.controllers.problems import ProblemsController
|
||||
from clide.controllers.todos import TodosController
|
||||
from clide.controllers.jira import JiraController
|
||||
|
||||
__all__ = [
|
||||
"controller",
|
||||
"ControllerMixin",
|
||||
"GitController",
|
||||
"EditorController",
|
||||
"DiffController",
|
||||
"ProblemsController",
|
||||
"TodosController",
|
||||
"JiraController",
|
||||
]
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Base controller utilities using decorator pattern."""
|
||||
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Callable, TypeVar
|
||||
|
||||
from textual.message import Message
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from textual.app import App
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def controller(cls: type[T]) -> type[T]:
|
||||
"""Decorator to add controller capabilities to a class.
|
||||
|
||||
Adds:
|
||||
- _app attribute for parent application reference
|
||||
- set_app() method to set the application
|
||||
- post_message() method to emit messages
|
||||
- initialize() and shutdown() lifecycle hooks (if not defined)
|
||||
|
||||
Usage:
|
||||
@controller
|
||||
class GitController:
|
||||
def __init__(self, workdir: Path) -> None:
|
||||
self.workdir = workdir
|
||||
|
||||
async def get_status(self) -> GitStatus:
|
||||
...
|
||||
"""
|
||||
original_init = cls.__init__
|
||||
|
||||
@wraps(original_init)
|
||||
def new_init(self, *args, **kwargs):
|
||||
self._app = None
|
||||
original_init(self, *args, **kwargs)
|
||||
|
||||
cls.__init__ = new_init
|
||||
|
||||
def set_app(self, app: "App[object]") -> None:
|
||||
"""Set the parent application."""
|
||||
self._app = app
|
||||
|
||||
def post_message(self, message: Message) -> None:
|
||||
"""Post a message to the application's message queue."""
|
||||
if self._app:
|
||||
self._app.post_message(message)
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize the controller. Called after app mount."""
|
||||
pass
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""Clean up resources. Called before app exit."""
|
||||
pass
|
||||
|
||||
# Only add methods if they don't exist
|
||||
if not hasattr(cls, "set_app"):
|
||||
cls.set_app = set_app
|
||||
if not hasattr(cls, "post_message"):
|
||||
cls.post_message = post_message
|
||||
if not hasattr(cls, "initialize"):
|
||||
cls.initialize = initialize
|
||||
if not hasattr(cls, "shutdown"):
|
||||
cls.shutdown = shutdown
|
||||
|
||||
return cls
|
||||
|
||||
|
||||
class ControllerMixin:
|
||||
"""Mixin alternative for controller capabilities.
|
||||
|
||||
Use this if you prefer inheritance over decorators.
|
||||
|
||||
Usage:
|
||||
class GitController(ControllerMixin):
|
||||
def __init__(self, workdir: Path) -> None:
|
||||
self.workdir = workdir
|
||||
"""
|
||||
|
||||
_app: "App[object] | None" = None
|
||||
|
||||
def set_app(self, app: "App[object]") -> None:
|
||||
"""Set the parent application."""
|
||||
self._app = app
|
||||
|
||||
@property
|
||||
def app(self) -> "App[object] | None":
|
||||
"""Get the parent application."""
|
||||
return self._app
|
||||
|
||||
def post_message(self, message: Message) -> None:
|
||||
"""Post a message to the application's message queue."""
|
||||
if self._app:
|
||||
self._app.post_message(message)
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize the controller. Called after app mount."""
|
||||
pass
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""Clean up resources. Called before app exit."""
|
||||
pass
|
||||
@@ -0,0 +1,330 @@
|
||||
"""Diff controller for viewing and managing diffs."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from textual.message import Message
|
||||
|
||||
from clide.controllers.base import controller
|
||||
from clide.models.diff import ChangeType, DiffContent, DiffHunk, DiffLine, DiffViewState
|
||||
from clide.services.git_service import GitService
|
||||
|
||||
|
||||
@controller
|
||||
class DiffController:
|
||||
"""Controller for diff viewing and Claude-proposed changes."""
|
||||
|
||||
class DiffLoaded(Message):
|
||||
"""Emitted when a diff is loaded."""
|
||||
|
||||
def __init__(self, diff: DiffContent) -> None:
|
||||
self.diff = diff
|
||||
super().__init__()
|
||||
|
||||
class HunkAccepted(Message):
|
||||
"""Emitted when a hunk is accepted."""
|
||||
|
||||
def __init__(self, hunk_index: int) -> None:
|
||||
self.hunk_index = hunk_index
|
||||
super().__init__()
|
||||
|
||||
class HunkRejected(Message):
|
||||
"""Emitted when a hunk is rejected."""
|
||||
|
||||
def __init__(self, hunk_index: int) -> None:
|
||||
self.hunk_index = hunk_index
|
||||
super().__init__()
|
||||
|
||||
class AllChangesAccepted(Message):
|
||||
"""Emitted when all changes are accepted."""
|
||||
pass
|
||||
|
||||
class AllChangesRejected(Message):
|
||||
"""Emitted when all changes are rejected."""
|
||||
pass
|
||||
|
||||
def __init__(self, repo_path: Path) -> None:
|
||||
self._git_service = GitService(repo_path)
|
||||
self._state = DiffViewState()
|
||||
|
||||
@property
|
||||
def state(self) -> DiffViewState:
|
||||
"""Get diff view state."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def diff(self) -> DiffContent | None:
|
||||
"""Get current diff content."""
|
||||
return self._state.diff
|
||||
|
||||
@property
|
||||
def is_proposal(self) -> bool:
|
||||
"""Check if current diff is a Claude proposal."""
|
||||
return self._state.is_proposal
|
||||
|
||||
async def load_git_diff(self, path: str, staged: bool = False) -> DiffContent | None:
|
||||
"""Load diff from git.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
staged: Whether to load staged diff
|
||||
|
||||
Returns:
|
||||
DiffContent or None if no diff
|
||||
"""
|
||||
diff_text = await self._git_service.get_diff(path, staged)
|
||||
if not diff_text:
|
||||
return None
|
||||
|
||||
diff = self._parse_diff(path, diff_text)
|
||||
self._state.diff = diff
|
||||
self._state.is_proposal = False
|
||||
self._state.accepted_hunks = set()
|
||||
self._state.rejected_hunks = set()
|
||||
|
||||
return diff
|
||||
|
||||
def load_proposal(self, path: str, old_content: str, new_content: str) -> DiffContent:
|
||||
"""Load a Claude-proposed change as a diff.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
old_content: Original content
|
||||
new_content: Proposed content
|
||||
|
||||
Returns:
|
||||
DiffContent of the proposal
|
||||
"""
|
||||
diff = self._create_diff_from_content(path, old_content, new_content)
|
||||
self._state.diff = diff
|
||||
self._state.is_proposal = True
|
||||
self._state.accepted_hunks = set()
|
||||
self._state.rejected_hunks = set()
|
||||
|
||||
return diff
|
||||
|
||||
def accept_hunk(self, index: int) -> None:
|
||||
"""Accept a specific hunk.
|
||||
|
||||
Args:
|
||||
index: Hunk index
|
||||
"""
|
||||
self._state.accepted_hunks.add(index)
|
||||
self._state.rejected_hunks.discard(index)
|
||||
|
||||
def reject_hunk(self, index: int) -> None:
|
||||
"""Reject a specific hunk.
|
||||
|
||||
Args:
|
||||
index: Hunk index
|
||||
"""
|
||||
self._state.rejected_hunks.add(index)
|
||||
self._state.accepted_hunks.discard(index)
|
||||
|
||||
def accept_all(self) -> None:
|
||||
"""Accept all hunks."""
|
||||
if self._state.diff:
|
||||
for i in range(len(self._state.diff.hunks)):
|
||||
self._state.accepted_hunks.add(i)
|
||||
self._state.rejected_hunks.clear()
|
||||
|
||||
def reject_all(self) -> None:
|
||||
"""Reject all hunks."""
|
||||
if self._state.diff:
|
||||
for i in range(len(self._state.diff.hunks)):
|
||||
self._state.rejected_hunks.add(i)
|
||||
self._state.accepted_hunks.clear()
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear current diff."""
|
||||
self._state.diff = None
|
||||
self._state.is_proposal = False
|
||||
self._state.accepted_hunks = set()
|
||||
self._state.rejected_hunks = set()
|
||||
|
||||
def toggle_side_by_side(self) -> bool:
|
||||
"""Toggle side-by-side view.
|
||||
|
||||
Returns:
|
||||
New side_by_side value
|
||||
"""
|
||||
self._state.side_by_side = not self._state.side_by_side
|
||||
return self._state.side_by_side
|
||||
|
||||
def _parse_diff(self, path: str, diff_text: str) -> DiffContent:
|
||||
"""Parse git diff output into DiffContent."""
|
||||
hunks: list[DiffHunk] = []
|
||||
current_hunk_lines: list[DiffLine] = []
|
||||
current_header = ""
|
||||
old_start = old_count = new_start = new_count = 0
|
||||
|
||||
for line in diff_text.split("\n"):
|
||||
if line.startswith("@@"):
|
||||
# Save previous hunk
|
||||
if current_hunk_lines:
|
||||
hunks.append(DiffHunk(
|
||||
header=current_header,
|
||||
old_start=old_start,
|
||||
old_count=old_count,
|
||||
new_start=new_start,
|
||||
new_count=new_count,
|
||||
lines=tuple(current_hunk_lines),
|
||||
))
|
||||
current_hunk_lines = []
|
||||
|
||||
# Parse hunk header
|
||||
current_header = line
|
||||
# Format: @@ -old_start,old_count +new_start,new_count @@
|
||||
import re
|
||||
match = re.match(r"@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@", line)
|
||||
if match:
|
||||
old_start = int(match.group(1))
|
||||
old_count = int(match.group(2)) if match.group(2) else 1
|
||||
new_start = int(match.group(3))
|
||||
new_count = int(match.group(4)) if match.group(4) else 1
|
||||
|
||||
elif line.startswith("+") and not line.startswith("+++"):
|
||||
current_hunk_lines.append(DiffLine(
|
||||
change_type=ChangeType.ADDED,
|
||||
content=line[1:],
|
||||
new_line_num=new_start + len([l for l in current_hunk_lines if l.change_type != ChangeType.REMOVED]),
|
||||
))
|
||||
elif line.startswith("-") and not line.startswith("---"):
|
||||
current_hunk_lines.append(DiffLine(
|
||||
change_type=ChangeType.REMOVED,
|
||||
content=line[1:],
|
||||
old_line_num=old_start + len([l for l in current_hunk_lines if l.change_type != ChangeType.ADDED]),
|
||||
))
|
||||
elif line.startswith(" "):
|
||||
old_num = old_start + len([l for l in current_hunk_lines if l.change_type != ChangeType.ADDED])
|
||||
new_num = new_start + len([l for l in current_hunk_lines if l.change_type != ChangeType.REMOVED])
|
||||
current_hunk_lines.append(DiffLine(
|
||||
change_type=ChangeType.CONTEXT,
|
||||
content=line[1:],
|
||||
old_line_num=old_num,
|
||||
new_line_num=new_num,
|
||||
))
|
||||
|
||||
# Save last hunk
|
||||
if current_hunk_lines:
|
||||
hunks.append(DiffHunk(
|
||||
header=current_header,
|
||||
old_start=old_start,
|
||||
old_count=old_count,
|
||||
new_start=new_start,
|
||||
new_count=new_count,
|
||||
lines=tuple(current_hunk_lines),
|
||||
))
|
||||
|
||||
return DiffContent(
|
||||
file_path=path,
|
||||
hunks=tuple(hunks),
|
||||
)
|
||||
|
||||
async def get_file_diff(self, path: str, staged: bool = False) -> DiffContent | None:
|
||||
"""Get diff for a file (alias for load_git_diff).
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
staged: Whether to get staged diff
|
||||
|
||||
Returns:
|
||||
DiffContent or None
|
||||
"""
|
||||
return await self.load_git_diff(path, staged)
|
||||
|
||||
async def accept_proposal(self, file_path: str) -> bool:
|
||||
"""Accept a proposed change and apply it.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
if not self._state.diff or not self._state.is_proposal:
|
||||
return False
|
||||
|
||||
self.accept_all()
|
||||
# TODO: Apply the changes to the file
|
||||
self.clear()
|
||||
return True
|
||||
|
||||
async def reject_proposal(self, file_path: str) -> bool:
|
||||
"""Reject a proposed change.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
self.reject_all()
|
||||
self.clear()
|
||||
return True
|
||||
|
||||
def _create_diff_from_content(self, path: str, old: str, new: str) -> DiffContent:
|
||||
"""Create diff from old and new content."""
|
||||
import difflib
|
||||
|
||||
old_lines = old.splitlines(keepends=True)
|
||||
new_lines = new.splitlines(keepends=True)
|
||||
|
||||
diff_lines: list[DiffLine] = []
|
||||
old_num = new_num = 1
|
||||
|
||||
for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(None, old_lines, new_lines).get_opcodes():
|
||||
if tag == "equal":
|
||||
for line in old_lines[i1:i2]:
|
||||
diff_lines.append(DiffLine(
|
||||
change_type=ChangeType.CONTEXT,
|
||||
content=line.rstrip("\n"),
|
||||
old_line_num=old_num,
|
||||
new_line_num=new_num,
|
||||
))
|
||||
old_num += 1
|
||||
new_num += 1
|
||||
elif tag == "delete":
|
||||
for line in old_lines[i1:i2]:
|
||||
diff_lines.append(DiffLine(
|
||||
change_type=ChangeType.REMOVED,
|
||||
content=line.rstrip("\n"),
|
||||
old_line_num=old_num,
|
||||
))
|
||||
old_num += 1
|
||||
elif tag == "insert":
|
||||
for line in new_lines[j1:j2]:
|
||||
diff_lines.append(DiffLine(
|
||||
change_type=ChangeType.ADDED,
|
||||
content=line.rstrip("\n"),
|
||||
new_line_num=new_num,
|
||||
))
|
||||
new_num += 1
|
||||
elif tag == "replace":
|
||||
for line in old_lines[i1:i2]:
|
||||
diff_lines.append(DiffLine(
|
||||
change_type=ChangeType.REMOVED,
|
||||
content=line.rstrip("\n"),
|
||||
old_line_num=old_num,
|
||||
))
|
||||
old_num += 1
|
||||
for line in new_lines[j1:j2]:
|
||||
diff_lines.append(DiffLine(
|
||||
change_type=ChangeType.ADDED,
|
||||
content=line.rstrip("\n"),
|
||||
new_line_num=new_num,
|
||||
))
|
||||
new_num += 1
|
||||
|
||||
hunk = DiffHunk(
|
||||
header="@@ -1 +1 @@",
|
||||
old_start=1,
|
||||
old_count=len(old_lines),
|
||||
new_start=1,
|
||||
new_count=len(new_lines),
|
||||
lines=tuple(diff_lines),
|
||||
)
|
||||
|
||||
return DiffContent(
|
||||
file_path=path,
|
||||
hunks=(hunk,),
|
||||
)
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Editor controller for managing open files."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from textual.message import Message
|
||||
|
||||
from clide.controllers.base import controller
|
||||
from clide.models.editor import CursorPosition, EditorState, FileBuffer
|
||||
from clide.services.file_service import FileService
|
||||
|
||||
|
||||
@controller
|
||||
class EditorController:
|
||||
"""Controller for editor state and file operations."""
|
||||
|
||||
class FileOpened(Message):
|
||||
"""Emitted when a file is opened."""
|
||||
|
||||
def __init__(self, buffer: FileBuffer) -> None:
|
||||
self.buffer = buffer
|
||||
super().__init__()
|
||||
|
||||
class FileClosed(Message):
|
||||
"""Emitted when a file is closed."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = path
|
||||
super().__init__()
|
||||
|
||||
class FileSaved(Message):
|
||||
"""Emitted when a file is saved."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = path
|
||||
super().__init__()
|
||||
|
||||
class FileModified(Message):
|
||||
"""Emitted when file content changes."""
|
||||
|
||||
def __init__(self, path: Path, is_modified: bool) -> None:
|
||||
self.path = path
|
||||
self.is_modified = is_modified
|
||||
super().__init__()
|
||||
|
||||
class ActiveBufferChanged(Message):
|
||||
"""Emitted when active buffer changes."""
|
||||
|
||||
def __init__(self, buffer: FileBuffer | None) -> None:
|
||||
self.buffer = buffer
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, project_path: Path | None = None) -> None:
|
||||
self._project_path = project_path or Path.cwd()
|
||||
self._state = EditorState()
|
||||
|
||||
@property
|
||||
def state(self) -> EditorState:
|
||||
"""Get editor state."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def active_buffer(self) -> FileBuffer | None:
|
||||
"""Get currently active buffer."""
|
||||
return self._state.active_buffer
|
||||
|
||||
@property
|
||||
def open_files(self) -> list[FileBuffer]:
|
||||
"""Get list of open file buffers."""
|
||||
return self._state.buffers
|
||||
|
||||
@property
|
||||
def has_unsaved_changes(self) -> bool:
|
||||
"""Check if any buffer has unsaved changes."""
|
||||
return any(b.is_modified for b in self._state.buffers)
|
||||
|
||||
async def open_file(self, path: Path, line: int | None = None) -> FileBuffer:
|
||||
"""Open a file in the editor.
|
||||
|
||||
Args:
|
||||
path: File path to open
|
||||
line: Optional line number to jump to
|
||||
|
||||
Returns:
|
||||
FileBuffer for the opened file
|
||||
"""
|
||||
# Check if already open
|
||||
existing = self._state.get_buffer_by_path(path)
|
||||
if existing:
|
||||
self._set_active_buffer(existing)
|
||||
if line:
|
||||
existing.cursor = CursorPosition(line=line - 1, column=0)
|
||||
return existing
|
||||
|
||||
# Read file content
|
||||
content = await self._service.read_file(path)
|
||||
language = await self._service.get_language(path)
|
||||
|
||||
cursor = CursorPosition(line=line - 1 if line else 0, column=0)
|
||||
|
||||
buffer = FileBuffer(
|
||||
path=path,
|
||||
content=content,
|
||||
language=language,
|
||||
cursor=cursor,
|
||||
)
|
||||
|
||||
self._state.buffers.append(buffer)
|
||||
self._set_active_buffer(buffer)
|
||||
|
||||
# Add to recent files
|
||||
if path not in self._state.recent_files:
|
||||
self._state.recent_files.insert(0, path)
|
||||
self._state.recent_files = self._state.recent_files[:20]
|
||||
|
||||
return buffer
|
||||
|
||||
async def close_file(self, path: Path) -> bool:
|
||||
"""Close a file buffer.
|
||||
|
||||
Args:
|
||||
path: File path to close
|
||||
|
||||
Returns:
|
||||
True if closed (may be False if unsaved and user cancels)
|
||||
"""
|
||||
buffer = self._state.get_buffer_by_path(path)
|
||||
if not buffer:
|
||||
return True
|
||||
|
||||
# Remove from buffers
|
||||
self._state.buffers.remove(buffer)
|
||||
|
||||
# Update active buffer
|
||||
if self._state.active_buffer_index is not None:
|
||||
if self._state.active_buffer_index >= len(self._state.buffers):
|
||||
self._state.active_buffer_index = (
|
||||
len(self._state.buffers) - 1 if self._state.buffers else None
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def save_file(self, path: Path | None = None) -> bool:
|
||||
"""Save a file.
|
||||
|
||||
Args:
|
||||
path: File path (defaults to active buffer)
|
||||
|
||||
Returns:
|
||||
True if saved successfully
|
||||
"""
|
||||
if path:
|
||||
buffer = self._state.get_buffer_by_path(path)
|
||||
else:
|
||||
buffer = self.active_buffer
|
||||
|
||||
if not buffer:
|
||||
return False
|
||||
|
||||
await self._service.write_file(buffer.path, buffer.content)
|
||||
buffer.is_modified = False
|
||||
|
||||
return True
|
||||
|
||||
async def save_all(self) -> int:
|
||||
"""Save all modified buffers.
|
||||
|
||||
Returns:
|
||||
Number of files saved
|
||||
"""
|
||||
count = 0
|
||||
for buffer in self._state.buffers:
|
||||
if buffer.is_modified:
|
||||
await self._service.write_file(buffer.path, buffer.content)
|
||||
buffer.is_modified = False
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def update_content(self, path: Path, content: str) -> None:
|
||||
"""Update buffer content.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
content: New content
|
||||
"""
|
||||
buffer = self._state.get_buffer_by_path(path)
|
||||
if buffer:
|
||||
buffer.content = content
|
||||
buffer.is_modified = True
|
||||
|
||||
def update_cursor(self, path: Path, line: int, column: int) -> None:
|
||||
"""Update cursor position.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
line: Line number (0-indexed)
|
||||
column: Column number (0-indexed)
|
||||
"""
|
||||
buffer = self._state.get_buffer_by_path(path)
|
||||
if buffer:
|
||||
buffer.cursor = CursorPosition(line=line, column=column)
|
||||
|
||||
def set_active_by_index(self, index: int) -> None:
|
||||
"""Set active buffer by index.
|
||||
|
||||
Args:
|
||||
index: Buffer index
|
||||
"""
|
||||
if 0 <= index < len(self._state.buffers):
|
||||
self._state.active_buffer_index = index
|
||||
|
||||
def _set_active_buffer(self, buffer: FileBuffer) -> None:
|
||||
"""Set active buffer."""
|
||||
try:
|
||||
index = self._state.buffers.index(buffer)
|
||||
self._state.active_buffer_index = index
|
||||
except ValueError:
|
||||
pass
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Git controller for managing git operations."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from textual.message import Message
|
||||
|
||||
from clide.controllers.base import controller
|
||||
from clide.models.git import GitBranch, GitCommit, GitStatus
|
||||
from clide.services.git_service import GitService
|
||||
|
||||
|
||||
@controller
|
||||
class GitController:
|
||||
"""Controller for git operations."""
|
||||
|
||||
class StatusUpdated(Message):
|
||||
"""Emitted when git status changes."""
|
||||
|
||||
def __init__(self, status: GitStatus) -> None:
|
||||
self.status = status
|
||||
super().__init__()
|
||||
|
||||
class BranchesUpdated(Message):
|
||||
"""Emitted when branches list changes."""
|
||||
|
||||
def __init__(self, branches: list[GitBranch]) -> None:
|
||||
self.branches = branches
|
||||
super().__init__()
|
||||
|
||||
class LogUpdated(Message):
|
||||
"""Emitted when commit log is refreshed."""
|
||||
|
||||
def __init__(self, commits: list[GitCommit]) -> None:
|
||||
self.commits = commits
|
||||
super().__init__()
|
||||
|
||||
class FileStaged(Message):
|
||||
"""Emitted when a file is staged."""
|
||||
|
||||
def __init__(self, path: str) -> None:
|
||||
self.path = path
|
||||
super().__init__()
|
||||
|
||||
class FileUnstaged(Message):
|
||||
"""Emitted when a file is unstaged."""
|
||||
|
||||
def __init__(self, path: str) -> None:
|
||||
self.path = path
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, repo_path: Path) -> None:
|
||||
self._service = GitService(repo_path)
|
||||
self._status: GitStatus | None = None
|
||||
self._branches: list[GitBranch] = []
|
||||
self._commits: list[GitCommit] = []
|
||||
|
||||
@property
|
||||
def status(self) -> GitStatus | None:
|
||||
"""Get current git status."""
|
||||
return self._status
|
||||
|
||||
@property
|
||||
def branches(self) -> list[GitBranch]:
|
||||
"""Get list of branches."""
|
||||
return self._branches
|
||||
|
||||
@property
|
||||
def commits(self) -> list[GitCommit]:
|
||||
"""Get commit log."""
|
||||
return self._commits
|
||||
|
||||
@property
|
||||
def current_branch(self) -> str:
|
||||
"""Get current branch name."""
|
||||
return self._status.branch if self._status else "unknown"
|
||||
|
||||
async def refresh_status(self) -> GitStatus:
|
||||
"""Refresh git status.
|
||||
|
||||
Returns:
|
||||
Updated GitStatus
|
||||
"""
|
||||
self._status = await self._service.get_status()
|
||||
return self._status
|
||||
|
||||
async def get_status(self) -> GitStatus | None:
|
||||
"""Get git status (refreshes if needed)."""
|
||||
return await self.refresh_status()
|
||||
|
||||
async def refresh_branches(self) -> list[GitBranch]:
|
||||
"""Refresh branches list.
|
||||
|
||||
Returns:
|
||||
Updated list of branches
|
||||
"""
|
||||
self._branches = await self._service.get_branches()
|
||||
return self._branches
|
||||
|
||||
async def get_branches(self) -> list[GitBranch]:
|
||||
"""Get branches list (refreshes if needed)."""
|
||||
return await self.refresh_branches()
|
||||
|
||||
async def refresh_log(self, max_count: int = 50) -> list[GitCommit]:
|
||||
"""Refresh commit log.
|
||||
|
||||
Args:
|
||||
max_count: Maximum commits to fetch
|
||||
|
||||
Returns:
|
||||
Updated list of commits
|
||||
"""
|
||||
self._commits = await self._service.get_log(max_count)
|
||||
return self._commits
|
||||
|
||||
async def get_log(self, limit: int = 50) -> list[GitCommit]:
|
||||
"""Get commit log (refreshes if needed)."""
|
||||
return await self.refresh_log(limit)
|
||||
|
||||
async def stage_file(self, path: str) -> bool:
|
||||
"""Stage a file.
|
||||
|
||||
Args:
|
||||
path: File path to stage
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
success = await self._service.stage_file(path)
|
||||
if success:
|
||||
await self.refresh_status()
|
||||
return success
|
||||
|
||||
async def unstage_file(self, path: str) -> bool:
|
||||
"""Unstage a file.
|
||||
|
||||
Args:
|
||||
path: File path to unstage
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
success = await self._service.unstage_file(path)
|
||||
if success:
|
||||
await self.refresh_status()
|
||||
return success
|
||||
|
||||
async def discard_changes(self, path: str) -> bool:
|
||||
"""Discard changes to a file.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
success = await self._service.discard_changes(path)
|
||||
if success:
|
||||
await self.refresh_status()
|
||||
return success
|
||||
|
||||
async def checkout_branch(self, branch: str) -> bool:
|
||||
"""Checkout a branch.
|
||||
|
||||
Args:
|
||||
branch: Branch name
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
success = await self._service.checkout_branch(branch)
|
||||
if success:
|
||||
await self.refresh_status()
|
||||
await self.refresh_branches()
|
||||
return success
|
||||
|
||||
async def create_branch(self, name: str) -> bool:
|
||||
"""Create and checkout a new branch.
|
||||
|
||||
Args:
|
||||
name: New branch name
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
success = await self._service.create_branch(name)
|
||||
if success:
|
||||
await self.refresh_status()
|
||||
await self.refresh_branches()
|
||||
return success
|
||||
|
||||
async def get_file_diff(self, path: str, staged: bool = False) -> str:
|
||||
"""Get diff for a file.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
staged: Whether to get staged diff
|
||||
|
||||
Returns:
|
||||
Diff string
|
||||
"""
|
||||
return await self._service.get_diff(path, staged)
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Jira controller for Jira CLI integration."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from textual.message import Message
|
||||
|
||||
from clide.controllers.base import controller
|
||||
from clide.services.process_service import ProcessService
|
||||
|
||||
|
||||
@controller
|
||||
class JiraController:
|
||||
"""Controller for Jira CLI integration."""
|
||||
|
||||
class JiraOutputUpdated(Message):
|
||||
"""Emitted when Jira output is updated."""
|
||||
|
||||
def __init__(self, output: str) -> None:
|
||||
self.output = output
|
||||
super().__init__()
|
||||
|
||||
class JiraError(Message):
|
||||
"""Emitted when Jira command fails."""
|
||||
|
||||
def __init__(self, error: str) -> None:
|
||||
self.error = error
|
||||
super().__init__()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
project_path: Path | None = None,
|
||||
jira_cli_path: str = "jira",
|
||||
enabled: bool = False,
|
||||
) -> None:
|
||||
self._project_path = project_path or Path.cwd()
|
||||
self._jira_cli = jira_cli_path
|
||||
self._enabled = enabled
|
||||
self._last_output: str = ""
|
||||
self._last_error: str = ""
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Check if Jira integration is enabled."""
|
||||
return self._enabled
|
||||
|
||||
@property
|
||||
def last_output(self) -> str:
|
||||
"""Get last Jira output."""
|
||||
return self._last_output
|
||||
|
||||
@property
|
||||
def last_error(self) -> str:
|
||||
"""Get last error message."""
|
||||
return self._last_error
|
||||
|
||||
def enable(self) -> None:
|
||||
"""Enable Jira integration."""
|
||||
self._enabled = True
|
||||
|
||||
def disable(self) -> None:
|
||||
"""Disable Jira integration."""
|
||||
self._enabled = False
|
||||
|
||||
async def run_command(self, *args: str) -> str:
|
||||
"""Run a Jira CLI command.
|
||||
|
||||
Args:
|
||||
*args: Command arguments
|
||||
|
||||
Returns:
|
||||
Command output
|
||||
"""
|
||||
if not self._enabled:
|
||||
return "Jira integration is disabled"
|
||||
|
||||
process = ProcessService(cwd=self._project_path)
|
||||
result = await process.run(self._jira_cli, *args)
|
||||
|
||||
if result.success:
|
||||
self._last_output = result.stdout
|
||||
self._last_error = ""
|
||||
return result.stdout
|
||||
else:
|
||||
self._last_error = result.stderr
|
||||
return f"Error: {result.stderr}"
|
||||
|
||||
async def list_issues(self, project: str | None = None) -> str:
|
||||
"""List Jira issues.
|
||||
|
||||
Args:
|
||||
project: Optional project key
|
||||
|
||||
Returns:
|
||||
Formatted issue list
|
||||
"""
|
||||
args = ["issue", "list"]
|
||||
if project:
|
||||
args.extend(["--project", project])
|
||||
|
||||
return await self.run_command(*args)
|
||||
|
||||
async def get_issue(self, issue_key: str) -> str:
|
||||
"""Get a specific issue.
|
||||
|
||||
Args:
|
||||
issue_key: Issue key (e.g., PROJ-123)
|
||||
|
||||
Returns:
|
||||
Issue details
|
||||
"""
|
||||
return await self.run_command("issue", "view", issue_key)
|
||||
|
||||
async def get_my_issues(self) -> str:
|
||||
"""Get issues assigned to current user.
|
||||
|
||||
Returns:
|
||||
Formatted issue list
|
||||
"""
|
||||
return await self.run_command("issue", "list", "--assignee", "@me")
|
||||
|
||||
async def get_sprint_issues(self) -> str:
|
||||
"""Get issues in current sprint.
|
||||
|
||||
Returns:
|
||||
Formatted issue list
|
||||
"""
|
||||
return await self.run_command("sprint", "list", "--current")
|
||||
|
||||
async def refresh(self) -> str:
|
||||
"""Refresh Jira data (get my issues).
|
||||
|
||||
Returns:
|
||||
Updated output
|
||||
"""
|
||||
return await self.get_my_issues()
|
||||
|
||||
async def check_available(self) -> bool:
|
||||
"""Check if Jira CLI is available.
|
||||
|
||||
Returns:
|
||||
True if available
|
||||
"""
|
||||
process = ProcessService(cwd=self._project_path)
|
||||
result = await process.run(self._jira_cli, "--version")
|
||||
return result.success
|
||||
|
||||
async def get_content(self) -> str | None:
|
||||
"""Get Jira content for display.
|
||||
|
||||
Returns:
|
||||
Markdown content or None
|
||||
"""
|
||||
if not self._enabled:
|
||||
return None
|
||||
try:
|
||||
return await self.get_my_issues()
|
||||
except Exception:
|
||||
return None
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Problems controller for linter integration."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from textual.message import Message
|
||||
|
||||
from clide.controllers.base import controller
|
||||
from clide.models.problems import Problem, ProblemsSummary, ProblemsState, Severity
|
||||
from clide.services.linter_service import LinterService
|
||||
|
||||
|
||||
@controller
|
||||
class ProblemsController:
|
||||
"""Controller for problems/diagnostics from linters."""
|
||||
|
||||
class ProblemsUpdated(Message):
|
||||
"""Emitted when problems list is updated."""
|
||||
|
||||
def __init__(self, problems: list[Problem], summary: ProblemsSummary) -> None:
|
||||
self.problems = problems
|
||||
self.summary = summary
|
||||
super().__init__()
|
||||
|
||||
class ProblemSelected(Message):
|
||||
"""Emitted when a problem is selected."""
|
||||
|
||||
def __init__(self, problem: Problem) -> None:
|
||||
self.problem = problem
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, project_path: Path, linters: list[str] | None = None) -> None:
|
||||
self._service = LinterService(project_path)
|
||||
self._linters = linters or ["ruff"]
|
||||
self._state = ProblemsState()
|
||||
|
||||
@property
|
||||
def state(self) -> ProblemsState:
|
||||
"""Get problems state."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def problems(self) -> list[Problem]:
|
||||
"""Get list of problems."""
|
||||
return self._state.problems
|
||||
|
||||
@property
|
||||
def summary(self) -> ProblemsSummary:
|
||||
"""Get problems summary."""
|
||||
return self._state.summary
|
||||
|
||||
@property
|
||||
def error_count(self) -> int:
|
||||
"""Get error count."""
|
||||
return self._state.summary.errors
|
||||
|
||||
@property
|
||||
def warning_count(self) -> int:
|
||||
"""Get warning count."""
|
||||
return self._state.summary.warnings
|
||||
|
||||
async def refresh(self) -> tuple[list[Problem], ProblemsSummary]:
|
||||
"""Refresh problems from all linters.
|
||||
|
||||
Returns:
|
||||
Tuple of (problems, summary)
|
||||
"""
|
||||
problems, summary = await self._service.run_all(self._linters)
|
||||
|
||||
self._state.problems = problems
|
||||
self._state.summary = summary
|
||||
|
||||
return problems, summary
|
||||
|
||||
def filter_by_severity(self, severity: Severity | None) -> list[Problem]:
|
||||
"""Filter problems by severity.
|
||||
|
||||
Args:
|
||||
severity: Severity to filter by, or None for all
|
||||
|
||||
Returns:
|
||||
Filtered list of problems
|
||||
"""
|
||||
self._state.filter_severity = severity
|
||||
|
||||
if severity is None:
|
||||
return self._state.problems
|
||||
|
||||
return [p for p in self._state.problems if p.severity == severity]
|
||||
|
||||
def filter_by_source(self, source: str | None) -> list[Problem]:
|
||||
"""Filter problems by source linter.
|
||||
|
||||
Args:
|
||||
source: Source to filter by, or None for all
|
||||
|
||||
Returns:
|
||||
Filtered list of problems
|
||||
"""
|
||||
self._state.filter_source = source
|
||||
|
||||
if source is None:
|
||||
return self._state.problems
|
||||
|
||||
return [p for p in self._state.problems if p.source == source]
|
||||
|
||||
def get_problems_for_file(self, path: Path) -> list[Problem]:
|
||||
"""Get problems for a specific file.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
|
||||
Returns:
|
||||
List of problems for that file
|
||||
"""
|
||||
return self._state.problems_for_file(path)
|
||||
|
||||
def select_problem(self, index: int) -> Problem | None:
|
||||
"""Select a problem by index.
|
||||
|
||||
Args:
|
||||
index: Problem index
|
||||
|
||||
Returns:
|
||||
Selected problem or None
|
||||
"""
|
||||
if 0 <= index < len(self._state.problems):
|
||||
self._state.selected_index = index
|
||||
return self._state.problems[index]
|
||||
return None
|
||||
|
||||
def next_problem(self) -> Problem | None:
|
||||
"""Select next problem.
|
||||
|
||||
Returns:
|
||||
Next problem or None
|
||||
"""
|
||||
if not self._state.problems:
|
||||
return None
|
||||
|
||||
if self._state.selected_index is None:
|
||||
self._state.selected_index = 0
|
||||
else:
|
||||
self._state.selected_index = (
|
||||
self._state.selected_index + 1
|
||||
) % len(self._state.problems)
|
||||
|
||||
return self._state.problems[self._state.selected_index]
|
||||
|
||||
def prev_problem(self) -> Problem | None:
|
||||
"""Select previous problem.
|
||||
|
||||
Returns:
|
||||
Previous problem or None
|
||||
"""
|
||||
if not self._state.problems:
|
||||
return None
|
||||
|
||||
if self._state.selected_index is None:
|
||||
self._state.selected_index = len(self._state.problems) - 1
|
||||
else:
|
||||
self._state.selected_index = (
|
||||
self._state.selected_index - 1
|
||||
) % len(self._state.problems)
|
||||
|
||||
return self._state.problems[self._state.selected_index]
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all problems."""
|
||||
self._state.problems = []
|
||||
self._state.summary = ProblemsSummary()
|
||||
self._state.selected_index = None
|
||||
|
||||
async def run_all(self) -> list[Problem]:
|
||||
"""Run all linters and return problems.
|
||||
|
||||
Returns:
|
||||
List of problems
|
||||
"""
|
||||
problems, _ = await self.refresh()
|
||||
return problems
|
||||
@@ -0,0 +1,138 @@
|
||||
"""TODOs controller for tracking TODO comments."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from textual.message import Message
|
||||
|
||||
from clide.controllers.base import controller
|
||||
from clide.models.todos import TodoItem, TodosSummary, TodosState, TodoType
|
||||
from clide.services.todo_scanner import TodoScanner
|
||||
|
||||
|
||||
@controller
|
||||
class TodosController:
|
||||
"""Controller for TODO/FIXME comment tracking."""
|
||||
|
||||
class TodosUpdated(Message):
|
||||
"""Emitted when TODOs list is updated."""
|
||||
|
||||
def __init__(self, items: list[TodoItem], summary: TodosSummary) -> None:
|
||||
self.items = items
|
||||
self.summary = summary
|
||||
super().__init__()
|
||||
|
||||
class TodoSelected(Message):
|
||||
"""Emitted when a TODO is selected."""
|
||||
|
||||
def __init__(self, item: TodoItem) -> None:
|
||||
self.item = item
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, project_path: Path) -> None:
|
||||
self._scanner = TodoScanner(project_path)
|
||||
self._state = TodosState()
|
||||
|
||||
@property
|
||||
def state(self) -> TodosState:
|
||||
"""Get TODOs state."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def items(self) -> list[TodoItem]:
|
||||
"""Get list of TODO items."""
|
||||
return self._state.items
|
||||
|
||||
@property
|
||||
def summary(self) -> TodosSummary:
|
||||
"""Get TODOs summary."""
|
||||
return self._state.summary
|
||||
|
||||
@property
|
||||
def total_count(self) -> int:
|
||||
"""Get total TODO count."""
|
||||
return self._state.summary.total
|
||||
|
||||
async def refresh(self) -> tuple[list[TodoItem], TodosSummary]:
|
||||
"""Refresh TODOs from project.
|
||||
|
||||
Returns:
|
||||
Tuple of (items, summary)
|
||||
"""
|
||||
items, summary = await self._scanner.scan()
|
||||
|
||||
self._state.items = items
|
||||
self._state.summary = summary
|
||||
|
||||
return items, summary
|
||||
|
||||
def filter_by_type(self, todo_type: TodoType | None) -> list[TodoItem]:
|
||||
"""Filter TODOs by type.
|
||||
|
||||
Args:
|
||||
todo_type: Type to filter by, or None for all
|
||||
|
||||
Returns:
|
||||
Filtered list of TODOs
|
||||
"""
|
||||
self._state.filter_type = todo_type
|
||||
|
||||
if todo_type is None:
|
||||
return self._state.items
|
||||
|
||||
return self._state.items_by_type(todo_type)
|
||||
|
||||
def get_items_for_file(self, path: Path) -> list[TodoItem]:
|
||||
"""Get TODOs for a specific file.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
|
||||
Returns:
|
||||
List of TODOs for that file
|
||||
"""
|
||||
return self._state.items_for_file(path)
|
||||
|
||||
def select_item(self, index: int) -> TodoItem | None:
|
||||
"""Select a TODO by index.
|
||||
|
||||
Args:
|
||||
index: Item index
|
||||
|
||||
Returns:
|
||||
Selected item or None
|
||||
"""
|
||||
if 0 <= index < len(self._state.items):
|
||||
self._state.selected_index = index
|
||||
return self._state.items[index]
|
||||
return None
|
||||
|
||||
def toggle_group_by_file(self) -> bool:
|
||||
"""Toggle grouping by file.
|
||||
|
||||
Returns:
|
||||
New group_by_file value
|
||||
"""
|
||||
self._state.group_by_file = not self._state.group_by_file
|
||||
return self._state.group_by_file
|
||||
|
||||
def get_grouped_items(self) -> dict[Path, list[TodoItem]]:
|
||||
"""Get TODOs grouped by file.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping file paths to TODO lists
|
||||
"""
|
||||
grouped: dict[Path, list[TodoItem]] = {}
|
||||
for item in self._state.items:
|
||||
if item.file_path not in grouped:
|
||||
grouped[item.file_path] = []
|
||||
grouped[item.file_path].append(item)
|
||||
return grouped
|
||||
|
||||
async def scan(self) -> list[TodoItem]:
|
||||
"""Scan for TODOs and return items.
|
||||
|
||||
Returns:
|
||||
List of TODO items
|
||||
"""
|
||||
items, _ = await self.refresh()
|
||||
return items
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Extension system for Clide using pluggy."""
|
||||
|
||||
from clide.extensions.hookspecs import ClideHookSpec, hookimpl, hookspec
|
||||
from clide.extensions.manager import ExtensionManager
|
||||
|
||||
__all__ = ["ClideHookSpec", "ExtensionManager", "hookimpl", "hookspec"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Built-in extensions for Clide."""
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Pluggy hook specifications for Clide extensions."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pluggy
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from textual.app import App
|
||||
from textual.widget import Widget
|
||||
|
||||
hookspec = pluggy.HookspecMarker("clide")
|
||||
hookimpl = pluggy.HookimplMarker("clide")
|
||||
|
||||
|
||||
class ClideHookSpec:
|
||||
"""Hook specifications for Clide extensions.
|
||||
|
||||
Extensions implement these hooks to extend functionality.
|
||||
"""
|
||||
|
||||
@hookspec
|
||||
def clide_register_panel(self) -> dict[str, Any] | None:
|
||||
"""Register a custom panel for the UI.
|
||||
|
||||
Returns:
|
||||
Dictionary with panel configuration:
|
||||
- name: Panel identifier
|
||||
- widget: Widget class to instantiate
|
||||
- position: "left", "right", or "bottom"
|
||||
- keybinding: Optional keyboard shortcut
|
||||
"""
|
||||
|
||||
@hookspec
|
||||
def clide_register_commands(self) -> list[dict[str, Any]] | None:
|
||||
"""Register custom commands for the command palette.
|
||||
|
||||
Returns:
|
||||
List of command dictionaries:
|
||||
- name: Command display name
|
||||
- callback: Async callable to execute
|
||||
- description: Help text
|
||||
"""
|
||||
|
||||
@hookspec
|
||||
def clide_on_app_startup(self, app: "App[object]") -> None:
|
||||
"""Called when the application starts.
|
||||
|
||||
Args:
|
||||
app: The Clide application instance
|
||||
"""
|
||||
|
||||
@hookspec
|
||||
def clide_on_app_shutdown(self, app: "App[object]") -> None:
|
||||
"""Called when the application is shutting down.
|
||||
|
||||
Args:
|
||||
app: The Clide application instance
|
||||
"""
|
||||
|
||||
@hookspec
|
||||
def clide_on_file_open(self, path: str) -> None:
|
||||
"""Called when a file is opened in the file browser.
|
||||
|
||||
Args:
|
||||
path: Absolute path to the opened file
|
||||
"""
|
||||
|
||||
@hookspec
|
||||
def clide_modify_widget(self, widget: "Widget") -> "Widget":
|
||||
"""Modify a widget before it's mounted.
|
||||
|
||||
Args:
|
||||
widget: The widget about to be mounted
|
||||
|
||||
Returns:
|
||||
The modified (or original) widget
|
||||
"""
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Extension manager for loading and managing Clide extensions."""
|
||||
|
||||
from importlib.metadata import entry_points
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pluggy
|
||||
|
||||
from clide.extensions.hookspecs import ClideHookSpec
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from textual.app import App
|
||||
|
||||
EXTENSION_NAMESPACE = "clide.extensions"
|
||||
|
||||
|
||||
class ExtensionManager:
|
||||
"""Manages loading and lifecycle of Clide extensions."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._pm = pluggy.PluginManager("clide")
|
||||
self._pm.add_hookspecs(ClideHookSpec)
|
||||
self._loaded: list[str] = []
|
||||
|
||||
@property
|
||||
def hook(self) -> pluggy.HookRelay:
|
||||
"""Access the hook relay for calling hooks."""
|
||||
return self._pm.hook
|
||||
|
||||
def load_extensions(self) -> None:
|
||||
"""Load all extensions from entry points."""
|
||||
eps = entry_points(group=EXTENSION_NAMESPACE)
|
||||
for ep in eps:
|
||||
try:
|
||||
plugin = ep.load()
|
||||
self._pm.register(plugin, name=ep.name)
|
||||
self._loaded.append(ep.name)
|
||||
except Exception as e:
|
||||
# Log but don't crash on extension load failure
|
||||
print(f"Failed to load extension {ep.name}: {e}")
|
||||
|
||||
def register_plugin(self, plugin: object, name: str) -> None:
|
||||
"""Manually register a plugin instance.
|
||||
|
||||
Args:
|
||||
plugin: Plugin object with hookimpl methods
|
||||
name: Unique name for the plugin
|
||||
"""
|
||||
self._pm.register(plugin, name=name)
|
||||
self._loaded.append(name)
|
||||
|
||||
def unregister_plugin(self, name: str) -> None:
|
||||
"""Unregister a plugin by name.
|
||||
|
||||
Args:
|
||||
name: Name of the plugin to unregister
|
||||
"""
|
||||
plugin = self._pm.get_plugin(name)
|
||||
if plugin:
|
||||
self._pm.unregister(plugin)
|
||||
self._loaded.remove(name)
|
||||
|
||||
def list_extensions(self) -> list[str]:
|
||||
"""Get list of loaded extension names."""
|
||||
return self._loaded.copy()
|
||||
|
||||
async def trigger_app_startup(self, app: "App[object]") -> None:
|
||||
"""Trigger startup hooks for all extensions.
|
||||
|
||||
Args:
|
||||
app: The Clide application instance
|
||||
"""
|
||||
self.hook.clide_on_app_startup(app=app)
|
||||
|
||||
async def trigger_app_shutdown(self, app: "App[object]") -> None:
|
||||
"""Trigger shutdown hooks for all extensions.
|
||||
|
||||
Args:
|
||||
app: The Clide application instance
|
||||
"""
|
||||
self.hook.clide_on_app_shutdown(app=app)
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared utility functions and helpers for Clide."""
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Pydantic models for Clide."""
|
||||
|
||||
from clide.models.config import ClideSettings, PanelConfig
|
||||
from clide.models.diff import ChangeType, DiffContent, DiffHunk, DiffLine, DiffViewState
|
||||
from clide.models.editor import CursorPosition, EditorState, FileBuffer, Selection
|
||||
from clide.models.git import (
|
||||
ChangeStatus,
|
||||
GitBranch,
|
||||
GitChange,
|
||||
GitCommit,
|
||||
GitGraph,
|
||||
GitStatus,
|
||||
)
|
||||
from clide.models.problems import Problem, ProblemsSummary, ProblemsState, Severity
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition, ThemeMetadata
|
||||
from clide.models.todos import TodoItem, TodosSummary, TodosState, TodoType
|
||||
|
||||
__all__ = [
|
||||
# Config
|
||||
"ClideSettings",
|
||||
"PanelConfig",
|
||||
# Git
|
||||
"ChangeStatus",
|
||||
"GitBranch",
|
||||
"GitChange",
|
||||
"GitCommit",
|
||||
"GitGraph",
|
||||
"GitStatus",
|
||||
# Editor
|
||||
"CursorPosition",
|
||||
"EditorState",
|
||||
"FileBuffer",
|
||||
"Selection",
|
||||
# Diff
|
||||
"ChangeType",
|
||||
"DiffContent",
|
||||
"DiffHunk",
|
||||
"DiffLine",
|
||||
"DiffViewState",
|
||||
# Problems
|
||||
"Problem",
|
||||
"ProblemsSummary",
|
||||
"ProblemsState",
|
||||
"Severity",
|
||||
# Todos
|
||||
"TodoItem",
|
||||
"TodosSummary",
|
||||
"TodosState",
|
||||
"TodoType",
|
||||
# Theme
|
||||
"ThemeColors",
|
||||
"ThemeDefinition",
|
||||
"ThemeMetadata",
|
||||
]
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Configuration models using Pydantic Settings."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class ClideSettings(BaseSettings):
|
||||
"""Main application settings loaded from environment and config files."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="CLIDE_",
|
||||
env_file=".config/.env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# Appearance
|
||||
theme: str = "summer-night" # Default to Summer Night
|
||||
|
||||
# Paths
|
||||
claude_path: str = "claude"
|
||||
default_workdir: Path = Path.cwd()
|
||||
jira_cli_path: str = "jira"
|
||||
|
||||
# Behavior
|
||||
auto_save: bool = True
|
||||
confirm_exit: bool = True
|
||||
|
||||
# Integrations
|
||||
jira_enabled: bool = False
|
||||
confluence_enabled: bool = False
|
||||
imagin_enabled: bool = False
|
||||
|
||||
# Linters
|
||||
linters: list[str] = ["ruff"]
|
||||
|
||||
|
||||
class PanelConfig(BaseModel):
|
||||
"""Panel visibility and layout configuration."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
sidebar_visible: bool = True
|
||||
context_visible: bool = True
|
||||
workspace_visible: bool = False # Hidden by default
|
||||
sidebar_width_percent: int = 20
|
||||
context_width_percent: int = 25
|
||||
|
||||
|
||||
class KeybindingsConfig(BaseModel):
|
||||
"""Keybinding configuration."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
# Global
|
||||
command_palette: str = "ctrl+shift+p"
|
||||
quick_open: str = "ctrl+p"
|
||||
toggle_sidebar: str = "ctrl+b"
|
||||
toggle_context: str = "ctrl+shift+b"
|
||||
toggle_terminal: str = "ctrl+`"
|
||||
toggle_compact: str = "ctrl+shift+c"
|
||||
fullscreen: str = "f11"
|
||||
|
||||
# Navigation
|
||||
focus_claude: str = "ctrl+1"
|
||||
focus_editor: str = "ctrl+2"
|
||||
focus_terminal: str = "ctrl+3"
|
||||
focus_sidebar: str = "ctrl+0"
|
||||
next_tab: str = "ctrl+tab"
|
||||
prev_tab: str = "ctrl+shift+tab"
|
||||
close_tab: str = "ctrl+w"
|
||||
|
||||
# Git
|
||||
git_panel: str = "ctrl+shift+g"
|
||||
stage_file: str = "ctrl+enter"
|
||||
unstage_file: str = "ctrl+backspace"
|
||||
|
||||
# Search
|
||||
find_in_file: str = "ctrl+f"
|
||||
find_in_project: str = "ctrl+shift+f"
|
||||
problems_panel: str = "ctrl+shift+m"
|
||||
next_problem: str = "f8"
|
||||
prev_problem: str = "shift+f8"
|
||||
|
||||
# Editor
|
||||
save: str = "ctrl+s"
|
||||
undo: str = "ctrl+z"
|
||||
redo: str = "ctrl+shift+z"
|
||||
go_to_line: str = "ctrl+g"
|
||||
|
||||
# Theme
|
||||
select_theme: str = "ctrl+k ctrl+t"
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Diff-related Pydantic models."""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class ChangeType(str, Enum):
|
||||
"""Type of change in a diff line."""
|
||||
|
||||
ADDED = "added"
|
||||
REMOVED = "removed"
|
||||
CONTEXT = "context"
|
||||
HEADER = "header"
|
||||
|
||||
|
||||
class DiffLine(BaseModel):
|
||||
"""A single line in a diff."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
change_type: ChangeType
|
||||
content: str
|
||||
old_line_num: int | None = None
|
||||
new_line_num: int | None = None
|
||||
|
||||
|
||||
class DiffHunk(BaseModel):
|
||||
"""A hunk (section) of a diff."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
header: str
|
||||
old_start: int
|
||||
old_count: int
|
||||
new_start: int
|
||||
new_count: int
|
||||
lines: tuple[DiffLine, ...]
|
||||
|
||||
|
||||
class DiffContent(BaseModel):
|
||||
"""Complete diff for a file."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
file_path: str
|
||||
old_path: str | None = None # For renames
|
||||
hunks: tuple[DiffHunk, ...]
|
||||
is_binary: bool = False
|
||||
is_new_file: bool = False
|
||||
is_deleted: bool = False
|
||||
|
||||
|
||||
class DiffViewState(BaseModel):
|
||||
"""State of the diff viewer."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
diff: DiffContent | None = None
|
||||
scroll_offset: int = 0
|
||||
selected_hunk_index: int | None = None
|
||||
side_by_side: bool = True
|
||||
# For Claude-proposed changes
|
||||
is_proposal: bool = False
|
||||
accepted_hunks: set[int] = set()
|
||||
rejected_hunks: set[int] = set()
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Editor-related Pydantic models."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class CursorPosition(BaseModel):
|
||||
"""Cursor position in editor."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
line: int
|
||||
column: int
|
||||
|
||||
|
||||
class Selection(BaseModel):
|
||||
"""Text selection range."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
start: CursorPosition
|
||||
end: CursorPosition
|
||||
|
||||
|
||||
class FileBuffer(BaseModel):
|
||||
"""A file buffer in the editor."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
path: Path
|
||||
content: str
|
||||
language: str | None = None
|
||||
is_modified: bool = False
|
||||
cursor: CursorPosition = CursorPosition(line=0, column=0)
|
||||
selection: Selection | None = None
|
||||
scroll_offset: int = 0
|
||||
|
||||
@property
|
||||
def filename(self) -> str:
|
||||
"""Get the filename from path."""
|
||||
return self.path.name
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
"""Get display name with modification indicator."""
|
||||
prefix = "● " if self.is_modified else ""
|
||||
return f"{prefix}{self.filename}"
|
||||
|
||||
|
||||
class EditorState(BaseModel):
|
||||
"""State of the editor panel."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
buffers: list[FileBuffer] = []
|
||||
active_buffer_index: int | None = None
|
||||
recent_files: list[Path] = []
|
||||
|
||||
@property
|
||||
def active_buffer(self) -> FileBuffer | None:
|
||||
"""Get currently active buffer."""
|
||||
if self.active_buffer_index is not None and self.buffers:
|
||||
return self.buffers[self.active_buffer_index]
|
||||
return None
|
||||
|
||||
def get_buffer_by_path(self, path: Path) -> FileBuffer | None:
|
||||
"""Find a buffer by its file path."""
|
||||
for buffer in self.buffers:
|
||||
if buffer.path == path:
|
||||
return buffer
|
||||
return None
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Git-related Pydantic models."""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class ChangeStatus(str, Enum):
|
||||
"""Git file change status."""
|
||||
|
||||
ADDED = "added"
|
||||
MODIFIED = "modified"
|
||||
DELETED = "deleted"
|
||||
RENAMED = "renamed"
|
||||
COPIED = "copied"
|
||||
UNTRACKED = "untracked"
|
||||
IGNORED = "ignored"
|
||||
UNMERGED = "unmerged"
|
||||
|
||||
|
||||
class GitChange(BaseModel):
|
||||
"""A single file change in git."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
path: str
|
||||
status: ChangeStatus
|
||||
staged: bool
|
||||
old_path: str | None = None # For renames
|
||||
|
||||
|
||||
class GitStatus(BaseModel):
|
||||
"""Current git repository status."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
branch: str
|
||||
ahead: int = 0
|
||||
behind: int = 0
|
||||
staged: tuple[GitChange, ...]
|
||||
unstaged: tuple[GitChange, ...]
|
||||
untracked: tuple[str, ...] = ()
|
||||
has_conflicts: bool = False
|
||||
|
||||
|
||||
class GitBranch(BaseModel):
|
||||
"""Git branch information."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
name: str
|
||||
is_current: bool = False
|
||||
is_remote: bool = False
|
||||
tracking: str | None = None
|
||||
commit_hash: str | None = None
|
||||
commit_message: str | None = None
|
||||
|
||||
|
||||
class GitCommit(BaseModel):
|
||||
"""Git commit information for graph view."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
hash: str
|
||||
short_hash: str
|
||||
message: str
|
||||
author: str
|
||||
date: str
|
||||
is_merge: bool = False
|
||||
refs: tuple[str, ...] = () # branch names, tags
|
||||
parents: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class GitGraph(BaseModel):
|
||||
"""Git log graph data."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
commits: tuple[GitCommit, ...]
|
||||
branches: tuple[GitBranch, ...]
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Problems (linter errors) Pydantic models."""
|
||||
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class Severity(str, Enum):
|
||||
"""Problem severity level."""
|
||||
|
||||
ERROR = "error"
|
||||
WARNING = "warning"
|
||||
INFO = "info"
|
||||
HINT = "hint"
|
||||
|
||||
|
||||
class Problem(BaseModel):
|
||||
"""A single linter problem/diagnostic."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
file_path: Path
|
||||
line: int
|
||||
column: int
|
||||
end_line: int | None = None
|
||||
end_column: int | None = None
|
||||
severity: Severity
|
||||
message: str
|
||||
source: str # e.g., "ruff", "mypy", "eslint"
|
||||
code: str | None = None # e.g., "E501", "W0612"
|
||||
|
||||
@property
|
||||
def location(self) -> str:
|
||||
"""Human-readable location string."""
|
||||
return f"{self.file_path}:{self.line}:{self.column}"
|
||||
|
||||
@property
|
||||
def severity_icon(self) -> str:
|
||||
"""Icon for severity level."""
|
||||
icons = {
|
||||
Severity.ERROR: "✖",
|
||||
Severity.WARNING: "⚠",
|
||||
Severity.INFO: "ℹ",
|
||||
Severity.HINT: "💡",
|
||||
}
|
||||
return icons[self.severity]
|
||||
|
||||
|
||||
class ProblemsSummary(BaseModel):
|
||||
"""Summary of problems in the workspace."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
errors: int = 0
|
||||
warnings: int = 0
|
||||
infos: int = 0
|
||||
hints: int = 0
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
"""Total number of problems."""
|
||||
return self.errors + self.warnings + self.infos + self.hints
|
||||
|
||||
@property
|
||||
def display_text(self) -> str:
|
||||
"""Text for tab badge."""
|
||||
if self.errors:
|
||||
return f"⚠ {self.errors}"
|
||||
if self.warnings:
|
||||
return f"⚠ {self.warnings}"
|
||||
return f"✓ {self.total}"
|
||||
|
||||
|
||||
class ProblemsState(BaseModel):
|
||||
"""State of the problems panel."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
problems: list[Problem] = []
|
||||
summary: ProblemsSummary = ProblemsSummary()
|
||||
filter_severity: Severity | None = None
|
||||
filter_source: str | None = None
|
||||
selected_index: int | None = None
|
||||
|
||||
def problems_for_file(self, path: Path) -> list[Problem]:
|
||||
"""Get problems for a specific file."""
|
||||
return [p for p in self.problems if p.file_path == path]
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Theme-related Pydantic models."""
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from textual.theme import Theme
|
||||
|
||||
|
||||
def validate_hex_color(value: str) -> str:
|
||||
"""Validate hex color format."""
|
||||
if not re.match(r"^#[0-9A-Fa-f]{6}$", value):
|
||||
raise ValueError(f"Invalid hex color: {value}")
|
||||
return value.lower()
|
||||
|
||||
|
||||
class ThemeColors(BaseModel):
|
||||
"""Color definitions for a theme."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
# Core colors
|
||||
primary: str
|
||||
secondary: str
|
||||
accent: str
|
||||
|
||||
# Backgrounds
|
||||
background: str
|
||||
surface: str
|
||||
panel: str
|
||||
|
||||
# Text
|
||||
foreground: str
|
||||
|
||||
# Status
|
||||
success: str
|
||||
warning: str
|
||||
error: str
|
||||
|
||||
@field_validator("*", mode="before")
|
||||
@classmethod
|
||||
def validate_colors(cls, v: str) -> str:
|
||||
"""Validate all color fields are valid hex."""
|
||||
return validate_hex_color(v)
|
||||
|
||||
|
||||
class ThemeDefinition(BaseModel):
|
||||
"""Complete theme definition."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
name: str # e.g., "summer-night"
|
||||
display_name: str # e.g., "Summer Night"
|
||||
dark: bool # True for dark themes
|
||||
colors: ThemeColors
|
||||
|
||||
def to_textual_colors(self) -> dict[str, str]:
|
||||
"""Convert to Textual theme color dict."""
|
||||
return {
|
||||
"primary": self.colors.primary,
|
||||
"secondary": self.colors.secondary,
|
||||
"accent": self.colors.accent,
|
||||
"background": self.colors.background,
|
||||
"surface": self.colors.surface,
|
||||
"panel": self.colors.panel,
|
||||
"foreground": self.colors.foreground,
|
||||
"success": self.colors.success,
|
||||
"warning": self.colors.warning,
|
||||
"error": self.colors.error,
|
||||
}
|
||||
|
||||
def to_textual_theme(self) -> "Theme":
|
||||
"""Convert to a Textual Theme object."""
|
||||
from textual.theme import Theme
|
||||
|
||||
return Theme(
|
||||
name=self.name,
|
||||
primary=self.colors.primary,
|
||||
secondary=self.colors.secondary,
|
||||
accent=self.colors.accent,
|
||||
background=self.colors.background,
|
||||
surface=self.colors.surface,
|
||||
panel=self.colors.panel,
|
||||
foreground=self.colors.foreground,
|
||||
success=self.colors.success,
|
||||
warning=self.colors.warning,
|
||||
error=self.colors.error,
|
||||
dark=self.dark,
|
||||
)
|
||||
|
||||
|
||||
class ThemeMetadata(BaseModel):
|
||||
"""Theme metadata for listing themes."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
name: str
|
||||
display_name: str
|
||||
dark: bool
|
||||
category: str = "custom" # e.g., "core", "popular", "seasonal", "custom"
|
||||
@@ -0,0 +1,93 @@
|
||||
"""TODO comments Pydantic models."""
|
||||
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class TodoType(str, Enum):
|
||||
"""Type of TODO comment."""
|
||||
|
||||
TODO = "TODO"
|
||||
FIXME = "FIXME"
|
||||
HACK = "HACK"
|
||||
XXX = "XXX"
|
||||
NOTE = "NOTE"
|
||||
BUG = "BUG"
|
||||
OPTIMIZE = "OPTIMIZE"
|
||||
REVIEW = "REVIEW"
|
||||
|
||||
|
||||
class TodoItem(BaseModel):
|
||||
"""A single TODO comment found in code."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
file_path: Path
|
||||
line: int
|
||||
column: int
|
||||
todo_type: TodoType
|
||||
text: str
|
||||
context_line: str # The full line containing the TODO
|
||||
|
||||
@property
|
||||
def location(self) -> str:
|
||||
"""Human-readable location string."""
|
||||
return f"{self.file_path}:{self.line}"
|
||||
|
||||
@property
|
||||
def type_icon(self) -> str:
|
||||
"""Icon for TODO type."""
|
||||
icons = {
|
||||
TodoType.TODO: "☐",
|
||||
TodoType.FIXME: "🔧",
|
||||
TodoType.HACK: "⚡",
|
||||
TodoType.XXX: "❗",
|
||||
TodoType.NOTE: "📝",
|
||||
TodoType.BUG: "🐛",
|
||||
TodoType.OPTIMIZE: "⚡",
|
||||
TodoType.REVIEW: "👀",
|
||||
}
|
||||
return icons[self.todo_type]
|
||||
|
||||
|
||||
class TodosSummary(BaseModel):
|
||||
"""Summary of TODOs in the workspace."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
todo_count: int = 0
|
||||
fixme_count: int = 0
|
||||
hack_count: int = 0
|
||||
other_count: int = 0
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
"""Total number of TODOs."""
|
||||
return self.todo_count + self.fixme_count + self.hack_count + self.other_count
|
||||
|
||||
@property
|
||||
def display_text(self) -> str:
|
||||
"""Text for tab badge."""
|
||||
return f"✓{self.total}"
|
||||
|
||||
|
||||
class TodosState(BaseModel):
|
||||
"""State of the TODOs panel."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
items: list[TodoItem] = []
|
||||
summary: TodosSummary = TodosSummary()
|
||||
filter_type: TodoType | None = None
|
||||
selected_index: int | None = None
|
||||
group_by_file: bool = True
|
||||
|
||||
def items_for_file(self, path: Path) -> list[TodoItem]:
|
||||
"""Get TODO items for a specific file."""
|
||||
return [item for item in self.items if item.file_path == path]
|
||||
|
||||
def items_by_type(self, todo_type: TodoType) -> list[TodoItem]:
|
||||
"""Get TODO items of a specific type."""
|
||||
return [item for item in self.items if item.todo_type == todo_type]
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Business logic services for Clide."""
|
||||
|
||||
from clide.services.git_service import GitService
|
||||
from clide.services.linter_service import LinterService
|
||||
from clide.services.process_service import ProcessService
|
||||
from clide.services.todo_scanner import TodoScanner
|
||||
|
||||
__all__ = [
|
||||
"GitService",
|
||||
"LinterService",
|
||||
"ProcessService",
|
||||
"TodoScanner",
|
||||
]
|
||||
@@ -0,0 +1,124 @@
|
||||
"""File operations service."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class FileService:
|
||||
"""Service for file I/O operations."""
|
||||
|
||||
def __init__(self, project_path: Path) -> None:
|
||||
self.project_path = project_path
|
||||
|
||||
async def read_file(self, path: Path) -> str:
|
||||
"""Read file contents.
|
||||
|
||||
Args:
|
||||
path: Path to file (relative or absolute)
|
||||
|
||||
Returns:
|
||||
File contents as string
|
||||
"""
|
||||
full_path = self._resolve_path(path)
|
||||
return full_path.read_text(encoding="utf-8")
|
||||
|
||||
async def write_file(self, path: Path, content: str) -> None:
|
||||
"""Write content to file.
|
||||
|
||||
Args:
|
||||
path: Path to file
|
||||
content: Content to write
|
||||
"""
|
||||
full_path = self._resolve_path(path)
|
||||
full_path.write_text(content, encoding="utf-8")
|
||||
|
||||
async def file_exists(self, path: Path) -> bool:
|
||||
"""Check if file exists.
|
||||
|
||||
Args:
|
||||
path: Path to check
|
||||
|
||||
Returns:
|
||||
True if file exists
|
||||
"""
|
||||
full_path = self._resolve_path(path)
|
||||
return full_path.exists() and full_path.is_file()
|
||||
|
||||
async def get_language(self, path: Path) -> str | None:
|
||||
"""Detect language from file extension.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
|
||||
Returns:
|
||||
Language identifier or None
|
||||
"""
|
||||
extension_map = {
|
||||
".py": "python",
|
||||
".js": "javascript",
|
||||
".ts": "typescript",
|
||||
".jsx": "jsx",
|
||||
".tsx": "tsx",
|
||||
".html": "html",
|
||||
".css": "css",
|
||||
".scss": "scss",
|
||||
".json": "json",
|
||||
".yaml": "yaml",
|
||||
".yml": "yaml",
|
||||
".toml": "toml",
|
||||
".md": "markdown",
|
||||
".rs": "rust",
|
||||
".go": "go",
|
||||
".java": "java",
|
||||
".c": "c",
|
||||
".cpp": "cpp",
|
||||
".h": "c",
|
||||
".hpp": "cpp",
|
||||
".rb": "ruby",
|
||||
".php": "php",
|
||||
".sh": "bash",
|
||||
".bash": "bash",
|
||||
".sql": "sql",
|
||||
".xml": "xml",
|
||||
".vue": "vue",
|
||||
".svelte": "svelte",
|
||||
}
|
||||
return extension_map.get(path.suffix.lower())
|
||||
|
||||
def _resolve_path(self, path: Path) -> Path:
|
||||
"""Resolve path relative to project root.
|
||||
|
||||
Args:
|
||||
path: Path to resolve
|
||||
|
||||
Returns:
|
||||
Absolute path
|
||||
"""
|
||||
if path.is_absolute():
|
||||
return path
|
||||
return self.project_path / path
|
||||
|
||||
def list_directory(self, path: Path | None = None) -> list[Path]:
|
||||
"""List directory contents.
|
||||
|
||||
Args:
|
||||
path: Directory path (defaults to project root)
|
||||
|
||||
Returns:
|
||||
List of paths in directory
|
||||
"""
|
||||
dir_path = self._resolve_path(path) if path else self.project_path
|
||||
if not dir_path.is_dir():
|
||||
return []
|
||||
|
||||
entries = []
|
||||
for entry in sorted(dir_path.iterdir()):
|
||||
# Skip hidden files and common excludes
|
||||
if entry.name.startswith("."):
|
||||
continue
|
||||
if entry.name in ("__pycache__", "node_modules", ".git"):
|
||||
continue
|
||||
entries.append(entry)
|
||||
|
||||
# Sort: directories first, then files
|
||||
entries.sort(key=lambda p: (not p.is_dir(), p.name.lower()))
|
||||
return entries
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Git operations service."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from clide.models.git import (
|
||||
ChangeStatus,
|
||||
GitBranch,
|
||||
GitChange,
|
||||
GitCommit,
|
||||
GitStatus,
|
||||
)
|
||||
from clide.services.process_service import ProcessService
|
||||
|
||||
|
||||
class GitService:
|
||||
"""Service for git operations."""
|
||||
|
||||
def __init__(self, repo_path: Path) -> None:
|
||||
self.repo_path = repo_path
|
||||
self._process = ProcessService(cwd=repo_path)
|
||||
|
||||
async def get_status(self) -> GitStatus:
|
||||
"""Get current git status.
|
||||
|
||||
Returns:
|
||||
GitStatus with staged/unstaged changes
|
||||
"""
|
||||
# Get porcelain status
|
||||
result = await self._process.run("git", "status", "--porcelain", "-z")
|
||||
|
||||
staged: list[GitChange] = []
|
||||
unstaged: list[GitChange] = []
|
||||
untracked: list[str] = []
|
||||
|
||||
if result.success and result.stdout:
|
||||
entries = result.stdout.split("\0")
|
||||
for entry in entries:
|
||||
if not entry or len(entry) < 3:
|
||||
continue
|
||||
|
||||
index_status = entry[0]
|
||||
worktree_status = entry[1]
|
||||
path = entry[3:]
|
||||
|
||||
# Parse status
|
||||
if index_status == "?":
|
||||
untracked.append(path)
|
||||
else:
|
||||
if index_status != " ":
|
||||
staged.append(GitChange(
|
||||
path=path,
|
||||
status=self._parse_status(index_status),
|
||||
staged=True,
|
||||
))
|
||||
if worktree_status != " ":
|
||||
unstaged.append(GitChange(
|
||||
path=path,
|
||||
status=self._parse_status(worktree_status),
|
||||
staged=False,
|
||||
))
|
||||
|
||||
# Get current branch
|
||||
branch_result = await self._process.run(
|
||||
"git", "branch", "--show-current"
|
||||
)
|
||||
branch = branch_result.stdout.strip() if branch_result.success else "HEAD"
|
||||
|
||||
# Get ahead/behind
|
||||
ahead, behind = await self._get_ahead_behind(branch)
|
||||
|
||||
return GitStatus(
|
||||
branch=branch,
|
||||
ahead=ahead,
|
||||
behind=behind,
|
||||
staged=tuple(staged),
|
||||
unstaged=tuple(unstaged),
|
||||
untracked=tuple(untracked),
|
||||
)
|
||||
|
||||
async def _get_ahead_behind(self, branch: str) -> tuple[int, int]:
|
||||
"""Get commits ahead/behind upstream."""
|
||||
result = await self._process.run(
|
||||
"git", "rev-list", "--left-right", "--count",
|
||||
f"{branch}...@{{upstream}}"
|
||||
)
|
||||
if result.success:
|
||||
parts = result.stdout.strip().split()
|
||||
if len(parts) == 2:
|
||||
return int(parts[0]), int(parts[1])
|
||||
return 0, 0
|
||||
|
||||
def _parse_status(self, char: str) -> ChangeStatus:
|
||||
"""Parse git status character to ChangeStatus."""
|
||||
mapping = {
|
||||
"A": ChangeStatus.ADDED,
|
||||
"M": ChangeStatus.MODIFIED,
|
||||
"D": ChangeStatus.DELETED,
|
||||
"R": ChangeStatus.RENAMED,
|
||||
"C": ChangeStatus.COPIED,
|
||||
"?": ChangeStatus.UNTRACKED,
|
||||
"!": ChangeStatus.IGNORED,
|
||||
"U": ChangeStatus.UNMERGED,
|
||||
}
|
||||
return mapping.get(char, ChangeStatus.MODIFIED)
|
||||
|
||||
async def stage_file(self, path: str) -> bool:
|
||||
"""Stage a file.
|
||||
|
||||
Args:
|
||||
path: File path to stage
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
result = await self._process.run("git", "add", path)
|
||||
return result.success
|
||||
|
||||
async def unstage_file(self, path: str) -> bool:
|
||||
"""Unstage a file.
|
||||
|
||||
Args:
|
||||
path: File path to unstage
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
result = await self._process.run("git", "restore", "--staged", path)
|
||||
return result.success
|
||||
|
||||
async def discard_changes(self, path: str) -> bool:
|
||||
"""Discard changes to a file.
|
||||
|
||||
Args:
|
||||
path: File path to discard
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
result = await self._process.run("git", "restore", path)
|
||||
return result.success
|
||||
|
||||
async def get_branches(self) -> list[GitBranch]:
|
||||
"""Get list of branches.
|
||||
|
||||
Returns:
|
||||
List of GitBranch objects
|
||||
"""
|
||||
result = await self._process.run(
|
||||
"git", "branch", "-a", "--format",
|
||||
"%(HEAD)%(refname:short)|%(upstream:short)|%(objectname:short)|%(subject)"
|
||||
)
|
||||
|
||||
branches: list[GitBranch] = []
|
||||
if result.success:
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
is_current = line.startswith("*")
|
||||
parts = line[1:].split("|")
|
||||
if len(parts) >= 4:
|
||||
name = parts[0].strip()
|
||||
branches.append(GitBranch(
|
||||
name=name,
|
||||
is_current=is_current,
|
||||
is_remote=name.startswith("remotes/"),
|
||||
tracking=parts[1] or None,
|
||||
commit_hash=parts[2],
|
||||
commit_message=parts[3],
|
||||
))
|
||||
|
||||
return branches
|
||||
|
||||
async def checkout_branch(self, branch: str) -> bool:
|
||||
"""Checkout a branch.
|
||||
|
||||
Args:
|
||||
branch: Branch name to checkout
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
result = await self._process.run("git", "checkout", branch)
|
||||
return result.success
|
||||
|
||||
async def create_branch(self, name: str, start_point: str | None = None) -> bool:
|
||||
"""Create a new branch.
|
||||
|
||||
Args:
|
||||
name: New branch name
|
||||
start_point: Optional starting commit/branch
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
args = ["git", "checkout", "-b", name]
|
||||
if start_point:
|
||||
args.append(start_point)
|
||||
result = await self._process.run(*args)
|
||||
return result.success
|
||||
|
||||
async def get_diff(self, path: str, staged: bool = False) -> str:
|
||||
"""Get diff for a file.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
staged: Whether to get staged diff
|
||||
|
||||
Returns:
|
||||
Diff output string
|
||||
"""
|
||||
args = ["git", "diff"]
|
||||
if staged:
|
||||
args.append("--cached")
|
||||
args.append("--")
|
||||
args.append(path)
|
||||
|
||||
result = await self._process.run(*args)
|
||||
return result.stdout if result.success else ""
|
||||
|
||||
async def get_log(self, max_count: int = 50) -> list[GitCommit]:
|
||||
"""Get commit log.
|
||||
|
||||
Args:
|
||||
max_count: Maximum number of commits to return
|
||||
|
||||
Returns:
|
||||
List of GitCommit objects
|
||||
"""
|
||||
result = await self._process.run(
|
||||
"git", "log",
|
||||
f"--max-count={max_count}",
|
||||
"--format=%H|%h|%s|%an|%ar|%P|%D",
|
||||
"--all",
|
||||
)
|
||||
|
||||
commits: list[GitCommit] = []
|
||||
if result.success:
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split("|")
|
||||
if len(parts) >= 7:
|
||||
parents = tuple(parts[5].split()) if parts[5] else ()
|
||||
refs = tuple(r.strip() for r in parts[6].split(",")) if parts[6] else ()
|
||||
commits.append(GitCommit(
|
||||
hash=parts[0],
|
||||
short_hash=parts[1],
|
||||
message=parts[2],
|
||||
author=parts[3],
|
||||
date=parts[4],
|
||||
is_merge=len(parents) > 1,
|
||||
parents=parents,
|
||||
refs=refs,
|
||||
))
|
||||
|
||||
return commits
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Linter integration service."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from clide.models.problems import Problem, ProblemsSummary, Severity
|
||||
from clide.services.process_service import ProcessService
|
||||
|
||||
|
||||
class LinterService:
|
||||
"""Service for running linters and parsing output."""
|
||||
|
||||
def __init__(self, project_path: Path) -> None:
|
||||
self.project_path = project_path
|
||||
self._process = ProcessService(cwd=project_path)
|
||||
|
||||
async def run_ruff(self) -> list[Problem]:
|
||||
"""Run ruff linter.
|
||||
|
||||
Returns:
|
||||
List of problems found
|
||||
"""
|
||||
result = await self._process.run(
|
||||
"ruff", "check", "--output-format=json", "."
|
||||
)
|
||||
|
||||
problems: list[Problem] = []
|
||||
if result.stdout:
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
for item in data:
|
||||
severity = self._ruff_severity(item.get("code", ""))
|
||||
problems.append(Problem(
|
||||
file_path=Path(item["filename"]),
|
||||
line=item["location"]["row"],
|
||||
column=item["location"]["column"],
|
||||
end_line=item.get("end_location", {}).get("row"),
|
||||
end_column=item.get("end_location", {}).get("column"),
|
||||
severity=severity,
|
||||
message=item["message"],
|
||||
source="ruff",
|
||||
code=item.get("code"),
|
||||
))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
return problems
|
||||
|
||||
def _ruff_severity(self, code: str) -> Severity:
|
||||
"""Map ruff code to severity."""
|
||||
if code.startswith("E") or code.startswith("F"):
|
||||
return Severity.ERROR
|
||||
if code.startswith("W"):
|
||||
return Severity.WARNING
|
||||
return Severity.INFO
|
||||
|
||||
async def run_mypy(self) -> list[Problem]:
|
||||
"""Run mypy type checker.
|
||||
|
||||
Returns:
|
||||
List of problems found
|
||||
"""
|
||||
result = await self._process.run(
|
||||
"mypy", "--output=json", "."
|
||||
)
|
||||
|
||||
problems: list[Problem] = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
data = json.loads(line)
|
||||
severity = self._mypy_severity(data.get("severity", "error"))
|
||||
problems.append(Problem(
|
||||
file_path=Path(data["file"]),
|
||||
line=data["line"],
|
||||
column=data.get("column", 1),
|
||||
severity=severity,
|
||||
message=data["message"],
|
||||
source="mypy",
|
||||
code=data.get("code"),
|
||||
))
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
continue
|
||||
|
||||
return problems
|
||||
|
||||
def _mypy_severity(self, severity: str) -> Severity:
|
||||
"""Map mypy severity to Severity enum."""
|
||||
mapping = {
|
||||
"error": Severity.ERROR,
|
||||
"warning": Severity.WARNING,
|
||||
"note": Severity.INFO,
|
||||
}
|
||||
return mapping.get(severity, Severity.ERROR)
|
||||
|
||||
async def run_all(self, linters: list[str]) -> tuple[list[Problem], ProblemsSummary]:
|
||||
"""Run all configured linters.
|
||||
|
||||
Args:
|
||||
linters: List of linter names to run
|
||||
|
||||
Returns:
|
||||
Tuple of (problems list, summary)
|
||||
"""
|
||||
all_problems: list[Problem] = []
|
||||
|
||||
for linter in linters:
|
||||
if linter == "ruff":
|
||||
all_problems.extend(await self.run_ruff())
|
||||
elif linter == "mypy":
|
||||
all_problems.extend(await self.run_mypy())
|
||||
|
||||
# Create summary
|
||||
errors = sum(1 for p in all_problems if p.severity == Severity.ERROR)
|
||||
warnings = sum(1 for p in all_problems if p.severity == Severity.WARNING)
|
||||
infos = sum(1 for p in all_problems if p.severity == Severity.INFO)
|
||||
hints = sum(1 for p in all_problems if p.severity == Severity.HINT)
|
||||
|
||||
summary = ProblemsSummary(
|
||||
errors=errors,
|
||||
warnings=warnings,
|
||||
infos=infos,
|
||||
hints=hints,
|
||||
)
|
||||
|
||||
return all_problems, summary
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Generic subprocess management service."""
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandResult:
|
||||
"""Result of a command execution."""
|
||||
|
||||
returncode: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
"""Check if command succeeded."""
|
||||
return self.returncode == 0
|
||||
|
||||
|
||||
class ProcessService:
|
||||
"""Service for running subprocess commands."""
|
||||
|
||||
def __init__(self, cwd: Path | None = None) -> None:
|
||||
self.cwd = cwd or Path.cwd()
|
||||
|
||||
async def run(
|
||||
self,
|
||||
*args: str,
|
||||
cwd: Path | None = None,
|
||||
timeout: float | None = 30.0,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> CommandResult:
|
||||
"""Run a command asynchronously.
|
||||
|
||||
Args:
|
||||
*args: Command and arguments
|
||||
cwd: Working directory (defaults to service cwd)
|
||||
timeout: Timeout in seconds
|
||||
env: Environment variables to add
|
||||
|
||||
Returns:
|
||||
CommandResult with stdout, stderr, and returncode
|
||||
"""
|
||||
working_dir = cwd or self.cwd
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=working_dir,
|
||||
env=env,
|
||||
)
|
||||
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(),
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
return CommandResult(
|
||||
returncode=process.returncode or 0,
|
||||
stdout=stdout.decode("utf-8", errors="replace"),
|
||||
stderr=stderr.decode("utf-8", errors="replace"),
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
return CommandResult(
|
||||
returncode=-1,
|
||||
stdout="",
|
||||
stderr="Command timed out",
|
||||
)
|
||||
except Exception as e:
|
||||
return CommandResult(
|
||||
returncode=-1,
|
||||
stdout="",
|
||||
stderr=str(e),
|
||||
)
|
||||
|
||||
def run_sync(
|
||||
self,
|
||||
*args: str,
|
||||
cwd: Path | None = None,
|
||||
timeout: float | None = 30.0,
|
||||
) -> CommandResult:
|
||||
"""Run a command synchronously (for use in threads).
|
||||
|
||||
Args:
|
||||
*args: Command and arguments
|
||||
cwd: Working directory
|
||||
timeout: Timeout in seconds
|
||||
|
||||
Returns:
|
||||
CommandResult with stdout, stderr, and returncode
|
||||
"""
|
||||
working_dir = cwd or self.cwd
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
args,
|
||||
capture_output=True,
|
||||
cwd=working_dir,
|
||||
timeout=timeout,
|
||||
text=True,
|
||||
)
|
||||
return CommandResult(
|
||||
returncode=result.returncode,
|
||||
stdout=result.stdout,
|
||||
stderr=result.stderr,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return CommandResult(
|
||||
returncode=-1,
|
||||
stdout="",
|
||||
stderr="Command timed out",
|
||||
)
|
||||
except Exception as e:
|
||||
return CommandResult(
|
||||
returncode=-1,
|
||||
stdout="",
|
||||
stderr=str(e),
|
||||
)
|
||||
@@ -0,0 +1,151 @@
|
||||
"""TODO comment scanner service."""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from clide.models.todos import TodoItem, TodosSummary, TodoType
|
||||
from clide.services.process_service import ProcessService
|
||||
|
||||
|
||||
class TodoScanner:
|
||||
"""Service for scanning TODO/FIXME comments in code."""
|
||||
|
||||
# Pattern to match TODO-style comments
|
||||
TODO_PATTERN = re.compile(
|
||||
r"(?:#|//|/\*|\*|<!--)\s*(TODO|FIXME|HACK|XXX|NOTE|BUG|OPTIMIZE|REVIEW)\s*:?\s*(.+?)(?:\*/|-->)?$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# File extensions to scan
|
||||
SCAN_EXTENSIONS = {
|
||||
".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".c", ".cpp", ".h",
|
||||
".go", ".rs", ".rb", ".php", ".css", ".scss", ".html", ".vue",
|
||||
".svelte", ".md", ".sh", ".bash", ".yaml", ".yml", ".toml",
|
||||
}
|
||||
|
||||
def __init__(self, project_path: Path) -> None:
|
||||
self.project_path = project_path
|
||||
self._process = ProcessService(cwd=project_path)
|
||||
|
||||
async def scan(self) -> tuple[list[TodoItem], TodosSummary]:
|
||||
"""Scan project for TODO comments.
|
||||
|
||||
Returns:
|
||||
Tuple of (todo items, summary)
|
||||
"""
|
||||
items: list[TodoItem] = []
|
||||
|
||||
# Use ripgrep if available for speed
|
||||
result = await self._process.run(
|
||||
"rg", "--line-number", "--no-heading",
|
||||
"-e", r"\b(TODO|FIXME|HACK|XXX|NOTE|BUG|OPTIMIZE|REVIEW)\b",
|
||||
"--type-add", "code:*.py",
|
||||
"--type-add", "code:*.js",
|
||||
"--type-add", "code:*.ts",
|
||||
"--type", "code",
|
||||
".",
|
||||
)
|
||||
|
||||
if result.success:
|
||||
items = self._parse_ripgrep_output(result.stdout)
|
||||
else:
|
||||
# Fallback to Python-based scanning
|
||||
items = await self._scan_with_python()
|
||||
|
||||
# Create summary
|
||||
todo_count = sum(1 for i in items if i.todo_type == TodoType.TODO)
|
||||
fixme_count = sum(1 for i in items if i.todo_type == TodoType.FIXME)
|
||||
hack_count = sum(1 for i in items if i.todo_type == TodoType.HACK)
|
||||
other_count = len(items) - todo_count - fixme_count - hack_count
|
||||
|
||||
summary = TodosSummary(
|
||||
todo_count=todo_count,
|
||||
fixme_count=fixme_count,
|
||||
hack_count=hack_count,
|
||||
other_count=other_count,
|
||||
)
|
||||
|
||||
return items, summary
|
||||
|
||||
def _parse_ripgrep_output(self, output: str) -> list[TodoItem]:
|
||||
"""Parse ripgrep output into TodoItems."""
|
||||
items: list[TodoItem] = []
|
||||
|
||||
for line in output.strip().split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Format: path:line:content
|
||||
parts = line.split(":", 2)
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
|
||||
file_path = Path(parts[0])
|
||||
try:
|
||||
line_num = int(parts[1])
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
content = parts[2]
|
||||
|
||||
# Parse the TODO type and text
|
||||
match = self.TODO_PATTERN.search(content)
|
||||
if match:
|
||||
todo_type_str = match.group(1).upper()
|
||||
todo_text = match.group(2).strip()
|
||||
|
||||
try:
|
||||
todo_type = TodoType(todo_type_str)
|
||||
except ValueError:
|
||||
todo_type = TodoType.TODO
|
||||
|
||||
items.append(TodoItem(
|
||||
file_path=file_path,
|
||||
line=line_num,
|
||||
column=content.find(todo_type_str) + 1,
|
||||
todo_type=todo_type,
|
||||
text=todo_text,
|
||||
context_line=content.strip(),
|
||||
))
|
||||
|
||||
return items
|
||||
|
||||
async def _scan_with_python(self) -> list[TodoItem]:
|
||||
"""Fallback Python-based scanning."""
|
||||
items: list[TodoItem] = []
|
||||
|
||||
for ext in self.SCAN_EXTENSIONS:
|
||||
for file_path in self.project_path.rglob(f"*{ext}"):
|
||||
# Skip hidden directories and common excludes
|
||||
if any(part.startswith(".") for part in file_path.parts):
|
||||
continue
|
||||
if "node_modules" in file_path.parts:
|
||||
continue
|
||||
if "__pycache__" in file_path.parts:
|
||||
continue
|
||||
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
||||
for line_num, line in enumerate(content.split("\n"), 1):
|
||||
match = self.TODO_PATTERN.search(line)
|
||||
if match:
|
||||
todo_type_str = match.group(1).upper()
|
||||
todo_text = match.group(2).strip()
|
||||
|
||||
try:
|
||||
todo_type = TodoType(todo_type_str)
|
||||
except ValueError:
|
||||
todo_type = TodoType.TODO
|
||||
|
||||
items.append(TodoItem(
|
||||
file_path=file_path.relative_to(self.project_path),
|
||||
line=line_num,
|
||||
column=line.find(todo_type_str) + 1,
|
||||
todo_type=todo_type,
|
||||
text=todo_text,
|
||||
context_line=line.strip(),
|
||||
))
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
return items
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Theme system for Clide."""
|
||||
|
||||
from clide.themes.registry import (
|
||||
get_theme,
|
||||
get_all_themes,
|
||||
get_themes_by_category,
|
||||
register_theme,
|
||||
DEFAULT_THEME,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"get_theme",
|
||||
"get_all_themes",
|
||||
"get_themes_by_category",
|
||||
"register_theme",
|
||||
"DEFAULT_THEME",
|
||||
]
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Built-in themes for Clide."""
|
||||
|
||||
from clide.themes.builtin import (
|
||||
summer_night,
|
||||
summer_day,
|
||||
one_dark,
|
||||
one_dark_pro,
|
||||
one_light,
|
||||
dracula,
|
||||
nord,
|
||||
gruvbox_dark,
|
||||
gruvbox_light,
|
||||
one_dark_teal,
|
||||
gamma,
|
||||
winter_is_coming,
|
||||
monokai_winter,
|
||||
fall,
|
||||
dark_autumn,
|
||||
all_hallows_eve,
|
||||
halloween,
|
||||
christmas,
|
||||
santa_baby,
|
||||
pro_hacker,
|
||||
hacker_style,
|
||||
houston,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"summer_night",
|
||||
"summer_day",
|
||||
"one_dark",
|
||||
"one_dark_pro",
|
||||
"one_light",
|
||||
"dracula",
|
||||
"nord",
|
||||
"gruvbox_dark",
|
||||
"gruvbox_light",
|
||||
"one_dark_teal",
|
||||
"gamma",
|
||||
"winter_is_coming",
|
||||
"monokai_winter",
|
||||
"fall",
|
||||
"dark_autumn",
|
||||
"all_hallows_eve",
|
||||
"halloween",
|
||||
"christmas",
|
||||
"santa_baby",
|
||||
"pro_hacker",
|
||||
"hacker_style",
|
||||
"houston",
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
"""All Hallows' Eve Plus theme - Halloween."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="all-hallows-eve",
|
||||
display_name="All Hallows' Eve",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#ff7518", # Pumpkin orange
|
||||
secondary="#9932cc", # Dark orchid
|
||||
accent="#ff6347",
|
||||
background="#1a0a1a",
|
||||
surface="#2d1a2d",
|
||||
panel="#401a40",
|
||||
foreground="#dda0dd",
|
||||
success="#32cd32",
|
||||
warning="#ff7518",
|
||||
error="#dc143c",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Christmas theme."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="christmas",
|
||||
display_name="Christmas",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#ff0000", # Christmas red
|
||||
secondary="#228b22", # Forest green
|
||||
accent="#ffd700", # Gold
|
||||
background="#0a1a0a",
|
||||
surface="#1a2a1a",
|
||||
panel="#2a3a2a",
|
||||
foreground="#f0f0f0",
|
||||
success="#228b22",
|
||||
warning="#ffd700",
|
||||
error="#ff0000",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Dark Autumn Frost theme."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="dark-autumn",
|
||||
display_name="Dark Autumn Frost",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#c49a6c",
|
||||
secondary="#8b7355",
|
||||
accent="#a0522d",
|
||||
background="#1c1410",
|
||||
surface="#2a1f18",
|
||||
panel="#382a20",
|
||||
foreground="#d2b48c",
|
||||
success="#6b8e23",
|
||||
warning="#b8860b",
|
||||
error="#8b0000",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Dracula theme."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="dracula",
|
||||
display_name="Dracula",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#bd93f9",
|
||||
secondary="#8be9fd",
|
||||
accent="#ff79c6",
|
||||
background="#282a36",
|
||||
surface="#21222c",
|
||||
panel="#343746",
|
||||
foreground="#f8f8f2",
|
||||
success="#50fa7b",
|
||||
warning="#ffb86c",
|
||||
error="#ff5555",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Fall theme - Autumn colors."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="fall",
|
||||
display_name="Fall",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#e9967a",
|
||||
secondary="#daa520",
|
||||
accent="#cd853f",
|
||||
background="#2d1f1f",
|
||||
surface="#3d2929",
|
||||
panel="#4d3333",
|
||||
foreground="#f5deb3",
|
||||
success="#8fbc8f",
|
||||
warning="#daa520",
|
||||
error="#cd5c5c",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Gamma theme - GitKraken Gamma style."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="gamma",
|
||||
display_name="Gamma",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#00d4aa",
|
||||
secondary="#7c3aed",
|
||||
accent="#f472b6",
|
||||
background="#0f172a",
|
||||
surface="#1e293b",
|
||||
panel="#334155",
|
||||
foreground="#e2e8f0",
|
||||
success="#22c55e",
|
||||
warning="#f59e0b",
|
||||
error="#ef4444",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Gruvbox Dark theme - Retro groove color scheme."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="gruvbox-dark",
|
||||
display_name="Gruvbox Dark",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#83a598",
|
||||
secondary="#8ec07c",
|
||||
accent="#d3869b",
|
||||
background="#282828",
|
||||
surface="#3c3836",
|
||||
panel="#504945",
|
||||
foreground="#ebdbb2",
|
||||
success="#b8bb26",
|
||||
warning="#fabd2f",
|
||||
error="#fb4934",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Gruvbox Light theme."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="gruvbox-light",
|
||||
display_name="Gruvbox Light",
|
||||
dark=False,
|
||||
colors=ThemeColors(
|
||||
primary="#076678",
|
||||
secondary="#427b58",
|
||||
accent="#8f3f71",
|
||||
background="#fbf1c7",
|
||||
surface="#ebdbb2",
|
||||
panel="#d5c4a1",
|
||||
foreground="#3c3836",
|
||||
success="#79740e",
|
||||
warning="#b57614",
|
||||
error="#9d0006",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Hacker Style theme - Matrix inspired."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="hacker-style",
|
||||
display_name="Hacker Style",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#20c20e",
|
||||
secondary="#33ff33",
|
||||
accent="#66ff66",
|
||||
background="#0c0c0c",
|
||||
surface="#121212",
|
||||
panel="#1a1a1a",
|
||||
foreground="#33ff33",
|
||||
success="#20c20e",
|
||||
warning="#c0c020",
|
||||
error="#c02020",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Halloween theme."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="halloween",
|
||||
display_name="Halloween",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#ff6600",
|
||||
secondary="#8a2be2",
|
||||
accent="#ff4500",
|
||||
background="#0d0d0d",
|
||||
surface="#1a1a1a",
|
||||
panel="#262626",
|
||||
foreground="#e6e6e6",
|
||||
success="#00ff00",
|
||||
warning="#ff6600",
|
||||
error="#ff0000",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Houston theme."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="houston",
|
||||
display_name="Houston",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#ff6f00",
|
||||
secondary="#00bcd4",
|
||||
accent="#ff4081",
|
||||
background="#17212b",
|
||||
surface="#232e3c",
|
||||
panel="#2e3a48",
|
||||
foreground="#eeffff",
|
||||
success="#4caf50",
|
||||
warning="#ff9800",
|
||||
error="#f44336",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Monokai Winter Night theme."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="monokai-winter",
|
||||
display_name="Monokai Winter Night",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#66d9ef",
|
||||
secondary="#a6e22e",
|
||||
accent="#f92672",
|
||||
background="#1a1a2e",
|
||||
surface="#16213e",
|
||||
panel="#0f3460",
|
||||
foreground="#f8f8f2",
|
||||
success="#a6e22e",
|
||||
warning="#e6db74",
|
||||
error="#f92672",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Nord theme - Arctic, north-bluish color palette."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="nord",
|
||||
display_name="Nord",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#88c0d0",
|
||||
secondary="#81a1c1",
|
||||
accent="#b48ead",
|
||||
background="#2e3440",
|
||||
surface="#3b4252",
|
||||
panel="#434c5e",
|
||||
foreground="#eceff4",
|
||||
success="#a3be8c",
|
||||
warning="#ebcb8b",
|
||||
error="#bf616a",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Atom One Dark theme."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="one-dark",
|
||||
display_name="One Dark",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#61afef",
|
||||
secondary="#56b6c2",
|
||||
accent="#c678dd",
|
||||
background="#282c34",
|
||||
surface="#21252b",
|
||||
panel="#2c313a",
|
||||
foreground="#abb2bf",
|
||||
success="#98c379",
|
||||
warning="#e5c07b",
|
||||
error="#e06c75",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""One Dark Pro theme."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="one-dark-pro",
|
||||
display_name="One Dark Pro",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#61afef",
|
||||
secondary="#56b6c2",
|
||||
accent="#c678dd",
|
||||
background="#282c34",
|
||||
surface="#1e2227",
|
||||
panel="#333842",
|
||||
foreground="#abb2bf",
|
||||
success="#98c379",
|
||||
warning="#d19a66",
|
||||
error="#e06c75",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""One Dark Teal theme - GitKraken signature teal accent."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="one-dark-teal",
|
||||
display_name="One Dark Teal",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#2acf9f", # GitKraken teal
|
||||
secondary="#61afef",
|
||||
accent="#c678dd",
|
||||
background="#282c34",
|
||||
surface="#21252b",
|
||||
panel="#2c313a",
|
||||
foreground="#abb2bf",
|
||||
success="#2acf9f",
|
||||
warning="#e5c07b",
|
||||
error="#e06c75",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Atom One Light theme."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="one-light",
|
||||
display_name="One Light",
|
||||
dark=False,
|
||||
colors=ThemeColors(
|
||||
primary="#4078f2",
|
||||
secondary="#0184bc",
|
||||
accent="#a626a4",
|
||||
background="#fafafa",
|
||||
surface="#f0f0f0",
|
||||
panel="#e5e5e6",
|
||||
foreground="#383a42",
|
||||
success="#50a14f",
|
||||
warning="#c18401",
|
||||
error="#e45649",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Pro Hacker theme - Green on black."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="pro-hacker",
|
||||
display_name="Pro Hacker",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#00ff00",
|
||||
secondary="#00cc00",
|
||||
accent="#00ff88",
|
||||
background="#000000",
|
||||
surface="#0a0a0a",
|
||||
panel="#141414",
|
||||
foreground="#00ff00",
|
||||
success="#00ff00",
|
||||
warning="#ffff00",
|
||||
error="#ff0000",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Santa Baby theme - Light Christmas theme."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="santa-baby",
|
||||
display_name="Santa Baby",
|
||||
dark=False,
|
||||
colors=ThemeColors(
|
||||
primary="#c41e3a", # Cardinal red
|
||||
secondary="#228b22", # Forest green
|
||||
accent="#b8860b", # Dark goldenrod
|
||||
background="#fff8f0",
|
||||
surface="#f0e8e0",
|
||||
panel="#e0d8d0",
|
||||
foreground="#2f1f1f",
|
||||
success="#228b22",
|
||||
warning="#daa520",
|
||||
error="#c41e3a",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Summer Day theme - Light variant of Summer Night.
|
||||
|
||||
Inverted lightness scale with adjusted accent hues for readability.
|
||||
"""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="summer-day",
|
||||
display_name="Summer Day",
|
||||
dark=False,
|
||||
colors=ThemeColors(
|
||||
primary="#0088b0",
|
||||
secondary="#008a99",
|
||||
accent="#d03060",
|
||||
background="#f5f7fa",
|
||||
surface="#e8ebf0",
|
||||
panel="#dde1e8",
|
||||
foreground="#21262f",
|
||||
success="#008a7a",
|
||||
warning="#b06830",
|
||||
error="#c04048",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Summer Night theme - Default dark theme.
|
||||
|
||||
Based on jackw01/summer-night-vscode-theme.
|
||||
Vibrant colors with HCL-based monochrome scale.
|
||||
"""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
# Monochrome scale (HCL equidistant lightness)
|
||||
# mono_1: #e2e8f5 - Lightest text
|
||||
# mono_2: #c4c9d6 - Secondary text
|
||||
# mono_3: #a6abb8 - Muted text
|
||||
# mono_4: #898e9a - Comments
|
||||
# mono_5: #6d727e - Subtle
|
||||
# mono_6: #525762 - Borders
|
||||
# mono_7: #393e48 - Surface
|
||||
# mono_8: #21262f - Background
|
||||
|
||||
# Accent colors (HCL analogous scales)
|
||||
# cyan: #00a3d2 - Primary accent
|
||||
# teal: #00a9b9 - Links
|
||||
# pink: #fa5f8b - Keywords
|
||||
# yellow: #d3ab58 - Strings
|
||||
# red: #f06c6f - Errors
|
||||
# orange: #d08447 - Warnings
|
||||
# coral: #e17954 - Functions
|
||||
# green: #00ab9a - Success
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="summer-night",
|
||||
display_name="Summer Night",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#00a3d2",
|
||||
secondary="#00a9b9",
|
||||
accent="#fa5f8b",
|
||||
background="#21262f",
|
||||
surface="#393e48",
|
||||
panel="#292e38",
|
||||
foreground="#e2e8f5",
|
||||
success="#00ab9a",
|
||||
warning="#d08447",
|
||||
error="#f06c6f",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Winter Is Coming theme - Bluish, icy vibe."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="winter-is-coming",
|
||||
display_name="Winter Is Coming",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#89ddff",
|
||||
secondary="#82aaff",
|
||||
accent="#c792ea",
|
||||
background="#011627",
|
||||
surface="#0d293e",
|
||||
panel="#1d3b53",
|
||||
foreground="#d6deeb",
|
||||
success="#22da6e",
|
||||
warning="#ecc48d",
|
||||
error="#ef5350",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Custom theme loader for user-defined themes."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import tomllib
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
from clide.themes.registry import register_theme
|
||||
|
||||
|
||||
def load_custom_themes(themes_dir: Path) -> list[str]:
|
||||
"""Load custom themes from a directory.
|
||||
|
||||
Args:
|
||||
themes_dir: Directory containing .toml theme files
|
||||
|
||||
Returns:
|
||||
List of loaded theme names
|
||||
"""
|
||||
loaded = []
|
||||
|
||||
if not themes_dir.exists():
|
||||
return loaded
|
||||
|
||||
for theme_file in themes_dir.glob("*.toml"):
|
||||
try:
|
||||
theme = load_theme_file(theme_file)
|
||||
if theme:
|
||||
register_theme(theme, "custom")
|
||||
loaded.append(theme.name)
|
||||
except Exception as e:
|
||||
# Log but don't crash on bad theme files
|
||||
print(f"Failed to load theme {theme_file}: {e}")
|
||||
|
||||
return loaded
|
||||
|
||||
|
||||
def load_theme_file(path: Path) -> ThemeDefinition | None:
|
||||
"""Load a single theme from a TOML file.
|
||||
|
||||
Args:
|
||||
path: Path to the theme TOML file
|
||||
|
||||
Returns:
|
||||
Theme definition or None if invalid
|
||||
|
||||
Example TOML format:
|
||||
name = "my-theme"
|
||||
display_name = "My Custom Theme"
|
||||
dark = true
|
||||
|
||||
[colors]
|
||||
primary = "#007acc"
|
||||
secondary = "#3c3c3c"
|
||||
accent = "#0e639c"
|
||||
background = "#1e1e1e"
|
||||
surface = "#252526"
|
||||
panel = "#2d2d2d"
|
||||
foreground = "#d4d4d4"
|
||||
success = "#4ec9b0"
|
||||
warning = "#dcdcaa"
|
||||
error = "#f14c4c"
|
||||
"""
|
||||
with open(path, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
|
||||
# Validate required fields
|
||||
required = ["name", "display_name", "dark", "colors"]
|
||||
for field in required:
|
||||
if field not in data:
|
||||
raise ValueError(f"Missing required field: {field}")
|
||||
|
||||
colors_data = data["colors"]
|
||||
color_fields = [
|
||||
"primary", "secondary", "accent", "background", "surface",
|
||||
"panel", "foreground", "success", "warning", "error"
|
||||
]
|
||||
for field in color_fields:
|
||||
if field not in colors_data:
|
||||
raise ValueError(f"Missing color field: {field}")
|
||||
|
||||
colors = ThemeColors(**colors_data)
|
||||
return ThemeDefinition(
|
||||
name=data["name"],
|
||||
display_name=data["display_name"],
|
||||
dark=data["dark"],
|
||||
colors=colors,
|
||||
)
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Theme registry for managing available themes."""
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from clide.models.theme import ThemeDefinition, ThemeMetadata
|
||||
|
||||
# Theme registry
|
||||
_themes: dict[str, ThemeDefinition] = {}
|
||||
_theme_metadata: dict[str, ThemeMetadata] = {}
|
||||
|
||||
DEFAULT_THEME = "summer-night"
|
||||
|
||||
|
||||
def register_theme(
|
||||
theme: ThemeDefinition,
|
||||
category: str = "custom",
|
||||
) -> None:
|
||||
"""Register a theme in the registry.
|
||||
|
||||
Args:
|
||||
theme: Theme definition to register
|
||||
category: Theme category (core, popular, seasonal, custom)
|
||||
"""
|
||||
_themes[theme.name] = theme
|
||||
_theme_metadata[theme.name] = ThemeMetadata(
|
||||
name=theme.name,
|
||||
display_name=theme.display_name,
|
||||
dark=theme.dark,
|
||||
category=category,
|
||||
)
|
||||
|
||||
|
||||
def get_theme(name: str) -> ThemeDefinition | None:
|
||||
"""Get a theme by name.
|
||||
|
||||
Args:
|
||||
name: Theme name
|
||||
|
||||
Returns:
|
||||
Theme definition or None if not found
|
||||
"""
|
||||
return _themes.get(name)
|
||||
|
||||
|
||||
def get_all_themes() -> list[ThemeMetadata]:
|
||||
"""Get metadata for all registered themes.
|
||||
|
||||
Returns:
|
||||
List of theme metadata sorted by category then name
|
||||
"""
|
||||
themes = list(_theme_metadata.values())
|
||||
# Sort: core first, then alphabetically by category, then by name
|
||||
category_order = {"core": 0, "popular": 1, "gitkraken": 2, "seasonal": 3, "hacker": 4, "custom": 5}
|
||||
themes.sort(key=lambda t: (category_order.get(t.category, 99), t.name))
|
||||
return themes
|
||||
|
||||
|
||||
def get_themes_by_category(category: str) -> list[ThemeMetadata]:
|
||||
"""Get themes filtered by category.
|
||||
|
||||
Args:
|
||||
category: Category to filter by
|
||||
|
||||
Returns:
|
||||
List of theme metadata in that category
|
||||
"""
|
||||
return [t for t in _theme_metadata.values() if t.category == category]
|
||||
|
||||
|
||||
def _load_builtin_themes() -> None:
|
||||
"""Load all built-in themes."""
|
||||
# Import here to avoid circular imports
|
||||
from clide.themes.builtin import (
|
||||
summer_night,
|
||||
summer_day,
|
||||
one_dark,
|
||||
one_dark_pro,
|
||||
one_light,
|
||||
dracula,
|
||||
nord,
|
||||
gruvbox_dark,
|
||||
gruvbox_light,
|
||||
one_dark_teal,
|
||||
gamma,
|
||||
winter_is_coming,
|
||||
monokai_winter,
|
||||
fall,
|
||||
dark_autumn,
|
||||
all_hallows_eve,
|
||||
halloween,
|
||||
christmas,
|
||||
santa_baby,
|
||||
pro_hacker,
|
||||
hacker_style,
|
||||
houston,
|
||||
)
|
||||
|
||||
# Core themes
|
||||
register_theme(summer_night.theme, "core")
|
||||
register_theme(summer_day.theme, "core")
|
||||
|
||||
# Popular themes
|
||||
register_theme(one_dark.theme, "popular")
|
||||
register_theme(one_dark_pro.theme, "popular")
|
||||
register_theme(one_light.theme, "popular")
|
||||
register_theme(dracula.theme, "popular")
|
||||
register_theme(nord.theme, "popular")
|
||||
register_theme(gruvbox_dark.theme, "popular")
|
||||
register_theme(gruvbox_light.theme, "popular")
|
||||
|
||||
# GitKraken style
|
||||
register_theme(one_dark_teal.theme, "gitkraken")
|
||||
register_theme(gamma.theme, "gitkraken")
|
||||
|
||||
# Seasonal - Winter
|
||||
register_theme(winter_is_coming.theme, "seasonal")
|
||||
register_theme(monokai_winter.theme, "seasonal")
|
||||
|
||||
# Seasonal - Fall
|
||||
register_theme(fall.theme, "seasonal")
|
||||
register_theme(dark_autumn.theme, "seasonal")
|
||||
|
||||
# Seasonal - Halloween
|
||||
register_theme(all_hallows_eve.theme, "seasonal")
|
||||
register_theme(halloween.theme, "seasonal")
|
||||
|
||||
# Seasonal - Christmas
|
||||
register_theme(christmas.theme, "seasonal")
|
||||
register_theme(santa_baby.theme, "seasonal")
|
||||
|
||||
# Hacker style
|
||||
register_theme(pro_hacker.theme, "hacker")
|
||||
register_theme(hacker_style.theme, "hacker")
|
||||
|
||||
# Bonus
|
||||
register_theme(houston.theme, "popular")
|
||||
|
||||
|
||||
# Load built-in themes on module import
|
||||
_load_builtin_themes()
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Textual widgets for Clide UI."""
|
||||
|
||||
from clide.widgets.components.branch_status import BranchStatus
|
||||
from clide.widgets.components.diff_pane import DiffPane
|
||||
from clide.widgets.components.editor_pane import EditorPane
|
||||
from clide.widgets.components.files_view import FilesView
|
||||
from clide.widgets.components.git_changes import GitChangesView
|
||||
from clide.widgets.components.git_graph import GitGraphView
|
||||
from clide.widgets.components.jira_view import JiraView
|
||||
from clide.widgets.components.problems_view import ProblemsView
|
||||
from clide.widgets.components.terminal_pane import TerminalPane
|
||||
from clide.widgets.components.todos_view import TodosView
|
||||
|
||||
__all__ = [
|
||||
"BranchStatus",
|
||||
"DiffPane",
|
||||
"EditorPane",
|
||||
"FilesView",
|
||||
"GitChangesView",
|
||||
"GitGraphView",
|
||||
"JiraView",
|
||||
"ProblemsView",
|
||||
"TerminalPane",
|
||||
"TodosView",
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Reusable UI components for Clide."""
|
||||
|
||||
from clide.widgets.components.branch_status import BranchStatus
|
||||
from clide.widgets.components.diff_pane import DiffPane
|
||||
from clide.widgets.components.editor_pane import EditorPane
|
||||
from clide.widgets.components.files_view import FilesView
|
||||
from clide.widgets.components.git_changes import GitChangesView
|
||||
from clide.widgets.components.git_graph import GitGraphView
|
||||
from clide.widgets.components.jira_view import JiraView
|
||||
from clide.widgets.components.problems_view import ProblemsView
|
||||
from clide.widgets.components.terminal_pane import TerminalPane
|
||||
from clide.widgets.components.todos_view import TodosView
|
||||
|
||||
__all__ = [
|
||||
"BranchStatus",
|
||||
"DiffPane",
|
||||
"EditorPane",
|
||||
"FilesView",
|
||||
"GitChangesView",
|
||||
"GitGraphView",
|
||||
"JiraView",
|
||||
"ProblemsView",
|
||||
"TerminalPane",
|
||||
"TodosView",
|
||||
]
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Branch status bar component."""
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.message import Message
|
||||
from textual.widgets import Button, Label, ListItem, ListView, Static
|
||||
|
||||
from clide.models.git import GitBranch
|
||||
|
||||
|
||||
class BranchStatus(Vertical):
|
||||
"""Branch status bar with popout branch selector."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
BranchStatus {
|
||||
height: auto;
|
||||
dock: bottom;
|
||||
}
|
||||
|
||||
BranchStatus .status-bar {
|
||||
height: 1;
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
BranchStatus .branch-icon {
|
||||
width: 2;
|
||||
}
|
||||
|
||||
BranchStatus .branch-name {
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
BranchStatus .popout {
|
||||
display: none;
|
||||
height: auto;
|
||||
max-height: 15;
|
||||
background: $panel;
|
||||
border: solid $primary;
|
||||
layer: popout;
|
||||
}
|
||||
|
||||
BranchStatus .popout.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
BranchStatus .popout-header {
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
BranchStatus .popout-actions {
|
||||
height: 1;
|
||||
padding: 0 1;
|
||||
}
|
||||
"""
|
||||
|
||||
class BranchChanged(Message):
|
||||
"""Emitted when branch is changed."""
|
||||
|
||||
def __init__(self, branch: str) -> None:
|
||||
self.branch = branch
|
||||
super().__init__()
|
||||
|
||||
# Alias for backwards compatibility
|
||||
BranchChangeRequested = BranchChanged
|
||||
|
||||
class NewBranchRequested(Message):
|
||||
"""Emitted when new branch creation is requested."""
|
||||
pass
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
current_branch: str = "main",
|
||||
branches: list[GitBranch] | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._current = current_branch
|
||||
self._branches = branches or []
|
||||
self._popout_visible = False
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Horizontal(classes="status-bar"):
|
||||
yield Static("⎇", classes="branch-icon")
|
||||
yield Static(self._current, classes="branch-name", id="branch-name")
|
||||
yield Static("▾", classes="toggle-icon")
|
||||
|
||||
with Vertical(classes="popout", id="branch-popout"):
|
||||
yield Label("Recent branches", classes="popout-header")
|
||||
yield ListView(
|
||||
*[ListItem(Label(b.name)) for b in self._branches[:5]],
|
||||
id="branch-list",
|
||||
)
|
||||
with Horizontal(classes="popout-actions"):
|
||||
yield Button("Checkout", id="btn-checkout", variant="primary")
|
||||
yield Button("New", id="btn-new")
|
||||
|
||||
@property
|
||||
def branch(self) -> str:
|
||||
"""Get current branch."""
|
||||
return self._current
|
||||
|
||||
@branch.setter
|
||||
def branch(self, value: str) -> None:
|
||||
"""Set current branch."""
|
||||
self._current = value
|
||||
try:
|
||||
self.query_one("#branch-name", Static).update(value)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def update_branch(self, branch: str) -> None:
|
||||
"""Update current branch display."""
|
||||
self.branch = branch
|
||||
|
||||
def update_branches(self, branches: list[GitBranch] | list[str]) -> None:
|
||||
"""Update branches list."""
|
||||
self._branches = branches # type: ignore
|
||||
try:
|
||||
branch_list = self.query_one("#branch-list", ListView)
|
||||
branch_list.clear()
|
||||
for branch in branches[:5]:
|
||||
if isinstance(branch, str):
|
||||
name = branch
|
||||
is_current = name == self._current
|
||||
else:
|
||||
name = branch.name
|
||||
is_current = branch.is_current
|
||||
marker = "● " if is_current else "○ "
|
||||
branch_list.append(ListItem(Label(f"{marker}{name}")))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def toggle_popout(self) -> None:
|
||||
"""Toggle popout visibility."""
|
||||
self._popout_visible = not self._popout_visible
|
||||
popout = self.query_one("#branch-popout")
|
||||
if self._popout_visible:
|
||||
popout.add_class("visible")
|
||||
else:
|
||||
popout.remove_class("visible")
|
||||
|
||||
def on_click(self) -> None:
|
||||
"""Handle click on status bar."""
|
||||
self.toggle_popout()
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle button presses."""
|
||||
if event.button.id == "btn-checkout":
|
||||
branch_list = self.query_one("#branch-list", ListView)
|
||||
if branch_list.highlighted_child:
|
||||
# Get selected branch name
|
||||
label = branch_list.highlighted_child.query_one(Label)
|
||||
branch = label.renderable.plain.lstrip("● ○ ")
|
||||
self.post_message(self.BranchChangeRequested(branch))
|
||||
self.toggle_popout()
|
||||
elif event.button.id == "btn-new":
|
||||
self.post_message(self.NewBranchRequested())
|
||||
self.toggle_popout()
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Diff pane component for viewing diffs."""
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.message import Message
|
||||
from textual.widgets import Button, RichLog, Static
|
||||
|
||||
from clide.models.diff import ChangeType, DiffContent, DiffHunk
|
||||
|
||||
|
||||
class DiffPane(Vertical):
|
||||
"""Diff viewer pane with accept/reject for proposals."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
DiffPane {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
DiffPane .diff-header {
|
||||
height: 1;
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
DiffPane .diff-content {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
DiffPane .diff-actions {
|
||||
height: auto;
|
||||
padding: 1;
|
||||
background: $panel;
|
||||
}
|
||||
|
||||
DiffPane .added {
|
||||
background: #1e3a1e;
|
||||
color: #4ec9b0;
|
||||
}
|
||||
|
||||
DiffPane .removed {
|
||||
background: #3a1e1e;
|
||||
color: #f14c4c;
|
||||
}
|
||||
|
||||
DiffPane .hunk-header {
|
||||
color: $accent;
|
||||
text-style: bold;
|
||||
}
|
||||
"""
|
||||
|
||||
class AcceptClicked(Message):
|
||||
"""Emitted when accept is clicked."""
|
||||
|
||||
def __init__(self, file_path: str) -> None:
|
||||
self.file_path = file_path
|
||||
super().__init__()
|
||||
|
||||
class RejectClicked(Message):
|
||||
"""Emitted when reject is clicked."""
|
||||
|
||||
def __init__(self, file_path: str) -> None:
|
||||
self.file_path = file_path
|
||||
super().__init__()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
diff: DiffContent | None = None,
|
||||
is_proposal: bool = False,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._diff = diff
|
||||
self._is_proposal = is_proposal
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
if self._diff:
|
||||
yield Static(f"Diff: {self._diff.file_path}", classes="diff-header")
|
||||
else:
|
||||
yield Static("No diff loaded", classes="diff-header")
|
||||
|
||||
yield RichLog(id="diff-log", highlight=True, markup=True, classes="diff-content")
|
||||
|
||||
if self._is_proposal:
|
||||
with Horizontal(classes="diff-actions"):
|
||||
yield Button("Accept", id="btn-accept", variant="success")
|
||||
yield Button("Reject", id="btn-reject", variant="error")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Render diff on mount."""
|
||||
self._render_diff()
|
||||
|
||||
def load_diff(self, diff: DiffContent, is_proposal: bool = False) -> None:
|
||||
"""Load a diff into the viewer."""
|
||||
self._diff = diff
|
||||
self._is_proposal = is_proposal
|
||||
|
||||
# Update header
|
||||
header = self.query_one(".diff-header", Static)
|
||||
header.update(f"Diff: {diff.file_path}")
|
||||
|
||||
# Show/hide action buttons
|
||||
try:
|
||||
actions = self.query_one(".diff-actions")
|
||||
actions.display = is_proposal
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._render_diff()
|
||||
|
||||
def _render_diff(self) -> None:
|
||||
"""Render the diff content."""
|
||||
log = self.query_one("#diff-log", RichLog)
|
||||
log.clear()
|
||||
|
||||
if not self._diff:
|
||||
log.write("[dim]No diff to display[/]")
|
||||
return
|
||||
|
||||
for hunk in self._diff.hunks:
|
||||
# Hunk header
|
||||
log.write(f"[hunk-header]{hunk.header}[/]")
|
||||
|
||||
for line in hunk.lines:
|
||||
if line.change_type == ChangeType.ADDED:
|
||||
log.write(f"[green]+{line.content}[/]")
|
||||
elif line.change_type == ChangeType.REMOVED:
|
||||
log.write(f"[red]-{line.content}[/]")
|
||||
else:
|
||||
log.write(f" {line.content}")
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the diff viewer."""
|
||||
self._diff = None
|
||||
log = self.query_one("#diff-log", RichLog)
|
||||
log.clear()
|
||||
|
||||
header = self.query_one(".diff-header", Static)
|
||||
header.update("No diff loaded")
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle button presses."""
|
||||
if not self._diff:
|
||||
return
|
||||
|
||||
if event.button.id == "btn-accept":
|
||||
self.post_message(self.AcceptClicked(self._diff.file_path))
|
||||
elif event.button.id == "btn-reject":
|
||||
self.post_message(self.RejectClicked(self._diff.file_path))
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Editor pane component with syntax highlighting."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.message import Message
|
||||
from textual.widgets import Label, Static, TextArea
|
||||
|
||||
from clide.models.editor import CursorPosition, FileBuffer
|
||||
|
||||
|
||||
class EditorPane(Vertical):
|
||||
"""Editor pane with TextArea and status bar."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
EditorPane {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
EditorPane TextArea {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
EditorPane .editor-status {
|
||||
height: 1;
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
EditorPane .file-tab {
|
||||
height: 1;
|
||||
background: $panel;
|
||||
}
|
||||
|
||||
EditorPane .modified {
|
||||
color: $warning;
|
||||
}
|
||||
"""
|
||||
|
||||
class ContentChanged(Message):
|
||||
"""Emitted when content changes."""
|
||||
|
||||
def __init__(self, path: Path, content: str) -> None:
|
||||
self.path = path
|
||||
self.content = content
|
||||
super().__init__()
|
||||
|
||||
class CursorMoved(Message):
|
||||
"""Emitted when cursor moves."""
|
||||
|
||||
def __init__(self, path: Path, line: int, column: int) -> None:
|
||||
self.path = path
|
||||
self.line = line
|
||||
self.column = column
|
||||
super().__init__()
|
||||
|
||||
class SaveRequested(Message):
|
||||
"""Emitted when save is requested."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = path
|
||||
super().__init__()
|
||||
|
||||
class FileSaved(Message):
|
||||
"""Emitted when file is saved."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = path
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, buffer: FileBuffer | None = None, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._buffer = buffer
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
if self._buffer:
|
||||
yield Static(self._buffer.display_name, classes="file-tab")
|
||||
yield TextArea(
|
||||
self._buffer.content,
|
||||
language=self._buffer.language,
|
||||
id="editor-textarea",
|
||||
show_line_numbers=True,
|
||||
)
|
||||
yield Static(
|
||||
self._get_status_text(),
|
||||
classes="editor-status",
|
||||
id="editor-status",
|
||||
)
|
||||
else:
|
||||
yield Static("No file open", classes="file-tab")
|
||||
yield TextArea(id="editor-textarea", show_line_numbers=True)
|
||||
yield Static("", classes="editor-status", id="editor-status")
|
||||
|
||||
def load_buffer(self, buffer: FileBuffer) -> None:
|
||||
"""Load a file buffer into the editor."""
|
||||
self._buffer = buffer
|
||||
|
||||
textarea = self.query_one("#editor-textarea", TextArea)
|
||||
textarea.load_text(buffer.content)
|
||||
textarea.language = buffer.language
|
||||
|
||||
# Update tab
|
||||
tab = self.query_one(".file-tab", Static)
|
||||
tab.update(buffer.display_name)
|
||||
|
||||
# Update status
|
||||
self._update_status()
|
||||
|
||||
# Set cursor position
|
||||
if buffer.cursor:
|
||||
textarea.cursor_location = (buffer.cursor.line, buffer.cursor.column)
|
||||
|
||||
def get_content(self) -> str:
|
||||
"""Get current editor content."""
|
||||
textarea = self.query_one("#editor-textarea", TextArea)
|
||||
return textarea.text
|
||||
|
||||
def _get_status_text(self) -> str:
|
||||
"""Generate status bar text."""
|
||||
if not self._buffer:
|
||||
return ""
|
||||
|
||||
line = self._buffer.cursor.line + 1 if self._buffer.cursor else 1
|
||||
col = self._buffer.cursor.column + 1 if self._buffer.cursor else 1
|
||||
lang = self._buffer.language or "plain text"
|
||||
|
||||
return f"Ln {line}, Col {col} | {lang}"
|
||||
|
||||
def _update_status(self) -> None:
|
||||
"""Update status bar."""
|
||||
status = self.query_one("#editor-status", Static)
|
||||
status.update(self._get_status_text())
|
||||
|
||||
def on_text_area_changed(self, event: TextArea.Changed) -> None:
|
||||
"""Handle text changes."""
|
||||
if self._buffer:
|
||||
self._buffer.content = event.text_area.text
|
||||
self._buffer.is_modified = True
|
||||
|
||||
# Update tab to show modified indicator
|
||||
tab = self.query_one(".file-tab", Static)
|
||||
tab.update(self._buffer.display_name)
|
||||
|
||||
self.post_message(self.ContentChanged(self._buffer.path, event.text_area.text))
|
||||
|
||||
def on_text_area_selection_changed(self, event: TextArea.SelectionChanged) -> None:
|
||||
"""Handle cursor movement."""
|
||||
if self._buffer:
|
||||
line, col = event.selection.end
|
||||
# CursorPosition is frozen, so create new one
|
||||
self._buffer.cursor = CursorPosition(line=line, column=col)
|
||||
self._update_status()
|
||||
self.post_message(self.CursorMoved(self._buffer.path, line, col))
|
||||
|
||||
@property
|
||||
def current_file(self) -> Path | None:
|
||||
"""Get currently open file path."""
|
||||
return self._buffer.path if self._buffer else None
|
||||
|
||||
@property
|
||||
def modified(self) -> bool:
|
||||
"""Check if buffer has unsaved changes."""
|
||||
return self._buffer.is_modified if self._buffer else False
|
||||
|
||||
def load_file(self, path: Path, goto_line: int | None = None) -> None:
|
||||
"""Load a file from disk into the editor."""
|
||||
from clide.services.file_service import FileService
|
||||
|
||||
content = FileService.read_file(path)
|
||||
language = FileService.detect_language(path)
|
||||
|
||||
buffer = FileBuffer(
|
||||
path=path,
|
||||
content=content,
|
||||
language=language,
|
||||
is_modified=False,
|
||||
)
|
||||
self.load_buffer(buffer)
|
||||
|
||||
if goto_line is not None:
|
||||
textarea = self.query_one("#editor-textarea", TextArea)
|
||||
textarea.cursor_location = (goto_line - 1, 0)
|
||||
|
||||
def save(self) -> bool:
|
||||
"""Save current buffer to disk."""
|
||||
if not self._buffer:
|
||||
return False
|
||||
|
||||
from clide.services.file_service import FileService
|
||||
|
||||
success = FileService.write_file(self._buffer.path, self._buffer.content)
|
||||
if success:
|
||||
self._buffer.is_modified = False
|
||||
# Update tab
|
||||
tab = self.query_one(".file-tab", Static)
|
||||
tab.update(self._buffer.display_name)
|
||||
self.post_message(self.FileSaved(self._buffer.path))
|
||||
return success
|
||||
@@ -0,0 +1,87 @@
|
||||
"""File browser component using DirectoryTree."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from rich.text import Text
|
||||
from textual.message import Message
|
||||
from textual.widgets import DirectoryTree
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from rich.style import Style
|
||||
from textual.widgets._directory_tree import DirEntry
|
||||
from textual.widgets._tree import TreeNode
|
||||
|
||||
|
||||
# Minimal Unicode icons (works with any font)
|
||||
ICON_FOLDER_OPEN = "▾"
|
||||
ICON_FOLDER_CLOSED = "▸"
|
||||
ICON_FILE = "◦"
|
||||
|
||||
|
||||
class FilesView(DirectoryTree):
|
||||
"""File browser widget wrapping DirectoryTree."""
|
||||
|
||||
class FileSelected(Message):
|
||||
"""Emitted when a file is selected."""
|
||||
|
||||
def __init__(self, node: TreeNode[DirEntry], path: Path) -> None:
|
||||
self.node = node
|
||||
self.path = path
|
||||
super().__init__()
|
||||
|
||||
class DirectorySelected(Message):
|
||||
"""Emitted when a directory is selected."""
|
||||
|
||||
def __init__(self, node: TreeNode[DirEntry], path: Path) -> None:
|
||||
self.node = node
|
||||
self.path = path
|
||||
super().__init__()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: Path,
|
||||
*,
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
path,
|
||||
name=name,
|
||||
id=id,
|
||||
classes=classes,
|
||||
)
|
||||
|
||||
def render_label(
|
||||
self, node: TreeNode[DirEntry], base_style: Style, style: Style
|
||||
) -> Text:
|
||||
"""Render a label with minimal Unicode icons."""
|
||||
path = node.data.path
|
||||
|
||||
if path.is_dir():
|
||||
icon = ICON_FOLDER_OPEN if node.is_expanded else ICON_FOLDER_CLOSED
|
||||
icon_style = "bold cyan"
|
||||
else:
|
||||
icon = ICON_FILE
|
||||
icon_style = "dim"
|
||||
|
||||
label = Text()
|
||||
label.append(f"{icon} ", style=icon_style)
|
||||
label.append(path.name, style=style)
|
||||
return label
|
||||
|
||||
def filter_paths(self, paths: list[Path]) -> list[Path]:
|
||||
"""Filter out hidden and ignored paths."""
|
||||
return [
|
||||
p for p in paths
|
||||
if not p.name.startswith(".")
|
||||
and p.name not in ("__pycache__", "node_modules", ".git", ".venv", "venv")
|
||||
]
|
||||
|
||||
|
||||
def refresh_tree(self) -> None:
|
||||
"""Refresh the directory tree."""
|
||||
self.reload()
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Git changes view component."""
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.message import Message
|
||||
from textual.widgets import Label, ListItem, ListView, Static
|
||||
|
||||
from clide.models.git import ChangeStatus, GitChange
|
||||
|
||||
|
||||
class GitChangeItem(ListItem):
|
||||
"""A single git change item."""
|
||||
|
||||
STATUS_ICONS = {
|
||||
ChangeStatus.ADDED: "+",
|
||||
ChangeStatus.MODIFIED: "~",
|
||||
ChangeStatus.DELETED: "-",
|
||||
ChangeStatus.RENAMED: "→",
|
||||
ChangeStatus.UNTRACKED: "?",
|
||||
ChangeStatus.COPIED: "C",
|
||||
ChangeStatus.UNMERGED: "!",
|
||||
ChangeStatus.IGNORED: "I",
|
||||
}
|
||||
|
||||
def __init__(self, change: GitChange) -> None:
|
||||
super().__init__()
|
||||
self.change = change
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
icon = self.STATUS_ICONS.get(self.change.status, "?")
|
||||
status_class = self.change.status.value
|
||||
yield Static(
|
||||
f"[{status_class}]{icon}[/] {self.change.path}",
|
||||
markup=True,
|
||||
)
|
||||
|
||||
|
||||
class GitChangesView(Vertical):
|
||||
"""View for staged and unstaged git changes."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
GitChangesView {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
GitChangesView .section-header {
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
GitChangesView ListView {
|
||||
height: auto;
|
||||
max-height: 50%;
|
||||
}
|
||||
|
||||
GitChangesView .added { color: $success; }
|
||||
GitChangesView .modified { color: $warning; }
|
||||
GitChangesView .deleted { color: $error; }
|
||||
GitChangesView .untracked { color: $accent; }
|
||||
"""
|
||||
|
||||
class FileClicked(Message):
|
||||
"""Emitted when a file is clicked."""
|
||||
|
||||
def __init__(self, change: GitChange) -> None:
|
||||
self.change = change
|
||||
super().__init__()
|
||||
|
||||
class StageRequested(Message):
|
||||
"""Emitted when staging is requested."""
|
||||
|
||||
def __init__(self, path: str) -> None:
|
||||
self.path = path
|
||||
super().__init__()
|
||||
|
||||
class UnstageRequested(Message):
|
||||
"""Emitted when unstaging is requested."""
|
||||
|
||||
def __init__(self, path: str) -> None:
|
||||
self.path = path
|
||||
super().__init__()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
staged: list[GitChange] | None = None,
|
||||
unstaged: list[GitChange] | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._staged = staged or []
|
||||
self._unstaged = unstaged or []
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Label("Staged Changes", classes="section-header")
|
||||
yield ListView(
|
||||
*[GitChangeItem(c) for c in self._staged],
|
||||
id="staged-list",
|
||||
)
|
||||
yield Label("Changes", classes="section-header")
|
||||
yield ListView(
|
||||
*[GitChangeItem(c) for c in self._unstaged],
|
||||
id="unstaged-list",
|
||||
)
|
||||
|
||||
def update_changes(
|
||||
self,
|
||||
staged: list[GitChange],
|
||||
unstaged: list[GitChange],
|
||||
) -> None:
|
||||
"""Update the changes lists."""
|
||||
self._staged = staged
|
||||
self._unstaged = unstaged
|
||||
|
||||
staged_list = self.query_one("#staged-list", ListView)
|
||||
unstaged_list = self.query_one("#unstaged-list", ListView)
|
||||
|
||||
staged_list.clear()
|
||||
for change in staged:
|
||||
staged_list.append(GitChangeItem(change))
|
||||
|
||||
unstaged_list.clear()
|
||||
for change in unstaged:
|
||||
unstaged_list.append(GitChangeItem(change))
|
||||
|
||||
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
||||
"""Handle item selection."""
|
||||
if isinstance(event.item, GitChangeItem):
|
||||
self.post_message(self.FileClicked(event.item.change))
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Git graph visualization component."""
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.message import Message
|
||||
from textual.widgets import RichLog, Static
|
||||
from textual.containers import Vertical
|
||||
|
||||
from clide.models.git import GitCommit
|
||||
|
||||
|
||||
class GitGraphView(Vertical):
|
||||
"""View for git commit graph visualization."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
GitGraphView {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
GitGraphView RichLog {
|
||||
height: 100%;
|
||||
scrollbar-size: 1 1;
|
||||
}
|
||||
|
||||
GitGraphView .commit-line {
|
||||
height: auto;
|
||||
}
|
||||
"""
|
||||
|
||||
# Graph drawing characters
|
||||
COMMIT = "●"
|
||||
MERGE = "◆"
|
||||
LINE = "│"
|
||||
BRANCH = "├"
|
||||
JOIN = "┴"
|
||||
|
||||
class CommitSelected(Message):
|
||||
"""Emitted when a commit is selected."""
|
||||
|
||||
def __init__(self, commit: GitCommit) -> None:
|
||||
self.commit = commit
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, commits: list[GitCommit] | None = None, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._commits = commits or []
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield RichLog(id="graph-log", highlight=True, markup=True)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Render initial graph."""
|
||||
self._render_graph()
|
||||
|
||||
def update_commits(self, commits: list[GitCommit]) -> None:
|
||||
"""Update the commit list."""
|
||||
self._commits = commits
|
||||
self._render_graph()
|
||||
|
||||
def _render_graph(self) -> None:
|
||||
"""Render the commit graph."""
|
||||
log = self.query_one("#graph-log", RichLog)
|
||||
log.clear()
|
||||
|
||||
for commit in self._commits:
|
||||
line = self._format_commit_line(commit)
|
||||
log.write(line)
|
||||
|
||||
def _format_commit_line(self, commit: GitCommit) -> str:
|
||||
"""Format a single commit line."""
|
||||
# Choose commit symbol
|
||||
symbol = self.MERGE if commit.is_merge else self.COMMIT
|
||||
|
||||
# Format refs (branches, tags)
|
||||
refs_str = ""
|
||||
if commit.refs:
|
||||
refs = ", ".join(commit.refs)
|
||||
refs_str = f" [bold cyan]({refs})[/]"
|
||||
|
||||
# Truncate message
|
||||
message = commit.message[:50]
|
||||
if len(commit.message) > 50:
|
||||
message += "..."
|
||||
|
||||
return (
|
||||
f"[bold yellow]{symbol}[/] "
|
||||
f"[dim]{commit.short_hash}[/]"
|
||||
f"{refs_str} "
|
||||
f"{message} "
|
||||
f"[dim]- {commit.author}, {commit.date}[/]"
|
||||
)
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Jira view component for Jira CLI output."""
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.message import Message
|
||||
from textual.widgets import Button, Markdown, Static
|
||||
|
||||
|
||||
class JiraView(Vertical):
|
||||
"""View for Jira CLI output."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
JiraView {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
JiraView .jira-header {
|
||||
height: 1;
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
JiraView Markdown {
|
||||
height: 1fr;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
JiraView .jira-actions {
|
||||
height: auto;
|
||||
padding: 0 1;
|
||||
background: $panel;
|
||||
}
|
||||
|
||||
JiraView .disabled-message {
|
||||
padding: 2;
|
||||
text-align: center;
|
||||
color: $warning;
|
||||
}
|
||||
"""
|
||||
|
||||
class RefreshRequested(Message):
|
||||
"""Emitted when refresh is requested."""
|
||||
pass
|
||||
|
||||
class IssueClicked(Message):
|
||||
"""Emitted when an issue is clicked."""
|
||||
|
||||
def __init__(self, issue_key: str) -> None:
|
||||
self.issue_key = issue_key
|
||||
super().__init__()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
content: str = "",
|
||||
enabled: bool = True,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._content = content
|
||||
self._enabled = enabled
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static("Jira", classes="jira-header")
|
||||
|
||||
if self._enabled:
|
||||
yield Markdown(self._content or "*Loading...*", id="jira-content")
|
||||
yield Button("↻ Refresh", id="btn-refresh", classes="jira-actions")
|
||||
else:
|
||||
yield Static(
|
||||
"Jira integration is disabled.\n\n"
|
||||
"Enable it in settings with CLIDE_JIRA_ENABLED=true",
|
||||
classes="disabled-message",
|
||||
)
|
||||
|
||||
def update_content(self, content: str) -> None:
|
||||
"""Update Jira output content."""
|
||||
self._content = content
|
||||
try:
|
||||
markdown = self.query_one("#jira-content", Markdown)
|
||||
markdown.update(content)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def set_loading(self) -> None:
|
||||
"""Show loading state."""
|
||||
self.update_content("*Loading...*")
|
||||
|
||||
def set_error(self, error: str) -> None:
|
||||
"""Show error state."""
|
||||
self.update_content(f"**Error:** {error}")
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle button presses."""
|
||||
if event.button.id == "btn-refresh":
|
||||
self.set_loading()
|
||||
self.post_message(self.RefreshRequested())
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Problems view component for linter errors."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.message import Message
|
||||
from textual.widgets import Label, ListItem, ListView, Static
|
||||
from textual.containers import Vertical
|
||||
|
||||
from clide.models.problems import Problem, Severity
|
||||
|
||||
|
||||
class ProblemItem(ListItem):
|
||||
"""A single problem item."""
|
||||
|
||||
def __init__(self, problem: Problem) -> None:
|
||||
super().__init__()
|
||||
self.problem = problem
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
icon = self.problem.severity_icon
|
||||
severity_class = self.problem.severity.value
|
||||
|
||||
yield Static(
|
||||
f"[{severity_class}]{icon}[/] "
|
||||
f"[dim]{self.problem.file_path}:{self.problem.line}[/] "
|
||||
f"{self.problem.message}",
|
||||
markup=True,
|
||||
)
|
||||
|
||||
|
||||
class ProblemsView(Vertical):
|
||||
"""View for linter problems/diagnostics."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
ProblemsView {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
ProblemsView .problems-header {
|
||||
height: 1;
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
ProblemsView ListView {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
ProblemsView .error { color: $error; }
|
||||
ProblemsView .warning { color: $warning; }
|
||||
ProblemsView .info { color: $primary; }
|
||||
ProblemsView .hint { color: $secondary; }
|
||||
|
||||
ProblemsView .empty-message {
|
||||
padding: 2;
|
||||
text-align: center;
|
||||
color: $success;
|
||||
}
|
||||
"""
|
||||
|
||||
class ProblemClicked(Message):
|
||||
"""Emitted when a problem is clicked."""
|
||||
|
||||
def __init__(self, problem: Problem) -> None:
|
||||
self.problem = problem
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, problems: list[Problem] | None = None, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._problems = problems or []
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
count = len(self._problems)
|
||||
yield Static(f"Problems ({count})", classes="problems-header", id="problems-header")
|
||||
|
||||
if self._problems:
|
||||
yield ListView(
|
||||
*[ProblemItem(p) for p in self._problems],
|
||||
id="problems-list",
|
||||
)
|
||||
else:
|
||||
yield Static("No problems found ✓", classes="empty-message")
|
||||
|
||||
def update_problems(self, problems: list[Problem]) -> None:
|
||||
"""Update the problems list."""
|
||||
self._problems = problems
|
||||
|
||||
# Update header
|
||||
header = self.query_one("#problems-header", Static)
|
||||
header.update(f"Problems ({len(problems)})")
|
||||
|
||||
# Update list
|
||||
try:
|
||||
problems_list = self.query_one("#problems-list", ListView)
|
||||
problems_list.clear()
|
||||
for problem in problems:
|
||||
problems_list.append(ProblemItem(problem))
|
||||
except Exception:
|
||||
# List might not exist yet, will be created on next compose
|
||||
pass
|
||||
|
||||
def filter_by_file(self, path: Path) -> list[Problem]:
|
||||
"""Get problems for a specific file."""
|
||||
return [p for p in self._problems if p.file_path == path]
|
||||
|
||||
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
||||
"""Handle item selection."""
|
||||
if isinstance(event.item, ProblemItem):
|
||||
self.post_message(self.ProblemClicked(event.item.problem))
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Terminal pane component."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.message import Message
|
||||
from textual.widgets import Input, RichLog, Static
|
||||
|
||||
|
||||
class TerminalPane(Vertical):
|
||||
"""Simple terminal/command runner pane."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
TerminalPane {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
TerminalPane .terminal-header {
|
||||
height: 1;
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
TerminalPane RichLog {
|
||||
height: 1fr;
|
||||
background: $background;
|
||||
}
|
||||
|
||||
TerminalPane Input {
|
||||
dock: bottom;
|
||||
height: 1;
|
||||
}
|
||||
|
||||
TerminalPane .prompt {
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
TerminalPane .output {
|
||||
color: $foreground;
|
||||
}
|
||||
|
||||
TerminalPane .error {
|
||||
color: $error;
|
||||
}
|
||||
"""
|
||||
|
||||
class CommandSubmitted(Message):
|
||||
"""Emitted when a command is submitted."""
|
||||
|
||||
def __init__(self, command: str) -> None:
|
||||
self.command = command
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, cwd: Path | None = None, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._cwd = cwd or Path.cwd()
|
||||
self._history: list[str] = []
|
||||
self._history_index = 0
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static(f"Terminal - {self._cwd}", classes="terminal-header")
|
||||
yield RichLog(id="terminal-log", highlight=True, markup=True)
|
||||
yield Input(placeholder="Enter command...", id="terminal-input")
|
||||
|
||||
@property
|
||||
def cwd(self) -> Path:
|
||||
"""Get current working directory."""
|
||||
return self._cwd
|
||||
|
||||
@cwd.setter
|
||||
def cwd(self, path: Path) -> None:
|
||||
"""Set current working directory."""
|
||||
self._cwd = path
|
||||
header = self.query_one(".terminal-header", Static)
|
||||
header.update(f"Terminal - {path}")
|
||||
|
||||
def write_output(self, text: str, style: str = "output") -> None:
|
||||
"""Write output to terminal.
|
||||
|
||||
Args:
|
||||
text: Text to write
|
||||
style: Style class (output, error, prompt)
|
||||
"""
|
||||
log = self.query_one("#terminal-log", RichLog)
|
||||
if style == "error":
|
||||
log.write(f"[red]{text}[/]")
|
||||
elif style == "prompt":
|
||||
log.write(f"[bold cyan]$ {text}[/]")
|
||||
else:
|
||||
log.write(text)
|
||||
|
||||
def write_command(self, command: str) -> None:
|
||||
"""Write a command to terminal (with prompt)."""
|
||||
self.write_output(command, "prompt")
|
||||
|
||||
def write_error(self, error: str) -> None:
|
||||
"""Write an error to terminal."""
|
||||
self.write_output(error, "error")
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear terminal output."""
|
||||
log = self.query_one("#terminal-log", RichLog)
|
||||
log.clear()
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
"""Handle command submission."""
|
||||
command = event.value.strip()
|
||||
if not command:
|
||||
return
|
||||
|
||||
# Add to history
|
||||
self._history.append(command)
|
||||
self._history_index = len(self._history)
|
||||
|
||||
# Clear input
|
||||
event.input.clear()
|
||||
|
||||
# Write command to output
|
||||
self.write_command(command)
|
||||
|
||||
# Post message for handling
|
||||
self.post_message(self.CommandSubmitted(command))
|
||||
|
||||
def history_up(self) -> None:
|
||||
"""Navigate history up."""
|
||||
if self._history and self._history_index > 0:
|
||||
self._history_index -= 1
|
||||
input_widget = self.query_one("#terminal-input", Input)
|
||||
input_widget.value = self._history[self._history_index]
|
||||
|
||||
def history_down(self) -> None:
|
||||
"""Navigate history down."""
|
||||
input_widget = self.query_one("#terminal-input", Input)
|
||||
if self._history_index < len(self._history) - 1:
|
||||
self._history_index += 1
|
||||
input_widget.value = self._history[self._history_index]
|
||||
else:
|
||||
self._history_index = len(self._history)
|
||||
input_widget.clear()
|
||||
@@ -0,0 +1,111 @@
|
||||
"""TODOs view component."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.message import Message
|
||||
from textual.widgets import Label, ListItem, ListView, Static
|
||||
from textual.containers import Vertical
|
||||
|
||||
from clide.models.todos import TodoItem, TodoType
|
||||
|
||||
|
||||
class TodoListItem(ListItem):
|
||||
"""A single TODO item."""
|
||||
|
||||
def __init__(self, item: TodoItem) -> None:
|
||||
super().__init__()
|
||||
self.item = item
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
icon = self.item.type_icon
|
||||
type_class = self.item.todo_type.value.lower()
|
||||
|
||||
yield Static(
|
||||
f"[{type_class}]{icon} {self.item.todo_type.value}[/] "
|
||||
f"[dim]{self.item.file_path}:{self.item.line}[/] "
|
||||
f"{self.item.text}",
|
||||
markup=True,
|
||||
)
|
||||
|
||||
|
||||
class TodosView(Vertical):
|
||||
"""View for TODO/FIXME comments."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
TodosView {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
TodosView .todos-header {
|
||||
height: 1;
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
TodosView ListView {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
TodosView .todo { color: $primary; }
|
||||
TodosView .fixme { color: $warning; }
|
||||
TodosView .hack { color: $error; }
|
||||
TodosView .xxx { color: $error; }
|
||||
TodosView .note { color: $secondary; }
|
||||
TodosView .bug { color: $error; }
|
||||
|
||||
TodosView .empty-message {
|
||||
padding: 2;
|
||||
text-align: center;
|
||||
color: $success;
|
||||
}
|
||||
"""
|
||||
|
||||
class TodoClicked(Message):
|
||||
"""Emitted when a TODO is clicked."""
|
||||
|
||||
def __init__(self, item: TodoItem) -> None:
|
||||
self.item = item
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, items: list[TodoItem] | None = None, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._items = items or []
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
count = len(self._items)
|
||||
yield Static(f"TODOs ({count})", classes="todos-header", id="todos-header")
|
||||
|
||||
if self._items:
|
||||
yield ListView(
|
||||
*[TodoListItem(item) for item in self._items],
|
||||
id="todos-list",
|
||||
)
|
||||
else:
|
||||
yield Static("No TODOs found ✓", classes="empty-message")
|
||||
|
||||
def update_items(self, items: list[TodoItem]) -> None:
|
||||
"""Update the TODOs list."""
|
||||
self._items = items
|
||||
|
||||
# Update header
|
||||
header = self.query_one("#todos-header", Static)
|
||||
header.update(f"TODOs ({len(items)})")
|
||||
|
||||
# Update list
|
||||
try:
|
||||
todos_list = self.query_one("#todos-list", ListView)
|
||||
todos_list.clear()
|
||||
for item in items:
|
||||
todos_list.append(TodoListItem(item))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def filter_by_type(self, todo_type: TodoType) -> list[TodoItem]:
|
||||
"""Filter by TODO type."""
|
||||
return [i for i in self._items if i.todo_type == todo_type]
|
||||
|
||||
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
||||
"""Handle item selection."""
|
||||
if isinstance(event.item, TodoListItem):
|
||||
self.post_message(self.TodoClicked(event.item.item))
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Main layout panels for Clide."""
|
||||
|
||||
from clide.widgets.panels.claude import ClaudePanel
|
||||
from clide.widgets.panels.context import ContextPanel
|
||||
from clide.widgets.panels.sidebar import SidebarPanel
|
||||
from clide.widgets.panels.workspace import WorkspacePanel
|
||||
|
||||
__all__ = [
|
||||
"ClaudePanel",
|
||||
"ContextPanel",
|
||||
"SidebarPanel",
|
||||
"WorkspacePanel",
|
||||
]
|
||||
@@ -0,0 +1,418 @@
|
||||
"""Claude panel with embedded terminal running Claude Code."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import fcntl
|
||||
import os
|
||||
import pty
|
||||
import shutil
|
||||
import struct
|
||||
import termios
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pyte
|
||||
from rich.text import Text
|
||||
from textual.containers import Vertical
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
from textual.strip import Strip
|
||||
from textual.widget import Widget
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from textual.app import ComposeResult
|
||||
|
||||
|
||||
class TerminalDisplay(Widget, can_focus=True):
|
||||
"""A terminal emulator widget using pyte."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
TerminalDisplay {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: $background;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cols: int = 80,
|
||||
rows: int = 24,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._cols = cols
|
||||
self._rows = rows
|
||||
self._screen = pyte.Screen(cols, rows)
|
||||
self._stream = pyte.Stream(self._screen)
|
||||
self._master_fd: int | None = None
|
||||
self._pid: int | None = None
|
||||
self._read_task: asyncio.Task | None = None
|
||||
|
||||
def on_resize(self, event) -> None:
|
||||
"""Handle terminal resize."""
|
||||
# Get new size from widget
|
||||
new_cols = max(event.size.width, 20)
|
||||
new_rows = max(event.size.height, 5)
|
||||
|
||||
if new_cols != self._cols or new_rows != self._rows:
|
||||
self._cols = new_cols
|
||||
self._rows = new_rows
|
||||
self._screen.resize(new_rows, new_cols)
|
||||
|
||||
# Update PTY size if running
|
||||
if self._master_fd is not None:
|
||||
self._set_pty_size(self._master_fd, new_rows, new_cols)
|
||||
|
||||
def _set_pty_size(self, fd: int, rows: int, cols: int) -> None:
|
||||
"""Set the PTY window size."""
|
||||
try:
|
||||
winsize = struct.pack("HHHH", rows, cols, 0, 0)
|
||||
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def start(self, command: str, cwd: str) -> None:
|
||||
"""Start a process in the terminal."""
|
||||
# Fork a PTY
|
||||
pid, master_fd = pty.fork()
|
||||
|
||||
if pid == 0:
|
||||
# Child process
|
||||
os.chdir(cwd)
|
||||
os.environ["TERM"] = "xterm-256color"
|
||||
os.environ["COLORTERM"] = "truecolor"
|
||||
os.execlp(command, command)
|
||||
else:
|
||||
# Parent process
|
||||
self._pid = pid
|
||||
self._master_fd = master_fd
|
||||
|
||||
# Set non-blocking
|
||||
flags = fcntl.fcntl(master_fd, fcntl.F_GETFL)
|
||||
fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
||||
|
||||
# Set initial size
|
||||
self._set_pty_size(master_fd, self._rows, self._cols)
|
||||
|
||||
# Start reading
|
||||
self._read_task = asyncio.create_task(self._read_output())
|
||||
|
||||
async def _read_output(self) -> None:
|
||||
"""Read output from the PTY."""
|
||||
if self._master_fd is None:
|
||||
return
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Wait for data to be available
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
try:
|
||||
data = os.read(self._master_fd, 65536)
|
||||
if not data:
|
||||
break
|
||||
|
||||
# Feed data to pyte
|
||||
self._stream.feed(data.decode("utf-8", errors="replace"))
|
||||
self.refresh()
|
||||
|
||||
except BlockingIOError:
|
||||
# No data available
|
||||
continue
|
||||
except OSError:
|
||||
# PTY closed
|
||||
break
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the terminal process."""
|
||||
if self._read_task:
|
||||
self._read_task.cancel()
|
||||
self._read_task = None
|
||||
|
||||
if self._master_fd is not None:
|
||||
try:
|
||||
os.close(self._master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
self._master_fd = None
|
||||
|
||||
if self._pid is not None:
|
||||
try:
|
||||
os.kill(self._pid, 9)
|
||||
os.waitpid(self._pid, 0)
|
||||
except (OSError, ChildProcessError):
|
||||
pass
|
||||
self._pid = None
|
||||
|
||||
def is_running(self) -> bool:
|
||||
"""Check if the process is still running."""
|
||||
if self._pid is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
pid, status = os.waitpid(self._pid, os.WNOHANG)
|
||||
if pid == 0:
|
||||
return True # Still running
|
||||
else:
|
||||
self._pid = None
|
||||
return False
|
||||
except ChildProcessError:
|
||||
self._pid = None
|
||||
return False
|
||||
|
||||
def send(self, data: str) -> None:
|
||||
"""Send data to the terminal."""
|
||||
if self._master_fd is not None:
|
||||
try:
|
||||
os.write(self._master_fd, data.encode("utf-8"))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def render_line(self, y: int) -> Strip:
|
||||
"""Render a line of the terminal."""
|
||||
if y >= self._rows:
|
||||
return Strip.blank(self._cols)
|
||||
|
||||
line = self._screen.buffer[y]
|
||||
text = Text()
|
||||
|
||||
for x in range(self._cols):
|
||||
char = line[x]
|
||||
char_data = char.data if char.data else " "
|
||||
|
||||
# Build style from pyte character attributes
|
||||
style_parts = []
|
||||
|
||||
if char.fg and char.fg != "default":
|
||||
style_parts.append(f"color({char.fg})" if char.fg.startswith("#") else char.fg)
|
||||
|
||||
if char.bg and char.bg != "default":
|
||||
style_parts.append(f"on color({char.bg})" if char.bg.startswith("#") else f"on {char.bg}")
|
||||
|
||||
if char.bold:
|
||||
style_parts.append("bold")
|
||||
if char.italics:
|
||||
style_parts.append("italic")
|
||||
if char.underscore:
|
||||
style_parts.append("underline")
|
||||
if char.reverse:
|
||||
style_parts.append("reverse")
|
||||
|
||||
style = " ".join(style_parts) if style_parts else None
|
||||
text.append(char_data, style=style)
|
||||
|
||||
# Render text to segments for Strip
|
||||
segments = list(text.render(self.app.console))
|
||||
return Strip(segments)
|
||||
|
||||
def on_key(self, event) -> None:
|
||||
"""Handle key presses."""
|
||||
# Map special keys
|
||||
key_map = {
|
||||
"enter": "\r",
|
||||
"tab": "\t",
|
||||
"backspace": "\x7f",
|
||||
"delete": "\x1b[3~",
|
||||
"up": "\x1b[A",
|
||||
"down": "\x1b[B",
|
||||
"right": "\x1b[C",
|
||||
"left": "\x1b[D",
|
||||
"home": "\x1b[H",
|
||||
"end": "\x1b[F",
|
||||
"pageup": "\x1b[5~",
|
||||
"pagedown": "\x1b[6~",
|
||||
"escape": "\x1b",
|
||||
}
|
||||
|
||||
if event.key in key_map:
|
||||
self.send(key_map[event.key])
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
elif event.key == "ctrl+c":
|
||||
self.send("\x03")
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
elif event.key == "ctrl+d":
|
||||
self.send("\x04")
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
elif event.key == "ctrl+z":
|
||||
self.send("\x1a")
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
elif event.key == "ctrl+l":
|
||||
self.send("\x0c")
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
elif event.character and len(event.character) == 1:
|
||||
self.send(event.character)
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
|
||||
|
||||
class ClaudePanel(Vertical):
|
||||
"""Terminal panel running Claude Code CLI.
|
||||
|
||||
Automatically starts Claude Code and restarts when it exits.
|
||||
Takes 100% height when workspace is hidden, 40% when visible.
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
ClaudePanel {
|
||||
height: 100%;
|
||||
background: $background;
|
||||
}
|
||||
|
||||
ClaudePanel.with-workspace {
|
||||
height: 40%;
|
||||
}
|
||||
|
||||
ClaudePanel TerminalDisplay {
|
||||
height: 100%;
|
||||
}
|
||||
"""
|
||||
|
||||
class ClaudeExited(Message):
|
||||
"""Emitted when Claude Code process exits."""
|
||||
|
||||
def __init__(self, return_code: int) -> None:
|
||||
self.return_code = return_code
|
||||
super().__init__()
|
||||
|
||||
class ClaudeStarted(Message):
|
||||
"""Emitted when Claude Code process starts."""
|
||||
pass
|
||||
|
||||
# Reactive state
|
||||
workspace_visible: reactive[bool] = reactive(False)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workdir: Path | None = None,
|
||||
auto_start: bool = True,
|
||||
restart_on_exit: bool = True,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.id = "panel-claude"
|
||||
self._workdir = workdir or Path.cwd()
|
||||
self._auto_start = auto_start
|
||||
self._restart_on_exit = restart_on_exit
|
||||
self._claude_command = self._find_claude_command()
|
||||
self._terminal: TerminalDisplay | None = None
|
||||
self._monitor_task: asyncio.Task | None = None
|
||||
|
||||
def _find_claude_command(self) -> str:
|
||||
"""Find the Claude Code CLI command."""
|
||||
# Check common locations
|
||||
claude_paths = [
|
||||
"claude", # In PATH
|
||||
str(Path.home() / ".claude" / "local" / "claude"),
|
||||
str(Path.home() / ".local" / "bin" / "claude"),
|
||||
"/usr/local/bin/claude",
|
||||
]
|
||||
|
||||
for path in claude_paths:
|
||||
if path and shutil.which(path):
|
||||
return path
|
||||
|
||||
# Fallback to 'claude' and hope it's in PATH
|
||||
return "claude"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
self._terminal = TerminalDisplay(id="claude-terminal")
|
||||
yield self._terminal
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Start Claude Code when mounted."""
|
||||
if self._auto_start:
|
||||
self.call_later(self.start_claude)
|
||||
|
||||
def watch_workspace_visible(self, visible: bool) -> None:
|
||||
"""Adjust height based on workspace visibility."""
|
||||
if visible:
|
||||
self.add_class("with-workspace")
|
||||
else:
|
||||
self.remove_class("with-workspace")
|
||||
|
||||
def start_claude(self) -> None:
|
||||
"""Start the Claude Code CLI in the terminal."""
|
||||
if self._terminal is None:
|
||||
return
|
||||
|
||||
# Start Claude Code with the working directory
|
||||
self._terminal.start(
|
||||
self._claude_command,
|
||||
str(self._workdir),
|
||||
)
|
||||
|
||||
self.post_message(self.ClaudeStarted())
|
||||
|
||||
# Start monitoring for exit
|
||||
if self._monitor_task:
|
||||
self._monitor_task.cancel()
|
||||
self._monitor_task = asyncio.create_task(self._monitor_claude())
|
||||
|
||||
async def _monitor_claude(self) -> None:
|
||||
"""Monitor Claude process and restart if needed."""
|
||||
if self._terminal is None:
|
||||
return
|
||||
|
||||
# Wait for the terminal process to exit
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Check if process has exited
|
||||
if not self._terminal.is_running():
|
||||
self.post_message(self.ClaudeExited(0))
|
||||
|
||||
if self._restart_on_exit:
|
||||
# Wait a moment before restarting
|
||||
await asyncio.sleep(0.5)
|
||||
self.start_claude()
|
||||
break
|
||||
|
||||
def stop_claude(self) -> None:
|
||||
"""Stop the Claude Code CLI."""
|
||||
if self._terminal:
|
||||
self._terminal.stop()
|
||||
|
||||
if self._monitor_task:
|
||||
self._monitor_task.cancel()
|
||||
self._monitor_task = None
|
||||
|
||||
def send_input(self, text: str) -> None:
|
||||
"""Send input to the Claude terminal."""
|
||||
if self._terminal:
|
||||
self._terminal.send(text)
|
||||
|
||||
def send_interrupt(self) -> None:
|
||||
"""Send Ctrl+C interrupt to Claude."""
|
||||
if self._terminal:
|
||||
self._terminal.send("\x03") # Ctrl+C
|
||||
|
||||
def focus_terminal(self) -> None:
|
||||
"""Focus the terminal."""
|
||||
if self._terminal:
|
||||
self._terminal.focus()
|
||||
|
||||
@property
|
||||
def workdir(self) -> Path:
|
||||
"""Get the working directory."""
|
||||
return self._workdir
|
||||
|
||||
@workdir.setter
|
||||
def workdir(self, path: Path) -> None:
|
||||
"""Set the working directory (requires restart)."""
|
||||
self._workdir = path
|
||||
|
||||
def set_restart_on_exit(self, restart: bool) -> None:
|
||||
"""Set whether to restart Claude when it exits."""
|
||||
self._restart_on_exit = restart
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Context panel with Problems, TODOs, and Jira tabs."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Static, TabbedContent, TabPane
|
||||
|
||||
from clide.models.problems import Problem
|
||||
from clide.models.todos import TodoItem
|
||||
from clide.widgets.components.jira_view import JiraView
|
||||
from clide.widgets.components.problems_view import ProblemsView
|
||||
from clide.widgets.components.todos_view import TodosView
|
||||
|
||||
|
||||
class ContextPanel(Vertical):
|
||||
"""Right context panel with Problems, TODOs, and Jira integration."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
ContextPanel {
|
||||
width: 25%;
|
||||
min-width: 30;
|
||||
height: 100%;
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
ContextPanel TabbedContent {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
ContextPanel .context-tab-bar {
|
||||
dock: bottom;
|
||||
height: 1;
|
||||
background: $panel;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
ContextPanel .tab-count {
|
||||
margin: 0 1;
|
||||
}
|
||||
|
||||
ContextPanel .error-count {
|
||||
color: $error;
|
||||
}
|
||||
|
||||
ContextPanel .warning-count {
|
||||
color: $warning;
|
||||
}
|
||||
|
||||
ContextPanel .success-count {
|
||||
color: $success;
|
||||
}
|
||||
"""
|
||||
|
||||
class ProblemClicked(Message):
|
||||
"""Emitted when a problem is clicked."""
|
||||
|
||||
def __init__(self, problem: Problem) -> None:
|
||||
self.problem = problem
|
||||
super().__init__()
|
||||
|
||||
class TodoClicked(Message):
|
||||
"""Emitted when a TODO is clicked."""
|
||||
|
||||
def __init__(self, item: TodoItem) -> None:
|
||||
self.item = item
|
||||
super().__init__()
|
||||
|
||||
class JiraRefreshRequested(Message):
|
||||
"""Emitted when Jira refresh is requested."""
|
||||
pass
|
||||
|
||||
# Reactive state with counts for tab badges
|
||||
problem_count: reactive[int] = reactive(0)
|
||||
todo_count: reactive[int] = reactive(0)
|
||||
visible: reactive[bool] = reactive(True)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
jira_enabled: bool = True,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._jira_enabled = jira_enabled
|
||||
self.id = "panel-context"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with TabbedContent(id="context-tabs"):
|
||||
with TabPane("Problems", id="context-problems"):
|
||||
yield ProblemsView(id="problems-view")
|
||||
with TabPane("TODOs", id="context-todos"):
|
||||
yield TodosView(id="todos-view")
|
||||
with TabPane("Jira", id="context-jira"):
|
||||
yield JiraView(enabled=self._jira_enabled, id="jira-view")
|
||||
# Tab bar with counts at bottom
|
||||
with Horizontal(classes="context-tab-bar"):
|
||||
yield Static("", id="tab-counts")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Initialize tab counts."""
|
||||
self._update_tab_counts()
|
||||
|
||||
def watch_visible(self, visible: bool) -> None:
|
||||
"""Handle visibility changes."""
|
||||
self.display = visible
|
||||
|
||||
def watch_problem_count(self, count: int) -> None:
|
||||
"""Update problem count display."""
|
||||
self._update_tab_counts()
|
||||
|
||||
def watch_todo_count(self, count: int) -> None:
|
||||
"""Update todo count display."""
|
||||
self._update_tab_counts()
|
||||
|
||||
def _update_tab_counts(self) -> None:
|
||||
"""Update the tab counts display."""
|
||||
try:
|
||||
counts = self.query_one("#tab-counts", Static)
|
||||
problem_style = "error-count" if self.problem_count > 0 else "success-count"
|
||||
todo_style = "warning-count" if self.todo_count > 0 else "success-count"
|
||||
|
||||
# Build count display
|
||||
parts = []
|
||||
if self.problem_count > 0:
|
||||
parts.append(f"[{problem_style}]⚠ {self.problem_count}[/]")
|
||||
else:
|
||||
parts.append(f"[{problem_style}]✓ 0[/]")
|
||||
|
||||
parts.append(f"[{todo_style}]☐ {self.todo_count}[/]")
|
||||
|
||||
counts.update(" │ ".join(parts))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def update_problems(self, problems: list[Problem]) -> None:
|
||||
"""Update problems view and count."""
|
||||
self.problem_count = len(problems)
|
||||
try:
|
||||
view = self.query_one("#problems-view", ProblemsView)
|
||||
view.update_problems(problems)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def update_todos(self, items: list[TodoItem]) -> None:
|
||||
"""Update TODOs view and count."""
|
||||
self.todo_count = len(items)
|
||||
try:
|
||||
view = self.query_one("#todos-view", TodosView)
|
||||
view.update_items(items)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def update_jira(self, content: str) -> None:
|
||||
"""Update Jira view content."""
|
||||
try:
|
||||
view = self.query_one("#jira-view", JiraView)
|
||||
view.update_content(content)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def set_jira_loading(self) -> None:
|
||||
"""Set Jira view to loading state."""
|
||||
try:
|
||||
view = self.query_one("#jira-view", JiraView)
|
||||
view.set_loading()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def set_jira_error(self, error: str) -> None:
|
||||
"""Set Jira view to error state."""
|
||||
try:
|
||||
view = self.query_one("#jira-view", JiraView)
|
||||
view.set_error(error)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def focus_tab(self, tab_id: str) -> None:
|
||||
"""Focus a specific tab."""
|
||||
tabs = self.query_one("#context-tabs", TabbedContent)
|
||||
tabs.active = f"context-{tab_id}"
|
||||
|
||||
def focus_problems(self) -> None:
|
||||
"""Focus problems tab."""
|
||||
self.focus_tab("problems")
|
||||
|
||||
def focus_todos(self) -> None:
|
||||
"""Focus TODOs tab."""
|
||||
self.focus_tab("todos")
|
||||
|
||||
def focus_jira(self) -> None:
|
||||
"""Focus Jira tab."""
|
||||
self.focus_tab("jira")
|
||||
|
||||
# Event forwarding
|
||||
def on_problems_view_problem_clicked(
|
||||
self,
|
||||
event: ProblemsView.ProblemClicked,
|
||||
) -> None:
|
||||
"""Forward problem click."""
|
||||
self.post_message(self.ProblemClicked(event.problem))
|
||||
|
||||
def on_todos_view_todo_clicked(self, event: TodosView.TodoClicked) -> None:
|
||||
"""Forward todo click."""
|
||||
self.post_message(self.TodoClicked(event.item))
|
||||
|
||||
def on_jira_view_refresh_requested(
|
||||
self,
|
||||
event: JiraView.RefreshRequested,
|
||||
) -> None:
|
||||
"""Forward Jira refresh request."""
|
||||
self.post_message(self.JiraRefreshRequested())
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Sidebar panel with Files, Git, and Tree tabs."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Container, Vertical
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Static, TabbedContent, TabPane
|
||||
|
||||
from clide.widgets.components.branch_status import BranchStatus
|
||||
from clide.widgets.components.files_view import FilesView
|
||||
from clide.widgets.components.git_changes import GitChangesView
|
||||
from clide.widgets.components.git_graph import GitGraphView
|
||||
|
||||
|
||||
class SidebarPanel(Vertical):
|
||||
"""Left sidebar panel with file browser, git changes, and git graph."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
SidebarPanel {
|
||||
width: 20%;
|
||||
min-width: 25;
|
||||
height: 100%;
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
SidebarPanel TabbedContent {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
SidebarPanel .sidebar-content {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
SidebarPanel BranchStatus {
|
||||
dock: bottom;
|
||||
height: auto;
|
||||
}
|
||||
"""
|
||||
|
||||
class FileSelected(Message):
|
||||
"""Emitted when a file is selected from the file browser."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = path
|
||||
super().__init__()
|
||||
|
||||
class GitFileSelected(Message):
|
||||
"""Emitted when a file is selected from git changes."""
|
||||
|
||||
def __init__(self, path: Path, staged: bool) -> None:
|
||||
self.path = path
|
||||
self.staged = staged
|
||||
super().__init__()
|
||||
|
||||
class BranchChanged(Message):
|
||||
"""Emitted when the branch is changed."""
|
||||
|
||||
def __init__(self, branch: str) -> None:
|
||||
self.branch = branch
|
||||
super().__init__()
|
||||
|
||||
# Reactive state
|
||||
current_branch: reactive[str] = reactive("main")
|
||||
visible: reactive[bool] = reactive(True)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workdir: Path | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._workdir = workdir or Path.cwd()
|
||||
self.id = "panel-sidebar"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Container(classes="sidebar-content"):
|
||||
with TabbedContent(id="sidebar-tabs"):
|
||||
with TabPane("Files", id="sidebar-files"):
|
||||
yield FilesView(path=self._workdir)
|
||||
with TabPane("Git", id="sidebar-git"):
|
||||
yield GitChangesView()
|
||||
with TabPane("Tree", id="sidebar-tree"):
|
||||
yield GitGraphView()
|
||||
yield BranchStatus(current_branch=self.current_branch)
|
||||
|
||||
def watch_visible(self, visible: bool) -> None:
|
||||
"""Handle visibility changes."""
|
||||
self.display = visible
|
||||
|
||||
def watch_current_branch(self, branch: str) -> None:
|
||||
"""Update branch status when branch changes."""
|
||||
try:
|
||||
branch_status = self.query_one(BranchStatus)
|
||||
branch_status.branch = branch
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def update_git_status(
|
||||
self,
|
||||
staged: list,
|
||||
unstaged: list,
|
||||
) -> None:
|
||||
"""Update git changes view."""
|
||||
try:
|
||||
git_view = self.query_one(GitChangesView)
|
||||
git_view.update_changes(staged, unstaged)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def update_git_graph(self, commits: list) -> None:
|
||||
"""Update git graph view."""
|
||||
try:
|
||||
graph = self.query_one(GitGraphView)
|
||||
graph.update_graph(commits)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def update_branches(self, branches: list[str]) -> None:
|
||||
"""Update available branches."""
|
||||
try:
|
||||
branch_status = self.query_one(BranchStatus)
|
||||
branch_status.update_branches(branches)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def refresh_files(self) -> None:
|
||||
"""Refresh file browser."""
|
||||
try:
|
||||
files_view = self.query_one(FilesView)
|
||||
files_view.reload()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def focus_tab(self, tab_id: str) -> None:
|
||||
"""Focus a specific tab."""
|
||||
tabs = self.query_one("#sidebar-tabs", TabbedContent)
|
||||
tabs.active = tab_id
|
||||
|
||||
def on_files_view_file_selected(self, event: FilesView.FileSelected) -> None:
|
||||
"""Forward file selection."""
|
||||
self.post_message(self.FileSelected(event.path))
|
||||
|
||||
def on_git_changes_view_file_clicked(
|
||||
self,
|
||||
event: GitChangesView.FileClicked,
|
||||
) -> None:
|
||||
"""Forward git file selection."""
|
||||
self.post_message(self.GitFileSelected(event.path, event.staged))
|
||||
|
||||
def on_branch_status_branch_changed(
|
||||
self,
|
||||
event: BranchStatus.BranchChanged,
|
||||
) -> None:
|
||||
"""Forward branch change."""
|
||||
self.current_branch = event.branch
|
||||
self.post_message(self.BranchChanged(event.branch))
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Workspace panel with Editor, Diff, and Terminal tabs."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import TabbedContent, TabPane
|
||||
|
||||
from clide.models.diff import DiffContent
|
||||
from clide.widgets.components.diff_pane import DiffPane
|
||||
from clide.widgets.components.editor_pane import EditorPane
|
||||
from clide.widgets.components.terminal_pane import TerminalPane
|
||||
|
||||
|
||||
class WorkspacePanel(Vertical):
|
||||
"""Center workspace panel with Editor, Diff, and Terminal tabs.
|
||||
|
||||
Hidden by default. Shows when:
|
||||
- File is opened
|
||||
- Diff is displayed
|
||||
- Terminal is activated
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
WorkspacePanel {
|
||||
height: 60%;
|
||||
background: $background;
|
||||
}
|
||||
|
||||
WorkspacePanel.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
WorkspacePanel TabbedContent {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
WorkspacePanel TabPane {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
"""
|
||||
|
||||
class FileSaved(Message):
|
||||
"""Emitted when a file is saved."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = path
|
||||
super().__init__()
|
||||
|
||||
class DiffAccepted(Message):
|
||||
"""Emitted when a diff is accepted."""
|
||||
|
||||
def __init__(self, file_path: str) -> None:
|
||||
self.file_path = file_path
|
||||
super().__init__()
|
||||
|
||||
class DiffRejected(Message):
|
||||
"""Emitted when a diff is rejected."""
|
||||
|
||||
def __init__(self, file_path: str) -> None:
|
||||
self.file_path = file_path
|
||||
super().__init__()
|
||||
|
||||
class CommandSubmitted(Message):
|
||||
"""Emitted when a terminal command is submitted."""
|
||||
|
||||
def __init__(self, command: str) -> None:
|
||||
self.command = command
|
||||
super().__init__()
|
||||
|
||||
class CloseRequested(Message):
|
||||
"""Emitted when workspace should be hidden."""
|
||||
pass
|
||||
|
||||
# Reactive state - persisted when hidden
|
||||
visible: reactive[bool] = reactive(False)
|
||||
active_tab: reactive[str] = reactive("editor")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workdir: Path | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._workdir = workdir or Path.cwd()
|
||||
self.id = "panel-workspace"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with TabbedContent(id="workspace-tabs"):
|
||||
with TabPane("Editor", id="workspace-editor"):
|
||||
yield EditorPane(id="editor-pane")
|
||||
with TabPane("Diff", id="workspace-diff"):
|
||||
yield DiffPane(id="diff-pane")
|
||||
with TabPane("Terminal", id="workspace-terminal"):
|
||||
yield TerminalPane(cwd=self._workdir, id="terminal-pane")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Set initial visibility."""
|
||||
self._update_visibility()
|
||||
|
||||
def watch_visible(self, visible: bool) -> None:
|
||||
"""Handle visibility changes - hide, don't destroy."""
|
||||
self._update_visibility()
|
||||
|
||||
def _update_visibility(self) -> None:
|
||||
"""Update display based on visibility state."""
|
||||
if self.visible:
|
||||
self.remove_class("hidden")
|
||||
else:
|
||||
self.add_class("hidden")
|
||||
|
||||
def show(self, tab: str | None = None) -> None:
|
||||
"""Show workspace, optionally focusing a specific tab."""
|
||||
self.visible = True
|
||||
if tab:
|
||||
self.focus_tab(tab)
|
||||
|
||||
def hide(self) -> None:
|
||||
"""Hide workspace (state is preserved)."""
|
||||
self.visible = False
|
||||
|
||||
def toggle(self) -> None:
|
||||
"""Toggle workspace visibility."""
|
||||
self.visible = not self.visible
|
||||
|
||||
def focus_tab(self, tab_id: str) -> None:
|
||||
"""Focus a specific tab."""
|
||||
tabs = self.query_one("#workspace-tabs", TabbedContent)
|
||||
tabs.active = f"workspace-{tab_id}"
|
||||
self.active_tab = tab_id
|
||||
|
||||
# Editor methods
|
||||
def open_file(self, path: Path, line: int | None = None) -> None:
|
||||
"""Open a file in the editor tab."""
|
||||
self.show("editor")
|
||||
try:
|
||||
editor = self.query_one("#editor-pane", EditorPane)
|
||||
editor.load_file(path, goto_line=line)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_current_file(self) -> Path | None:
|
||||
"""Get the currently open file."""
|
||||
try:
|
||||
editor = self.query_one("#editor-pane", EditorPane)
|
||||
return editor.current_file
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def has_unsaved_changes(self) -> bool:
|
||||
"""Check if editor has unsaved changes."""
|
||||
try:
|
||||
editor = self.query_one("#editor-pane", EditorPane)
|
||||
return editor.modified
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# Diff methods
|
||||
def show_diff(
|
||||
self,
|
||||
diff: DiffContent,
|
||||
is_proposal: bool = False,
|
||||
) -> None:
|
||||
"""Show a diff in the diff tab."""
|
||||
self.show("diff")
|
||||
try:
|
||||
diff_pane = self.query_one("#diff-pane", DiffPane)
|
||||
diff_pane.load_diff(diff, is_proposal)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def clear_diff(self) -> None:
|
||||
"""Clear the diff view."""
|
||||
try:
|
||||
diff_pane = self.query_one("#diff-pane", DiffPane)
|
||||
diff_pane.clear()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Terminal methods
|
||||
def show_terminal(self) -> None:
|
||||
"""Show and focus the terminal tab."""
|
||||
self.show("terminal")
|
||||
try:
|
||||
terminal = self.query_one("#terminal-pane", TerminalPane)
|
||||
terminal_input = terminal.query_one("#terminal-input")
|
||||
terminal_input.focus()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def write_terminal_output(self, text: str, style: str = "output") -> None:
|
||||
"""Write output to terminal."""
|
||||
try:
|
||||
terminal = self.query_one("#terminal-pane", TerminalPane)
|
||||
terminal.write_output(text, style)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def write_terminal_error(self, error: str) -> None:
|
||||
"""Write error to terminal."""
|
||||
try:
|
||||
terminal = self.query_one("#terminal-pane", TerminalPane)
|
||||
terminal.write_error(error)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def clear_terminal(self) -> None:
|
||||
"""Clear terminal output."""
|
||||
try:
|
||||
terminal = self.query_one("#terminal-pane", TerminalPane)
|
||||
terminal.clear()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Event forwarding
|
||||
def on_editor_pane_file_saved(self, event: EditorPane.FileSaved) -> None:
|
||||
"""Forward file save event."""
|
||||
self.post_message(self.FileSaved(event.path))
|
||||
|
||||
def on_diff_pane_accept_clicked(self, event: DiffPane.AcceptClicked) -> None:
|
||||
"""Forward diff accept event."""
|
||||
self.post_message(self.DiffAccepted(event.file_path))
|
||||
|
||||
def on_diff_pane_reject_clicked(self, event: DiffPane.RejectClicked) -> None:
|
||||
"""Forward diff reject event."""
|
||||
self.post_message(self.DiffRejected(event.file_path))
|
||||
|
||||
def on_terminal_pane_command_submitted(
|
||||
self,
|
||||
event: TerminalPane.CommandSubmitted,
|
||||
) -> None:
|
||||
"""Forward terminal command event."""
|
||||
self.post_message(self.CommandSubmitted(event.command))
|
||||
@@ -0,0 +1 @@
|
||||
"""Clide test suite."""
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Shared pytest fixtures for Clide tests."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator, Generator
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from textual.pilot import Pilot
|
||||
|
||||
from clide.app import ClideApp
|
||||
from clide.extensions.manager import ExtensionManager
|
||||
from clide.models.config import ClideSettings
|
||||
from tests.harnesses.app_harness import AppHarness
|
||||
from tests.harnesses.controller_harness import ControllerHarness
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_workdir(tmp_path: Path) -> Path:
|
||||
"""Create a temporary working directory with sample files."""
|
||||
# Create sample directory structure
|
||||
(tmp_path / "src").mkdir()
|
||||
(tmp_path / "tests").mkdir()
|
||||
(tmp_path / "src" / "main.py").write_text("# Main file")
|
||||
(tmp_path / "README.md").write_text("# Test Project")
|
||||
|
||||
# Initialize git repo
|
||||
(tmp_path / ".git").mkdir()
|
||||
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_settings() -> ClideSettings:
|
||||
"""Create test settings with defaults."""
|
||||
return ClideSettings(
|
||||
theme="dark",
|
||||
claude_path="/usr/bin/echo", # Safe mock
|
||||
auto_save=False,
|
||||
confirm_exit=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_extension_manager() -> ExtensionManager:
|
||||
"""Create an extension manager without loading external extensions."""
|
||||
manager = ExtensionManager()
|
||||
# Don't load entry points in tests
|
||||
return manager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_harness(
|
||||
temp_workdir: Path,
|
||||
mock_settings: ClideSettings,
|
||||
mock_extension_manager: ExtensionManager,
|
||||
) -> Generator[AppHarness, None, None]:
|
||||
"""Create a full application test harness."""
|
||||
harness = AppHarness(
|
||||
workdir=temp_workdir,
|
||||
settings=mock_settings,
|
||||
extension_manager=mock_extension_manager,
|
||||
)
|
||||
yield harness
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def running_app(
|
||||
app_harness: AppHarness,
|
||||
) -> AsyncGenerator[tuple[ClideApp, Pilot], None]:
|
||||
"""Start the app and yield (app, pilot) for interaction."""
|
||||
app, pilot = await app_harness.start()
|
||||
try:
|
||||
yield app, pilot
|
||||
finally:
|
||||
await app_harness.stop()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def controller_harness() -> ControllerHarness:
|
||||
"""Create an isolated controller test harness."""
|
||||
return ControllerHarness()
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Test harnesses for Clide testing."""
|
||||
|
||||
from tests.harnesses.app_harness import AppHarness
|
||||
from tests.harnesses.controller_harness import ControllerHarness
|
||||
|
||||
__all__ = ["AppHarness", "ControllerHarness"]
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Full application test harness for Clide."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from textual.pilot import Pilot
|
||||
|
||||
from clide.app import ClideApp
|
||||
from clide.extensions.manager import ExtensionManager
|
||||
from clide.models.config import ClideSettings
|
||||
|
||||
|
||||
class AppHarness:
|
||||
"""Test harness for running the full Clide application.
|
||||
|
||||
Provides a controlled environment for integration testing with
|
||||
mocked services and isolated file systems.
|
||||
|
||||
Usage:
|
||||
harness = AppHarness(workdir=tmp_path)
|
||||
app, pilot = await harness.start()
|
||||
await pilot.press("ctrl+q")
|
||||
await harness.stop()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workdir: Path,
|
||||
settings: Optional[ClideSettings] = None,
|
||||
extension_manager: Optional[ExtensionManager] = None,
|
||||
) -> None:
|
||||
self.workdir = workdir
|
||||
self.settings = settings or ClideSettings()
|
||||
self.extension_manager = extension_manager or ExtensionManager()
|
||||
self._app: Optional[ClideApp] = None
|
||||
self._pilot: Optional[Pilot] = None
|
||||
|
||||
async def start(self) -> tuple[ClideApp, Pilot]:
|
||||
"""Start the application and return app and pilot for testing.
|
||||
|
||||
Returns:
|
||||
Tuple of (ClideApp instance, Pilot for simulating input)
|
||||
"""
|
||||
self._app = ClideApp(workdir=self.workdir)
|
||||
# Inject test dependencies
|
||||
self._app.extension_manager = self.extension_manager
|
||||
|
||||
# Start app in test mode
|
||||
async with self._app.run_test() as pilot:
|
||||
self._pilot = pilot
|
||||
return self._app, pilot
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Clean shutdown of the application."""
|
||||
if self._app:
|
||||
await self._app.action_quit()
|
||||
self._app = None
|
||||
self._pilot = None
|
||||
|
||||
@property
|
||||
def app(self) -> ClideApp:
|
||||
"""Get the running app instance."""
|
||||
if self._app is None:
|
||||
raise RuntimeError("App not started. Call start() first.")
|
||||
return self._app
|
||||
|
||||
@property
|
||||
def pilot(self) -> Pilot:
|
||||
"""Get the pilot for simulating user input."""
|
||||
if self._pilot is None:
|
||||
raise RuntimeError("App not started. Call start() first.")
|
||||
return self._pilot
|
||||
|
||||
async def press_keys(self, *keys: str) -> None:
|
||||
"""Simulate pressing a sequence of keys."""
|
||||
await self.pilot.press(*keys)
|
||||
|
||||
async def click(self, selector: str) -> None:
|
||||
"""Click on a widget by CSS selector."""
|
||||
await self.pilot.click(selector)
|
||||
|
||||
async def wait_for_animation(self) -> None:
|
||||
"""Wait for any running animations to complete."""
|
||||
await self.pilot.pause()
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Controller isolation test harness for Clide."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from textual.message import Message
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from clide.controllers.base import BaseController
|
||||
|
||||
|
||||
class MockApp:
|
||||
"""Minimal mock of a Textual App for controller testing."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.messages: list[Message] = []
|
||||
self.post_message = MagicMock(side_effect=self._capture_message)
|
||||
|
||||
def _capture_message(self, message: Message) -> None:
|
||||
self.messages.append(message)
|
||||
|
||||
def get_messages(self, message_type: Optional[type] = None) -> list[Message]:
|
||||
"""Get captured messages, optionally filtered by type."""
|
||||
if message_type is None:
|
||||
return self.messages.copy()
|
||||
return [m for m in self.messages if isinstance(m, message_type)]
|
||||
|
||||
def clear_messages(self) -> None:
|
||||
"""Clear captured messages."""
|
||||
self.messages.clear()
|
||||
|
||||
|
||||
class ControllerHarness:
|
||||
"""Test harness for isolated controller testing.
|
||||
|
||||
Provides a mock app environment for testing controllers without
|
||||
the full Textual application overhead.
|
||||
|
||||
Usage:
|
||||
harness = ControllerHarness()
|
||||
controller = GitController(harness.mock_app)
|
||||
await controller.initialize()
|
||||
await controller.refresh_status()
|
||||
messages = harness.get_messages()
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._mock_app = MockApp()
|
||||
self._controllers: list["BaseController"] = []
|
||||
self._mocks: dict[str, Any] = {}
|
||||
|
||||
@property
|
||||
def mock_app(self) -> MockApp:
|
||||
"""Get the mock app for controller injection."""
|
||||
return self._mock_app
|
||||
|
||||
def register_controller(self, controller: "BaseController") -> None:
|
||||
"""Register a controller for lifecycle management."""
|
||||
self._controllers.append(controller)
|
||||
|
||||
async def initialize_all(self) -> None:
|
||||
"""Initialize all registered controllers."""
|
||||
for controller in self._controllers:
|
||||
await controller.initialize()
|
||||
|
||||
async def shutdown_all(self) -> None:
|
||||
"""Shutdown all registered controllers."""
|
||||
for controller in self._controllers:
|
||||
await controller.shutdown()
|
||||
|
||||
def get_messages(self, message_type: Optional[type] = None) -> list[Message]:
|
||||
"""Get messages posted to the mock app."""
|
||||
return self._mock_app.get_messages(message_type)
|
||||
|
||||
def clear_messages(self) -> None:
|
||||
"""Clear all captured messages."""
|
||||
self._mock_app.clear_messages()
|
||||
|
||||
def add_mock(self, name: str, mock: Any) -> None:
|
||||
"""Add a named mock for dependency injection.
|
||||
|
||||
Args:
|
||||
name: Identifier for the mock
|
||||
mock: Mock object or AsyncMock
|
||||
"""
|
||||
self._mocks[name] = mock
|
||||
|
||||
def get_mock(self, name: str) -> Any:
|
||||
"""Retrieve a named mock."""
|
||||
return self._mocks.get(name)
|
||||
|
||||
def create_async_mock(self, return_value: Any = None) -> AsyncMock:
|
||||
"""Create an AsyncMock with optional return value."""
|
||||
mock = AsyncMock()
|
||||
if return_value is not None:
|
||||
mock.return_value = return_value
|
||||
return mock
|
||||
@@ -0,0 +1 @@
|
||||
"""Integration tests for Clide."""
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Integration tests for FilesView widget."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Static
|
||||
|
||||
from clide.widgets.components.files_view import FilesView
|
||||
|
||||
|
||||
class FilesViewTestApp(App):
|
||||
"""Test app for FilesView."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
super().__init__()
|
||||
self.test_path = path
|
||||
self.selected_files: list[Path] = []
|
||||
self.selected_dirs: list[Path] = []
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield FilesView(self.test_path, id="files")
|
||||
|
||||
def on_files_view_file_selected(self, event: FilesView.FileSelected) -> None:
|
||||
self.selected_files.append(event.path)
|
||||
|
||||
def on_files_view_directory_selected(self, event: FilesView.DirectorySelected) -> None:
|
||||
self.selected_dirs.append(event.path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_directory(tmp_path: Path) -> Path:
|
||||
"""Create a test directory structure."""
|
||||
# Create directories
|
||||
(tmp_path / "src").mkdir()
|
||||
(tmp_path / "src" / "components").mkdir()
|
||||
(tmp_path / "tests").mkdir()
|
||||
|
||||
# Create files
|
||||
(tmp_path / "README.md").write_text("# Test")
|
||||
(tmp_path / "src" / "main.py").write_text("print('hello')")
|
||||
(tmp_path / "src" / "components" / "button.py").write_text("class Button: pass")
|
||||
(tmp_path / "tests" / "test_main.py").write_text("def test_main(): pass")
|
||||
|
||||
return tmp_path
|
||||
|
||||
|
||||
async def test_files_view_renders(test_directory: Path):
|
||||
"""Test that FilesView renders without errors."""
|
||||
app = FilesViewTestApp(test_directory)
|
||||
async with app.run_test() as pilot:
|
||||
files_view = app.query_one("#files", FilesView)
|
||||
assert files_view is not None
|
||||
assert files_view.path == test_directory
|
||||
|
||||
|
||||
async def test_files_view_shows_files(test_directory: Path):
|
||||
"""Test that FilesView shows files in the directory."""
|
||||
app = FilesViewTestApp(test_directory)
|
||||
async with app.run_test() as pilot:
|
||||
files_view = app.query_one("#files", FilesView)
|
||||
# The root should be loaded
|
||||
assert files_view.root is not None
|
||||
|
||||
|
||||
async def test_directory_click_expands(test_directory: Path):
|
||||
"""Test that clicking a directory expands it and emits event."""
|
||||
app = FilesViewTestApp(test_directory)
|
||||
async with app.run_test() as pilot:
|
||||
files_view = app.query_one("#files", FilesView)
|
||||
|
||||
# Wait for initial load
|
||||
await pilot.pause()
|
||||
|
||||
# Find the src directory node and click it
|
||||
for node in files_view.root.children:
|
||||
if node.data and node.data.path.name == "src":
|
||||
files_view.select_node(node)
|
||||
await pilot.pause()
|
||||
break
|
||||
|
||||
# Check that directory was selected
|
||||
assert len(app.selected_dirs) >= 1
|
||||
assert any(p.name == "src" for p in app.selected_dirs)
|
||||
|
||||
|
||||
async def test_file_click_emits_event(test_directory: Path):
|
||||
"""Test that clicking a file emits FileSelected event."""
|
||||
app = FilesViewTestApp(test_directory)
|
||||
async with app.run_test() as pilot:
|
||||
files_view = app.query_one("#files", FilesView)
|
||||
|
||||
# Wait for initial load
|
||||
await pilot.pause()
|
||||
|
||||
# Find and click README.md
|
||||
for node in files_view.root.children:
|
||||
if node.data and node.data.path.name == "README.md":
|
||||
files_view.select_node(node)
|
||||
await pilot.pause()
|
||||
break
|
||||
|
||||
# Check that file was selected
|
||||
assert len(app.selected_files) >= 1
|
||||
assert any(p.name == "README.md" for p in app.selected_files)
|
||||
|
||||
|
||||
async def test_filter_paths_hides_hidden_files(test_directory: Path):
|
||||
"""Test that hidden files are filtered out."""
|
||||
# Create hidden files/dirs
|
||||
(test_directory / ".git").mkdir()
|
||||
(test_directory / ".hidden_file").write_text("hidden")
|
||||
(test_directory / "__pycache__").mkdir()
|
||||
|
||||
app = FilesViewTestApp(test_directory)
|
||||
async with app.run_test() as pilot:
|
||||
files_view = app.query_one("#files", FilesView)
|
||||
|
||||
# Wait for initial load
|
||||
await pilot.pause()
|
||||
|
||||
# Check that hidden items are not in the tree
|
||||
visible_names = {
|
||||
node.data.path.name
|
||||
for node in files_view.root.children
|
||||
if node.data
|
||||
}
|
||||
|
||||
assert ".git" not in visible_names
|
||||
assert ".hidden_file" not in visible_names
|
||||
assert "__pycache__" not in visible_names
|
||||
assert "src" in visible_names
|
||||
assert "README.md" in visible_names
|
||||
|
||||
|
||||
async def test_render_label_shows_icons(test_directory: Path):
|
||||
"""Test that render_label produces proper icons."""
|
||||
app = FilesViewTestApp(test_directory)
|
||||
async with app.run_test() as pilot:
|
||||
files_view = app.query_one("#files", FilesView)
|
||||
|
||||
# Wait for initial load
|
||||
await pilot.pause()
|
||||
|
||||
# Check that nodes have labels rendered (icons are part of label)
|
||||
for node in files_view.root.children:
|
||||
if node.data:
|
||||
# The label should contain the filename
|
||||
label_text = str(node.label)
|
||||
assert node.data.path.name in label_text
|
||||
@@ -0,0 +1 @@
|
||||
"""Snapshot (visual regression) tests for Clide."""
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 34 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 34 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,37 @@
|
||||
"""Snapshot tests for Clide UI."""
|
||||
|
||||
from clide.app import ClideApp
|
||||
|
||||
|
||||
def test_initial_layout(snap_compare) -> None:
|
||||
"""Test the initial application layout renders correctly."""
|
||||
app = ClideApp(test_mode=True)
|
||||
assert snap_compare(app, terminal_size=(120, 40))
|
||||
|
||||
|
||||
def test_layout_without_right_panel(snap_compare) -> None:
|
||||
"""Test layout with right panel hidden."""
|
||||
|
||||
async def hide_right_panel(pilot):
|
||||
await pilot.press("f2")
|
||||
|
||||
app = ClideApp(test_mode=True)
|
||||
assert snap_compare(
|
||||
app,
|
||||
terminal_size=(120, 40),
|
||||
run_before=hide_right_panel,
|
||||
)
|
||||
|
||||
|
||||
def test_layout_without_left_panel(snap_compare) -> None:
|
||||
"""Test layout with left panel hidden."""
|
||||
|
||||
async def hide_left_panel(pilot):
|
||||
await pilot.press("f1")
|
||||
|
||||
app = ClideApp(test_mode=True)
|
||||
assert snap_compare(
|
||||
app,
|
||||
terminal_size=(120, 40),
|
||||
run_before=hide_left_panel,
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Unit tests for Clide."""
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Tests for ClideApp."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from clide.models.config import ClideSettings
|
||||
|
||||
|
||||
class TestClideAppInit:
|
||||
"""Tests for ClideApp initialization."""
|
||||
|
||||
def test_default_initialization(self):
|
||||
"""Test default initialization."""
|
||||
from clide.app import ClideApp
|
||||
|
||||
app = ClideApp()
|
||||
assert app.workdir == Path.cwd()
|
||||
assert isinstance(app.settings, ClideSettings)
|
||||
# Note: reactive properties can't be tested directly without running the app
|
||||
# because watchers try to query the DOM
|
||||
|
||||
def test_initialization_with_workdir(self, tmp_path: Path):
|
||||
"""Test initialization with custom workdir."""
|
||||
from clide.app import ClideApp
|
||||
|
||||
app = ClideApp(workdir=tmp_path)
|
||||
assert app.workdir == tmp_path
|
||||
|
||||
def test_initialization_with_settings(self):
|
||||
"""Test initialization with custom settings."""
|
||||
from clide.app import ClideApp
|
||||
|
||||
settings = ClideSettings(theme="dracula", jira_enabled=True)
|
||||
app = ClideApp(settings=settings)
|
||||
assert app.settings.theme == "dracula"
|
||||
assert app.settings.jira_enabled is True
|
||||
|
||||
def test_controllers_initialized(self, tmp_path: Path):
|
||||
"""Test that all controllers are initialized."""
|
||||
from clide.app import ClideApp
|
||||
from clide.controllers.diff import DiffController
|
||||
from clide.controllers.editor import EditorController
|
||||
from clide.controllers.git import GitController
|
||||
from clide.controllers.jira import JiraController
|
||||
from clide.controllers.problems import ProblemsController
|
||||
from clide.controllers.todos import TodosController
|
||||
|
||||
app = ClideApp(workdir=tmp_path)
|
||||
assert isinstance(app.git_controller, GitController)
|
||||
assert isinstance(app.editor_controller, EditorController)
|
||||
assert isinstance(app.diff_controller, DiffController)
|
||||
assert isinstance(app.problems_controller, ProblemsController)
|
||||
assert isinstance(app.todos_controller, TodosController)
|
||||
assert isinstance(app.jira_controller, JiraController)
|
||||
|
||||
def test_jira_controller_enabled_from_settings(self):
|
||||
"""Test Jira controller uses settings."""
|
||||
from clide.app import ClideApp
|
||||
|
||||
settings = ClideSettings(jira_enabled=True)
|
||||
app = ClideApp(settings=settings)
|
||||
assert app.jira_controller.enabled is True
|
||||
|
||||
settings_disabled = ClideSettings(jira_enabled=False)
|
||||
app_disabled = ClideApp(settings=settings_disabled)
|
||||
assert app_disabled.jira_controller.enabled is False
|
||||
|
||||
|
||||
class TestClideAppBindings:
|
||||
"""Tests for ClideApp keybindings."""
|
||||
|
||||
def test_bindings_defined(self):
|
||||
"""Test that keybindings are defined."""
|
||||
from clide.app import ClideApp
|
||||
|
||||
app = ClideApp()
|
||||
bindings = {b.key for b in app.BINDINGS}
|
||||
|
||||
# Check key bindings exist
|
||||
assert "ctrl+q" in bindings
|
||||
assert "ctrl+b" in bindings
|
||||
assert "ctrl+shift+p" in bindings
|
||||
assert "ctrl+`" in bindings
|
||||
assert "ctrl+1" in bindings
|
||||
assert "f11" in bindings
|
||||
assert "escape" in bindings
|
||||
|
||||
|
||||
class TestClideAppMeta:
|
||||
"""Tests for ClideApp metadata."""
|
||||
|
||||
def test_title(self):
|
||||
"""Test app title."""
|
||||
from clide.app import ClideApp
|
||||
|
||||
app = ClideApp()
|
||||
assert app.TITLE == "Clide"
|
||||
assert app.SUB_TITLE == "Claude Code IDE"
|
||||
|
||||
def test_css_defined(self):
|
||||
"""Test CSS is defined."""
|
||||
from clide.app import ClideApp
|
||||
|
||||
assert ClideApp.CSS
|
||||
assert "#main-container" in ClideApp.CSS
|
||||
assert "SidebarPanel" in ClideApp.CSS
|
||||
assert "ContextPanel" in ClideApp.CSS
|
||||
assert "WorkspacePanel" in ClideApp.CSS
|
||||
assert "ClaudePanel" in ClideApp.CSS
|
||||
|
||||
|
||||
class TestClideAppReactive:
|
||||
"""Tests for ClideApp reactive properties.
|
||||
|
||||
Note: Most reactive property tests require running the app
|
||||
because accessing them triggers watchers that query the DOM.
|
||||
These tests verify the property definitions exist.
|
||||
"""
|
||||
|
||||
def test_reactive_properties_defined(self):
|
||||
"""Test that reactive properties are defined."""
|
||||
from clide.app import ClideApp
|
||||
|
||||
# Check the reactive descriptors exist on the class
|
||||
assert hasattr(ClideApp, "workspace_visible")
|
||||
assert hasattr(ClideApp, "compact_mode")
|
||||
assert hasattr(ClideApp, "fullscreen_panel")
|
||||
assert hasattr(ClideApp, "current_file")
|
||||
|
||||
|
||||
class TestClideAppThemes:
|
||||
"""Tests for ClideApp theme registration."""
|
||||
|
||||
def test_themes_registered(self):
|
||||
"""Test that themes are registered."""
|
||||
from clide.app import ClideApp
|
||||
|
||||
app = ClideApp()
|
||||
# Default theme should be set
|
||||
assert app.theme == "summer-night"
|
||||
|
||||
def test_custom_theme_from_settings(self):
|
||||
"""Test that custom theme from settings is applied."""
|
||||
from clide.app import ClideApp
|
||||
|
||||
settings = ClideSettings(theme="dracula")
|
||||
app = ClideApp(settings=settings)
|
||||
assert app.theme == "dracula"
|
||||
|
||||
|
||||
class TestClideAppActions:
|
||||
"""Tests for ClideApp action methods.
|
||||
|
||||
Note: Action methods that modify reactive properties or
|
||||
query the DOM cannot be fully tested without running the app.
|
||||
"""
|
||||
|
||||
def test_action_methods_exist(self):
|
||||
"""Test that action methods are defined."""
|
||||
from clide.app import ClideApp
|
||||
|
||||
app = ClideApp()
|
||||
# Verify action methods exist
|
||||
assert hasattr(app, "action_toggle_compact")
|
||||
assert hasattr(app, "action_toggle_sidebar")
|
||||
assert hasattr(app, "action_toggle_context")
|
||||
assert hasattr(app, "action_toggle_terminal")
|
||||
assert hasattr(app, "action_focus_claude")
|
||||
assert callable(app.action_toggle_compact)
|
||||
|
||||
|
||||
class TestClideSettings:
|
||||
"""Tests for ClideSettings."""
|
||||
|
||||
def test_default_settings(self):
|
||||
"""Test default settings values."""
|
||||
settings = ClideSettings()
|
||||
assert settings.theme == "summer-night"
|
||||
assert settings.jira_enabled is False
|
||||
|
||||
def test_custom_settings(self):
|
||||
"""Test custom settings values."""
|
||||
settings = ClideSettings(theme="nord", jira_enabled=True)
|
||||
assert settings.theme == "nord"
|
||||
assert settings.jira_enabled is True
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Tests for configuration models."""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from clide.models.config import ClideSettings, KeybindingsConfig, PanelConfig
|
||||
|
||||
|
||||
class TestClideSettings:
|
||||
"""Tests for ClideSettings model."""
|
||||
|
||||
def test_default_settings(self) -> None:
|
||||
"""Default settings should be valid."""
|
||||
settings = ClideSettings()
|
||||
assert settings.theme == "summer-night"
|
||||
assert settings.auto_save is True
|
||||
assert settings.confirm_exit is True
|
||||
assert settings.jira_enabled is False
|
||||
|
||||
def test_custom_settings(self) -> None:
|
||||
"""Custom settings should be applied."""
|
||||
settings = ClideSettings(
|
||||
theme="dracula",
|
||||
claude_path="/custom/path/claude",
|
||||
auto_save=False,
|
||||
jira_enabled=True,
|
||||
)
|
||||
assert settings.theme == "dracula"
|
||||
assert settings.claude_path == "/custom/path/claude"
|
||||
assert settings.auto_save is False
|
||||
assert settings.jira_enabled is True
|
||||
|
||||
|
||||
class TestPanelConfig:
|
||||
"""Tests for PanelConfig model."""
|
||||
|
||||
def test_default_values(self) -> None:
|
||||
"""Default values should be set correctly."""
|
||||
config = PanelConfig()
|
||||
assert config.sidebar_visible is True
|
||||
assert config.context_visible is True
|
||||
assert config.workspace_visible is False
|
||||
assert config.sidebar_width_percent == 20
|
||||
assert config.context_width_percent == 25
|
||||
|
||||
def test_frozen_model(self) -> None:
|
||||
"""PanelConfig should be immutable."""
|
||||
config = PanelConfig()
|
||||
with pytest.raises(ValidationError):
|
||||
config.sidebar_visible = False # type: ignore
|
||||
|
||||
def test_strict_mode(self) -> None:
|
||||
"""PanelConfig should enforce strict types."""
|
||||
with pytest.raises(ValidationError):
|
||||
PanelConfig(sidebar_width_percent="30") # type: ignore
|
||||
|
||||
|
||||
class TestKeybindingsConfig:
|
||||
"""Tests for KeybindingsConfig model."""
|
||||
|
||||
def test_default_keybindings(self) -> None:
|
||||
"""Default keybindings should be valid."""
|
||||
config = KeybindingsConfig()
|
||||
assert config.toggle_sidebar == "ctrl+b"
|
||||
assert config.toggle_terminal == "ctrl+`"
|
||||
assert config.focus_claude == "ctrl+1"
|
||||
|
||||
def test_custom_keybindings(self) -> None:
|
||||
"""Custom keybindings should be applied."""
|
||||
config = KeybindingsConfig(
|
||||
toggle_sidebar="ctrl+shift+s",
|
||||
save="cmd+s",
|
||||
)
|
||||
assert config.toggle_sidebar == "ctrl+shift+s"
|
||||
assert config.save == "cmd+s"
|
||||
@@ -0,0 +1,419 @@
|
||||
"""Tests for controller classes."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from clide.controllers.diff import DiffController
|
||||
from clide.controllers.editor import EditorController
|
||||
from clide.controllers.git import GitController
|
||||
from clide.controllers.jira import JiraController
|
||||
from clide.controllers.problems import ProblemsController
|
||||
from clide.controllers.todos import TodosController
|
||||
from clide.models.diff import ChangeType, DiffContent, DiffHunk, DiffLine
|
||||
from clide.models.editor import CursorPosition, FileBuffer
|
||||
from clide.models.git import ChangeStatus, GitBranch, GitChange, GitCommit, GitStatus
|
||||
from clide.models.problems import Problem, ProblemsSummary, Severity
|
||||
from clide.models.todos import TodoItem, TodosSummary, TodoType
|
||||
from clide.services.git_service import GitService
|
||||
from clide.services.process_service import CommandResult
|
||||
|
||||
|
||||
class TestGitController:
|
||||
"""Tests for GitController."""
|
||||
|
||||
@pytest.fixture
|
||||
def controller(self, tmp_path: Path) -> GitController:
|
||||
return GitController(tmp_path)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_status(self, controller: GitController):
|
||||
mock_status = GitStatus(
|
||||
branch="main",
|
||||
staged=(GitChange(path="a.py", status=ChangeStatus.ADDED, staged=True),),
|
||||
unstaged=(),
|
||||
)
|
||||
with patch.object(controller._service, "get_status", return_value=mock_status):
|
||||
status = await controller.get_status()
|
||||
assert status.branch == "main"
|
||||
assert len(status.staged) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_branches(self, controller: GitController):
|
||||
mock_branches = [
|
||||
GitBranch(name="main", is_current=True, is_remote=False),
|
||||
GitBranch(name="develop", is_current=False, is_remote=False),
|
||||
]
|
||||
with patch.object(controller._service, "get_branches", return_value=mock_branches):
|
||||
branches = await controller.get_branches()
|
||||
assert len(branches) == 2
|
||||
assert branches[0].is_current is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_log(self, controller: GitController):
|
||||
mock_commits = [
|
||||
GitCommit(
|
||||
hash="abc123def456",
|
||||
short_hash="abc123",
|
||||
message="Initial commit",
|
||||
author="Test",
|
||||
date="2024-01-01",
|
||||
)
|
||||
]
|
||||
with patch.object(controller._service, "get_log", return_value=mock_commits):
|
||||
commits = await controller.get_log(limit=10)
|
||||
assert len(commits) == 1
|
||||
assert commits[0].message == "Initial commit"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stage_file(self, controller: GitController):
|
||||
mock_status = GitStatus(branch="main", staged=(), unstaged=())
|
||||
with patch.object(controller._service, "stage_file", return_value=True):
|
||||
with patch.object(controller._service, "get_status", return_value=mock_status):
|
||||
result = await controller.stage_file("test.py")
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unstage_file(self, controller: GitController):
|
||||
mock_status = GitStatus(branch="main", staged=(), unstaged=())
|
||||
with patch.object(controller._service, "unstage_file", return_value=True):
|
||||
with patch.object(controller._service, "get_status", return_value=mock_status):
|
||||
result = await controller.unstage_file("test.py")
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_checkout_branch(self, controller: GitController):
|
||||
mock_status = GitStatus(branch="develop", staged=(), unstaged=())
|
||||
with patch.object(controller._service, "checkout_branch", return_value=True):
|
||||
with patch.object(controller._service, "get_status", return_value=mock_status):
|
||||
with patch.object(controller._service, "get_branches", return_value=[]):
|
||||
result = await controller.checkout_branch("develop")
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_current_branch_property(self, controller: GitController):
|
||||
mock_status = GitStatus(branch="feature", staged=(), unstaged=())
|
||||
with patch.object(controller._service, "get_status", return_value=mock_status):
|
||||
await controller.get_status() # Populate _status
|
||||
assert controller.current_branch == "feature"
|
||||
|
||||
def test_current_branch_unknown(self, controller: GitController):
|
||||
assert controller.current_branch == "unknown"
|
||||
|
||||
|
||||
class TestEditorController:
|
||||
"""Tests for EditorController."""
|
||||
|
||||
@pytest.fixture
|
||||
def controller(self) -> EditorController:
|
||||
return EditorController()
|
||||
|
||||
def test_initial_state(self, controller: EditorController):
|
||||
assert controller.active_buffer is None
|
||||
assert controller.open_files == []
|
||||
assert controller.has_unsaved_changes is False
|
||||
|
||||
def test_update_content(self, controller: EditorController):
|
||||
buffer = FileBuffer(path=Path("/test.py"), content="original")
|
||||
controller._state.buffers.append(buffer)
|
||||
|
||||
controller.update_content(Path("/test.py"), "modified")
|
||||
assert buffer.content == "modified"
|
||||
assert buffer.is_modified is True
|
||||
|
||||
def test_update_cursor(self, controller: EditorController):
|
||||
buffer = FileBuffer(path=Path("/test.py"), content="test")
|
||||
controller._state.buffers.append(buffer)
|
||||
|
||||
controller.update_cursor(Path("/test.py"), 5, 10)
|
||||
assert buffer.cursor.line == 5
|
||||
assert buffer.cursor.column == 10
|
||||
|
||||
def test_set_active_by_index(self, controller: EditorController):
|
||||
buffer1 = FileBuffer(path=Path("/a.py"), content="a")
|
||||
buffer2 = FileBuffer(path=Path("/b.py"), content="b")
|
||||
controller._state.buffers = [buffer1, buffer2]
|
||||
|
||||
controller.set_active_by_index(1)
|
||||
assert controller._state.active_buffer_index == 1
|
||||
|
||||
def test_set_active_invalid_index(self, controller: EditorController):
|
||||
controller.set_active_by_index(10) # Should not crash
|
||||
assert controller._state.active_buffer_index is None
|
||||
|
||||
|
||||
class TestDiffController:
|
||||
"""Tests for DiffController."""
|
||||
|
||||
@pytest.fixture
|
||||
def controller(self, tmp_path: Path) -> DiffController:
|
||||
return DiffController(tmp_path)
|
||||
|
||||
def test_initial_state(self, controller: DiffController):
|
||||
assert controller.diff is None
|
||||
assert controller.is_proposal is False
|
||||
|
||||
def test_load_proposal(self, controller: DiffController):
|
||||
old_content = "line1\nline2\n"
|
||||
new_content = "line1\nmodified\n"
|
||||
|
||||
diff = controller.load_proposal("test.py", old_content, new_content)
|
||||
assert diff.file_path == "test.py"
|
||||
assert controller.is_proposal is True
|
||||
|
||||
def test_accept_hunk(self, controller: DiffController):
|
||||
controller._state.diff = DiffContent(file_path="t.py", hunks=())
|
||||
controller.accept_hunk(0)
|
||||
assert 0 in controller._state.accepted_hunks
|
||||
assert 0 not in controller._state.rejected_hunks
|
||||
|
||||
def test_reject_hunk(self, controller: DiffController):
|
||||
controller._state.diff = DiffContent(file_path="t.py", hunks=())
|
||||
controller.reject_hunk(0)
|
||||
assert 0 in controller._state.rejected_hunks
|
||||
assert 0 not in controller._state.accepted_hunks
|
||||
|
||||
def test_accept_all(self, controller: DiffController):
|
||||
hunk = DiffHunk(
|
||||
header="@@",
|
||||
old_start=1,
|
||||
old_count=1,
|
||||
new_start=1,
|
||||
new_count=1,
|
||||
lines=(),
|
||||
)
|
||||
controller._state.diff = DiffContent(file_path="t.py", hunks=(hunk, hunk))
|
||||
controller.accept_all()
|
||||
assert len(controller._state.accepted_hunks) == 2
|
||||
|
||||
def test_reject_all(self, controller: DiffController):
|
||||
hunk = DiffHunk(
|
||||
header="@@",
|
||||
old_start=1,
|
||||
old_count=1,
|
||||
new_start=1,
|
||||
new_count=1,
|
||||
lines=(),
|
||||
)
|
||||
controller._state.diff = DiffContent(file_path="t.py", hunks=(hunk,))
|
||||
controller.reject_all()
|
||||
assert len(controller._state.rejected_hunks) == 1
|
||||
|
||||
def test_clear(self, controller: DiffController):
|
||||
controller._state.diff = DiffContent(file_path="t.py", hunks=())
|
||||
controller._state.is_proposal = True
|
||||
controller.clear()
|
||||
assert controller.diff is None
|
||||
assert controller.is_proposal is False
|
||||
|
||||
def test_toggle_side_by_side(self, controller: DiffController):
|
||||
assert controller._state.side_by_side is True # Default is True
|
||||
result = controller.toggle_side_by_side()
|
||||
assert result is False # Toggled to False
|
||||
assert controller._state.side_by_side is False
|
||||
|
||||
|
||||
class TestProblemsController:
|
||||
"""Tests for ProblemsController."""
|
||||
|
||||
@pytest.fixture
|
||||
def controller(self, tmp_path: Path) -> ProblemsController:
|
||||
return ProblemsController(tmp_path)
|
||||
|
||||
def test_initial_state(self, controller: ProblemsController):
|
||||
assert controller.problems == []
|
||||
assert controller.error_count == 0
|
||||
assert controller.warning_count == 0
|
||||
|
||||
def test_filter_by_severity(self, controller: ProblemsController):
|
||||
problems = [
|
||||
Problem(
|
||||
file_path=Path("/a.py"),
|
||||
line=1,
|
||||
column=1,
|
||||
severity=Severity.ERROR,
|
||||
message="err",
|
||||
source="test",
|
||||
),
|
||||
Problem(
|
||||
file_path=Path("/b.py"),
|
||||
line=1,
|
||||
column=1,
|
||||
severity=Severity.WARNING,
|
||||
message="warn",
|
||||
source="test",
|
||||
),
|
||||
]
|
||||
controller._state.problems = problems
|
||||
|
||||
errors = controller.filter_by_severity(Severity.ERROR)
|
||||
assert len(errors) == 1
|
||||
assert errors[0].severity == Severity.ERROR
|
||||
|
||||
def test_filter_by_source(self, controller: ProblemsController):
|
||||
problems = [
|
||||
Problem(
|
||||
file_path=Path("/a.py"),
|
||||
line=1,
|
||||
column=1,
|
||||
severity=Severity.ERROR,
|
||||
message="err",
|
||||
source="ruff",
|
||||
),
|
||||
Problem(
|
||||
file_path=Path("/b.py"),
|
||||
line=1,
|
||||
column=1,
|
||||
severity=Severity.ERROR,
|
||||
message="err",
|
||||
source="mypy",
|
||||
),
|
||||
]
|
||||
controller._state.problems = problems
|
||||
|
||||
ruff_problems = controller.filter_by_source("ruff")
|
||||
assert len(ruff_problems) == 1
|
||||
|
||||
def test_next_problem(self, controller: ProblemsController):
|
||||
problems = [
|
||||
Problem(
|
||||
file_path=Path("/a.py"),
|
||||
line=1,
|
||||
column=1,
|
||||
severity=Severity.ERROR,
|
||||
message="1",
|
||||
source="test",
|
||||
),
|
||||
Problem(
|
||||
file_path=Path("/b.py"),
|
||||
line=2,
|
||||
column=1,
|
||||
severity=Severity.ERROR,
|
||||
message="2",
|
||||
source="test",
|
||||
),
|
||||
]
|
||||
controller._state.problems = problems
|
||||
|
||||
p1 = controller.next_problem()
|
||||
assert p1.message == "1"
|
||||
p2 = controller.next_problem()
|
||||
assert p2.message == "2"
|
||||
# Should wrap around
|
||||
p3 = controller.next_problem()
|
||||
assert p3.message == "1"
|
||||
|
||||
def test_prev_problem(self, controller: ProblemsController):
|
||||
problems = [
|
||||
Problem(
|
||||
file_path=Path("/a.py"),
|
||||
line=1,
|
||||
column=1,
|
||||
severity=Severity.ERROR,
|
||||
message="1",
|
||||
source="test",
|
||||
),
|
||||
Problem(
|
||||
file_path=Path("/b.py"),
|
||||
line=2,
|
||||
column=1,
|
||||
severity=Severity.ERROR,
|
||||
message="2",
|
||||
source="test",
|
||||
),
|
||||
]
|
||||
controller._state.problems = problems
|
||||
|
||||
p = controller.prev_problem()
|
||||
assert p.message == "2"
|
||||
|
||||
def test_clear(self, controller: ProblemsController):
|
||||
controller._state.problems = [
|
||||
Problem(
|
||||
file_path=Path("/a.py"),
|
||||
line=1,
|
||||
column=1,
|
||||
severity=Severity.ERROR,
|
||||
message="err",
|
||||
source="test",
|
||||
)
|
||||
]
|
||||
controller.clear()
|
||||
assert controller.problems == []
|
||||
|
||||
|
||||
class TestTodosController:
|
||||
"""Tests for TodosController."""
|
||||
|
||||
@pytest.fixture
|
||||
def controller(self, tmp_path: Path) -> TodosController:
|
||||
return TodosController(tmp_path)
|
||||
|
||||
def test_initial_state(self, controller: TodosController):
|
||||
assert controller.items == []
|
||||
assert controller.total_count == 0
|
||||
|
||||
def test_filter_by_type(self, controller: TodosController):
|
||||
items = [
|
||||
TodoItem(file_path=Path("/a.py"), line=1, column=1, todo_type=TodoType.TODO, text="1", context_line="# TODO: 1"),
|
||||
TodoItem(file_path=Path("/b.py"), line=2, column=1, todo_type=TodoType.FIXME, text="2", context_line="# FIXME: 2"),
|
||||
]
|
||||
controller._state.items = items
|
||||
|
||||
todos = controller.filter_by_type(TodoType.TODO)
|
||||
assert len(todos) == 1
|
||||
assert todos[0].todo_type == TodoType.TODO
|
||||
|
||||
def test_get_grouped_items(self, controller: TodosController):
|
||||
items = [
|
||||
TodoItem(file_path=Path("/a.py"), line=1, column=1, todo_type=TodoType.TODO, text="1", context_line="# TODO: 1"),
|
||||
TodoItem(file_path=Path("/a.py"), line=5, column=1, todo_type=TodoType.FIXME, text="2", context_line="# FIXME: 2"),
|
||||
TodoItem(file_path=Path("/b.py"), line=1, column=1, todo_type=TodoType.TODO, text="3", context_line="# TODO: 3"),
|
||||
]
|
||||
controller._state.items = items
|
||||
|
||||
grouped = controller.get_grouped_items()
|
||||
assert len(grouped) == 2
|
||||
assert len(grouped[Path("/a.py")]) == 2
|
||||
assert len(grouped[Path("/b.py")]) == 1
|
||||
|
||||
def test_toggle_group_by_file(self, controller: TodosController):
|
||||
assert controller._state.group_by_file is True # Default is True per TodosState model
|
||||
result = controller.toggle_group_by_file()
|
||||
assert result is False
|
||||
assert controller._state.group_by_file is False
|
||||
|
||||
|
||||
class TestJiraController:
|
||||
"""Tests for JiraController."""
|
||||
|
||||
@pytest.fixture
|
||||
def controller(self) -> JiraController:
|
||||
return JiraController(enabled=True)
|
||||
|
||||
@pytest.fixture
|
||||
def disabled_controller(self) -> JiraController:
|
||||
return JiraController(enabled=False)
|
||||
|
||||
def test_initial_enabled_state(self, controller: JiraController):
|
||||
assert controller.enabled is True
|
||||
|
||||
def test_initial_disabled_state(self, disabled_controller: JiraController):
|
||||
assert disabled_controller.enabled is False
|
||||
|
||||
def test_enable_disable(self, disabled_controller: JiraController):
|
||||
disabled_controller.enable()
|
||||
assert disabled_controller.enabled is True
|
||||
disabled_controller.disable()
|
||||
assert disabled_controller.enabled is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_command_when_disabled(self, disabled_controller: JiraController):
|
||||
result = await disabled_controller.run_command("issue", "list")
|
||||
assert "disabled" in result.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_content_when_disabled(self, disabled_controller: JiraController):
|
||||
result = await disabled_controller.get_content()
|
||||
assert result is None
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Tests for extension system."""
|
||||
|
||||
import pytest
|
||||
|
||||
from clide.extensions import hookimpl
|
||||
from clide.extensions.manager import ExtensionManager
|
||||
|
||||
|
||||
class SampleExtension:
|
||||
"""Sample extension for testing."""
|
||||
|
||||
@hookimpl
|
||||
def clide_on_app_startup(self, app: object) -> None:
|
||||
"""Track that startup was called."""
|
||||
self.startup_called = True
|
||||
self.received_app = app
|
||||
|
||||
|
||||
class TestExtensionManager:
|
||||
"""Tests for ExtensionManager."""
|
||||
|
||||
def test_register_plugin(self) -> None:
|
||||
"""Plugins can be registered manually."""
|
||||
manager = ExtensionManager()
|
||||
extension = SampleExtension()
|
||||
|
||||
manager.register_plugin(extension, "sample")
|
||||
|
||||
assert "sample" in manager.list_extensions()
|
||||
|
||||
def test_unregister_plugin(self) -> None:
|
||||
"""Plugins can be unregistered."""
|
||||
manager = ExtensionManager()
|
||||
extension = SampleExtension()
|
||||
manager.register_plugin(extension, "sample")
|
||||
|
||||
manager.unregister_plugin("sample")
|
||||
|
||||
assert "sample" not in manager.list_extensions()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_startup_hook(self) -> None:
|
||||
"""Startup hooks are triggered for all extensions."""
|
||||
manager = ExtensionManager()
|
||||
extension = SampleExtension()
|
||||
manager.register_plugin(extension, "sample")
|
||||
mock_app = object()
|
||||
|
||||
await manager.trigger_app_startup(mock_app)
|
||||
|
||||
assert extension.startup_called is True
|
||||
assert extension.received_app is mock_app
|
||||
@@ -0,0 +1,437 @@
|
||||
"""Tests for Pydantic models."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from clide.models.config import ClideSettings, KeybindingsConfig, PanelConfig
|
||||
from clide.models.diff import ChangeType, DiffContent, DiffHunk, DiffLine, DiffViewState
|
||||
from clide.models.editor import CursorPosition, EditorState, FileBuffer, Selection
|
||||
from clide.models.git import ChangeStatus, GitBranch, GitChange, GitCommit, GitGraph, GitStatus
|
||||
from clide.models.problems import Problem, ProblemsSummary, ProblemsState, Severity
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition, ThemeMetadata
|
||||
from clide.models.todos import TodoItem, TodosSummary, TodosState, TodoType
|
||||
|
||||
|
||||
class TestCursorPosition:
|
||||
"""Tests for CursorPosition model."""
|
||||
|
||||
def test_create_cursor(self):
|
||||
cursor = CursorPosition(line=10, column=5)
|
||||
assert cursor.line == 10
|
||||
assert cursor.column == 5
|
||||
|
||||
def test_cursor_is_frozen(self):
|
||||
cursor = CursorPosition(line=0, column=0)
|
||||
with pytest.raises(ValidationError):
|
||||
cursor.line = 1
|
||||
|
||||
|
||||
class TestFileBuffer:
|
||||
"""Tests for FileBuffer model."""
|
||||
|
||||
def test_create_buffer(self):
|
||||
buffer = FileBuffer(
|
||||
path=Path("/test/file.py"),
|
||||
content="print('hello')",
|
||||
language="python",
|
||||
)
|
||||
assert buffer.path == Path("/test/file.py")
|
||||
assert buffer.content == "print('hello')"
|
||||
assert buffer.language == "python"
|
||||
assert buffer.is_modified is False
|
||||
|
||||
def test_buffer_display_name(self):
|
||||
buffer = FileBuffer(path=Path("/test/file.py"), content="")
|
||||
assert buffer.display_name == "file.py"
|
||||
|
||||
def test_buffer_modified_display_name(self):
|
||||
buffer = FileBuffer(path=Path("/test/file.py"), content="", is_modified=True)
|
||||
assert buffer.display_name == "● file.py"
|
||||
|
||||
def test_buffer_filename(self):
|
||||
buffer = FileBuffer(path=Path("/some/deep/path/script.js"), content="")
|
||||
assert buffer.filename == "script.js"
|
||||
|
||||
|
||||
class TestEditorState:
|
||||
"""Tests for EditorState model."""
|
||||
|
||||
def test_empty_state(self):
|
||||
state = EditorState()
|
||||
assert state.buffers == []
|
||||
assert state.active_buffer_index is None
|
||||
assert state.active_buffer is None
|
||||
|
||||
def test_active_buffer(self):
|
||||
buffer1 = FileBuffer(path=Path("/a.py"), content="a")
|
||||
buffer2 = FileBuffer(path=Path("/b.py"), content="b")
|
||||
state = EditorState(buffers=[buffer1, buffer2], active_buffer_index=1)
|
||||
assert state.active_buffer == buffer2
|
||||
|
||||
def test_get_buffer_by_path(self):
|
||||
buffer = FileBuffer(path=Path("/test.py"), content="test")
|
||||
state = EditorState(buffers=[buffer])
|
||||
assert state.get_buffer_by_path(Path("/test.py")) == buffer
|
||||
assert state.get_buffer_by_path(Path("/other.py")) is None
|
||||
|
||||
|
||||
class TestGitChange:
|
||||
"""Tests for GitChange model."""
|
||||
|
||||
def test_create_change(self):
|
||||
from clide.models.git import ChangeStatus
|
||||
change = GitChange(path="src/main.py", status=ChangeStatus.MODIFIED, staged=True)
|
||||
assert change.path == "src/main.py"
|
||||
assert change.status == ChangeStatus.MODIFIED
|
||||
assert change.staged is True
|
||||
|
||||
def test_change_is_frozen(self):
|
||||
from clide.models.git import ChangeStatus
|
||||
change = GitChange(path="f", status=ChangeStatus.ADDED, staged=False)
|
||||
with pytest.raises(ValidationError):
|
||||
change.path = "new.py"
|
||||
|
||||
|
||||
class TestGitStatus:
|
||||
"""Tests for GitStatus model."""
|
||||
|
||||
def test_create_status(self):
|
||||
staged = (GitChange(path="a.py", status=ChangeStatus.ADDED, staged=True),)
|
||||
unstaged = (GitChange(path="b.py", status=ChangeStatus.MODIFIED, staged=False),)
|
||||
status = GitStatus(branch="main", staged=staged, unstaged=unstaged)
|
||||
assert status.branch == "main"
|
||||
assert len(status.staged) == 1
|
||||
assert len(status.unstaged) == 1
|
||||
|
||||
def test_empty_status(self):
|
||||
status = GitStatus(branch="main", staged=(), unstaged=())
|
||||
assert status.branch == "main"
|
||||
assert len(status.staged) == 0
|
||||
assert len(status.unstaged) == 0
|
||||
|
||||
|
||||
class TestGitBranch:
|
||||
"""Tests for GitBranch model."""
|
||||
|
||||
def test_create_branch(self):
|
||||
branch = GitBranch(name="feature/test", is_current=True, is_remote=False)
|
||||
assert branch.name == "feature/test"
|
||||
assert branch.is_current is True
|
||||
assert branch.is_remote is False
|
||||
|
||||
|
||||
class TestGitCommit:
|
||||
"""Tests for GitCommit model."""
|
||||
|
||||
def test_create_commit(self):
|
||||
commit = GitCommit(
|
||||
hash="abc123def456789",
|
||||
short_hash="abc123",
|
||||
message="Test commit",
|
||||
author="Test Author",
|
||||
date="2024-01-01",
|
||||
)
|
||||
assert commit.hash == "abc123def456789"
|
||||
assert commit.short_hash == "abc123"
|
||||
assert commit.message == "Test commit"
|
||||
|
||||
|
||||
class TestDiffLine:
|
||||
"""Tests for DiffLine model."""
|
||||
|
||||
def test_added_line(self):
|
||||
line = DiffLine(change_type=ChangeType.ADDED, content="new line", new_line_num=10)
|
||||
assert line.change_type == ChangeType.ADDED
|
||||
assert line.content == "new line"
|
||||
|
||||
def test_removed_line(self):
|
||||
line = DiffLine(change_type=ChangeType.REMOVED, content="old line", old_line_num=5)
|
||||
assert line.change_type == ChangeType.REMOVED
|
||||
|
||||
def test_context_line(self):
|
||||
line = DiffLine(
|
||||
change_type=ChangeType.CONTEXT,
|
||||
content="unchanged",
|
||||
old_line_num=5,
|
||||
new_line_num=5,
|
||||
)
|
||||
assert line.change_type == ChangeType.CONTEXT
|
||||
|
||||
|
||||
class TestDiffHunk:
|
||||
"""Tests for DiffHunk model."""
|
||||
|
||||
def test_create_hunk(self):
|
||||
lines = (
|
||||
DiffLine(change_type=ChangeType.REMOVED, content="old", old_line_num=1),
|
||||
DiffLine(change_type=ChangeType.ADDED, content="new", new_line_num=1),
|
||||
)
|
||||
hunk = DiffHunk(
|
||||
header="@@ -1,1 +1,1 @@",
|
||||
old_start=1,
|
||||
old_count=1,
|
||||
new_start=1,
|
||||
new_count=1,
|
||||
lines=lines,
|
||||
)
|
||||
assert len(hunk.lines) == 2
|
||||
|
||||
|
||||
class TestDiffContent:
|
||||
"""Tests for DiffContent model."""
|
||||
|
||||
def test_create_diff(self):
|
||||
hunk = DiffHunk(
|
||||
header="@@ -1 +1 @@",
|
||||
old_start=1,
|
||||
old_count=1,
|
||||
new_start=1,
|
||||
new_count=1,
|
||||
lines=(),
|
||||
)
|
||||
diff = DiffContent(file_path="test.py", hunks=(hunk,))
|
||||
assert diff.file_path == "test.py"
|
||||
assert len(diff.hunks) == 1
|
||||
|
||||
|
||||
class TestProblem:
|
||||
"""Tests for Problem model."""
|
||||
|
||||
def test_create_problem(self):
|
||||
problem = Problem(
|
||||
file_path=Path("/test.py"),
|
||||
line=10,
|
||||
column=5,
|
||||
severity=Severity.ERROR,
|
||||
message="Syntax error",
|
||||
source="ruff",
|
||||
code="E999",
|
||||
)
|
||||
assert problem.line == 10
|
||||
assert problem.severity == Severity.ERROR
|
||||
|
||||
def test_severity_icon(self):
|
||||
error = Problem(
|
||||
file_path=Path("/t.py"),
|
||||
line=1,
|
||||
column=1,
|
||||
severity=Severity.ERROR,
|
||||
message="err",
|
||||
source="test",
|
||||
)
|
||||
assert error.severity_icon == "✖"
|
||||
|
||||
warning = Problem(
|
||||
file_path=Path("/t.py"),
|
||||
line=1,
|
||||
column=1,
|
||||
severity=Severity.WARNING,
|
||||
message="warn",
|
||||
source="test",
|
||||
)
|
||||
assert warning.severity_icon == "⚠"
|
||||
|
||||
|
||||
class TestProblemsSummary:
|
||||
"""Tests for ProblemsSummary model."""
|
||||
|
||||
def test_create_summary(self):
|
||||
summary = ProblemsSummary(errors=5, warnings=3, infos=1, hints=0)
|
||||
assert summary.total == 9
|
||||
|
||||
|
||||
class TestTodoItem:
|
||||
"""Tests for TodoItem model."""
|
||||
|
||||
def test_create_todo(self):
|
||||
todo = TodoItem(
|
||||
file_path=Path("/src/main.py"),
|
||||
line=42,
|
||||
column=5,
|
||||
todo_type=TodoType.TODO,
|
||||
text="Implement this feature",
|
||||
context_line="# TODO: Implement this feature",
|
||||
)
|
||||
assert todo.line == 42
|
||||
assert todo.todo_type == TodoType.TODO
|
||||
|
||||
def test_type_icon(self):
|
||||
todo = TodoItem(
|
||||
file_path=Path("/t.py"),
|
||||
line=1,
|
||||
column=1,
|
||||
todo_type=TodoType.TODO,
|
||||
text="todo",
|
||||
context_line="# TODO: todo",
|
||||
)
|
||||
assert todo.type_icon == "☐"
|
||||
|
||||
fixme = TodoItem(
|
||||
file_path=Path("/t.py"),
|
||||
line=1,
|
||||
column=1,
|
||||
todo_type=TodoType.FIXME,
|
||||
text="fixme",
|
||||
context_line="# FIXME: fixme",
|
||||
)
|
||||
assert fixme.type_icon == "🔧"
|
||||
|
||||
|
||||
class TestTodosSummary:
|
||||
"""Tests for TodosSummary model."""
|
||||
|
||||
def test_create_summary(self):
|
||||
summary = TodosSummary(
|
||||
todo_count=5,
|
||||
fixme_count=3,
|
||||
hack_count=2,
|
||||
other_count=0,
|
||||
)
|
||||
assert summary.total == 10
|
||||
assert summary.todo_count == 5
|
||||
|
||||
|
||||
class TestThemeColors:
|
||||
"""Tests for ThemeColors model."""
|
||||
|
||||
def test_valid_colors(self):
|
||||
colors = ThemeColors(
|
||||
primary="#00a3d2",
|
||||
secondary="#00a9b9",
|
||||
accent="#fa5f8b",
|
||||
background="#21262f",
|
||||
surface="#393e48",
|
||||
panel="#292e38",
|
||||
foreground="#e2e8f5",
|
||||
success="#00ab9a",
|
||||
warning="#d08447",
|
||||
error="#f06c6f",
|
||||
)
|
||||
assert colors.primary == "#00a3d2"
|
||||
|
||||
def test_invalid_color_rejected(self):
|
||||
with pytest.raises(ValidationError):
|
||||
ThemeColors(
|
||||
primary="invalid",
|
||||
secondary="#00a9b9",
|
||||
accent="#fa5f8b",
|
||||
background="#21262f",
|
||||
surface="#393e48",
|
||||
panel="#292e38",
|
||||
foreground="#e2e8f5",
|
||||
success="#00ab9a",
|
||||
warning="#d08447",
|
||||
error="#f06c6f",
|
||||
)
|
||||
|
||||
def test_color_normalized_to_lowercase(self):
|
||||
colors = ThemeColors(
|
||||
primary="#00A3D2",
|
||||
secondary="#00A9B9",
|
||||
accent="#FA5F8B",
|
||||
background="#21262F",
|
||||
surface="#393E48",
|
||||
panel="#292E38",
|
||||
foreground="#E2E8F5",
|
||||
success="#00AB9A",
|
||||
warning="#D08447",
|
||||
error="#F06C6F",
|
||||
)
|
||||
assert colors.primary == "#00a3d2"
|
||||
|
||||
|
||||
class TestThemeDefinition:
|
||||
"""Tests for ThemeDefinition model."""
|
||||
|
||||
def test_create_theme(self):
|
||||
colors = ThemeColors(
|
||||
primary="#00a3d2",
|
||||
secondary="#00a9b9",
|
||||
accent="#fa5f8b",
|
||||
background="#21262f",
|
||||
surface="#393e48",
|
||||
panel="#292e38",
|
||||
foreground="#e2e8f5",
|
||||
success="#00ab9a",
|
||||
warning="#d08447",
|
||||
error="#f06c6f",
|
||||
)
|
||||
theme = ThemeDefinition(
|
||||
name="test-theme",
|
||||
display_name="Test Theme",
|
||||
dark=True,
|
||||
colors=colors,
|
||||
)
|
||||
assert theme.name == "test-theme"
|
||||
assert theme.dark is True
|
||||
|
||||
def test_to_textual_theme(self):
|
||||
colors = ThemeColors(
|
||||
primary="#00a3d2",
|
||||
secondary="#00a9b9",
|
||||
accent="#fa5f8b",
|
||||
background="#21262f",
|
||||
surface="#393e48",
|
||||
panel="#292e38",
|
||||
foreground="#e2e8f5",
|
||||
success="#00ab9a",
|
||||
warning="#d08447",
|
||||
error="#f06c6f",
|
||||
)
|
||||
theme_def = ThemeDefinition(
|
||||
name="test",
|
||||
display_name="Test",
|
||||
dark=True,
|
||||
colors=colors,
|
||||
)
|
||||
textual_theme = theme_def.to_textual_theme()
|
||||
assert textual_theme.name == "test"
|
||||
|
||||
|
||||
class TestThemeMetadata:
|
||||
"""Tests for ThemeMetadata model."""
|
||||
|
||||
def test_create_metadata(self):
|
||||
meta = ThemeMetadata(
|
||||
name="summer-night",
|
||||
display_name="Summer Night",
|
||||
dark=True,
|
||||
category="core",
|
||||
)
|
||||
assert meta.name == "summer-night"
|
||||
assert meta.category == "core"
|
||||
|
||||
|
||||
class TestClideSettings:
|
||||
"""Tests for ClideSettings model."""
|
||||
|
||||
def test_default_settings(self):
|
||||
settings = ClideSettings()
|
||||
assert settings.theme == "summer-night"
|
||||
assert settings.jira_enabled is False
|
||||
|
||||
def test_custom_settings(self):
|
||||
settings = ClideSettings(theme="dracula", jira_enabled=True)
|
||||
assert settings.theme == "dracula"
|
||||
assert settings.jira_enabled is True
|
||||
|
||||
|
||||
class TestPanelConfig:
|
||||
"""Tests for PanelConfig model."""
|
||||
|
||||
def test_default_panel_config(self):
|
||||
config = PanelConfig()
|
||||
assert config.sidebar_visible is True
|
||||
assert config.context_visible is True
|
||||
assert config.workspace_visible is False
|
||||
|
||||
|
||||
class TestKeybindingsConfig:
|
||||
"""Tests for KeybindingsConfig model."""
|
||||
|
||||
def test_default_keybindings(self):
|
||||
config = KeybindingsConfig()
|
||||
assert config.toggle_sidebar == "ctrl+b"
|
||||
assert config.toggle_terminal == "ctrl+`"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user