Restructure project and add user settings persistence

- Move src/clide/ to clide/ (flatten directory structure)
- Add SettingsService for persistent user preferences
- Settings stored in ~/.clide/settings.json
- Persist theme, panel visibility, and compact mode state
- Fix terminal rendering glitch with throttled refresh (~60fps)
- Update pyproject.toml, Makefile, and tests for new structure

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeroen Schweitzer
2026-02-01 00:42:46 +01:00
co-authored by Claude Opus 4.5
parent 6b7c3ecf9b
commit 1ed21d2a0f
88 changed files with 378 additions and 226 deletions
+3 -63
View File
@@ -59,80 +59,20 @@ make build # Build for current platform
```
clide/
├── src/clide/
├── clide/ # Package source
│ ├── __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/
│ ├── widgets/ # UI components
│ │ ├── 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
└── helpers/ # Utility functions
├── tests/
│ ├── conftest.py
+9 -9
View File
@@ -43,7 +43,7 @@ test-single:
$(BIN)/pytest $(TEST) -v
test-cov:
$(BIN)/pytest --cov=src/clide --cov-report=html --cov-report=term
$(BIN)/pytest --cov=clide/clide --cov-report=html --cov-report=term
test-snapshots:
$(BIN)/pytest tests/snapshots/
@@ -52,14 +52,14 @@ test-snapshots-update:
$(BIN)/pytest tests/snapshots/ --snapshot-update
typecheck:
$(BIN)/mypy src/
$(BIN)/mypy clide/
lint:
$(BIN)/ruff check src/ tests/
$(BIN)/ruff check clide/ tests/
format:
$(BIN)/ruff format src/ tests/
$(BIN)/ruff check --fix src/ tests/
$(BIN)/ruff format clide/ tests/
$(BIN)/ruff check --fix clide/ tests/
build:
$(BIN)/pyinstaller clide.spec --clean
@@ -70,7 +70,7 @@ build-onefile:
--onefile \
--clean \
--noconfirm \
src/clide/__main__.py
clide/clide/__main__.py
clean:
rm -rf $(VENV)
@@ -83,12 +83,12 @@ clean:
# CI targets (for GitHub Actions)
ci-lint:
pip install ruff mypy
ruff check src/ tests/
mypy src/
ruff check clide/ tests/
mypy clide/
ci-test:
pip install -e ".[dev]"
pytest --cov=src/clide --cov-report=xml
pytest --cov=clide/clide --cov-report=xml
ci-build:
pip install -e ".[build]"
+63 -12
View File
@@ -5,7 +5,7 @@ from typing import ClassVar
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Container, Horizontal, Vertical
from textual.containers import Horizontal, Vertical
from textual.reactive import reactive
from textual.widgets import Footer, Header
@@ -17,6 +17,7 @@ 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.services.settings_service import SettingsService, get_settings_service
from clide.themes.registry import get_all_themes, get_theme
from clide.widgets.panels.claude import ClaudePanel
from clide.widgets.panels.context import ContextPanel
@@ -151,6 +152,10 @@ class ClideApp(App[None]):
self.settings = settings or ClideSettings()
self._test_mode = test_mode
# User settings persistence
self._settings_service = get_settings_service()
self._user_settings = self._settings_service.load()
# Extension manager
self.extension_manager = ExtensionManager()
@@ -161,7 +166,7 @@ class ClideApp(App[None]):
self.problems_controller = ProblemsController(self.workdir)
self.todos_controller = TodosController(self.workdir)
self.jira_controller = JiraController(
enabled=self.settings.jira_enabled,
enabled=self._user_settings.jira_enabled,
)
# Register themes
@@ -174,8 +179,28 @@ class ClideApp(App[None]):
if theme_def:
self.register_theme(theme_def.to_textual_theme())
# Set initial theme
self.theme = self.settings.theme
# Set initial theme from user settings
self.theme = self._user_settings.theme
def set_theme(self, theme_name: str, *, save: bool = True) -> None:
"""Set the application theme.
Args:
theme_name: Name of the theme to apply
save: Whether to persist the setting (default: True)
"""
self.theme = theme_name
if save:
self._settings_service.set("theme", theme_name)
def save_user_settings(self) -> None:
"""Save current user settings to disk."""
self._settings_service.update(
theme=self.theme,
compact_mode=self.compact_mode,
sidebar_visible=self.query_one(SidebarPanel).display,
context_visible=self.query_one(ContextPanel).display,
)
def compose(self) -> ComposeResult:
"""Create the main layout."""
@@ -202,6 +227,9 @@ class ClideApp(App[None]):
async def on_mount(self) -> None:
"""Initialize application on mount."""
# Apply saved user settings
self._apply_user_settings()
# Load extensions
self.extension_manager.load_extensions()
await self.extension_manager.trigger_app_startup(self)
@@ -210,12 +238,24 @@ class ClideApp(App[None]):
await self._refresh_git()
await self._refresh_problems()
await self._refresh_todos()
if self.settings.jira_enabled:
if self._user_settings.jira_enabled:
await self._refresh_jira()
# Focus Claude panel
self.action_focus_claude()
def _apply_user_settings(self) -> None:
"""Apply saved user settings on startup."""
# Panel visibility
sidebar = self.query_one(SidebarPanel)
sidebar.visible = self._user_settings.sidebar_visible
context = self.query_one(ContextPanel)
context.visible = self._user_settings.context_visible
# Compact mode
self.compact_mode = self._user_settings.compact_mode
# Reactive watchers
def watch_workspace_visible(self, visible: bool) -> None:
"""Update panels when workspace visibility changes."""
@@ -279,11 +319,13 @@ class ClideApp(App[None]):
"""Toggle sidebar visibility."""
sidebar = self.query_one(SidebarPanel)
sidebar.visible = not sidebar.visible
self._settings_service.set("sidebar_visible", sidebar.visible)
def action_toggle_context(self) -> None:
"""Toggle context panel visibility."""
context = self.query_one(ContextPanel)
context.visible = not context.visible
self._settings_service.set("context_visible", context.visible)
def action_toggle_terminal(self) -> None:
"""Toggle terminal (shows workspace with terminal tab)."""
@@ -297,6 +339,7 @@ class ClideApp(App[None]):
def action_toggle_compact(self) -> None:
"""Toggle compact mode."""
self.compact_mode = not self.compact_mode
self._settings_service.set("compact_mode", self.compact_mode)
def action_toggle_fullscreen(self) -> None:
"""Toggle fullscreen for focused panel."""
@@ -364,7 +407,6 @@ class ClideApp(App[None]):
def action_save_file(self) -> None:
"""Save current file."""
workspace = self.query_one(WorkspacePanel)
# EditorPane handles save internally
pass
@@ -378,10 +420,19 @@ class ClideApp(App[None]):
# TODO: Implement quick open
pass
def action_select_theme(self) -> None:
async def action_select_theme(self) -> None:
"""Open theme selector."""
# TODO: Implement theme selector via command palette
pass
from clide.themes.registry import get_all_themes
# Get all available themes
themes = get_all_themes()
theme_names = [t.name for t in themes]
# Use Textual's built-in selection if available, otherwise cycle
# For now, simple cycle through themes
current_idx = theme_names.index(self.theme) if self.theme in theme_names else 0
next_idx = (current_idx + 1) % len(theme_names)
self.set_theme(theme_names[next_idx])
# Event handlers for panel messages
async def on_sidebar_panel_file_selected(
@@ -439,14 +490,14 @@ class ClideApp(App[None]):
async def on_context_panel_jira_refresh_requested(
self,
event: ContextPanel.JiraRefreshRequested,
_event: ContextPanel.JiraRefreshRequested,
) -> None:
"""Handle Jira refresh request."""
await self._refresh_jira()
async def on_workspace_panel_file_saved(
self,
event: WorkspacePanel.FileSaved,
_event: WorkspacePanel.FileSaved,
) -> None:
"""Handle file save - refresh problems and git."""
await self._refresh_git()
@@ -493,7 +544,7 @@ class ClideApp(App[None]):
def on_claude_panel_claude_started(
self,
event: ClaudePanel.ClaudeStarted,
_event: ClaudePanel.ClaudeStarted,
) -> None:
"""Handle Claude Code started."""
self.notify("Claude Code started", severity="information")
+4 -4
View File
@@ -1,7 +1,7 @@
"""Typer CLI entry point for Clide."""
from pathlib import Path
from typing import Annotated, Optional
from typing import Annotated
import typer
@@ -25,12 +25,12 @@ def version_callback(value: bool) -> None:
@app.callback(invoke_without_command=True)
def main(
ctx: typer.Context,
version: Annotated[
Optional[bool],
_version: Annotated[
bool | None,
typer.Option("--version", "-v", callback=version_callback, is_eager=True),
] = None,
workdir: Annotated[
Optional[Path],
Path | None,
typer.Option("--workdir", "-w", help="Working directory to open"),
] = None,
) -> None:
@@ -1,12 +1,12 @@
"""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.base import ControllerMixin, controller
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.controllers.jira import JiraController
__all__ = [
"controller",
@@ -1,17 +1,15 @@
"""Base controller utilities using decorator pattern."""
from functools import wraps
from typing import TYPE_CHECKING, Callable, TypeVar
from typing import TYPE_CHECKING
from textual.message import Message
if TYPE_CHECKING:
from textual.app import App
T = TypeVar("T")
def controller(cls: type[T]) -> type[T]:
def controller[T](cls: type[T]) -> type[T]:
"""Decorator to add controller capabilities to a class.
Adds:
@@ -186,17 +186,17 @@ class DiffController:
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]),
new_line_num=new_start + len([ln for ln in current_hunk_lines if ln.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]),
old_line_num=old_start + len([ln for ln in current_hunk_lines if ln.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])
old_num = old_start + len([ln for ln in current_hunk_lines if ln.change_type != ChangeType.ADDED])
new_num = new_start + len([ln for ln in current_hunk_lines if ln.change_type != ChangeType.REMOVED])
current_hunk_lines.append(DiffLine(
change_type=ChangeType.CONTEXT,
content=line[1:],
@@ -232,7 +232,7 @@ class DiffController:
"""
return await self.load_git_diff(path, staged)
async def accept_proposal(self, file_path: str) -> bool:
async def accept_proposal(self, _file_path: str) -> bool:
"""Accept a proposed change and apply it.
Args:
@@ -249,7 +249,7 @@ class DiffController:
self.clear()
return True
async def reject_proposal(self, file_path: str) -> bool:
async def reject_proposal(self, _file_path: str) -> bool:
"""Reject a proposed change.
Args:
@@ -6,7 +6,6 @@ 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
@@ -131,11 +130,10 @@ class EditorController:
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
)
if self._state.active_buffer_index is not None and 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
@@ -5,7 +5,7 @@ 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.models.problems import Problem, ProblemsState, ProblemsSummary, Severity
from clide.services.linter_service import LinterService
@@ -5,7 +5,7 @@ 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.models.todos import TodoItem, TodosState, TodosSummary, TodoType
from clide.services.todo_scanner import TodoScanner
@@ -11,9 +11,9 @@ from clide.models.git import (
GitGraph,
GitStatus,
)
from clide.models.problems import Problem, ProblemsSummary, ProblemsState, Severity
from clide.models.problems import Problem, ProblemsState, ProblemsSummary, Severity
from clide.models.theme import ThemeColors, ThemeDefinition, ThemeMetadata
from clide.models.todos import TodoItem, TodosSummary, TodosState, TodoType
from clide.models.todos import TodoItem, TodosState, TodosSummary, TodoType
__all__ = [
# Config
@@ -1,7 +1,6 @@
"""Git-related Pydantic models."""
from enum import Enum
from typing import Literal
from pydantic import BaseModel, ConfigDict
@@ -3,11 +3,15 @@
from clide.services.git_service import GitService
from clide.services.linter_service import LinterService
from clide.services.process_service import ProcessService
from clide.services.settings_service import SettingsService, UserSettings, get_settings_service
from clide.services.todo_scanner import TodoScanner
__all__ = [
"GitService",
"LinterService",
"ProcessService",
"SettingsService",
"TodoScanner",
"UserSettings",
"get_settings_service",
]
@@ -65,7 +65,7 @@ class ProcessService:
stdout=stdout.decode("utf-8", errors="replace"),
stderr=stderr.decode("utf-8", errors="replace"),
)
except asyncio.TimeoutError:
except TimeoutError:
process.kill()
return CommandResult(
returncode=-1,
+159
View File
@@ -0,0 +1,159 @@
"""Settings persistence service for user preferences."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from pydantic import BaseModel
class UserSettings(BaseModel):
"""User settings that persist across sessions.
Stored in ~/.clide/settings.json
"""
# Appearance
theme: str = "summer-night"
# Panel state
sidebar_visible: bool = True
context_visible: bool = True
# Window
compact_mode: bool = False
# Behavior
auto_save: bool = True
confirm_exit: bool = True
# Integrations
jira_enabled: bool = False
class SettingsService:
"""Service for loading and saving user settings.
Settings are stored in ~/.clide/settings.json
"""
def __init__(self, settings_dir: Path | None = None) -> None:
"""Initialize the settings service.
Args:
settings_dir: Override the settings directory (default: ~/.clide)
"""
self._settings_dir = settings_dir or Path.home() / ".clide"
self._settings_file = self._settings_dir / "settings.json"
self._settings: UserSettings | None = None
@property
def settings_dir(self) -> Path:
"""Get the settings directory path."""
return self._settings_dir
@property
def settings_file(self) -> Path:
"""Get the settings file path."""
return self._settings_file
def load(self) -> UserSettings:
"""Load settings from disk, creating defaults if needed.
Returns:
The loaded or default UserSettings
"""
if self._settings is not None:
return self._settings
if self._settings_file.exists():
try:
data = json.loads(self._settings_file.read_text())
self._settings = UserSettings.model_validate(data)
except (json.JSONDecodeError, ValueError):
# Invalid JSON or schema, use defaults
self._settings = UserSettings()
else:
self._settings = UserSettings()
return self._settings
def save(self) -> None:
"""Save current settings to disk."""
if self._settings is None:
return
# Ensure directory exists
self._settings_dir.mkdir(parents=True, exist_ok=True)
# Write settings as formatted JSON
data = self._settings.model_dump(mode="json")
self._settings_file.write_text(
json.dumps(data, indent=2, sort_keys=True) + "\n"
)
def get(self, key: str, default: Any = None) -> Any:
"""Get a setting value.
Args:
key: The setting key (attribute name)
default: Default value if key doesn't exist
Returns:
The setting value or default
"""
settings = self.load()
return getattr(settings, key, default)
def set(self, key: str, value: Any, *, save: bool = True) -> None:
"""Set a setting value.
Args:
key: The setting key (attribute name)
value: The value to set
save: Whether to save immediately (default: True)
"""
settings = self.load()
if hasattr(settings, key):
# Create new settings with updated value
data = settings.model_dump()
data[key] = value
self._settings = UserSettings.model_validate(data)
if save:
self.save()
def update(self, **kwargs: Any) -> None:
"""Update multiple settings at once.
Args:
**kwargs: Key-value pairs to update
"""
settings = self.load()
data = settings.model_dump()
for key, value in kwargs.items():
if hasattr(settings, key):
data[key] = value
self._settings = UserSettings.model_validate(data)
self.save()
def reset(self) -> None:
"""Reset settings to defaults."""
self._settings = UserSettings()
self.save()
# Global instance for convenience
_settings_service: SettingsService | None = None
def get_settings_service() -> SettingsService:
"""Get the global settings service instance."""
global _settings_service
if _settings_service is None:
_settings_service = SettingsService()
return _settings_service
@@ -1,11 +1,11 @@
"""Theme system for Clide."""
from clide.themes.registry import (
get_theme,
DEFAULT_THEME,
get_all_themes,
get_theme,
get_themes_by_category,
register_theme,
DEFAULT_THEME,
)
__all__ = [
@@ -1,28 +1,28 @@
"""Built-in themes for Clide."""
from clide.themes.builtin import (
summer_night,
summer_day,
one_dark,
one_dark_pro,
one_light,
all_hallows_eve,
christmas,
dark_autumn,
dracula,
nord,
fall,
gamma,
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,
halloween,
houston,
monokai_winter,
nord,
one_dark,
one_dark_pro,
one_dark_teal,
one_light,
pro_hacker,
santa_baby,
summer_day,
summer_night,
winter_is_coming,
)
__all__ = [
@@ -1,8 +1,7 @@
"""Custom theme loader for user-defined themes."""
from pathlib import Path
import tomllib
from pathlib import Path
from clide.models.theme import ThemeColors, ThemeDefinition
from clide.themes.registry import register_theme
@@ -1,6 +1,5 @@
"""Theme registry for managing available themes."""
from typing import Callable
from clide.models.theme import ThemeDefinition, ThemeMetadata
@@ -71,28 +70,28 @@ 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,
all_hallows_eve,
christmas,
dark_autumn,
dracula,
nord,
fall,
gamma,
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,
halloween,
houston,
monokai_winter,
nord,
one_dark,
one_dark_pro,
one_dark_teal,
one_light,
pro_hacker,
santa_baby,
summer_day,
summer_night,
winter_is_coming,
)
# Core themes
@@ -146,7 +146,7 @@ class BranchStatus(Vertical):
"""Handle click on status bar."""
self.toggle_popout()
def on_button_pressed(self, event: Button.Pressed) -> None:
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)
@@ -5,7 +5,7 @@ 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
from clide.models.diff import ChangeType, DiffContent
class DiffPane(Vertical):
@@ -137,7 +137,7 @@ class DiffPane(Vertical):
header = self.query_one(".diff-header", Static)
header.update("No diff loaded")
def on_button_pressed(self, event: Button.Pressed) -> None:
def on_button_pressed(self, _event: Button.Pressed) -> None:
"""Handle button presses."""
if not self._diff:
return
@@ -5,7 +5,7 @@ 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 textual.widgets import Static, TextArea
from clide.models.editor import CursorPosition, FileBuffer
@@ -92,7 +92,7 @@ class EditorPane(Vertical):
yield TextArea(id="editor-textarea", show_line_numbers=True)
yield Static("", classes="editor-status", id="editor-status")
def load_buffer(self, buffer: FileBuffer) -> None:
def load_buffer(self, buffer: FileBuffer) -> None: # noqa: ARG002
"""Load a file buffer into the editor."""
self._buffer = buffer
@@ -1,9 +1,9 @@
"""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 textual.message import Message
from textual.widgets import RichLog
from clide.models.git import GitCommit
@@ -3,11 +3,11 @@
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 textual.message import Message
from textual.widgets import ListItem, ListView, Static
from clide.models.problems import Problem, Severity
from clide.models.problems import Problem
class ProblemItem(ListItem):
@@ -1,11 +1,10 @@
"""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 textual.message import Message
from textual.widgets import ListItem, ListView, Static
from clide.models.todos import TodoItem, TodoType
@@ -9,6 +9,7 @@ import pty
import shutil
import struct
import termios
import time
from pathlib import Path
from typing import TYPE_CHECKING
@@ -49,6 +50,9 @@ class TerminalDisplay(Widget, can_focus=True):
self._master_fd: int | None = None
self._pid: int | None = None
self._read_task: asyncio.Task | None = None
self._refresh_task: asyncio.Task | None = None
self._needs_refresh: bool = False
self._last_refresh: float = 0
def on_resize(self, event) -> None:
"""Handle terminal resize."""
@@ -99,17 +103,35 @@ class TerminalDisplay(Widget, can_focus=True):
# Start reading
self._read_task = asyncio.create_task(self._read_output())
def _schedule_refresh(self) -> None:
"""Schedule a throttled refresh to avoid glitching."""
self._needs_refresh = True
if self._refresh_task is None or self._refresh_task.done():
self._refresh_task = asyncio.create_task(self._throttled_refresh())
async def _throttled_refresh(self) -> None:
"""Refresh at most every 16ms (~60fps) to avoid glitching."""
min_interval = 0.016 # ~60fps
while self._needs_refresh:
now = time.monotonic()
elapsed = now - self._last_refresh
if elapsed < min_interval:
await asyncio.sleep(min_interval - elapsed)
self._needs_refresh = False
self._last_refresh = time.monotonic()
self.refresh()
# Small delay to batch rapid updates
await asyncio.sleep(0.008)
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)
await asyncio.sleep(0.005)
try:
data = os.read(self._master_fd, 65536)
@@ -118,7 +140,7 @@ class TerminalDisplay(Widget, can_focus=True):
# Feed data to pyte
self._stream.feed(data.decode("utf-8", errors="replace"))
self.refresh()
self._schedule_refresh()
except BlockingIOError:
# No data available
@@ -136,6 +158,10 @@ class TerminalDisplay(Widget, can_focus=True):
self._read_task.cancel()
self._read_task = None
if self._refresh_task:
self._refresh_task.cancel()
self._refresh_task = None
if self._master_fd is not None:
try:
os.close(self._master_fd)
@@ -1,6 +1,5 @@
"""Context panel with Problems, TODOs, and Jira tabs."""
from pathlib import Path
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
@@ -6,7 +6,7 @@ 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 textual.widgets import TabbedContent, TabPane
from clide.widgets.components.branch_status import BranchStatus
from clide.widgets.components.files_view import FilesView
@@ -75,14 +75,13 @@ class SidebarPanel(Vertical):
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()
with Container(classes="sidebar-content"), 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:
+3 -3
View File
@@ -54,7 +54,7 @@ clide = "clide.cli:app"
# example = "clide.extensions.builtin.example:ExampleExtension"
[tool.hatch.build.targets.wheel]
packages = ["src/clide"]
packages = ["clide"]
[tool.pytest.ini_options]
testpaths = ["tests"]
@@ -75,7 +75,7 @@ exclude = ["tests", "dist", "build"]
[tool.ruff]
target-version = "py312"
line-length = 100
src = ["src", "tests"]
src = ["clide", "tests"]
[tool.ruff.lint]
select = [
@@ -100,7 +100,7 @@ ignore = [
known-first-party = ["clide"]
[tool.coverage.run]
source = ["src/clide"]
source = ["clide"]
branch = true
[tool.coverage.report]
+1 -2
View File
@@ -1,8 +1,7 @@
"""Shared pytest fixtures for Clide tests."""
from collections.abc import AsyncGenerator, Generator
from pathlib import Path
from typing import AsyncGenerator, Generator
from unittest.mock import MagicMock
import pytest
from textual.pilot import Pilot
+4 -5
View File
@@ -1,7 +1,6 @@
"""Full application test harness for Clide."""
from pathlib import Path
from typing import Optional
from textual.pilot import Pilot
@@ -26,14 +25,14 @@ class AppHarness:
def __init__(
self,
workdir: Path,
settings: Optional[ClideSettings] = None,
extension_manager: Optional[ExtensionManager] = None,
settings: ClideSettings | None = None,
extension_manager: ExtensionManager | None = 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
self._app: ClideApp | None = None
self._pilot: Pilot | None = None
async def start(self) -> tuple[ClideApp, Pilot]:
"""Start the application and return app and pilot for testing.
+4 -4
View File
@@ -1,6 +1,6 @@
"""Controller isolation test harness for Clide."""
from typing import TYPE_CHECKING, Any, Optional
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, MagicMock
from textual.message import Message
@@ -19,7 +19,7 @@ class MockApp:
def _capture_message(self, message: Message) -> None:
self.messages.append(message)
def get_messages(self, message_type: Optional[type] = None) -> list[Message]:
def get_messages(self, message_type: type | None = None) -> list[Message]:
"""Get captured messages, optionally filtered by type."""
if message_type is None:
return self.messages.copy()
@@ -46,7 +46,7 @@ class ControllerHarness:
def __init__(self) -> None:
self._mock_app = MockApp()
self._controllers: list["BaseController"] = []
self._controllers: list[BaseController] = []
self._mocks: dict[str, Any] = {}
@property
@@ -68,7 +68,7 @@ class ControllerHarness:
for controller in self._controllers:
await controller.shutdown()
def get_messages(self, message_type: Optional[type] = None) -> list[Message]:
def get_messages(self, message_type: type | None = None) -> list[Message]:
"""Get messages posted to the mock app."""
return self._mock_app.get_messages(message_type)
-1
View File
@@ -4,7 +4,6 @@ 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
-3
View File
@@ -1,9 +1,6 @@
"""Tests for ClideApp."""
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from clide.models.config import ClideSettings
+5 -7
View File
@@ -1,7 +1,7 @@
"""Tests for controller classes."""
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import patch
import pytest
@@ -11,13 +11,11 @@ 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.diff import DiffContent, DiffHunk
from clide.models.editor import 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
from clide.models.problems import Problem, Severity
from clide.models.todos import TodoItem, TodoType
class TestGitController:
+5 -5
View File
@@ -6,12 +6,12 @@ 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.diff import ChangeType, DiffContent, DiffHunk, DiffLine
from clide.models.editor import CursorPosition, EditorState, FileBuffer
from clide.models.git import ChangeStatus, GitBranch, GitChange, GitCommit, GitStatus
from clide.models.problems import Problem, ProblemsSummary, Severity
from clide.models.theme import ThemeColors, ThemeDefinition, ThemeMetadata
from clide.models.todos import TodoItem, TodosSummary, TodosState, TodoType
from clide.models.todos import TodoItem, TodosSummary, TodoType
class TestCursorPosition:
-5
View File
@@ -1,12 +1,7 @@
"""Tests for panel widgets."""
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from clide.models.diff import DiffContent
from clide.models.git import GitBranch, GitChange
from clide.models.problems import Problem, Severity
from clide.models.todos import TodoItem, TodoType
+2 -5
View File
@@ -1,14 +1,11 @@
"""Tests for service classes."""
import asyncio
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import patch
import pytest
from clide.models.git import GitBranch, GitChange, GitCommit, GitStatus
from clide.models.problems import Problem, Severity
from clide.models.todos import TodoItem, TodoType
from clide.models.todos import TodoType
from clide.services.file_service import FileService
from clide.services.git_service import GitService
from clide.services.linter_service import LinterService
+4 -6
View File
@@ -1,13 +1,11 @@
"""Tests for widget components."""
from pathlib import Path
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
import pytest
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
from clide.models.diff import DiffContent
from clide.models.editor import FileBuffer
from clide.models.git import ChangeStatus, GitChange, GitCommit
from clide.models.problems import Problem, Severity
from clide.models.todos import TodoItem, TodoType