From cb5d7738611c4ab5a8d9daef67303282bb7513cb Mon Sep 17 00:00:00 2001 From: Anmol Puri Date: Wed, 16 Apr 2025 17:50:41 +0530 Subject: [PATCH 01/24] ops: add build.yml gh action Signed-off-by: Anmol Puri --- .github/workflows/build.yml | 47 +++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..6a1b058 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,47 @@ +name: build + +on: + workflow_run: + workflows: ["CI"] + types: + - completed + branches: [master] + workflow_dispatch: + +jobs: + build: + if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }} + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.9', '3.10', '3.11', '3.12'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Poetry + run: | + curl -sSL https://install.python-poetry.org | python3 - + echo "$HOME/.local/bin" >> $GITHUB_PATH + + - name: Configure Poetry + run: | + poetry config virtualenvs.create true + poetry config virtualenvs.in-project false + + - name: Install dependencies + run: | + poetry install --with dev + + - name: Check for compilation errors + run: | + poetry run python -c "from gitx.app import GitxApp; print('Import successful')" + + - name: Verify app starts without errors + run: | + bash scripts/verify.sh \ No newline at end of file From fc55be5b62b44ff49e38f2c371ce605ee34b95cf Mon Sep 17 00:00:00 2001 From: Anmol Puri Date: Wed, 16 Apr 2025 17:51:42 +0530 Subject: [PATCH 02/24] feat: add verify.sh Signed-off-by: Anmol Puri --- scripts/verify.sh | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100755 scripts/verify.sh diff --git a/scripts/verify.sh b/scripts/verify.sh new file mode 100755 index 0000000..e9781cd --- /dev/null +++ b/scripts/verify.sh @@ -0,0 +1,13 @@ +#!/bin/bash +timeout 5s poetry run python -c "from gitx.app import GitxApp; app = GitxApp()" || true +exit_code=$? +if [ -n "$exit_code" ] && [ "$exit_code" -eq 124 ]; then + echo "App initialized successfully (timeout as expected)" + exit 0 +elif [ -n "$exit_code" ] && [ "$exit_code" -eq 0 ]; then + echo "App initialized successfully" + exit 0 +else + echo "App initialization failed with exit code: $exit_code" + exit 1 +fi \ No newline at end of file From 2e84622e6bf3add3b7bfa1050c24b18aa38f3f1a Mon Sep 17 00:00:00 2001 From: Anmol Puri Date: Wed, 16 Apr 2025 17:52:27 +0530 Subject: [PATCH 03/24] update app.py Signed-off-by: Anmol Puri --- src/gitx/app.py | 228 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 213 insertions(+), 15 deletions(-) diff --git a/src/gitx/app.py b/src/gitx/app.py index 2e275f0..640f4a1 100644 --- a/src/gitx/app.py +++ b/src/gitx/app.py @@ -1,7 +1,16 @@ 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, Static, Input, Tree +from textual.screen import Screen + +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,26 +20,215 @@ 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="r", action="refresh", description="Refresh"), + 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.""" - self.theme = ( - "flexoki" if self.theme == "catppuccin-latte" else "catppuccin-latte" - ) + 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_refresh(self) -> None: + """Refresh all panels with the latest git data.""" + self.query_one(StatusPanel).refresh_status() + self.query_one(FileTree).refresh_tree() + self.query_one(CommitLog).refresh_log() + self.query_one(BranchesPanel).refresh_branches() + self.notify("Refreshed all panels") + + def action_toggle_theme(self) -> None: + """Toggle dark mode.""" + self.dark = not self.dark + + def action_stage_file(self) -> None: + """Stage the selected file.""" + file_tree = self.query_one(FileTree) + tree = file_tree.query_one(Tree) + + if tree.cursor_node and hasattr(tree.cursor_node, 'data') and tree.cursor_node.data: + file_path = tree.cursor_node.data.get("path") + if file_path: + if self.git.stage_file(file_path): + self.notify(f"Staged: {file_path}") + self.action_refresh() + else: + self.notify(f"Failed to stage: {file_path}", severity="error") + + def action_unstage_file(self) -> None: + """Unstage the selected file.""" + file_tree = self.query_one(FileTree) + tree = file_tree.query_one(Tree) + + if tree.cursor_node and hasattr(tree.cursor_node, 'data') and tree.cursor_node.data: + file_path = tree.cursor_node.data.get("path") + if file_path: + if self.git.unstage_file(file_path): + self.notify(f"Unstaged: {file_path}") + self.action_refresh() + else: + self.notify(f"Failed to unstage: {file_path}", severity="error") + + def action_commit(self) -> None: + """Commit staged changes.""" + # Create a modal input dialog for commit message + from textual.widgets import Label # Add this import + + class CommitScreen(Screen): + def compose(self) -> ComposeResult: + yield Container( + Label("[bold]Enter commit message:[/bold]"), + Input(id="commit-message", placeholder="Commit message..."), + Static("Press Enter to commit, Esc to cancel"), + id="commit-dialog" + ) + + def on_key(self, event): + if event.key == "escape": + self.app.pop_screen() + elif event.key == "enter": + commit_msg = self.query_one("#commit-message").value + if commit_msg.strip(): + self.app.pop_screen() + if self.app.git.commit(commit_msg): + self.app.notify(f"Committed: {commit_msg}") + self.app.action_refresh() + else: + self.app.notify("Commit failed", severity="error") + else: + self.app.notify("Please enter a commit message", severity="warning") + + self.push_screen(CommitScreen()) + + def action_push(self) -> None: + """Push changes to remote.""" + success, output = self.git.push() + if success: + self.notify("Successfully pushed to remote") + else: + self.notify(f"Push failed: {output}", severity="error") + self.action_refresh() + + def action_pull(self) -> None: + """Pull changes from remote.""" + success, output = self.git.pull() + if success: + self.notify("Successfully pulled from remote") + else: + self.notify(f"Pull failed: {output}", severity="error") + self.action_refresh() + + def action_new_branch(self) -> None: + """Create a new branch.""" + # Create a modal input dialog for branch name + from textual.widgets import Label # Add this import + + class BranchScreen(Screen): + def compose(self) -> ComposeResult: + yield Container( + Label("[bold]Enter new branch name:[/bold]"), + Input(id="branch-name", placeholder="Branch name..."), + Static("Press Enter to create, Esc to cancel"), + id="branch-dialog" + ) + + def on_key(self, event): + if event.key == "escape": + self.app.pop_screen() + elif event.key == "enter": + branch_name = self.query_one("#branch-name").value + if branch_name.strip(): + self.app.pop_screen() + if self.app.git.create_branch(branch_name): + self.app.notify(f"Created and switched to branch: {branch_name}") + self.app.action_refresh() + else: + self.app.notify(f"Failed to create branch: {branch_name}", severity="error") + else: + self.app.notify("Please enter a branch name", severity="warning") + + self.push_screen(BranchScreen()) + + def action_toggle_help(self) -> None: + """Toggle help screen.""" + from textual.widgets import Label # Add this import + + class HelpScreen(Screen): + def compose(self) -> ComposeResult: + yield Container( + Label("[bold]GitX Help[/bold]"), + Static("[bold]Keyboard Shortcuts:[/bold]"), + Static("q - Quit"), + Static("t - Toggle theme"), + Static("s - Stage selected file"), + Static("u - Unstage selected file"), + Static("c - Commit staged changes"), + Static("p - Push to remote"), + Static("f - Pull from remote"), + Static("b - Create new branch"), + Static("r - Refresh all panels"), + Static("? - Toggle this help screen"), + Static("^p - Command palette"), + Static(""), + Static("Press any key to close this help"), + id="help-dialog" + ) + + def on_key(self, event): + self.app.pop_screen() + + self.push_screen(HelpScreen()) + def main() -> None: """Run the app.""" From 6b7dc0c580345d5ba633baf8b22324057a9d9f47 Mon Sep 17 00:00:00 2001 From: Anmol Puri Date: Wed, 16 Apr 2025 17:53:04 +0530 Subject: [PATCH 04/24] update app.tcss Signed-off-by: Anmol Puri --- src/gitx/css/app.tcss | 172 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 156 insertions(+), 16 deletions(-) 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 From d8cdde18ca7301552841ffcca94ba65b4ec2186a Mon Sep 17 00:00:00 2001 From: Anmol Puri Date: Wed, 16 Apr 2025 17:53:24 +0530 Subject: [PATCH 05/24] update handler.py Signed-off-by: Anmol Puri --- src/gitx/git/handler.py | 380 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 380 insertions(+) create mode 100644 src/gitx/git/handler.py diff --git a/src/gitx/git/handler.py b/src/gitx/git/handler.py new file mode 100644 index 0000000..abcee10 --- /dev/null +++ b/src/gitx/git/handler.py @@ -0,0 +1,380 @@ +import os +import subprocess +# Remove or use Path +from typing import List, Dict, Optional, Tuple, Any + + +class GitHandler: + """Handles Git operations.""" + + def __init__(self, repo_path: Optional[str] = None): + """Initialize the Git handler. + + Args: + repo_path: Path to the Git repository. Uses current directory if None. + """ + self.repo_path = repo_path or os.getcwd() + + # Verify this is a git repository + self._check_git_repository() + + def _check_git_repository(self) -> None: + """Check if the current directory is a git repository.""" + try: + self._run_git_command("rev-parse", "--is-inside-work-tree") + except subprocess.CalledProcessError: + # Fix f-string missing placeholder issue + raise ValueError(f"The directory '{self.repo_path}' is not a Git repository") + + def _run_git_command(self, *args: str, capture_output: bool = True) -> subprocess.CompletedProcess: + """Run a git command and return the result. + + Args: + *args: Arguments to pass to git + capture_output: Whether to capture the command output + + Returns: + The completed process with output if capture_output is True + """ + cmd = ["git", "-C", self.repo_path] + list(args) + + return subprocess.run( + cmd, + capture_output=capture_output, + text=True, + check=True + ) + + def get_status(self) -> Dict[str, List[str]]: + """Get the status of the repository.""" + result = self._run_git_command("status", "--porcelain") + + status = { + "untracked": [], + "modified": [], + "staged": [], + "deleted": [], + "renamed": [] + } + + for line in result.stdout.splitlines(): + if not line: + continue + + # The first two characters represent the status + code = line[:2] + file_path = line[3:] + + # Parse the status code + # M = modified, A = added, R = renamed, D = deleted, ?? = untracked + if code == "??": + status["untracked"].append(file_path) + elif code[0] == "M" or code[1] == "M": + if code[0] != " ": # Changes in the staging area + status["staged"].append(file_path) + if code[1] != " ": # Changes in the working directory + status["modified"].append(file_path) + elif code[0] == "A": + status["staged"].append(file_path) + elif code[0] == "D" or code[1] == "D": + if code[0] != " ": # Deleted in the staging area + status["staged"].append(file_path) + if code[1] != " ": # Deleted in the working directory + status["deleted"].append(file_path) + elif code[0] == "R": + status["renamed"].append(file_path) + + return status + + def get_branches(self) -> List[Dict[str, Any]]: + """Get all branches in the repository.""" + # Get local branches + result = self._run_git_command("branch", "--format=%(refname:short)") + local_branches = [line.strip() for line in result.stdout.splitlines() if line.strip()] + + # Get current branch + try: + current_branch = self.get_current_branch() + except subprocess.CalledProcessError: + current_branch = "HEAD detached" + + # Get remote branches + result = self._run_git_command("branch", "-r", "--format=%(refname:short)") + remote_branches = [line.strip() for line in result.stdout.splitlines() if line.strip()] + + branches = [] + for branch in local_branches: + remote = None + # Find the corresponding remote branch if it exists + for remote_branch in remote_branches: + if remote_branch.endswith("/" + branch): + remote = remote_branch + break + + branches.append({ + "name": branch, + "current": branch == current_branch, + "remote": remote + }) + + return branches + + def get_current_branch(self) -> str: + """Get the name of the current branch.""" + result = self._run_git_command("rev-parse", "--abbrev-ref", "HEAD") + return result.stdout.strip() + + def get_commit_history(self, count: int = 20) -> List[Dict[str, str]]: + """Get commit history. + + Args: + count: Number of commits to retrieve + + Returns: + List of commit dictionaries with hash, author, date, and message + """ + # Format: hash, author name, author email, date, subject + format_str = "--pretty=format:%h|%an|%ae|%ar|%s" + result = self._run_git_command("log", "-n", str(count), format_str) + + commits = [] + for line in result.stdout.splitlines(): + if not line: + continue + + parts = line.split("|") + if len(parts) >= 5: + commits.append({ + "hash": parts[0], + "author": f"{parts[1]} <{parts[2]}>", + "date": parts[3], + "message": parts[4] + }) + + return commits + + def get_commit_details(self, commit_hash: str) -> Dict[str, Any]: + """Get detailed information about a specific commit. + + Args: + commit_hash: The commit hash to get details for + + Returns: + Dictionary with commit details including full hash, author, date, message, and changed files + """ + # Get basic commit info + format_str = "--pretty=format:%H|%an|%ae|%ad|%s" + result = self._run_git_command("show", "--no-patch", format_str, commit_hash) + + parts = result.stdout.strip().split("|") + if len(parts) < 5: + return {} + + # Get changed files + files_result = self._run_git_command("show", "--name-status", "--pretty=format:", commit_hash) + + changed_files = { + "added": [], + "modified": [], + "deleted": [] + } + + for line in files_result.stdout.splitlines(): + if not line.strip(): + continue + + parts_file = line.split() + if len(parts_file) >= 2: + status, file_path = parts_file[0], " ".join(parts_file[1:]) + + if status == "A": + changed_files["added"].append(file_path) + elif status == "M": + changed_files["modified"].append(file_path) + elif status == "D": + changed_files["deleted"].append(file_path) + + return { + "hash": parts[0], + "author": f"{parts[1]} <{parts[2]}>", + "date": parts[3], + "message": parts[4], + "changed_files": changed_files + } + + def get_repo_status_summary(self) -> Dict[str, str]: + """Get a summary of the repository status.""" + status = self.get_status() + current_branch = self.get_current_branch() + + # Check if working directory is clean + is_clean = not (status["modified"] or status["untracked"] or status["deleted"]) + status_text = "✓ clean" if is_clean else "! modified" + + # Get ahead/behind info + try: + ahead_behind = self._run_git_command( + "rev-list", "--left-right", "--count", "@{u}...HEAD" + ) + behind, ahead = ahead_behind.stdout.strip().split() + remote_status = f"origin (ahead:{ahead}, behind:{behind})" + except (subprocess.CalledProcessError, ValueError): + remote_status = "no upstream branch" + + return { + "branch": current_branch, + "status": status_text, + "remote": remote_status + } + + def get_file_diff(self, file_path: str, staged: bool = False) -> str: + """Get the diff for a specific file. + + Args: + file_path: Path to the file + staged: Whether to get the staged diff + + Returns: + Diff output as a string + """ + args = ["diff", "--color=never"] + + if staged: + args.append("--staged") + + args.append("--") + args.append(file_path) + + result = self._run_git_command(*args) + return result.stdout + + def stage_file(self, file_path: str) -> bool: + """Stage a file. + + Args: + file_path: Path to the file to stage + + Returns: + True if successful + """ + try: + self._run_git_command("add", "--", file_path, capture_output=False) + return True + except subprocess.CalledProcessError: + return False + + def unstage_file(self, file_path: str) -> bool: + """Unstage a file. + + Args: + file_path: Path to the file to unstage + + Returns: + True if successful + """ + try: + self._run_git_command("reset", "HEAD", "--", file_path, capture_output=False) + return True + except subprocess.CalledProcessError: + return False + + def commit(self, message: str) -> bool: + """Commit staged changes. + + Args: + message: Commit message + + Returns: + True if successful + """ + try: + self._run_git_command("commit", "-m", message, capture_output=False) + return True + except subprocess.CalledProcessError: + return False + + def checkout_branch(self, branch_name: str) -> bool: + """Checkout a branch. + + Args: + branch_name: Name of the branch to checkout + + Returns: + True if successful + """ + try: + self._run_git_command("checkout", branch_name, capture_output=False) + return True + except subprocess.CalledProcessError: + return False + + def create_branch(self, branch_name: str) -> bool: + """Create a new branch. + + Args: + branch_name: Name of the branch to create + + Returns: + True if successful + """ + try: + self._run_git_command("checkout", "-b", branch_name, capture_output=False) + return True + except subprocess.CalledProcessError: + return False + + def merge_branch(self, branch_name: str) -> Tuple[bool, Optional[str]]: + """Merge a branch into the current branch. + + Args: + branch_name: Name of the branch to merge + + Returns: + Tuple of (success, error_message) + """ + try: + result = self._run_git_command("merge", branch_name) + return True, result.stdout + except subprocess.CalledProcessError as e: + return False, e.stderr + + def pull(self) -> Tuple[bool, Optional[str]]: + """Pull changes from remote. + + Returns: + Tuple of (success, output_or_error_message) + """ + try: + result = self._run_git_command("pull") + return True, result.stdout + except subprocess.CalledProcessError as e: + return False, e.stderr + + def push(self) -> Tuple[bool, Optional[str]]: + """Push changes to remote. + + Returns: + Tuple of (success, output_or_error_message) + """ + try: + result = self._run_git_command("push") + return True, result.stdout + except subprocess.CalledProcessError as e: + return False, e.stderr + + def execute_command(self, command: str) -> Tuple[bool, str]: + """Execute a custom git command. + + Args: + command: Git command to execute (without 'git' prefix) + + Returns: + Tuple of (success, output_or_error_message) + """ + try: + # Split the command into arguments + args = command.split() + result = self._run_git_command(*args) + return True, result.stdout + except subprocess.CalledProcessError as e: + return False, e.stderr From 6cba86fac6306cac39937613a562894f26df9f25 Mon Sep 17 00:00:00 2001 From: Anmol Puri Date: Wed, 16 Apr 2025 17:53:47 +0530 Subject: [PATCH 06/24] update branches_panel.py Signed-off-by: Anmol Puri --- src/gitx/widgets/branches_panel.py | 75 ++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 src/gitx/widgets/branches_panel.py diff --git a/src/gitx/widgets/branches_panel.py b/src/gitx/widgets/branches_panel.py new file mode 100644 index 0000000..57aa28f --- /dev/null +++ b/src/gitx/widgets/branches_panel.py @@ -0,0 +1,75 @@ +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.""" + self.refresh_branches() + + def refresh_branches(self) -> None: + """Refresh the branches tree with current repository branches.""" + tree = self.query_one(Tree) + tree.clear() + + try: + # Get actual branches from git + branches = self.app.git.get_branches() + current_branch = self.app.git.get_current_branch() + + if not branches: + tree.root.add_leaf("No branches found") + return + + # Add branches to tree with proper styling + for branch_info in branches: + branch = branch_info["name"] + node = tree.root.add_leaf(branch) + node.data = {"branch": branch} + + if branch == current_branch: + # Current branch in green with check mark + node.label.stylize("green bold") + node.label = Text("✓ ") + node.label + + # Get remote branches + remote_branches = [] + for branch_info in branches: + if branch_info.get("remote"): + remote_branches.append(branch_info["remote"]) + + # Add any remote branches that don't have a local counterpart + if remote_branches: + for remote_branch in remote_branches: + if "/" in remote_branch: # Make sure it's a valid remote branch + node = tree.root.add_leaf(remote_branch) + node.data = {"branch": remote_branch} + node.label.stylize("blue") + + # Expand the tree by default + tree.root.expand() + except Exception as e: + tree.root.add_leaf(f"Error: {str(e)}") + + def on_tree_node_selected(self, event: Tree.NodeSelected) -> None: + """Handle tree node selection.""" + node = event.node + + if hasattr(node, 'data') and node.data and "branch" in node.data: + branch = node.data["branch"] + + # Show dialog to confirm checkout or perform related branch action + self.app.notify(f"Selected branch: {branch} (checkout not implemented yet)") From c7afae205e784edf63238c8dd7f2832a98a15445 Mon Sep 17 00:00:00 2001 From: Anmol Puri Date: Wed, 16 Apr 2025 17:54:36 +0530 Subject: [PATCH 07/24] update command_panel.py Signed-off-by: Anmol Puri --- src/gitx/widgets/command_panel.py | 60 +++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/gitx/widgets/command_panel.py diff --git a/src/gitx/widgets/command_panel.py b/src/gitx/widgets/command_panel.py new file mode 100644 index 0000000..43d6ee8 --- /dev/null +++ b/src/gitx/widgets/command_panel.py @@ -0,0 +1,60 @@ +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") + + # Execute the actual git command + success, result = self.app.git.execute_command(command) + + # Format the response based on success/failure + response = Text(result + "\n" if result else "Command executed successfully.\n") + if success: + response.stylize("green") + else: + response.stylize("red") + + 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 + if success: + self.app.notify(f"Executed: git {command}") + else: + self.app.notify(f"Error executing: git {command}", severity="error") + else: + self.app.notify("Please enter a command", severity="warning") From a1704aefd372893dd42956dddd2534c8af20d1e5 Mon Sep 17 00:00:00 2001 From: Anmol Puri Date: Wed, 16 Apr 2025 17:55:00 +0530 Subject: [PATCH 08/24] update commit_log.py Signed-off-by: Anmol Puri --- src/gitx/widgets/commit_log.py | 96 ++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 src/gitx/widgets/commit_log.py diff --git a/src/gitx/widgets/commit_log.py b/src/gitx/widgets/commit_log.py new file mode 100644 index 0000000..b3698e6 --- /dev/null +++ b/src/gitx/widgets/commit_log.py @@ -0,0 +1,96 @@ +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.""" + self.refresh_log() + + def refresh_log(self, count: int = 20) -> None: + """Refresh the commit log with the latest commits. + + Args: + count: Number of commits to show + """ + log = self.query_one(RichLog) + log.clear() + + try: + # Get actual commit history from git + commits = self.app.git.get_commit_history(count) + + if not commits: + log.write("[yellow]No commits found in this repository.[/yellow]") + return + + # Get current branch for reference + current_branch = self.app.git.get_current_branch() + + # Format and display the commits + for i, commit in enumerate(commits): + # Show branch indicator for the first commit + branch_indicator = "" + if i == 0: + branch_indicator = f"([red]HEAD → {current_branch}[/red])" + + # Display commit in a format similar to git log + log.write(f"[green]✱[/green] [yellow]commit[/yellow] [green]{commit['hash']}[/green] {branch_indicator}") + log.write(f"│ [blue]Author:[/blue] {commit['author']}") + log.write(f"│ [blue]Date:[/blue] {commit['date']}") + log.write("│ ") + + # Format commit message with proper indentation + for line in commit['message'].split("\n"): + log.write(f"│ {line}") + + log.write("│") + except Exception as e: + log.write(f"[red]Error loading commit history: {str(e)}[/red]") + + def on_click(self, event) -> None: + """Handle click events to select commits.""" + # Find which line was clicked + log = self.query_one(RichLog) + + try: + # Get the line that was clicked + line_index = log.get_line_at(event.y) + if line_index is None: + return + + line = log.get_content_at(line_index) + + # Check if this is a commit line (starts with ✱ commit) + if "[green]✱[/green] [yellow]commit[/yellow]" in line: + # Extract the commit hash + parts = line.split() + for i, part in enumerate(parts): + if "[green]" in part and len(part) > 15: # Likely the hash + # Extract just the hash, removing formatting + commit_hash = part.replace("[green]", "").replace("[/green]", "") + + # Show commit details in the main panel + main_panel = self.app.query_one('MainPanel') + main_panel.show_commit_details(commit_hash) + break + except Exception: + # Silently ignore any errors in click handling + pass + + def update_log(self, count: int = 20) -> None: + """Update the commit log with real commit data.""" + self.refresh_log(count) From ae854f9bfb8d645d8c50c2f44a2da6b68df99508 Mon Sep 17 00:00:00 2001 From: Anmol Puri Date: Wed, 16 Apr 2025 17:55:16 +0530 Subject: [PATCH 09/24] update file_tree.py Signed-off-by: Anmol Puri --- src/gitx/widgets/file_tree.py | 95 +++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 src/gitx/widgets/file_tree.py diff --git a/src/gitx/widgets/file_tree.py b/src/gitx/widgets/file_tree.py new file mode 100644 index 0000000..5d86ed6 --- /dev/null +++ b/src/gitx/widgets/file_tree.py @@ -0,0 +1,95 @@ +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.""" + self.refresh_tree() + + def refresh_tree(self) -> None: + """Refresh the file tree with current repository status.""" + tree = self.query_one(Tree) + tree.clear() + + try: + # Get the actual status from git + status = self.app.git.get_status() + + # Add sections for different file statuses + if status.get("staged"): + staged = tree.root.add("Staged Changes", expand=True) + for file in status["staged"]: + node = staged.add_leaf(file) + node.data = {"status": "staged", "path": file} + + if status.get("modified"): + unstaged = tree.root.add("Unstaged Changes", expand=True) + for file in status["modified"]: + node = unstaged.add_leaf(file) + node.data = {"status": "modified", "path": file} + + if status.get("deleted"): + deleted_node = tree.root.add("Deleted Files", expand=True) + for file in status["deleted"]: + node = deleted_node.add_leaf(file) + node.data = {"status": "deleted", "path": file} + + if status.get("untracked"): + untracked = tree.root.add("Untracked Files", expand=True) + for file in status["untracked"]: + node = untracked.add_leaf(file) + node.data = {"status": "untracked", "path": file} + + # Add styling to tree nodes + self.apply_tree_styling() + except Exception as e: + # If there's an error, add an error node + error_node = tree.root.add("Error") + error_node.add_leaf(f"Error: {str(e)}") + + 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") + elif node.data.get("status") == "deleted": + node.label.stylize("red dim") + + def on_tree_node_selected(self, event: Tree.NodeSelected) -> None: + """Handle tree node selection.""" + node = event.node + + # Only process file nodes (not category nodes) + if hasattr(node, 'data') and node.data and "path" in node.data: + file_path = node.data["path"] + status = node.data["status"] + + # Show the diff in the main panel + main_panel = self.app.query_one('MainPanel') + is_staged = status == "staged" + main_panel.show_file_diff(file_path, staged=is_staged) From 348c8bb3f549da821c2ccb28178a9cc75d4a47b6 Mon Sep 17 00:00:00 2001 From: Anmol Puri Date: Wed, 16 Apr 2025 17:55:33 +0530 Subject: [PATCH 10/24] update main_panel.py Signed-off-by: Anmol Puri --- src/gitx/widgets/main_panel.py | 127 +++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 src/gitx/widgets/main_panel.py diff --git a/src/gitx/widgets/main_panel.py b/src/gitx/widgets/main_panel.py new file mode 100644 index 0000000..157d42b --- /dev/null +++ b/src/gitx/widgets/main_panel.py @@ -0,0 +1,127 @@ +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, staged: bool = False) -> None: + """Show the diff content of a file. + + Args: + file_path: Path to the file + staged: Whether to show the staged diff + """ + content = self.query_one("#main-content", RichLog) + content.clear() + + # Update the title + staging_status = "Staged" if staged else "Unstaged" + self.query_one(".section-title", Label).update(f"[bold]4-Diff: {file_path} ({staging_status})[/bold]") + + try: + # Get the actual diff from git + diff_output = self.app.git.get_file_diff(file_path, staged) + + if not diff_output: + content.write("[yellow]No changes detected in this file.[/yellow]") + return + + # Parse and colorize the diff output + for line in diff_output.splitlines(): + if line.startswith("+") and not line.startswith("+++"): + content.write(f"[green]{line}[/green]") + elif line.startswith("-") and not line.startswith("---"): + content.write(f"[red]{line}[/red]") + elif line.startswith("@@"): + content.write(f"[cyan]{line}[/cyan]") + elif line.startswith("diff") or line.startswith("index") or line.startswith("---") or line.startswith("+++"): + content.write(f"[green]{line}[/green]") + else: + content.write(line) + except Exception as e: + content.write(f"[red]Error displaying diff: {str(e)}[/red]") + + def show_commit_details(self, commit_hash: str) -> None: + """Show the details of a commit. + + Args: + commit_hash: The commit hash to display + """ + 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]") + + try: + # Get the actual commit details from git + details = self.app.git.get_commit_details(commit_hash) + + if not details: + content.write(f"[red]Could not find commit: {commit_hash}[/red]") + return + + # Show basic commit info + content.write(f"[yellow]commit[/yellow] [green]{details['hash']}[/green]") + content.write(f"[blue]Author:[/blue] {details['author']}") + content.write(f"[blue]Date:[/blue] {details['date']}") + content.write("") + + # Show commit message with proper indentation + for line in details['message'].split("\n"): + content.write(f" {line}") + content.write("") + + # Show changed files + content.write("[bold]Changed files:[/bold]") + + for file in details['changed_files'].get('deleted', []): + content.write(f"[red]- {file}[/red]") + + for file in details['changed_files'].get('modified', []): + content.write(f"[yellow]~ {file}[/yellow]") + + for file in details['changed_files'].get('added', []): + content.write(f"[green]+ {file}[/green]") + except Exception as e: + content.write(f"[red]Error displaying commit details: {str(e)}[/red]") + + 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") From cfa24bc2274d98e172cdb633f05a7462f0a52b7d Mon Sep 17 00:00:00 2001 From: Anmol Puri Date: Wed, 16 Apr 2025 17:55:52 +0530 Subject: [PATCH 11/24] update status_panel.py Signed-off-by: Anmol Puri --- src/gitx/widgets/status_panel.py | 59 ++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 src/gitx/widgets/status_panel.py diff --git a/src/gitx/widgets/status_panel.py b/src/gitx/widgets/status_panel.py new file mode 100644 index 0000000..30b1d15 --- /dev/null +++ b/src/gitx/widgets/status_panel.py @@ -0,0 +1,59 @@ +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("", 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.""" + self.refresh_status() + + def refresh_status(self) -> None: + """Refresh the status information with current repository state.""" + try: + # Get actual status from git + status_info = self.app.git.get_repo_status_summary() + + branch_label = self.query_one("#current-branch", Static) + branch_text = Text(f"{status_info['branch']} - {status_info['status']}") + + # Color based on status + if "clean" in status_info["status"]: + branch_text.stylize("green") + elif "modified" in status_info["status"]: + branch_text.stylize("red") + elif "untracked" in status_info["status"]: + branch_text.stylize("magenta") + + branch_label.update(branch_text) + except Exception as e: + branch_label = self.query_one("#current-branch", Static) + error_text = Text(f"Error: {str(e)}") + error_text.stylize("red") + branch_label.update(error_text) + + def update_status(self, branch: str = None, status: str = None) -> None: + """Update the status information manually. + + Args: + branch: The name of the current branch (or None to auto-detect) + status: Status string ('clean', 'modified', 'untracked', or None to auto-detect) + """ + self.refresh_status() From c6234a2f7b6f6f240e4bc774ed3bee90d4e1fcf2 Mon Sep 17 00:00:00 2001 From: Anmol Puri Date: Wed, 16 Apr 2025 17:50:41 +0530 Subject: [PATCH 12/24] ops: add build.yml gh action Signed-off-by: Anmol Puri --- .github/workflows/build.yml | 47 +++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..6a1b058 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,47 @@ +name: build + +on: + workflow_run: + workflows: ["CI"] + types: + - completed + branches: [master] + workflow_dispatch: + +jobs: + build: + if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }} + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.9', '3.10', '3.11', '3.12'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Poetry + run: | + curl -sSL https://install.python-poetry.org | python3 - + echo "$HOME/.local/bin" >> $GITHUB_PATH + + - name: Configure Poetry + run: | + poetry config virtualenvs.create true + poetry config virtualenvs.in-project false + + - name: Install dependencies + run: | + poetry install --with dev + + - name: Check for compilation errors + run: | + poetry run python -c "from gitx.app import GitxApp; print('Import successful')" + + - name: Verify app starts without errors + run: | + bash scripts/verify.sh \ No newline at end of file From d868ff6a24c074d637d1f3ff522e5e8635f6e2ab Mon Sep 17 00:00:00 2001 From: Anmol Puri Date: Wed, 16 Apr 2025 17:51:42 +0530 Subject: [PATCH 13/24] feat: add verify.sh Signed-off-by: Anmol Puri --- scripts/verify.sh | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100755 scripts/verify.sh diff --git a/scripts/verify.sh b/scripts/verify.sh new file mode 100755 index 0000000..e9781cd --- /dev/null +++ b/scripts/verify.sh @@ -0,0 +1,13 @@ +#!/bin/bash +timeout 5s poetry run python -c "from gitx.app import GitxApp; app = GitxApp()" || true +exit_code=$? +if [ -n "$exit_code" ] && [ "$exit_code" -eq 124 ]; then + echo "App initialized successfully (timeout as expected)" + exit 0 +elif [ -n "$exit_code" ] && [ "$exit_code" -eq 0 ]; then + echo "App initialized successfully" + exit 0 +else + echo "App initialization failed with exit code: $exit_code" + exit 1 +fi \ No newline at end of file From de898ad23142d5ed1c8f09e2361e82584b0453b1 Mon Sep 17 00:00:00 2001 From: Anmol Puri Date: Wed, 16 Apr 2025 17:52:27 +0530 Subject: [PATCH 14/24] update app.py Signed-off-by: Anmol Puri --- src/gitx/app.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/gitx/app.py b/src/gitx/app.py index 337ad2b..bc524f1 100644 --- a/src/gitx/app.py +++ b/src/gitx/app.py @@ -30,6 +30,10 @@ class GitxApp(App): Binding(key="^p", action="palette", description="Command palette"), ] + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.git = GitHandler() + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.git = GitHandler() From 73d2b4f983e6c7a12cca1b06de03dea2d21f4d61 Mon Sep 17 00:00:00 2001 From: Anmol Puri Date: Wed, 16 Apr 2025 17:53:24 +0530 Subject: [PATCH 15/24] update handler.py Signed-off-by: Anmol Puri --- src/gitx/git/handler.py | 380 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 380 insertions(+) diff --git a/src/gitx/git/handler.py b/src/gitx/git/handler.py index e69de29..abcee10 100644 --- a/src/gitx/git/handler.py +++ b/src/gitx/git/handler.py @@ -0,0 +1,380 @@ +import os +import subprocess +# Remove or use Path +from typing import List, Dict, Optional, Tuple, Any + + +class GitHandler: + """Handles Git operations.""" + + def __init__(self, repo_path: Optional[str] = None): + """Initialize the Git handler. + + Args: + repo_path: Path to the Git repository. Uses current directory if None. + """ + self.repo_path = repo_path or os.getcwd() + + # Verify this is a git repository + self._check_git_repository() + + def _check_git_repository(self) -> None: + """Check if the current directory is a git repository.""" + try: + self._run_git_command("rev-parse", "--is-inside-work-tree") + except subprocess.CalledProcessError: + # Fix f-string missing placeholder issue + raise ValueError(f"The directory '{self.repo_path}' is not a Git repository") + + def _run_git_command(self, *args: str, capture_output: bool = True) -> subprocess.CompletedProcess: + """Run a git command and return the result. + + Args: + *args: Arguments to pass to git + capture_output: Whether to capture the command output + + Returns: + The completed process with output if capture_output is True + """ + cmd = ["git", "-C", self.repo_path] + list(args) + + return subprocess.run( + cmd, + capture_output=capture_output, + text=True, + check=True + ) + + def get_status(self) -> Dict[str, List[str]]: + """Get the status of the repository.""" + result = self._run_git_command("status", "--porcelain") + + status = { + "untracked": [], + "modified": [], + "staged": [], + "deleted": [], + "renamed": [] + } + + for line in result.stdout.splitlines(): + if not line: + continue + + # The first two characters represent the status + code = line[:2] + file_path = line[3:] + + # Parse the status code + # M = modified, A = added, R = renamed, D = deleted, ?? = untracked + if code == "??": + status["untracked"].append(file_path) + elif code[0] == "M" or code[1] == "M": + if code[0] != " ": # Changes in the staging area + status["staged"].append(file_path) + if code[1] != " ": # Changes in the working directory + status["modified"].append(file_path) + elif code[0] == "A": + status["staged"].append(file_path) + elif code[0] == "D" or code[1] == "D": + if code[0] != " ": # Deleted in the staging area + status["staged"].append(file_path) + if code[1] != " ": # Deleted in the working directory + status["deleted"].append(file_path) + elif code[0] == "R": + status["renamed"].append(file_path) + + return status + + def get_branches(self) -> List[Dict[str, Any]]: + """Get all branches in the repository.""" + # Get local branches + result = self._run_git_command("branch", "--format=%(refname:short)") + local_branches = [line.strip() for line in result.stdout.splitlines() if line.strip()] + + # Get current branch + try: + current_branch = self.get_current_branch() + except subprocess.CalledProcessError: + current_branch = "HEAD detached" + + # Get remote branches + result = self._run_git_command("branch", "-r", "--format=%(refname:short)") + remote_branches = [line.strip() for line in result.stdout.splitlines() if line.strip()] + + branches = [] + for branch in local_branches: + remote = None + # Find the corresponding remote branch if it exists + for remote_branch in remote_branches: + if remote_branch.endswith("/" + branch): + remote = remote_branch + break + + branches.append({ + "name": branch, + "current": branch == current_branch, + "remote": remote + }) + + return branches + + def get_current_branch(self) -> str: + """Get the name of the current branch.""" + result = self._run_git_command("rev-parse", "--abbrev-ref", "HEAD") + return result.stdout.strip() + + def get_commit_history(self, count: int = 20) -> List[Dict[str, str]]: + """Get commit history. + + Args: + count: Number of commits to retrieve + + Returns: + List of commit dictionaries with hash, author, date, and message + """ + # Format: hash, author name, author email, date, subject + format_str = "--pretty=format:%h|%an|%ae|%ar|%s" + result = self._run_git_command("log", "-n", str(count), format_str) + + commits = [] + for line in result.stdout.splitlines(): + if not line: + continue + + parts = line.split("|") + if len(parts) >= 5: + commits.append({ + "hash": parts[0], + "author": f"{parts[1]} <{parts[2]}>", + "date": parts[3], + "message": parts[4] + }) + + return commits + + def get_commit_details(self, commit_hash: str) -> Dict[str, Any]: + """Get detailed information about a specific commit. + + Args: + commit_hash: The commit hash to get details for + + Returns: + Dictionary with commit details including full hash, author, date, message, and changed files + """ + # Get basic commit info + format_str = "--pretty=format:%H|%an|%ae|%ad|%s" + result = self._run_git_command("show", "--no-patch", format_str, commit_hash) + + parts = result.stdout.strip().split("|") + if len(parts) < 5: + return {} + + # Get changed files + files_result = self._run_git_command("show", "--name-status", "--pretty=format:", commit_hash) + + changed_files = { + "added": [], + "modified": [], + "deleted": [] + } + + for line in files_result.stdout.splitlines(): + if not line.strip(): + continue + + parts_file = line.split() + if len(parts_file) >= 2: + status, file_path = parts_file[0], " ".join(parts_file[1:]) + + if status == "A": + changed_files["added"].append(file_path) + elif status == "M": + changed_files["modified"].append(file_path) + elif status == "D": + changed_files["deleted"].append(file_path) + + return { + "hash": parts[0], + "author": f"{parts[1]} <{parts[2]}>", + "date": parts[3], + "message": parts[4], + "changed_files": changed_files + } + + def get_repo_status_summary(self) -> Dict[str, str]: + """Get a summary of the repository status.""" + status = self.get_status() + current_branch = self.get_current_branch() + + # Check if working directory is clean + is_clean = not (status["modified"] or status["untracked"] or status["deleted"]) + status_text = "✓ clean" if is_clean else "! modified" + + # Get ahead/behind info + try: + ahead_behind = self._run_git_command( + "rev-list", "--left-right", "--count", "@{u}...HEAD" + ) + behind, ahead = ahead_behind.stdout.strip().split() + remote_status = f"origin (ahead:{ahead}, behind:{behind})" + except (subprocess.CalledProcessError, ValueError): + remote_status = "no upstream branch" + + return { + "branch": current_branch, + "status": status_text, + "remote": remote_status + } + + def get_file_diff(self, file_path: str, staged: bool = False) -> str: + """Get the diff for a specific file. + + Args: + file_path: Path to the file + staged: Whether to get the staged diff + + Returns: + Diff output as a string + """ + args = ["diff", "--color=never"] + + if staged: + args.append("--staged") + + args.append("--") + args.append(file_path) + + result = self._run_git_command(*args) + return result.stdout + + def stage_file(self, file_path: str) -> bool: + """Stage a file. + + Args: + file_path: Path to the file to stage + + Returns: + True if successful + """ + try: + self._run_git_command("add", "--", file_path, capture_output=False) + return True + except subprocess.CalledProcessError: + return False + + def unstage_file(self, file_path: str) -> bool: + """Unstage a file. + + Args: + file_path: Path to the file to unstage + + Returns: + True if successful + """ + try: + self._run_git_command("reset", "HEAD", "--", file_path, capture_output=False) + return True + except subprocess.CalledProcessError: + return False + + def commit(self, message: str) -> bool: + """Commit staged changes. + + Args: + message: Commit message + + Returns: + True if successful + """ + try: + self._run_git_command("commit", "-m", message, capture_output=False) + return True + except subprocess.CalledProcessError: + return False + + def checkout_branch(self, branch_name: str) -> bool: + """Checkout a branch. + + Args: + branch_name: Name of the branch to checkout + + Returns: + True if successful + """ + try: + self._run_git_command("checkout", branch_name, capture_output=False) + return True + except subprocess.CalledProcessError: + return False + + def create_branch(self, branch_name: str) -> bool: + """Create a new branch. + + Args: + branch_name: Name of the branch to create + + Returns: + True if successful + """ + try: + self._run_git_command("checkout", "-b", branch_name, capture_output=False) + return True + except subprocess.CalledProcessError: + return False + + def merge_branch(self, branch_name: str) -> Tuple[bool, Optional[str]]: + """Merge a branch into the current branch. + + Args: + branch_name: Name of the branch to merge + + Returns: + Tuple of (success, error_message) + """ + try: + result = self._run_git_command("merge", branch_name) + return True, result.stdout + except subprocess.CalledProcessError as e: + return False, e.stderr + + def pull(self) -> Tuple[bool, Optional[str]]: + """Pull changes from remote. + + Returns: + Tuple of (success, output_or_error_message) + """ + try: + result = self._run_git_command("pull") + return True, result.stdout + except subprocess.CalledProcessError as e: + return False, e.stderr + + def push(self) -> Tuple[bool, Optional[str]]: + """Push changes to remote. + + Returns: + Tuple of (success, output_or_error_message) + """ + try: + result = self._run_git_command("push") + return True, result.stdout + except subprocess.CalledProcessError as e: + return False, e.stderr + + def execute_command(self, command: str) -> Tuple[bool, str]: + """Execute a custom git command. + + Args: + command: Git command to execute (without 'git' prefix) + + Returns: + Tuple of (success, output_or_error_message) + """ + try: + # Split the command into arguments + args = command.split() + result = self._run_git_command(*args) + return True, result.stdout + except subprocess.CalledProcessError as e: + return False, e.stderr From 37b19d0fa2851bc3a2cf0c58503a697b0d53a750 Mon Sep 17 00:00:00 2001 From: Anmol Puri Date: Wed, 16 Apr 2025 17:53:47 +0530 Subject: [PATCH 16/24] update branches_panel.py Signed-off-by: Anmol Puri --- src/gitx/widgets/branches_panel.py | 76 ++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/src/gitx/widgets/branches_panel.py b/src/gitx/widgets/branches_panel.py index 8431104..37f94be 100644 --- a/src/gitx/widgets/branches_panel.py +++ b/src/gitx/widgets/branches_panel.py @@ -46,3 +46,79 @@ def on_mount(self) -> None: # Expand the tree by default tree.root.expand() + +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.""" + self.refresh_branches() + + def refresh_branches(self) -> None: + """Refresh the branches tree with current repository branches.""" + tree = self.query_one(Tree) + tree.clear() + + try: + # Get actual branches from git + branches = self.app.git.get_branches() + current_branch = self.app.git.get_current_branch() + + if not branches: + tree.root.add_leaf("No branches found") + return + + # Add branches to tree with proper styling + for branch_info in branches: + branch = branch_info["name"] + node = tree.root.add_leaf(branch) + node.data = {"branch": branch} + + if branch == current_branch: + # Current branch in green with check mark + node.label.stylize("green bold") + node.label = Text("✓ ") + node.label + + # Get remote branches + remote_branches = [] + for branch_info in branches: + if branch_info.get("remote"): + remote_branches.append(branch_info["remote"]) + + # Add any remote branches that don't have a local counterpart + if remote_branches: + for remote_branch in remote_branches: + if "/" in remote_branch: # Make sure it's a valid remote branch + node = tree.root.add_leaf(remote_branch) + node.data = {"branch": remote_branch} + node.label.stylize("blue") + + # Expand the tree by default + tree.root.expand() + except Exception as e: + tree.root.add_leaf(f"Error: {str(e)}") + + def on_tree_node_selected(self, event: Tree.NodeSelected) -> None: + """Handle tree node selection.""" + node = event.node + + if hasattr(node, 'data') and node.data and "branch" in node.data: + branch = node.data["branch"] + + # Show dialog to confirm checkout or perform related branch action + self.app.notify(f"Selected branch: {branch} (checkout not implemented yet)") From ed71a3441426cf0543cc86502b8796f3e5bc8388 Mon Sep 17 00:00:00 2001 From: Anmol Puri Date: Wed, 16 Apr 2025 17:54:36 +0530 Subject: [PATCH 17/24] update command_panel.py Signed-off-by: Anmol Puri --- src/gitx/widgets/command_panel.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/gitx/widgets/command_panel.py b/src/gitx/widgets/command_panel.py index cc32b52..43d6ee8 100644 --- a/src/gitx/widgets/command_panel.py +++ b/src/gitx/widgets/command_panel.py @@ -33,9 +33,15 @@ def on_button_pressed(self, event: Button.Pressed) -> None: output_text = Text(f"$ git {command}\n") output_text.stylize("yellow") - # Add dummy response - response = Text("Executing command...\n") - response.stylize("green") + # Execute the actual git command + success, result = self.app.git.execute_command(command) + + # Format the response based on success/failure + response = Text(result + "\n" if result else "Command executed successfully.\n") + if success: + response.stylize("green") + else: + response.stylize("red") output.update(output_text + response) @@ -46,6 +52,9 @@ def on_button_pressed(self, event: Button.Pressed) -> None: self.app.set_focus(command_input) # Notify user about command execution - self.app.notify(f"Executed: git {command}") + if success: + self.app.notify(f"Executed: git {command}") + else: + self.app.notify(f"Error executing: git {command}", severity="error") else: self.app.notify("Please enter a command", severity="warning") From d7e092644c37b5c96c10937c86a70d9f9c35dd45 Mon Sep 17 00:00:00 2001 From: Anmol Puri Date: Wed, 16 Apr 2025 17:55:00 +0530 Subject: [PATCH 18/24] update commit_log.py Signed-off-by: Anmol Puri --- src/gitx/widgets/commit_log.py | 120 +++++++++++++++++++-------------- 1 file changed, 71 insertions(+), 49 deletions(-) diff --git a/src/gitx/widgets/commit_log.py b/src/gitx/widgets/commit_log.py index 67666b1..b3698e6 100644 --- a/src/gitx/widgets/commit_log.py +++ b/src/gitx/widgets/commit_log.py @@ -18,57 +18,79 @@ def compose(self) -> ComposeResult: def on_mount(self) -> None: """Set up the commit log when mounted.""" - log = self.query_one(RichLog) + self.refresh_log() - # Make sure we start with an empty log - log.clear() + def refresh_log(self, count: int = 20) -> None: + """Refresh the commit log with the latest commits. - # 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.""" + Args: + count: Number of commits to show + """ 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() + try: + # Get actual commit history from git + commits = self.app.git.get_commit_history(count) + + if not commits: + log.write("[yellow]No commits found in this repository.[/yellow]") + return + + # Get current branch for reference + current_branch = self.app.git.get_current_branch() + + # Format and display the commits + for i, commit in enumerate(commits): + # Show branch indicator for the first commit + branch_indicator = "" + if i == 0: + branch_indicator = f"([red]HEAD → {current_branch}[/red])" + + # Display commit in a format similar to git log + log.write(f"[green]✱[/green] [yellow]commit[/yellow] [green]{commit['hash']}[/green] {branch_indicator}") + log.write(f"│ [blue]Author:[/blue] {commit['author']}") + log.write(f"│ [blue]Date:[/blue] {commit['date']}") + log.write("│ ") + + # Format commit message with proper indentation + for line in commit['message'].split("\n"): + log.write(f"│ {line}") + + log.write("│") + except Exception as e: + log.write(f"[red]Error loading commit history: {str(e)}[/red]") + + def on_click(self, event) -> None: + """Handle click events to select commits.""" + # Find which line was clicked + log = self.query_one(RichLog) + + try: + # Get the line that was clicked + line_index = log.get_line_at(event.y) + if line_index is None: + return + + line = log.get_content_at(line_index) + + # Check if this is a commit line (starts with ✱ commit) + if "[green]✱[/green] [yellow]commit[/yellow]" in line: + # Extract the commit hash + parts = line.split() + for i, part in enumerate(parts): + if "[green]" in part and len(part) > 15: # Likely the hash + # Extract just the hash, removing formatting + commit_hash = part.replace("[green]", "").replace("[/green]", "") + + # Show commit details in the main panel + main_panel = self.app.query_one('MainPanel') + main_panel.show_commit_details(commit_hash) + break + except Exception: + # Silently ignore any errors in click handling + pass + + def update_log(self, count: int = 20) -> None: + """Update the commit log with real commit data.""" + self.refresh_log(count) From 6f1f12ba712c71bb78a5371f0f446f6de4b43f4a Mon Sep 17 00:00:00 2001 From: Anmol Puri Date: Wed, 16 Apr 2025 17:55:16 +0530 Subject: [PATCH 19/24] update file_tree.py Signed-off-by: Anmol Puri --- src/gitx/widgets/file_tree.py | 71 ++++++++++++++++++++++++++--------- 1 file changed, 54 insertions(+), 17 deletions(-) diff --git a/src/gitx/widgets/file_tree.py b/src/gitx/widgets/file_tree.py index 00791a8..5d86ed6 100644 --- a/src/gitx/widgets/file_tree.py +++ b/src/gitx/widgets/file_tree.py @@ -17,32 +17,53 @@ def compose(self) -> ComposeResult: def on_mount(self) -> None: """Set up the tree when mounted.""" + self.refresh_tree() + + def refresh_tree(self) -> None: + """Refresh the file tree with current repository status.""" tree = self.query_one(Tree) + tree.clear() + + try: + # Get the actual status from git + status = self.app.git.get_status() - # 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"} + # Add sections for different file statuses + if status.get("staged"): + staged = tree.root.add("Staged Changes", expand=True) + for file in status["staged"]: + node = staged.add_leaf(file) + node.data = {"status": "staged", "path": file} - 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"} + if status.get("modified"): + unstaged = tree.root.add("Unstaged Changes", expand=True) + for file in status["modified"]: + node = unstaged.add_leaf(file) + node.data = {"status": "modified", "path": file} - staged = tree.root.add("Staged Changes", expand=True) - # Add files to the staged section - node = staged.add_leaf("utils.py") - node.data = {"status": "staged"} + if status.get("deleted"): + deleted_node = tree.root.add("Deleted Files", expand=True) + for file in status["deleted"]: + node = deleted_node.add_leaf(file) + node.data = {"status": "deleted", "path": file} - # Add styling to tree nodes - self.apply_tree_styling() + if status.get("untracked"): + untracked = tree.root.add("Untracked Files", expand=True) + for file in status["untracked"]: + node = untracked.add_leaf(file) + node.data = {"status": "untracked", "path": file} + + # Add styling to tree nodes + self.apply_tree_styling() + except Exception as e: + # If there's an error, add an error node + error_node = tree.root.add("Error") + error_node.add_leaf(f"Error: {str(e)}") 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 @@ -56,3 +77,19 @@ def apply_tree_styling(self) -> None: node.label.stylize("magenta") elif node.data.get("status") == "staged": node.label.stylize("green") + elif node.data.get("status") == "deleted": + node.label.stylize("red dim") + + def on_tree_node_selected(self, event: Tree.NodeSelected) -> None: + """Handle tree node selection.""" + node = event.node + + # Only process file nodes (not category nodes) + if hasattr(node, 'data') and node.data and "path" in node.data: + file_path = node.data["path"] + status = node.data["status"] + + # Show the diff in the main panel + main_panel = self.app.query_one('MainPanel') + is_staged = status == "staged" + main_panel.show_file_diff(file_path, staged=is_staged) From f4055add1d01a9b59a782803e0ebd6d7027da53e Mon Sep 17 00:00:00 2001 From: Anmol Puri Date: Wed, 16 Apr 2025 17:55:33 +0530 Subject: [PATCH 20/24] update main_panel.py Signed-off-by: Anmol Puri --- src/gitx/widgets/main_panel.py | 104 ++++++++++++++++++++++----------- 1 file changed, 69 insertions(+), 35 deletions(-) diff --git a/src/gitx/widgets/main_panel.py b/src/gitx/widgets/main_panel.py index adb6f52..157d42b 100644 --- a/src/gitx/widgets/main_panel.py +++ b/src/gitx/widgets/main_panel.py @@ -26,53 +26,87 @@ def on_mount(self) -> None: 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.""" + def show_file_diff(self, file_path: str, staged: bool = False) -> None: + """Show the diff content of a file. + + Args: + file_path: Path to the file + staged: Whether to show the staged diff + """ 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]") + staging_status = "Staged" if staged else "Unstaged" + self.query_one(".section-title", Label).update(f"[bold]4-Diff: {file_path} ({staging_status})[/bold]") + + try: + # Get the actual diff from git + diff_output = self.app.git.get_file_diff(file_path, staged) + + if not diff_output: + content.write("[yellow]No changes detected in this file.[/yellow]") + return + + # Parse and colorize the diff output + for line in diff_output.splitlines(): + if line.startswith("+") and not line.startswith("+++"): + content.write(f"[green]{line}[/green]") + elif line.startswith("-") and not line.startswith("---"): + content.write(f"[red]{line}[/red]") + elif line.startswith("@@"): + content.write(f"[cyan]{line}[/cyan]") + elif line.startswith("diff") or line.startswith("index") or line.startswith("---") or line.startswith("+++"): + content.write(f"[green]{line}[/green]") + else: + content.write(line) + except Exception as e: + content.write(f"[red]Error displaying diff: {str(e)}[/red]") def show_commit_details(self, commit_hash: str) -> None: - """Show the details of a commit.""" + """Show the details of a commit. + + Args: + commit_hash: The commit hash to display + """ 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]") + try: + # Get the actual commit details from git + details = self.app.git.get_commit_details(commit_hash) + + if not details: + content.write(f"[red]Could not find commit: {commit_hash}[/red]") + return + + # Show basic commit info + content.write(f"[yellow]commit[/yellow] [green]{details['hash']}[/green]") + content.write(f"[blue]Author:[/blue] {details['author']}") + content.write(f"[blue]Date:[/blue] {details['date']}") + content.write("") + + # Show commit message with proper indentation + for line in details['message'].split("\n"): + content.write(f" {line}") + content.write("") + + # Show changed files + content.write("[bold]Changed files:[/bold]") + + for file in details['changed_files'].get('deleted', []): + content.write(f"[red]- {file}[/red]") + + for file in details['changed_files'].get('modified', []): + content.write(f"[yellow]~ {file}[/yellow]") + + for file in details['changed_files'].get('added', []): + content.write(f"[green]+ {file}[/green]") + except Exception as e: + content.write(f"[red]Error displaying commit details: {str(e)}[/red]") def show_welcome(self) -> None: """Show welcome message.""" From 1cf628497d9e340bc99c196f8f038d5bbf72e4c5 Mon Sep 17 00:00:00 2001 From: Anmol Puri Date: Wed, 16 Apr 2025 17:55:52 +0530 Subject: [PATCH 21/24] update status_panel.py Signed-off-by: Anmol Puri --- src/gitx/widgets/status_panel.py | 55 +++++++++++++++++++------------- 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/src/gitx/widgets/status_panel.py b/src/gitx/widgets/status_panel.py index 5e617d8..30b1d15 100644 --- a/src/gitx/widgets/status_panel.py +++ b/src/gitx/widgets/status_panel.py @@ -14,7 +14,7 @@ def compose(self) -> ComposeResult: 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"), + Static("", id="current-branch", classes="status-value"), classes="status-row" ), id="status-panel", @@ -23,28 +23,37 @@ def compose(self) -> ComposeResult: 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. + self.refresh_status() + + def refresh_status(self) -> None: + """Refresh the status information with current repository state.""" + try: + # Get actual status from git + status_info = self.app.git.get_repo_status_summary() + + branch_label = self.query_one("#current-branch", Static) + branch_text = Text(f"{status_info['branch']} - {status_info['status']}") + + # Color based on status + if "clean" in status_info["status"]: + branch_text.stylize("green") + elif "modified" in status_info["status"]: + branch_text.stylize("red") + elif "untracked" in status_info["status"]: + branch_text.stylize("magenta") + + branch_label.update(branch_text) + except Exception as e: + branch_label = self.query_one("#current-branch", Static) + error_text = Text(f"Error: {str(e)}") + error_text.stylize("red") + branch_label.update(error_text) + + def update_status(self, branch: str = None, status: str = None) -> None: + """Update the status information manually. Args: - branch: The name of the current branch - status: Status string ('clean', 'modified', 'untracked') + branch: The name of the current branch (or None to auto-detect) + status: Status string ('clean', 'modified', 'untracked', or None to auto-detect) """ - 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) + self.refresh_status() From f0a5efa3d9da21f207e34dac98be325e0c9fbf45 Mon Sep 17 00:00:00 2001 From: Ayush Date: Wed, 16 Apr 2025 18:11:43 +0530 Subject: [PATCH 22/24] fix CI errors Signed-off-by: Ayush --- src/gitx/app.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/gitx/app.py b/src/gitx/app.py index 8a16989..c74875f 100644 --- a/src/gitx/app.py +++ b/src/gitx/app.py @@ -28,6 +28,9 @@ class GitxApp(App): Binding(key="f", action="pull", description="Pull (fetch)"), Binding(key="b", action="new_branch", description="New branch"), Binding(key="r", action="refresh", description="Refresh"), + Binding(key="?", action="toggle_help", description="Help"), + Binding(key="^p", action="palette", description="Command palette"), + ] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -226,7 +229,6 @@ def on_key(self, event): self.push_screen(HelpScreen()) - def action_stage_file(self) -> None: """Stage the selected file.""" self.notify("Action: Stage file (not implemented yet)") From 0e2218c59e890fa97d86f153929d79c4269c393a Mon Sep 17 00:00:00 2001 From: Ayush Date: Wed, 16 Apr 2025 18:14:42 +0530 Subject: [PATCH 23/24] fix CI errors Signed-off-by: Ayush --- src/gitx/app.py | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/src/gitx/app.py b/src/gitx/app.py index c74875f..640f4a1 100644 --- a/src/gitx/app.py +++ b/src/gitx/app.py @@ -229,34 +229,6 @@ def on_key(self, event): self.push_screen(HelpScreen()) - 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.""" From 42ae1088b43e32925b1cc03b52fe8956c5c34aa8 Mon Sep 17 00:00:00 2001 From: Anmol Puri Date: Wed, 16 Apr 2025 17:53:47 +0530 Subject: [PATCH 24/24] update branches_panel.py Signed-off-by: Anmol Puri --- src/gitx/widgets/branches_panel.py | 58 ++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/gitx/widgets/branches_panel.py b/src/gitx/widgets/branches_panel.py index 57aa28f..e06fb6f 100644 --- a/src/gitx/widgets/branches_panel.py +++ b/src/gitx/widgets/branches_panel.py @@ -5,6 +5,64 @@ 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.""" + self.refresh_branches() + + def refresh_branches(self) -> None: + """Refresh the branches tree with current repository branches.""" + tree = self.query_one(Tree) + tree.clear() + + try: + # Get actual branches from git + branches = self.app.git.get_branches() + current_branch = self.app.git.get_current_branch() + + if not branches: + tree.root.add_leaf("No branches found") + return + + # Add branches to tree with proper styling + for branch_info in branches: + branch = branch_info["name"] + node = tree.root.add_leaf(branch) + node.data = {"branch": branch} + + if branch == current_branch: + # Current branch in green with check mark + node.label.stylize("green bold") + node.label = Text("✓ ") + node.label + + # Get remote branches + remote_branches = [] + for branch_info in branches: + if branch_info.get("remote"): + remote_branches.append(branch_info["remote"]) + + # Add any remote branches that don't have a local counterpart + if remote_branches: + for remote_branch in remote_branches: + if "/" in remote_branch: # Make sure it's a valid remote branch + node = tree.root.add_leaf(remote_branch) + node.data = {"branch": remote_branch} + node.label.stylize("blue") + + # Expand the tree by default + tree.root.expand() + + class BranchesPanel(Static): """Panel that displays and manages branches."""