From ad7030ae383146898b7d19a298dc5292f2a712f7 Mon Sep 17 00:00:00 2001 From: Joe Fleming Date: Sat, 7 Mar 2026 11:52:39 -0700 Subject: [PATCH] feat: first attempt at a gui for managing sessions --- .gitignore | 41 +++++ PLAN.md | 140 +++++++++++++++ README.md | 63 +++++++ app.py | 430 +++++++++++++++++++++++++++++++++++++++++++++++ database.py | 350 ++++++++++++++++++++++++++++++++++++++ main.py | 8 + mise.toml | 3 + requirements.txt | 1 + session-mover.py | 10 ++ 9 files changed, 1046 insertions(+) create mode 100644 .gitignore create mode 100644 PLAN.md create mode 100644 README.md create mode 100644 app.py create mode 100644 database.py create mode 100644 main.py create mode 100644 mise.toml create mode 100644 requirements.txt create mode 100644 session-mover.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..caa77bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +.venv/ +pip-log.txt +pip-delete-this-directory.txt +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.log +.git +.mypy_cache/ +.pytest_cache/ +.hypothesis/ + +# Session Mover specific +opencode-*.db +*.db-wal +*.db-shm + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Application +opencode.db* diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..96b3b3d --- /dev/null +++ b/PLAN.md @@ -0,0 +1,140 @@ +# Session Mover TUI - Project Plan + +## Overview +A Python Textual TUI application to visually manage OpenCode sessions (move/copy between projects and workspaces) without writing SQL. + +## Database Schema (opencode.db) + +### Core Tables +- **session**: `id, project_id, workspace_id, parent_id, slug, directory, title, version, time_created, time_updated, time_archived` +- **project**: `id, worktree, name, vcs` +- **workspace**: `id, project_id, branch, type, name, directory` + +### Related Data (cascade on session delete) +- **message** → **part**: Conversation content +- **todo**: Session todos +- **session_share**: Share links + +## Goals + +1. **Visual browsing** - Tree view of Projects → Workspaces → Sessions +2. **Easy session movement** - Drag-and-drop or keyboard-driven moving between projects +3. **Batch operations** - Select multiple sessions and move them together +4. **Safety** - Confirmation dialogs, preview SQL, automatic backup +5. **Discoverability** - Hotkeys always visible, clear status indicators + +## Key Features + +### Display +- Split pane: Left = Projects/Workspaces tree, Right = Sessions table +- Sessions table columns: Title, Project, Workspace, Messages, Created, Archived status +- Filter/search box for sessions +- Toggle for archived sessions (hidden by default) +- Selected sessions highlighted/checked + +### Navigation +- Mouse: Click to select, double-click to open details +- Keyboard: Tab/Shift+Tab between panels, arrows to navigate +- Hotkeys always visible at bottom + +### Actions +- **Backup** (`B`) - Create timestamped DB copy +- **Move** (`M`) - Move selected sessions to another project/workspace +- **Copy** (`C`) - Duplicate sessions to another project +- **Show archived** (`A`) - Toggle archived sessions visibility +- **Refresh** (`R`) - Reload data from database +- **Quit** (`Q`) + +### Move/Copy Dialog +- Dropdown selectors for destination Project (required) and Workspace (optional) +- Preview of affected sessions and SQL statements that will execute +- Confirmation before executing +- Summary after completion + +### Backup Strategy +- Before first write operation, create backup: `opencode-YYYYMMDD-HHMMSS.db` +- Location: same directory as source DB +- Only one backup per session (don't spam) +- User can manually backup at any time with `B` + +## Technical Implementation + +### Stack +- **Python 3.9+** +- **Textual** (TUI framework) +- **sqlite3** (stdlib) + +### Project Structure +``` +session-mover/ +├── main.py # Application entry point +├── app.py # Textual App class +├── database.py # DB connection, queries, models +├── widgets/ +│ ├── project_tree.py # Projects/Workspaces tree +│ ├── sessions_table.py # Sessions data table +│ └── dialogs.py # Move/Copy dialogs +├── utils.py # Backup, formatting helpers +└── requirements.txt +``` + +### Database Operations + +**Move session:** +```sql +UPDATE session SET project_id = ? WHERE id = ?; +-- Optionally: UPDATE session SET workspace_id = ? WHERE id = ?; +``` + +**Copy session:** +```sql +BEGIN TRANSACTION; +-- Copy session with new UUID +INSERT INTO session (...) +SELECT ... FROM session WHERE id = ?; +-- Copy related records with new IDs +INSERT INTO message ... SELECT ... FROM message WHERE session_id = ?; +INSERT INTO part ... SELECT ... FROM part WHERE session_id = ?; +INSERT INTO todo ... SELECT ... FROM todo WHERE session_id = ?; +INSERT INTO session_share ... SELECT ... FROM session_share WHERE session_id = ?; +COMMIT; +``` + +**Validation checks:** +- Target project exists +- If workspace specified: exists and belongs to target project +- Sessions exist and aren't already in target + +## Implementation Steps + +1. **Setup** - Create project structure, requirements.txt with `textual` +2. **Database layer** - `database.py` with connection, session/project/workspace queries +3. **TUI skeleton** - Basic Textual app with split layout +4. **Project tree widget** - Load projects/workspaces, show hierarchy +5. **Sessions table** - Load sessions with counts, implement filtering +6. **Selection** - Multi-select mechanism (checkboxes or highlight+shift) +7. **Action dialogs** - Move/Copy dialog with preview +8. **Backup system** - Before first write operation +9. **SQL execution** - Safe updates with transactions +10. **Polish** - Status messages, error handling, hotkeys display + +## Open Questions (Resolved) + +- Use Textual: **Yes** +- Show hotkeys: **Always at bottom** +- Include archived: **hidden by default, toggle显示** +- Single DB path: **Default opencode.db in current dir, --db flag to override** +- Copy command: **Yes, duplicate sessions** +- Batch move: **All selected move to same destination** +- Confirmation needed: **Yes, with SQL preview** +- Backup: **Timestamped copy before first write** +- Workspace on move: **Optional - can keep current or select new** + +## Success Criteria + +- Can browse all sessions with project/workspace context +- Can select session(s) and move them to different project with one keystroke +- Can see exactly what SQL will run before confirming +- Backup created automatically before any change +- All operations display clear success/error feedback +- No need to write SQL manually diff --git a/README.md b/README.md new file mode 100644 index 0000000..d100bd0 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# Session Mover TUI + +A Python Textual TUI for easily moving OpenCode sessions between projects and workspaces without writing SQL. + +## Quick Start + +```bash +# Install dependencies +pip install -r requirements.txt + +# Run (looks for opencode.db in current directory) +python session-mover.py + +# Or specify a database path +python session-mover.py /path/to/opencode.db +``` + +## How to Use + +1. **Browse** - See all sessions organized by project/workspace on the left +2. **Select** - Click or use arrow keys + Enter to select sessions (multiple with Shift+Click) +3. **Filter** - Type in the filter box to search session titles +4. **Move** - Press `m` to move selected sessions to another project +5. **Copy** - Press `c` to duplicate sessions to another project +6. **Backup** - Press `b` to manually create a database backup + +### Hotkeys (always visible at bottom) + +- `b` - Backup database +- `m` - Move selected sessions +- `c` - Copy selected sessions +- `a` - Toggle archived sessions visibility +- `r` - Refresh all data +- `q` - Quit +- `Escape` - Deselect / close dialogs + +### Move/Copy Dialog + +1. Select destination project (dropdown) +2. Optionally select a workspace within that project +3. Review the SQL preview to see what will execute +4. Press `Enter` or click Confirm to execute +5. A backup is automatically created before the first write operation + +### Safety + +- Before any move/copy, a timestamped backup is created: `opencode-YYYYMMDD-HHMMSS.db` +- Confirmation dialog shows exactly what will happen +- Changes appear immediately in the UI after success + +## Database Schema Support + +Works with OpenCode's `opencode.db` with tables: +- `session` (with project_id, workspace_id, parent_id) +- `project`, `workspace` +- `message` → `part`, `todo`, `session_share` (all cascade automatically) + +## Tips + +- Filter by typing in the search box (searches title and slug) +- Toggle archived sessions with `a` (hidden by default) +- The left panel shows Projects → Workspaces hierarchy +- Selected sessions remain selected when filtering, so you can search, select, then clear filter diff --git a/app.py b/app.py new file mode 100644 index 0000000..0650e12 --- /dev/null +++ b/app.py @@ -0,0 +1,430 @@ +"""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() diff --git a/database.py b/database.py new file mode 100644 index 0000000..1b50b3a --- /dev/null +++ b/database.py @@ -0,0 +1,350 @@ +"""Database layer for OpenCode session operations.""" + +import sqlite3 +from pathlib import Path +from typing import Optional, List, Tuple +from dataclasses import dataclass, field +from datetime import datetime + + +@dataclass +class Project: + """Project data structure.""" + id: str + worktree: str + name: Optional[str] + vcs: Optional[str] + time_created: int + time_updated: int + + +@dataclass +class Workspace: + """Workspace data structure.""" + id: str + project_id: str + branch: Optional[str] + type: str + name: Optional[str] + directory: Optional[str] + + +@dataclass +class Session: + """Session data structure.""" + id: str + project_id: str + workspace_id: Optional[str] + parent_id: Optional[str] + slug: str + directory: str + title: str + version: str + time_created: int + time_updated: int + time_archived: Optional[int] = None + # Computed fields + message_count: int = 0 + todo_count: int = 0 + workspace_name: Optional[str] = None + project_name: Optional[str] = None + + +class Database: + """Database connection and query handler.""" + + def __init__(self, db_path: str = "opencode.db"): + self.db_path = Path(db_path) + self.conn: Optional[sqlite3.Connection] = None + self.projects: dict[str, Project] = {} + self.workspaces: dict[str, Workspace] = {} + self.workspaces_by_project: dict[str, List[Workspace]] = {} + + def connect(self): + """Establish database connection.""" + self.conn = sqlite3.connect(self.db_path) + self.conn.row_factory = sqlite3.Row + + def close(self): + """Close database connection.""" + if self.conn: + self.conn.close() + + def load_reference_data(self): + """Load projects and workspaces into memory for fast lookups.""" + assert self.conn is not None + cursor = self.conn.cursor() + + # Load projects + cursor.execute("SELECT id, worktree, name, vcs, time_created, time_updated FROM project") + for row in cursor.fetchall(): + proj = Project( + id=row["id"], + worktree=row["worktree"], + name=row["name"], + vcs=row["vcs"], + time_created=row["time_created"], + time_updated=row["time_updated"] + ) + self.projects[proj.id] = proj + + # Load workspaces + cursor.execute("SELECT id, project_id, branch, type, name, directory FROM workspace") + for row in cursor.fetchall(): + ws = Workspace( + id=row["id"], + project_id=row["project_id"], + branch=row["branch"], + type=row["type"], + name=row["name"], + directory=row["directory"] + ) + self.workspaces[ws.id] = ws + + # Build workspace lookup by project + self.workspaces_by_project = {} + for ws in self.workspaces.values(): + self.workspaces_by_project.setdefault(ws.project_id, []).append(ws) + + def get_sessions(self, + include_archived: bool = False, + project_id: Optional[str] = None, + workspace_id: Optional[str] = None, + search: str = "") -> List[Session]: + """ + Fetch sessions with optional filtering. + + Args: + include_archived: Include archived sessions + project_id: Filter by project + workspace_id: Filter by workspace + search: Text search in title or slug + + Returns: + List of Session objects with computed counts + """ + assert self.conn is not None, "Database not connected" + cursor = self.conn.cursor() + + query = """ + SELECT s.*, + (SELECT COUNT(*) FROM message WHERE session_id = s.id) as msg_count, + (SELECT COUNT(*) FROM todo WHERE session_id = s.id) as todo_count + FROM session s + WHERE 1=1 + """ + params = [] + + if not include_archived: + query += " AND s.time_archived IS NULL" + + if project_id: + query += " AND s.project_id = ?" + params.append(project_id) + + if workspace_id: + query += " AND s.workspace_id = ?" + params.append(workspace_id) + + if search: + query += " AND (s.title LIKE ? OR s.slug LIKE ?)" + params.extend([f"%{search}%", f"%{search}%"]) + + query += " ORDER BY s.time_created DESC" + + cursor.execute(query, params) + rows = cursor.fetchall() + + sessions = [] + for row in rows: + sess = Session( + id=row["id"], + project_id=row["project_id"], + workspace_id=row["workspace_id"], + parent_id=row["parent_id"], + slug=row["slug"], + directory=row["directory"], + title=row["title"], + version=row["version"], + time_created=row["time_created"], + time_updated=row["time_updated"], + time_archived=row["time_archived"], + message_count=row["msg_count"], + todo_count=row["todo_count"] + ) + + # Add project name + if sess.project_id in self.projects: + sess.project_name = self.projects[sess.project_id].name or self.projects[sess.project_id].worktree + + # Add workspace name + if sess.workspace_id and sess.workspace_id in self.workspaces: + ws = self.workspaces[sess.workspace_id] + sess.workspace_name = ws.name or ws.branch or ws.id + + sessions.append(sess) + + return sessions + + def get_project_tree(self) -> List[Tuple[str, List[str], bool]]: + """ + Build a hierarchical view of projects and workspaces. + + Returns: + List of tuples: (project_name, [workspace_names], has_workspaces) + """ + result = [] + for proj in self.projects.values(): + ws_list = self.workspaces_by_project.get(proj.id, []) + ws_names = [w.name or w.branch or w.id for w in ws_list] + has_workspaces = len(ws_list) > 0 + result.append((proj.name or proj.worktree, ws_names, has_workspaces)) + return sorted(result) + + def get_session(self, session_id: str) -> Optional[Session]: + """Get a single session by ID.""" + sessions = self.get_sessions(include_archived=True) + for s in sessions: + if s.id == session_id: + return s + return None + + def move_sessions(self, session_ids: List[str], target_project_id: str, + target_workspace_id: Optional[str] = None) -> Tuple[bool, List[str]]: + """ + Move sessions to a different project (and optionally workspace). + + Returns: + (success, list of SQL statements executed) + """ + assert self.conn is not None, "Database not connected" + cursor = self.conn.cursor() + + # Verify target project exists + if target_project_id not in self.projects: + return False, [f"ERROR: Project {target_project_id} not found"] + + # Verify workspace exists and belongs to target project + if target_workspace_id: + if target_workspace_id not in self.workspaces: + return False, [f"ERROR: Workspace {target_workspace_id} not found"] + if self.workspaces[target_workspace_id].project_id != target_project_id: + return False, [f"ERROR: Workspace {target_workspace_id} does not belong to project {target_project_id}"] + + sql_statements = [] + for sess_id in session_ids: + if target_workspace_id: + sql = "UPDATE session SET project_id = ?, workspace_id = ? WHERE id = ?" + cursor.execute(sql, (target_project_id, target_workspace_id, sess_id)) + sql_statements.append(f"UPDATE session SET project_id = {target_project_id}, workspace_id = {target_workspace_id} WHERE id = {sess_id}") + else: + # Keep current workspace if any, or set to NULL + sql = "UPDATE session SET project_id = ? WHERE id = ?" + cursor.execute(sql, (target_project_id, sess_id)) + sql_statements.append(f"UPDATE session SET project_id = {target_project_id} WHERE id = {sess_id}") + + self.conn.commit() + return True, sql_statements + + def copy_sessions(self, session_ids: List[str], target_project_id: str, + target_workspace_id: Optional[str] = None) -> Tuple[bool, List[str], List[str]]: + """ + Copy sessions to a target project. + + Returns: + (success, list of SQL statements, list of new session IDs) + """ + assert self.conn is not None, "Database not connected" + import uuid + + cursor = self.conn.cursor() + + # Verify target project exists + if target_project_id not in self.projects: + return False, [f"ERROR: Project {target_project_id} not found"], [] + + # Verify workspace exists and belongs to target project + if target_workspace_id: + if target_workspace_id not in self.workspaces: + return False, [f"ERROR: Workspace {target_workspace_id} not found"], [] + if self.workspaces[target_workspace_id].project_id != target_project_id: + return False, [f"ERROR: Workspace {target_workspace_id} does not belong to project {target_project_id}"], [] + + new_session_ids = [] + sql_statements = [] + + for sess_id in session_ids: + # Get original session + cursor.execute("SELECT * FROM session WHERE id = ?", (sess_id,)) + row = cursor.fetchone() + if not row: + continue + + # Create new session ID + new_id = str(uuid.uuid4()).replace("-", "") + while new_id[:3] != "ses": + new_id = "ses_" + new_id + + # Insert new session + cols = [k for k in row.keys() if k != "id"] + col_list = ", ".join(cols) + placeholders = ", ".join(["?"] * len(cols)) + values = [row[col] for col in cols] + + # Override project_id and optionally workspace_id + project_idx = cols.index("project_id") + values[project_idx] = target_project_id + + if target_workspace_id: + if "workspace_id" in cols: + ws_idx = cols.index("workspace_id") + values[ws_idx] = target_workspace_id + else: + # Clear workspace if not specified + if "workspace_id" in cols: + ws_idx = cols.index("workspace_id") + values[ws_idx] = None + + sql = f"INSERT INTO session (id, {col_list}) VALUES (?, {placeholders})" + cursor.execute(sql, [new_id] + values) + sql_statements.append(f"INSERT INTO session ... VALUES ({new_id}, ...)") + + # Copy related records + for table, foreign_key in [("message", "session_id"), ("part", "session_id"), ("todo", "session_id"), ("session_share", "session_id")]: + cursor.execute(f"SELECT * FROM {table} WHERE {foreign_key} = ?", (sess_id,)) + rows = cursor.fetchall() + for r in rows: + cols = [k for k in r.keys() if k != "id" and k != foreign_key] + col_list = ", ".join(cols) + placeholders = ", ".join(["?"] * len(cols)) + values = [r[col] for col in cols] + + # Set foreign key to new session ID + fk_idx = cols.index(foreign_key) if foreign_key in cols else -1 + if fk_idx >= 0: + values[fk_idx] = new_id + + # Generate new ID for tables with id column + if "id" in r.keys(): + new_table_id = str(uuid.uuid4()).replace("-", "") + sql = f"INSERT INTO {table} (id, {col_list}) VALUES (?, {placeholders})" + cursor.execute(sql, [new_table_id] + values) + else: + sql = f"INSERT INTO {table} ({col_list}) VALUES ({placeholders})" + cursor.execute(sql, values) + + new_session_ids.append(new_id) + + self.conn.commit() + return True, sql_statements, new_session_ids + + def create_backup(self) -> Path: + """Create a timestamped backup of the database.""" + if not self.db_path.exists(): + raise FileNotFoundError(f"Database not found: {self.db_path}") + + timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") + backup_path = self.db_path.parent / f"{self.db_path.stem}-{timestamp}{self.db_path.suffix}" + import shutil + shutil.copy2(self.db_path, backup_path) + return backup_path diff --git a/main.py b/main.py new file mode 100644 index 0000000..8bf4223 --- /dev/null +++ b/main.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +"""Session Mover TUI - Move OpenCode sessions between projects without SQL.""" + +from app import SessionMoverApp + +if __name__ == "__main__": + app = SessionMoverApp() + app.run() diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..0e8aeba --- /dev/null +++ b/mise.toml @@ -0,0 +1,3 @@ +[tools] +python = "3.14.3" +sqlite = "3.51.2" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4c6130e --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +textual==0.44.0 diff --git a/session-mover.py b/session-mover.py new file mode 100644 index 0000000..81a0afe --- /dev/null +++ b/session-mover.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +"""Session Mover TUI - Move OpenCode sessions between projects without SQL.""" + +from app import SessionMoverApp +import sys + +if __name__ == "__main__": + db_path = sys.argv[1] if len(sys.argv) > 1 else "opencode.db" + app = SessionMoverApp(db_path) + app.run()