v1.9.2: S3 right-click context menu — copy presigned download link

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chrome-storm-c442
2026-03-03 07:36:21 -05:00
parent 1e729fcf3a
commit e403da4f9d
5 changed files with 98 additions and 1 deletions

View File

@@ -390,6 +390,10 @@ _EN = {
"s3_uploading_n": "Uploading {count} files...", "s3_uploading_n": "Uploading {count} files...",
"s3_uploaded_n": "Uploaded {count} files", "s3_uploaded_n": "Uploaded {count} files",
"s3_upload_partial": "Uploaded {ok}/{total} 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 tab
"grafana_refresh": "Refresh", "grafana_refresh": "Refresh",
@@ -892,6 +896,10 @@ _RU = {
"s3_uploading_n": "Загрузка {count} файлов...", "s3_uploading_n": "Загрузка {count} файлов...",
"s3_uploaded_n": "Загружено {count} файлов", "s3_uploaded_n": "Загружено {count} файлов",
"s3_upload_partial": "Загружено {ok}/{total} файлов", "s3_upload_partial": "Загружено {ok}/{total} файлов",
"s3_copy_link": "Копировать ссылку",
"s3_generating_link": "Генерация ссылки...",
"s3_link_copied": "Ссылка скопирована",
"s3_link_failed": "Ошибка генерации ссылки",
# Grafana tab # Grafana tab
"grafana_refresh": "Обновить", "grafana_refresh": "Обновить",
@@ -1394,6 +1402,10 @@ _ZH = {
"s3_uploading_n": "正在上传 {count} 个文件...", "s3_uploading_n": "正在上传 {count} 个文件...",
"s3_uploaded_n": "已上传 {count} 个文件", "s3_uploaded_n": "已上传 {count} 个文件",
"s3_upload_partial": "已上传 {ok}/{total} 个文件", "s3_upload_partial": "已上传 {ok}/{total} 个文件",
"s3_copy_link": "复制链接",
"s3_generating_link": "生成链接中...",
"s3_link_copied": "链接已复制",
"s3_link_failed": "生成链接失败",
# Grafana tab # Grafana tab
"grafana_refresh": "刷新", "grafana_refresh": "刷新",

View File

@@ -277,6 +277,26 @@ class S3Client:
log.error("S3 delete failed: %s", exc) log.error("S3 delete failed: %s", exc)
return False 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: def get_object_size(self, bucket: str, key: str) -> int:
"""Get size of an object in bytes.""" """Get size of an object in bytes."""
if not self._ensure_connected(): if not self._ensure_connected():

View File

@@ -7,6 +7,7 @@ import os
import sys import sys
import tempfile import tempfile
import threading import threading
import tkinter as tk
from tkinter import ttk, filedialog from tkinter import ttk, filedialog
import customtkinter as ctk import customtkinter as ctk
@@ -205,6 +206,23 @@ class S3Tab(ctk.CTkFrame):
# Double-click to enter prefix (folder) or download file # Double-click to enter prefix (folder) or download file
self._tree.bind("<Double-1>", self._on_double_click) self._tree.bind("<Double-1>", 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("<Button-3>", self._on_right_click)
# Dark treeview style # Dark treeview style
style = ttk.Style() style = ttk.Style()
style.configure("Dark.Treeview", style.configure("Dark.Treeview",
@@ -460,6 +478,53 @@ class S3Tab(ctk.CTkFrame):
# Double-click file = download # Double-click file = download
self._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): def _go_back(self):
if self._nav_stack: if self._nav_stack:
self._current_prefix = self._nav_stack.pop() self._current_prefix = self._nav_stack.pop()

Binary file not shown.

View File

@@ -1,6 +1,6 @@
"""Version info for ServerManager.""" """Version info for ServerManager."""
__version__ = "1.9.1" __version__ = "1.9.2"
__app_name__ = "ServerManager" __app_name__ = "ServerManager"
__author__ = "aibot777" __author__ = "aibot777"
__description__ = "Desktop GUI for managing remote servers" __description__ = "Desktop GUI for managing remote servers"