diff --git a/core/i18n.py b/core/i18n.py index 16c3c4d..b50fe4c 100644 --- a/core/i18n.py +++ b/core/i18n.py @@ -390,6 +390,10 @@ _EN = { "s3_uploading_n": "Uploading {count} files...", "s3_uploaded_n": "Uploaded {count} files", "s3_upload_partial": "Uploaded {ok}/{total} files", + "s3_copy_link": "Copy Link", + "s3_generating_link": "Generating link...", + "s3_link_copied": "Link copied to clipboard", + "s3_link_failed": "Failed to generate link", # Grafana tab "grafana_refresh": "Refresh", @@ -892,6 +896,10 @@ _RU = { "s3_uploading_n": "Загрузка {count} файлов...", "s3_uploaded_n": "Загружено {count} файлов", "s3_upload_partial": "Загружено {ok}/{total} файлов", + "s3_copy_link": "Копировать ссылку", + "s3_generating_link": "Генерация ссылки...", + "s3_link_copied": "Ссылка скопирована", + "s3_link_failed": "Ошибка генерации ссылки", # Grafana tab "grafana_refresh": "Обновить", @@ -1394,6 +1402,10 @@ _ZH = { "s3_uploading_n": "正在上传 {count} 个文件...", "s3_uploaded_n": "已上传 {count} 个文件", "s3_upload_partial": "已上传 {ok}/{total} 个文件", + "s3_copy_link": "复制链接", + "s3_generating_link": "生成链接中...", + "s3_link_copied": "链接已复制", + "s3_link_failed": "生成链接失败", # Grafana tab "grafana_refresh": "刷新", diff --git a/core/s3_client.py b/core/s3_client.py index fcff6f8..6f052c7 100644 --- a/core/s3_client.py +++ b/core/s3_client.py @@ -277,6 +277,26 @@ class S3Client: log.error("S3 delete failed: %s", exc) return False + def generate_presigned_url(self, bucket: str, key: str, + expires_in: int = 3600) -> str | None: + """Generate a presigned download URL for an object. + + expires_in: URL lifetime in seconds (default 1 hour). + Returns URL string or None on failure. + """ + if not self._ensure_connected(): + return None + try: + url = self._client.generate_presigned_url( + "get_object", + Params={"Bucket": bucket, "Key": key}, + ExpiresIn=expires_in, + ) + return url + except Exception as exc: + log.error("S3 presigned URL failed: %s", exc) + return None + def get_object_size(self, bucket: str, key: str) -> int: """Get size of an object in bytes.""" if not self._ensure_connected(): diff --git a/gui/tabs/s3_tab.py b/gui/tabs/s3_tab.py index 8387b21..a795063 100644 --- a/gui/tabs/s3_tab.py +++ b/gui/tabs/s3_tab.py @@ -7,6 +7,7 @@ import os import sys import tempfile import threading +import tkinter as tk from tkinter import ttk, filedialog import customtkinter as ctk @@ -205,6 +206,23 @@ class S3Tab(ctk.CTkFrame): # Double-click to enter prefix (folder) or download file self._tree.bind("", self._on_double_click) + # Right-click context menu + self._ctx_menu = tk.Menu(self._tree, tearoff=0) + self._ctx_menu.add_command( + label=icon_text("copy", t("s3_copy_link")), + command=self._copy_link, + ) + self._ctx_menu.add_command( + label=icon_text("download", t("s3_download")), + command=self._download, + ) + self._ctx_menu.add_separator() + self._ctx_menu.add_command( + label=icon_text("delete", t("s3_delete")), + command=self._delete, + ) + self._tree.bind("", self._on_right_click) + # Dark treeview style style = ttk.Style() style.configure("Dark.Treeview", @@ -460,6 +478,53 @@ class S3Tab(ctk.CTkFrame): # Double-click file = download self._download() + def _on_right_click(self, event): + """Show context menu on right-click.""" + item_id = self._tree.identify_row(event.y) + if not item_id: + return + self._tree.selection_set(item_id) + # Check if it's a file (not a folder) + item = self._tree.item(item_id) + name = item["values"][0] if item["values"] else "" + if not isinstance(name, str): + name = str(name) + is_folder = name.endswith("/") + # Enable/disable menu items based on type + self._ctx_menu.entryconfigure(0, state="normal" if not is_folder else "disabled") # Copy link + self._ctx_menu.entryconfigure(1, state="normal" if not is_folder else "disabled") # Download + self._ctx_menu.entryconfigure(3, state="normal" if not is_folder else "disabled") # Delete + self._ctx_menu.tk_popup(event.x_root, event.y_root) + + def _copy_link(self): + """Generate presigned URL and copy to clipboard.""" + sel = self._tree.selection() + if not sel or not self._client or not self._current_bucket: + return + item = self._tree.item(sel[0]) + name = item["values"][0] if item["values"] else "" + if not isinstance(name, str): + name = str(name) + if name.endswith("/"): + return + + key = self._current_prefix + name + self._status_label.configure(text=t("s3_generating_link")) + + def _do(): + url = self._client.generate_presigned_url(self._current_bucket, key) + self.after(0, lambda: self._on_link_ready(url)) + + threading.Thread(target=_do, daemon=True).start() + + def _on_link_ready(self, url: str | None): + if url: + self.clipboard_clear() + self.clipboard_append(url) + self._status_label.configure(text=t("s3_link_copied")) + else: + self._status_label.configure(text=t("s3_link_failed")) + def _go_back(self): if self._nav_stack: self._current_prefix = self._nav_stack.pop() diff --git a/releases/ServerManager-v1.9.2-win-x64.exe b/releases/ServerManager-v1.9.2-win-x64.exe new file mode 100644 index 0000000..1536f79 Binary files /dev/null and b/releases/ServerManager-v1.9.2-win-x64.exe differ diff --git a/version.py b/version.py index 69751a3..a866cfb 100755 --- a/version.py +++ b/version.py @@ -1,6 +1,6 @@ """Version info for ServerManager.""" -__version__ = "1.9.1" +__version__ = "1.9.2" __app_name__ = "ServerManager" __author__ = "aibot777" __description__ = "Desktop GUI for managing remote servers"