feat: first attempt at a gui for managing sessions
This commit is contained in:
430
app.py
Normal file
430
app.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user