"""Вкладка Файлы: SFTP-браузер с upload/download/редактором.""" import os from pathlib import PurePosixPath import flet as ft from app.core import files as fcore from app.core.ssh import SSHClient from app.db.models import Server from app.ui.components import confirm_dialog, snack class FilesTab(ft.Column): def __init__(self, page: ft.Page, srv: Server): super().__init__(expand=True, spacing=8) self._page = page self.srv = srv self._path = f"/home/{srv.username}" if srv.username != "root" else "/root" self._client = None self.path_text = ft.Text(self._path, size=13, weight=ft.FontWeight.BOLD) self.list_col = ft.Column(scroll=ft.ScrollMode.AUTO, expand=True, spacing=0) self.progress = ft.ProgressBar(visible=False, height=4) self.status = ft.Text("", size=12, color=ft.Colors.GREY_500) self.controls = [ ft.Container(height=8), ft.Row([ ft.IconButton(ft.Icons.ARROW_UPWARD, tooltip="Вверх", on_click=self._up), ft.IconButton(ft.Icons.HOME, tooltip="Домой", on_click=self._home), ft.IconButton(ft.Icons.REFRESH, on_click=lambda e: self._reload()), self.path_text, ft.Container(expand=True), ft.IconButton(ft.Icons.CREATE_NEW_FOLDER, tooltip="Новая папка", on_click=self._mkdir_dialog), ft.IconButton(ft.Icons.UPLOAD_FILE, tooltip="Загрузить файл", on_click=self._upload_dialog), ]), self.progress, ft.Container(expand=True, border=ft.Border.all(1, ft.Colors.GREY_800), border_radius=8, padding=4, content=self.list_col), self.status, ] # file picker для upload/download (async API flet ≥0.80) self.picker = ft.FilePicker() page.services.append(self.picker) self._page.run_task(self._connect) async def _connect(self): try: self._client = SSHClient(self.srv) await self._client.connect() await self._load() except Exception as e: snack(self._page, f"SFTP: {e}", error=True) async def _load(self): if not self._client: return try: entries = await fcore.list_dir(self._client, self._path) except PermissionError as e: self.status.value = f"нет доступа: {e}" self._page.update() return self.path_text.value = self._path self.list_col.controls = [] for en in entries: icon = ft.Icons.FOLDER if en.is_dir else ft.Icons.INSERT_DRIVE_FILE size = "" if en.is_dir else self._fmt_size(en.size) self.list_col.controls.append( ft.ListTile( leading=ft.Icon(icon, size=20, color=ft.Colors.AMBER_300 if en.is_dir else None), title=ft.Text(en.name, size=13), subtitle=ft.Text(en.permissions, size=10, color=ft.Colors.GREY_600), trailing=ft.Row([ ft.Text(size, size=11, color=ft.Colors.GREY_500), ft.IconButton(ft.Icons.DOWNLOAD, icon_size=16, tooltip="Скачать", visible=not en.is_dir, on_click=lambda e, p=en.path: self._download(p)), ft.IconButton(ft.Icons.EDIT, icon_size=16, tooltip="Редактировать", visible=not en.is_dir, on_click=lambda e, p=en.path: self._edit(p)), ft.IconButton(ft.Icons.DRIVE_FILE_RENAME_OUTLINE, icon_size=16, tooltip="Переименовать", on_click=lambda e, en=en: self._rename_dialog(en)), ft.IconButton(ft.Icons.DELETE_OUTLINE, icon_size=16, tooltip="Удалить", on_click=lambda e, en=en: self._delete(en)), ], spacing=0, tight=True), dense=True, on_click=lambda e, en=en: self._enter(en), ) ) self.status.value = f"{len(entries)} объектов" self._page.update() def _fmt_size(self, s): for unit in ("Б", "КБ", "МБ", "ГБ"): if s < 1024 or unit == "ГБ": return f"{s:.0f} {unit}" if unit == "Б" else f"{s:.1f} {unit}" s /= 1024 def _reload(self): self._page.run_task(self._load) def _enter(self, en): if en.is_dir: self._path = en.path self._reload() else: self._edit(en.path) def _up(self, e): parent = str(PurePosixPath(self._path).parent) self._path = parent or "/" self._reload() def _home(self, e): self._path = f"/home/{self.srv.username}" if self.srv.username != "root" else "/root" self._reload() # ---------- действия ---------- def _delete(self, en): def do(): async def _d(): await fcore.delete(self._client, en.path, en.is_dir) await self._load() self._page.run_task(_d) confirm_dialog(self._page, f"Удалить {en.name}?", f"{'Папка и всё содержимое' if en.is_dir else 'Файл'} будет удалён с сервера.", do) def _rename_dialog(self, en): tf = ft.TextField(label="Новое имя", value=en.name, autofocus=True) def do(e): async def _r(): new = str(PurePosixPath(self._path) / tf.value) await fcore.rename(self._client, en.path, new) self._page.pop_dialog() await self._load() self._page.run_task(_r) dlg = ft.AlertDialog(modal=True, title=ft.Text("Переименовать"), content=tf, actions=[ft.TextButton("Отмена", on_click=lambda e: self._close(dlg)), ft.FilledButton("Ок", on_click=do)]) self._page.show_dialog(dlg) def _mkdir_dialog(self, e): tf = ft.TextField(label="Имя папки", autofocus=True) def do(e): async def _m(): await fcore.mkdir(self._client, str(PurePosixPath(self._path) / tf.value)) self._page.pop_dialog() await self._load() self._page.run_task(_m) dlg = ft.AlertDialog(modal=True, title=ft.Text("Новая папка"), content=tf, actions=[ft.TextButton("Отмена", on_click=lambda e: self._close(dlg)), ft.FilledButton("Создать", on_click=do)]) self._page.show_dialog(dlg) def _close(self, dlg): self._page.pop_dialog() def _download(self, remote_path): async def _do(): local = await self.picker.save_file( dialog_title="Сохранить как", file_name=PurePosixPath(remote_path).name, ) if not local: return self.progress.visible = True self._page.update() def prog(done, total): self.progress.value = done / total if total else None self.status.value = f"скачивание… {done//1024}/{total//1024} КБ" self._page.update() await fcore.download(self._client, remote_path, local, prog) self.progress.visible = False self.status.value = f"скачано: {local}" self._page.update() self._page.run_task(_do) def _upload_dialog(self, e): async def _do(): files = await self.picker.pick_files(dialog_title="Выберите файл для загрузки") if not files: return f = files[0] remote = str(PurePosixPath(self._path) / f.name) self.progress.visible = True self._page.update() def prog(done, total): self.progress.value = done / total if total else None self.status.value = f"загрузка… {done//1024}/{total//1024} КБ" self._page.update() await fcore.upload(self._client, f.path, remote, prog) self.progress.visible = False await self._load() self._page.run_task(_do) def _edit(self, path): tf = ft.TextField(multiline=True, min_lines=20, max_lines=20, text_size=12, text_style=ft.TextStyle(font_family="Consolas"), expand=True) sudo_cb = ft.Checkbox(label="sudo (root-файл)", value=False) async def _load_text(): try: tf.value = await fcore.read_text(self._client, path) dlg.content.update() except Exception as ex: tf.value = f"// ошибка чтения: {ex}" dlg.content.update() def save(e): async def _s(): await fcore.write_text(self._client, path, tf.value, sudo_cb.value) self._page.pop_dialog() snack(self._page, "Сохранено") self._page.run_task(_s) dlg = ft.AlertDialog( modal=True, title=ft.Text(path, size=13), content=ft.Container(width=700, content=ft.Column([sudo_cb, tf], tight=True)), actions=[ft.TextButton("Отмена", on_click=lambda e: self._close(dlg)), ft.FilledButton("Сохранить", on_click=save)], ) self._page.show_dialog(dlg) self._page.run_task(_load_text)