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 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 diff --git a/src/gitx/app.py b/src/gitx/app.py index 337ad2b..8a16989 100644 --- a/src/gitx/app.py +++ b/src/gitx/app.py @@ -1,7 +1,8 @@ from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Container, Grid -from textual.widgets import Header, Footer +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 @@ -26,9 +27,7 @@ class GitxApp(App): 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"), - ] + Binding(key="r", action="refresh", description="Refresh"), def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -77,11 +76,156 @@ def on_mount(self) -> None: 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.theme = ( - "textual-dark" if self.theme == "textual-light" else "textual-light" - ) + 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 action_stage_file(self) -> None: """Stage the selected file.""" 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 diff --git a/src/gitx/widgets/branches_panel.py b/src/gitx/widgets/branches_panel.py index 8431104..57aa28f 100644 --- a/src/gitx/widgets/branches_panel.py +++ b/src/gitx/widgets/branches_panel.py @@ -18,31 +18,58 @@ def compose(self) -> ComposeResult: 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"] - # 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() + # Show dialog to confirm checkout or perform related branch action + self.app.notify(f"Selected branch: {branch} (checkout not implemented yet)") 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") 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) 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) 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.""" 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()