fully functional, now ui tweaks
This commit is contained in:
217
app.py
217
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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user