diff --git a/src/main.py b/src/main.py index a3b3bf4..9786c3c 100644 --- a/src/main.py +++ b/src/main.py @@ -1,5 +1,4 @@ import typer -from dependency_injector import providers from service_locator import ServiceContainer from models import Project @@ -10,9 +9,9 @@ @app.command() def tui(project_path: str) -> None: - project = Project.from_json(json_path=project_path) ServiceContainer() - tui_app = MainApp(project) + ServiceContainer.context().project = Project.from_json(json_path=project_path) + tui_app = MainApp() tui_app.run() diff --git a/src/models/app_context.py b/src/models/app_context.py new file mode 100644 index 0000000..338b0d6 --- /dev/null +++ b/src/models/app_context.py @@ -0,0 +1,18 @@ +from typing import Optional + +from pydantic import BaseModel + +from models import Project + + +class AppContext(BaseModel): + project: Optional[Project] = None + + # Composer related context + composer_updatable_packages: Optional[dict[str, str]] = None + + @property + def current_project(self) -> Project: + if self.project is None: + raise RuntimeError("No project set in AppContext") + return self.project diff --git a/src/models/project.py b/src/models/project.py index 30796b9..ddbb673 100644 --- a/src/models/project.py +++ b/src/models/project.py @@ -2,6 +2,7 @@ import os from functools import cached_property from typing import Optional +from uuid import uuid4, UUID from pydantic import BaseModel, Field, field_validator, model_validator @@ -14,6 +15,7 @@ class ProjectAction(BaseModel): class Project(BaseModel): + id_: UUID = Field(default_factory=uuid4) path: str project_name: Optional[str] = None composer: Optional[bool] = Field(default=False) diff --git a/src/presentation/__init__.py b/src/presentation/__init__.py index b79b7c9..711154b 100644 --- a/src/presentation/__init__.py +++ b/src/presentation/__init__.py @@ -1,20 +1,20 @@ from textual import on -from textual.app import App, ComposeResult -from textual.widgets import Footer, Header, TabbedContent, TabPane -from textual.containers import Container +from textual.app import App +from textual.css.query import NoMatches from models import Project +from service_locator import ServiceContainer from .component.message import TerminalCommandRequested -from .composer import ComposerContainer, ComposerCommandRequested +from .composer import ComposerCommandRequested +from .composer.composer_screen import ComposerScreen from .docker import DockerContainer from .summary import ProjectSummaryContainer from .component import Sidebar, TerminalModal, NonShellCommand +from .summary.summary_screen import SummaryScreen class MainApp(App[None]): - """A Textual app to manage stopwatches.""" - TITLE = "DX Companion" BINDINGS = [ ("d", "toggle_dark", "Toggle dark mode"), @@ -26,33 +26,29 @@ class MainApp(App[None]): } """ CSS_PATH = "../tcss/layout.tcss" + SCREENS = {"summary": SummaryScreen, "composer": ComposerScreen} + _project: Project - def __init__(self, project: Project): - self._project = project + def __init__(self): super().__init__() - self.title = f"DX Companion - {project.name}" + self._project = ServiceContainer.context().current_project + self.title = f"DX Companion - {self._project.name}" - def compose(self) -> ComposeResult: - yield Sidebar(project=self._project, classes="-hidden") - yield Header() - with TabbedContent(initial="summary-pan"): - with TabPane(title="Summary", id="summary-pan"): - yield ProjectSummaryContainer(project=self._project) - with TabPane(title="Composer", id="composer-pan"): - yield ComposerContainer(project=self._project) - with TabPane(title="Docker", id="docker-pan"): - yield DockerContainer(project=self._project) - yield Footer() + def on_mount(self) -> None: + self.push_screen("summary") def action_toggle_sidebar(self) -> None: - self.query_one(Sidebar).toggle_class("-hidden") + try: + self.query_one(Sidebar).toggle_class("-hidden") + except NoMatches: + pass @on(ComposerCommandRequested) def action_composer_script(self, event: ComposerCommandRequested) -> None: def refresh_composer(result: bool | None): if event.refresh_composer_on_success and result: - self.query_one(ComposerContainer).action_refresh() + ServiceContainer.composer_client().reset_updatable_packages() self.query_one(Sidebar).add_class("-hidden") self.app.push_screen( diff --git a/src/presentation/component/sidebar.py b/src/presentation/component/sidebar.py index 4cf4f1b..f35eb94 100644 --- a/src/presentation/component/sidebar.py +++ b/src/presentation/component/sidebar.py @@ -1,10 +1,6 @@ from textual.app import ComposeResult from textual.containers import Container -from textual.widgets import OptionList -from textual.widgets.option_list import Option, Separator - -from models import Project from presentation.component.action_option_list import ActionOptionList from presentation.composer.composer_script_option_list import ComposerScriptOptionList from service_locator import ServiceContainer @@ -25,20 +21,20 @@ class Sidebar(Container): } """ - def __init__(self, project: Project, **kwargs): - self.project = project + def __init__(self, **kwargs): + self._project = ServiceContainer.context().current_project super().__init__(**kwargs) self.add_class("-hidden") def compose(self) -> ComposeResult: - if len(ServiceContainer.composer_client().scripts(self.project)) > 0: - yield ComposerScriptOptionList(self.project) - if self.project.actions is None: + if len(ServiceContainer.composer_client().scripts(self._project)) > 0: + yield ComposerScriptOptionList(self._project) + if self._project.actions is None: return - for action_group, actions in self.project.actions.items(): + for action_group, actions in self._project.actions.items(): if len(actions) > 0: yield ActionOptionList( - project=self.project, actions=actions, group_name=action_group + project=self._project, actions=actions, group_name=action_group ) # yield ActionOptionList(project=self.project) diff --git a/src/presentation/composer/composer_container.py b/src/presentation/composer/composer_container.py index 2b412ab..3ae6080 100644 --- a/src/presentation/composer/composer_container.py +++ b/src/presentation/composer/composer_container.py @@ -4,7 +4,6 @@ from textual.widgets import Button from textual.worker import Worker, WorkerState -from models import Project from models.composer import Composer from service_locator import ServiceContainer @@ -29,9 +28,9 @@ class ComposerContainer(Container): } """ - def __init__(self, project: Project, **kwargs): - self.project = project - self.composer = Composer.from_json(project.path) + def __init__(self, **kwargs): + self._project = ServiceContainer.context().current_project + self.composer = Composer.from_json(self._project.path) super().__init__(**kwargs) def compose(self) -> ComposeResult: @@ -58,19 +57,20 @@ def action_refresh(self) -> None: self._load_composer() async def on_mount(self): - self.action_refresh() + self.loading = True + self._load_composer() @work(exclusive=True, thread=True) async def _load_composer(self) -> dict[str, str]: - return ServiceContainer.composer_client().updatable_packages(self.project) + return ServiceContainer.composer_client().updatable_packages() @on(Worker.StateChanged) - async def refresh_listview(self, event: Worker.StateChanged) -> None: + async def refresh_packages(self, event: Worker.StateChanged) -> None: """Called when the worker state changes.""" if event.state != WorkerState.SUCCESS: return packages_updatable = event.worker.result - composer = ServiceContainer.composer_client().composer_json(self.project) + composer = ServiceContainer.composer_client().composer_json(self._project) package_table: ComposerPackagesTable = self.query_one( "#composer-packages-table" ) diff --git a/src/presentation/composer/composer_screen.py b/src/presentation/composer/composer_screen.py new file mode 100644 index 0000000..6469460 --- /dev/null +++ b/src/presentation/composer/composer_screen.py @@ -0,0 +1,27 @@ +from textual import on +from textual.app import ComposeResult +from textual.events import ScreenResume +from textual.screen import Screen +from textual.widgets import Header, Footer + +from .composer_container import ComposerContainer +from presentation.component.sidebar import Sidebar + + +class ComposerScreen(Screen): + BINDINGS = { + ("escape", "return", "Return to project"), + } + + def compose(self) -> ComposeResult: + yield Sidebar(classes="-hidden") + yield Header() + yield ComposerContainer() + yield Footer() + + def action_return(self): + self.app.switch_screen("summary") + + @on(ScreenResume) + def screen_resume(self): + self.query_one(ComposerContainer).action_refresh() diff --git a/src/presentation/summary/__init__.py b/src/presentation/summary/__init__.py index c110997..b243151 100644 --- a/src/presentation/summary/__init__.py +++ b/src/presentation/summary/__init__.py @@ -1,30 +1 @@ -from textual.containers import Container -from textual.widgets import Markdown - -from models import Project - - -# service: Service = Provide[Container.service] -class ProjectSummaryContainer(Container): - BORDER_TITLE = "Project's summary" - DEFAULT_CSS = """ - ProjectSummaryContainer { - border-title-color: $accent; - border: $primary-background round; - content-align: center middle; - Markdown { - height: auto; - } - } - """ - - def __init__(self, project: Project, **kwargs): - self.project = project - super().__init__(**kwargs) - - def compose(self): - yield Markdown( - f""" -# Project : {self.project.project_name} -""" - ) +from .summary_container import ProjectSummaryContainer diff --git a/src/presentation/summary/composer_card.py b/src/presentation/summary/composer_card.py new file mode 100644 index 0000000..361a488 --- /dev/null +++ b/src/presentation/summary/composer_card.py @@ -0,0 +1,112 @@ +from typing import Optional + +from rich.style import Style +from rich.table import Table +from textual import work, on +from textual.app import ComposeResult +from textual.containers import Container +from textual.widgets import Static, Button +from textual.worker import Worker, WorkerState + +from models.composer import Composer +from presentation.composer.composer_screen import ComposerScreen +from service_locator import ServiceContainer + + +class ComposerCard(Container): + DEFAULT_CSS = """ + ComposerCard { + height: auto; + width: 45; + border: $primary-background round; + + Button, Button:focus, Button:hover { + height: 1; + border: none; + width: 100%; + margin-top: 1; + } + } + """ + + _composer_config: Optional[Composer] = None + _packages_updatable: dict[str, str] = {} + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._project = ServiceContainer.context().current_project + self._composer_panel = Static(id="composer_panel") + + def compose(self) -> ComposeResult: + yield self._composer_panel + yield Button("[underline]Manage packages", id="toggle_composer_tab") + + def on_mount(self) -> None: + self._composer_config = ServiceContainer.composer_client().composer_json( + self._project + ) + self._composer_panel.update(self.get_composer_panel()) + self.query_one(Button).loading = True + self._load_composer() + + def get_composer_panel(self) -> Table: + table = Table( + show_header=False, + box=None, + title="Composer status", + title_style=Style(color="#bbc8e8", bold=True), + ) + table.add_column() + table.add_column(min_width=25, max_width=27) + table.add_row( + "[label]Composer:", + "[green]Enabled" if self._project.composer else "[red]Disabled", + ) + + if self._project.composer and self._composer_config is not None: + updatable_packages_keys = self._packages_updatable.keys() + updatable_packages = len( + set(updatable_packages_keys) & set(self._packages_updatable.keys()) + ) + if updatable_packages > 0: + table.add_row( + "[label]Packages:", + f"{len(self._composer_config.required_packages)} ([orange1]{updatable_packages} updates available[/orange1])", + ) + else: + table.add_row( + "[label]Packages:", + f"{len(self._composer_config.required_packages)}", + ) + + updatable_packages_dev = len( + set(updatable_packages_keys) & set(self._packages_updatable.keys()) + ) + if updatable_packages_dev > 0: + table.add_row( + "[label]Packages-dev:", + f"{len(self._composer_config.required_packages_dev)} " + f"([orange1]{updatable_packages_dev} updates available[/orange1])", + ) + else: + table.add_row( + "[label]Packages-dev:", + f"{len(self._composer_config.required_packages_dev)}", + ) + return table + + @work(exclusive=True, thread=True) + async def _load_composer(self, no_cache: bool = False) -> dict[str, str]: + return ServiceContainer.composer_client().updatable_packages() + + @on(Worker.StateChanged) + async def refresh_listview(self, event: Worker.StateChanged) -> None: + if event.state != WorkerState.SUCCESS: + return + self._packages_updatable = event.worker.result + self._composer_panel.update(self.get_composer_panel()) + self.query_one(Button).loading = False + + @on(Button.Pressed, "#toggle_composer_tab") + def on_composer_manage(self): + self.app.push_screen(ComposerScreen()) diff --git a/src/presentation/summary/summary_container.py b/src/presentation/summary/summary_container.py new file mode 100644 index 0000000..126970d --- /dev/null +++ b/src/presentation/summary/summary_container.py @@ -0,0 +1,34 @@ +from textual.containers import Container +from textual.widgets import Markdown + +from .composer_card import ComposerCard +from service_locator import ServiceContainer + + +class ProjectSummaryContainer(Container): + BORDER_TITLE = "Project's summary" + DEFAULT_CSS = """ + ProjectSummaryContainer { + border-title-color: $accent; + border: $primary-background round; + content-align: center middle; + Markdown { + height: auto; + } + } + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._project = ServiceContainer.context().current_project + + def compose(self): + yield Markdown( + f""" +# Project : {self._project.project_name} +""" + ) + yield ComposerCard() + + def refresh_composer(self): + self.query_one(ComposerCard).on_mount() diff --git a/src/presentation/summary/summary_screen.py b/src/presentation/summary/summary_screen.py new file mode 100644 index 0000000..114a228 --- /dev/null +++ b/src/presentation/summary/summary_screen.py @@ -0,0 +1,38 @@ +from textual import on +from textual.app import ComposeResult +from textual.events import ScreenResume +from textual.screen import Screen +from textual.widgets import Header, Footer, TabbedContent, TabPane + +from presentation import Sidebar, DockerContainer +from .summary_container import ProjectSummaryContainer +from service_locator import ServiceContainer + + +class SummaryScreen(Screen): + """ + Screen to display project summary + """ + + BINDINGS = { + ("c", "toggle_composer_screen", "Composer"), + } + + def compose(self) -> ComposeResult: + yield Sidebar(classes="-hidden") + yield Header() + with TabbedContent(initial="summary-pan"): + with TabPane(title="Summary", id="summary-pan"): + yield ProjectSummaryContainer() + with TabPane(title="Docker", id="docker-pan"): + yield DockerContainer( + project=ServiceContainer.context().current_project + ) + yield Footer() + + @on(ScreenResume) + def screen_resume(self): + self.query_one(ProjectSummaryContainer).refresh_composer() + + def action_toggle_composer_screen(self): + self.app.switch_screen("composer") diff --git a/src/service_locator.py b/src/service_locator.py index 2e056e3..6fec858 100644 --- a/src/service_locator.py +++ b/src/service_locator.py @@ -1,13 +1,14 @@ from dependency_injector import containers, providers -from models import Project +from models.app_context import AppContext from services import DockerClient, ComposerClient class ServiceContainer(containers.DeclarativeContainer): config = providers.Configuration() docker_client = providers.Singleton(DockerClient) - composer_client = providers.Singleton(ComposerClient) + context = providers.Singleton(AppContext) + composer_client = providers.Singleton(ComposerClient, context=context) # project = providers.Factory(Project) # api_client = providers.Singleton( diff --git a/src/services/composer_client.py b/src/services/composer_client.py index 0a11ffd..107dad8 100644 --- a/src/services/composer_client.py +++ b/src/services/composer_client.py @@ -1,15 +1,24 @@ +from __future__ import annotations import subprocess from models import Project +from models.app_context import AppContext from models.composer import Composer from .base_service import BaseService class ComposerClient(BaseService): - @staticmethod - def updatable_packages(project: Project) -> dict[str, str]: + def __init__(self, context: AppContext): + self._context = context + + def updatable_packages(self) -> dict[str, str]: + project = self._context.current_project + + if self._context.composer_updatable_packages is not None: + return self._context.composer_updatable_packages + with subprocess.Popen( - ["composer", "update", "--dry-run"], + ["composer", "update", "--dry-run", "--no-ansi"], cwd=project.path, stdout=subprocess.PIPE, stderr=subprocess.PIPE, @@ -22,21 +31,19 @@ def updatable_packages(project: Project) -> dict[str, str]: # Processing lines for packages for line in lines: if line.startswith(" - Upgrading"): - # Extract package name and target version parts = line.split("(") - package_name = line.strip().split(" ")[2] # Get the package name - version_info = ( - parts[1].strip().rstrip(")") - ) # Get the version info (v2.2.9 => v2.3.0) - target_version = version_info.split("=>")[ - -1 - ].strip() # Get the target version - - # Append to the packages list as a dictionary + package_name = line.strip().split(" ")[2] + version_info = parts[1].strip().rstrip(")") + target_version = version_info.split("=>")[-1].strip() packages[package_name] = target_version - return packages + self._context.composer_updatable_packages = packages + return self._context.composer_updatable_packages + + def reset_updatable_packages(self) -> None: + self._context.composer_updatable_packages = None - def composer_json(self, project: Project) -> None | Composer: + @staticmethod + def composer_json(project: Project) -> None | Composer: if not project.composer: return None return Composer.from_json(project.path)