From 6c51fcd9ae62206ba5199080316b045a2f8a1afe Mon Sep 17 00:00:00 2001 From: rambros Date: Tue, 3 Mar 2026 03:47:59 +0530 Subject: [PATCH] improve proress tracking --- src/ui/backup_ops.py | 16 +- src/ui/modals.py | 399 +++++++++++++++++++++++++++++++++--------- src/ui/mode_screen.py | 76 +++----- src/ui/shuttle_ops.py | 89 ++++------ 4 files changed, 384 insertions(+), 196 deletions(-) diff --git a/src/ui/backup_ops.py b/src/ui/backup_ops.py index 9a0902d..fae7131 100644 --- a/src/ui/backup_ops.py +++ b/src/ui/backup_ops.py @@ -18,7 +18,7 @@ from textual import work from src.core.configuration import load_config from src.core.base import MigrationContext from src.disco_reaper.exporter import DiscordExporter -from src.ui.modals import ProgressModal, ChannelSelectModal +from src.ui.modals import ProgressScreen, ChannelSelectScreen, ReportModal class BackupPane(Container): @@ -123,7 +123,7 @@ class BackupPane(Container): @work(exclusive=True, thread=True) async def run_backup_profile(self) -> None: - modal = ProgressModal() + modal = ProgressScreen() self.app.call_from_thread(self.app.push_screen, modal) await asyncio.sleep(0.1) @@ -152,12 +152,12 @@ class BackupPane(Container): self.app.call_from_thread(modal.write, f"[bold red]Error: {e}[/bold red]") finally: await self.engine.close_connections() - self.app.call_from_thread(modal.set_status, "Finished.") self.app.call_from_thread(modal.allow_close) + self.app.call_from_thread(self.app.push_screen, ReportModal("Backup Complete", "Server profile and structure successfully exported.")) @work(exclusive=True, thread=True) async def run_backup_messages(self) -> None: - modal_prog = ProgressModal() + modal_prog = ProgressScreen() self.app.call_from_thread(self.app.push_screen, modal_prog) await asyncio.sleep(0.1) @@ -199,7 +199,7 @@ class BackupPane(Container): self.app.call_from_thread( self.app.push_screen, - ChannelSelectModal(eligible_channels, cat_map, backed_up_ids, any_found), + ChannelSelectScreen(eligible_channels, cat_map, backed_up_ids, any_found), check_channels, ) @@ -241,12 +241,12 @@ class BackupPane(Container): self.app.call_from_thread(modal_prog.write, f"[bold red]Message backup failed: {e}[/bold red]") finally: await self.engine.close_connections() - self.app.call_from_thread(modal_prog.set_status, "Finished.") self.app.call_from_thread(modal_prog.allow_close) + self.app.call_from_thread(self.app.push_screen, ReportModal("Backup Complete", "Channel messages and threads successfully backed up.")) @work(exclusive=True, thread=True) async def run_backup_sync(self) -> None: - modal_prog = ProgressModal() + modal_prog = ProgressScreen() self.app.call_from_thread(self.app.push_screen, modal_prog) await asyncio.sleep(0.1) @@ -293,5 +293,5 @@ class BackupPane(Container): self.app.call_from_thread(modal_prog.write, f"[bold red]Sync failed: {e}[/bold red]") finally: await self.engine.close_connections() - self.app.call_from_thread(modal_prog.set_status, "Finished.") self.app.call_from_thread(modal_prog.allow_close) + self.app.call_from_thread(self.app.push_screen, ReportModal("Sync Complete", "Backup cleanly synced with latest Discord data.")) diff --git a/src/ui/modals.py b/src/ui/modals.py index 2eb4cc4..2344513 100644 --- a/src/ui/modals.py +++ b/src/ui/modals.py @@ -3,47 +3,183 @@ Shared modals used by backup and shuttle operations. """ from textual.app import ComposeResult -from textual.containers import Horizontal, Vertical, VerticalScroll -from textual.widgets import Button, Label, Input, ProgressBar, RichLog, Rule, RadioButton -from textual.screen import ModalScreen +from textual.containers import Horizontal, Vertical, VerticalScroll, Container, Center +from textual.widgets import Button, Label, Input, ProgressBar, RichLog, Rule, RadioButton, LoadingIndicator, Header, Footer, RadioSet +from textual.screen import ModalScreen, Screen +import time + # --------------------------------------------------------------------------- -# ProgressModal – unified progress / log display +# ProgressScreen – unified full-screen progress / log / stats display # --------------------------------------------------------------------------- -class ProgressModal(ModalScreen[None]): - """Modal to display progress for any long-running operation.""" +class ProgressScreen(Screen[None]): + """Screen to display progress for any operation, with stats and logs.""" + + DEFAULT_CSS = """ + ProgressScreen { align: center middle; } + #prog_outer { width: 100%; height: 100%; align: center top; } + #prog_dialog { + width: 80%; + height: 100%; + layout: vertical; + border: solid green; + padding: 1 2; + margin: 2 0; + background: $surface; + } + #prog_header { height: auto; margin-bottom: 1; dock: top; } + #prog_status { text-style: bold; width: 1fr; content-align: left middle; } + #prog_timer { text-style: bold; width: 20; content-align: right middle; color: yellow; } + + #prog_stats { + height: auto; + layout: horizontal; + border: solid cyan; + padding: 1; + margin-bottom: 1; + display: none; + } + .stat_label { width: 1fr; content-align: center middle; text-style: bold; } + + #prog_log { height: 1fr; margin-bottom: 1; border: solid $primary; } + #prog_loader { margin-bottom: 1; } + #prog_bar_container { height: auto; width: 100%; } + #prog_bar { margin-bottom: 1; width: 80%; } + + #prog_actions { height: auto; margin-top: 1; dock: bottom; margin-bottom: 0; } + #btn_close_progress { width: 1fr; margin: 0 1; } + """ def compose(self) -> ComposeResult: - with Vertical(id="progress_dialog"): - yield Label("Operation Status", id="progress_status") - yield ProgressBar(total=None, show_eta=False, id="progress_bar") - yield RichLog(id="progress_log", highlight=True, markup=True) - yield Button("Close", id="btn_close_progress", disabled=True) + yield Header(show_clock=True) + with Container(id="prog_outer"): + with Container(id="prog_dialog"): + with Horizontal(id="prog_header"): + yield Label("Operation Status...", id="prog_status") + yield Label("00:00", id="prog_timer") + + with Horizontal(id="prog_stats"): + yield Label("Messages: 0", id="stat_messages", classes="stat_label") + yield Label("Threads: 0", id="stat_threads", classes="stat_label") + yield Label("Files: 0", id="stat_files", classes="stat_label") + + yield LoadingIndicator(id="prog_loader") + with Center(id="prog_bar_container"): + pb = ProgressBar(total=None, show_eta=False, id="prog_bar") + pb.display = False + yield pb + + yield RichLog(id="prog_log", highlight=True, markup=True) + + with Horizontal(id="prog_actions"): + yield Button("Close", id="btn_close_progress", disabled=True) + yield Footer() + + def on_mount(self): + self.start_time = time.time() + self.timer_event = self.set_interval(1.0, self.update_timer) + + def update_timer(self): + elapsed = int(time.time() - self.start_time) + mins, secs = divmod(elapsed, 60) + try: + self.query_one("#prog_timer", Label).update(f"Elapsed: {mins:02d}:{secs:02d}") + except Exception: + pass def on_button_pressed(self, event: Button.Pressed): if event.button.id == "btn_close_progress": + if self.timer_event: + self.timer_event.stop() self.dismiss(None) def write(self, message: str): - self.query_one("#progress_log", RichLog).write(message) + try: + self.query_one("#prog_log", RichLog).write(message) + except Exception: + pass - # alias used by backup code write_to_log = write def set_status(self, status: str): - self.query_one("#progress_status", Label).update(status) + try: + self.query_one("#prog_status", Label).update(status) + except Exception: + pass def set_progress(self, current: int, total: int): - bar = self.query_one("#progress_bar", ProgressBar) - bar.update(total=total, progress=current) + try: + self.query_one("#prog_loader", LoadingIndicator).display = False + bar = self.query_one("#prog_bar", ProgressBar) + bar.display = True + bar.update(total=total, progress=current) + except Exception: + pass + + def show_stats(self): + try: + self.query_one("#prog_stats", Horizontal).display = True + except Exception: + pass + + def update_stats(self, **kwargs): + # kwargs can be messages, threads, files + for key, val in kwargs.items(): + try: + self.query_one(f"#stat_{key}", Label).update(f"{key.capitalize()}: {val}") + except Exception: + pass def allow_close(self): - btn = self.query_one("#btn_close_progress", Button) - btn.disabled = False - btn.variant = "success" - self.query_one("#progress_bar", ProgressBar).update(total=100, progress=100) + if self.timer_event: + self.timer_event.stop() + try: + btn = self.query_one("#btn_close_progress", Button) + btn.disabled = False + btn.variant = "success" + self.query_one("#prog_loader", LoadingIndicator).display = False + bar = self.query_one("#prog_bar", ProgressBar) + bar.display = True + bar.update(total=100, progress=100) + except Exception: + pass + +# --------------------------------------------------------------------------- +# ReportModal – simple post-operation report +# --------------------------------------------------------------------------- + +class ReportModal(ModalScreen[None]): + """Modal to display a post-operation report.""" + + DEFAULT_CSS = """ + ReportModal { align: center middle; } + #report_dialog { + width: 60; + height: auto; + border: thick $background 80%; + background: $surface; + padding: 1 2; + } + #report_title { text-style: bold; margin-bottom: 1; content-align: center middle; width: 100%; color: green; } + #report_content { margin-bottom: 2; height: auto; } + #report_btn { width: 1fr; margin: 0 1; } + """ + + def __init__(self, title: str, report_text: str): + super().__init__() + self.report_title = title + self.report_text = report_text + + def compose(self) -> ComposeResult: + with Vertical(id="report_dialog"): + yield Label(self.report_title, id="report_title") + yield Label(self.report_text, id="report_content") + yield Button("OK", variant="primary", id="report_btn") + + def on_button_pressed(self, event: Button.Pressed): + self.dismiss(None) # --------------------------------------------------------------------------- @@ -52,6 +188,7 @@ class ProgressModal(ModalScreen[None]): class ConfirmModal(ModalScreen[bool]): """Simple Yes / No confirmation modal.""" + DEFAULT_CSS = "ConfirmModal { align: center middle; }" def __init__(self, message: str, danger: bool = False): super().__init__() @@ -75,6 +212,7 @@ class ConfirmModal(ModalScreen[bool]): class SubMenuModal(ModalScreen[str]): """A generic sub-menu modal that presents a list of labelled buttons.""" + DEFAULT_CSS = "SubMenuModal { align: center middle; }" def __init__(self, title: str, options: list[tuple[str, str, str]]): """options: list of (button_id, label, variant)""" @@ -101,27 +239,63 @@ class SubMenuModal(ModalScreen[str]): # ChannelPickerModal – single-channel selection (shuttle) # --------------------------------------------------------------------------- -class ChannelPickerModal(ModalScreen[int]): - """Modal listing Discord channels for single-channel selection.""" +class ChannelPickerScreen(Screen[tuple]): + """Screen listing Discord channels (left) and Target platforms channels (right) for dual selection.""" - def __init__(self, channels: list, categories: dict, label: str = "Select Channel"): + DEFAULT_CSS = """ + ChannelPickerScreen { align: center middle; } + #cp_outer { width: 100%; height: 100%; align: center top; } + #chanpick_dialog { + width: 80%; + height: 100%; + layout: vertical; + border: solid green; + padding: 1 2; + margin: 2 0; + background: $surface; + } + #chanpick_title { text-style: bold; margin-bottom: 1; content-align: center middle; width: 100%; } + #chanpick_split { + height: 1fr; + layout: horizontal; + } + .split_pane { + width: 1fr; + height: 100%; + border: solid $primary; + margin: 0 1; + padding: 0 1; + } + .pane_title { text-style: bold; margin-bottom: 1; color: cyan; margin-left: 1; } + .category_header { + margin-top: 1; + background: $primary 10%; + text-style: bold; + padding-left: 1; + } + #chanpick_buttons { height: auto; margin-top: 1; dock: bottom; margin-bottom: 0; } + #chanpick_buttons Button { width: 1fr; margin: 0 1; } + """ + + def __init__(self, src_channels: list, src_cat_map: dict, tgt_channels: list, tgt_cat_map: dict, tgt_name: str = "Fluxer"): super().__init__() - self._channels = channels - self._categories = categories - self._label = label + self.src_channels = src_channels + self.src_cat_map = src_cat_map + self.tgt_channels = tgt_channels + self.tgt_cat_map = tgt_cat_map + self.tgt_name = tgt_name - def compose(self) -> ComposeResult: - with Vertical(id="chanpick_dialog"): - yield Label(self._label, id="chanpick_title") - with VerticalScroll(id="chanpick_scroll"): - cat_grouped: dict[int | None, list] = {} - for c in self._channels: - cat_id = getattr(c, "category_id", None) if not isinstance(c, dict) else c.get("parent_id") - cat_grouped.setdefault(cat_id, []).append(c) + def _render_pane(self, channels, categories, pane_id, prefix): + cat_grouped: dict[int | None, list] = {} + for c in channels: + cat_id = getattr(c, "category_id", None) if not isinstance(c, dict) else c.get("parent_id") + cat_grouped.setdefault(cat_id, []).append(c) - for cat_id in sorted(cat_grouped, key=lambda k: self._categories.get(k, "") if k else ""): - if cat_id is not None and cat_id in self._categories: - yield Label(f"[cyan]{self._categories[cat_id]}[/cyan]", classes="category_header") + with VerticalScroll(classes="split_pane", id=pane_id): + with RadioSet(id=f"{prefix}_radioset"): + for cat_id in sorted(cat_grouped, key=lambda k: categories.get(k, "") if k else ""): + if cat_id is not None and cat_id in categories: + yield Label(f"[cyan]{categories[cat_id]}[/cyan]", classes="category_header") for c in cat_grouped[cat_id]: if isinstance(c, dict): name = c.get("name", "Unnamed") @@ -129,30 +303,91 @@ class ChannelPickerModal(ModalScreen[int]): else: name = c.name cid = c.id - yield RadioButton(name, value=False, id=f"chpk_{cid}") + yield RadioButton(name, value=False, id=f"{prefix}_{cid}") - with Horizontal(id="chanpick_buttons"): - yield Button("Select", variant="success", id="btn_pick_ok") - yield Button("Cancel", id="btn_pick_cancel") + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + with Container(id="cp_outer"): + with Container(id="chanpick_dialog"): + yield Label("Select Source and Target Channels", id="chanpick_title") + with Horizontal(id="chanpick_split"): + with Vertical(): + yield Label("Source: Discord", classes="pane_title") + yield from self._render_pane(self.src_channels, self.src_cat_map, "pane_src", "src") + + with Vertical(): + yield Label(f"Target: {self.tgt_name}", classes="pane_title") + yield from self._render_pane(self.tgt_channels, self.tgt_cat_map, "pane_tgt", "tgt") + + yield Rule() + with Horizontal(id="chanpick_buttons"): + yield Button("Select", variant="success", id="btn_pick_ok") + yield Button("Cancel", id="btn_pick_cancel") + yield Footer() def on_button_pressed(self, event: Button.Pressed): if event.button.id == "btn_pick_cancel": self.dismiss(None) elif event.button.id == "btn_pick_ok": - for rb in self.query(RadioButton): - if rb.value and rb.id and rb.id.startswith("chpk_"): - self.dismiss(int(rb.id.split("_", 1)[1])) - return - # Nothing selected - return + src_val = None + tgt_val = None + + src_rset = self.query_one("#src_radioset", RadioSet) + if src_rset.pressed_button: + src_val = src_rset.pressed_button.id.split("_", 1)[1] + + tgt_rset = self.query_one("#tgt_radioset", RadioSet) + if tgt_rset.pressed_button: + tgt_val = tgt_rset.pressed_button.id.split("_", 1)[1] + + if src_val and tgt_val: + self.dismiss((int(src_val), tgt_val)) # Source is guaranteed Discord ID (int), Target could be UUID/string + else: + self.notify("Please select one channel from both lists.", severity="warning") # --------------------------------------------------------------------------- # ChannelSelectModal – multi-channel selection with sync/force (backup) # --------------------------------------------------------------------------- -class ChannelSelectModal(ModalScreen[dict]): - """Modal for selecting channels using a simple checkbox list.""" +class ChannelSelectScreen(Screen[dict]): + """Screen for selecting channels using a simple checkbox list.""" + + DEFAULT_CSS = """ + ChannelSelectScreen { align: center middle; } + #cs_outer { width: 100%; height: 100%; align: center top; } + #channel_dialog { + width: 80%; + height: 100%; + layout: vertical; + border: solid green; + padding: 1 2; + margin: 2 0; + background: $surface; + } + #chan_title { text-style: bold; margin-bottom: 1; } + #channel_list_scroll { + height: 1fr; + border: solid $primary; + margin-bottom: 1; + padding: 0 1; + } + .category_header { + margin-top: 1; + background: $primary 10%; + text-style: bold; + padding-left: 1; + } + .label_warning { + padding-top: 1; + padding-right: 1; + color: yellow; + } + #select_all_buttons { height: auto; margin-bottom: 1; } + #select_all_buttons Button { width: auto; margin-right: 1; } + #confirm_buttons { height: auto; margin-top: 1; dock: bottom; margin-bottom: 0; } + #confirm_buttons Button { width: 1fr; margin: 0 1; } + """ def __init__(self, channels: list, categories: dict, backed_up_ids: set, any_found: bool): super().__init__() @@ -169,42 +404,46 @@ class ChannelSelectModal(ModalScreen[dict]): self.any_found = any_found def compose(self) -> ComposeResult: - with Vertical(id="channel_dialog"): - yield Label("Select Channels to Backup", id="chan_title") + yield Header(show_clock=True) + with Container(id="cs_outer"): + with Container(id="channel_dialog"): + yield Label("Select Channels to Backup", id="chan_title") - with VerticalScroll(id="channel_list_scroll"): - cat_ids = sorted( - [k for k in self.channels_by_category.keys() if k is not None], - key=lambda k: self.categories.get(k, ""), - ) + with VerticalScroll(id="channel_list_scroll"): + cat_ids = sorted( + [k for k in self.channels_by_category.keys() if k is not None], + key=lambda k: self.categories.get(k, ""), + ) - # No category channels - if None in self.channels_by_category: - for c in sorted(self.channels_by_category[None], key=lambda x: x.position if hasattr(x, 'position') else 0): - label = f"{c.name}" - color = "green" if c.id in self.backed_up_ids else "white" - yield RadioButton(f"[{color}]{label}[/]", value=False, id=f"chan_{c.id}") + # No category channels + if None in self.channels_by_category: + for c in sorted(self.channels_by_category[None], key=lambda x: x.position if hasattr(x, 'position') else 0): + label = f"{c.name}" + color = "green" if c.id in self.backed_up_ids else "white" + yield RadioButton(f"[{color}]{label}[/]", value=False, id=f"chan_{c.id}") - for cat_id in cat_ids: - cat_name = self.categories.get(cat_id, "Unknown Category") - yield Label(f"[cyan]{cat_name}[/cyan]", classes="category_header") - for c in sorted(self.channels_by_category[cat_id], key=lambda x: x.position if hasattr(x, 'position') else 0): - label = f"{c.name}" - color = "green" if c.id in self.backed_up_ids else "white" - yield RadioButton(f"[{color}]{label}[/]", value=False, id=f"chan_{c.id}") + for cat_id in cat_ids: + cat_name = self.categories.get(cat_id, "Unknown Category") + yield Label(f"[cyan]{cat_name}[/cyan]", classes="category_header") + for c in sorted(self.channels_by_category[cat_id], key=lambda x: x.position if hasattr(x, 'position') else 0): + label = f"{c.name}" + color = "green" if c.id in self.backed_up_ids else "white" + yield RadioButton(f"[{color}]{label}[/]", value=False, id=f"chan_{c.id}") - with Horizontal(id="select_all_buttons"): - yield Button("Select All", id="btn_all") - yield Button("Deselect All", id="btn_none") + with Horizontal(id="select_all_buttons"): + yield Button("Select All", id="btn_all") + yield Button("Deselect All", id="btn_none") - with Horizontal(id="confirm_buttons"): - if self.any_found: - yield Label("Existing backups found:", classes="label_warning") - yield Button("Sync", variant="success", id="btn_sync") - yield Button("Force Overwrite", variant="error", id="btn_force") - else: - yield Button("Backup", variant="success", id="btn_backup") - yield Button("Cancel", id="btn_cancel_chan") + yield Rule() + with Horizontal(id="confirm_buttons"): + if self.any_found: + yield Label("Existing backups found:", classes="label_warning") + yield Button("Sync", variant="success", id="btn_sync") + yield Button("Force Overwrite", variant="error", id="btn_force") + else: + yield Button("Backup", variant="success", id="btn_backup") + yield Button("Cancel", id="btn_cancel_chan") + yield Footer() def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "btn_all": diff --git a/src/ui/mode_screen.py b/src/ui/mode_screen.py index cd374a3..4de6162 100644 --- a/src/ui/mode_screen.py +++ b/src/ui/mode_screen.py @@ -22,7 +22,7 @@ class ModeScreen(Screen): """Unified mode screen — adapts content based on tool_mode.""" CSS = """ - #main_scroll { + #main_outer { align: center top; height: 100%; } @@ -48,33 +48,22 @@ class ModeScreen(Screen): margin: 0 1; } - /* Modal styling (shared across both panes) */ - #progress_dialog { + ModalScreen { + align: center middle; + } + #progress_dialog, #shuttle_config_dialog, #platform_select_dialog, #submenu_dialog, #confirm_dialog, #chanpick_dialog, #channel_dialog { width: 80%; - height: 80%; - border: thick $background 80%; - background: $surface; + height: 100%; + layout: vertical; + border: solid green; padding: 1 2; + margin: 2 0; + background: $surface; } #progress_status { text-style: bold; margin-bottom: 1; } #progress_bar { margin-bottom: 1; } + #progress_loader { margin-bottom: 1; } #progress_log { height: 1fr; margin-bottom: 1; border: solid $primary; } - - #shuttle_config_dialog, #platform_select_dialog, #submenu_dialog, #confirm_dialog { - width: 60%; - height: auto; - max-height: 80%; - border: thick $background 80%; - background: $surface; - padding: 1 2; - } - #chanpick_dialog { - width: 70%; - height: 75%; - border: thick $background 80%; - background: $surface; - padding: 1 2; - } #chanpick_scroll { height: 1fr; border: solid $primary; @@ -96,14 +85,6 @@ class ModeScreen(Screen): #config_buttons Button, #confirm_buttons Button, #chanpick_buttons Button { width: 1fr; margin: 0 1; } - - #channel_dialog { - width: 80%; - height: 80%; - border: thick $background 80%; - background: $surface; - padding: 1 2; - } #channel_list_scroll { height: 1fr; border: solid $primary; @@ -134,7 +115,7 @@ class ModeScreen(Screen): mode = self.config.tool_mode or "backup_only" - with VerticalScroll(id="main_scroll"): + with Container(id="main_outer"): with Container(id="main_container"): if mode == "backup_only": yield BackupPane(self.cfg_name, self.config_path, id="pane_backup") @@ -162,9 +143,17 @@ class ModeScreen(Screen): from src.ui.main_app import ConfigScreen def reload_screen(saved: bool = False): if saved: - self.app.pop_screen() - from src.ui.mode_screen import ModeScreen - self.app.push_screen(ModeScreen(self.cfg_name, self.config_path)) + new_cfg = load_config(self.config_path) + if new_cfg.tool_mode != self.config.tool_mode: + self.app.pop_screen() + from src.ui.mode_screen import ModeScreen + self.app.push_screen(ModeScreen(self.cfg_name, self.config_path)) + else: + self.config = new_cfg + for pane in self.query(BackupPane): + pane.reload_config() + for pane in self.query(ShuttlePane): + pane.reload_config() self.app.push_screen(ConfigScreen(self.cfg_name, self.config_path), reload_screen) elif bid == "btn_switch": self._toggle_pane() @@ -181,22 +170,3 @@ class ModeScreen(Screen): switcher.current = "pane_backup" btn.label = "Switch to Migrate ⇄" self._showing_backup = True - - def on_screen_resume(self) -> None: - """Reload config when returning from ConfigScreen. - If the mode changed, pop back to ConfigSelectionScreen so the user - re-enters with the correct layout. - """ - old_mode = self.config.tool_mode - self.config = load_config(self.config_path) - new_mode = self.config.tool_mode - - if new_mode != old_mode: - self.app.pop_screen() - return - - # Propagate config reload to panes - for pane in self.query(BackupPane): - pane.reload_config() - for pane in self.query(ShuttlePane): - pane.reload_config() diff --git a/src/ui/shuttle_ops.py b/src/ui/shuttle_ops.py index 10aecba..1722a0e 100644 --- a/src/ui/shuttle_ops.py +++ b/src/ui/shuttle_ops.py @@ -20,7 +20,7 @@ from src.core.configuration import load_config from src.core.base import MigrationContext from src.core.audit import log_audit_event from src.ui.modals import ( - ProgressModal, ConfirmModal, SubMenuModal, ChannelPickerModal, + ProgressScreen, ReportModal, ConfirmModal, SubMenuModal, ChannelPickerScreen, ) import src.fluxer.roles_permissions as fluxer_roles @@ -292,7 +292,7 @@ class ShuttlePane(Container): else: from src.stoat.clone_server import sync_channel_state, migrate_channels - modal = ProgressModal() + modal = ProgressScreen() self.app.push_screen(modal) await asyncio.sleep(0.1) @@ -356,7 +356,7 @@ class ShuttlePane(Container): @work(exclusive=True) async def run_clone_roles(self) -> None: roles_mod = fluxer_roles if self.target_platform == "fluxer" else stoat_roles - modal = ProgressModal() + modal = ProgressScreen() self.app.push_screen(modal) await asyncio.sleep(0.1) @@ -394,7 +394,7 @@ class ShuttlePane(Container): @work(exclusive=True) async def run_sync_permissions(self) -> None: roles_mod = fluxer_roles if self.target_platform == "fluxer" else stoat_roles - modal = ProgressModal() + modal = ProgressScreen() self.app.push_screen(modal) await asyncio.sleep(0.1) @@ -453,7 +453,7 @@ class ShuttlePane(Container): @work(exclusive=True) async def run_copy_emojis(self, types_to_include: list[str]) -> None: asset_mod = stoat_emoji_stickers if self.target_platform == "stoat" else fluxer_emoji_stickers - modal = ProgressModal() + modal = ProgressScreen() self.app.push_screen(modal) await asyncio.sleep(0.1) @@ -524,7 +524,7 @@ class ShuttlePane(Container): @work(exclusive=True) async def run_sync_metadata(self, components: list[str]) -> None: meta_mod = fluxer_metadata if self.target_platform == "fluxer" else stoat_metadata - modal = ProgressModal() + modal = ProgressScreen() self.app.push_screen(modal) await asyncio.sleep(0.1) @@ -575,7 +575,7 @@ class ShuttlePane(Container): migrate_mod = fluxer_migrate if self.target_platform == "fluxer" else stoat_migrate platform_name = self.target_platform.capitalize() - modal = ProgressModal() + modal = ProgressScreen() self.app.push_screen(modal) await asyncio.sleep(0.1) @@ -593,65 +593,38 @@ class ShuttlePane(Container): modal.allow_close() return - # Pick source channel - self.app.pop_screen() - - loop = asyncio.get_running_loop() - src_future = loop.create_future() - - def on_src(cid): - if not src_future.done(): - src_future.set_result(cid) - - self.app.push_screen(ChannelPickerModal(d_channels, d_cat_map, "Select Source Discord Channel"), on_src) - src_id = await src_future - - if src_id is None: - await self.engine.close_connections() - return - - source_channel = next(c for c in d_channels if c.id == src_id) - # Fetch target channels - modal2_status = ProgressModal() - self.app.push_screen(modal2_status) - await asyncio.sleep(0.1) - modal2_status.set_status(f"Fetching {platform_name} channels...") - + modal.set_status(f"Fetching {platform_name} channels...") + full_f = await self.engine.writer.get_channels() f_channels = [c for c in full_f if c.get("name") not in ["reaper_logs", "reaper-logs"] and c.get("type") not in [2, 4]] if not f_channels: - modal2_status.write(f"[yellow]No channels found in {platform_name} community.[/yellow]") - modal2_status.allow_close() + modal.write(f"[yellow]No channels found in {platform_name} community.[/yellow]") + modal.allow_close() await self.engine.close_connections() return - # Auto-match - mapped_id = self.engine.state.get_fluxer_channel_id(str(source_channel.id)) - recommended = None - if mapped_id: - recommended = next((c for c in f_channels if str(c.get("id", "")) == mapped_id), None) - if not recommended: - recommended = next((c for c in f_channels if c.get("name") == source_channel.name), None) - self.app.pop_screen() target_cat_names = {str(c.get("id")): c.get("name") for c in full_f if c.get("type") == 4} - tgt_future = loop.create_future() + loop = asyncio.get_running_loop() + pick_future = loop.create_future() - def on_tgt(cid): - if not tgt_future.done(): - tgt_future.set_result(cid) + def on_pick(result): + if not pick_future.done(): + pick_future.set_result(result) - self.app.push_screen(ChannelPickerModal(f_channels, target_cat_names, f"Select Target {platform_name} Channel"), on_tgt) - tgt_id = await tgt_future + self.app.push_screen(ChannelPickerScreen(d_channels, d_cat_map, f_channels, target_cat_names, platform_name), on_pick) + res = await pick_future - if tgt_id is None: + if res is None: await self.engine.close_connections() return - + + src_id, tgt_id = res + source_channel = next(c for c in d_channels if c.id == src_id) target_channel = next(c for c in f_channels if c.get("id") == tgt_id) # Determine after_id @@ -661,10 +634,11 @@ class ShuttlePane(Container): after_id = int(last_migrated) # Analyze - modal = ProgressModal() + modal = ProgressScreen() self.app.push_screen(modal) await asyncio.sleep(0.1) modal.set_status("Analyzing channel...") + modal.show_stats() self.engine.is_running = True stats = {"messages": 0, "threads": 0, "attachments": 0} @@ -690,6 +664,7 @@ class ShuttlePane(Container): async def update_msg(count): modal.set_status(f"[cyan]Migrated {count}/{total_messages} messages...") modal.set_progress(count, total_messages) + modal.update_stats(messages=count) result = await migrate_mod.migrate_messages( self.engine, @@ -706,6 +681,11 @@ class ShuttlePane(Container): modal.write(f"[bold yellow]Interrupted! {result['messages']} messages migrated.[/bold yellow]") event_title = "Message History Migration Interrupted" + self.app.push_screen(ReportModal( + event_title, + f"Successfully processed {result['messages']} messages, {result['attachments']} attachments, and {result['threads']} threads." + )) + lines = [f"Migrated Discord #{source_channel.name} → {platform_name} #{target_channel.get('name')}:"] lines.append(f"{result['messages']} messages, {result['attachments']} attachments, {result['threads']} threads") await log_audit_event(self.engine, event_title, "\n".join(lines)) @@ -719,7 +699,6 @@ class ShuttlePane(Container): finally: self.engine.is_running = False await self.engine.close_connections() - modal.set_status("Finished.") modal.allow_close() # ── (6) danger zone ─────────────────────────────────────────────────── @@ -755,7 +734,7 @@ class ShuttlePane(Container): else: from src.stoat.danger_zone import danger_delete_all_channels - modal = ProgressModal() + modal = ProgressScreen() self.app.push_screen(modal) await asyncio.sleep(0.1) @@ -784,7 +763,7 @@ class ShuttlePane(Container): else: from src.stoat.danger_zone import danger_reset_channel_permissions - modal = ProgressModal() + modal = ProgressScreen() self.app.push_screen(modal) await asyncio.sleep(0.1) @@ -813,7 +792,7 @@ class ShuttlePane(Container): else: from src.stoat.danger_zone import danger_delete_all_roles - modal = ProgressModal() + modal = ProgressScreen() self.app.push_screen(modal) await asyncio.sleep(0.1) @@ -842,7 +821,7 @@ class ShuttlePane(Container): else: from src.stoat.danger_zone import danger_delete_all_emojis_and_stickers - modal = ProgressModal() + modal = ProgressScreen() self.app.push_screen(modal) await asyncio.sleep(0.1)