Clide is being rebuilt as a Flutter desktop app. The Python Textual implementation moves wholesale into legacy/ rather than being deleted: its pane model, panel set, git skills, and panel communication design are real thought that should remain readable next to the new code while the rebuild finds its shape. Git's rename tracking preserves history, so `git log -- legacy/` still works. The Flutter rebuild lives at the repo root alongside a Go sidecar (the architecture claudian was heading toward, which folds into clide as a core component rather than a separate plugin project). Bootstrap of the new shape lands in subsequent commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
433 lines
11 KiB
Markdown
433 lines
11 KiB
Markdown
# Clide Architecture
|
|
|
|
Technical documentation covering Clide's architecture, the frameworks it builds on, and implementation patterns.
|
|
|
|
## Table of Contents
|
|
|
|
- [Application Architecture](#application-architecture)
|
|
- [Textual TUI Framework](#textual-tui-framework)
|
|
- [Pydantic Data Validation](#pydantic-data-validation)
|
|
- [Extension System](#extension-system)
|
|
- [Testing Strategy](#testing-strategy)
|
|
- [Build and Distribution](#build-and-distribution)
|
|
|
|
---
|
|
|
|
## Application Architecture
|
|
|
|
Clide follows a layered architecture with clear separation between UI, business logic, and data.
|
|
|
|
### Layer Overview
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────┐
|
|
│ ClideApp (app.py) │
|
|
│ Main application, layout, keybindings │
|
|
├─────────────────────────────────────────────────────────┤
|
|
│ Widgets Layer │
|
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
|
│ │ Panels │ │ Components │ │ Themes │ │
|
|
│ │ (layout) │ │ (reusable) │ │ (styling) │ │
|
|
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
|
├─────────────────────────────────────────────────────────┤
|
|
│ Controllers Layer │
|
|
│ Business logic, state management │
|
|
├─────────────────────────────────────────────────────────┤
|
|
│ Services Layer │
|
|
│ Git, files, scanning, settings, skills │
|
|
├─────────────────────────────────────────────────────────┤
|
|
│ Models Layer │
|
|
│ Pydantic data structures │
|
|
└─────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### Data Flow
|
|
|
|
```
|
|
User Action (click, keypress)
|
|
│
|
|
▼
|
|
Widget Event
|
|
│
|
|
▼
|
|
Message Bubbles Up
|
|
│
|
|
▼
|
|
App Event Handler
|
|
│
|
|
▼
|
|
Controller Method
|
|
│
|
|
▼
|
|
Service Call
|
|
│
|
|
▼
|
|
Return Data/Status
|
|
│
|
|
▼
|
|
Update UI State
|
|
│
|
|
▼
|
|
Reactive UI Update
|
|
```
|
|
|
|
### Key Patterns
|
|
|
|
**Message-based communication** — Widgets emit messages that bubble up. Parent widgets or the app handle messages and coordinate responses.
|
|
|
|
**Reactive properties** — UI state uses Textual's `reactive` type. Changes automatically trigger `watch_*` methods.
|
|
|
|
**Background workers** — Long operations use `@work(thread=True)` to avoid blocking the UI.
|
|
|
|
**State preservation** — Hiding panels uses `display: none`, never destroying widgets. All state persists.
|
|
|
|
For detailed code organization, see [Code Organization](code-organization.md).
|
|
|
|
---
|
|
|
|
## Textual TUI Framework
|
|
|
|
Textual provides the foundation for Clide's terminal UI.
|
|
|
|
### Core Concepts
|
|
|
|
**Widgets** — Building blocks of the UI. Everything visible is a widget.
|
|
|
|
**Containers** — Widgets that hold other widgets (Vertical, Horizontal, Container).
|
|
|
|
**Reactive Programming** — State changes trigger automatic UI updates.
|
|
|
|
**CSS Styling** — Layout and appearance defined in CSS, similar to web development.
|
|
|
|
### Layout System
|
|
|
|
Clide uses CSS Grid for the main layout:
|
|
|
|
```css
|
|
Screen {
|
|
layout: grid;
|
|
grid-size: 3 1;
|
|
grid-columns: 20% 1fr 25%;
|
|
}
|
|
```
|
|
|
|
Panels use percentage widths with minimum sizes:
|
|
|
|
```css
|
|
#panel-sidebar {
|
|
width: 20%;
|
|
min-width: 25;
|
|
}
|
|
```
|
|
|
|
### Widget Lifecycle
|
|
|
|
```python
|
|
class MyWidget(Widget):
|
|
def __init__(self):
|
|
super().__init__()
|
|
# Initialize instance variables
|
|
|
|
def compose(self) -> ComposeResult:
|
|
# Yield child widgets
|
|
yield Label("Hello")
|
|
|
|
def on_mount(self) -> None:
|
|
# Called after widget is added to DOM
|
|
# Safe to query other widgets here
|
|
|
|
def on_unmount(self) -> None:
|
|
# Cleanup when removed
|
|
```
|
|
|
|
### Event Handling
|
|
|
|
Events bubble up through the widget tree:
|
|
|
|
```python
|
|
# Define a message
|
|
class FileSelected(Message):
|
|
def __init__(self, path: Path):
|
|
self.path = path
|
|
super().__init__()
|
|
|
|
# Emit the message
|
|
self.post_message(self.FileSelected(path))
|
|
|
|
# Handle in parent (naming convention: on_<widget>_<message>)
|
|
def on_files_view_file_selected(self, event: FilesView.FileSelected):
|
|
self.open_file(event.path)
|
|
```
|
|
|
|
### Background Tasks
|
|
|
|
Use `@work` for operations that shouldn't block the UI:
|
|
|
|
```python
|
|
from textual import work
|
|
|
|
@work(thread=True)
|
|
def fetch_data(self) -> dict:
|
|
"""Runs in thread pool."""
|
|
result = expensive_operation()
|
|
return result
|
|
|
|
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
|
if event.state == WorkerState.SUCCESS:
|
|
self.update_ui(event.worker.result)
|
|
```
|
|
|
|
### References
|
|
|
|
- [Textual Documentation](https://textual.textualize.io/)
|
|
- [Textual Widgets](https://textual.textualize.io/widgets/)
|
|
- [Textual CSS](https://textual.textualize.io/guide/CSS/)
|
|
|
|
---
|
|
|
|
## Pydantic Data Validation
|
|
|
|
All data models use Pydantic v2 with strict mode.
|
|
|
|
### Model Configuration
|
|
|
|
```python
|
|
from pydantic import BaseModel, ConfigDict
|
|
|
|
class GitChange(BaseModel):
|
|
model_config = ConfigDict(strict=True, frozen=True)
|
|
|
|
path: str
|
|
status: Literal["added", "modified", "deleted"]
|
|
staged: bool
|
|
```
|
|
|
|
**strict=True** — No type coercion. `"123"` won't become `123`.
|
|
|
|
**frozen=True** — Immutable instances. Enables hashing for use as dict keys.
|
|
|
|
### Settings Management
|
|
|
|
Application settings use `pydantic-settings`:
|
|
|
|
```python
|
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
|
|
class ClideSettings(BaseSettings):
|
|
model_config = SettingsConfigDict(
|
|
env_prefix="CLIDE_",
|
|
env_file=".env",
|
|
)
|
|
|
|
theme: str = "summer-night"
|
|
jira_enabled: bool = False
|
|
```
|
|
|
|
Settings load from (in priority order):
|
|
1. Environment variables (`CLIDE_THEME=dracula`)
|
|
2. `.env` file
|
|
3. Default values
|
|
|
|
### References
|
|
|
|
- [Pydantic Documentation](https://docs.pydantic.dev/latest/)
|
|
- [Pydantic Settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/)
|
|
|
|
---
|
|
|
|
## Extension System
|
|
|
|
Clide uses Pluggy for hook-based extensibility.
|
|
|
|
### Hook Specifications
|
|
|
|
Hooks define extension points:
|
|
|
|
```python
|
|
# clide/extensions/hookspecs.py
|
|
import pluggy
|
|
|
|
hookspec = pluggy.HookspecMarker("clide")
|
|
hookimpl = pluggy.HookimplMarker("clide")
|
|
|
|
class ClideHookSpec:
|
|
@hookspec
|
|
def clide_startup(self, app: App) -> None:
|
|
"""Called when the app starts."""
|
|
|
|
@hookspec
|
|
def clide_on_file_changed(self, event: FileEvent) -> None:
|
|
"""Called when a file changes."""
|
|
```
|
|
|
|
### Implementing Hooks
|
|
|
|
Extensions implement hooks with the `@hookimpl` decorator:
|
|
|
|
```python
|
|
from clide.extensions import hookimpl
|
|
|
|
class MyExtension:
|
|
@hookimpl
|
|
def clide_startup(self, app: App) -> None:
|
|
app.notify("Extension loaded!")
|
|
|
|
@hookimpl
|
|
def clide_on_file_changed(self, event: FileEvent) -> None:
|
|
if event.path.suffix == ".py":
|
|
# React to Python file changes
|
|
pass
|
|
```
|
|
|
|
### Distribution
|
|
|
|
Extensions can be packaged and distributed via entry points:
|
|
|
|
```toml
|
|
# pyproject.toml of extension package
|
|
[project.entry-points."clide.extensions"]
|
|
my_extension = "my_package:MyExtension"
|
|
```
|
|
|
|
### Available Hooks
|
|
|
|
| Hook | When Called |
|
|
|------|-------------|
|
|
| `clide_startup` | App initialization |
|
|
| `clide_shutdown` | App cleanup |
|
|
| `clide_on_file_changed` | File created/modified/deleted |
|
|
| `clide_on_file_saved` | File saved in editor |
|
|
|
|
### References
|
|
|
|
- [Pluggy Documentation](https://pluggy.readthedocs.io/)
|
|
|
|
---
|
|
|
|
## Testing Strategy
|
|
|
|
### Test Organization
|
|
|
|
```
|
|
tests/
|
|
├── unit/ # Isolated component tests
|
|
├── integration/ # Component interaction tests
|
|
└── snapshots/ # Visual regression tests
|
|
```
|
|
|
|
### Async Testing
|
|
|
|
Configure pytest-asyncio in auto mode:
|
|
|
|
```toml
|
|
[tool.pytest.ini_options]
|
|
asyncio_mode = "auto"
|
|
```
|
|
|
|
Tests can be async without decorators:
|
|
|
|
```python
|
|
async def test_async_operation():
|
|
result = await some_async_function()
|
|
assert result == expected
|
|
```
|
|
|
|
### Snapshot Testing
|
|
|
|
Visual regression testing with pytest-textual-snapshot:
|
|
|
|
```python
|
|
def test_layout(snap_compare):
|
|
assert snap_compare(ClideApp(), terminal_size=(120, 40))
|
|
|
|
def test_with_interaction(snap_compare):
|
|
async def setup(pilot):
|
|
await pilot.press("tab", "enter")
|
|
|
|
assert snap_compare(ClideApp(), run_before=setup)
|
|
```
|
|
|
|
Update snapshots after intentional changes:
|
|
|
|
```bash
|
|
pytest tests/snapshots/ --snapshot-update
|
|
```
|
|
|
|
### Mocking
|
|
|
|
Use `AsyncMock` for async dependencies:
|
|
|
|
```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"
|
|
```
|
|
|
|
### References
|
|
|
|
- [pytest-asyncio](https://pytest-asyncio.readthedocs.io/)
|
|
- [pytest-textual-snapshot](https://github.com/Textualize/pytest-textual-snapshot)
|
|
- [Textual Testing Guide](https://textual.textualize.io/guide/testing/)
|
|
|
|
---
|
|
|
|
## Build and Distribution
|
|
|
|
### Development
|
|
|
|
```bash
|
|
make setup # Create venv, install deps
|
|
make run # Run application
|
|
make test # Run all tests
|
|
make typecheck # Run mypy
|
|
make lint # Run ruff
|
|
make format # Format code
|
|
```
|
|
|
|
### PyInstaller
|
|
|
|
Build standalone executables:
|
|
|
|
```bash
|
|
pip install -e ".[build]"
|
|
pyinstaller clide.spec --clean
|
|
```
|
|
|
|
**Important**: PyInstaller cannot cross-compile. Build on each target platform.
|
|
|
|
### CI/CD
|
|
|
|
Multi-platform builds via Gitea Actions:
|
|
|
|
```yaml
|
|
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
|
|
# ... same steps
|
|
```
|
|
|
|
### Optimization
|
|
|
|
- Use `--onefile` for single executable
|
|
- Apply `--strip` to reduce size
|
|
- Use UPX compression for further reduction
|
|
- Exclude unused modules with `--exclude-module`
|
|
|
|
### References
|
|
|
|
- [PyInstaller Documentation](https://pyinstaller.org/)
|
|
- [Gitea Actions](https://docs.gitea.com/usage/actions/overview)
|