fully functional, now ui tweaks

This commit is contained in:
Joe Fleming
2026-03-07 15:13:15 -07:00
parent 71afa51f09
commit bfa337ff13
2 changed files with 199 additions and 59 deletions

211
app.py
View File

@@ -5,7 +5,7 @@ A Textual-based TUI for managing OpenCode sessions.
from textual.app import App, ComposeResult 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, Tree, 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
from database import Database, Session from database import Database, Session
@@ -110,6 +110,12 @@ class MoveCopyDialog(ModalScreen):
self.query_one("#sql-preview").update("\n".join(lines)) 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: def action_cancel(self) -> None:
self.app.pop_screen() self.app.pop_screen()
@@ -161,6 +167,41 @@ class BackupDialog(ModalScreen):
self.app.pop_screen() 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): class SessionMoverApp(App):
"""Main TUI Application.""" """Main TUI Application."""
@@ -184,13 +225,17 @@ class SessionMoverApp(App):
padding: 1; padding: 1;
} }
#project-tree { #project-panel-title, #session-panel-title {
height: 1fr; height: 1;
border: solid $primary-lighten-2; padding: 0 1;
background: $primary-darken-2;
color: $text;
text-style: bold;
} }
Tree { #project-list {
padding: 0 1; height: 1fr;
border: solid $primary-lighten-2;
} }
#filter-bar { #filter-bar {
@@ -227,11 +272,20 @@ class SessionMoverApp(App):
} }
/* Dialog styles */ /* Dialog styles */
MoveCopyDialog, BackupDialog, DeleteConfirmDialog {
align: center middle;
}
#delete-warning {
margin: 1 0;
color: $warning;
}
#dialog, #backup-dialog { #dialog, #backup-dialog {
width: 50; width: 60;
height: auto; height: auto;
background: $surface; background: $surface;
padding: 1; padding: 1 2;
border: solid $primary; border: solid $primary;
} }
@@ -273,6 +327,8 @@ class SessionMoverApp(App):
("m", "move", "Move"), ("m", "move", "Move"),
("c", "copy", "Copy"), ("c", "copy", "Copy"),
("a", "toggle_archived", "Archived"), ("a", "toggle_archived", "Archived"),
("d", "delete", "Delete"),
("f", "focus_filter", "Filter"),
("r", "refresh", "Refresh"), ("r", "refresh", "Refresh"),
("space", "toggle_selection", "Select"), ("space", "toggle_selection", "Select"),
("escape", "clear_selection", "Clear"), ("escape", "clear_selection", "Clear"),
@@ -292,12 +348,15 @@ class SessionMoverApp(App):
yield Header() yield Header()
with Horizontal(id="main-hsplit"): with Horizontal(id="main-hsplit"):
with Vertical(id="project-panel"): 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"): with Vertical(id="session-panel"):
yield Label("Sessions", id="session-panel-title")
yield Static("Select a project from the left", id="current-selection") yield Static("Select a project from the left", id="current-selection")
with Horizontal(id="filter-bar"): 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("Clear", id="filter-clear", variant="default")
yield Button("Archived", id="archived-toggle", variant="default")
yield DataTable(id="sessions-table") yield DataTable(id="sessions-table")
yield Static("0 selected", id="selection-count") yield Static("0 selected", id="selection-count")
yield Footer() yield Footer()
@@ -307,10 +366,13 @@ class SessionMoverApp(App):
self.db.connect() self.db.connect()
self.db.load_reference_data() self.db.load_reference_data()
# Setup project tree self._project_ids: List[str] = []
tree = self.query_one("#project-tree") self.refresh_project_list()
tree.root.expand()
self.refresh_project_tree() # 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) # Setup sessions table (but don't load data yet)
table = self.query_one("#sessions-table") table = self.query_one("#sessions-table")
@@ -322,24 +384,49 @@ class SessionMoverApp(App):
status = self.query_one("#current-selection") status = self.query_one("#current-selection")
status.update("Select a project from the left") 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): def on_data_table_row_selected(self, event):
"""Handle row selection in either table.""" """Handle row selection in either table."""
# Use click to toggle selection in sessions table only # Use click to toggle selection in sessions table only
pass # Handled by click handler pass # Handled by click handler
def refresh_project_tree(self) -> None: def refresh_project_list(self) -> None:
"""Update the project tree.""" """Update the project list."""
tree = self.query_one("#project-tree") lv = self.query_one("#project-list", ListView)
tree.clear() lv.clear()
self._project_ids = []
root = tree.root projects = sorted(
root.data = None self.db.projects.values(),
root.expand() key=lambda p: (os.path.basename(p.worktree) if p.worktree else (p.name or "")).lower()
)
# Flat list of projects for proj in projects:
for proj in self.db.projects.values():
proj_name = os.path.basename(proj.worktree) if proj.worktree else (proj.name or "Unknown") 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: def refresh_sessions(self) -> None:
"""Update the sessions table.""" """Update the sessions table."""
@@ -388,36 +475,27 @@ class SessionMoverApp(App):
else: else:
count_widget.update("0 selected") count_widget.update("0 selected")
def on_tree_node_highlighted(self, event): def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
"""Handle project tree selection.""" """Handle project list selection."""
tree = self.query_one("#project-tree") if event.list_view.id != "project-list":
node = tree.cursor_node return
index = event.list_view.index
if node is None: if index is None or index >= len(self._project_ids):
return return
node_data = getattr(node, 'data', None) proj_id = self._project_ids[index]
if node_data is None:
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 = self.db.projects.get(proj_id) proj = self.db.projects.get(proj_id)
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")
if proj: if proj:
status.update(f"📁 {proj.worktree}") status.update(f"📁 {proj.worktree}")
# Clear selection when changing projects
if self.selected_session_ids: if self.selected_session_ids:
count = len(self.selected_session_ids) count = len(self.selected_session_ids)
self.selected_session_ids.clear() 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() self.refresh_sessions()
@@ -441,9 +519,17 @@ class SessionMoverApp(App):
self.refresh_sessions() self.refresh_sessions()
def action_clear_selection(self) -> None: def action_clear_selection(self) -> None:
"""Clear all selections.""" """Clear filter if active, otherwise clear session selections."""
self.selected_session_ids.clear() inp = self.query_one("#filter-input", Input)
self.refresh_sessions() 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: def action_move(self) -> None:
"""Open move dialog.""" """Open move dialog."""
@@ -479,15 +565,46 @@ class SessionMoverApp(App):
self.push_screen(MoveCopyDialog(self.db, sessions, is_copy=True)) 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: def action_toggle_archived(self) -> None:
"""Toggle archived sessions visibility.""" """Toggle archived sessions visibility."""
self.show_archived = not self.show_archived 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() self.refresh_sessions()
def action_refresh(self) -> None: def action_refresh(self) -> None:
"""Reload all data from database.""" """Reload all data from database."""
self.db.load_reference_data() self.db.load_reference_data()
self.refresh_project_tree() self.refresh_project_list()
self.refresh_sessions() self.refresh_sessions()
self.notify("Data refreshed", severity="information") self.notify("Data refreshed", severity="information")

View File

@@ -147,7 +147,7 @@ class Database:
params.append(workspace_id) params.append(workspace_id)
if search: 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}%"]) params.extend([f"%{search}%", f"%{search}%"])
query += " ORDER BY s.time_created DESC" query += " ORDER BY s.time_created DESC"
@@ -311,18 +311,17 @@ class Database:
# Copy related records # Copy related records
for table, foreign_key in [("message", "session_id"), ("part", "session_id"), ("todo", "session_id"), ("session_share", "session_id")]: 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() rows = cursor.fetchall()
for r in rows: 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) col_list = ", ".join(cols)
placeholders = ", ".join(["?"] * len(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 # Generate new ID for tables with id column
if "id" in r.keys(): if "id" in r.keys():
@@ -338,6 +337,30 @@ class Database:
self.conn.commit() self.conn.commit()
return True, sql_statements, new_session_ids 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: def create_backup(self) -> Path:
"""Create a timestamped backup of the database.""" """Create a timestamped backup of the database."""
if not self.db_path.exists(): if not self.db_path.exists():