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

217
app.py
View File

@@ -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")