fully functional, now ui tweaks
This commit is contained in:
207
app.py
207
app.py
@@ -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,7 +519,15 @@ 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."""
|
||||||
|
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.selected_session_ids.clear()
|
||||||
self.refresh_sessions()
|
self.refresh_sessions()
|
||||||
|
|
||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
39
database.py
39
database.py
@@ -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")]:
|
||||||
|
try:
|
||||||
cursor.execute(f"SELECT * FROM {table} WHERE {foreign_key} = ?", (sess_id,))
|
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():
|
||||||
|
|||||||
Reference in New Issue
Block a user