Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Project custom commands #7

Merged
merged 8 commits into from
Oct 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading