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/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 76c7510..47a507b 100644 --- a/gittask/main.py +++ b/gittask/main.py @@ -21,6 +21,27 @@ 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 + 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() + @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..75a584a --- /dev/null +++ b/gittask/tui/css/styles.tcss @@ -0,0 +1,259 @@ +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-rows: auto; + 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: 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 { + border: solid $success; + background: $surface-lighten-1; +} + +.task-name { + text-align: center; + text-style: bold; + height: auto; + width: 100%; + margin-bottom: 1; +} + +.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; +} + +/* 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; +} + +#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 new file mode 100644 index 0000000..6e84b8d --- /dev/null +++ b/gittask/tui/screens/dashboard.py @@ -0,0 +1,239 @@ +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) + self.last_active_session_id = None + + def compose(self) -> ComposeResult: + yield Container( + 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"), + 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() + 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() + + 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() + + # 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') + 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)) + + 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") + + 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 + 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_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 + 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/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_options.py b/gittask/tui/screens/task_options.py new file mode 100644 index 0000000..26d5ac6 --- /dev/null +++ b/gittask/tui/screens/task_options.py @@ -0,0 +1,116 @@ +from textual.app import ComposeResult +from textual.screen import ModalScreen +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"): + 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") + 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 + + 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") + self.query_one("#branch-suggestions").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 new file mode 100644 index 0000000..bb10b72 --- /dev/null +++ b/gittask/tui/screens/task_search.py @@ -0,0 +1,255 @@ +from textual.app import ComposeResult +from textual import work +from textual.screen import Screen +from textual.widgets import Input, ListView, ListItem, Label, Button, LoadingIndicator +from textual.containers import Container +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): + super().__init__(id="search", **kwargs) + + 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") + ) + + 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: + 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) + 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, 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'] + 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: + 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 + + # 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 + + 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 + + if action == "create_branch": + branch_name = result.get("branch_name") + 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, 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 + db = DBManager() + branch_name = f"@global:{task_name.replace(' ', '_')}" + + db.start_session(branch_name, "GLOBAL", task_gid) + 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, task_name: str = None, task_gid: str = None) -> None: + self.notify(f"Checking out {branch_name}...") + self._checkout_worker(branch_name, create_new, task_name, task_gid) + + @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: + # Use subprocess.run for sync execution in thread + result = subprocess.run(cmd, capture_output=True, text=True) + output = result.stdout + result.stderr + + 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.call_from_thread(self.app.push_screen, LogScreen("Checkout Failed", output)) + + except Exception as e: + 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": + 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..3d019c5 --- /dev/null +++ b/gittask/tui/widgets/task_card.py @@ -0,0 +1,147 @@ +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 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}") + + 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") + else: + yield Button("Push", variant="default", id="push-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() + + 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") + + # 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)) + + elif button_id == "push-btn": + # 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)) + + 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__() + + class TaskRemovalRequested(Message): + """Sent when task removal is requested.""" + 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__() 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", +]