First Release
This commit is contained in:
12
CHANGELOG.md
Normal file
12
CHANGELOG.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
|
||||
|
||||
## [0.1.0] - 2026-02-14
|
||||
|
||||
### Added
|
||||
|
||||
- Added a single-file `git-tui` curses application for common Git flows: stage all, stage selected files, unstage selected files, commit, push, pull, fetch, and status.
|
||||
- Added `doc/` documentation index and component documentation for architecture and usage.
|
||||
13
doc/README.md
Normal file
13
doc/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Documentation Index
|
||||
|
||||
This directory contains project documentation for `git-tui`.
|
||||
|
||||
## Modules
|
||||
|
||||
- [`doc/architecture.md`](architecture.md) - architecture and module responsibilities.
|
||||
- [`doc/usage.md`](usage.md) - installation, runtime requirements, and user workflow.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Keep public behavior changes documented in both module docs and `CHANGELOG.md`.
|
||||
- Keep this index updated when adding documentation modules.
|
||||
38
doc/architecture.md
Normal file
38
doc/architecture.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
`git-tui` is a single executable Python script that provides a terminal UI for common Git commands.
|
||||
|
||||
## Module Responsibilities
|
||||
|
||||
- `git-tui`:
|
||||
- Initializes and runs the curses application loop.
|
||||
- Renders a menu-driven UI with keyboard navigation.
|
||||
- Executes Git subprocess commands and displays command output.
|
||||
- Implements file selection for staged and unstaged operations.
|
||||
|
||||
## Public API and Contracts
|
||||
|
||||
- Command entry point: `git-tui` executable script.
|
||||
- Runtime contract:
|
||||
- Must be executed in a terminal that supports curses.
|
||||
- Must be run from a directory inside a Git working tree.
|
||||
- Requires `git` to be installed and available in `PATH`.
|
||||
|
||||
## Data Model
|
||||
|
||||
- `ChangedFile`:
|
||||
- `index_status`: index status marker from `git status --short`.
|
||||
- `worktree_status`: worktree status marker from `git status --short`.
|
||||
- `path`: repository-relative file path.
|
||||
|
||||
## Build and Deployment Rules
|
||||
|
||||
- No build step is required.
|
||||
- Deployment is a direct executable file install (copy or symlink).
|
||||
|
||||
## Cross References
|
||||
|
||||
- Usage and install details: [`doc/usage.md`](usage.md)
|
||||
- Change tracking: [`CHANGELOG.md`](../CHANGELOG.md)
|
||||
53
doc/usage.md
Normal file
53
doc/usage.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Usage
|
||||
|
||||
## Environment Requirements
|
||||
|
||||
- Linux or Unix-like terminal environment with curses support.
|
||||
- Python 3 interpreter available as `python3`.
|
||||
- Git installed and available in `PATH`.
|
||||
|
||||
## Installation
|
||||
|
||||
1. Make the script executable:
|
||||
```bash
|
||||
chmod +x git-tui
|
||||
```
|
||||
2. Install as a memorable command name (example: `gtui`):
|
||||
```bash
|
||||
sudo install -m 755 git-tui /usr/local/bin/gtui
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
- Enter any Git repository and run:
|
||||
```bash
|
||||
gtui
|
||||
```
|
||||
|
||||
## Key Commands in UI
|
||||
|
||||
- Main menu:
|
||||
- `Up/Down` or `j/k` to move.
|
||||
- `Enter` to execute selected action.
|
||||
- `q` or `Esc` to quit.
|
||||
- File selector:
|
||||
- `Space` to toggle file selection.
|
||||
- `a` to toggle all files.
|
||||
- `Enter` to confirm.
|
||||
- `q` or `Esc` to cancel.
|
||||
|
||||
## Git Operations Covered
|
||||
|
||||
- Stage all changes (`git add -A`).
|
||||
- Stage selected files (`git add -- <paths...>`).
|
||||
- Unstage selected files (`git restore --staged -- <paths...>`).
|
||||
- Commit staged changes (`git commit -m "<message>"`).
|
||||
- Push current branch (`git push`).
|
||||
- Pull with rebase (`git pull --rebase`).
|
||||
- Fetch all remotes (`git fetch --all --prune`).
|
||||
- Show full status (`git status`).
|
||||
|
||||
## Cross References
|
||||
|
||||
- Architecture and contracts: [`doc/architecture.md`](architecture.md)
|
||||
- Change history: [`CHANGELOG.md`](../CHANGELOG.md)
|
||||
611
git-tui
Executable file
611
git-tui
Executable file
@@ -0,0 +1,611 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Terminal UI for common Git workflows.
|
||||
|
||||
This module provides a single-file curses application that helps users run
|
||||
common Git commands from a keyboard-driven terminal interface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import curses
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional, Sequence
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChangedFile:
|
||||
"""Represents one file entry from `git status --short`.
|
||||
|
||||
Attributes:
|
||||
index_status: The index status character from porcelain output.
|
||||
worktree_status: The worktree status character from porcelain output.
|
||||
path: The repository-relative path for the change.
|
||||
"""
|
||||
|
||||
index_status: str
|
||||
worktree_status: str
|
||||
path: str
|
||||
|
||||
def short_status(self) -> str:
|
||||
"""Returns the two-character status code for UI display.
|
||||
|
||||
Returns:
|
||||
A two-character status string in the same shape as porcelain output.
|
||||
"""
|
||||
|
||||
return f"{self.index_status}{self.worktree_status}"
|
||||
|
||||
|
||||
def run_git(args: Sequence[str]) -> tuple[int, str]:
|
||||
"""Executes a Git command and captures combined output.
|
||||
|
||||
Args:
|
||||
args: Command arguments to pass after `git`.
|
||||
|
||||
Returns:
|
||||
A tuple of `(exit_code, output_text)` where output includes both
|
||||
standard output and standard error.
|
||||
"""
|
||||
|
||||
command = ["git", *args]
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
output = (completed.stdout or "") + (completed.stderr or "")
|
||||
return completed.returncode, output.rstrip()
|
||||
|
||||
|
||||
def parse_changed_files(short_status_output: str) -> List[ChangedFile]:
|
||||
"""Parses `git status --short` output into structured file entries.
|
||||
|
||||
Args:
|
||||
short_status_output: Raw output from `git status --short`.
|
||||
|
||||
Returns:
|
||||
A list of parsed changed files in displayed order.
|
||||
"""
|
||||
|
||||
parsed_files: List[ChangedFile] = []
|
||||
for line in short_status_output.splitlines():
|
||||
if len(line) < 4:
|
||||
continue
|
||||
index_status = line[0]
|
||||
worktree_status = line[1]
|
||||
raw_path = line[3:]
|
||||
if " -> " in raw_path:
|
||||
raw_path = raw_path.split(" -> ", maxsplit=1)[1]
|
||||
parsed_files.append(
|
||||
ChangedFile(
|
||||
index_status=index_status,
|
||||
worktree_status=worktree_status,
|
||||
path=raw_path,
|
||||
)
|
||||
)
|
||||
return parsed_files
|
||||
|
||||
|
||||
class GitTuiApp:
|
||||
"""Implements the interactive curses application for Git operations."""
|
||||
|
||||
MENU_ITEMS = [
|
||||
("Stage all changes", "stage_all"),
|
||||
("Stage selected files", "stage_selected"),
|
||||
("Unstage selected files", "unstage_selected"),
|
||||
("Commit staged changes", "commit"),
|
||||
("Push current branch", "push"),
|
||||
("Pull current branch (rebase)", "pull"),
|
||||
("Fetch all remotes", "fetch"),
|
||||
("Show git status", "status"),
|
||||
("Quit", "quit"),
|
||||
]
|
||||
|
||||
def __init__(self, stdscr: "curses._CursesWindow") -> None:
|
||||
"""Initializes UI state for the curses app.
|
||||
|
||||
Args:
|
||||
stdscr: The root curses window provided by `curses.wrapper`.
|
||||
"""
|
||||
|
||||
self.stdscr = stdscr
|
||||
self.status_message = "Ready."
|
||||
|
||||
def run(self) -> None:
|
||||
"""Runs the main menu event loop until the user quits."""
|
||||
|
||||
if not self.ensure_inside_git_repo():
|
||||
self.show_output(
|
||||
"Not a Git repository",
|
||||
"Run git-tui inside a Git repository working directory.",
|
||||
)
|
||||
return
|
||||
|
||||
self.set_cursor_visibility(0)
|
||||
self.stdscr.keypad(True)
|
||||
|
||||
selected_index = 0
|
||||
menu_size = len(self.MENU_ITEMS)
|
||||
|
||||
while True:
|
||||
self.draw_main_screen(selected_index)
|
||||
key = self.stdscr.getch()
|
||||
|
||||
if key in (ord("q"), 27):
|
||||
return
|
||||
if key in (curses.KEY_UP, ord("k")):
|
||||
selected_index = (selected_index - 1) % menu_size
|
||||
continue
|
||||
if key in (curses.KEY_DOWN, ord("j")):
|
||||
selected_index = (selected_index + 1) % menu_size
|
||||
continue
|
||||
if key in (10, 13, curses.KEY_ENTER):
|
||||
action_name = self.MENU_ITEMS[selected_index][1]
|
||||
if action_name == "quit":
|
||||
return
|
||||
self.run_action(action_name)
|
||||
|
||||
def ensure_inside_git_repo(self) -> bool:
|
||||
"""Checks whether the current working directory is in a Git repository.
|
||||
|
||||
Returns:
|
||||
True when inside a Git work tree, otherwise False.
|
||||
"""
|
||||
|
||||
exit_code, output = run_git(["rev-parse", "--is-inside-work-tree"])
|
||||
return exit_code == 0 and output.strip() == "true"
|
||||
|
||||
def current_branch(self) -> str:
|
||||
"""Determines the current branch name for header display.
|
||||
|
||||
Returns:
|
||||
The current branch name, or `detached` if HEAD is detached.
|
||||
"""
|
||||
|
||||
exit_code, output = run_git(["branch", "--show-current"])
|
||||
if exit_code != 0 or not output.strip():
|
||||
return "detached"
|
||||
return output.strip()
|
||||
|
||||
def changed_files(self) -> List[ChangedFile]:
|
||||
"""Reads the working tree status and returns changed files.
|
||||
|
||||
Returns:
|
||||
A list of changed files represented in short porcelain format.
|
||||
"""
|
||||
|
||||
exit_code, output = run_git(
|
||||
["-c", "core.quotePath=false", "status", "--short"]
|
||||
)
|
||||
if exit_code != 0:
|
||||
return []
|
||||
return parse_changed_files(output)
|
||||
|
||||
def draw_main_screen(self, selected_index: int) -> None:
|
||||
"""Renders the main menu with repository summary information.
|
||||
|
||||
Args:
|
||||
selected_index: Index of the currently highlighted menu item.
|
||||
"""
|
||||
|
||||
self.stdscr.erase()
|
||||
height, width = self.stdscr.getmaxyx()
|
||||
branch_name = self.current_branch()
|
||||
changed_count = len(self.changed_files())
|
||||
|
||||
self.safe_addstr(
|
||||
0,
|
||||
0,
|
||||
f"git-tui | branch: {branch_name} | changed files: {changed_count}",
|
||||
curses.A_BOLD,
|
||||
)
|
||||
|
||||
for idx, (label, _) in enumerate(self.MENU_ITEMS):
|
||||
prefix = ">" if idx == selected_index else " "
|
||||
row_text = f"{prefix} {label}"
|
||||
attr = curses.A_REVERSE if idx == selected_index else curses.A_NORMAL
|
||||
self.safe_addstr(2 + idx, 0, row_text, attr)
|
||||
|
||||
self.safe_addstr(height - 2, 0, self.status_message[: max(width - 1, 1)])
|
||||
self.safe_addstr(
|
||||
height - 1,
|
||||
0,
|
||||
"Keys: Up/Down or j/k, Enter run, q or Esc quit",
|
||||
)
|
||||
self.stdscr.refresh()
|
||||
|
||||
def safe_addstr(
|
||||
self, row: int, col: int, text: str, attr: int = curses.A_NORMAL
|
||||
) -> None:
|
||||
"""Draws text safely inside current terminal bounds.
|
||||
|
||||
Args:
|
||||
row: Target row position in the active window.
|
||||
col: Target column position in the active window.
|
||||
text: The text content to draw.
|
||||
attr: Optional curses text attribute.
|
||||
"""
|
||||
|
||||
height, width = self.stdscr.getmaxyx()
|
||||
if row < 0 or row >= height or col >= width:
|
||||
return
|
||||
max_chars = max(width - col - 1, 0)
|
||||
if max_chars == 0:
|
||||
return
|
||||
clipped_text = text[:max_chars]
|
||||
try:
|
||||
self.stdscr.addstr(row, col, clipped_text, attr)
|
||||
except curses.error:
|
||||
return
|
||||
|
||||
def set_cursor_visibility(self, visibility: int) -> None:
|
||||
"""Sets cursor visibility without failing on unsupported terminals.
|
||||
|
||||
Args:
|
||||
visibility: Curses cursor mode where 0 hides and 1 shows.
|
||||
"""
|
||||
|
||||
try:
|
||||
curses.curs_set(visibility)
|
||||
except curses.error:
|
||||
return
|
||||
|
||||
def run_action(self, action_name: str) -> None:
|
||||
"""Dispatches a selected menu action.
|
||||
|
||||
Args:
|
||||
action_name: Identifier for the action selected from the menu.
|
||||
"""
|
||||
|
||||
if action_name == "stage_all":
|
||||
self.action_stage_all()
|
||||
return
|
||||
if action_name == "stage_selected":
|
||||
self.action_stage_selected()
|
||||
return
|
||||
if action_name == "unstage_selected":
|
||||
self.action_unstage_selected()
|
||||
return
|
||||
if action_name == "commit":
|
||||
self.action_commit()
|
||||
return
|
||||
if action_name == "push":
|
||||
self.action_push()
|
||||
return
|
||||
if action_name == "pull":
|
||||
self.action_pull()
|
||||
return
|
||||
if action_name == "fetch":
|
||||
self.action_fetch()
|
||||
return
|
||||
if action_name == "status":
|
||||
self.action_status()
|
||||
return
|
||||
|
||||
def action_stage_all(self) -> None:
|
||||
"""Stages all tracked and untracked changes."""
|
||||
|
||||
exit_code, output = run_git(["add", "-A"])
|
||||
self.show_command_result(["git", "add", "-A"], exit_code, output)
|
||||
|
||||
def action_stage_selected(self) -> None:
|
||||
"""Prompts for changed files and stages selected paths."""
|
||||
|
||||
files = self.changed_files()
|
||||
if not files:
|
||||
self.status_message = "No changed files to stage."
|
||||
return
|
||||
|
||||
selected_paths = self.select_files(files, "Select files to stage")
|
||||
if not selected_paths:
|
||||
self.status_message = "Stage selected canceled."
|
||||
return
|
||||
|
||||
exit_code, output = run_git(["add", "--", *selected_paths])
|
||||
self.show_command_result(
|
||||
["git", "add", "--", *selected_paths], exit_code, output
|
||||
)
|
||||
|
||||
def action_unstage_selected(self) -> None:
|
||||
"""Prompts for staged files and unstages selected paths."""
|
||||
|
||||
staged_files = [
|
||||
file_entry
|
||||
for file_entry in self.changed_files()
|
||||
if file_entry.index_status not in (" ", "?")
|
||||
]
|
||||
if not staged_files:
|
||||
self.status_message = "No staged files to unstage."
|
||||
return
|
||||
|
||||
selected_paths = self.select_files(staged_files, "Select files to unstage")
|
||||
if not selected_paths:
|
||||
self.status_message = "Unstage selected canceled."
|
||||
return
|
||||
|
||||
exit_code, output = run_git(["restore", "--staged", "--", *selected_paths])
|
||||
self.show_command_result(
|
||||
["git", "restore", "--staged", "--", *selected_paths],
|
||||
exit_code,
|
||||
output,
|
||||
)
|
||||
|
||||
def action_commit(self) -> None:
|
||||
"""Prompts for a commit message and runs `git commit`."""
|
||||
|
||||
if not self.has_staged_changes():
|
||||
self.status_message = "No staged changes to commit."
|
||||
return
|
||||
|
||||
commit_message = self.prompt_input("Commit message")
|
||||
if commit_message is None:
|
||||
self.status_message = "Commit canceled."
|
||||
return
|
||||
|
||||
exit_code, output = run_git(["commit", "-m", commit_message])
|
||||
self.show_command_result(
|
||||
["git", "commit", "-m", commit_message],
|
||||
exit_code,
|
||||
output,
|
||||
)
|
||||
|
||||
def action_push(self) -> None:
|
||||
"""Pushes current branch changes to default remote."""
|
||||
|
||||
exit_code, output = run_git(["push"])
|
||||
self.show_command_result(["git", "push"], exit_code, output)
|
||||
|
||||
def action_pull(self) -> None:
|
||||
"""Pulls remote changes using rebase mode."""
|
||||
|
||||
exit_code, output = run_git(["pull", "--rebase"])
|
||||
self.show_command_result(["git", "pull", "--rebase"], exit_code, output)
|
||||
|
||||
def action_fetch(self) -> None:
|
||||
"""Fetches updates from all remotes and prunes deleted refs."""
|
||||
|
||||
exit_code, output = run_git(["fetch", "--all", "--prune"])
|
||||
self.show_command_result(
|
||||
["git", "fetch", "--all", "--prune"], exit_code, output
|
||||
)
|
||||
|
||||
def action_status(self) -> None:
|
||||
"""Shows detailed repository status output."""
|
||||
|
||||
exit_code, output = run_git(["-c", "core.quotePath=false", "status"])
|
||||
if exit_code != 0:
|
||||
self.show_command_result(
|
||||
["git", "-c", "core.quotePath=false", "status"],
|
||||
exit_code,
|
||||
output,
|
||||
)
|
||||
return
|
||||
self.show_output("git status", output or "(no output)")
|
||||
self.status_message = "Displayed repository status."
|
||||
|
||||
def has_staged_changes(self) -> bool:
|
||||
"""Checks whether there are staged changes ready to commit.
|
||||
|
||||
Returns:
|
||||
True when `git diff --cached --quiet` reports staged differences.
|
||||
"""
|
||||
|
||||
exit_code, _ = run_git(["diff", "--cached", "--quiet"])
|
||||
if exit_code == 0:
|
||||
return False
|
||||
if exit_code == 1:
|
||||
return True
|
||||
return False
|
||||
|
||||
def prompt_input(self, label: str) -> Optional[str]:
|
||||
"""Prompts for a single line of text input near the screen footer.
|
||||
|
||||
Args:
|
||||
label: Prompt label displayed before the input field.
|
||||
|
||||
Returns:
|
||||
The entered text, or None when canceled or empty.
|
||||
"""
|
||||
|
||||
height, width = self.stdscr.getmaxyx()
|
||||
prompt = f"{label}: "
|
||||
max_input_len = max(width - len(prompt) - 1, 1)
|
||||
|
||||
self.safe_addstr(height - 2, 0, " " * max(width - 1, 1))
|
||||
self.safe_addstr(height - 2, 0, prompt)
|
||||
self.stdscr.refresh()
|
||||
|
||||
curses.echo()
|
||||
self.set_cursor_visibility(1)
|
||||
try:
|
||||
raw_input = self.stdscr.getstr(height - 2, len(prompt), max_input_len)
|
||||
except curses.error:
|
||||
raw_input = b""
|
||||
finally:
|
||||
curses.noecho()
|
||||
self.set_cursor_visibility(0)
|
||||
|
||||
value = raw_input.decode("utf-8", errors="ignore").strip()
|
||||
if not value:
|
||||
return None
|
||||
return value
|
||||
|
||||
def select_files(
|
||||
self, files: Sequence[ChangedFile], title: str
|
||||
) -> List[str]:
|
||||
"""Shows a multiselect list of files and returns chosen paths.
|
||||
|
||||
Args:
|
||||
files: Candidate file entries that can be selected.
|
||||
title: Header title displayed in the selector.
|
||||
|
||||
Returns:
|
||||
A list of selected file paths, or an empty list when canceled.
|
||||
"""
|
||||
|
||||
selected = [False] * len(files)
|
||||
cursor_index = 0
|
||||
top_index = 0
|
||||
|
||||
while True:
|
||||
self.stdscr.erase()
|
||||
height, _ = self.stdscr.getmaxyx()
|
||||
|
||||
self.safe_addstr(0, 0, title, curses.A_BOLD)
|
||||
self.safe_addstr(
|
||||
1,
|
||||
0,
|
||||
"Space toggle, a toggle all, Enter confirm, q cancel",
|
||||
)
|
||||
|
||||
list_height = max(height - 4, 1)
|
||||
if cursor_index < top_index:
|
||||
top_index = cursor_index
|
||||
if cursor_index >= top_index + list_height:
|
||||
top_index = cursor_index - list_height + 1
|
||||
|
||||
for row_offset in range(list_height):
|
||||
file_index = top_index + row_offset
|
||||
if file_index >= len(files):
|
||||
break
|
||||
marker = "x" if selected[file_index] else " "
|
||||
file_entry = files[file_index]
|
||||
row_text = (
|
||||
f"[{marker}] {file_entry.short_status()} {file_entry.path}"
|
||||
)
|
||||
row_attr = (
|
||||
curses.A_REVERSE
|
||||
if file_index == cursor_index
|
||||
else curses.A_NORMAL
|
||||
)
|
||||
self.safe_addstr(2 + row_offset, 0, row_text, row_attr)
|
||||
|
||||
self.stdscr.refresh()
|
||||
key = self.stdscr.getch()
|
||||
|
||||
if key in (ord("q"), 27):
|
||||
return []
|
||||
if key in (10, 13, curses.KEY_ENTER):
|
||||
return [
|
||||
file_entry.path
|
||||
for idx, file_entry in enumerate(files)
|
||||
if selected[idx]
|
||||
]
|
||||
if key in (curses.KEY_UP, ord("k")):
|
||||
cursor_index = (cursor_index - 1) % len(files)
|
||||
continue
|
||||
if key in (curses.KEY_DOWN, ord("j")):
|
||||
cursor_index = (cursor_index + 1) % len(files)
|
||||
continue
|
||||
if key == ord(" "):
|
||||
selected[cursor_index] = not selected[cursor_index]
|
||||
continue
|
||||
if key == ord("a"):
|
||||
new_value = not all(selected)
|
||||
selected = [new_value] * len(files)
|
||||
continue
|
||||
|
||||
def show_command_result(
|
||||
self, command: Sequence[str], exit_code: int, output: str
|
||||
) -> None:
|
||||
"""Displays command output and updates status message.
|
||||
|
||||
Args:
|
||||
command: Full command arguments for display.
|
||||
exit_code: Process exit code from command execution.
|
||||
output: Captured process output.
|
||||
"""
|
||||
|
||||
command_text = shlex.join(command)
|
||||
result_title = "Command succeeded" if exit_code == 0 else "Command failed"
|
||||
result_body = f"$ {command_text}\n\n{output or '(no output)'}"
|
||||
self.show_output(result_title, result_body)
|
||||
|
||||
if exit_code == 0:
|
||||
self.status_message = f"Success: {command_text}"
|
||||
else:
|
||||
self.status_message = f"Failed ({exit_code}): {command_text}"
|
||||
|
||||
def show_output(self, title: str, text: str) -> None:
|
||||
"""Shows a scrollable read-only text view.
|
||||
|
||||
Args:
|
||||
title: Header displayed at the top of the output view.
|
||||
text: Content rendered line by line.
|
||||
"""
|
||||
|
||||
lines = text.splitlines() if text else ["(no output)"]
|
||||
offset = 0
|
||||
|
||||
while True:
|
||||
self.stdscr.erase()
|
||||
height, _ = self.stdscr.getmaxyx()
|
||||
view_height = max(height - 3, 1)
|
||||
|
||||
self.safe_addstr(0, 0, title, curses.A_BOLD)
|
||||
for row_offset in range(view_height):
|
||||
line_index = offset + row_offset
|
||||
if line_index >= len(lines):
|
||||
break
|
||||
self.safe_addstr(1 + row_offset, 0, lines[line_index])
|
||||
|
||||
self.safe_addstr(
|
||||
height - 1,
|
||||
0,
|
||||
"Keys: Up/Down scroll, PgUp/PgDn page, q or Enter close",
|
||||
)
|
||||
self.stdscr.refresh()
|
||||
|
||||
key = self.stdscr.getch()
|
||||
if key in (ord("q"), 27, 10, 13, curses.KEY_ENTER):
|
||||
return
|
||||
if key == curses.KEY_UP:
|
||||
offset = max(0, offset - 1)
|
||||
continue
|
||||
if key == curses.KEY_DOWN:
|
||||
max_offset = max(len(lines) - view_height, 0)
|
||||
offset = min(max_offset, offset + 1)
|
||||
continue
|
||||
if key == curses.KEY_PPAGE:
|
||||
offset = max(0, offset - view_height)
|
||||
continue
|
||||
if key == curses.KEY_NPAGE:
|
||||
max_offset = max(len(lines) - view_height, 0)
|
||||
offset = min(max_offset, offset + view_height)
|
||||
continue
|
||||
|
||||
|
||||
def run_app(stdscr: "curses._CursesWindow") -> None:
|
||||
"""Creates and runs the curses Git TUI application.
|
||||
|
||||
Args:
|
||||
stdscr: Root curses window provided by `curses.wrapper`.
|
||||
"""
|
||||
|
||||
app = GitTuiApp(stdscr)
|
||||
app.run()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Runs the program entry point and maps terminal failures to exit codes.
|
||||
|
||||
Returns:
|
||||
Process exit code where 0 indicates success.
|
||||
"""
|
||||
|
||||
try:
|
||||
curses.wrapper(run_app)
|
||||
except KeyboardInterrupt:
|
||||
return 130
|
||||
except curses.error as error:
|
||||
print(f"Terminal error: {error}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user