From bfa337ff131474cc7e89cae6fa616957bf4ffd4b Mon Sep 17 00:00:00 2001 From: Joe Fleming Date: Sat, 7 Mar 2026 15:13:15 -0700 Subject: [PATCH] fully functional, now ui tweaks --- app.py | 217 ++++++++++++++++++++++++++++++++++++++++------------ database.py | 41 +++++++--- 2 files changed, 199 insertions(+), 59 deletions(-) diff --git a/app.py b/app.py index b60d5fa..0aaf96f 100644 --- a/app.py +++ b/app.py @@ -5,7 +5,7 @@ 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, Input, Button, Select, Tree, DataTable, Label, Static +from textual.widgets import Header, Footer, Input, Button, Select, ListView, ListItem, DataTable, Label, Static from textual.screen import ModalScreen from typing import List, Optional from database import Database, Session @@ -110,6 +110,12 @@ class MoveCopyDialog(ModalScreen): self.query_one("#sql-preview").update("\n".join(lines)) + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "cancel-btn": + self.action_cancel() + elif event.button.id == "confirm-btn": + self.action_confirm() + def action_cancel(self) -> None: self.app.pop_screen() @@ -161,6 +167,41 @@ class BackupDialog(ModalScreen): self.app.pop_screen() +class DeleteConfirmDialog(ModalScreen): + """Confirmation dialog before deleting sessions.""" + + BINDINGS = [ + ("escape", "cancel", "Cancel"), + ] + + def __init__(self, sessions: List[Session]): + super().__init__() + self.sessions = sessions + + def compose(self) -> ComposeResult: + titles = "\n".join(f" • {s.title or s.id[:12]}" for s in self.sessions[:10]) + if len(self.sessions) > 10: + titles += f"\n ... and {len(self.sessions) - 10} more" + with Container(id="dialog"): + yield Label("Delete Sessions", id="dialog-title") + yield Static(f"Permanently delete {len(self.sessions)} session(s)?\n\n{titles}\n\nThis cannot be undone.", id="delete-warning") + with Horizontal(id="dialog-buttons"): + yield Button("Cancel", variant="default", id="cancel-btn") + yield Button("Delete", variant="error", id="confirm-btn") + + def on_mount(self) -> None: + self.query_one("#dialog").border_title = "Confirm Delete" + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "cancel-btn": + self.action_cancel() + elif event.button.id == "confirm-btn": + self.dismiss(True) + + def action_cancel(self) -> None: + self.dismiss(False) + + class SessionMoverApp(App): """Main TUI Application.""" @@ -184,13 +225,17 @@ class SessionMoverApp(App): padding: 1; } - #project-tree { - height: 1fr; - border: solid $primary-lighten-2; + #project-panel-title, #session-panel-title { + height: 1; + padding: 0 1; + background: $primary-darken-2; + color: $text; + text-style: bold; } - Tree { - padding: 0 1; + #project-list { + height: 1fr; + border: solid $primary-lighten-2; } #filter-bar { @@ -227,11 +272,20 @@ class SessionMoverApp(App): } /* Dialog styles */ + MoveCopyDialog, BackupDialog, DeleteConfirmDialog { + align: center middle; + } + + #delete-warning { + margin: 1 0; + color: $warning; + } + #dialog, #backup-dialog { - width: 50; + width: 60; height: auto; background: $surface; - padding: 1; + padding: 1 2; border: solid $primary; } @@ -273,6 +327,8 @@ class SessionMoverApp(App): ("m", "move", "Move"), ("c", "copy", "Copy"), ("a", "toggle_archived", "Archived"), + ("d", "delete", "Delete"), + ("f", "focus_filter", "Filter"), ("r", "refresh", "Refresh"), ("space", "toggle_selection", "Select"), ("escape", "clear_selection", "Clear"), @@ -292,12 +348,15 @@ class SessionMoverApp(App): yield Header() with Horizontal(id="main-hsplit"): with Vertical(id="project-panel"): - yield Tree("Projects", id="project-tree") + yield Label("All Projects", id="project-panel-title") + yield ListView(id="project-list") with Vertical(id="session-panel"): + yield Label("Sessions", id="session-panel-title") yield Static("Select a project from the left", id="current-selection") with Horizontal(id="filter-bar"): - yield Input(placeholder="🔍 Filter...", id="filter-input") + yield Input(placeholder="🔍 Filter... (f)", id="filter-input") yield Button("Clear", id="filter-clear", variant="default") + yield Button("Archived", id="archived-toggle", variant="default") yield DataTable(id="sessions-table") yield Static("0 selected", id="selection-count") yield Footer() @@ -307,10 +366,13 @@ class SessionMoverApp(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() + self._project_ids: List[str] = [] + self.refresh_project_list() + + # Keep filter widgets out of tab order; use 'f' to focus + self.query_one("#filter-input", Input).can_focus = False + self.query_one("#filter-clear", Button).can_focus = False + self.query_one("#archived-toggle", Button).can_focus = False # Setup sessions table (but don't load data yet) table = self.query_one("#sessions-table") @@ -322,24 +384,49 @@ class SessionMoverApp(App): status = self.query_one("#current-selection") status.update("Select a project from the left") + def on_input_changed(self, event: Input.Changed) -> None: + if event.input.id == "filter-input": + self.current_filter = event.value + self.refresh_sessions() + + def on_input_submitted(self, event: Input.Submitted) -> None: + if event.input.id == "filter-input": + inp = self.query_one("#filter-input", Input) + inp.can_focus = False + self.query_one("#sessions-table", DataTable).focus() + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "filter-clear": + self.query_one("#filter-input", Input).value = "" + self.current_filter = "" + self.refresh_sessions() + elif event.button.id == "archived-toggle": + self.action_toggle_archived() + + def action_focus_filter(self) -> None: + inp = self.query_one("#filter-input", Input) + inp.can_focus = True + inp.focus() + def on_data_table_row_selected(self, event): """Handle row selection in either table.""" # Use click to toggle selection in sessions table only pass # Handled by click handler - def refresh_project_tree(self) -> None: - """Update the project tree.""" - tree = self.query_one("#project-tree") - tree.clear() + def refresh_project_list(self) -> None: + """Update the project list.""" + lv = self.query_one("#project-list", ListView) + lv.clear() + self._project_ids = [] - root = tree.root - root.data = None - root.expand() - - # Flat list of projects - for proj in self.db.projects.values(): + projects = sorted( + self.db.projects.values(), + key=lambda p: (os.path.basename(p.worktree) if p.worktree else (p.name or "")).lower() + ) + for proj in projects: proj_name = os.path.basename(proj.worktree) if proj.worktree else (proj.name or "Unknown") - root.add(proj_name, {"type": "project", "id": proj.id, "label": proj_name, "full_path": proj.worktree}) + lv.append(ListItem(Label(proj_name))) + self._project_ids.append(proj.id) def refresh_sessions(self) -> None: """Update the sessions table.""" @@ -388,37 +475,28 @@ class SessionMoverApp(App): else: count_widget.update("0 selected") - def on_tree_node_highlighted(self, event): - """Handle project tree selection.""" - tree = self.query_one("#project-tree") - node = tree.cursor_node - - if node is None: + def on_list_view_highlighted(self, event: ListView.Highlighted) -> None: + """Handle project list selection.""" + if event.list_view.id != "project-list": return - - node_data = getattr(node, 'data', None) - if node_data is None: + index = event.list_view.index + if index is None or index >= len(self._project_ids): return - - node_type = node_data.get("type") - if node_type != "project": - return - - status = self.query_one("#current-selection") - proj_id = node_data.get("id") + + proj_id = self._project_ids[index] proj = self.db.projects.get(proj_id) self.filter_project_id = proj_id self.filter_workspace_id = None - + + status = self.query_one("#current-selection") if proj: status.update(f"📁 {proj.worktree}") - - # Clear selection when changing projects + if self.selected_session_ids: count = len(self.selected_session_ids) self.selected_session_ids.clear() - self.notify(f"Selection cleared ({count} session(s)))", severity="information") - + self.notify(f"Selection cleared ({count} session(s))", severity="information") + self.refresh_sessions() def action_toggle_selection(self) -> None: @@ -441,9 +519,17 @@ class SessionMoverApp(App): self.refresh_sessions() def action_clear_selection(self) -> None: - """Clear all selections.""" - self.selected_session_ids.clear() - self.refresh_sessions() + """Clear filter if active, otherwise clear session selections.""" + inp = self.query_one("#filter-input", Input) + if inp.has_focus or self.current_filter: + inp.value = "" + self.current_filter = "" + inp.can_focus = False + self.query_one("#sessions-table", DataTable).focus() + self.refresh_sessions() + else: + self.selected_session_ids.clear() + self.refresh_sessions() def action_move(self) -> None: """Open move dialog.""" @@ -479,15 +565,46 @@ class SessionMoverApp(App): self.push_screen(MoveCopyDialog(self.db, sessions, is_copy=True)) + def action_delete(self) -> None: + """Open delete confirmation dialog.""" + if not self.selected_session_ids: + self.notify("No sessions selected", severity="warning") + return + + sessions = [self.db.get_session(sid) for sid in self.selected_session_ids] + sessions = [s for s in sessions if s] + + if not self.backup_made: + self.db.create_backup() + self.backup_made = True + self.notify("Backup created", severity="information") + + def handle_result(confirmed: bool) -> None: + if not confirmed: + return + success, err = self.db.delete_sessions([s.id for s in sessions]) + if success: + self.selected_session_ids.clear() + self.notify(f"Deleted {len(sessions)} session(s)", severity="information") + self.refresh_sessions() + else: + self.notify(f"Delete failed: {err}", severity="error") + + self.push_screen(DeleteConfirmDialog(sessions), handle_result) + def action_toggle_archived(self) -> None: """Toggle archived sessions visibility.""" self.show_archived = not self.show_archived + btn = self.query_one("#archived-toggle", Button) + btn.variant = "success" if self.show_archived else "default" + title = self.query_one("#session-panel-title", Label) + title.update("Sessions (+ Archived)" if self.show_archived else "Sessions") self.refresh_sessions() def action_refresh(self) -> None: """Reload all data from database.""" self.db.load_reference_data() - self.refresh_project_tree() + self.refresh_project_list() self.refresh_sessions() self.notify("Data refreshed", severity="information") diff --git a/database.py b/database.py index 1b50b3a..a65f311 100644 --- a/database.py +++ b/database.py @@ -147,7 +147,7 @@ class Database: params.append(workspace_id) if search: - query += " AND (s.title LIKE ? OR s.slug LIKE ?)" + query += " AND (LOWER(s.title) LIKE LOWER(?) OR LOWER(s.slug) LIKE LOWER(?))" params.extend([f"%{search}%", f"%{search}%"]) query += " ORDER BY s.time_created DESC" @@ -311,18 +311,17 @@ class Database: # 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,)) + try: + cursor.execute(f"SELECT * FROM {table} WHERE {foreign_key} = ?", (sess_id,)) + except sqlite3.OperationalError: + continue # Table doesn't exist in this schema version rows = cursor.fetchall() for r in rows: - cols = [k for k in r.keys() if k != "id" and k != foreign_key] + # Include foreign_key in cols so the new session_id is written + cols = [k for k in r.keys() if k != "id"] + values = [new_id if k == foreign_key else r[k] for k in cols] 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(): @@ -338,6 +337,30 @@ class Database: self.conn.commit() return True, sql_statements, new_session_ids + def delete_sessions(self, session_ids: List[str]) -> Tuple[bool, str]: + """ + Permanently delete sessions and all related records. + + Returns: + (success, error_message) + """ + assert self.conn is not None, "Database not connected" + cursor = self.conn.cursor() + + try: + for sess_id in session_ids: + for table, foreign_key in [("message", "session_id"), ("part", "session_id"), ("todo", "session_id"), ("session_share", "session_id")]: + try: + cursor.execute(f"DELETE FROM {table} WHERE {foreign_key} = ?", (sess_id,)) + except sqlite3.OperationalError: + continue + cursor.execute("DELETE FROM session WHERE id = ?", (sess_id,)) + self.conn.commit() + return True, "" + except sqlite3.Error as e: + self.conn.rollback() + return False, str(e) + def create_backup(self) -> Path: """Create a timestamped backup of the database.""" if not self.db_path.exists():