improve proress tracking

This commit is contained in:
rambros 2026-03-03 03:47:59 +05:30
parent 1cf5463da6
commit 6c51fcd9ae
4 changed files with 384 additions and 196 deletions

View file

@ -18,7 +18,7 @@ from textual import work
from src.core.configuration import load_config from src.core.configuration import load_config
from src.core.base import MigrationContext from src.core.base import MigrationContext
from src.disco_reaper.exporter import DiscordExporter 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): class BackupPane(Container):
@ -123,7 +123,7 @@ class BackupPane(Container):
@work(exclusive=True, thread=True) @work(exclusive=True, thread=True)
async def run_backup_profile(self) -> None: async def run_backup_profile(self) -> None:
modal = ProgressModal() modal = ProgressScreen()
self.app.call_from_thread(self.app.push_screen, modal) self.app.call_from_thread(self.app.push_screen, modal)
await asyncio.sleep(0.1) 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]") self.app.call_from_thread(modal.write, f"[bold red]Error: {e}[/bold red]")
finally: finally:
await self.engine.close_connections() 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(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) @work(exclusive=True, thread=True)
async def run_backup_messages(self) -> None: async def run_backup_messages(self) -> None:
modal_prog = ProgressModal() modal_prog = ProgressScreen()
self.app.call_from_thread(self.app.push_screen, modal_prog) self.app.call_from_thread(self.app.push_screen, modal_prog)
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
@ -199,7 +199,7 @@ class BackupPane(Container):
self.app.call_from_thread( self.app.call_from_thread(
self.app.push_screen, 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, 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]") self.app.call_from_thread(modal_prog.write, f"[bold red]Message backup failed: {e}[/bold red]")
finally: finally:
await self.engine.close_connections() 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(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) @work(exclusive=True, thread=True)
async def run_backup_sync(self) -> None: async def run_backup_sync(self) -> None:
modal_prog = ProgressModal() modal_prog = ProgressScreen()
self.app.call_from_thread(self.app.push_screen, modal_prog) self.app.call_from_thread(self.app.push_screen, modal_prog)
await asyncio.sleep(0.1) 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]") self.app.call_from_thread(modal_prog.write, f"[bold red]Sync failed: {e}[/bold red]")
finally: finally:
await self.engine.close_connections() 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(modal_prog.allow_close)
self.app.call_from_thread(self.app.push_screen, ReportModal("Sync Complete", "Backup cleanly synced with latest Discord data."))

View file

@ -3,47 +3,183 @@ Shared modals used by backup and shuttle operations.
""" """
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical, VerticalScroll from textual.containers import Horizontal, Vertical, VerticalScroll, Container, Center
from textual.widgets import Button, Label, Input, ProgressBar, RichLog, Rule, RadioButton from textual.widgets import Button, Label, Input, ProgressBar, RichLog, Rule, RadioButton, LoadingIndicator, Header, Footer, RadioSet
from textual.screen import ModalScreen 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]): class ProgressScreen(Screen[None]):
"""Modal to display progress for any long-running operation.""" """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: def compose(self) -> ComposeResult:
with Vertical(id="progress_dialog"): yield Header(show_clock=True)
yield Label("Operation Status", id="progress_status") with Container(id="prog_outer"):
yield ProgressBar(total=None, show_eta=False, id="progress_bar") with Container(id="prog_dialog"):
yield RichLog(id="progress_log", highlight=True, markup=True) with Horizontal(id="prog_header"):
yield Button("Close", id="btn_close_progress", disabled=True) 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): def on_button_pressed(self, event: Button.Pressed):
if event.button.id == "btn_close_progress": if event.button.id == "btn_close_progress":
if self.timer_event:
self.timer_event.stop()
self.dismiss(None) self.dismiss(None)
def write(self, message: str): 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 write_to_log = write
def set_status(self, status: str): 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): def set_progress(self, current: int, total: int):
bar = self.query_one("#progress_bar", ProgressBar) try:
bar.update(total=total, progress=current) 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): def allow_close(self):
btn = self.query_one("#btn_close_progress", Button) if self.timer_event:
btn.disabled = False self.timer_event.stop()
btn.variant = "success" try:
self.query_one("#progress_bar", ProgressBar).update(total=100, progress=100) 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]): class ConfirmModal(ModalScreen[bool]):
"""Simple Yes / No confirmation modal.""" """Simple Yes / No confirmation modal."""
DEFAULT_CSS = "ConfirmModal { align: center middle; }"
def __init__(self, message: str, danger: bool = False): def __init__(self, message: str, danger: bool = False):
super().__init__() super().__init__()
@ -75,6 +212,7 @@ class ConfirmModal(ModalScreen[bool]):
class SubMenuModal(ModalScreen[str]): class SubMenuModal(ModalScreen[str]):
"""A generic sub-menu modal that presents a list of labelled buttons.""" """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]]): def __init__(self, title: str, options: list[tuple[str, str, str]]):
"""options: list of (button_id, label, variant)""" """options: list of (button_id, label, variant)"""
@ -101,27 +239,63 @@ class SubMenuModal(ModalScreen[str]):
# ChannelPickerModal single-channel selection (shuttle) # ChannelPickerModal single-channel selection (shuttle)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class ChannelPickerModal(ModalScreen[int]): class ChannelPickerScreen(Screen[tuple]):
"""Modal listing Discord channels for single-channel selection.""" """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__() super().__init__()
self._channels = channels self.src_channels = src_channels
self._categories = categories self.src_cat_map = src_cat_map
self._label = label self.tgt_channels = tgt_channels
self.tgt_cat_map = tgt_cat_map
self.tgt_name = tgt_name
def compose(self) -> ComposeResult: def _render_pane(self, channels, categories, pane_id, prefix):
with Vertical(id="chanpick_dialog"): cat_grouped: dict[int | None, list] = {}
yield Label(self._label, id="chanpick_title") for c in channels:
with VerticalScroll(id="chanpick_scroll"): cat_id = getattr(c, "category_id", None) if not isinstance(c, dict) else c.get("parent_id")
cat_grouped: dict[int | None, list] = {} cat_grouped.setdefault(cat_id, []).append(c)
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)
for cat_id in sorted(cat_grouped, key=lambda k: self._categories.get(k, "") if k else ""): with VerticalScroll(classes="split_pane", id=pane_id):
if cat_id is not None and cat_id in self._categories: with RadioSet(id=f"{prefix}_radioset"):
yield Label(f"[cyan]{self._categories[cat_id]}[/cyan]", classes="category_header") 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]: for c in cat_grouped[cat_id]:
if isinstance(c, dict): if isinstance(c, dict):
name = c.get("name", "Unnamed") name = c.get("name", "Unnamed")
@ -129,30 +303,91 @@ class ChannelPickerModal(ModalScreen[int]):
else: else:
name = c.name name = c.name
cid = c.id 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"): def compose(self) -> ComposeResult:
yield Button("Select", variant="success", id="btn_pick_ok") yield Header(show_clock=True)
yield Button("Cancel", id="btn_pick_cancel") 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): def on_button_pressed(self, event: Button.Pressed):
if event.button.id == "btn_pick_cancel": if event.button.id == "btn_pick_cancel":
self.dismiss(None) self.dismiss(None)
elif event.button.id == "btn_pick_ok": elif event.button.id == "btn_pick_ok":
for rb in self.query(RadioButton): src_val = None
if rb.value and rb.id and rb.id.startswith("chpk_"): tgt_val = None
self.dismiss(int(rb.id.split("_", 1)[1]))
return src_rset = self.query_one("#src_radioset", RadioSet)
# Nothing selected if src_rset.pressed_button:
return 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) # ChannelSelectModal multi-channel selection with sync/force (backup)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class ChannelSelectModal(ModalScreen[dict]): class ChannelSelectScreen(Screen[dict]):
"""Modal for selecting channels using a simple checkbox list.""" """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): def __init__(self, channels: list, categories: dict, backed_up_ids: set, any_found: bool):
super().__init__() super().__init__()
@ -169,42 +404,46 @@ class ChannelSelectModal(ModalScreen[dict]):
self.any_found = any_found self.any_found = any_found
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with Vertical(id="channel_dialog"): yield Header(show_clock=True)
yield Label("Select Channels to Backup", id="chan_title") 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"): with VerticalScroll(id="channel_list_scroll"):
cat_ids = sorted( cat_ids = sorted(
[k for k in self.channels_by_category.keys() if k is not None], [k for k in self.channels_by_category.keys() if k is not None],
key=lambda k: self.categories.get(k, ""), key=lambda k: self.categories.get(k, ""),
) )
# No category channels # No category channels
if None in self.channels_by_category: 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): for c in sorted(self.channels_by_category[None], key=lambda x: x.position if hasattr(x, 'position') else 0):
label = f"{c.name}" label = f"{c.name}"
color = "green" if c.id in self.backed_up_ids else "white" color = "green" if c.id in self.backed_up_ids else "white"
yield RadioButton(f"[{color}]{label}[/]", value=False, id=f"chan_{c.id}") yield RadioButton(f"[{color}]{label}[/]", value=False, id=f"chan_{c.id}")
for cat_id in cat_ids: for cat_id in cat_ids:
cat_name = self.categories.get(cat_id, "Unknown Category") cat_name = self.categories.get(cat_id, "Unknown Category")
yield Label(f"[cyan]{cat_name}[/cyan]", classes="category_header") 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): 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}" label = f"{c.name}"
color = "green" if c.id in self.backed_up_ids else "white" color = "green" if c.id in self.backed_up_ids else "white"
yield RadioButton(f"[{color}]{label}[/]", value=False, id=f"chan_{c.id}") yield RadioButton(f"[{color}]{label}[/]", value=False, id=f"chan_{c.id}")
with Horizontal(id="select_all_buttons"): with Horizontal(id="select_all_buttons"):
yield Button("Select All", id="btn_all") yield Button("Select All", id="btn_all")
yield Button("Deselect All", id="btn_none") yield Button("Deselect All", id="btn_none")
with Horizontal(id="confirm_buttons"): yield Rule()
if self.any_found: with Horizontal(id="confirm_buttons"):
yield Label("Existing backups found:", classes="label_warning") if self.any_found:
yield Button("Sync", variant="success", id="btn_sync") yield Label("Existing backups found:", classes="label_warning")
yield Button("Force Overwrite", variant="error", id="btn_force") yield Button("Sync", variant="success", id="btn_sync")
else: yield Button("Force Overwrite", variant="error", id="btn_force")
yield Button("Backup", variant="success", id="btn_backup") else:
yield Button("Cancel", id="btn_cancel_chan") 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: def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn_all": if event.button.id == "btn_all":

View file

@ -22,7 +22,7 @@ class ModeScreen(Screen):
"""Unified mode screen — adapts content based on tool_mode.""" """Unified mode screen — adapts content based on tool_mode."""
CSS = """ CSS = """
#main_scroll { #main_outer {
align: center top; align: center top;
height: 100%; height: 100%;
} }
@ -48,33 +48,22 @@ class ModeScreen(Screen):
margin: 0 1; margin: 0 1;
} }
/* Modal styling (shared across both panes) */ ModalScreen {
#progress_dialog { align: center middle;
}
#progress_dialog, #shuttle_config_dialog, #platform_select_dialog, #submenu_dialog, #confirm_dialog, #chanpick_dialog, #channel_dialog {
width: 80%; width: 80%;
height: 80%; height: 100%;
border: thick $background 80%; layout: vertical;
background: $surface; border: solid green;
padding: 1 2; padding: 1 2;
margin: 2 0;
background: $surface;
} }
#progress_status { text-style: bold; margin-bottom: 1; } #progress_status { text-style: bold; margin-bottom: 1; }
#progress_bar { margin-bottom: 1; } #progress_bar { margin-bottom: 1; }
#progress_loader { margin-bottom: 1; }
#progress_log { height: 1fr; margin-bottom: 1; border: solid $primary; } #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 { #chanpick_scroll {
height: 1fr; height: 1fr;
border: solid $primary; border: solid $primary;
@ -96,14 +85,6 @@ class ModeScreen(Screen):
#config_buttons Button, #confirm_buttons Button, #chanpick_buttons Button { #config_buttons Button, #confirm_buttons Button, #chanpick_buttons Button {
width: 1fr; margin: 0 1; width: 1fr; margin: 0 1;
} }
#channel_dialog {
width: 80%;
height: 80%;
border: thick $background 80%;
background: $surface;
padding: 1 2;
}
#channel_list_scroll { #channel_list_scroll {
height: 1fr; height: 1fr;
border: solid $primary; border: solid $primary;
@ -134,7 +115,7 @@ class ModeScreen(Screen):
mode = self.config.tool_mode or "backup_only" mode = self.config.tool_mode or "backup_only"
with VerticalScroll(id="main_scroll"): with Container(id="main_outer"):
with Container(id="main_container"): with Container(id="main_container"):
if mode == "backup_only": if mode == "backup_only":
yield BackupPane(self.cfg_name, self.config_path, id="pane_backup") 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 from src.ui.main_app import ConfigScreen
def reload_screen(saved: bool = False): def reload_screen(saved: bool = False):
if saved: if saved:
self.app.pop_screen() new_cfg = load_config(self.config_path)
from src.ui.mode_screen import ModeScreen if new_cfg.tool_mode != self.config.tool_mode:
self.app.push_screen(ModeScreen(self.cfg_name, self.config_path)) 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) self.app.push_screen(ConfigScreen(self.cfg_name, self.config_path), reload_screen)
elif bid == "btn_switch": elif bid == "btn_switch":
self._toggle_pane() self._toggle_pane()
@ -181,22 +170,3 @@ class ModeScreen(Screen):
switcher.current = "pane_backup" switcher.current = "pane_backup"
btn.label = "Switch to Migrate ⇄" btn.label = "Switch to Migrate ⇄"
self._showing_backup = True 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()

View file

@ -20,7 +20,7 @@ from src.core.configuration import load_config
from src.core.base import MigrationContext from src.core.base import MigrationContext
from src.core.audit import log_audit_event from src.core.audit import log_audit_event
from src.ui.modals import ( from src.ui.modals import (
ProgressModal, ConfirmModal, SubMenuModal, ChannelPickerModal, ProgressScreen, ReportModal, ConfirmModal, SubMenuModal, ChannelPickerScreen,
) )
import src.fluxer.roles_permissions as fluxer_roles import src.fluxer.roles_permissions as fluxer_roles
@ -292,7 +292,7 @@ class ShuttlePane(Container):
else: else:
from src.stoat.clone_server import sync_channel_state, migrate_channels from src.stoat.clone_server import sync_channel_state, migrate_channels
modal = ProgressModal() modal = ProgressScreen()
self.app.push_screen(modal) self.app.push_screen(modal)
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
@ -356,7 +356,7 @@ class ShuttlePane(Container):
@work(exclusive=True) @work(exclusive=True)
async def run_clone_roles(self) -> None: async def run_clone_roles(self) -> None:
roles_mod = fluxer_roles if self.target_platform == "fluxer" else stoat_roles roles_mod = fluxer_roles if self.target_platform == "fluxer" else stoat_roles
modal = ProgressModal() modal = ProgressScreen()
self.app.push_screen(modal) self.app.push_screen(modal)
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
@ -394,7 +394,7 @@ class ShuttlePane(Container):
@work(exclusive=True) @work(exclusive=True)
async def run_sync_permissions(self) -> None: async def run_sync_permissions(self) -> None:
roles_mod = fluxer_roles if self.target_platform == "fluxer" else stoat_roles roles_mod = fluxer_roles if self.target_platform == "fluxer" else stoat_roles
modal = ProgressModal() modal = ProgressScreen()
self.app.push_screen(modal) self.app.push_screen(modal)
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
@ -453,7 +453,7 @@ class ShuttlePane(Container):
@work(exclusive=True) @work(exclusive=True)
async def run_copy_emojis(self, types_to_include: list[str]) -> None: 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 asset_mod = stoat_emoji_stickers if self.target_platform == "stoat" else fluxer_emoji_stickers
modal = ProgressModal() modal = ProgressScreen()
self.app.push_screen(modal) self.app.push_screen(modal)
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
@ -524,7 +524,7 @@ class ShuttlePane(Container):
@work(exclusive=True) @work(exclusive=True)
async def run_sync_metadata(self, components: list[str]) -> None: async def run_sync_metadata(self, components: list[str]) -> None:
meta_mod = fluxer_metadata if self.target_platform == "fluxer" else stoat_metadata meta_mod = fluxer_metadata if self.target_platform == "fluxer" else stoat_metadata
modal = ProgressModal() modal = ProgressScreen()
self.app.push_screen(modal) self.app.push_screen(modal)
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
@ -575,7 +575,7 @@ class ShuttlePane(Container):
migrate_mod = fluxer_migrate if self.target_platform == "fluxer" else stoat_migrate migrate_mod = fluxer_migrate if self.target_platform == "fluxer" else stoat_migrate
platform_name = self.target_platform.capitalize() platform_name = self.target_platform.capitalize()
modal = ProgressModal() modal = ProgressScreen()
self.app.push_screen(modal) self.app.push_screen(modal)
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
@ -593,65 +593,38 @@ class ShuttlePane(Container):
modal.allow_close() modal.allow_close()
return 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 # Fetch target channels
modal2_status = ProgressModal() modal.set_status(f"Fetching {platform_name} channels...")
self.app.push_screen(modal2_status)
await asyncio.sleep(0.1)
modal2_status.set_status(f"Fetching {platform_name} channels...")
full_f = await self.engine.writer.get_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]] 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: if not f_channels:
modal2_status.write(f"[yellow]No channels found in {platform_name} community.[/yellow]") modal.write(f"[yellow]No channels found in {platform_name} community.[/yellow]")
modal2_status.allow_close() modal.allow_close()
await self.engine.close_connections() await self.engine.close_connections()
return 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() self.app.pop_screen()
target_cat_names = {str(c.get("id")): c.get("name") for c in full_f if c.get("type") == 4} 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): def on_pick(result):
if not tgt_future.done(): if not pick_future.done():
tgt_future.set_result(cid) pick_future.set_result(result)
self.app.push_screen(ChannelPickerModal(f_channels, target_cat_names, f"Select Target {platform_name} Channel"), on_tgt) self.app.push_screen(ChannelPickerScreen(d_channels, d_cat_map, f_channels, target_cat_names, platform_name), on_pick)
tgt_id = await tgt_future res = await pick_future
if tgt_id is None: if res is None:
await self.engine.close_connections() await self.engine.close_connections()
return 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) target_channel = next(c for c in f_channels if c.get("id") == tgt_id)
# Determine after_id # Determine after_id
@ -661,10 +634,11 @@ class ShuttlePane(Container):
after_id = int(last_migrated) after_id = int(last_migrated)
# Analyze # Analyze
modal = ProgressModal() modal = ProgressScreen()
self.app.push_screen(modal) self.app.push_screen(modal)
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
modal.set_status("Analyzing channel...") modal.set_status("Analyzing channel...")
modal.show_stats()
self.engine.is_running = True self.engine.is_running = True
stats = {"messages": 0, "threads": 0, "attachments": 0} stats = {"messages": 0, "threads": 0, "attachments": 0}
@ -690,6 +664,7 @@ class ShuttlePane(Container):
async def update_msg(count): async def update_msg(count):
modal.set_status(f"[cyan]Migrated {count}/{total_messages} messages...") modal.set_status(f"[cyan]Migrated {count}/{total_messages} messages...")
modal.set_progress(count, total_messages) modal.set_progress(count, total_messages)
modal.update_stats(messages=count)
result = await migrate_mod.migrate_messages( result = await migrate_mod.migrate_messages(
self.engine, self.engine,
@ -706,6 +681,11 @@ class ShuttlePane(Container):
modal.write(f"[bold yellow]Interrupted! {result['messages']} messages migrated.[/bold yellow]") modal.write(f"[bold yellow]Interrupted! {result['messages']} messages migrated.[/bold yellow]")
event_title = "Message History Migration Interrupted" 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 = [f"Migrated Discord #{source_channel.name}{platform_name} #{target_channel.get('name')}:"]
lines.append(f"{result['messages']} messages, {result['attachments']} attachments, {result['threads']} threads") lines.append(f"{result['messages']} messages, {result['attachments']} attachments, {result['threads']} threads")
await log_audit_event(self.engine, event_title, "\n".join(lines)) await log_audit_event(self.engine, event_title, "\n".join(lines))
@ -719,7 +699,6 @@ class ShuttlePane(Container):
finally: finally:
self.engine.is_running = False self.engine.is_running = False
await self.engine.close_connections() await self.engine.close_connections()
modal.set_status("Finished.")
modal.allow_close() modal.allow_close()
# ── (6) danger zone ─────────────────────────────────────────────────── # ── (6) danger zone ───────────────────────────────────────────────────
@ -755,7 +734,7 @@ class ShuttlePane(Container):
else: else:
from src.stoat.danger_zone import danger_delete_all_channels from src.stoat.danger_zone import danger_delete_all_channels
modal = ProgressModal() modal = ProgressScreen()
self.app.push_screen(modal) self.app.push_screen(modal)
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
@ -784,7 +763,7 @@ class ShuttlePane(Container):
else: else:
from src.stoat.danger_zone import danger_reset_channel_permissions from src.stoat.danger_zone import danger_reset_channel_permissions
modal = ProgressModal() modal = ProgressScreen()
self.app.push_screen(modal) self.app.push_screen(modal)
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
@ -813,7 +792,7 @@ class ShuttlePane(Container):
else: else:
from src.stoat.danger_zone import danger_delete_all_roles from src.stoat.danger_zone import danger_delete_all_roles
modal = ProgressModal() modal = ProgressScreen()
self.app.push_screen(modal) self.app.push_screen(modal)
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
@ -842,7 +821,7 @@ class ShuttlePane(Container):
else: else:
from src.stoat.danger_zone import danger_delete_all_emojis_and_stickers from src.stoat.danger_zone import danger_delete_all_emojis_and_stickers
modal = ProgressModal() modal = ProgressScreen()
self.app.push_screen(modal) self.app.push_screen(modal)
await asyncio.sleep(0.1) await asyncio.sleep(0.1)