From 2e9aa2beca321b38929b06aa3005dd239f465563 Mon Sep 17 00:00:00 2001 From: Andreas Fiehn Date: Sat, 10 Jan 2026 17:33:49 +0100 Subject: [PATCH 1/9] feat: initial gui setup --- gittask/main.py | 6 ++ gittask/tui/app.py | 48 +++++++++ gittask/tui/css/styles.tcss | 167 +++++++++++++++++++++++++++++ gittask/tui/screens/dashboard.py | 165 ++++++++++++++++++++++++++++ gittask/tui/screens/log_view.py | 26 +++++ gittask/tui/screens/progress.py | 66 ++++++++++++ gittask/tui/screens/status.py | 91 ++++++++++++++++ gittask/tui/screens/task_search.py | 78 ++++++++++++++ gittask/tui/widgets/task_card.py | 110 +++++++++++++++++++ pyproject.toml | 10 ++ 10 files changed, 767 insertions(+) create mode 100644 gittask/tui/app.py create mode 100644 gittask/tui/css/styles.tcss create mode 100644 gittask/tui/screens/dashboard.py create mode 100644 gittask/tui/screens/log_view.py create mode 100644 gittask/tui/screens/progress.py create mode 100644 gittask/tui/screens/status.py create mode 100644 gittask/tui/screens/task_search.py create mode 100644 gittask/tui/widgets/task_card.py diff --git a/gittask/main.py b/gittask/main.py index 76c7510..8f6cda1 100644 --- a/gittask/main.py +++ b/gittask/main.py @@ -21,6 +21,12 @@ app.command(name="start", help="Start time tracking")(session.start) app.command(name="track", help="Track time on a global task")(track.track) +@app.command(name="gui", help="Launch the Graphical User Interface (TUI)") +def gui(): + from .tui.app import GitTaskApp + app = GitTaskApp() + app.run() + @app.callback() def main(ctx: typer.Context): """ diff --git a/gittask/tui/app.py b/gittask/tui/app.py new file mode 100644 index 0000000..924944e --- /dev/null +++ b/gittask/tui/app.py @@ -0,0 +1,48 @@ +from textual.app import App, ComposeResult +from textual.widgets import Header, Footer +from .screens.dashboard import Dashboard +from .screens.task_search import TaskSearch +from .screens.progress import ProgressScreen +from .screens.status import StatusScreen + +class GitTaskApp(App): + """A Textual app for GitTask.""" + + CSS_PATH = "css/styles.tcss" + BINDINGS = [ + ("d", "navigate('dashboard')", "Dashboard"), + ("s", "navigate('search')", "Search Tasks"), + ("p", "navigate('progress')", "Progress"), + ("ctrl+c", "request_quit", "Quit"), + ] + + SCREENS = { + "dashboard": Dashboard, + "search": TaskSearch, + "progress": ProgressScreen, + "status": StatusScreen, + } + + def compose(self) -> ComposeResult: + yield Header() + yield Footer() + + def on_mount(self) -> None: + self.push_screen("dashboard") + self.last_quit_request = 0 + + def action_navigate(self, screen: str) -> None: + self.switch_screen(screen) + + def action_request_quit(self) -> None: + import time + now = time.time() + if now - self.last_quit_request < 1.0: + self.exit() + else: + self.last_quit_request = now + self.notify("Press Ctrl+C again to quit") + +if __name__ == "__main__": + app = GitTaskApp() + app.run() diff --git a/gittask/tui/css/styles.tcss b/gittask/tui/css/styles.tcss new file mode 100644 index 0000000..63f165a --- /dev/null +++ b/gittask/tui/css/styles.tcss @@ -0,0 +1,167 @@ +Screen { + layout: vertical; +} + +Header { + dock: top; +} + +Footer { + dock: bottom; +} + +/* Dashboard Layout */ +.dashboard-container { + layout: vertical; + height: 100%; +} + +.task-grid { + layout: grid; + grid-size: 3; + grid-gutter: 1; + padding: 1; + height: 1fr; + overflow-y: auto; +} + +.bottom-bar { + dock: bottom; + height: auto; + border-top: solid $secondary; + padding: 1; + layout: horizontal; + align: center middle; +} + +/* Task Card Styles */ +TaskCard { + height: 14; + border: solid $primary-muted; + padding: 1; + background: $surface; + layout: vertical; +} + +TaskCard.active { + border: solid $success; + background: $surface-lighten-1; +} + +.task-name { + text-align: center; + text-style: bold; + height: 3; + width: 100%; +} + +.branch-name { + text-align: center; + color: $text-muted; + height: 1; + width: 100%; +} + +.timer { + text-align: center; + text-style: bold; + color: $warning; + height: 3; + content-align: center middle; + width: 100%; + text-opacity: 0%; /* Hidden by default if not active? Or just 00:00:00 */ +} + +TaskCard.active .timer { + text-opacity: 100%; +} + +.card-actions { + align: center bottom; + height: auto; + dock: bottom; +} + +TaskCard Button { + min-width: 10; + margin: 0 1; +} + +/* General Buttons */ +Button { + margin: 0 1; +} + +/* Status Screen Styles */ +.screen-container { + layout: vertical; + height: 100%; +} + +.page-title { + dock: top; + width: 100%; + text-align: center; + background: $primary; + color: $text; + padding: 1; + text-style: bold; +} + +.content-scroll { + height: 1fr; + padding: 1 2; +} + +.status-section { + margin-bottom: 2; + height: auto; +} + +.section-title { + color: $secondary; + text-style: bold; + margin-bottom: 1; +} + +.status-card { + border: solid $primary-muted; + padding: 1 2; + background: $surface; + height: auto; +} + +.status-card.active { + border: solid $success; + background: $surface-lighten-1; +} + +DataTable { + height: auto; + max-height: 20; + border: solid $primary-muted; +} + +/* Log Screen */ +.log-modal { + width: 80%; + height: 80%; + border: solid $error; + background: $surface; + padding: 1; + align: center middle; +} + +.log-title { + text-align: center; + text-style: bold; + color: $error; + margin-bottom: 1; +} + +RichLog { + height: 1fr; + border: solid $secondary; + background: $surface-darken-1; + margin-bottom: 1; +} diff --git a/gittask/tui/screens/dashboard.py b/gittask/tui/screens/dashboard.py new file mode 100644 index 0000000..8ae5271 --- /dev/null +++ b/gittask/tui/screens/dashboard.py @@ -0,0 +1,165 @@ +from textual.app import ComposeResult +from textual.screen import Screen +from textual.widgets import Button, Label, Static +from textual.containers import Container, Horizontal, VerticalScroll +from ...database import DBManager +from ..widgets.task_card import TaskCard +from .log_view import LogScreen +import time + +class Dashboard(Screen): + def __init__(self, **kwargs): + super().__init__(id="dashboard", **kwargs) + + def compose(self) -> ComposeResult: + yield Container( + VerticalScroll(id="task-grid", classes="task-grid"), + Horizontal( + Button("New Task", variant="success", id="new-task-btn"), + Button("Sync", variant="primary", id="sync-btn"), + Button("Status", variant="default", id="status-btn"), + Button("Progress", variant="default", id="progress-btn"), + Button("Quit", variant="error", id="quit-btn"), + classes="bottom-bar" + ), + classes="dashboard-container" + ) + + def on_mount(self) -> None: + self.refresh_tasks() + + def refresh_tasks(self) -> None: + grid = self.query_one("#task-grid") + grid.remove_children() + + db = DBManager() + branch_map = db.branch_map.all() + active = db.get_active_session() + + tasks_to_show = [] + seen_branches = set() + + # Prioritize active session + if active: + branch = active.get('branch') + if branch and branch not in seen_branches: + # Get task details + task_info = db.get_task_for_branch(branch, active.get('repo_path')) + if not task_info: + task_info = { + 'branch': branch, + 'asana_task_name': 'Unknown Task', + 'asana_task_gid': active.get('task_gid') + } + else: + # Ensure branch key exists if it came from branch_map (which uses branch_name) + task_info['branch'] = task_info.get('branch_name', branch) + + tasks_to_show.append(task_info) + seen_branches.add(branch) + + # Add from branch_map + for item in branch_map: + branch = item.get('branch_name') or item.get('branch') + if branch and branch not in seen_branches: + item['branch'] = branch # Normalize for TaskCard + tasks_to_show.append(item) + seen_branches.add(branch) + + # Get current branch + try: + from ...git_handler import GitHandler + git = GitHandler() + current_branch = git.get_current_branch() + except Exception: + current_branch = None + + # Create cards + for task_data in tasks_to_show: + grid.mount(TaskCard(task_data, current_branch=current_branch)) + + def on_task_card_status_changed(self, message: TaskCard.StatusChanged) -> None: + # Refresh all cards to update active state (only one can be active) + # Or just re-mount everything to be safe and simple + self.refresh_tasks() + + def on_task_card_checkout_requested(self, message: TaskCard.CheckoutRequested) -> None: + self.notify(f"Checking out {message.branch}...") + self.run_worker(self.perform_checkout(message.branch)) + + async def perform_checkout(self, branch: str) -> None: + import sys + import subprocess + from .log_view import LogScreen + + # Run checkout command + cmd = [sys.executable, "-m", "gittask.main", "checkout", branch] + + try: + # Run in a thread to avoid blocking the event loop, although subprocess.run blocks the thread. + # Textual workers run in threads by default if not async, but here we are async def. + # We should use asyncio.create_subprocess_exec or run in executor. + # But for simplicity in this context, let's use subprocess.run in a thread via run_worker default behavior + # if we made this non-async, OR use asyncio subprocess. + + # Let's use asyncio subprocess + import asyncio + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await process.communicate() + + output = stdout.decode() + stderr.decode() + + if process.returncode == 0: + self.notify(f"Checked out {branch}") + self.refresh_tasks() + else: + self.app.push_screen(LogScreen("Checkout Failed", output)) + + except Exception as e: + self.notify(f"Checkout failed: {e}", severity="error") + + async def perform_sync(self) -> None: + import sys + import subprocess + from .log_view import LogScreen + import asyncio + + self.notify("Syncing with Asana...") + + # Run sync command + cmd = [sys.executable, "-m", "gittask.main", "sync"] + + try: + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await process.communicate() + + output = stdout.decode() + stderr.decode() + + if process.returncode == 0: + self.notify("Sync completed successfully") + self.refresh_tasks() + else: + self.app.push_screen(LogScreen("Sync Failed", output)) + + except Exception as e: + self.notify(f"Sync failed: {e}", severity="error") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "new-task-btn": + self.app.action_navigate("search") + elif event.button.id == "sync-btn": + self.run_worker(self.perform_sync()) + elif event.button.id == "status-btn": + self.app.action_navigate("status") + elif event.button.id == "progress-btn": + self.app.action_navigate("progress") + elif event.button.id == "quit-btn": + self.app.action_request_quit() diff --git a/gittask/tui/screens/log_view.py b/gittask/tui/screens/log_view.py new file mode 100644 index 0000000..b8aaa4c --- /dev/null +++ b/gittask/tui/screens/log_view.py @@ -0,0 +1,26 @@ +from textual.app import ComposeResult +from textual.screen import ModalScreen +from textual.widgets import Label, Button, RichLog +from textual.containers import Container, Vertical + +class LogScreen(ModalScreen): + def __init__(self, title: str, content: str, **kwargs): + super().__init__(**kwargs) + self.log_title = title + self.content = content + + def compose(self) -> ComposeResult: + yield Container( + Label(self.log_title, classes="log-title"), + RichLog(id="log-view", wrap=True, highlight=True, markup=True), + Button("Close", variant="primary", id="close-btn"), + classes="log-modal" + ) + + def on_mount(self) -> None: + log = self.query_one(RichLog) + log.write(self.content) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "close-btn": + self.dismiss() diff --git a/gittask/tui/screens/progress.py b/gittask/tui/screens/progress.py new file mode 100644 index 0000000..5d7acf6 --- /dev/null +++ b/gittask/tui/screens/progress.py @@ -0,0 +1,66 @@ +from textual.app import ComposeResult +from textual.screen import Screen +from textual.widgets import Label, Button, DataTable +from textual.containers import Container +from ...database import DBManager +import time +from datetime import datetime, timedelta + +class ProgressScreen(Screen): + def __init__(self, **kwargs): + super().__init__(id="progress", **kwargs) + + def compose(self) -> ComposeResult: + yield Container( + Label("Progress & Statistics", classes="header"), + Label("Daily Summary", classes="subheader"), + DataTable(id="daily-stats"), + Button("Back to Dashboard", variant="default", id="back-btn") + ) + + def on_mount(self) -> None: + table = self.query_one("#daily-stats", DataTable) + table.add_columns("Date", "Total Time", "Tasks Worked On") + self.update_stats() + + def update_stats(self) -> None: + db = DBManager() + sessions = db.time_sessions.all() + + # Group by date + daily_stats = {} + for s in sessions: + if not s['start_time']: + continue + + date_str = time.strftime('%Y-%m-%d', time.localtime(s['start_time'])) + + if date_str not in daily_stats: + daily_stats[date_str] = {'duration': 0, 'tasks': set()} + + duration = s.get('duration_seconds', 0) + if s['end_time'] is None: + # Active session, calculate current duration + duration = time.time() - s['start_time'] + + daily_stats[date_str]['duration'] += duration + daily_stats[date_str]['tasks'].add(s['branch']) + + table = self.query_one("#daily-stats", DataTable) + table.clear() + + # Sort by date desc + sorted_dates = sorted(daily_stats.keys(), reverse=True) + + for date in sorted_dates: + stats = daily_stats[date] + duration = stats['duration'] + hours = int(duration // 3600) + minutes = int((duration % 3600) // 60) + task_count = len(stats['tasks']) + + table.add_row(date, f"{hours}h {minutes}m", str(task_count)) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "back-btn": + self.app.action_navigate("dashboard") diff --git a/gittask/tui/screens/status.py b/gittask/tui/screens/status.py new file mode 100644 index 0000000..c98c48a --- /dev/null +++ b/gittask/tui/screens/status.py @@ -0,0 +1,91 @@ +from textual.app import ComposeResult +from textual.screen import Screen +from textual.widgets import Label, Button, DataTable, Static +from textual.containers import Container, VerticalScroll, Horizontal +from ...database import DBManager +import time +from datetime import datetime + +class StatusScreen(Screen): + def __init__(self, **kwargs): + super().__init__(id="status", **kwargs) + + def compose(self) -> ComposeResult: + yield Container( + Label("Status Overview", classes="page-title"), + VerticalScroll( + Container( + Label("Current Session", classes="section-title"), + Static(id="current-status-info", classes="status-card"), + classes="status-section" + ), + Container( + Label("Unsynced Sessions", classes="section-title"), + DataTable(id="unsynced-table"), + classes="status-section" + ), + classes="content-scroll" + ), + Horizontal( + Button("Back to Dashboard", variant="default", id="back-btn"), + classes="bottom-bar" + ), + classes="screen-container" + ) + + def on_mount(self) -> None: + table = self.query_one("#unsynced-table", DataTable) + table.add_columns("Branch", "Duration", "Date") + self.update_status() + + def on_screen_resume(self) -> None: + self.update_status() + + def update_status(self) -> None: + db = DBManager() + + # Current Session + active = db.get_active_session() + status_info = self.query_one("#current-status-info", Static) + + if active: + start_time = active['start_time'] + duration = time.time() - start_time + hours = int(duration // 3600) + minutes = int((duration % 3600) // 60) + + branch = active['branch'] + if branch.startswith("@global:"): + branch = branch.replace("@global:", "") + " (Global)" + + task_info = db.get_task_for_branch(active['branch'], active.get('repo_path')) + task_name = task_info['asana_task_name'] if task_info else "Unknown Task" + + status_text = ( + f"[bold green]Currently Tracking[/bold green]\n\n" + f"[bold]Branch:[/bold] {branch}\n" + f"[bold]Task:[/bold] {task_name}\n" + f"[bold]Duration:[/bold] {hours}h {minutes}m" + ) + status_info.update(status_text) + status_info.add_class("active") + else: + status_info.update("[yellow]No active time tracking session.[/yellow]") + status_info.remove_class("active") + + # Unsynced Sessions + unsynced = db.get_unsynced_sessions() + table = self.query_one("#unsynced-table", DataTable) + table.clear() + + for s in unsynced: + if s['end_time']: + duration = s['duration_seconds'] + hours = int(duration // 3600) + minutes = int((duration % 3600) // 60) + date_str = datetime.fromtimestamp(s['start_time']).strftime('%Y-%m-%d %H:%M') + table.add_row(s['branch'], f"{hours}h {minutes}m", date_str) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "back-btn": + self.app.action_navigate("dashboard") diff --git a/gittask/tui/screens/task_search.py b/gittask/tui/screens/task_search.py new file mode 100644 index 0000000..ba7dbf6 --- /dev/null +++ b/gittask/tui/screens/task_search.py @@ -0,0 +1,78 @@ +from textual.app import ComposeResult +from textual.screen import Screen +from textual.widgets import Input, ListView, ListItem, Label, Button +from textual.containers import Container +from ...config import ConfigManager +from ...asana_client import AsanaClient +from ...database import DBManager + +class TaskSearch(Screen): + def __init__(self, **kwargs): + super().__init__(id="search", **kwargs) + + def compose(self) -> ComposeResult: + yield Container( + Label("Search Asana Tasks"), + Input(placeholder="Type to search...", id="search-input"), + ListView(id="results-list"), + Button("Back to Dashboard", variant="default", id="back-btn") + ) + + def on_input_submitted(self, event: Input.Submitted) -> None: + query = event.value + if query: + self.search_tasks(query) + + def search_tasks(self, query: str) -> None: + config = ConfigManager() + token = config.get_api_token() + workspace_gid = config.get_default_workspace() + + if not token or not workspace_gid: + self.notify("Not authenticated or no workspace set", severity="error") + return + + try: + with AsanaClient(token) as client: + tasks = client.search_tasks(workspace_gid, query) + + list_view = self.query_one("#results-list", ListView) + list_view.clear() + + for task in tasks: + list_view.append(ListItem(Label(task['name']), name=task['gid'])) + + except Exception as e: + self.notify(f"Search failed: {e}", severity="error") + + def on_screen_resume(self) -> None: + # Clear previous state + self.query_one("#search-input", Input).value = "" + self.query_one("#results-list", ListView).clear() + self.query_one("#search-input", Input).focus() + + def on_list_view_selected(self, event: ListView.Selected) -> None: + task_gid = event.item.name + # Get text from the Label widget + label = event.item.query_one(Label) + task_name = str(label.renderable) + + # Start global session + db = DBManager() + # For global tasks, we use a special branch name format + branch_name = f"@global:{task_name.replace(' ', '_')}" + + db.start_session(branch_name, "GLOBAL", task_gid) + + # Also need to link it in branch_map if we want to persist the name mapping? + # Actually start_session just takes task_gid. + # But get_task_for_branch needs an entry in branch_map to return task details. + # So we should link it. + db.link_branch_to_task(branch_name, "GLOBAL", task_gid, task_name, "None", "None") + + self.notify(f"Started tracking: {task_name}") + self.app.action_navigate("dashboard") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "back-btn": + self.app.action_navigate("dashboard") diff --git a/gittask/tui/widgets/task_card.py b/gittask/tui/widgets/task_card.py new file mode 100644 index 0000000..d366dd2 --- /dev/null +++ b/gittask/tui/widgets/task_card.py @@ -0,0 +1,110 @@ +from textual.app import ComposeResult +from textual.containers import Container, Horizontal +from textual.widgets import Static, Button, Label +from textual.reactive import reactive +from textual.message import Message +from ...database import DBManager +import time + +class TaskCard(Static): + """A card widget representing a task.""" + + is_active = reactive(False) + duration = reactive(0.0) + + def __init__(self, task_data: dict, current_branch: str = None, **kwargs): + super().__init__(**kwargs) + self.task_data = task_data + self.branch_name = task_data.get('branch') + self.task_name = task_data.get('asana_task_name', 'Unknown Task') + self.task_gid = task_data.get('asana_task_gid') + self.current_branch = current_branch + + # Check if active + db = DBManager() + active_session = db.get_active_session() + if active_session and active_session['branch'] == self.branch_name: + self.is_active = True + self.start_time = active_session['start_time'] + else: + self.is_active = False + self.start_time = None + + def compose(self) -> ComposeResult: + yield Label(self.task_name, classes="task-name") + yield Label(self.branch_name, classes="branch-name") + yield Label("00:00:00", classes="timer", id=f"timer-{self.id}") + + with Horizontal(classes="card-actions"): + if self.is_active: + yield Button("Stop", variant="error", id="stop-btn") + else: + yield Button("Start", variant="success", id="start-btn") + + if self.branch_name and not self.branch_name.startswith("@global:"): + # Only show checkout if not already on this branch + if self.branch_name != self.current_branch: + yield Button("Checkout", variant="primary", id="checkout-btn") + + def on_mount(self) -> None: + self.set_interval(1, self.update_timer) + if self.is_active: + self.add_class("active") + + def update_timer(self) -> None: + if self.is_active and self.start_time: + self.duration = time.time() - self.start_time + hours = int(self.duration // 3600) + minutes = int((self.duration % 3600) // 60) + seconds = int(self.duration % 60) + self.query_one(f"#timer-{self.id}", Label).update(f"{hours:02}:{minutes:02}:{seconds:02}") + else: + # Maybe show total time for this task? + # For now just show 0 or last duration + pass + + def on_button_pressed(self, event: Button.Pressed) -> None: + event.stop() + button_id = event.button.id + db = DBManager() + + if button_id == "start-btn": + # Start session + # If another session is active, stop it first (DBManager handles this usually or we should) + db.stop_any_active_session() + db.start_session(self.branch_name, "LOCAL" if not self.branch_name.startswith("@global:") else "GLOBAL", self.task_gid) + self.is_active = True + self.start_time = time.time() + self.add_class("active") + + # Re-compose to show Stop button? Or just update button + # Textual doesn't easily support replacing widgets in-place without removing/adding + # But we can swap the button display if we had both and hid one, or just remove and mount new one. + # Simpler: Post a message to Dashboard to refresh everything + self.post_message(self.StatusChanged()) + + elif button_id == "stop-btn": + db.stop_any_active_session() + self.is_active = False + self.remove_class("active") + self.post_message(self.StatusChanged()) + + elif button_id == "checkout-btn": + # Trigger checkout + # This requires git command execution. + # For TUI, maybe we just notify for now or use gitpython if available in DB/helpers + # The user asked for "checkout button if it is a branch linked task" + # We can use os.system or subprocess, or better, the Checkout command logic. + # But importing commands might be circular or heavy. + # Let's post a message to App/Dashboard to handle checkout + self.post_message(self.CheckoutRequested(self.branch_name)) + + class StatusChanged(Message): + """Sent when task status changes (start/stop).""" + pass + + class CheckoutRequested(Message): + """Sent when checkout is requested.""" + def __init__(self, branch: str): + self.branch = branch + super().__init__() diff --git a/pyproject.toml b/pyproject.toml index 52162cc..4c500af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,12 +14,14 @@ dependencies = [ "python-dateutil>=2.8.2", "thefuzz>=0.20.0", "PyGithub", + "textual>=0.40.0", ] [project.optional-dependencies] dev = [ "pytest", "pytest-mock", + "pytest-asyncio", ] [project.scripts] @@ -33,3 +35,11 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["gittask"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +python_files = "test_*.py" +markers = [ + "asyncio: mark test as async", +] From c5d562eef7d1efcb28a543c855a6228d5707386d Mon Sep 17 00:00:00 2001 From: Andreas Fiehn Date: Sat, 10 Jan 2026 17:58:37 +0100 Subject: [PATCH 2/9] fix: repo path --- gittask/tui/css/styles.tcss | 34 ++++++++++++ gittask/tui/screens/task_options.py | 82 +++++++++++++++++++++++++++++ gittask/tui/screens/task_search.py | 65 ++++++++++++++++++++--- gittask/tui/widgets/task_card.py | 17 +++++- 4 files changed, 191 insertions(+), 7 deletions(-) create mode 100644 gittask/tui/screens/task_options.py diff --git a/gittask/tui/css/styles.tcss b/gittask/tui/css/styles.tcss index 63f165a..6225fd9 100644 --- a/gittask/tui/css/styles.tcss +++ b/gittask/tui/css/styles.tcss @@ -165,3 +165,37 @@ RichLog { background: $surface-darken-1; margin-bottom: 1; } + +/* Options Modal */ +.options-modal { + width: 60; + height: auto; + border: solid $primary; + background: $surface; + padding: 1 2; + align: center middle; +} + +.modal-header { + text-align: center; + text-style: bold; + margin-bottom: 1; + color: $accent; +} + +.hidden { + display: none; +} + +#select-mode Button { + width: 100%; + margin-bottom: 1; +} + +#input-mode { + height: auto; +} + +#branch-input { + margin: 1 0; +} diff --git a/gittask/tui/screens/task_options.py b/gittask/tui/screens/task_options.py new file mode 100644 index 0000000..3fa591e --- /dev/null +++ b/gittask/tui/screens/task_options.py @@ -0,0 +1,82 @@ +from textual.app import ComposeResult +from textual.screen import ModalScreen +from textual.widgets import Label, Button, Input +from textual.containers import Container, Vertical, Horizontal +import re + +class TaskOptionsModal(ModalScreen): + def __init__(self, task_name: str, task_gid: str, **kwargs): + super().__init__(**kwargs) + self.task_name = task_name + self.task_gid = task_gid + self.mode = "select" # select, create_branch, checkout_existing + + def compose(self) -> ComposeResult: + with Container(classes="options-modal"): + yield Label(f"Task: {self.task_name}", classes="modal-header") + + with Vertical(id="select-mode"): + yield Button("Create New Branch", variant="success", id="btn-create") + yield Button("Checkout Existing Branch", variant="primary", id="btn-checkout") + yield Button("Track Globally (No Branch)", variant="default", id="btn-global") + yield Button("Cancel", variant="error", id="btn-cancel") + + with Vertical(id="input-mode", classes="hidden"): + yield Label("Enter Branch Name:", id="input-label") + yield Input(id="branch-input") + with Horizontal(): + yield Button("Confirm", variant="success", id="btn-confirm") + yield Button("Back", variant="default", id="btn-back") + + def on_button_pressed(self, event: Button.Pressed) -> None: + btn_id = event.button.id + + if btn_id == "btn-cancel": + self.dismiss(None) + + elif btn_id == "btn-global": + self.dismiss({ + "action": "track_global", + "task_name": self.task_name, + "task_gid": self.task_gid + }) + + elif btn_id == "btn-create": + self.mode = "create_branch" + self.show_input("Create Branch", self._slugify(self.task_name)) + + elif btn_id == "btn-checkout": + self.mode = "checkout_existing" + self.show_input("Checkout Branch", "") + + elif btn_id == "btn-back": + self.mode = "select" + self.query_one("#select-mode").remove_class("hidden") + self.query_one("#input-mode").add_class("hidden") + + elif btn_id == "btn-confirm": + branch_name = self.query_one("#branch-input", Input).value + if branch_name: + self.dismiss({ + "action": self.mode, + "branch_name": branch_name, + "task_name": self.task_name, + "task_gid": self.task_gid + }) + + def show_input(self, title: str, value: str) -> None: + self.query_one("#select-mode").add_class("hidden") + input_container = self.query_one("#input-mode") + input_container.remove_class("hidden") + + self.query_one("#input-label", Label).update(title) + inp = self.query_one("#branch-input", Input) + inp.value = value + inp.focus() + + def _slugify(self, text: str) -> str: + # Simple slugify + text = text.lower() + text = re.sub(r'[^a-z0-9\s-]', '', text) + text = re.sub(r'[\s-]+', '-', text).strip('-') + return f"feature/{text}" diff --git a/gittask/tui/screens/task_search.py b/gittask/tui/screens/task_search.py index ba7dbf6..74a1f75 100644 --- a/gittask/tui/screens/task_search.py +++ b/gittask/tui/screens/task_search.py @@ -57,22 +57,75 @@ def on_list_view_selected(self, event: ListView.Selected) -> None: label = event.item.query_one(Label) task_name = str(label.renderable) + from .task_options import TaskOptionsModal + self.app.push_screen(TaskOptionsModal(task_name, task_gid), self.handle_options) + + def handle_options(self, result: dict) -> None: + if not result: + return + + action = result.get("action") + + if action == "track_global": + self.start_global_tracking(result.get("task_name"), result.get("task_gid")) # Wait, modal doesn't pass these back? + # Modal passes action and branch_name. + # I need task info. I can store it in self or pass it through modal result? + # Better to store selected task in self temporarily or pass it to modal and have modal return it. + # Let's assume modal returns what we need or we know what we selected. + # Actually, handle_options receives the result from dismiss. + # The modal has task_name and task_gid. It should probably include them in result. + pass + + elif action == "create_branch": + branch_name = result.get("branch_name") + self.perform_checkout(branch_name, create_new=True) + + elif action == "checkout_existing": + branch_name = result.get("branch_name") + self.perform_checkout(branch_name, create_new=False) + + def start_global_tracking(self, task_name: str, task_gid: str) -> None: # Start global session db = DBManager() - # For global tasks, we use a special branch name format branch_name = f"@global:{task_name.replace(' ', '_')}" db.start_session(branch_name, "GLOBAL", task_gid) - - # Also need to link it in branch_map if we want to persist the name mapping? - # Actually start_session just takes task_gid. - # But get_task_for_branch needs an entry in branch_map to return task details. - # So we should link it. db.link_branch_to_task(branch_name, "GLOBAL", task_gid, task_name, "None", "None") self.notify(f"Started tracking: {task_name}") self.app.action_navigate("dashboard") + def perform_checkout(self, branch_name: str, create_new: bool) -> None: + self.notify(f"Checking out {branch_name}...") + self.run_worker(self._checkout_worker(branch_name, create_new)) + + async def _checkout_worker(self, branch_name: str, create_new: bool) -> None: + import sys + import asyncio + from .log_view import LogScreen + + cmd = [sys.executable, "-m", "gittask.main", "checkout", branch_name] + if create_new: + cmd.append("-b") + + try: + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await process.communicate() + output = stdout.decode() + stderr.decode() + + if process.returncode == 0: + self.notify(f"Checked out {branch_name}") + self.app.action_navigate("dashboard") + else: + self.app.push_screen(LogScreen("Checkout Failed", output)) + + except Exception as e: + self.notify(f"Checkout failed: {e}", severity="error") + def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "back-btn": self.app.action_navigate("dashboard") diff --git a/gittask/tui/widgets/task_card.py b/gittask/tui/widgets/task_card.py index d366dd2..a4555f2 100644 --- a/gittask/tui/widgets/task_card.py +++ b/gittask/tui/widgets/task_card.py @@ -72,7 +72,22 @@ def on_button_pressed(self, event: Button.Pressed) -> None: # Start session # If another session is active, stop it first (DBManager handles this usually or we should) db.stop_any_active_session() - db.start_session(self.branch_name, "LOCAL" if not self.branch_name.startswith("@global:") else "GLOBAL", self.task_gid) + + repo_path = self.task_data.get('repo_path') + if not repo_path: + # Fallback if not in task_data (shouldn't happen for branch tasks) + if self.branch_name.startswith("@global:"): + repo_path = "GLOBAL" + else: + from ...git_handler import GitHandler + import os + try: + repo_path = GitHandler().get_repo_root() + except: + # Fallback to current working directory if git fails + repo_path = os.getcwd() + + db.start_session(self.branch_name, repo_path, self.task_gid) self.is_active = True self.start_time = time.time() self.add_class("active") From c680f62194e4c9b3476a8f3d10b2cfcfae4df3cf Mon Sep 17 00:00:00 2001 From: Andreas Fiehn Date: Sat, 10 Jan 2026 18:15:58 +0100 Subject: [PATCH 3/9] feat: search tasks and add new task --- gittask/git_handler.py | 10 ++++++++++ gittask/main.py | 15 +++++++++++++++ gittask/tui/screens/dashboard.py | 3 +++ gittask/tui/screens/task_search.py | 9 +++++---- gittask/tui/widgets/task_card.py | 10 ++++++++++ 5 files changed, 43 insertions(+), 4 deletions(-) diff --git a/gittask/git_handler.py b/gittask/git_handler.py index a18862b..33a2fc3 100644 --- a/gittask/git_handler.py +++ b/gittask/git_handler.py @@ -33,3 +33,13 @@ def get_remote_url(self, remote_name: str = "origin") -> Optional[str]: return self.repo.remote(remote_name).url except ValueError: return None + + def push_branch(self, branch_name: str, remote_name: str = "origin"): + try: + remote = self.repo.remote(remote_name) + # Push and set upstream + remote.push(refspec=f"{branch_name}:{branch_name}", set_upstream=True) + except ValueError: + raise Exception(f"Remote '{remote_name}' not found") + except git.exc.GitCommandError as e: + raise Exception(f"Failed to push branch: {e}") diff --git a/gittask/main.py b/gittask/main.py index 8f6cda1..47a507b 100644 --- a/gittask/main.py +++ b/gittask/main.py @@ -24,6 +24,21 @@ @app.command(name="gui", help="Launch the Graphical User Interface (TUI)") def gui(): from .tui.app import GitTaskApp + from .config import ConfigManager + from .asana_client import AsanaClient + + # Warm up AsanaClient to initialize multiprocessing pool before Textual starts + # This prevents "bad value(s) in fds_to_keep" error on macOS + try: + config = ConfigManager() + token = config.get_api_token() + if token: + client = AsanaClient(token) + client.close() + except Exception: + # Ignore errors here, let the app handle them or fail later + pass + app = GitTaskApp() app.run() diff --git a/gittask/tui/screens/dashboard.py b/gittask/tui/screens/dashboard.py index 8ae5271..6bf99ca 100644 --- a/gittask/tui/screens/dashboard.py +++ b/gittask/tui/screens/dashboard.py @@ -28,6 +28,9 @@ def compose(self) -> ComposeResult: def on_mount(self) -> None: self.refresh_tasks() + def on_screen_resume(self) -> None: + self.refresh_tasks() + def refresh_tasks(self) -> None: grid = self.query_one("#task-grid") grid.remove_children() diff --git a/gittask/tui/screens/task_search.py b/gittask/tui/screens/task_search.py index 74a1f75..77a5588 100644 --- a/gittask/tui/screens/task_search.py +++ b/gittask/tui/screens/task_search.py @@ -40,7 +40,9 @@ def search_tasks(self, query: str) -> None: list_view.clear() for task in tasks: - list_view.append(ListItem(Label(task['name']), name=task['gid'])) + item = ListItem(Label(task['name']), name=task['gid']) + item.task_name = task['name'] + list_view.append(item) except Exception as e: self.notify(f"Search failed: {e}", severity="error") @@ -53,9 +55,8 @@ def on_screen_resume(self) -> None: def on_list_view_selected(self, event: ListView.Selected) -> None: task_gid = event.item.name - # Get text from the Label widget - label = event.item.query_one(Label) - task_name = str(label.renderable) + task_gid = event.item.name + task_name = getattr(event.item, 'task_name', 'Unknown Task') from .task_options import TaskOptionsModal self.app.push_screen(TaskOptionsModal(task_name, task_gid), self.handle_options) diff --git a/gittask/tui/widgets/task_card.py b/gittask/tui/widgets/task_card.py index a4555f2..5cb8201 100644 --- a/gittask/tui/widgets/task_card.py +++ b/gittask/tui/widgets/task_card.py @@ -45,6 +45,8 @@ def compose(self) -> ComposeResult: # Only show checkout if not already on this branch if self.branch_name != self.current_branch: yield Button("Checkout", variant="primary", id="checkout-btn") + else: + yield Button("Push", variant="default", id="push-btn") def on_mount(self) -> None: self.set_interval(1, self.update_timer) @@ -114,6 +116,14 @@ def on_button_pressed(self, event: Button.Pressed) -> None: # Let's post a message to App/Dashboard to handle checkout self.post_message(self.CheckoutRequested(self.branch_name)) + elif button_id == "push-btn": + from ...git_handler import GitHandler + try: + GitHandler().push_branch(self.branch_name) + self.notify(f"Successfully pushed {self.branch_name}", title="Push Success", severity="information") + except Exception as e: + self.notify(f"Failed to push: {e}", title="Push Error", severity="error") + class StatusChanged(Message): """Sent when task status changes (start/stop).""" pass From 7d5a331569576dfcf01522f2defc4c5127595f14 Mon Sep 17 00:00:00 2001 From: Andreas Fiehn Date: Sat, 10 Jan 2026 18:21:08 +0100 Subject: [PATCH 4/9] feat: task search loadin animation --- gittask/tui/screens/task_search.py | 43 ++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/gittask/tui/screens/task_search.py b/gittask/tui/screens/task_search.py index 77a5588..c86caca 100644 --- a/gittask/tui/screens/task_search.py +++ b/gittask/tui/screens/task_search.py @@ -1,6 +1,7 @@ from textual.app import ComposeResult +from textual import work from textual.screen import Screen -from textual.widgets import Input, ListView, ListItem, Label, Button +from textual.widgets import Input, ListView, ListItem, Label, Button, LoadingIndicator from textual.containers import Container from ...config import ConfigManager from ...asana_client import AsanaClient @@ -14,6 +15,7 @@ def compose(self) -> ComposeResult: yield Container( Label("Search Asana Tasks"), Input(placeholder="Type to search...", id="search-input"), + LoadingIndicator(id="loading"), ListView(id="results-list"), Button("Back to Dashboard", variant="default", id="back-btn") ) @@ -32,26 +34,45 @@ def search_tasks(self, query: str) -> None: self.notify("Not authenticated or no workspace set", severity="error") return + try: + self.query_one("#loading").display = True + self.query_one("#results-list").display = False + self._search_worker(query, token, workspace_gid) + except Exception as e: + self.notify(f"Search failed: {e}", severity="error") + + @work(exclusive=True, thread=True) + def _search_worker(self, query: str, token: str, workspace_gid: str) -> None: try: with AsanaClient(token) as client: tasks = client.search_tasks(workspace_gid, query) - - list_view = self.query_one("#results-list", ListView) - list_view.clear() - - for task in tasks: - item = ListItem(Label(task['name']), name=task['gid']) - item.task_name = task['name'] - list_view.append(item) - + self.app.call_from_thread(self._update_results, tasks) except Exception as e: - self.notify(f"Search failed: {e}", severity="error") + self.app.call_from_thread(self._handle_search_error, e) + + def _update_results(self, tasks: list) -> None: + self.query_one("#loading").display = False + list_view = self.query_one("#results-list") + list_view.display = True + list_view.clear() + + for task in tasks: + item = ListItem(Label(task['name']), name=task['gid']) + item.task_name = task['name'] + list_view.append(item) + + def _handle_search_error(self, error: Exception) -> None: + self.query_one("#loading").display = False + self.query_one("#results-list").display = True + self.notify(f"Search failed: {error}", severity="error") def on_screen_resume(self) -> None: # Clear previous state self.query_one("#search-input", Input).value = "" self.query_one("#results-list", ListView).clear() self.query_one("#search-input", Input).focus() + self.query_one("#loading").display = False + self.query_one("#results-list").display = True def on_list_view_selected(self, event: ListView.Selected) -> None: task_gid = event.item.name From 26c48271e56e21e190ef94a755c093b049404707 Mon Sep 17 00:00:00 2001 From: Andreas Fiehn Date: Sat, 10 Jan 2026 18:22:55 +0100 Subject: [PATCH 5/9] feat: create new task in the gui --- gittask/tui/screens/task_search.py | 46 ++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/gittask/tui/screens/task_search.py b/gittask/tui/screens/task_search.py index c86caca..6d16db5 100644 --- a/gittask/tui/screens/task_search.py +++ b/gittask/tui/screens/task_search.py @@ -46,16 +46,21 @@ def _search_worker(self, query: str, token: str, workspace_gid: str) -> None: try: with AsanaClient(token) as client: tasks = client.search_tasks(workspace_gid, query) - self.app.call_from_thread(self._update_results, tasks) + self.app.call_from_thread(self._update_results, tasks, query) except Exception as e: self.app.call_from_thread(self._handle_search_error, e) - def _update_results(self, tasks: list) -> None: + def _update_results(self, tasks: list, query: str) -> None: self.query_one("#loading").display = False list_view = self.query_one("#results-list") list_view.display = True list_view.clear() + # Add "Create Task" option + create_item = ListItem(Label(f"[+] Create task \"{query}\""), name="create_new_task") + create_item.task_name = query + list_view.append(create_item) + for task in tasks: item = ListItem(Label(task['name']), name=task['gid']) item.task_name = task['name'] @@ -75,13 +80,48 @@ def on_screen_resume(self) -> None: self.query_one("#results-list").display = True def on_list_view_selected(self, event: ListView.Selected) -> None: - task_gid = event.item.name + if event.item.name == "create_new_task": + task_name = getattr(event.item, 'task_name', 'New Task') + self.create_task(task_name) + return + task_gid = event.item.name task_name = getattr(event.item, 'task_name', 'Unknown Task') from .task_options import TaskOptionsModal self.app.push_screen(TaskOptionsModal(task_name, task_gid), self.handle_options) + def create_task(self, task_name: str) -> None: + self.query_one("#loading").display = True + self.query_one("#results-list").display = False + self._create_task_worker(task_name) + + @work(exclusive=True, thread=True) + def _create_task_worker(self, task_name: str) -> None: + try: + config = ConfigManager() + token = config.get_api_token() + workspace_gid = config.get_default_workspace() + project_gid = config.get_default_project() + + with AsanaClient(token) as client: + new_task = client.create_task(workspace_gid, project_gid, task_name) + + self.app.call_from_thread(self._on_task_created, new_task) + + except Exception as e: + self.app.call_from_thread(self._handle_search_error, e) + + def _on_task_created(self, task: dict) -> None: + self.query_one("#loading").display = False + self.query_one("#results-list").display = True + + task_gid = task['gid'] + task_name = task['name'] + + from .task_options import TaskOptionsModal + self.app.push_screen(TaskOptionsModal(task_name, task_gid), self.handle_options) + def handle_options(self, result: dict) -> None: if not result: return From 418ceca87fa004284f3361d7fcb0a46078cf91fc Mon Sep 17 00:00:00 2001 From: Andreas Fiehn Date: Sat, 10 Jan 2026 18:25:08 +0100 Subject: [PATCH 6/9] feat: autocomplete git branch names --- gittask/tui/screens/task_options.py | 36 ++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/gittask/tui/screens/task_options.py b/gittask/tui/screens/task_options.py index 3fa591e..26d5ac6 100644 --- a/gittask/tui/screens/task_options.py +++ b/gittask/tui/screens/task_options.py @@ -1,15 +1,24 @@ from textual.app import ComposeResult from textual.screen import ModalScreen -from textual.widgets import Label, Button, Input +from textual.widgets import Label, Button, Input, ListView, ListItem from textual.containers import Container, Vertical, Horizontal import re +from ...git_handler import GitHandler class TaskOptionsModal(ModalScreen): def __init__(self, task_name: str, task_gid: str, **kwargs): super().__init__(**kwargs) self.task_name = task_name self.task_gid = task_gid + self.task_gid = task_gid self.mode = "select" # select, create_branch, checkout_existing + self.all_branches = [] + + def on_mount(self) -> None: + try: + self.all_branches = GitHandler().list_branches() + except Exception: + self.all_branches = [] def compose(self) -> ComposeResult: with Container(classes="options-modal"): @@ -24,10 +33,34 @@ def compose(self) -> ComposeResult: with Vertical(id="input-mode", classes="hidden"): yield Label("Enter Branch Name:", id="input-label") yield Input(id="branch-input") + yield ListView(id="branch-suggestions", classes="hidden") with Horizontal(): yield Button("Confirm", variant="success", id="btn-confirm") yield Button("Back", variant="default", id="btn-back") + def on_input_changed(self, event: Input.Changed) -> None: + if self.mode == "checkout_existing" and event.value: + matches = [b for b in self.all_branches if event.value.lower() in b.lower()] + + suggestions = self.query_one("#branch-suggestions", ListView) + suggestions.clear() + + if matches: + for match in matches: + suggestions.append(ListItem(Label(match), name=match)) + suggestions.remove_class("hidden") + else: + suggestions.add_class("hidden") + else: + self.query_one("#branch-suggestions", ListView).add_class("hidden") + + def on_list_view_selected(self, event: ListView.Selected) -> None: + if event.list_view.id == "branch-suggestions": + inp = self.query_one("#branch-input", Input) + inp.value = event.item.name + inp.focus() + self.query_one("#branch-suggestions", ListView).add_class("hidden") + def on_button_pressed(self, event: Button.Pressed) -> None: btn_id = event.button.id @@ -53,6 +86,7 @@ def on_button_pressed(self, event: Button.Pressed) -> None: self.mode = "select" self.query_one("#select-mode").remove_class("hidden") self.query_one("#input-mode").add_class("hidden") + self.query_one("#branch-suggestions").add_class("hidden") elif btn_id == "btn-confirm": branch_name = self.query_one("#branch-input", Input).value From 461ba2e77fe824680e407c85dab52c99dffb8bd8 Mon Sep 17 00:00:00 2001 From: Andreas Fiehn Date: Sat, 10 Jan 2026 18:28:32 +0100 Subject: [PATCH 7/9] feat: add tagging for newly created tasks --- gittask/tui/screens/tag_selection.py | 107 +++++++++++++++++++++++++++ gittask/tui/screens/task_search.py | 48 +++++++++++- 2 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 gittask/tui/screens/tag_selection.py diff --git a/gittask/tui/screens/tag_selection.py b/gittask/tui/screens/tag_selection.py new file mode 100644 index 0000000..6f95e01 --- /dev/null +++ b/gittask/tui/screens/tag_selection.py @@ -0,0 +1,107 @@ +from textual.app import ComposeResult +from textual.screen import ModalScreen +from textual.widgets import Label, Button, Input, ListView, ListItem, Checkbox +from textual.containers import Container, Vertical, Horizontal +from textual import work +from ...config import ConfigManager +from ...asana_client import AsanaClient + +class TagSelectionModal(ModalScreen): + def __init__(self, workspace_gid: str, **kwargs): + super().__init__(**kwargs) + self.workspace_gid = workspace_gid + self.selected_tags = set() + self.all_tags = [] + + def compose(self) -> ComposeResult: + with Container(classes="options-modal"): + yield Label("Select Tags", classes="modal-header") + yield Label("Space to toggle, Enter to confirm", classes="modal-subtitle") + + yield ListView(id="tag-list") + + yield Input(placeholder="Create new tag...", id="new-tag-input") + + with Horizontal(classes="modal-actions"): + yield Button("Confirm", variant="success", id="btn-confirm") + yield Button("Skip", variant="default", id="btn-skip") + + def on_mount(self) -> None: + self._fetch_tags() + + @work(exclusive=True, thread=True) + def _fetch_tags(self) -> None: + try: + config = ConfigManager() + token = config.get_api_token() + + with AsanaClient(token) as client: + tags = client.get_tags(self.workspace_gid) + + self.app.call_from_thread(self._update_tag_list, tags) + except Exception as e: + self.app.call_from_thread(self.notify, f"Failed to fetch tags: {e}", severity="error") + + def _update_tag_list(self, tags: list) -> None: + self.all_tags = tags + list_view = self.query_one("#tag-list", ListView) + list_view.clear() + + for tag in tags: + # We use a custom ListItem that tracks selection state visually + # Since Checkbox inside ListItem might be tricky with focus, let's just use text + # and toggle style or prefix. + item = ListItem(Label(f"[ ] {tag['name']}"), name=tag['gid']) + item.tag_name = tag['name'] + item.tag_gid = tag['gid'] + list_view.append(item) + + def on_list_view_selected(self, event: ListView.Selected) -> None: + item = event.item + tag_gid = item.tag_gid + + if tag_gid in self.selected_tags: + self.selected_tags.remove(tag_gid) + item.query_one(Label).update(f"[ ] {item.tag_name}") + else: + self.selected_tags.add(tag_gid) + item.query_one(Label).update(f"[x] {item.tag_name}") + + def on_input_submitted(self, event: Input.Submitted) -> None: + tag_name = event.value + if tag_name: + self._create_tag(tag_name) + event.input.value = "" + + @work(exclusive=True, thread=True) + def _create_tag(self, tag_name: str) -> None: + try: + config = ConfigManager() + token = config.get_api_token() + + with AsanaClient(token) as client: + new_tag = client.create_tag(self.workspace_gid, tag_name) + + self.app.call_from_thread(self._on_tag_created, new_tag) + except Exception as e: + self.app.call_from_thread(self.notify, f"Failed to create tag: {e}", severity="error") + + def _on_tag_created(self, tag: dict) -> None: + self.all_tags.append(tag) + self.selected_tags.add(tag['gid']) + + list_view = self.query_one("#tag-list", ListView) + item = ListItem(Label(f"[x] {tag['name']}"), name=tag['gid']) + item.tag_name = tag['name'] + item.tag_gid = tag['gid'] + list_view.append(item) + + # Scroll to bottom + list_view.scroll_to_end() + self.notify(f"Created tag: {tag['name']}") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn-confirm": + self.dismiss(list(self.selected_tags)) + elif event.button.id == "btn-skip": + self.dismiss([]) diff --git a/gittask/tui/screens/task_search.py b/gittask/tui/screens/task_search.py index 6d16db5..7977ed5 100644 --- a/gittask/tui/screens/task_search.py +++ b/gittask/tui/screens/task_search.py @@ -116,12 +116,56 @@ def _on_task_created(self, task: dict) -> None: self.query_one("#loading").display = False self.query_one("#results-list").display = True - task_gid = task['gid'] - task_name = task['name'] + # Store task info for later use in handle_tags + self.created_task = task + + # Open Tag Selection + config = ConfigManager() + workspace_gid = config.get_default_workspace() + + from .tag_selection import TagSelectionModal + self.app.push_screen(TagSelectionModal(workspace_gid), self.handle_tags) + + def handle_tags(self, tag_gids: list) -> None: + if tag_gids: + self.notify(f"Applying {len(tag_gids)} tags...") + self._add_tags_worker(self.created_task['gid'], tag_gids) + + # Proceed to options + task_gid = self.created_task['gid'] + task_name = self.created_task['name'] from .task_options import TaskOptionsModal self.app.push_screen(TaskOptionsModal(task_name, task_gid), self.handle_options) + @work(exclusive=False, thread=True) + def _add_tags_worker(self, task_gid: str, tag_gids: list) -> None: + try: + config = ConfigManager() + token = config.get_api_token() + + with AsanaClient(token) as client: + for tag_gid in tag_gids: + # Retry logic similar to CLI + max_retries = 5 + for attempt in range(max_retries): + try: + client.add_tag_to_task(task_gid, tag_gid) + break + except Exception as e: + if "404" in str(e) and attempt < max_retries - 1: + import time + time.sleep(1 * (attempt + 1)) + continue + # If failed after retries or other error, log/notify + self.app.call_from_thread(self.notify, f"Failed to add tag: {e}", severity="error") + break + + self.app.call_from_thread(self.notify, "Tags applied successfully") + + except Exception as e: + self.app.call_from_thread(self.notify, f"Error applying tags: {e}", severity="error") + def handle_options(self, result: dict) -> None: if not result: return From e9213920c21b4459dba35169e0b381703fb0d668 Mon Sep 17 00:00:00 2001 From: Andreas Fiehn Date: Sat, 10 Jan 2026 19:14:14 +0100 Subject: [PATCH 8/9] feat: trash icon and scrollable task grid --- gittask/database.py | 6 +++ gittask/tui/css/styles.tcss | 62 +++++++++++++++++++++++++++++- gittask/tui/screens/dashboard.py | 43 ++++++++++++++++++++- gittask/tui/screens/task_search.py | 58 ++++++++++++++++++---------- gittask/tui/widgets/task_card.py | 10 +++++ 5 files changed, 156 insertions(+), 23 deletions(-) diff --git a/gittask/database.py b/gittask/database.py index cf772d9..850a53e 100644 --- a/gittask/database.py +++ b/gittask/database.py @@ -53,6 +53,12 @@ def link_branch_to_task(self, branch_name: str, repo_path: str, task_gid: str, t 'workspace_gid': workspace_gid }, (Branch.branch_name == branch_name) & (Branch.repo_path == repo_path)) + def remove_branch_link(self, branch_name: str, repo_path: str): + Branch = Query() + self.branch_map.remove( + (Branch.branch_name == branch_name) & (Branch.repo_path == repo_path) + ) + # Time Session Operations def start_session(self, branch_name: str, repo_path: str, task_gid: str): # Enforce single active session: Stop ANY other active session first diff --git a/gittask/tui/css/styles.tcss b/gittask/tui/css/styles.tcss index 6225fd9..75a584a 100644 --- a/gittask/tui/css/styles.tcss +++ b/gittask/tui/css/styles.tcss @@ -19,12 +19,15 @@ Footer { .task-grid { layout: grid; grid-size: 3; + grid-rows: auto; grid-gutter: 1; padding: 1; height: 1fr; overflow-y: auto; } + + .bottom-bar { dock: bottom; height: auto; @@ -36,11 +39,30 @@ Footer { /* Task Card Styles */ TaskCard { - height: 14; + height: auto; + min-height: 14; border: solid $primary-muted; padding: 1; background: $surface; layout: vertical; + margin-bottom: 1; +} + +.trash-btn { + dock: right; + width: 4; + min-width: 4; + height: 1; + background: transparent; + border: none; + color: $text-muted; + padding: 0; + margin: 0; +} + +.trash-btn:hover { + color: $error; + background: transparent; } TaskCard.active { @@ -51,8 +73,9 @@ TaskCard.active { .task-name { text-align: center; text-style: bold; - height: 3; + height: auto; width: 100%; + margin-bottom: 1; } .branch-name { @@ -199,3 +222,38 @@ RichLog { #branch-input { margin: 1 0; } + +#branch-suggestions { + height: auto; + max-height: 10; + border: solid $secondary; + background: $surface-lighten-1; + margin-bottom: 1; +} + +#tag-list { + height: 10; + border: solid $secondary; + margin: 1 0; +} + +.modal-subtitle { + text-align: center; + color: $text-muted; + margin-bottom: 1; +} + +.modal-actions { + align: center middle; + height: auto; + margin-top: 1; +} + +.experimental-notice { + width: 100%; + text-align: center; + background: $warning; + color: $text; + text-style: bold; + padding: 0 1; +} diff --git a/gittask/tui/screens/dashboard.py b/gittask/tui/screens/dashboard.py index 6bf99ca..6edab13 100644 --- a/gittask/tui/screens/dashboard.py +++ b/gittask/tui/screens/dashboard.py @@ -10,10 +10,12 @@ class Dashboard(Screen): def __init__(self, **kwargs): super().__init__(id="dashboard", **kwargs) + self.last_active_session_id = None def compose(self) -> ComposeResult: yield Container( - VerticalScroll(id="task-grid", classes="task-grid"), + Label("⚠️ GUI is experimental (help build the project at https://github.com/AndreasLF/gittask-cli)", classes="experimental-notice"), + Container(id="task-grid", classes="task-grid"), Horizontal( Button("New Task", variant="success", id="new-task-btn"), Button("Sync", variant="primary", id="sync-btn"), @@ -27,6 +29,22 @@ def compose(self) -> ComposeResult: def on_mount(self) -> None: self.refresh_tasks() + self.set_interval(2.0, self.check_for_changes) + + def check_for_changes(self) -> None: + try: + db = DBManager() + active = db.get_active_session() + active_id = str(active.doc_id) if active else "None" + + # Also check if the branch map changed? + # For now, just checking active session is a good start for "tracked task changed" + # To be more robust, we could hash the relevant parts of the DB or just refresh if active session changes. + + if active_id != self.last_active_session_id: + self.refresh_tasks() + except Exception: + pass def on_screen_resume(self) -> None: self.refresh_tasks() @@ -42,6 +60,9 @@ def refresh_tasks(self) -> None: tasks_to_show = [] seen_branches = set() + # Update last active session tracking + self.last_active_session_id = str(active.doc_id) if active else "None" + # Prioritize active session if active: branch = active.get('branch') @@ -90,6 +111,26 @@ def on_task_card_checkout_requested(self, message: TaskCard.CheckoutRequested) - self.notify(f"Checking out {message.branch}...") self.run_worker(self.perform_checkout(message.branch)) + def on_task_card_task_removal_requested(self, message: TaskCard.TaskRemovalRequested) -> None: + db = DBManager() + branch = message.task_data.get('branch') + repo_path = message.task_data.get('repo_path') + + if not repo_path: + # Try to get from git handler if not in data (fallback) + try: + from ...git_handler import GitHandler + repo_path = GitHandler().get_repo_root() + except: + pass + + if branch and repo_path: + db.remove_branch_link(branch, repo_path) + self.notify(f"Removed {branch} from dashboard") + self.refresh_tasks() + else: + self.notify("Could not identify task to remove", severity="error") + async def perform_checkout(self, branch: str) -> None: import sys import subprocess diff --git a/gittask/tui/screens/task_search.py b/gittask/tui/screens/task_search.py index 7977ed5..bb10b72 100644 --- a/gittask/tui/screens/task_search.py +++ b/gittask/tui/screens/task_search.py @@ -6,6 +6,9 @@ from ...config import ConfigManager from ...asana_client import AsanaClient from ...database import DBManager +from ...git_handler import GitHandler +import subprocess +import sys class TaskSearch(Screen): def __init__(self, **kwargs): @@ -182,13 +185,13 @@ def handle_options(self, result: dict) -> None: # The modal has task_name and task_gid. It should probably include them in result. pass - elif action == "create_branch": + if action == "create_branch": branch_name = result.get("branch_name") - self.perform_checkout(branch_name, create_new=True) + self.perform_checkout(branch_name, create_new=True, task_name=result.get("task_name"), task_gid=result.get("task_gid")) elif action == "checkout_existing": branch_name = result.get("branch_name") - self.perform_checkout(branch_name, create_new=False) + self.perform_checkout(branch_name, create_new=False, task_name=result.get("task_name"), task_gid=result.get("task_gid")) def start_global_tracking(self, task_name: str, task_gid: str) -> None: # Start global session @@ -201,36 +204,51 @@ def start_global_tracking(self, task_name: str, task_gid: str) -> None: self.notify(f"Started tracking: {task_name}") self.app.action_navigate("dashboard") - def perform_checkout(self, branch_name: str, create_new: bool) -> None: + def perform_checkout(self, branch_name: str, create_new: bool, task_name: str = None, task_gid: str = None) -> None: self.notify(f"Checking out {branch_name}...") - self.run_worker(self._checkout_worker(branch_name, create_new)) + self._checkout_worker(branch_name, create_new, task_name, task_gid) - async def _checkout_worker(self, branch_name: str, create_new: bool) -> None: - import sys - import asyncio + @work(exclusive=True, thread=True) + def _checkout_worker(self, branch_name: str, create_new: bool, task_name: str = None, task_gid: str = None) -> None: from .log_view import LogScreen + # Pre-link if we have task info + if task_name and task_gid: + try: + git = GitHandler() + repo_path = git.get_repo_root() + db = DBManager() + config = ConfigManager() + + # Link it + db.link_branch_to_task( + branch_name, + repo_path, + task_gid, + task_name, + project_gid=config.get_default_project() or "", + workspace_gid=config.get_default_workspace() or "" + ) + except Exception as e: + self.app.call_from_thread(self.notify, f"Failed to pre-link task: {e}", severity="warning") + cmd = [sys.executable, "-m", "gittask.main", "checkout", branch_name] if create_new: cmd.append("-b") try: - process = await asyncio.create_subprocess_exec( - *cmd, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE - ) - stdout, stderr = await process.communicate() - output = stdout.decode() + stderr.decode() + # Use subprocess.run for sync execution in thread + result = subprocess.run(cmd, capture_output=True, text=True) + output = result.stdout + result.stderr - if process.returncode == 0: - self.notify(f"Checked out {branch_name}") - self.app.action_navigate("dashboard") + if result.returncode == 0: + self.app.call_from_thread(self.notify, f"Checked out {branch_name}") + self.app.call_later(self.app.action_navigate, "dashboard") else: - self.app.push_screen(LogScreen("Checkout Failed", output)) + self.app.call_from_thread(self.app.push_screen, LogScreen("Checkout Failed", output)) except Exception as e: - self.notify(f"Checkout failed: {e}", severity="error") + self.app.call_from_thread(self.notify, f"Checkout failed: {e}", severity="error") def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "back-btn": diff --git a/gittask/tui/widgets/task_card.py b/gittask/tui/widgets/task_card.py index 5cb8201..0b4ba71 100644 --- a/gittask/tui/widgets/task_card.py +++ b/gittask/tui/widgets/task_card.py @@ -31,6 +31,7 @@ def __init__(self, task_data: dict, current_branch: str = None, **kwargs): self.start_time = None def compose(self) -> ComposeResult: + yield Button("🗑️", id="trash-btn", classes="trash-btn") yield Label(self.task_name, classes="task-name") yield Label(self.branch_name, classes="branch-name") yield Label("00:00:00", classes="timer", id=f"timer-{self.id}") @@ -124,6 +125,9 @@ def on_button_pressed(self, event: Button.Pressed) -> None: except Exception as e: self.notify(f"Failed to push: {e}", title="Push Error", severity="error") + elif button_id == "trash-btn": + self.post_message(self.TaskRemovalRequested(self.task_data)) + class StatusChanged(Message): """Sent when task status changes (start/stop).""" pass @@ -133,3 +137,9 @@ class CheckoutRequested(Message): def __init__(self, branch: str): self.branch = branch super().__init__() + + class TaskRemovalRequested(Message): + """Sent when task removal is requested.""" + def __init__(self, task_data: dict): + self.task_data = task_data + super().__init__() From efa4f8a8fc31e55f13a6af680c7f4f25f13ea576 Mon Sep 17 00:00:00 2001 From: Andreas Fiehn Date: Sat, 10 Jan 2026 19:24:16 +0100 Subject: [PATCH 9/9] feat: use gittask push when pushing from dashboard --- gittask/tui/screens/dashboard.py | 30 ++++++++++++++++++++++++++++++ gittask/tui/widgets/task_card.py | 14 ++++++++------ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/gittask/tui/screens/dashboard.py b/gittask/tui/screens/dashboard.py index 6edab13..6e84b8d 100644 --- a/gittask/tui/screens/dashboard.py +++ b/gittask/tui/screens/dashboard.py @@ -131,6 +131,10 @@ def on_task_card_task_removal_requested(self, message: TaskCard.TaskRemovalReque else: self.notify("Could not identify task to remove", severity="error") + def on_task_card_push_requested(self, message: TaskCard.PushRequested) -> None: + self.notify(f"Pushing {message.branch}...") + self.run_worker(self.perform_push(message.branch)) + async def perform_checkout(self, branch: str) -> None: import sys import subprocess @@ -165,6 +169,32 @@ async def perform_checkout(self, branch: str) -> None: except Exception as e: self.notify(f"Checkout failed: {e}", severity="error") + + async def perform_push(self, branch: str) -> None: + import sys + import asyncio + from .log_view import LogScreen + + # Run gt push command + cmd = [sys.executable, "-m", "gittask.main", "push"] + + try: + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await process.communicate() + + output = stdout.decode() + stderr.decode() + + if process.returncode == 0: + self.notify(f"Successfully pushed {branch}") + else: + self.app.push_screen(LogScreen("Push Failed", output)) + + except Exception as e: + self.notify(f"Push failed: {e}", severity="error") async def perform_sync(self) -> None: import sys diff --git a/gittask/tui/widgets/task_card.py b/gittask/tui/widgets/task_card.py index 0b4ba71..3d019c5 100644 --- a/gittask/tui/widgets/task_card.py +++ b/gittask/tui/widgets/task_card.py @@ -118,12 +118,8 @@ def on_button_pressed(self, event: Button.Pressed) -> None: self.post_message(self.CheckoutRequested(self.branch_name)) elif button_id == "push-btn": - from ...git_handler import GitHandler - try: - GitHandler().push_branch(self.branch_name) - self.notify(f"Successfully pushed {self.branch_name}", title="Push Success", severity="information") - except Exception as e: - self.notify(f"Failed to push: {e}", title="Push Error", severity="error") + # Use gt push CLI command to include Asana commenting + self.post_message(self.PushRequested(self.branch_name)) elif button_id == "trash-btn": self.post_message(self.TaskRemovalRequested(self.task_data)) @@ -143,3 +139,9 @@ class TaskRemovalRequested(Message): def __init__(self, task_data: dict): self.task_data = task_data super().__init__() + + class PushRequested(Message): + """Sent when push is requested.""" + def __init__(self, branch: str): + self.branch = branch + super().__init__()