Skip to content

Commit

Permalink
Project custom commands (#7)
Browse files Browse the repository at this point in the history
* Use basic project configuration file

* Refactor TabbedContents

* Upgrade Terminal Component

* Update design

* Add project custom command setup

* Init Sidebar

* Move composer customer script to sidebar and refactor composer action messages

* Setup project custom action group
  • Loading branch information
jeckel authored Oct 31, 2024
1 parent e5b9f38 commit 1f0f96a
Show file tree
Hide file tree
Showing 23 changed files with 446 additions and 218 deletions.
4 changes: 2 additions & 2 deletions src/main.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import typer
from dependency_injector import providers

from service_locator import Container
from service_locator import ServiceContainer
from models import Project
from presentation import MainApp

Expand All @@ -11,7 +11,7 @@
@app.command()
def tui(project_path: str) -> None:
project = Project.from_json(json_path=project_path)
Container()
ServiceContainer()
tui_app = MainApp(project)
tui_app.run()

Expand Down
9 changes: 7 additions & 2 deletions src/models/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@

from pydantic import BaseModel, Field, field_validator, model_validator

from models.composer import Composer

class ProjectAction(BaseModel):
label: str
command: str
help: Optional[str] = None
use_shell: bool = False


class Project(BaseModel):
Expand All @@ -14,7 +19,7 @@ class Project(BaseModel):
composer: Optional[bool] = Field(default=False)
composer_cmd: list[str] = ["composer"]
docker_composer_cmd: list[str] = ["docker", "compose"]
actions: dict[str, str] = {}
actions: Optional[dict[str, list[ProjectAction]]] = None

@classmethod
def from_json(cls, json_path: str):
Expand Down
46 changes: 44 additions & 2 deletions src/presentation/__init__.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,30 @@
from textual import on
from textual.app import App, ComposeResult
from textual.widgets import Footer, Header, TabbedContent, TabPane
from textual.containers import Container

from models import Project
from .component.message import TerminalCommandRequested

from .composer import ComposerContainer
from .composer import ComposerContainer, ComposerCommandRequested
from .docker import DockerContainer
from .summary import ProjectSummaryContainer
from .component import Sidebar, TerminalModal, NonShellCommand


class MainApp(App):
class MainApp(App[None]):
"""A Textual app to manage stopwatches."""

TITLE = "DX Companion"
BINDINGS = [
("d", "toggle_dark", "Toggle dark mode"),
("ctrl+s", "toggle_sidebar", "Toggle sidebar"),
]
DEFAULT_CSS = """
Screen {
layers: sidebar;
}
"""
CSS_PATH = "../tcss/layout.tcss"
_project: Project

Expand All @@ -24,6 +34,7 @@ def __init__(self, project: Project):
self.title = f"DX Companion - {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"):
Expand All @@ -33,3 +44,34 @@ def compose(self) -> ComposeResult:
with TabPane(title="Docker", id="docker-pan"):
yield DockerContainer(project=self._project)
yield Footer()

def action_toggle_sidebar(self) -> None:
self.query_one(Sidebar).toggle_class("-hidden")

@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()

self.query_one(Sidebar).add_class("-hidden")
self.app.push_screen(
TerminalModal(
command=NonShellCommand(
path=self._project.path,
command=event.command,
),
allow_rerun=event.allow_rerun,
),
refresh_composer,
)

@on(TerminalCommandRequested)
def action_terminal_command(self, event: TerminalCommandRequested) -> None:
self.query_one(Sidebar).add_class("-hidden")
self.app.push_screen(
TerminalModal(
command=event.command,
allow_rerun=event.allow_rerun,
)
)
3 changes: 2 additions & 1 deletion src/presentation/component/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
from .terminal_modal import TerminalModal
from .terminal import Terminal
from .terminal import Terminal, ShellCommand, NonShellCommand, CommandType
from .sidebar import Sidebar
37 changes: 37 additions & 0 deletions src/presentation/component/action_option_list.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from textual import on
from textual.widgets import OptionList
from textual.widgets.option_list import Option

from models import Project
from models.project import ProjectAction
from .terminal import ShellCommand, NonShellCommand, CommandType
from .message import TerminalCommandRequested


class ActionOptionList(OptionList):
# BORDER_TITLE = "Commands"

# def __init__(self, project: Project, **kwargs):
def __init__(
self,
project: Project,
actions: list[ProjectAction],
group_name: str = "Commands",
**kwargs
):
self._project: Project = project
self._actions: list[ProjectAction] = actions
super().__init__(*(Option(action.label) for action in self._actions), **kwargs)
self.border_title = group_name

@on(OptionList.OptionSelected)
def on_script_selected(self, event: OptionList.OptionSelected) -> None:
action = self._actions[event.option_index]
command: CommandType = (
ShellCommand(path=self._project.path, command=action.command)
if action.use_shell
else NonShellCommand(
path=self._project.path, command=action.command.split(" ")
)
)
self.post_message(TerminalCommandRequested(command=command))
14 changes: 14 additions & 0 deletions src/presentation/component/message.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from textual.message import Message

from .terminal import CommandType


class TerminalCommandRequested(Message):
def __init__(
self,
command: CommandType,
allow_rerun: bool = True,
):
self.command = command
self.allow_rerun = allow_rerun
super().__init__()
44 changes: 44 additions & 0 deletions src/presentation/component/sidebar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
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


class Sidebar(Container):
DEFAULT_CSS = """
Sidebar {
width: 30;
height: 100%;
dock: left;
background: $background;
layer: sidebar;
}
Sidebar.-hidden {
display: none;
}
"""

def __init__(self, project: Project, **kwargs):
self.project = 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:
return
for action_group, actions in self.project.actions.items():
if len(actions) > 0:
yield ActionOptionList(
project=self.project, actions=actions, group_name=action_group
)
# yield ActionOptionList(project=self.project)
56 changes: 42 additions & 14 deletions src/presentation/component/terminal.py
Original file line number Diff line number Diff line change
@@ -1,48 +1,75 @@
import subprocess
from time import sleep
from typing import Union, Optional

from dataclasses import dataclass
from textual.widgets import RichLog
from textual import on
from textual.message import Message
from textual.worker import Worker, WorkerState


@dataclass
class ShellCommand:
path: str
command: str
shell: bool = True

def __str__(self):
return self.command


@dataclass
class NonShellCommand:
path: str
command: list[str]
shell: bool = False

def __str__(self):
return " ".join(self.command)


CommandType = Union[ShellCommand, NonShellCommand]


class Terminal(RichLog):
DEFAULT_CSS = """
Terminal {
padding: 1 1;
}
"""
command: list[str] = []
command: Optional[CommandType] = None
current_worker: Worker | None = None

def __init__(self, **kwargs):
super().__init__(highlight=True, markup=True, **kwargs)

def execute(self, command: list[str], path: str) -> None:
self.command = command
def execute(self, command: CommandType) -> None:
# self.command = command
self.current_worker = self.run_worker(
self._execute(command, path), exclusive=True, thread=True
self._execute(command), exclusive=True, thread=True
)

def is_running(self) -> bool:
return self.current_worker is not None and self.current_worker.is_running

async def _execute(self, command: list[str], path: str) -> None:
async def _execute(self, command: CommandType) -> None:
self.command = command
self.post_message(self.TerminalStarted(self.command))
self.clear()
self.write(f"Path: [bold blue]{path}[/bold blue]")
self.write(f"Command: [bold blue]{" ".join(command)}[/bold blue]")
self.write(f"Path: [bold blue]{command.path}[/bold blue]")
self.write(f"Command: [bold blue]{command}[/bold blue]")
self.write(
"----------------------------------------------------------------",
shrink=True,
)
self.log(f"Running: {command} in {path}")
self.log(f"Running: {command.command} in {command.path}")
with subprocess.Popen(
command,
cwd=path,
command.command,
cwd=command.path,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
shell=command.shell,
text=True,
) as process:
assert process.stdout is not None
Expand All @@ -63,8 +90,9 @@ async def _execute(self, command: list[str], path: str) -> None:

@on(Worker.StateChanged)
async def worker_state_changed(self, event: Worker.StateChanged) -> None:
if event.state == WorkerState.RUNNING:
self.post_message(self.TerminalStarted(self.command))
if event.state == WorkerState.PENDING or event.state == WorkerState.RUNNING:
return
assert self.command is not None
if event.state == WorkerState.SUCCESS:
self.post_message(self.TerminalCompleted(self.command))
if event.state == WorkerState.CANCELLED or event.state == WorkerState.ERROR:
Expand All @@ -75,7 +103,7 @@ class TerminalStarted(Message):
Message sent when terminal execution starts
"""

def __init__(self, command: list[str]) -> None:
def __init__(self, command: CommandType) -> None:
self.command = command
super().__init__()

Expand All @@ -84,7 +112,7 @@ class TerminalCompleted(Message):
Message sent when terminal execution completes
"""

def __init__(self, command: list[str], success: bool = True) -> None:
def __init__(self, command: CommandType, success: bool = True) -> None:
self.command = command
self.success = success
super().__init__()
13 changes: 6 additions & 7 deletions src/presentation/component/terminal_modal.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from textual.screen import ModalScreen
from textual.widgets import Button, Static

from .terminal import Terminal
from .terminal import Terminal, CommandType


class TerminalModal(ModalScreen[bool]):
Expand Down Expand Up @@ -39,15 +39,13 @@ class TerminalModal(ModalScreen[bool]):

def __init__(
self,
command: list[str],
path: str,
command: CommandType,
allow_rerun: bool = False,
**kwargs,
):
super().__init__(**kwargs)
self.command = command
self.path = path
self.modal_title = f"Running: {" ".join(self.command)}"
self.modal_title = f"Running: {self.command}"
self.allow_rerun = allow_rerun
self.terminal = Terminal(
id="terminal_command",
Expand All @@ -66,18 +64,19 @@ def compose(self) -> ComposeResult:
yield Button.success(" Rerun", id="modal_rerun")

def on_mount(self) -> None:
self.terminal.execute(command=self.command, path=self.path)
self.terminal.execute(command=self.command)

@on(Button.Pressed, "#modal_close")
def on_close(self, event: Button.Pressed) -> None:
self.dismiss(self._result)

@on(Button.Pressed, "#modal_rerun")
def on_rerun(self, event: Button.Pressed) -> None:
self.terminal.execute(command=self.command, path=self.path)
self.terminal.execute(command=self.command)

@on(Terminal.TerminalCompleted)
def on_terminal_completed(self, event: Terminal.TerminalCompleted) -> None:
self._result = event.success
self.query_one("#modal_button_container").loading = False

@on(Terminal.TerminalStarted)
Expand Down
1 change: 1 addition & 0 deletions src/presentation/composer/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
from .composer_container import ComposerContainer
from .composer_message import ComposerCommandRequested
Loading

0 comments on commit 1f0f96a

Please sign in to comment.