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:
12
core/i18n.py
12
core/i18n.py
@@ -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": "刷新",
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
BIN
releases/ServerManager-v1.9.2-win-x64.exe
Normal file
BIN
releases/ServerManager-v1.9.2-win-x64.exe
Normal file
Binary file not shown.
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user