"""Session Mover TUI Application. A Textual-based TUI for managing OpenCode sessions. """ from textual.app import App, ComposeResult from textual.containers import Container, Horizontal, Vertical from textual.widgets import Header, Footer, Static, Input, Button, Select, Tree, DataTable, Label from textual.screen import ModalScreen from textual import events from typing import List, Optional from database import Database, Session, Project from datetime import datetime from pathlib import Path class MoveCopyDialog(ModalScreen): """Dialog for moving or copying sessions.""" BINDINGS = [ ("escape", "cancel", "Cancel"), ("enter", "confirm", "Confirm"), ] def __init__(self, db: Database, sessions: List[Session], is_copy: bool = False): super().__init__() self.db = db self.sessions = sessions self.is_copy = is_copy self.target_project: Optional[str] = None self.target_workspace: Optional[str] = None self.sql_preview: List[str] = [] def compose(self) -> ComposeResult: with Container(id="dialog"): yield Label(f"{'Copy' if self.is_copy else 'Move'} Sessions", id="dialog-title") yield Label("Target Project:") yield Select( [("-- Select Project --", "")] + [ (f"{p.name or p.worktree} ({p.id})", p.id) for p in self.db.projects.values() ], id="project-select", prompt="Select a project" ) yield Label("Target Workspace (optional):") yield Select( [("-- No workspace --", "")] + [ (f"{w.name or w.branch or w.id}", w.id) for w in self.db.workspaces.values() ], id="workspace-select", prompt="Select a workspace" ) yield Label("Preview:", id="preview-label") yield Static("", id="sql-preview") with Horizontal(): yield Button("Cancel", variant="error", id="cancel-btn") yield Button("Confirm", variant="primary", id="confirm-btn", disabled=True) def on_mount(self) -> None: self.query_one("#dialog").border_title = f"{'Copy' if self.is_copy else 'Move'} Sessions" def on_select_changed(self, event: Select.Changed) -> None: if event.select.id == "project-select": self.target_project = event.value if event.value else None elif event.select.id == "workspace-select": self.target_workspace = event.value if event.value else None self.update_preview() confirm_btn = self.query_one("#confirm-btn") confirm_btn.disabled = not (self.target_project is not None) def update_preview(self) -> None: if not self.target_project: self.sql_preview = [] self.query_one("#sql-preview").update("Select a project to see preview") return target_proj = self.db.projects.get(self.target_project) target_ws = self.db.workspaces.get(self.target_workspace) if self.target_workspace else None preview_lines = [ f"Source: {len(self.sessions)} session(s)", f"Destination Project: {target_proj.name or target_proj.worktree if target_proj else 'Unknown'}", ] if target_ws: preview_lines.append(f"Destination Workspace: {target_ws.name or target_ws.branch or target_ws.id}") else: preview_lines.append("Destination Workspace: (keep current or none)") preview_lines.append("") preview_lines.append("SQL statements:") for sess in self.sessions: if self.is_copy: preview_lines.append(f" COPY session {sess.id[:8]}... → new ID (with all related data)") else: if target_ws: preview_lines.append(f" UPDATE session SET project_id = {self.target_project}, workspace_id = {self.target_workspace} WHERE id = {sess.id[:16]}...;") else: preview_lines.append(f" UPDATE session SET project_id = {self.target_project} WHERE id = {sess.id[:16]}...;") if self.is_copy: preview_lines.append("\nNote: Copy creates new sessions with all associated messages, parts, todos, and shares.") self.sql_preview = preview_lines self.query_one("#sql-preview").update("\n".join(preview_lines)) def action_cancel(self) -> None: self.app.pop_screen() def action_confirm(self) -> None: if not self.target_project: return # Execute operation if self.is_copy: success, sql, new_ids = self.db.copy_sessions( [s.id for s in self.sessions], self.target_project, self.target_workspace ) else: success, sql = self.db.move_sessions( [s.id for s in self.sessions], self.target_project, self.target_workspace ) if success: self.app.notify(f"{'Copied' if self.is_copy else 'Moved'} {len(self.sessions)} session(s) successfully!", severity="information") self.app.pop_screen() # Trigger refresh in main app self.app.query_one(SessionMoverApp).refresh_sessions() else: self.app.notify(f"Operation failed: {sql}", severity="error") class BackupDialog(ModalScreen): """Dialog showing backup creation.""" def __init__(self, backup_path: Path): super().__init__() self.backup_path = backup_path def compose(self) -> ComposeResult: with Container(id="backup-dialog"): yield Label("Backup Created!", id="backup-title") yield Static(f"Database backed up to:\n{self.backup_path}", id="backup-path") with Horizontal(): yield Button("OK", variant="primary", id="ok-btn") def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "ok-btn": self.app.pop_screen() class SessionMoverApp(App): """Main TUI Application.""" CSS = """ Screen { layout: horizontal; } #project-panel { width: 30%; border-right: solid $primary; padding: 1; } #session-panel { width: 70%; padding: 1; } #project-tree { height: 1fr; border: solid $primary-lighten-2; } Tree { padding: 0 1; } #filter-input { width: 100%; margin-bottom: 1; } #sessions-table { height: 1fr; } DataTable { border: solid $primary-lighten-2; } #action-bar { height: auto; padding: 1; background: $surface; } Button { margin-right: 1; } #hotkeys { color: $text-muted; } /* Dialog styles */ #dialog, #backup-dialog { width: 60; height: auto; border: thick $primary; background: $surface; padding: 2; } #dialog-title, #backup-title { text-align: center; text-style: bold; margin-bottom: 1; } #sql-preview { height: 10; border: solid $primary-lighten-3; padding: 1; overflow: auto; } #preview-label { margin-top: 1; text-style: bold; } .hidden { display: none; } """ BINDINGS = [ ("q", "quit", "Quit"), ("b", "backup", "Backup DB"), ("m", "move", "Move"), ("c", "copy", "Copy"), ("a", "toggle_archived", "Show Archived"), ("r", "refresh", "Refresh"), ("escape", "cancel_selection", "Deselect"), ] def __init__(self, db_path: str = "opencode.db"): super().__init__() self.db = Database(db_path) self.selected_sessions: List[Session] = [] self.show_archived = False self.current_filter = "" self.backup_made = False def compose(self) -> ComposeResult: yield Header() with Horizontal(): with Vertical(id="project-panel"): yield Static("Projects & Workspaces", id="projects-title") yield Tree("Projects", id="project-tree") with Vertical(id="session-panel"): with Horizontal(): yield Input(placeholder="Filter sessions...", id="filter-input") yield Button("Clear", id="filter-clear", variant="default") yield DataTable(id="sessions-table") with Container(id="action-bar"): yield Static( "Hotkeys: [b] Backup [m] Move [c] Copy [a] Archive [r] Refresh [q] Quit", id="hotkeys" ) yield Footer() def on_mount(self) -> None: """Initialize app.""" self.db.connect() self.db.load_reference_data() # Setup project tree tree = self.query_one("#project-tree") tree.root.expand() self.refresh_project_tree() # Setup sessions table table = self.query_one("#sessions-table") table.zebra_stripes = True table.cursor_type = "row" table.add_columns("ID", "Title", "Project", "Workspace", "Msgs", "Todos", "Created", "Archived") self.refresh_sessions() def refresh_project_tree(self) -> None: """Update the project/workspace tree.""" tree = self.query_one("#project-tree") tree.clear() root = tree.root root.data = None for proj_name, ws_names, has_workspaces in self.db.get_project_tree(): proj_node = root.add(proj_name, {"type": "project", "name": proj_name}) proj_node.expand() if has_workspaces: for ws_name in ws_names: proj_node.add_leaf(ws_name, {"type": "workspace", "name": ws_name}) def refresh_sessions(self) -> None: """Update the sessions table.""" table = self.query_one("#sessions-table") table.clear(columns=True) table.add_columns("ID", "Title", "Project", "Workspace", "Msgs", "Todos", "Created", "Archived") sessions = self.db.get_sessions( include_archived=self.show_archived, search=self.current_filter ) for sess in sessions: created_dt = datetime.fromtimestamp(sess.time_created / 1000).strftime("%Y-%m-%d %H:%M") if sess.time_created else "N/A" archived_str = "🗓️" if sess.time_archived else "" table.add_row( sess.id[:8], sess.title[:50], sess.project_name or sess.project_id[:8], sess.workspace_name or "", str(sess.message_count), str(sess.todo_count), created_dt, archived_str, key=sess.id ) self.notify(f"Loaded {len(sessions)} sessions", severity="information") def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: """Handle row selection.""" session_id = event.row_key if session_id: session = self.db.get_session(session_id) if session: if session in self.selected_sessions: self.selected_sessions.remove(session) else: self.selected_sessions.append(session) self.update_status() def update_status(self) -> None: """Update footer/status with selection count.""" count = len(self.selected_sessions) if count > 0: self.notify(f"{count} session(s) selected", severity="information") def on_input_changed(self, event: Input.Changed) -> None: """Handle filter input.""" if event.input.id == "filter-input": self.current_filter = event.value self.refresh_sessions() def on_button_pressed(self, event: Button.Pressed) -> None: """Handle button clicks.""" if event.button.id == "filter-clear": self.query_one("#filter-input").value = "" self.current_filter = "" self.refresh_sessions() def action_backup(self) -> None: """Create database backup.""" backup_path = self.db.create_backup() self.push_screen(BackupDialog(backup_path)) def action_move(self) -> None: """Open move dialog.""" if not self.selected_sessions: self.notify("No sessions selected", severity="warning") return # Ensure backup exists before write operation if not self.backup_made: self.db.create_backup() self.backup_made = True self.notify("Backup created", severity="information") self.push_screen(MoveCopyDialog(self.db, self.selected_sessions, is_copy=False)) def action_copy(self) -> None: """Open copy dialog.""" if not self.selected_sessions: self.notify("No sessions selected", severity="warning") return # Ensure backup exists before write operation if not self.backup_made: self.db.create_backup() self.backup_made = True self.notify("Backup created", severity="information") self.push_screen(MoveCopyDialog(self.db, self.selected_sessions, is_copy=True)) def action_toggle_archived(self) -> None: """Toggle archived sessions visibility.""" self.show_archived = not self.show_archived self.refresh_sessions() status = "shown" if self.show_archived else "hidden" self.notify(f"Archived sessions {status}", severity="information") def action_refresh(self) -> None: """Reload all data from database.""" self.db.load_reference_data() self.refresh_project_tree() self.refresh_sessions() self.notify("Data refreshed", severity="information") def on_unmount(self) -> None: """Cleanup on exit.""" self.db.close() if __name__ == "__main__": app = SessionMoverApp() app.run()