Compare commits

..

2 Commits

Author SHA1 Message Date
Joe Fleming
c768caa025 database id fixes 2026-03-08 14:22:39 -06:00
Joe Fleming
d04af8d6dc handle errors better 2026-03-08 13:41:35 -06:00
3 changed files with 145 additions and 55 deletions

58
app.py
View File

@@ -7,7 +7,7 @@ from textual.app import App, ComposeResult
from textual.containers import Container, Horizontal, Vertical from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Header, Footer, Input, Button, Select, ListView, ListItem, DataTable, Label, Static from textual.widgets import Header, Footer, Input, Button, Select, ListView, ListItem, DataTable, Label, Static
from textual.screen import ModalScreen from textual.screen import ModalScreen
from typing import List, Optional from typing import List, Optional, cast
from database import Database, Session from database import Database, Session
from datetime import datetime from datetime import datetime
import os import os
@@ -31,7 +31,8 @@ class MoveCopyDialog(ModalScreen):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
# Get currently selected project from tree to exclude from destination list # Get currently selected project from tree to exclude from destination list
current_project_id = self.app.filter_project_id if hasattr(self.app, 'filter_project_id') else None main_app = cast("SessionMoverApp", self.app)
current_project_id = main_app.filter_project_id if hasattr(main_app, 'filter_project_id') else None
project_options = sorted( project_options = sorted(
[ [
@@ -68,7 +69,7 @@ class MoveCopyDialog(ModalScreen):
yield Button("Confirm", variant="primary", id="confirm-btn", disabled=True) yield Button("Confirm", variant="primary", id="confirm-btn", disabled=True)
def on_mount(self) -> None: def on_mount(self) -> None:
self.query_one("#dialog").border_title = f"{'Copy' if self.is_copy else 'Move'} Sessions" self.query_one("#dialog", Container).border_title = f"{'Copy' if self.is_copy else 'Move'} Sessions"
def on_select_changed(self, event): def on_select_changed(self, event):
if event.select.id == "project-select": if event.select.id == "project-select":
@@ -77,12 +78,12 @@ class MoveCopyDialog(ModalScreen):
self.target_workspace = event.value if event.value else None self.target_workspace = event.value if event.value else None
self.update_preview() self.update_preview()
confirm_btn = self.query_one("#confirm-btn") confirm_btn = self.query_one("#confirm-btn", Button)
confirm_btn.disabled = not (self.target_project is not None) confirm_btn.disabled = not (self.target_project is not None)
def update_preview(self): def update_preview(self):
if not self.target_project: if not self.target_project:
preview = self.query_one("#sql-preview") preview = self.query_one("#sql-preview", Static)
preview.update("Select a project to see preview") preview.update("Select a project to see preview")
return return
@@ -111,7 +112,7 @@ class MoveCopyDialog(ModalScreen):
if self.is_copy: if self.is_copy:
lines.append("\nNote: Copy creates new sessions with all related data.") lines.append("\nNote: Copy creates new sessions with all related data.")
self.query_one("#sql-preview").update("\n".join(lines)) self.query_one("#sql-preview", Static).update("\n".join(lines))
def on_button_pressed(self, event: Button.Pressed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "cancel-btn": if event.button.id == "cancel-btn":
@@ -141,13 +142,11 @@ class MoveCopyDialog(ModalScreen):
) )
if success: if success:
self.app.notify(f"{'Copied' if self.is_copy else 'Moved'} {len(self.sessions)} session(s) successfully!", severity="information") main_app = cast("SessionMoverApp", self.app)
self.app.pop_screen() main_app.notify(f"{'Copied' if self.is_copy else 'Moved'} {len(self.sessions)} session(s) successfully!", severity="information")
# Get reference to main app and refresh main_app.pop_screen()
app = self.app main_app.refresh_project_list()
if hasattr(app, 'refresh_sessions'): main_app.refresh_sessions()
app.refresh_project_list()
app.refresh_sessions()
else: else:
self.app.notify(f"Operation failed: {sql}", severity="error") self.app.notify(f"Operation failed: {sql}", severity="error")
@@ -194,7 +193,7 @@ class DeleteConfirmDialog(ModalScreen):
yield Button("Delete", variant="error", id="confirm-btn") yield Button("Delete", variant="error", id="confirm-btn")
def on_mount(self) -> None: def on_mount(self) -> None:
self.query_one("#dialog").border_title = "Confirm Delete" self.query_one("#dialog", Container).border_title = "Confirm Delete"
def on_button_pressed(self, event: Button.Pressed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "cancel-btn": if event.button.id == "cancel-btn":
@@ -282,6 +281,12 @@ class SessionMoverApp(App):
color: $text; color: $text;
} }
#current-selection.error {
background: $error;
color: $text;
text-style: bold;
}
#selection-count { #selection-count {
height: auto; height: auto;
padding: 1; padding: 1;
@@ -385,10 +390,13 @@ class SessionMoverApp(App):
self.db.connect() self.db.connect()
self.db.load_reference_data() self.db.load_reference_data()
except FileNotFoundError as e: except FileNotFoundError as e:
self.notify(str(e), severity="error", timeout=10) widget = self.query_one("#current-selection", Static)
self.set_timer(1, self.exit) widget.add_class("error")
widget.update(f"Error: {e}\n\nPress q to quit.")
self._db_error = True
return return
self._db_error = False
self._project_ids: List[str] = [] self._project_ids: List[str] = []
self.refresh_project_list() self.refresh_project_list()
@@ -398,15 +406,14 @@ class SessionMoverApp(App):
self.query_one("#archived-toggle", Button).can_focus = False self.query_one("#archived-toggle", Button).can_focus = False
# Setup sessions table (but don't load data yet) # Setup sessions table (but don't load data yet)
table = self.query_one("#sessions-table") table = self.query_one("#sessions-table", DataTable)
table.zebra_stripes = True table.zebra_stripes = True
table.cursor_type = "row" table.cursor_type = "row"
table.show_cursor = False table.show_cursor = False
table.add_columns(" ", "ID", "Title", "Project", "Workspace", "Msgs", "Created") table.add_columns(" ", "ID", "Title", "Project", "Workspace", "Msgs", "Created")
# Don't load sessions until a project is selected # Don't load sessions until a project is selected
status = self.query_one("#current-selection") self.query_one("#current-selection", Static).update("Select a project from the left")
status.update("Select a project from the left")
def on_input_changed(self, event: Input.Changed) -> None: def on_input_changed(self, event: Input.Changed) -> None:
if event.input.id == "filter-input": if event.input.id == "filter-input":
@@ -462,7 +469,7 @@ class SessionMoverApp(App):
def refresh_sessions(self) -> None: def refresh_sessions(self) -> None:
"""Update the sessions table.""" """Update the sessions table."""
table = self.query_one("#sessions-table") table = self.query_one("#sessions-table", DataTable)
# Save cursor position before clearing # Save cursor position before clearing
cursor_row = table.cursor_row cursor_row = table.cursor_row
@@ -501,7 +508,7 @@ class SessionMoverApp(App):
# Update selection count # Update selection count
count = len(self.selected_session_ids) count = len(self.selected_session_ids)
count_widget = self.query_one("#selection-count") count_widget = self.query_one("#selection-count", Static)
if count > 0: if count > 0:
count_widget.update(f"{count} session(s) selected") count_widget.update(f"{count} session(s) selected")
else: else:
@@ -534,7 +541,7 @@ class SessionMoverApp(App):
self.filter_project_id = proj_id self.filter_project_id = proj_id
self.filter_workspace_id = None self.filter_workspace_id = None
status = self.query_one("#current-selection") status = self.query_one("#current-selection", Static)
if proj: if proj:
status.update(f"📁 {proj.worktree}") status.update(f"📁 {proj.worktree}")
@@ -547,7 +554,7 @@ class SessionMoverApp(App):
def action_toggle_selection(self) -> None: def action_toggle_selection(self) -> None:
"""Toggle selection of current row.""" """Toggle selection of current row."""
table = self.query_one("#sessions-table") table = self.query_one("#sessions-table", DataTable)
row_key = table.cursor_row row_key = table.cursor_row
if row_key is not None: if row_key is not None:
sessions = self.db.get_sessions( sessions = self.db.get_sessions(
@@ -655,6 +662,11 @@ class SessionMoverApp(App):
self.refresh_sessions() self.refresh_sessions()
self.notify("Data refreshed", severity="information") self.notify("Data refreshed", severity="information")
async def action_quit(self) -> None:
"""Exit the app, with a non-zero return code if there was a DB error."""
return_code = 1 if getattr(self, "_db_error", False) else 0
self.exit(return_code)
def on_unmount(self) -> None: def on_unmount(self) -> None:
"""Cleanup on exit.""" """Cleanup on exit."""
self.db.close() self.db.close()

View File

@@ -74,7 +74,8 @@ class Database:
def load_reference_data(self): def load_reference_data(self):
"""Load projects and workspaces into memory for fast lookups.""" """Load projects and workspaces into memory for fast lookups."""
assert self.conn is not None if self.conn is None:
raise RuntimeError("Database is not connected. Call connect() first.")
cursor = self.conn.cursor() cursor = self.conn.cursor()
# Load projects # Load projects
@@ -125,7 +126,8 @@ class Database:
Returns: Returns:
List of Session objects with computed counts List of Session objects with computed counts
""" """
assert self.conn is not None, "Database not connected" if self.conn is None:
raise RuntimeError("Database is not connected. Call connect() first.")
cursor = self.conn.cursor() cursor = self.conn.cursor()
query = """ query = """
@@ -205,7 +207,8 @@ class Database:
def get_session_counts_by_project(self) -> dict: def get_session_counts_by_project(self) -> dict:
"""Return a dict of project_id -> active session count.""" """Return a dict of project_id -> active session count."""
assert self.conn is not None if self.conn is None:
raise RuntimeError("Database is not connected. Call connect() first.")
cursor = self.conn.cursor() cursor = self.conn.cursor()
cursor.execute( cursor.execute(
"SELECT project_id, COUNT(*) FROM session WHERE time_archived IS NULL GROUP BY project_id" "SELECT project_id, COUNT(*) FROM session WHERE time_archived IS NULL GROUP BY project_id"
@@ -214,12 +217,44 @@ class Database:
def get_session(self, session_id: str) -> Optional[Session]: def get_session(self, session_id: str) -> Optional[Session]:
"""Get a single session by ID.""" """Get a single session by ID."""
sessions = self.get_sessions(include_archived=True) if self.conn is None:
for s in sessions: raise RuntimeError("Database is not connected. Call connect() first.")
if s.id == session_id: cursor = self.conn.cursor()
return s cursor.execute("SELECT * FROM session WHERE id = ?", (session_id,))
row = cursor.fetchone()
if not row:
return None return None
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
cursor.execute("SELECT COUNT(*) FROM message WHERE session_id = ?", (session_id,))
sess.message_count = cursor.fetchone()[0]
# Project name
if sess.project_id in self.projects:
proj = self.projects[sess.project_id]
sess.project_name = proj.name or proj.worktree
# 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
return sess
def move_sessions(self, session_ids: List[str], target_project_id: str, def move_sessions(self, session_ids: List[str], target_project_id: str,
target_workspace_id: Optional[str] = None) -> Tuple[bool, List[str]]: target_workspace_id: Optional[str] = None) -> Tuple[bool, List[str]]:
""" """
@@ -228,7 +263,8 @@ class Database:
Returns: Returns:
(success, list of SQL statements executed) (success, list of SQL statements executed)
""" """
assert self.conn is not None, "Database not connected" if self.conn is None:
raise RuntimeError("Database is not connected. Call connect() first.")
cursor = self.conn.cursor() cursor = self.conn.cursor()
# Verify target project exists # Verify target project exists
@@ -265,7 +301,8 @@ class Database:
Returns: Returns:
(success, list of SQL statements, list of new session IDs) (success, list of SQL statements, list of new session IDs)
""" """
assert self.conn is not None, "Database not connected" if self.conn is None:
raise RuntimeError("Database is not connected. Call connect() first.")
import uuid import uuid
cursor = self.conn.cursor() cursor = self.conn.cursor()
@@ -292,9 +329,7 @@ class Database:
continue continue
# Create new session ID # Create new session ID
new_id = str(uuid.uuid4()).replace("-", "") new_id = "ses_" + uuid.uuid4().hex
while new_id[:3] != "ses":
new_id = "ses_" + new_id
# Insert new session # Insert new session
cols = [k for k in row.keys() if k != "id"] cols = [k for k in row.keys() if k != "id"]
@@ -320,28 +355,70 @@ class Database:
cursor.execute(sql, [new_id] + values) cursor.execute(sql, [new_id] + values)
sql_statements.append(f"INSERT INTO session ... VALUES ({new_id}, ...)") sql_statements.append(f"INSERT INTO session ... VALUES ({new_id}, ...)")
# Copy related records # Copy messages first, building old_id -> new_id map so parts can be relinked
for table, foreign_key in [("message", "session_id"), ("part", "session_id"), ("todo", "session_id"), ("session_share", "session_id")]: msg_id_map: dict = {}
try: try:
cursor.execute(f"SELECT * FROM {table} WHERE {foreign_key} = ?", (sess_id,)) cursor.execute("SELECT * FROM message WHERE session_id = ?", (sess_id,))
except sqlite3.OperationalError: except sqlite3.OperationalError:
continue # Table doesn't exist in this schema version pass
rows = cursor.fetchall() else:
for r in rows: for r in cursor.fetchall():
# Include foreign_key in cols so the new session_id is written new_msg_id = uuid.uuid4().hex
msg_id_map[r["id"]] = new_msg_id
cols = [k for k in r.keys() if k != "id"] cols = [k for k in r.keys() if k != "id"]
values = [new_id if k == foreign_key else r[k] for k in cols] values = [new_id if k == "session_id" else r[k] for k in cols]
col_list = ", ".join(cols) col_list = ", ".join(cols)
placeholders = ", ".join(["?"] * len(cols)) placeholders = ", ".join(["?"] * len(cols))
cursor.execute(
f"INSERT INTO message (id, {col_list}) VALUES (?, {placeholders})",
[new_msg_id] + values,
)
# Generate new ID for tables with id column # Copy parts, relinking both session_id and message_id
if "id" in r.keys(): try:
new_table_id = str(uuid.uuid4()).replace("-", "") cursor.execute("SELECT * FROM part WHERE session_id = ?", (sess_id,))
sql = f"INSERT INTO {table} (id, {col_list}) VALUES (?, {placeholders})" except sqlite3.OperationalError:
cursor.execute(sql, [new_table_id] + values) pass
else: else:
sql = f"INSERT INTO {table} ({col_list}) VALUES ({placeholders})" for r in cursor.fetchall():
cursor.execute(sql, values) new_part_id = uuid.uuid4().hex
cols = [k for k in r.keys() if k != "id"]
values = []
for k in cols:
if k == "session_id":
values.append(new_id)
elif k == "message_id":
values.append(msg_id_map.get(r[k], r[k]))
else:
values.append(r[k])
col_list = ", ".join(cols)
placeholders = ", ".join(["?"] * len(cols))
cursor.execute(
f"INSERT INTO part (id, {col_list}) VALUES (?, {placeholders})",
[new_part_id] + values,
)
# Copy todo and session_share (session_id only, no secondary FKs)
for table in ("todo", "session_share"):
try:
cursor.execute(f"SELECT * FROM {table} WHERE session_id = ?", (sess_id,))
except sqlite3.OperationalError:
continue
for r in cursor.fetchall():
cols = [k for k in r.keys() if k != "id"]
values = [new_id if k == "session_id" else r[k] for k in cols]
col_list = ", ".join(cols)
placeholders = ", ".join(["?"] * len(cols))
if "id" in r.keys():
cursor.execute(
f"INSERT INTO {table} (id, {col_list}) VALUES (?, {placeholders})",
[uuid.uuid4().hex] + values,
)
else:
cursor.execute(
f"INSERT INTO {table} ({col_list}) VALUES ({placeholders})",
values,
)
new_session_ids.append(new_id) new_session_ids.append(new_id)
@@ -355,7 +432,8 @@ class Database:
Returns: Returns:
(success, error_message) (success, error_message)
""" """
assert self.conn is not None, "Database not connected" if self.conn is None:
raise RuntimeError("Database is not connected. Call connect() first.")
cursor = self.conn.cursor() cursor = self.conn.cursor()
try: try:

0
session-mover.py Normal file → Executable file
View File