diff --git a/.gitignore b/.gitignore index 0a19790..689f282 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # Byte-compiled / optimized / DLL files +.DS_Store __pycache__/ *.py[cod] *$py.class diff --git a/src/gitx/app.py b/src/gitx/app.py index 2e275f0..337ad2b 100644 --- a/src/gitx/app.py +++ b/src/gitx/app.py @@ -1,7 +1,15 @@ from textual.app import App, ComposeResult from textual.binding import Binding -from textual.containers import Container -from textual.widgets import Header, Footer, Static +from textual.containers import Container, Grid +from textual.widgets import Header, Footer + +from gitx.widgets.status_panel import StatusPanel +from gitx.widgets.file_tree import FileTree +from gitx.widgets.commit_log import CommitLog +from gitx.widgets.branches_panel import BranchesPanel +from gitx.widgets.command_panel import CommandPanel +from gitx.widgets.main_panel import MainPanel +from gitx.git.handler import GitHandler class GitxApp(App): @@ -11,27 +19,99 @@ class GitxApp(App): BINDINGS = [ Binding(key="q", action="quit", description="Quit"), - Binding(key="t", action="toggle_dark", description="Toggle dark mode"), + Binding(key="t", action="toggle_theme", description="Toggle theme"), + Binding(key="s", action="stage_file", description="Stage file"), + Binding(key="u", action="unstage_file", description="Unstage file"), + Binding(key="c", action="commit", description="Commit"), + Binding(key="p", action="push", description="Push"), + Binding(key="f", action="pull", description="Pull (fetch)"), + Binding(key="b", action="new_branch", description="New branch"), + Binding(key="?", action="toggle_help", description="Help"), + Binding(key="^p", action="palette", description="Command palette"), ] + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.git = GitHandler() + def compose(self) -> ComposeResult: - """Compose the app layout using the welcome screen.""" - self.theme = "flexoki" # Default theme + """Compose the app layout.""" yield Header(show_clock=True) - yield Container( - Static("Welcome to gitx!", classes="welcome-title"), - Static("A Terminal User Interface for Git", classes="welcome-text"), - Static("Press 't' to toggle theme or 'q' to quit", classes="welcome-text"), - classes="welcome" + + # Left side - Status and Files + yield Grid( + # Left column - Status and file changes + Container( + StatusPanel(), + FileTree(), + BranchesPanel(), + id="left-panels" + ), + + # Right side - Main content area and logs + Container( + # Top right - Commit log + CommitLog(), + # Bottom right - Main content + MainPanel(), + id="right-panels" + ), + + # Command panel at bottom + Container( + CommandPanel(), + id="bottom-panel" + ), + + id="app-layout" ) + yield Footer() - def action_toggle_dark(self) -> None: - """Toggle between light and dark theme.""" + def on_mount(self) -> None: + """Initial setup when app is mounted.""" + self.title = "GitXApp" + self.dark = True + + # Show welcome message in main panel + main_panel = self.query_one(MainPanel) + main_panel.show_welcome() + + def action_toggle_theme(self) -> None: + """Toggle dark mode.""" self.theme = ( - "flexoki" if self.theme == "catppuccin-latte" else "catppuccin-latte" + "textual-dark" if self.theme == "textual-light" else "textual-light" ) + def action_stage_file(self) -> None: + """Stage the selected file.""" + self.notify("Action: Stage file (not implemented yet)") + + def action_unstage_file(self) -> None: + """Unstage the selected file.""" + self.notify("Action: Unstage file (not implemented yet)") + + def action_commit(self) -> None: + """Commit staged changes.""" + self.notify("Action: Commit (not implemented yet)") + + def action_push(self) -> None: + """Push changes to remote.""" + self.notify("Action: Push (not implemented yet)") + + def action_pull(self) -> None: + """Pull changes from remote.""" + self.notify("Action: Pull (not implemented yet)") + + def action_new_branch(self) -> None: + """Create a new branch.""" + self.notify("Action: New branch (not implemented yet)") + + def action_toggle_help(self) -> None: + """Toggle help screen.""" + self.notify("Action: Help (not implemented yet)") + + def main() -> None: """Run the app.""" app = GitxApp() diff --git a/src/gitx/css/app.tcss b/src/gitx/css/app.tcss index 27b8f3c..4847b4c 100644 --- a/src/gitx/css/app.tcss +++ b/src/gitx/css/app.tcss @@ -1,39 +1,179 @@ -/* Base application styles */ +/* Base application styles inspired by LazyGit */ Screen { - background: $surface; - color: $text; - layout: vertical; + background: #0d1117; + color: #c9d1d9; } Header { dock: top; height: 1; + background: #161b22; + color: #c9d1d9; } Footer { dock: bottom; height: 1; + background: #161b22; + color: #58a6ff; } -/* Common styles that apply to the whole app */ -.welcome { - width: 100%; +/* Grid layout */ +#app-layout { + layout: grid; + grid-size: 2; + grid-rows: 1fr 3fr 1fr 1fr; + grid-columns: 1fr 2fr; +} + +#left-panels { + row-span: 4; + column-span: 1; + layout: vertical; + background: #0d1117; + border-right: solid #30363d; +} + +#right-panels { + row-span: 4; + column-span: 1; + layout: vertical; + background: #0d1117; +} + +#bottom-panel { + row-span: 1; + column-span: 2; height: 100%; - align: center middle; - background: $surface; + background: #0d1117; + border-top: solid #30363d; } -.welcome-title { +/* Panel styles */ +.panel { + border: none; + padding: 0 0 0 0; + height: 1fr; + overflow: auto; +} + +.section-title { text-style: bold; - text-align: center; - margin-bottom: 1; - color: $accent; + background: #21262d; + color: #89b4fa; + padding: 0 1; width: 100%; + text-align: left; height: 1; } -.welcome-text { - text-align: center; - margin-bottom: 1; +/* Status panel */ +#status-panel { + height: auto; + margin-bottom: 0; + border-bottom: solid #30363d; +} + +.status-row { + height: 1; + margin-bottom: 0; + padding: 0 1; +} + +.status-label { + width: 30%; + text-style: bold; + color: #c9d1d9; +} + +.status-value { + width: 70%; + color: #7ee787; +} + +/* Command panel */ +#command-panel { + width: 100%; + height: 100%; +} + +#command-row { width: 100%; + height: auto; + background: #21262d; +} + +#command-input { + width: 4fr; + background: #0d1117; + color: #c9d1d9; + border: none; + height: 1; + padding: 0 1; + margin: 0; +} + +#run-command-btn { + width: 1fr; + background: #388bfd; + color: #ffffff; + height: 1; + margin: 0; +} + +#command-output { + width: 100%; + height: 1fr; + background: #0d1117; + color: #8b949e; + padding: 1; +} + +/* Trees and tables */ +Tree { + padding: 0; +} + +Tree > .tree--cursor { + background: #21262d; + color: #e6edf3; +} + +Tree > .tree--highlight { + background: #388bfd; + color: #ffffff; +} + +/* Rich log styling */ +RichLog { + background: #0d1117; + color: #c9d1d9; + border: none; + padding: 0 1; +} + +Button { + background: #21262d; + color: #c9d1d9; + border: none; +} + +Button:hover { + background: #30363d; +} + +Button.active { + background: #388bfd; + color: #ffffff; +} + +/* Input field styling */ +Input { + background: #0d1117; + color: #c9d1d9; + border: solid #30363d; +} + +Input:focus { + border: solid #388bfd; } \ No newline at end of file diff --git a/src/gitx/git/handler.py b/src/gitx/git/handler.py new file mode 100644 index 0000000..e69de29 diff --git a/src/gitx/widgets/branches_panel.py b/src/gitx/widgets/branches_panel.py new file mode 100644 index 0000000..8431104 --- /dev/null +++ b/src/gitx/widgets/branches_panel.py @@ -0,0 +1,48 @@ +from textual.widgets import Static, Tree +from textual.app import ComposeResult +from textual.containers import Vertical +from textual.widgets import Label +from rich.text import Text + + +class BranchesPanel(Static): + """Panel that displays and manages branches.""" + + def compose(self) -> ComposeResult: + """Compose the branches panel.""" + yield Vertical( + Label("[bold]3-Local branches[/bold]", classes="section-title"), + Tree("Branches", id="branches-tree"), + classes="panel" + ) + + def on_mount(self) -> None: + """Set up the tree when mounted.""" + tree = self.query_one(Tree) + + # Add local branches + current_branch = "feat/init-git-commands" + branches = [ + "master", + "feat/init-git-commands", + "feat/init-gitx-app", + "doc/tutorial-update", + "feat/lint-ci", + "origin/feat/lint-ci", + "feat/ghactions-docs", + "feat/mkdocs-integrate" + ] + + # Add branches to tree with proper styling + for branch in branches: + node = tree.root.add_leaf(branch) + if branch == current_branch: + # Current branch in green with check mark + node.label.stylize("green bold") + node.label = Text("✓ ") + node.label + elif branch.startswith("origin/"): + # Remote branches in blue + node.label.stylize("blue") + + # Expand the tree by default + tree.root.expand() diff --git a/src/gitx/widgets/command_panel.py b/src/gitx/widgets/command_panel.py new file mode 100644 index 0000000..cc32b52 --- /dev/null +++ b/src/gitx/widgets/command_panel.py @@ -0,0 +1,51 @@ +from textual.widgets import Static, Input, Button +from textual.app import ComposeResult +from textual.containers import Vertical, Horizontal +from textual.widgets import Label +from rich.text import Text + + +class CommandPanel(Static): + """Panel for executing custom Git commands.""" + + def compose(self) -> ComposeResult: + """Compose the command panel.""" + yield Vertical( + Label("[bold]5-Command log[/bold]", classes="section-title"), + Horizontal( + Input(placeholder="Enter git command...", id="command-input"), + Button("Run", id="run-command-btn", variant="primary"), + id="command-row" + ), + Static("You can hide/focus this panel by pressing '@'", id="command-output"), + id="command-panel", + classes="panel" + ) + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle button press event.""" + if event.button.id == "run-command-btn": + command_input = self.query_one("#command-input", Input) + command = command_input.value + + if command.strip(): + output = self.query_one("#command-output", Static) + output_text = Text(f"$ git {command}\n") + output_text.stylize("yellow") + + # Add dummy response + response = Text("Executing command...\n") + response.stylize("green") + + output.update(output_text + response) + + # Clear the input field after execution + command_input.value = "" + + # Focus back on input for next command + self.app.set_focus(command_input) + + # Notify user about command execution + self.app.notify(f"Executed: git {command}") + else: + self.app.notify("Please enter a command", severity="warning") diff --git a/src/gitx/widgets/commit_log.py b/src/gitx/widgets/commit_log.py new file mode 100644 index 0000000..67666b1 --- /dev/null +++ b/src/gitx/widgets/commit_log.py @@ -0,0 +1,74 @@ +from textual.widgets import Static, RichLog +from textual.app import ComposeResult +from textual.containers import Vertical +from textual.widgets import Label + + +class CommitLog(Static): + """Widget to display commit history.""" + + def compose(self) -> ComposeResult: + """Compose the commit log.""" + yield Vertical( + Label("[bold]2-Log[/bold]", classes="section-title"), + RichLog(id="commit-log", wrap=False, highlight=True, markup=True), + id="commit-log-panel", + classes="panel" + ) + + def on_mount(self) -> None: + """Set up the commit log when mounted.""" + log = self.query_one(RichLog) + + # Make sure we start with an empty log + log.clear() + + # Add dummy data that mimics LazyGit commit log styling + log.write("[green]✱[/green] [yellow]commit[/yellow] [green]a215ea6[/green] ([red]HEAD → feat/init-git-commands[/red], [red]feat/init-widgets[/red])") + log.write("│ [blue]Author:[/blue] Ayush ") + log.write("│ [blue]Date:[/blue] 3 minutes ago") + log.write("│ ") + log.write("│ widgets cn 1") + log.write("│ ") + log.write("│ [blue]Signed-off-by:[/blue] Ayush ") + log.write("│") + log.write("[green]✱[/green] [yellow]commit[/yellow] [green]71d3e14[/green] ([red]origin/master[/red], [red]origin/HEAD[/red], [red]master[/red])") + log.write("│\\ [blue]Merge:[/blue] 35d7366 6b16804") + log.write("│ │ [blue]Author:[/blue] Anmol ") + log.write("│ │ [blue]Date:[/blue] 4 days ago") + log.write("│ │ ") + log.write("│ │ Merge pull request #12 from gitxtui/feat/init-gitx-app") + log.write("│ │ ") + log.write("│ │ feat+doc: init base app, change doc site styling") + log.write("│ │") + + # Add more commits to simulate a longer history + self._add_more_sample_commits(log) + + def _add_more_sample_commits(self, log): + """Add more sample commits to fill the log.""" + log.write("[green]✱[/green] [yellow]commit[/yellow] [green]6b16804[/green] ([red]origin/feat/init-gitx-app[/red], [red]feat/init-gitx-app[/red])") + log.write("│ [blue]Author:[/blue] Ayush ") + log.write("│ [blue]Date:[/blue] 4 days ago") + log.write("│ ") + log.write("│ feat+doc: init base app, change doc site styling") + log.write("│ ") + log.write("│ [blue]Signed-off-by:[/blue] Ayush ") + log.write("│") + log.write("[green]✱[/green] [yellow]commit[/yellow] [green]35d7366[/green]") + log.write("│\\ [blue]Merge:[/blue] 5796a72 86f8adc") + log.write("│ │ [blue]Author:[/blue] Ashmit9955 ") + log.write("│ │ [blue]Date:[/blue] 7 days ago") + log.write("│ │ ") + log.write("│ │ Merge pull request #10 from gitxtui/doc/tutorial-update") + log.write("│ │ ") + log.write("│ │ Doc/tutorial update") + + def update_log(self, commits): + """Update the commit log with real commit data.""" + log = self.query_one(RichLog) + log.clear() + + # This would be implemented to display actual commit data + # For now we'll just display the dummy data + self.on_mount() diff --git a/src/gitx/widgets/file_tree.py b/src/gitx/widgets/file_tree.py new file mode 100644 index 0000000..00791a8 --- /dev/null +++ b/src/gitx/widgets/file_tree.py @@ -0,0 +1,58 @@ +from textual.widgets import Tree, Static +from textual.app import ComposeResult +from textual.containers import Vertical +from textual.widgets import Label + + +class FileTree(Static): + """Tree view for displaying unstaged/untracked files.""" + + def compose(self) -> ComposeResult: + """Compose the file tree.""" + yield Vertical( + Label("[bold]2-Files[/bold]", classes="section-title"), + Tree("Files", id="file-tree"), + classes="panel" + ) + + def on_mount(self) -> None: + """Set up the tree when mounted.""" + tree = self.query_one(Tree) + + # Add sections for different file statuses with LazyGit-like styling + unstaged = tree.root.add("Unstaged Changes", expand=True) + # Add files to the unstaged section + for file in ["app.py", "README.md"]: + node = unstaged.add_leaf(file) + node.data = {"status": "modified"} + + untracked = tree.root.add("Untracked Files", expand=True) + # Add files to the untracked section + for file in ["file1.txt", "file2.py"]: + node = untracked.add_leaf(file) + node.data = {"status": "untracked"} + + staged = tree.root.add("Staged Changes", expand=True) + # Add files to the staged section + node = staged.add_leaf("utils.py") + node.data = {"status": "staged"} + + # Add styling to tree nodes + self.apply_tree_styling() + + def apply_tree_styling(self) -> None: + """Apply appropriate CSS classes to tree nodes based on their status.""" + tree = self.query_one(Tree) + # Walk all children and manually skip the root node + for node in tree.walk_children(): + # Skip the root node + if node is tree.root: + continue + + if hasattr(node, 'data') and node.data: + if node.data.get("status") == "modified": + node.label.stylize("red") + elif node.data.get("status") == "untracked": + node.label.stylize("magenta") + elif node.data.get("status") == "staged": + node.label.stylize("green") diff --git a/src/gitx/widgets/main_panel.py b/src/gitx/widgets/main_panel.py new file mode 100644 index 0000000..adb6f52 --- /dev/null +++ b/src/gitx/widgets/main_panel.py @@ -0,0 +1,93 @@ +from textual.widgets import Static, RichLog +from textual.app import ComposeResult +from textual.containers import Vertical +from textual.widgets import Label + + +class MainPanel(Static): + """Main panel that changes based on context.""" + + def compose(self) -> ComposeResult: + """Compose the main panel.""" + yield Vertical( + Label("[bold]4-Main[/bold]", classes="section-title"), + RichLog( + markup=True, + highlight=True, + id="main-content", + ), + id="main-panel", + classes="panel" + ) + + def on_mount(self) -> None: + """Set up the main panel when mounted.""" + content = self.query_one("#main-content", RichLog) + content.clear() + content.write("Select a file to view its contents or a commit to view its details.") + + def show_file_diff(self, file_path: str) -> None: + """Show the diff content of a file.""" + content = self.query_one("#main-content", RichLog) + content.clear() + + # Update the title + self.query_one(".section-title", Label).update(f"[bold]4-Diff: {file_path}[/bold]") + + # Add some dummy diff content + content.write("[green]diff --git a/app.py b/app.py[/green]") + content.write("[green]index 1234567..abcdefg 100644[/green]") + content.write("[green]--- a/app.py[/green]") + content.write("[green]+++ b/app.py[/green]") + content.write("[cyan]@@ -20,7 +20,7 @@[/cyan] def some_function():") + content.write(" # Some comment") + content.write(" print('Hello')") + content.write("[red]- return False[/red]") + content.write("[green]+ return True[/green]") + content.write(" ") + content.write(" # Another comment") + content.write("[cyan]@@ -35,6 +35,9 @@[/cyan] def another_function():") + content.write(" # Process data") + content.write("[green]+ # New functionality[/green]") + content.write("[green]+ result = process_data()[/green]") + content.write("[green]+ return result[/green]") + + def show_commit_details(self, commit_hash: str) -> None: + """Show the details of a commit.""" + content = self.query_one("#main-content", RichLog) + content.clear() + + # Update the title + self.query_one(".section-title", Label).update(f"[bold]4-Commit: {commit_hash}[/bold]") + + # Add commit details + content.write(f"[yellow]commit[/yellow] [green]{commit_hash}[/green]") + content.write("[blue]Author:[/blue] Ayush ") + content.write("[blue]Date:[/blue] 3 minutes ago") + content.write("") + content.write(" widgets cn 1") + content.write(" ") + content.write(" [blue]Signed-off-by:[/blue] Ayush ") + content.write("") + content.write("[bold]Changed files:[/bold]") + content.write("[red]- app.py[/red]") + content.write("[red]- README.md[/red]") + content.write("[green]+ utils.py[/green]") + + def show_welcome(self) -> None: + """Show welcome message.""" + content = self.query_one("#main-content", RichLog) + content.clear() + + # Update the title + self.query_one(".section-title", Label).update("[bold]4-Welcome[/bold]") + + # Add welcome content + content.write("[bold]Welcome to GitX - A beginner-friendly Git TUI[/bold]") + content.write("") + content.write("[green]•[/green] Select files to view and manage them") + content.write("[green]•[/green] View commit history and branch information") + content.write("[green]•[/green] Use keyboard shortcuts shown at the bottom") + content.write("[green]•[/green] Run custom Git commands in the command panel") + content.write("") + content.write("Press [bold]?[/bold] for help and available commands") diff --git a/src/gitx/widgets/status_panel.py b/src/gitx/widgets/status_panel.py new file mode 100644 index 0000000..5e617d8 --- /dev/null +++ b/src/gitx/widgets/status_panel.py @@ -0,0 +1,50 @@ +from textual.widgets import Static +from textual.app import ComposeResult +from textual.containers import Vertical, Horizontal +from textual.widgets import Label +from rich.text import Text + + +class StatusPanel(Static): + """Panel that shows the current status of the repository.""" + + def compose(self) -> ComposeResult: + """Compose the status panel.""" + yield Vertical( + Label("[bold]1-Status[/bold]", classes="section-title"), + Horizontal( + Static("gitx ➜", classes="status-label"), + Static("feat/init-git-commands", id="current-branch", classes="status-value"), + classes="status-row" + ), + id="status-panel", + classes="panel" + ) + + def on_mount(self) -> None: + """Set up the status panel when mounted.""" + # Set the branch text with proper styling - green for clean status + branch_label = self.query_one("#current-branch", Static) + branch_text = Text("feat/init-git-commands") + branch_text.stylize("green") + branch_label.update(branch_text) + + def update_status(self, branch: str, status: str = "clean") -> None: + """Update the status information. + + Args: + branch: The name of the current branch + status: Status string ('clean', 'modified', 'untracked') + """ + branch_label = self.query_one("#current-branch", Static) + branch_text = Text(branch) + + # Color based on status + if status == "clean": + branch_text.stylize("green") + elif status == "modified": + branch_text.stylize("red") + elif status == "untracked": + branch_text.stylize("magenta") + + branch_label.update(branch_text)