commit ad7030ae383146898b7d19a298dc5292f2a712f7 Author: Joe Fleming Date: Sat Mar 7 11:52:39 2026 -0700 feat: first attempt at a gui for managing sessions 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()