v1.9.5: S3 — new folder, folder delete with confirmation, folder drag-and-drop
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
18
core/i18n.py
18
core/i18n.py
@@ -390,6 +390,12 @@ _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_new_folder": "New Folder",
|
||||||
|
"s3_folder_name_prompt": "Folder name:",
|
||||||
|
"s3_creating_folder": "Creating folder...",
|
||||||
|
"s3_folder_failed": "Failed to create folder",
|
||||||
|
"s3_delete_folder_confirm": "Delete folder \"{folder}\" and all its contents?",
|
||||||
|
"s3_deleted_n": "Deleted {count} objects",
|
||||||
"s3_copy_link_48h": "Copy Link (48h)",
|
"s3_copy_link_48h": "Copy Link (48h)",
|
||||||
"s3_copy_link_permanent": "Copy Direct Link",
|
"s3_copy_link_permanent": "Copy Direct Link",
|
||||||
"s3_generating_link": "Generating link...",
|
"s3_generating_link": "Generating link...",
|
||||||
@@ -897,6 +903,12 @@ _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_new_folder": "Новая папка",
|
||||||
|
"s3_folder_name_prompt": "Имя папки:",
|
||||||
|
"s3_creating_folder": "Создание папки...",
|
||||||
|
"s3_folder_failed": "Ошибка создания папки",
|
||||||
|
"s3_delete_folder_confirm": "Удалить папку \"{folder}\" со всем содержимым?",
|
||||||
|
"s3_deleted_n": "Удалено {count} объектов",
|
||||||
"s3_copy_link_48h": "Ссылка (48ч)",
|
"s3_copy_link_48h": "Ссылка (48ч)",
|
||||||
"s3_copy_link_permanent": "Прямая ссылка",
|
"s3_copy_link_permanent": "Прямая ссылка",
|
||||||
"s3_generating_link": "Генерация ссылки...",
|
"s3_generating_link": "Генерация ссылки...",
|
||||||
@@ -1404,6 +1416,12 @@ _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_new_folder": "新建文件夹",
|
||||||
|
"s3_folder_name_prompt": "文件夹名称:",
|
||||||
|
"s3_creating_folder": "创建文件夹中...",
|
||||||
|
"s3_folder_failed": "创建文件夹失败",
|
||||||
|
"s3_delete_folder_confirm": "删除文件夹 \"{folder}\" 及其所有内容?",
|
||||||
|
"s3_deleted_n": "已删除 {count} 个对象",
|
||||||
"s3_copy_link_48h": "复制链接 (48小时)",
|
"s3_copy_link_48h": "复制链接 (48小时)",
|
||||||
"s3_copy_link_permanent": "复制直接链接",
|
"s3_copy_link_permanent": "复制直接链接",
|
||||||
"s3_generating_link": "生成链接中...",
|
"s3_generating_link": "生成链接中...",
|
||||||
|
|||||||
@@ -306,6 +306,43 @@ class S3Client:
|
|||||||
log.error("S3 presigned URL failed: %s", exc)
|
log.error("S3 presigned URL failed: %s", exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def delete_prefix(self, bucket: str, prefix: str) -> int:
|
||||||
|
"""Recursively delete all objects under a prefix. Returns count deleted."""
|
||||||
|
if not self._ensure_connected():
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
deleted = 0
|
||||||
|
paginator = self._client.get_paginator("list_objects_v2")
|
||||||
|
for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
|
||||||
|
objects = page.get("Contents", [])
|
||||||
|
if not objects:
|
||||||
|
continue
|
||||||
|
delete_req = {
|
||||||
|
"Objects": [{"Key": obj["Key"]} for obj in objects],
|
||||||
|
"Quiet": True,
|
||||||
|
}
|
||||||
|
self._client.delete_objects(Bucket=bucket, Delete=delete_req)
|
||||||
|
deleted += len(objects)
|
||||||
|
self._last_ok = time.time()
|
||||||
|
log.info("S3 deleted prefix s3://%s/%s (%d objects)", bucket, prefix, deleted)
|
||||||
|
return deleted
|
||||||
|
except Exception as exc:
|
||||||
|
log.error("S3 delete prefix failed: %s", exc)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def create_folder(self, bucket: str, key: str) -> bool:
|
||||||
|
"""Create a folder (empty object with trailing slash) in S3."""
|
||||||
|
if not self._ensure_connected():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
self._client.put_object(Bucket=bucket, Key=key, Body=b"")
|
||||||
|
self._last_ok = time.time()
|
||||||
|
log.info("S3 created folder s3://%s/%s", bucket, key)
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
log.error("S3 create folder failed: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
def get_direct_url(self, bucket: str, key: str) -> str:
|
def get_direct_url(self, bucket: str, key: str) -> str:
|
||||||
"""Build a direct (permanent) URL: endpoint/bucket/key."""
|
"""Build a direct (permanent) URL: endpoint/bucket/key."""
|
||||||
endpoint = self._endpoint.rstrip("/")
|
endpoint = self._endpoint.rstrip("/")
|
||||||
|
|||||||
@@ -116,6 +116,12 @@ class S3Tab(ctk.CTkFrame):
|
|||||||
)
|
)
|
||||||
self._refresh_btn.pack(side="left", padx=(0, 5))
|
self._refresh_btn.pack(side="left", padx=(0, 5))
|
||||||
|
|
||||||
|
self._mkdir_btn = make_icon_button(
|
||||||
|
btn_frame, "add", t("s3_new_folder"), width=120,
|
||||||
|
command=self._create_folder,
|
||||||
|
)
|
||||||
|
self._mkdir_btn.pack(side="left", padx=(0, 5))
|
||||||
|
|
||||||
self._upload_btn = make_icon_button(
|
self._upload_btn = make_icon_button(
|
||||||
btn_frame, "upload", t("s3_upload"), width=100,
|
btn_frame, "upload", t("s3_upload"), width=100,
|
||||||
command=self._upload,
|
command=self._upload,
|
||||||
@@ -222,6 +228,11 @@ class S3Tab(ctk.CTkFrame):
|
|||||||
command=self._download,
|
command=self._download,
|
||||||
)
|
)
|
||||||
self._ctx_menu.add_separator()
|
self._ctx_menu.add_separator()
|
||||||
|
self._ctx_menu.add_command(
|
||||||
|
label=icon_text("add", t("s3_new_folder")),
|
||||||
|
command=self._create_folder,
|
||||||
|
)
|
||||||
|
self._ctx_menu.add_separator()
|
||||||
self._ctx_menu.add_command(
|
self._ctx_menu.add_command(
|
||||||
label=icon_text("delete", t("s3_delete")),
|
label=icon_text("delete", t("s3_delete")),
|
||||||
command=self._delete,
|
command=self._delete,
|
||||||
@@ -516,10 +527,12 @@ class S3Tab(ctk.CTkFrame):
|
|||||||
is_folder = name.endswith("/")
|
is_folder = name.endswith("/")
|
||||||
# Enable/disable menu items based on type
|
# Enable/disable menu items based on type
|
||||||
state = "normal" if not is_folder else "disabled"
|
state = "normal" if not is_folder else "disabled"
|
||||||
self._ctx_menu.entryconfigure(0, state=state) # Link 48h
|
file_state = "normal" if not is_folder else "disabled"
|
||||||
self._ctx_menu.entryconfigure(1, state=state) # Link permanent
|
self._ctx_menu.entryconfigure(0, state=file_state) # Link 48h
|
||||||
self._ctx_menu.entryconfigure(3, state=state) # Download
|
self._ctx_menu.entryconfigure(1, state=file_state) # Link permanent
|
||||||
self._ctx_menu.entryconfigure(5, state=state) # Delete
|
self._ctx_menu.entryconfigure(3, state=file_state) # Download
|
||||||
|
# 5 = New Folder — always enabled
|
||||||
|
# 7 = Delete — enabled for both files and folders
|
||||||
self._ctx_menu.tk_popup(event.x_root, event.y_root)
|
self._ctx_menu.tk_popup(event.x_root, event.y_root)
|
||||||
|
|
||||||
def _get_selected_key(self) -> str | None:
|
def _get_selected_key(self) -> str | None:
|
||||||
@@ -570,6 +583,31 @@ class S3Tab(ctk.CTkFrame):
|
|||||||
else:
|
else:
|
||||||
self._status_label.configure(text=t("s3_link_failed"))
|
self._status_label.configure(text=t("s3_link_failed"))
|
||||||
|
|
||||||
|
def _create_folder(self):
|
||||||
|
"""Prompt for folder name and create it as an empty prefix in S3."""
|
||||||
|
if not self._client or not self._current_bucket:
|
||||||
|
return
|
||||||
|
dialog = ctk.CTkInputDialog(
|
||||||
|
text=t("s3_folder_name_prompt"),
|
||||||
|
title=t("s3_new_folder"),
|
||||||
|
)
|
||||||
|
name = dialog.get_input()
|
||||||
|
if not name or not name.strip():
|
||||||
|
return
|
||||||
|
name = name.strip().strip("/")
|
||||||
|
key = self._current_prefix + name + "/"
|
||||||
|
self._status_label.configure(text=t("s3_creating_folder"))
|
||||||
|
|
||||||
|
def _do():
|
||||||
|
ok = self._client.create_folder(self._current_bucket, key)
|
||||||
|
if ok:
|
||||||
|
self.after(0, self._refresh)
|
||||||
|
else:
|
||||||
|
self.after(0, lambda: self._status_label.configure(
|
||||||
|
text=t("s3_folder_failed")))
|
||||||
|
|
||||||
|
threading.Thread(target=_do, daemon=True).start()
|
||||||
|
|
||||||
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()
|
||||||
@@ -630,24 +668,44 @@ class S3Tab(ctk.CTkFrame):
|
|||||||
|
|
||||||
def _delete(self):
|
def _delete(self):
|
||||||
sel = self._tree.selection()
|
sel = self._tree.selection()
|
||||||
if not sel:
|
if not sel or not self._client or not self._current_bucket:
|
||||||
return
|
return
|
||||||
item = self._tree.item(sel[0])
|
item = self._tree.item(sel[0])
|
||||||
name = item["values"][0] if item["values"] else ""
|
name = item["values"][0] if item["values"] else ""
|
||||||
if not isinstance(name, str):
|
if not isinstance(name, str):
|
||||||
name = str(name)
|
name = str(name)
|
||||||
|
|
||||||
if name.endswith("/"):
|
if name.endswith("/"):
|
||||||
return # Don't delete prefixes
|
# Folder — ask confirmation
|
||||||
|
clean = name.replace("\U0001f4c1 ", "").strip()
|
||||||
|
prefix = self._current_prefix + clean
|
||||||
|
from tkinter import messagebox
|
||||||
|
ok = messagebox.askyesno(
|
||||||
|
t("s3_delete"),
|
||||||
|
t("s3_delete_folder_confirm").format(folder=clean),
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
return
|
||||||
|
self._status_label.configure(text=t("s3_deleting"))
|
||||||
|
|
||||||
key = self._current_prefix + name
|
def _do_folder():
|
||||||
|
count = self._client.delete_prefix(self._current_bucket, prefix)
|
||||||
def _do():
|
|
||||||
ok = self._client.delete_object(self._current_bucket, key)
|
|
||||||
if ok:
|
|
||||||
self.after(0, self._refresh)
|
|
||||||
else:
|
|
||||||
self.after(0, lambda: self._status_label.configure(
|
self.after(0, lambda: self._status_label.configure(
|
||||||
text=t("s3_delete_failed")))
|
text=t("s3_deleted_n").format(count=count)))
|
||||||
|
self.after(0, self._refresh)
|
||||||
|
|
||||||
self._status_label.configure(text=t("s3_deleting"))
|
threading.Thread(target=_do_folder, daemon=True).start()
|
||||||
threading.Thread(target=_do, daemon=True).start()
|
else:
|
||||||
|
# Single file
|
||||||
|
key = self._current_prefix + name
|
||||||
|
|
||||||
|
def _do():
|
||||||
|
ok = self._client.delete_object(self._current_bucket, key)
|
||||||
|
if ok:
|
||||||
|
self.after(0, self._refresh)
|
||||||
|
else:
|
||||||
|
self.after(0, lambda: self._status_label.configure(
|
||||||
|
text=t("s3_delete_failed")))
|
||||||
|
|
||||||
|
self._status_label.configure(text=t("s3_deleting"))
|
||||||
|
threading.Thread(target=_do, daemon=True).start()
|
||||||
|
|||||||
BIN
releases/ServerManager-v1.9.5-win-x64.exe
Normal file
BIN
releases/ServerManager-v1.9.5-win-x64.exe
Normal file
Binary file not shown.
@@ -1,6 +1,6 @@
|
|||||||
"""Version info for ServerManager."""
|
"""Version info for ServerManager."""
|
||||||
|
|
||||||
__version__ = "1.9.4"
|
__version__ = "1.9.5"
|
||||||
__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