diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f0418ed..ef08c795 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## [Unreleased] +### Added +- **Web Remote — File Transfer drop zone:** left panel shows a drag-and-drop upload box (click to pick files, multi-file supported). Uploads go to the folder currently open on the remote side; progress appears in the Transfers column. +- **Web Remote — RustDesk file transfer parity:** 128 KB blocks, zstd upload compression, resume via `offset_blk` / `transferred_size`, overwrite confirmation dialog (Skip / Overwrite / apply to all), remote context menu (rename, delete, new folder), hidden-files toggle, and Resume in the transfer queue after errors. + ### Changed - **Docker — official single container (default):** `install.sh` and quick-start docs now deploy one pre-built image (`ghcr.io/unitronix/betterdesk`) via `docker-compose.quick.single.yml`. Updates are a single `docker compose pull && up -d`. Legacy two-container layout remains available with `install.sh --split` or `docker-compose.quick.yml`. Panel update instructions respect `BETTERDESK_DOCKER_LAYOUT` (`single` vs `split`). Migrating from split to single reuses the same named volumes; RustDesk client API URL changes from port `21114` to `21121`. diff --git a/web-nodejs/lang/ar.json b/web-nodejs/lang/ar.json index 61a0a899..0dbd6b5a 100644 --- a/web-nodejs/lang/ar.json +++ b/web-nodejs/lang/ar.json @@ -1261,7 +1261,7 @@ "file_status_downloaded": "تم التنزيل", "file_status_uploaded": "تم الرفع", "file_drop_upload_hint": "أفلت للرفع إلى: {path}", - "file_local_upload_hint": "اسحب الملفات إلى اللوحة البعيدة أو استخدم الرفع. تُحفظ التنزيلات في مجلد المتصفح الافتراضي.", + "file_local_upload_hint": "أسقط الملفات في المربع على اليسار أو انقر للرفع. الوجهة هي المجلد المفتوح على الجانب البعيد.", "file_transfer_stalled": "الجهاز البعيد لم يبدأ النقل", "file_no_local_tree": "اختر مجلدًا لتصفح الملفات المحلية (اختياري)", "reset_keyboard": "إعادة تعيين حالة لوحة المفاتيح", @@ -1269,7 +1269,22 @@ "keyboard_mode": "وضع لوحة المفاتيح", "keyboard_mode_auto": "تلقائي (Map، مثل RustDesk)", "keyboard_mode_legacy": "Legacy (حرف)", - "keyboard_mode_map": "Map (رمز مسح فعلي)" + "keyboard_mode_map": "Map (رمز مسح فعلي)", + "file_dropzone_title": "أسقط الملفات هنا", + "file_dropzone_subtitle": "أو انقر لاختيار الملفات", + "file_dropzone_multiple": "يدعم عدة ملفات", + "file_dropzone_waiting": "في انتظار الاتصال البعيد…", + "file_overwrite_title": "الملف موجود بالفعل", + "file_overwrite_message": "يوجد ملف باسم «{name}» على الجهاز البعيد. هل تريد استبداله؟", + "file_overwrite_skip": "تخطي", + "file_overwrite_skip_all": "تخطي الكل", + "file_overwrite_confirm": "استبدال", + "file_overwrite_all": "استبدال الكل", + "file_resume": "استئناف", + "file_open": "فتح", + "file_up": "أعلى", + "file_home": "الرئيسية", + "file_hide_hidden": "إخفاء الملفات المخفية" }, "branding": { "identity_title": "هوية العلامة التجارية", diff --git a/web-nodejs/lang/cs.json b/web-nodejs/lang/cs.json index 3333f808..707a349f 100644 --- a/web-nodejs/lang/cs.json +++ b/web-nodejs/lang/cs.json @@ -1260,9 +1260,24 @@ "file_status_downloaded": "Staženo", "file_status_uploaded": "Nahráno", "file_drop_upload_hint": "Přetáhněte pro nahrání do: {path}", - "file_local_upload_hint": "Přetáhněte soubory na vzdálený panel nebo použijte Nahrát. Stažené soubory se uloží do výchozí složky prohlížeče.", + "file_local_upload_hint": "Přetáhněte soubory do pole vlevo nebo klikněte pro nahrání. Cílem je složka otevřená na vzdálené straně.", "file_transfer_stalled": "Vzdálené zařízení nezahájilo přenos", - "file_no_local_tree": "Vyberte složku pro procházení místních souborů (volitelné)" + "file_no_local_tree": "Vyberte složku pro procházení místních souborů (volitelné)", + "file_dropzone_title": "Přetáhněte soubory sem", + "file_dropzone_subtitle": "nebo klikněte pro výběr souborů", + "file_dropzone_multiple": "Podporováno více souborů", + "file_dropzone_waiting": "Čekání na vzdálené připojení…", + "file_overwrite_title": "Soubor již existuje", + "file_overwrite_message": "Soubor „{name}“ již na vzdáleném počítači existuje. Přepsat?", + "file_overwrite_skip": "Přeskočit", + "file_overwrite_skip_all": "Přeskočit vše", + "file_overwrite_confirm": "Přepsat", + "file_overwrite_all": "Přepsat vše", + "file_resume": "Obnovit", + "file_open": "Otevřít", + "file_up": "Nahoru", + "file_home": "Domů", + "file_hide_hidden": "Skrýt skryté soubory" }, "branding": { "identity_title": "Identita značky", diff --git a/web-nodejs/lang/da.json b/web-nodejs/lang/da.json index b951461a..3765b968 100644 --- a/web-nodejs/lang/da.json +++ b/web-nodejs/lang/da.json @@ -1266,9 +1266,24 @@ "file_status_downloaded": "Downloadet", "file_status_uploaded": "Uploadet", "file_drop_upload_hint": "Slip for at uploade til: {path}", - "file_local_upload_hint": "Træk filer til det fjernpanel eller brug Upload. Downloads gemmes i browserens standardmappe.", + "file_local_upload_hint": "Slip filer i feltet til venstre eller klik for at uploade. Destinationen er mappen åben på remote-siden.", "file_transfer_stalled": "Fjernenhed startede ikke overførslen", - "file_no_local_tree": "Vælg en mappe for at gennemse lokale filer (valgfrit)" + "file_no_local_tree": "Vælg en mappe for at gennemse lokale filer (valgfrit)", + "file_dropzone_title": "Slip filer her", + "file_dropzone_subtitle": "eller klik for at vælge filer", + "file_dropzone_multiple": "Flere filer understøttes", + "file_dropzone_waiting": "Venter på remote-forbindelse…", + "file_overwrite_title": "Filen findes allerede", + "file_overwrite_message": "En fil ved navn «{name}» findes allerede på remote-computeren. Overskrive?", + "file_overwrite_skip": "Spring over", + "file_overwrite_skip_all": "Spring alle over", + "file_overwrite_confirm": "Overskriv", + "file_overwrite_all": "Overskriv alle", + "file_resume": "Genoptag", + "file_open": "Åbn", + "file_up": "Op", + "file_home": "Hjem", + "file_hide_hidden": "Skjul skjulte filer" }, "branding": { "identity_title": "Brand Identitet", diff --git a/web-nodejs/lang/de.json b/web-nodejs/lang/de.json index 621ca0b3..28bc1685 100644 --- a/web-nodejs/lang/de.json +++ b/web-nodejs/lang/de.json @@ -1260,9 +1260,24 @@ "file_status_downloaded": "Heruntergeladen", "file_status_uploaded": "Hochgeladen", "file_drop_upload_hint": "Zum Hochladen hier ablegen: {path}", - "file_local_upload_hint": "Dateien auf das Remote-Panel ziehen oder Hochladen verwenden. Downloads landen im Standardordner des Browsers.", + "file_local_upload_hint": "Dateien links ablegen oder klicken zum Hochladen. Ziel ist der auf der Remote-Seite geöffnete Ordner.", "file_transfer_stalled": "Remote-Gerät hat Transfer nicht gestartet", - "file_no_local_tree": "Ordner wählen, um lokale Dateien zu durchsuchen (optional)" + "file_no_local_tree": "Ordner wählen, um lokale Dateien zu durchsuchen (optional)", + "file_dropzone_title": "Dateien hier ablegen", + "file_dropzone_subtitle": "oder klicken zum Auswählen", + "file_dropzone_multiple": "Mehrere Dateien unterstützt", + "file_dropzone_waiting": "Warte auf Remote-Verbindung…", + "file_overwrite_title": "Datei existiert bereits", + "file_overwrite_message": "Die Datei „{name}“ existiert bereits auf dem Remote-Computer. Überschreiben?", + "file_overwrite_skip": "Überspringen", + "file_overwrite_skip_all": "Alle überspringen", + "file_overwrite_confirm": "Überschreiben", + "file_overwrite_all": "Alle überschreiben", + "file_resume": "Fortsetzen", + "file_open": "Öffnen", + "file_up": "Nach oben", + "file_home": "Start", + "file_hide_hidden": "Versteckte Dateien ausblenden" }, "branding": { "identity_title": "Markenidentität", diff --git a/web-nodejs/lang/en.json b/web-nodejs/lang/en.json index d4d8585d..bf69e497 100644 --- a/web-nodejs/lang/en.json +++ b/web-nodejs/lang/en.json @@ -1281,9 +1281,24 @@ "file_status_downloaded": "Downloaded", "file_status_uploaded": "Uploaded", "file_drop_upload_hint": "Drop to upload to: {path}", - "file_local_upload_hint": "Drag files onto the remote panel or use Upload. Downloads save to your browser's default folder.", + "file_local_upload_hint": "Drop files in the box on the left or click it to upload. Destination is the folder open on the remote side.", "file_transfer_stalled": "Remote did not start transfer", - "file_no_local_tree": "Choose a folder to browse local files (optional)" + "file_no_local_tree": "Choose a folder to browse local files (optional)", + "file_dropzone_title": "Drop files here", + "file_dropzone_subtitle": "or click to choose files", + "file_dropzone_multiple": "Multiple files supported", + "file_dropzone_waiting": "Waiting for remote connection…", + "file_overwrite_title": "File already exists", + "file_overwrite_message": "A file named \"{name}\" already exists on the remote computer. Overwrite it?", + "file_overwrite_skip": "Skip", + "file_overwrite_skip_all": "Skip all", + "file_overwrite_confirm": "Overwrite", + "file_overwrite_all": "Overwrite all", + "file_resume": "Resume", + "file_open": "Open", + "file_up": "Up", + "file_home": "Home", + "file_hide_hidden": "Hide hidden files" }, "remote_dashboard": { "title": "Remote Desktop Client", diff --git a/web-nodejs/lang/es.json b/web-nodejs/lang/es.json index c9eee561..ec447f0f 100644 --- a/web-nodejs/lang/es.json +++ b/web-nodejs/lang/es.json @@ -1260,9 +1260,24 @@ "file_status_downloaded": "Descargado", "file_status_uploaded": "Subido", "file_drop_upload_hint": "Suelta para subir a: {path}", - "file_local_upload_hint": "Arrastra archivos al panel remoto o usa Subir. Las descargas se guardan en la carpeta predeterminada del navegador.", + "file_local_upload_hint": "Suelta archivos en el recuadro izquierdo o haz clic para subir. El destino es la carpeta abierta en el equipo remoto.", "file_transfer_stalled": "El dispositivo remoto no inició la transferencia", - "file_no_local_tree": "Elige una carpeta para explorar archivos locales (opcional)" + "file_no_local_tree": "Elige una carpeta para explorar archivos locales (opcional)", + "file_dropzone_title": "Suelta archivos aquí", + "file_dropzone_subtitle": "o haz clic para elegir archivos", + "file_dropzone_multiple": "Varios archivos admitidos", + "file_dropzone_waiting": "Esperando conexión remota…", + "file_overwrite_title": "El archivo ya existe", + "file_overwrite_message": "Ya existe un archivo llamado «{name}» en el equipo remoto. ¿Sobrescribir?", + "file_overwrite_skip": "Omitir", + "file_overwrite_skip_all": "Omitir todos", + "file_overwrite_confirm": "Sobrescribir", + "file_overwrite_all": "Sobrescribir todos", + "file_resume": "Reanudar", + "file_open": "Abrir", + "file_up": "Subir", + "file_home": "Inicio", + "file_hide_hidden": "Ocultar archivos ocultos" }, "branding": { "identity_title": "Identidad de marca", diff --git a/web-nodejs/lang/fi.json b/web-nodejs/lang/fi.json index 8b7ba075..4209dc33 100644 --- a/web-nodejs/lang/fi.json +++ b/web-nodejs/lang/fi.json @@ -1266,9 +1266,24 @@ "file_status_downloaded": "Ladattu", "file_status_uploaded": "Lähetetty", "file_drop_upload_hint": "Pudota ladataksesi kohteeseen: {path}", - "file_local_upload_hint": "Vedä tiedostot etäpaneeliin tai käytä Lataa. Lataukset tallentuvat selaimen oletuskansioon.", + "file_local_upload_hint": "Pudota tiedostot vasemmalla olevaan laatikkoon tai napsauta ladataksesi. Kohde on etäpuolella avoinna oleva kansio.", "file_transfer_stalled": "Etälaite ei aloittanut siirtoa", - "file_no_local_tree": "Valitse kansio paikallisten tiedostojen selaamiseen (valinnainen)" + "file_no_local_tree": "Valitse kansio paikallisten tiedostojen selaamiseen (valinnainen)", + "file_dropzone_title": "Pudota tiedostot tähän", + "file_dropzone_subtitle": "tai napsauta valitaksesi tiedostot", + "file_dropzone_multiple": "Useita tiedostoja tuettu", + "file_dropzone_waiting": "Odotetaan etäyhteyttä…", + "file_overwrite_title": "Tiedosto on jo olemassa", + "file_overwrite_message": "Tiedosto «{name}» on jo etätietokoneella. Korvataanko?", + "file_overwrite_skip": "Ohita", + "file_overwrite_skip_all": "Ohita kaikki", + "file_overwrite_confirm": "Korvaa", + "file_overwrite_all": "Korvaa kaikki", + "file_resume": "Jatka", + "file_open": "Avaa", + "file_up": "Ylös", + "file_home": "Alku", + "file_hide_hidden": "Piilota piilotetut tiedostot" }, "branding": { "identity_title": "Brändin identiteetti", diff --git a/web-nodejs/lang/fr.json b/web-nodejs/lang/fr.json index 39434f7c..410aaf2c 100644 --- a/web-nodejs/lang/fr.json +++ b/web-nodejs/lang/fr.json @@ -1277,9 +1277,24 @@ "file_status_downloaded": "Téléchargé", "file_status_uploaded": "Envoyé", "file_drop_upload_hint": "Déposez pour envoyer vers : {path}", - "file_local_upload_hint": "Glissez des fichiers sur le panneau distant ou utilisez Envoyer. Les téléchargements vont dans le dossier par défaut du navigateur.", + "file_local_upload_hint": "Déposez les fichiers dans la zone à gauche ou cliquez pour téléverser. La destination est le dossier ouvert côté distant.", "file_transfer_stalled": "L'appareil distant n'a pas démarré le transfert", - "file_no_local_tree": "Choisissez un dossier pour parcourir les fichiers locaux (facultatif)" + "file_no_local_tree": "Choisissez un dossier pour parcourir les fichiers locaux (facultatif)", + "file_dropzone_title": "Déposez les fichiers ici", + "file_dropzone_subtitle": "ou cliquez pour choisir des fichiers", + "file_dropzone_multiple": "Plusieurs fichiers pris en charge", + "file_dropzone_waiting": "En attente de la connexion distante…", + "file_overwrite_title": "Le fichier existe déjà", + "file_overwrite_message": "Un fichier nommé « {name} » existe déjà sur l’ordinateur distant. Remplacer ?", + "file_overwrite_skip": "Ignorer", + "file_overwrite_skip_all": "Tout ignorer", + "file_overwrite_confirm": "Remplacer", + "file_overwrite_all": "Tout remplacer", + "file_resume": "Reprendre", + "file_open": "Ouvrir", + "file_up": "Monter", + "file_home": "Accueil", + "file_hide_hidden": "Masquer les fichiers cachés" }, "branding": { "identity_title": "Identité de marque", diff --git a/web-nodejs/lang/hi.json b/web-nodejs/lang/hi.json index f2d6136e..2a4ddb48 100644 --- a/web-nodejs/lang/hi.json +++ b/web-nodejs/lang/hi.json @@ -1266,9 +1266,24 @@ "file_status_downloaded": "डाउनलोड हो गया", "file_status_uploaded": "अपलोड हो गया", "file_drop_upload_hint": "अपलोड करने के लिए यहाँ छोड़ें: {path}", - "file_local_upload_hint": "फ़ाइलों को रिमोट पैनल पर खींचें या अपलोड का उपयोग करें। डाउनलोड आपके ब्राउज़र के डिफ़ॉल्ट फ़ोल्डर में सहेजे जाते हैं।", + "file_local_upload_hint": "बाएँ बॉक्स में फ़ाइलें छोड़ें या अपलोड के लिए क्लिक करें। गंतव्य रिमोट पर खुला फ़ोल्डर है।", "file_transfer_stalled": "रिमोट ने स्थानांतरण शुरू नहीं किया", - "file_no_local_tree": "स्थानीय फ़ाइलें ब्राउज़ करने के लिए फ़ोल्डर चुनें (वैकल्पिक)" + "file_no_local_tree": "स्थानीय फ़ाइलें ब्राउज़ करने के लिए फ़ोल्डर चुनें (वैकल्पिक)", + "file_dropzone_title": "फ़ाइलें यहाँ छोड़ें", + "file_dropzone_subtitle": "या फ़ाइलें चुनने के लिए क्लिक करें", + "file_dropzone_multiple": "कई फ़ाइलें समर्थित", + "file_dropzone_waiting": "रिमोट कनेक्शन की प्रतीक्षा…", + "file_overwrite_title": "फ़ाइल पहले से मौजूद है", + "file_overwrite_message": "«{name}» नाम की फ़ाइल रिमोट कंप्यूटर पर पहले से है। ओवरराइट करें?", + "file_overwrite_skip": "छोड़ें", + "file_overwrite_skip_all": "सभी छोड़ें", + "file_overwrite_confirm": "ओवरराइट", + "file_overwrite_all": "सभी ओवरराइट", + "file_resume": "फिर से शुरू", + "file_open": "खोलें", + "file_up": "ऊपर", + "file_home": "होम", + "file_hide_hidden": "छिपी फ़ाइलें छुपाएँ" }, "branding": { "identity_title": "ब्रांड पहचान", diff --git a/web-nodejs/lang/hu.json b/web-nodejs/lang/hu.json index 00908b0a..e9427030 100644 --- a/web-nodejs/lang/hu.json +++ b/web-nodejs/lang/hu.json @@ -1266,9 +1266,24 @@ "file_status_downloaded": "Letöltve", "file_status_uploaded": "Feltöltve", "file_drop_upload_hint": "Engedje el a feltöltéshez ide: {path}", - "file_local_upload_hint": "Húzza a fájlokat a távoli panelre, vagy használja a Feltöltést. A letöltések a böngésző alapértelmezett mappájába kerülnek.", + "file_local_upload_hint": "Húzza ide a fájlokat a bal oldali mezőbe, vagy kattintson a feltöltéshez. A cél a távoli oldalon megnyitott mappa.", "file_transfer_stalled": "A távoli eszköz nem indította el az átvitelt", - "file_no_local_tree": "Válasszon mappát a helyi fájlok böngészéséhez (opcionális)" + "file_no_local_tree": "Válasszon mappát a helyi fájlok böngészéséhez (opcionális)", + "file_dropzone_title": "Húzza ide a fájlokat", + "file_dropzone_subtitle": "vagy kattintson a fájlok kiválasztásához", + "file_dropzone_multiple": "Több fájl támogatott", + "file_dropzone_waiting": "Várakozás a távoli kapcsolatra…", + "file_overwrite_title": "A fájl már létezik", + "file_overwrite_message": "A(z) «{name}» nevű fájl már létezik a távoli gépen. Felülírja?", + "file_overwrite_skip": "Kihagyás", + "file_overwrite_skip_all": "Összes kihagyása", + "file_overwrite_confirm": "Felülírás", + "file_overwrite_all": "Összes felülírása", + "file_resume": "Folytatás", + "file_open": "Megnyitás", + "file_up": "Fel", + "file_home": "Kezdőlap", + "file_hide_hidden": "Rejtett fájlok elrejtése" }, "branding": { "identity_title": "Márkaidentitás", diff --git a/web-nodejs/lang/id.json b/web-nodejs/lang/id.json index 4fbe6d3a..c5778c6e 100644 --- a/web-nodejs/lang/id.json +++ b/web-nodejs/lang/id.json @@ -1266,9 +1266,24 @@ "file_status_downloaded": "Diunduh", "file_status_uploaded": "Diunggah", "file_drop_upload_hint": "Lepaskan untuk mengunggah ke: {path}", - "file_local_upload_hint": "Seret file ke panel remote atau gunakan Unggah. Unduhan disimpan ke folder default browser.", + "file_local_upload_hint": "Letakkan file di kotak kiri atau klik untuk mengunggah. Tujuan adalah folder yang terbuka di sisi remote.", "file_transfer_stalled": "Perangkat remote tidak memulai transfer", - "file_no_local_tree": "Pilih folder untuk menelusuri file lokal (opsional)" + "file_no_local_tree": "Pilih folder untuk menelusuri file lokal (opsional)", + "file_dropzone_title": "Letakkan file di sini", + "file_dropzone_subtitle": "atau klik untuk memilih file", + "file_dropzone_multiple": "Beberapa file didukung", + "file_dropzone_waiting": "Menunggu koneksi remote…", + "file_overwrite_title": "File sudah ada", + "file_overwrite_message": "File bernama «{name}» sudah ada di komputer remote. Timpa?", + "file_overwrite_skip": "Lewati", + "file_overwrite_skip_all": "Lewati semua", + "file_overwrite_confirm": "Timpa", + "file_overwrite_all": "Timpa semua", + "file_resume": "Lanjutkan", + "file_open": "Buka", + "file_up": "Naik", + "file_home": "Beranda", + "file_hide_hidden": "Sembunyikan file tersembunyi" }, "branding": { "identity_title": "Identitas Merek", diff --git a/web-nodejs/lang/it.json b/web-nodejs/lang/it.json index 7850c649..3d2ffb30 100644 --- a/web-nodejs/lang/it.json +++ b/web-nodejs/lang/it.json @@ -1260,9 +1260,24 @@ "file_status_downloaded": "Scaricato", "file_status_uploaded": "Caricato", "file_drop_upload_hint": "Rilascia per caricare in: {path}", - "file_local_upload_hint": "Trascina i file sul pannello remoto o usa Carica. I download vanno nella cartella predefinita del browser.", + "file_local_upload_hint": "Trascina i file nel riquadro a sinistra o clicca per caricare. La destinazione è la cartella aperta sul lato remoto.", "file_transfer_stalled": "Il dispositivo remoto non ha avviato il trasferimento", - "file_no_local_tree": "Scegli una cartella per sfogliare i file locali (facoltativo)" + "file_no_local_tree": "Scegli una cartella per sfogliare i file locali (facoltativo)", + "file_dropzone_title": "Trascina i file qui", + "file_dropzone_subtitle": "o clicca per scegliere i file", + "file_dropzone_multiple": "Più file supportati", + "file_dropzone_waiting": "In attesa della connessione remota…", + "file_overwrite_title": "Il file esiste già", + "file_overwrite_message": "Un file denominato «{name}» esiste già sul computer remoto. Sovrascrivere?", + "file_overwrite_skip": "Salta", + "file_overwrite_skip_all": "Salta tutti", + "file_overwrite_confirm": "Sovrascrivi", + "file_overwrite_all": "Sovrascrivi tutti", + "file_resume": "Riprendi", + "file_open": "Apri", + "file_up": "Su", + "file_home": "Home", + "file_hide_hidden": "Nascondi file nascosti" }, "branding": { "identity_title": "Identità del marchio", diff --git a/web-nodejs/lang/ja.json b/web-nodejs/lang/ja.json index 0a0e7434..f27aea0b 100644 --- a/web-nodejs/lang/ja.json +++ b/web-nodejs/lang/ja.json @@ -1260,9 +1260,24 @@ "file_status_downloaded": "ダウンロード完了", "file_status_uploaded": "アップロード完了", "file_drop_upload_hint": "ドロップしてアップロード先: {path}", - "file_local_upload_hint": "ファイルをリモートパネルにドラッグするか、アップロードを使用してください。ダウンロードはブラウザの既定フォルダーに保存されます。", + "file_local_upload_hint": "左のボックスにファイルをドロップするか、クリックしてアップロードします。送信先はリモート側で開いているフォルダーです。", "file_transfer_stalled": "リモートが転送を開始しませんでした", - "file_no_local_tree": "ローカルファイルを参照するフォルダーを選択(任意)" + "file_no_local_tree": "ローカルファイルを参照するフォルダーを選択(任意)", + "file_dropzone_title": "ここにファイルをドロップ", + "file_dropzone_subtitle": "またはクリックしてファイルを選択", + "file_dropzone_multiple": "複数ファイルに対応", + "file_dropzone_waiting": "リモート接続を待機中…", + "file_overwrite_title": "ファイルは既に存在します", + "file_overwrite_message": "リモートに「{name}」というファイルが既にあります。上書きしますか?", + "file_overwrite_skip": "スキップ", + "file_overwrite_skip_all": "すべてスキップ", + "file_overwrite_confirm": "上書き", + "file_overwrite_all": "すべて上書き", + "file_resume": "再開", + "file_open": "開く", + "file_up": "上へ", + "file_home": "ホーム", + "file_hide_hidden": "隠しファイルを非表示" }, "branding": { "identity_title": "ブランドアイデンティティ", diff --git a/web-nodejs/lang/ko.json b/web-nodejs/lang/ko.json index aa3659d6..a7250e6f 100644 --- a/web-nodejs/lang/ko.json +++ b/web-nodejs/lang/ko.json @@ -1260,9 +1260,24 @@ "file_status_downloaded": "다운로드됨", "file_status_uploaded": "업로드됨", "file_drop_upload_hint": "업로드 대상에 놓기: {path}", - "file_local_upload_hint": "파일을 원격 패널에 끌어다 놓거나 업로드를 사용하세요. 다운로드는 브라우저 기본 폴더에 저장됩니다.", + "file_local_upload_hint": "왼쪽 상자에 파일을 놓거나 클릭하여 업로드하세요. 대상은 원격 쪽에서 열린 폴더입니다.", "file_transfer_stalled": "원격 장치가 전송을 시작하지 않았습니다", - "file_no_local_tree": "로컬 파일을 탐색하려면 폴더 선택(선택 사항)" + "file_no_local_tree": "로컬 파일을 탐색하려면 폴더 선택(선택 사항)", + "file_dropzone_title": "여기에 파일 놓기", + "file_dropzone_subtitle": "또는 클릭하여 파일 선택", + "file_dropzone_multiple": "여러 파일 지원", + "file_dropzone_waiting": "원격 연결 대기 중…", + "file_overwrite_title": "파일이 이미 있습니다", + "file_overwrite_message": "원격 컴퓨터에 «{name}» 파일이 이미 있습니다. 덮어쓸까요?", + "file_overwrite_skip": "건너뛰기", + "file_overwrite_skip_all": "모두 건너뛰기", + "file_overwrite_confirm": "덮어쓰기", + "file_overwrite_all": "모두 덮어쓰기", + "file_resume": "재개", + "file_open": "열기", + "file_up": "위로", + "file_home": "홈", + "file_hide_hidden": "숨김 파일 숨기기" }, "branding": { "identity_title": "브랜드 아이덴티티", diff --git a/web-nodejs/lang/nb.json b/web-nodejs/lang/nb.json index 5706bcf5..81291548 100644 --- a/web-nodejs/lang/nb.json +++ b/web-nodejs/lang/nb.json @@ -1266,9 +1266,24 @@ "file_status_downloaded": "Lastet ned", "file_status_uploaded": "Lastet opp", "file_drop_upload_hint": "Slipp for å laste opp til: {path}", - "file_local_upload_hint": "Dra filer til fjernpanelet eller bruk Last opp. Nedlastinger lagres i nettleserens standardmappe.", + "file_local_upload_hint": "Slipp filer i boksen til venstre eller klikk for å laste opp. Målet er mappen som er åpen på fjernsiden.", "file_transfer_stalled": "Fjernenheten startet ikke overføringen", - "file_no_local_tree": "Velg en mappe for å bla i lokale filer (valgfritt)" + "file_no_local_tree": "Velg en mappe for å bla i lokale filer (valgfritt)", + "file_dropzone_title": "Slipp filer her", + "file_dropzone_subtitle": "eller klikk for å velge filer", + "file_dropzone_multiple": "Flere filer støttes", + "file_dropzone_waiting": "Venter på fjernforbindelse…", + "file_overwrite_title": "Filen finnes allerede", + "file_overwrite_message": "En fil med navnet «{name}» finnes allerede på fjernmaskinen. Overskrive?", + "file_overwrite_skip": "Hopp over", + "file_overwrite_skip_all": "Hopp over alle", + "file_overwrite_confirm": "Overskriv", + "file_overwrite_all": "Overskriv alle", + "file_resume": "Gjenoppta", + "file_open": "Åpne", + "file_up": "Opp", + "file_home": "Hjem", + "file_hide_hidden": "Skjul skjulte filer" }, "branding": { "identity_title": "Merkeidentitet", diff --git a/web-nodejs/lang/nl.json b/web-nodejs/lang/nl.json index 90beeba2..f6fedd6f 100644 --- a/web-nodejs/lang/nl.json +++ b/web-nodejs/lang/nl.json @@ -1260,9 +1260,24 @@ "file_status_downloaded": "Gedownload", "file_status_uploaded": "Geüpload", "file_drop_upload_hint": "Laat los om te uploaden naar: {path}", - "file_local_upload_hint": "Sleep bestanden naar het externe paneel of gebruik Uploaden. Downloads worden opgeslagen in de standaardmap van de browser.", + "file_local_upload_hint": "Sleep bestanden in het vak links of klik om te uploaden. Bestemming is de map open aan de remote-kant.", "file_transfer_stalled": "Extern apparaat startte overdracht niet", - "file_no_local_tree": "Kies een map om lokale bestanden te bladeren (optioneel)" + "file_no_local_tree": "Kies een map om lokale bestanden te bladeren (optioneel)", + "file_dropzone_title": "Sleep bestanden hierheen", + "file_dropzone_subtitle": "of klik om bestanden te kiezen", + "file_dropzone_multiple": "Meerdere bestanden ondersteund", + "file_dropzone_waiting": "Wachten op remote-verbinding…", + "file_overwrite_title": "Bestand bestaat al", + "file_overwrite_message": "Er bestaat al een bestand «{name}» op de remote-computer. Overschrijven?", + "file_overwrite_skip": "Overslaan", + "file_overwrite_skip_all": "Alles overslaan", + "file_overwrite_confirm": "Overschrijven", + "file_overwrite_all": "Alles overschrijven", + "file_resume": "Hervatten", + "file_open": "Openen", + "file_up": "Omhoog", + "file_home": "Home", + "file_hide_hidden": "Verborgen bestanden verbergen" }, "branding": { "identity_title": "Merkidentiteit", diff --git a/web-nodejs/lang/pl.json b/web-nodejs/lang/pl.json index 6627cdcb..a2b89a8a 100644 --- a/web-nodejs/lang/pl.json +++ b/web-nodejs/lang/pl.json @@ -1273,9 +1273,24 @@ "file_status_downloaded": "Pobrano", "file_status_uploaded": "Wysłano", "file_drop_upload_hint": "Upuść, aby przesłać do: {path}", - "file_local_upload_hint": "Przeciągnij pliki na panel zdalny lub użyj Prześlij. Pobrania trafiają do domyślnego folderu przeglądarki.", + "file_local_upload_hint": "Upuść pliki w polu po lewej lub kliknij je, aby przesłać. Folder docelowy to ten otwarty po stronie zdalnej.", "file_transfer_stalled": "Urządzenie zdalne nie rozpoczęło transferu", - "file_no_local_tree": "Wybierz folder, aby przeglądać pliki lokalne (opcjonalnie)" + "file_no_local_tree": "Wybierz folder, aby przeglądać pliki lokalne (opcjonalnie)", + "file_dropzone_title": "Upuść pliki tutaj", + "file_dropzone_subtitle": "lub kliknij, aby wybrać pliki", + "file_dropzone_multiple": "Obsługiwane wiele plików", + "file_dropzone_waiting": "Oczekiwanie na połączenie ze zdalnym komputerem…", + "file_overwrite_title": "Plik już istnieje", + "file_overwrite_message": "Plik „{name}” już istnieje na komputerze zdalnym. Nadpisać?", + "file_overwrite_skip": "Pomiń", + "file_overwrite_skip_all": "Pomiń wszystkie", + "file_overwrite_confirm": "Nadpisz", + "file_overwrite_all": "Nadpisz wszystkie", + "file_resume": "Wznów", + "file_open": "Otwórz", + "file_up": "W górę", + "file_home": "Początek", + "file_hide_hidden": "Ukryj ukryte pliki" }, "branding": { "identity_title": "Identyfikacja marki", diff --git a/web-nodejs/lang/pt.json b/web-nodejs/lang/pt.json index e40ff12f..2567570e 100644 --- a/web-nodejs/lang/pt.json +++ b/web-nodejs/lang/pt.json @@ -1260,9 +1260,24 @@ "file_status_downloaded": "Transferido", "file_status_uploaded": "Enviado", "file_drop_upload_hint": "Largue para enviar para: {path}", - "file_local_upload_hint": "Arraste ficheiros para o painel remoto ou use Enviar. As transferências são guardadas na pasta predefinida do navegador.", + "file_local_upload_hint": "Solte ficheiros na caixa à esquerda ou clique para enviar. O destino é a pasta aberta no lado remoto.", "file_transfer_stalled": "O dispositivo remoto não iniciou a transferência", - "file_no_local_tree": "Escolha uma pasta para procurar ficheiros locais (opcional)" + "file_no_local_tree": "Escolha uma pasta para procurar ficheiros locais (opcional)", + "file_dropzone_title": "Solte ficheiros aqui", + "file_dropzone_subtitle": "ou clique para escolher ficheiros", + "file_dropzone_multiple": "Vários ficheiros suportados", + "file_dropzone_waiting": "A aguardar ligação remota…", + "file_overwrite_title": "O ficheiro já existe", + "file_overwrite_message": "Já existe um ficheiro chamado «{name}» no computador remoto. Substituir?", + "file_overwrite_skip": "Ignorar", + "file_overwrite_skip_all": "Ignorar todos", + "file_overwrite_confirm": "Substituir", + "file_overwrite_all": "Substituir todos", + "file_resume": "Retomar", + "file_open": "Abrir", + "file_up": "Acima", + "file_home": "Início", + "file_hide_hidden": "Ocultar ficheiros ocultos" }, "branding": { "identity_title": "Identidade da marca", diff --git a/web-nodejs/lang/ro.json b/web-nodejs/lang/ro.json index 8a38ea6d..98cbf9ca 100644 --- a/web-nodejs/lang/ro.json +++ b/web-nodejs/lang/ro.json @@ -1266,9 +1266,24 @@ "file_status_downloaded": "Descărcat", "file_status_uploaded": "Încărcat", "file_drop_upload_hint": "Plasați pentru a încărca în: {path}", - "file_local_upload_hint": "Trageți fișiere pe panoul la distanță sau folosiți Încărcare. Descărcările se salvează în folderul implicit al browserului.", + "file_local_upload_hint": "Trageți fișierele în caseta din stânga sau faceți clic pentru încărcare. Destinația este folderul deschis pe partea remote.", "file_transfer_stalled": "Dispozitivul la distanță nu a pornit transferul", - "file_no_local_tree": "Alegeți un folder pentru a răsfoi fișierele locale (opțional)" + "file_no_local_tree": "Alegeți un folder pentru a răsfoi fișierele locale (opțional)", + "file_dropzone_title": "Trageți fișierele aici", + "file_dropzone_subtitle": "sau faceți clic pentru a alege fișiere", + "file_dropzone_multiple": "Mai multe fișiere acceptate", + "file_dropzone_waiting": "Se așteaptă conexiunea remote…", + "file_overwrite_title": "Fișierul există deja", + "file_overwrite_message": "Un fișier numit «{name}» există deja pe calculatorul remote. Suprascrieți?", + "file_overwrite_skip": "Omite", + "file_overwrite_skip_all": "Omite toate", + "file_overwrite_confirm": "Suprascrie", + "file_overwrite_all": "Suprascrie toate", + "file_resume": "Reia", + "file_open": "Deschide", + "file_up": "Sus", + "file_home": "Acasă", + "file_hide_hidden": "Ascunde fișierele ascunse" }, "branding": { "identity_title": "Identitatea de marcă", diff --git a/web-nodejs/lang/sv.json b/web-nodejs/lang/sv.json index 0b5b9c68..ba6c42d3 100644 --- a/web-nodejs/lang/sv.json +++ b/web-nodejs/lang/sv.json @@ -1266,9 +1266,24 @@ "file_status_downloaded": "Nedladdad", "file_status_uploaded": "Uppladdad", "file_drop_upload_hint": "Släpp för att ladda upp till: {path}", - "file_local_upload_hint": "Dra filer till fjärrpanelen eller använd Ladda upp. Nedladdningar sparas i webbläsarens standardmapp.", + "file_local_upload_hint": "Släpp filer i rutan till vänster eller klicka för att ladda upp. Målet är mappen som är öppen på fjärrsidan.", "file_transfer_stalled": "Fjärrenheten startade inte överföringen", - "file_no_local_tree": "Välj en mapp för att bläddra lokala filer (valfritt)" + "file_no_local_tree": "Välj en mapp för att bläddra lokala filer (valfritt)", + "file_dropzone_title": "Släpp filer här", + "file_dropzone_subtitle": "eller klicka för att välja filer", + "file_dropzone_multiple": "Flera filer stöds", + "file_dropzone_waiting": "Väntar på fjärranslutning…", + "file_overwrite_title": "Filen finns redan", + "file_overwrite_message": "En fil med namnet «{name}» finns redan på fjärrdatorn. Skriva över?", + "file_overwrite_skip": "Hoppa över", + "file_overwrite_skip_all": "Hoppa över alla", + "file_overwrite_confirm": "Skriv över", + "file_overwrite_all": "Skriv över alla", + "file_resume": "Återuppta", + "file_open": "Öppna", + "file_up": "Upp", + "file_home": "Hem", + "file_hide_hidden": "Dölj dolda filer" }, "branding": { "identity_title": "Varumärkesidentitet", diff --git a/web-nodejs/lang/th.json b/web-nodejs/lang/th.json index 4ba0b243..91027524 100644 --- a/web-nodejs/lang/th.json +++ b/web-nodejs/lang/th.json @@ -1266,9 +1266,24 @@ "file_status_downloaded": "ดาวน์โหลดแล้ว", "file_status_uploaded": "อัปโหลดแล้ว", "file_drop_upload_hint": "วางเพื่ออัปโหลดไปที่: {path}", - "file_local_upload_hint": "ลากไฟล์ไปที่แผงระยะไกลหรือใช้การอัปโหลด การดาวน์โหลดจะบันทึกไปยังโฟลเดอร์เริ่มต้นของเบราว์เซอร์", + "file_local_upload_hint": "วางไฟล์ในกล่องทางซ้ายหรือคลิกเพื่ออัปโหลด ปลายทางคือโฟลเดอร์ที่เปิดอยู่ฝั่ง remote", "file_transfer_stalled": "อุปกรณ์ระยะไกลไม่เริ่มการถ่ายโอน", - "file_no_local_tree": "เลือกโฟลเดอร์เพื่อเรียกดูไฟล์ในเครื่อง (ไม่บังคับ)" + "file_no_local_tree": "เลือกโฟลเดอร์เพื่อเรียกดูไฟล์ในเครื่อง (ไม่บังคับ)", + "file_dropzone_title": "วางไฟล์ที่นี่", + "file_dropzone_subtitle": "หรือคลิกเพื่อเลือกไฟล์", + "file_dropzone_multiple": "รองรับหลายไฟล์", + "file_dropzone_waiting": "กำลังรอการเชื่อมต่อ remote…", + "file_overwrite_title": "มีไฟล์นี้อยู่แล้ว", + "file_overwrite_message": "มีไฟล์ «{name}» อยู่บนคอมพิวเตอร์ remote แล้ว เขียนทับหรือไม่?", + "file_overwrite_skip": "ข้าม", + "file_overwrite_skip_all": "ข้ามทั้งหมด", + "file_overwrite_confirm": "เขียนทับ", + "file_overwrite_all": "เขียนทับทั้งหมด", + "file_resume": "ดำเนินต่อ", + "file_open": "เปิด", + "file_up": "ขึ้น", + "file_home": "หน้าแรก", + "file_hide_hidden": "ซ่อนไฟล์ที่ซ่อนอยู่" }, "branding": { "identity_title": "เอกลักษณ์ของแบรนด์", diff --git a/web-nodejs/lang/tr.json b/web-nodejs/lang/tr.json index 16f640dc..ada4a153 100644 --- a/web-nodejs/lang/tr.json +++ b/web-nodejs/lang/tr.json @@ -1266,9 +1266,24 @@ "file_status_downloaded": "İndirildi", "file_status_uploaded": "Yüklendi", "file_drop_upload_hint": "Yüklemek için bırakın: {path}", - "file_local_upload_hint": "Dosyaları uzak panele sürükleyin veya Yükle'yi kullanın. İndirmeler tarayıcının varsayılan klasörüne kaydedilir.", + "file_local_upload_hint": "Dosyaları soldaki kutuya bırakın veya yüklemek için tıklayın. Hedef, uzak tarafta açık klasördür.", "file_transfer_stalled": "Uzak cihaz aktarımı başlatmadı", - "file_no_local_tree": "Yerel dosyalara göz atmak için klasör seçin (isteğe bağlı)" + "file_no_local_tree": "Yerel dosyalara göz atmak için klasör seçin (isteğe bağlı)", + "file_dropzone_title": "Dosyaları buraya bırakın", + "file_dropzone_subtitle": "veya dosya seçmek için tıklayın", + "file_dropzone_multiple": "Birden fazla dosya desteklenir", + "file_dropzone_waiting": "Uzak bağlantı bekleniyor…", + "file_overwrite_title": "Dosya zaten var", + "file_overwrite_message": "«{name}» adlı dosya uzak bilgisayarda zaten mevcut. Üzerine yazılsın mı?", + "file_overwrite_skip": "Atla", + "file_overwrite_skip_all": "Tümünü atla", + "file_overwrite_confirm": "Üzerine yaz", + "file_overwrite_all": "Tümünün üzerine yaz", + "file_resume": "Sürdür", + "file_open": "Aç", + "file_up": "Yukarı", + "file_home": "Ana", + "file_hide_hidden": "Gizli dosyaları gizle" }, "branding": { "identity_title": "Marka Kimliği", diff --git a/web-nodejs/lang/uk.json b/web-nodejs/lang/uk.json index 35d8562a..f4776bb6 100644 --- a/web-nodejs/lang/uk.json +++ b/web-nodejs/lang/uk.json @@ -1266,9 +1266,24 @@ "file_status_downloaded": "Завантажено", "file_status_uploaded": "Завантажено", "file_drop_upload_hint": "Перетягніть для завантаження в: {path}", - "file_local_upload_hint": "Перетягніть файли на віддалену панель або скористайтеся завантаженням. Завантаження зберігаються в папці браузера за замовчуванням.", + "file_local_upload_hint": "Перетягніть файли в поле зліва або натисніть для завантаження. Призначення — папка, відкрита на віддаленій стороні.", "file_transfer_stalled": "Віддалений пристрій не розпочав передачу", - "file_no_local_tree": "Оберіть папку для перегляду локальних файлів (необов'язково)" + "file_no_local_tree": "Оберіть папку для перегляду локальних файлів (необов'язково)", + "file_dropzone_title": "Перетягніть файли сюди", + "file_dropzone_subtitle": "або натисніть, щоб вибрати файли", + "file_dropzone_multiple": "Підтримується кілька файлів", + "file_dropzone_waiting": "Очікування віддаленого з’єднання…", + "file_overwrite_title": "Файл уже існує", + "file_overwrite_message": "Файл «{name}» уже існує на віддаленому комп’ютері. Перезаписати?", + "file_overwrite_skip": "Пропустити", + "file_overwrite_skip_all": "Пропустити всі", + "file_overwrite_confirm": "Перезаписати", + "file_overwrite_all": "Перезаписати всі", + "file_resume": "Відновити", + "file_open": "Відкрити", + "file_up": "Вгору", + "file_home": "Початок", + "file_hide_hidden": "Приховати приховані файли" }, "branding": { "identity_title": "Ідентичність бренду", diff --git a/web-nodejs/lang/vi.json b/web-nodejs/lang/vi.json index 9caa5a30..22d31b7c 100644 --- a/web-nodejs/lang/vi.json +++ b/web-nodejs/lang/vi.json @@ -1266,9 +1266,24 @@ "file_status_downloaded": "Đã tải xuống", "file_status_uploaded": "Đã tải lên", "file_drop_upload_hint": "Thả để tải lên: {path}", - "file_local_upload_hint": "Kéo tệp vào bảng từ xa hoặc dùng Tải lên. Tệp tải xuống lưu vào thư mục mặc định của trình duyệt.", + "file_local_upload_hint": "Thả tệp vào hộp bên trái hoặc nhấp để tải lên. Đích là thư mục đang mở ở phía remote.", "file_transfer_stalled": "Thiết bị từ xa không bắt đầu truyền", - "file_no_local_tree": "Chọn thư mục để duyệt tệp cục bộ (tùy chọn)" + "file_no_local_tree": "Chọn thư mục để duyệt tệp cục bộ (tùy chọn)", + "file_dropzone_title": "Thả tệp vào đây", + "file_dropzone_subtitle": "hoặc nhấp để chọn tệp", + "file_dropzone_multiple": "Hỗ trợ nhiều tệp", + "file_dropzone_waiting": "Đang chờ kết nối remote…", + "file_overwrite_title": "Tệp đã tồn tại", + "file_overwrite_message": "Tệp «{name}» đã có trên máy remote. Ghi đè?", + "file_overwrite_skip": "Bỏ qua", + "file_overwrite_skip_all": "Bỏ qua tất cả", + "file_overwrite_confirm": "Ghi đè", + "file_overwrite_all": "Ghi đè tất cả", + "file_resume": "Tiếp tục", + "file_open": "Mở", + "file_up": "Lên", + "file_home": "Trang chủ", + "file_hide_hidden": "Ẩn tệp ẩn" }, "branding": { "identity_title": "Nhận diện thương hiệu", diff --git a/web-nodejs/lang/zh-TW.json b/web-nodejs/lang/zh-TW.json index 7c8a3f81..8e05bf3c 100644 --- a/web-nodejs/lang/zh-TW.json +++ b/web-nodejs/lang/zh-TW.json @@ -1277,9 +1277,24 @@ "file_status_downloaded": "已下載", "file_status_uploaded": "已上傳", "file_drop_upload_hint": "拖放到此處上傳到:{path}", - "file_local_upload_hint": "將檔案拖到遠端面板或使用上傳。下載會儲存到瀏覽器的預設資料夾。", + "file_local_upload_hint": "將檔案拖到左側框中或點擊上傳。目的地為遠端側目前開啟的資料夾。", "file_transfer_stalled": "遠端裝置未開始傳輸", - "file_no_local_tree": "選擇資料夾以瀏覽本機檔案(選用)" + "file_no_local_tree": "選擇資料夾以瀏覽本機檔案(選用)", + "file_dropzone_title": "將檔案拖放到此處", + "file_dropzone_subtitle": "或點擊選擇檔案", + "file_dropzone_multiple": "支援多個檔案", + "file_dropzone_waiting": "正在等待遠端連線…", + "file_overwrite_title": "檔案已存在", + "file_overwrite_message": "遠端電腦上已存在名為「{name}」的檔案。是否覆寫?", + "file_overwrite_skip": "略過", + "file_overwrite_skip_all": "全部略過", + "file_overwrite_confirm": "覆寫", + "file_overwrite_all": "全部覆寫", + "file_resume": "繼續", + "file_open": "開啟", + "file_up": "上層", + "file_home": "首頁", + "file_hide_hidden": "隱藏隱藏檔案" }, "branding": { "identity_title": "品牌標識", diff --git a/web-nodejs/lang/zh.json b/web-nodejs/lang/zh.json index f8b78002..11bb73ac 100644 --- a/web-nodejs/lang/zh.json +++ b/web-nodejs/lang/zh.json @@ -1260,9 +1260,24 @@ "file_status_downloaded": "已下载", "file_status_uploaded": "已上传", "file_drop_upload_hint": "拖放到此处上传到:{path}", - "file_local_upload_hint": "将文件拖到远程面板或使用上传。下载会保存到浏览器的默认文件夹。", + "file_local_upload_hint": "将文件拖到左侧框中或点击上传。目标为远程侧当前打开的文件夹。", "file_transfer_stalled": "远程设备未开始传输", - "file_no_local_tree": "选择文件夹以浏览本地文件(可选)" + "file_no_local_tree": "选择文件夹以浏览本地文件(可选)", + "file_dropzone_title": "将文件拖放到此处", + "file_dropzone_subtitle": "或点击选择文件", + "file_dropzone_multiple": "支持多个文件", + "file_dropzone_waiting": "正在等待远程连接…", + "file_overwrite_title": "文件已存在", + "file_overwrite_message": "远程计算机上已存在名为「{name}」的文件。是否覆盖?", + "file_overwrite_skip": "跳过", + "file_overwrite_skip_all": "全部跳过", + "file_overwrite_confirm": "覆盖", + "file_overwrite_all": "全部覆盖", + "file_resume": "继续", + "file_open": "打开", + "file_up": "上级", + "file_home": "主页", + "file_hide_hidden": "隐藏隐藏文件" }, "branding": { "identity_title": "品牌标识", diff --git a/web-nodejs/public/css/remote.css b/web-nodejs/public/css/remote.css index 37290253..495fa3bb 100644 --- a/web-nodejs/public/css/remote.css +++ b/web-nodejs/public/css/remote.css @@ -1449,6 +1449,7 @@ flex-direction: column; box-shadow: 0 16px 48px rgba(0, 0, 0, 0.45); overflow: hidden; + position: relative; } .ft-modal-header { @@ -1661,14 +1662,191 @@ font-size: 36px; } -.ft-local-hint { - padding: 6px 10px; - font-size: 11px; +.ft-local-body { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.ft-local-dropzone { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + margin: 12px; + min-height: 180px; + padding: 24px 16px; + border: 2px dashed var(--border-primary, #30363d); + border-radius: 8px; + text-align: center; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + user-select: none; +} + +.ft-local-dropzone.compact { + flex: 0 0 auto; + min-height: 0; + padding: 10px 12px; + margin: 8px; + gap: 2px; +} + +.ft-local-dropzone.compact .ft-dropzone-icon { + font-size: 24px !important; +} + +.ft-local-dropzone.compact .ft-dropzone-subtitle, +.ft-local-dropzone.compact .ft-dropzone-multiple { + display: none; +} + +.ft-local-dropzone:hover:not(.ft-dropzone-disabled) { + border-color: var(--accent, #3884ff); + background: rgba(56, 132, 255, 0.04); +} + +.ft-local-dropzone.ft-dropzone-active { + border-color: var(--accent, #3884ff); + background: rgba(56, 132, 255, 0.08); +} + +.ft-local-dropzone.ft-dropzone-disabled { + opacity: 0.45; + cursor: not-allowed; + pointer-events: none; +} + +.ft-dropzone-icon { + font-size: 40px !important; + color: var(--accent, #3884ff); +} + +.ft-dropzone-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary, #e6edf3); +} + +.ft-dropzone-subtitle, +.ft-dropzone-multiple { + font-size: 12px; color: var(--text-secondary, #8b949e); } -.ft-local-hint .ft-hint-text { - display: inline; +.ft-dropzone-target { + font-size: 11px; + color: var(--text-secondary, #8b949e); + margin-top: 4px; + max-width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding: 0 8px; +} + +.ft-btn.active { + color: var(--accent, #3884ff); +} + +.ft-overwrite-dialog { + position: absolute; + inset: 0; + z-index: 20; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.55); +} + +.ft-overwrite-dialog[hidden] { + display: none !important; +} + +.ft-overwrite-panel { + background: var(--bg-secondary, #161b22); + border: 1px solid var(--border-primary, #30363d); + border-radius: 8px; + padding: 16px 20px; + max-width: 420px; + width: 90%; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); +} + +.ft-overwrite-title { + font-weight: 600; + font-size: 14px; + margin-bottom: 8px; +} + +.ft-overwrite-message { + font-size: 12px; + color: var(--text-secondary, #8b949e); + margin-bottom: 16px; + line-height: 1.5; +} + +.ft-overwrite-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + +.ft-context-menu { + position: fixed; + z-index: 300; + min-width: 160px; + background: var(--bg-secondary, #161b22); + border: 1px solid var(--border-primary, #30363d); + border-radius: 6px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); + padding: 4px 0; +} + +.ft-context-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 12px; + background: none; + border: none; + color: var(--text-primary, #e6edf3); + font-size: 12px; + cursor: pointer; + text-align: left; +} + +.ft-context-item:hover { + background: rgba(255, 255, 255, 0.06); +} + +.ft-context-item .material-icons { + font-size: 16px; + color: var(--text-secondary, #8b949e); +} + +.ft-transfer-resume { + flex-shrink: 0; + background: none; + border: none; + color: var(--accent, #3884ff); + cursor: pointer; + padding: 2px; + border-radius: 4px; + line-height: 1; +} + +.ft-transfer-resume:hover { + background: rgba(56, 132, 255, 0.12); +} + +.ft-transfer-resume .material-icons { + font-size: 16px; } .ft-btn:disabled { diff --git a/web-nodejs/public/js/rdclient/file-modal.js b/web-nodejs/public/js/rdclient/file-modal.js index dcd1ec1b..a8a675cc 100644 --- a/web-nodejs/public/js/rdclient/file-modal.js +++ b/web-nodejs/public/js/rdclient/file-modal.js @@ -36,6 +36,8 @@ this._selectedRemote = null; this._transferMeta = new Map(); this._wiredSessionId = null; + this._remoteReady = false; + this._contextMenu = null; } FileTransferModal.prototype._ensureDom = function () { @@ -55,19 +57,29 @@ '
' + '
computer
' + '
' + - '' + + '' + '' + '' + '' + '' + '
' + - '
' + - '
' + + '
' + + '
' + + 'upload_file' + + '' + + '' + + '' + + '' + + '
' + + '' + + '
' + '' + '
' + '
' + '
desktop_windows
' + '
' + + '' + + '' + '' + '' + '' + @@ -85,6 +97,18 @@ '
sync_alt
' + '
' + '
' + + '' + ''; document.body.appendChild(el); this._el = el; @@ -94,6 +118,12 @@ el.querySelector('.ft-local-send').innerHTML = t('remote.file_send', 'Send') + ' chevron_right'; el.querySelector('.ft-remote-download').innerHTML = 'chevron_left ' + t('remote.file_download', 'Download'); el.querySelector('.ft-queue-empty span:last-child').textContent = t('remote.file_queue_empty', 'No transfers in progress'); + el.querySelector('.ft-local-pick').title = t('remote.file_pick_folder', 'Choose folder'); + el.querySelector('.ft-remote-new-folder').title = t('remote.file_new_folder', 'New Folder'); + el.querySelector('.ft-remote-hidden').title = t('remote.file_show_hidden', 'Show hidden files'); + + this._initDropzoneLabels(); + this._initOverwriteDialogLabels(); var self = this; el.querySelector('.ft-modal-close').addEventListener('click', function () { self.close(); }); @@ -105,12 +135,45 @@ el.querySelector('.ft-remote-up').addEventListener('click', function () { self._remoteGoUp(); }); el.querySelector('.ft-remote-home').addEventListener('click', function () { self._remoteGoHome(); }); el.querySelector('.ft-remote-download').addEventListener('click', function () { self._downloadSelectedRemote(); }); + el.querySelector('.ft-remote-new-folder').addEventListener('click', function () { self._remoteNewFolder(); }); + el.querySelector('.ft-remote-hidden').addEventListener('click', function () { self._toggleRemoteHidden(); }); el.querySelector('.ft-local-file-input').addEventListener('change', function (e) { self._uploadFiles(e.target.files); e.target.value = ''; }); + + var dropzone = el.querySelector('.ft-local-dropzone'); + dropzone.addEventListener('click', function () { + if (dropzone.classList.contains('ft-dropzone-disabled')) return; + self._el.querySelector('.ft-local-file-input').click(); + }); + dropzone.addEventListener('keydown', function (e) { + if (dropzone.classList.contains('ft-dropzone-disabled')) return; + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + self._el.querySelector('.ft-local-file-input').click(); + } + }); + dropzone.addEventListener('dragover', function (e) { + if (dropzone.classList.contains('ft-dropzone-disabled')) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + dropzone.classList.add('ft-dropzone-active'); + }); + dropzone.addEventListener('dragleave', function (e) { + if (dropzone.contains(e.relatedTarget)) return; + dropzone.classList.remove('ft-dropzone-active'); + }); + dropzone.addEventListener('drop', function (e) { + if (dropzone.classList.contains('ft-dropzone-disabled')) return; + e.preventDefault(); + dropzone.classList.remove('ft-dropzone-active'); + if (e.dataTransfer && e.dataTransfer.files.length) self._uploadFiles(e.dataTransfer.files); + }); + var remotePane = el.querySelector('.ft-pane-remote'); remotePane.addEventListener('dragover', function (e) { + if (!self._remoteReady) return; e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; remotePane.classList.add('ft-drag-over'); @@ -122,11 +185,66 @@ self._hideDragOverlay(); }); remotePane.addEventListener('drop', function (e) { + if (!self._remoteReady) return; e.preventDefault(); remotePane.classList.remove('ft-drag-over'); self._hideDragOverlay(); if (e.dataTransfer && e.dataTransfer.files.length) self._uploadFiles(e.dataTransfer.files); }); + + document.addEventListener('click', function () { self._hideContextMenu(); }); + }; + + FileTransferModal.prototype._initDropzoneLabels = function () { + if (!this._el) return; + var dz = this._el.querySelector('.ft-local-dropzone'); + if (!dz) return; + dz.querySelector('.ft-dropzone-title').textContent = t('remote.file_dropzone_title', 'Drop files here'); + dz.querySelector('.ft-dropzone-subtitle').textContent = t('remote.file_dropzone_subtitle', 'or click to choose files'); + dz.querySelector('.ft-dropzone-multiple').textContent = t('remote.file_dropzone_multiple', 'Multiple files supported'); + dz.setAttribute('aria-label', t('remote.file_dropzone_title', 'Drop files here')); + }; + + FileTransferModal.prototype._initOverwriteDialogLabels = function () { + if (!this._el) return; + this._el.querySelector('.ft-overwrite-title').textContent = t('remote.file_overwrite_title', 'File already exists'); + this._el.querySelector('.ft-overwrite-skip').textContent = t('remote.file_overwrite_skip', 'Skip'); + this._el.querySelector('.ft-overwrite-skip-all').textContent = t('remote.file_overwrite_skip_all', 'Skip all'); + this._el.querySelector('.ft-overwrite-confirm').textContent = t('remote.file_overwrite_confirm', 'Overwrite'); + this._el.querySelector('.ft-overwrite-all').textContent = t('remote.file_overwrite_all', 'Overwrite all'); + }; + + FileTransferModal.prototype._updateDropzoneTarget = function () { + if (!this._el) return; + var target = this._el.querySelector('.ft-dropzone-target'); + if (!target) return; + var ft = this._session && this._session.client && this._session.client.fileTransfer; + var path = (ft && ft.currentPath) ? ft.currentPath : t('remote.file_remote', 'Remote'); + var hint = t('remote.file_drop_upload_hint', 'Drop to upload to: {path}'); + target.textContent = hint.replace('{path}', path); + }; + + FileTransferModal.prototype._setDropzoneEnabled = function (enabled) { + if (!this._el) return; + this._remoteReady = !!enabled; + var dz = this._el.querySelector('.ft-local-dropzone'); + if (!dz) return; + dz.classList.toggle('ft-dropzone-disabled', !enabled); + if (!enabled) { + var wait = t('remote.file_dropzone_waiting', 'Waiting for remote connection…'); + dz.querySelector('.ft-dropzone-target').textContent = wait; + } else { + this._updateDropzoneTarget(); + } + }; + + FileTransferModal.prototype._syncLocalLayout = function () { + if (!this._el) return; + var hasRoot = this._local.hasRoot; + var dz = this._el.querySelector('.ft-local-dropzone'); + var list = this._el.querySelector('.ft-local-list'); + if (dz) dz.classList.toggle('compact', hasRoot); + if (list) list.hidden = !hasRoot; }; FileTransferModal.prototype._syncSaveDownloadHook = function () { @@ -163,6 +281,7 @@ var noTreeTitle = t('remote.file_no_local_tree', 'Choose a folder to browse local files (optional)'); var upBtn = this._el.querySelector('.ft-local-up'); var homeBtn = this._el.querySelector('.ft-local-home'); + var sendBtn = this._el.querySelector('.ft-local-send'); if (upBtn) { if (!upBtn.dataset.defaultTitle) upBtn.title = t('remote.file_up', 'Up'); upBtn.dataset.defaultTitle = upBtn.title; @@ -175,6 +294,24 @@ homeBtn.disabled = !hasRoot; homeBtn.title = hasRoot ? homeBtn.dataset.defaultTitle : noTreeTitle; } + if (sendBtn) sendBtn.style.display = hasRoot ? '' : 'none'; + this._syncLocalLayout(); + }; + + FileTransferModal.prototype._updateRemoteHiddenButton = function () { + var btn = this._el && this._el.querySelector('.ft-remote-hidden'); + var ft = this._session && this._session.client && this._session.client.fileTransfer; + if (!btn || !ft) return; + var icon = btn.querySelector('.material-icons'); + if (ft.showHidden) { + icon.textContent = 'visibility'; + btn.title = t('remote.file_hide_hidden', 'Hide hidden files'); + btn.classList.add('active'); + } else { + icon.textContent = 'visibility_off'; + btn.title = t('remote.file_show_hidden', 'Show hidden files'); + btn.classList.remove('active'); + } }; FileTransferModal.prototype.open = function (session) { @@ -182,15 +319,18 @@ this._session = session; if (!session.client || !session.client.fileTransfer) return; this._transferMeta.clear(); + this._remoteReady = false; this._el.querySelector('.ft-modal-title').textContent = t('remote.file_transfer', 'File Transfer') + ' — ' + (session.deviceName || session.deviceId); - this._updateLocalHint(); + this._initDropzoneLabels(); + this._setDropzoneEnabled(false); this._updateLocalToolbarState(); this._renderLocalList(); this._remoteEntries = []; this._selectedRemote = null; this._el.querySelector('.ft-remote-path').textContent = ''; this._renderRemoteLoading(t('remote.file_connecting', 'Connecting file transfer session…')); + this._updateRemoteHiddenButton(); this._el.style.display = 'flex'; document.getElementById('btn-file-transfer')?.classList.add('active'); if (this._wiredSessionId !== session.deviceId) { @@ -205,6 +345,8 @@ FileTransferModal.prototype.close = function () { if (this._el) this._el.style.display = 'none'; this._hideDragOverlay(); + this._hideContextMenu(); + this._hideOverwriteDialog(); document.getElementById('btn-file-transfer')?.classList.remove('active'); if (this._session && this._session.client && this._session.client.fileTransfer) { this._session.client.fileTransfer._saveDownload = null; @@ -222,56 +364,43 @@ if (!self._remoteEntries.length) { self._renderRemoteLoading(t('remote.file_loading', 'Loading…')); } + self._setDropzoneEnabled(false); }); client.on('file_connecting', function () { if (!self._remoteEntries.length) { self._renderRemoteLoading(t('remote.file_connecting', 'Connecting file transfer session…')); } + self._setDropzoneEnabled(false); }); client.on('file_connect_error', function (data) { self._renderRemoteError(data.error || t('remote.file_connect_failed', 'Could not open file transfer session')); + self._setDropzoneEnabled(false); }); client.on('file_dir', function (data) { self._remoteEntries = data.entries || []; self._selectedRemote = null; self._el.querySelector('.ft-remote-path').textContent = data.path || '/'; self._renderRemoteList(); + self._setDropzoneEnabled(true); + self._updateDropzoneTarget(); + self._updateRemoteHiddenButton(); }); client.on('file_browse_timeout', function () { self._renderRemoteError(t('remote.file_timeout', 'Remote device did not respond.')); + self._setDropzoneEnabled(false); }); client.on('file_transfer_start', function (data) { self._addTransfer(data); }); client.on('file_transfer_progress', function (data) { self._updateTransfer(data); }); client.on('file_transfer_complete', function (data) { self._completeTransfer(data); }); client.on('file_transfer_error', function (data) { self._errorTransfer(data); }); client.on('file_transfer_cancelled', function (data) { self._removeTransfer(data.id); }); - }; - - FileTransferModal.prototype._updateLocalHint = function () { - var self = this; - var hint = this._el.querySelector('.ft-local-hint'); - if (this._local.hasRoot) { - hint.innerHTML = ''; - return; - } - if (LocalFiles.isSupported()) { - hint.innerHTML = '' + - ' ' + - escapeHtml(t('remote.file_local_upload_hint', - 'Drag files onto the remote panel or use Upload. Downloads save to your browser\'s default folder.')) + - ''; - hint.querySelector('.ft-local-pick-inline').addEventListener('click', function () { self._pickLocalFolder(); }); - } else { - hint.innerHTML = '' + - escapeHtml(t('remote.file_local_upload_hint', - 'Drag files onto the remote panel or use Upload. Downloads save to your browser\'s default folder.')) + - ' '; - hint.querySelector('.ft-local-upload-fallback').addEventListener('click', function () { - self._el.querySelector('.ft-local-file-input').click(); - }); - } + client.on('file_transfer_overwrite_prompt', function (data) { self._showOverwriteDialog(data); }); + client.on('file_action', function () { + setTimeout(function () { + var ft = self._session && self._session.client && self._session.client.fileTransfer; + if (ft) ft.browseDir(ft.currentPath || ''); + }, 400); + }); }; FileTransferModal.prototype._pickLocalFolder = async function () { @@ -281,7 +410,6 @@ this._localEntries = await this._local.listCurrent(); this._selectedLocal = null; this._renderLocalList(); - this._el.querySelector('.ft-local-hint').innerHTML = ''; this._syncSaveDownloadHook(); this._updateLocalToolbarState(); } catch (e) { @@ -309,13 +437,11 @@ FileTransferModal.prototype._renderLocalList = function () { var list = this._el.querySelector('.ft-local-list'); - list.innerHTML = ''; if (!this._local.hasRoot) { - list.innerHTML = '
' + escapeHtml( - t('remote.file_local_upload_hint', 'Drag files onto the remote panel or use Upload.') - ) + '
'; + list.innerHTML = ''; return; } + list.innerHTML = ''; if (!this._localEntries.length) { list.innerHTML = '
' + escapeHtml(t('remote.file_empty', 'No files')) + '
'; return; @@ -368,6 +494,13 @@ }); }; + FileTransferModal.prototype._buildRemoteFullPath = function (entry) { + var ft = this._session.client.fileTransfer; + var sep = (ft.currentPath || '').includes('\\') ? '\\' : '/'; + var base = ft.currentPath || ''; + return base ? (base.replace(/[\\/]+$/, '') + sep + entry.name) : entry.name; + }; + FileTransferModal.prototype._renderRemoteList = function () { var list = this._el.querySelector('.ft-remote-list'); list.innerHTML = ''; @@ -399,6 +532,10 @@ row.addEventListener('dblclick', function () { if (!entry.isDir) self._downloadRemoteEntry(entry); }); + row.addEventListener('contextmenu', function (e) { + e.preventDefault(); + self._showRemoteContextMenu(e, entry); + }); list.appendChild(row); }); }; @@ -411,6 +548,121 @@ this._session.client?.fileTransfer?.browseDir(''); }; + FileTransferModal.prototype._remoteNewFolder = function () { + var ft = this._session.client?.fileTransfer; + if (!ft) return; + var name = window.prompt(t('remote.file_new_folder_prompt', 'Enter folder name:')); + if (!name || !name.trim()) return; + var sep = (ft.currentPath || '').includes('\\') ? '\\' : '/'; + var base = ft.currentPath || ''; + var path = base ? (base.replace(/[\\/]+$/, '') + sep + name.trim()) : name.trim(); + ft.createDir(path); + }; + + FileTransferModal.prototype._toggleRemoteHidden = function () { + var ft = this._session.client?.fileTransfer; + if (!ft) return; + ft.setShowHidden(!ft.showHidden); + this._updateRemoteHiddenButton(); + }; + + FileTransferModal.prototype._hideContextMenu = function () { + if (this._contextMenu) { + this._contextMenu.remove(); + this._contextMenu = null; + } + }; + + FileTransferModal.prototype._showRemoteContextMenu = function (e, entry) { + var self = this; + this._hideContextMenu(); + var menu = document.createElement('div'); + menu.className = 'ft-context-menu'; + menu.style.left = e.clientX + 'px'; + menu.style.top = e.clientY + 'px'; + + function addItem(label, icon, action) { + var btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'ft-context-item'; + btn.innerHTML = '' + icon + '' + escapeHtml(label) + ''; + btn.addEventListener('click', function () { + self._hideContextMenu(); + action(); + }); + menu.appendChild(btn); + } + + if (entry.isDir) { + addItem(t('remote.file_open', 'Open'), 'folder_open', function () { + var ft = self._session.client.fileTransfer; + var sep = (ft.currentPath || '').includes('\\') ? '\\' : '/'; + var base = ft.currentPath || ''; + var next = base ? (base.replace(/[\\/]+$/, '') + sep + entry.name) : entry.name; + ft.browseDir(next); + }); + } else { + addItem(t('remote.file_download', 'Download'), 'download', function () { + self._downloadRemoteEntry(entry); + }); + } + addItem(t('remote.file_rename', 'Rename'), 'drive_file_rename_outline', function () { + self._remoteRename(entry); + }); + addItem(t('actions.delete', 'Delete'), 'delete_outline', function () { + self._remoteDelete(entry); + }); + + document.body.appendChild(menu); + this._contextMenu = menu; + }; + + FileTransferModal.prototype._remoteRename = function (entry) { + var ft = this._session.client?.fileTransfer; + if (!ft) return; + var newName = window.prompt(t('remote.file_rename_prompt', 'Enter new name:'), entry.name); + if (!newName || !newName.trim() || newName.trim() === entry.name) return; + ft.rename(this._buildRemoteFullPath(entry), newName.trim()); + }; + + FileTransferModal.prototype._remoteDelete = function (entry) { + var ft = this._session.client?.fileTransfer; + if (!ft) return; + if (!window.confirm(t('remote.file_delete_confirm', 'Are you sure you want to delete this item?'))) return; + var path = this._buildRemoteFullPath(entry); + if (entry.isDir) ft.removeDir(path, true); + else ft.removeFile(path); + }; + + FileTransferModal.prototype._showOverwriteDialog = function (data) { + var dlg = this._el.querySelector('.ft-overwrite-dialog'); + if (!dlg) return; + var msg = t('remote.file_overwrite_message', 'A file named "{name}" already exists on the remote computer. Overwrite it?'); + dlg.querySelector('.ft-overwrite-message').textContent = msg.replace('{name}', data.fileName || 'file'); + dlg.hidden = false; + var self = this; + var ft = this._session.client?.fileTransfer; + var id = data.id; + var bind = function (sel, skip, all) { + var btn = dlg.querySelector(sel); + var clone = btn.cloneNode(true); + btn.parentNode.replaceChild(clone, btn); + clone.addEventListener('click', function () { + self._hideOverwriteDialog(); + if (ft) ft.confirmOverwrite(id, skip, all); + }); + }; + bind('.ft-overwrite-skip', true, false); + bind('.ft-overwrite-skip-all', true, true); + bind('.ft-overwrite-confirm', false, false); + bind('.ft-overwrite-all', false, true); + }; + + FileTransferModal.prototype._hideOverwriteDialog = function () { + var dlg = this._el && this._el.querySelector('.ft-overwrite-dialog'); + if (dlg) dlg.hidden = true; + }; + FileTransferModal.prototype._sendSelectedLocal = function () { if (this._selectedLocal && !this._selectedLocal.isDir) this._uploadLocalEntry(this._selectedLocal); }; @@ -424,7 +676,7 @@ FileTransferModal.prototype._uploadFiles = function (fileList) { var ft = this._session.client?.fileTransfer; - if (!ft || !fileList) return; + if (!ft || !fileList || !this._remoteReady) return; for (var i = 0; i < fileList.length; i++) ft.uploadFile(fileList[i], ft.currentPath || ''); }; @@ -449,7 +701,8 @@ percent: 0, transferred: 0, total: data.fileSize || 0, - error: null + error: null, + resumable: false }); } return this._transferMeta.get(id); @@ -478,12 +731,14 @@ var icon = meta.type === 'upload' ? 'upload' : 'download'; if (meta.phase === 'pending') icon = 'hourglass_empty'; + if (meta.error && meta.resumable) icon = 'replay'; var indeterminate = meta.phase === 'pending'; var fillWidth = meta.error ? 0 : Math.min(100, meta.percent || 0); var fillClass = 'ft-transfer-fill' + (indeterminate ? ' indeterminate' : ''); var fillStyle = indeterminate ? '' : ' style="width:' + fillWidth + '%"'; var canCancel = !meta.error && meta.phase !== 'done' && meta.phase !== 'saving'; + var canResume = meta.error && meta.resumable; return '
' + '' + icon + '' + @@ -492,6 +747,8 @@ '
' + '
' + escapeHtml(this._transferStatusText(meta)) + '
' + '
' + + (canResume ? '' : '') + (canCancel ? '' : '') + ''; @@ -517,6 +774,23 @@ if (id && ft) ft.cancelTransfer(id); }); }); + list.querySelectorAll('.ft-transfer-resume').forEach(function (btn) { + btn.addEventListener('click', function () { + var row = btn.closest('.ft-transfer-row'); + var id = Number(row && row.dataset.id); + var ft = self._session.client?.fileTransfer; + if (id && ft) { + var meta = self._transferMeta.get(id); + if (meta) { + meta.error = null; + meta.phase = 'pending'; + meta.resumable = false; + } + ft.resumeTransfer(id); + self._paintTransferQueue(); + } + }); + }); }; FileTransferModal.prototype._addTransfer = function (data) { @@ -527,6 +801,7 @@ meta.phase = 'pending'; meta.percent = 0; meta.error = null; + meta.resumable = false; this._paintTransferQueue(); }; @@ -549,6 +824,8 @@ meta.phase = 'done'; meta.percent = 100; meta.transferred = meta.total || meta.transferred; + meta.error = null; + meta.resumable = false; this._paintTransferQueue(); }; @@ -560,6 +837,7 @@ } meta.error = errMsg; meta.phase = 'error'; + meta.resumable = !!data.resumable; this._paintTransferQueue(); }; diff --git a/web-nodejs/public/js/rdclient/filetransfer.js b/web-nodejs/public/js/rdclient/filetransfer.js index 450e8a96..07ddf71a 100644 --- a/web-nodejs/public/js/rdclient/filetransfer.js +++ b/web-nodejs/public/js/rdclient/filetransfer.js @@ -56,9 +56,41 @@ class RDFileTransfer { FILE_LINK: 5 }; - // Block size for uploads (64KB, matching RustDesk default) - this.BLOCK_SIZE = 65536; + // Block size for uploads (128KB, matching hbb_common BUF_SIZE) + this.BLOCK_SIZE = 131072; this.TRANSFER_STALL_MS = 15000; + + /** @type {'overwrite'|'skip'|null} Session-wide overwrite strategy */ + this._overwriteStrategy = null; + + /** @type {Map} Pending overwrite prompts */ + this._pendingOverwrite = new Map(); + } + + /** Extensions that skip zstd compression (RustDesk is_compressed_file parity) */ + static get COMPRESSED_EXTENSIONS() { + return new Set(['xz', 'gz', 'zip', '7z', 'rar', 'bz2', 'tgz', 'png', 'jpg', 'jpeg', 'webp', 'gif', 'mp4', 'mp3', 'avi', 'mkv']); + } + + /** + * @param {string} fileName + * @returns {boolean} + */ + static isPreCompressedFileName(fileName) { + const ext = String(fileName || '').split('.').pop().toLowerCase(); + return RDFileTransfer.COMPRESSED_EXTENSIONS.has(ext); + } + + /** + * @param {number} transferredSize + * @param {number} blockSize + * @returns {number} + */ + static computeOffsetBlk(transferredSize, blockSize) { + const size = Number(transferredSize || 0); + const blk = Number(blockSize || 131072); + if (!size || !blk) return 0; + return Math.floor(size / blk); } /** @@ -85,16 +117,26 @@ class RDFileTransfer { return dir + (dir.endsWith(sep) ? '' : sep) + (fileName || ''); } - _failTransfer(id, message) { + _failTransfer(id, message, opts) { + const options = opts || {}; const transfer = this._transfers.get(id); if (!transfer) return; this._clearTransferTimeout(id); const fileName = transfer.fileName; - this._transfers.delete(id); + const resumable = !!options.resumable; + if (resumable) { + transfer.status = 'error'; + transfer.errorMessage = message || 'Transfer failed'; + transfer.resumable = true; + } else { + this._transfers.delete(id); + } this._emit('file_transfer_error', { id: id, fileName: fileName, - error: message || 'Transfer failed' + error: message || 'Transfer failed', + resumable: resumable, + type: transfer.type }); } @@ -135,11 +177,17 @@ class RDFileTransfer { const t = self._transfers.get(id); if (!t || t.status !== 'pending') return; t.status = 'error'; - self._transfers.delete(id); + t.resumable = (t.type === 'upload' && (t.sentBytes || 0) > 0) + || (t.type === 'download' && (t.receivedBytes || 0) > 0); + if (!t.resumable) { + self._transfers.delete(id); + } self._emit('file_transfer_error', { id: id, fileName: t.fileName, - error: 'Remote did not start transfer' + error: 'Remote did not start transfer', + resumable: t.resumable, + type: t.type }); }, this.TRANSFER_STALL_MS); } @@ -170,6 +218,84 @@ class RDFileTransfer { get enabled() { return this._enabled; } get currentPath() { return this._currentPath; } get entries() { return this._entries; } + get showHidden() { return this._showHidden; } + + /** + * Respond to overwrite prompt from UI. + * @param {number} id + * @param {boolean} skip + * @param {boolean} [applyToAll] + */ + confirmOverwrite(id, skip, applyToAll) { + const pending = this._pendingOverwrite.get(id); + if (!pending) return; + this._pendingOverwrite.delete(id); + if (applyToAll) { + this._overwriteStrategy = skip ? 'skip' : 'overwrite'; + } + pending.resolve(!!skip); + } + + /** + * Resume a failed transfer when checkpoint data is available. + * @param {number} id + */ + resumeTransfer(id) { + const transfer = this._transfers.get(id); + if (!transfer || !transfer.resumable) return; + + transfer.status = 'pending'; + transfer.errorMessage = null; + transfer.resumable = false; + const self = this; + + this._emit('file_transfer_start', { + id: id, + type: transfer.type, + fileName: transfer.fileName, + fileSize: transfer.fileSize + }); + + this._runWithConnection(function () { + try { + if (transfer.type === 'upload') { + const modified = transfer.file && transfer.file.lastModified + ? Math.floor(transfer.file.lastModified / 1000) : 0; + const startOffset = transfer.sentBytes || 0; + const isResume = startOffset > 0; + if (!isResume) { + const file = transfer.file; + const files = [{ + entryType: self.FILE_TYPE.FILE, + name: file.name, + size: file.size, + modifiedTime: modified + }]; + self._sendMessageSafe(self._proto.buildFileReceiveRequest( + id, transfer.remotePath, files, transfer.fileNum || 0, Number(file.size) + )); + } + self._sendMessageSafe(self._proto.buildFileDigest( + id, transfer.fileNum || 0, transfer.fileSize, modified, + { isUpload: true, isResume: isResume, transferredSize: startOffset } + )); + self._armTransferTimeout(id); + } else if (transfer.type === 'download') { + const fullPath = RDFileTransfer.buildRemoteFilePath( + transfer.remotePath, transfer.fileName + ); + self._sendMessageSafe(self._proto.buildFileSendRequest( + id, fullPath, self._showHidden, transfer.fileNum || 0 + )); + self._armTransferTimeout(id); + } + } catch (err) { + self._failTransfer(id, err.message || String(err)); + } + }).catch(function (err) { + self._failTransfer(id, err.message || 'Could not connect file transfer session'); + }); + } /** * Browse a directory on the remote machine @@ -493,9 +619,20 @@ class RDFileTransfer { const fileNum = transfer.fileNum; const isIdentical = !!(digest.isIdentical || digest.is_identical); + const transferredSize = Number( + digest.transferredSize != null ? digest.transferredSize : (digest.transferred_size || 0) + ); + const isResume = !!(digest.isResume || digest.is_resume); if (transfer.type === 'download') { - this._sendMessageSafe(this._proto.buildFileSendConfirm(id, fileNum, false, 0)); + let offsetBlk = 0; + if (isResume && transferredSize > 0) { + offsetBlk = RDFileTransfer.computeOffsetBlk(transferredSize, this.BLOCK_SIZE); + transfer.receivedBytes = transferredSize; + } else if (transfer.receivedBytes > 0) { + offsetBlk = RDFileTransfer.computeOffsetBlk(transfer.receivedBytes, this.BLOCK_SIZE); + } + this._sendMessageSafe(this._proto.buildFileSendConfirm(id, fileNum, false, offsetBlk)); } else if (transfer.type === 'upload') { // Peer-initiated digest (overwrite check) — operator already sent initial digest. if (!(digest.isUpload || digest.is_upload)) return; @@ -511,16 +648,57 @@ class RDFileTransfer { }); return; } - this._sendMessageSafe(this._proto.buildFileSendConfirm(id, fileNum, false, 0)); - this._sendUploadBlocks(transfer); + const self = this; + const proceedUpload = function (skip) { + if (skip) { + self._sendMessageSafe(self._proto.buildFileSendConfirm(id, fileNum, true, 0)); + self._transfers.delete(id); + self._emit('file_transfer_complete', { + id: id, + fileName: transfer.fileName, + fileSize: transfer.fileSize, + type: 'upload', + elapsed: (Date.now() - transfer.startTime) / 1000, + skipped: true + }); + return; + } + self._sendMessageSafe(self._proto.buildFileSendConfirm(id, fileNum, false, 0)); + self._sendUploadBlocks(transfer, transfer.sentBytes || 0); + }; + + if (this._overwriteStrategy === 'skip') { + proceedUpload(true); + return; + } + if (this._overwriteStrategy === 'overwrite') { + proceedUpload(false); + return; + } + + this._pendingOverwrite.set(id, { + resolve: proceedUpload + }); + this._emit('file_transfer_overwrite_prompt', { + id: id, + fileName: transfer.fileName, + fileSize: transfer.fileSize, + remotePath: transfer.remotePath + }); + return; } + const startTransferred = transfer.type === 'download' + ? (transfer.receivedBytes || transferredSize || 0) + : (transfer.sentBytes || 0); this._emit('file_transfer_progress', { id: id, fileName: transfer.fileName, fileSize: transfer.fileSize, - transferred: 0, - percent: 0, + transferred: startTransferred, + percent: transfer.fileSize > 0 + ? Math.min(100, Math.round((startTransferred / transfer.fileSize) * 100)) + : 0, type: transfer.type, phase: 'transferring' }); @@ -550,17 +728,26 @@ class RDFileTransfer { return; } + const offsetBlk = Number( + confirm.offsetBlk != null ? confirm.offsetBlk : (confirm.offset_blk || 0) + ); + const startOffset = offsetBlk > 0 + ? offsetBlk * this.BLOCK_SIZE + : (transfer.sentBytes || 0); + transfer.status = 'transferring'; this._emit('file_transfer_progress', { id: id, fileName: transfer.fileName, fileSize: transfer.fileSize, - transferred: 0, - percent: 0, + transferred: startOffset, + percent: transfer.fileSize > 0 + ? Math.min(100, Math.round((startOffset / transfer.fileSize) * 100)) + : 0, type: 'upload', phase: 'transferring' }); - this._sendUploadBlocks(transfer); + this._sendUploadBlocks(transfer, startOffset); } /** @@ -664,37 +851,52 @@ class RDFileTransfer { if (transfer) { this._clearTransferTimeout(id); transfer.status = 'error'; - this._transfers.delete(id); + transfer.resumable = (transfer.receivedBytes || transfer.sentBytes || 0) > 0; + if (!transfer.resumable) { + this._transfers.delete(id); + } } this._emit('file_transfer_error', { id: id, fileName: fileName, - error: error.error || 'Unknown error' + error: error.error || 'Unknown error', + resumable: transfer ? transfer.resumable : false, + type: transfer ? transfer.type : null }); } // ---- Upload block streaming ---- + async _tryCompressBlock(data, fileName) { + if (RDFileTransfer.isPreCompressedFileName(fileName)) { + return { data: data, compressed: false }; + } + const result = await RDCompress.compressZstd(data); + return { data: result.content, compressed: result.compress }; + } + /** * Stream file blocks for upload * @param {Object} transfer + * @param {number} [startOffset=0] */ - async _sendUploadBlocks(transfer) { + async _sendUploadBlocks(transfer, startOffset) { const file = transfer.file; if (!file) return; try { - let offset = 0; - let blkId = 0; + let offset = Math.max(0, Number(startOffset || 0)); + let blkId = RDFileTransfer.computeOffsetBlk(offset, this.BLOCK_SIZE); while (offset < file.size && transfer.status === 'transferring') { const end = Math.min(offset + this.BLOCK_SIZE, file.size); const slice = file.slice(offset, end); - const data = new Uint8Array(await slice.arrayBuffer()); + const raw = new Uint8Array(await slice.arrayBuffer()); + const packed = await this._tryCompressBlock(raw, file.name); this._sendMessageSafe(this._proto.buildFileBlock( - transfer.id, transfer.fileNum, data, false, blkId + transfer.id, transfer.fileNum, packed.data, packed.compressed, blkId )); transfer.sentBytes = end; @@ -723,12 +925,17 @@ class RDFileTransfer { } } catch (err) { transfer.status = 'error'; + transfer.resumable = (transfer.sentBytes || 0) > 0 && (transfer.sentBytes || 0) < file.size; this._emit('file_transfer_error', { id: transfer.id, fileName: transfer.fileName, - error: err.message || 'Upload failed' + error: err.message || 'Upload failed', + resumable: transfer.resumable, + type: 'upload' }); - this._transfers.delete(transfer.id); + if (!transfer.resumable) { + this._transfers.delete(transfer.id); + } } } @@ -855,3 +1062,7 @@ class RDFileTransfer { return { active: active, count: active.length }; } } + +if (typeof module !== 'undefined' && module.exports) { + module.exports.RDFileTransfer = RDFileTransfer; +} diff --git a/web-nodejs/public/js/rdclient/protocol.js b/web-nodejs/public/js/rdclient/protocol.js index 773d91f1..818c70b9 100644 --- a/web-nodejs/public/js/rdclient/protocol.js +++ b/web-nodejs/public/js/rdclient/protocol.js @@ -631,6 +631,10 @@ class RDProtocol { }; if (opts.isUpload) digest.isUpload = true; if (opts.isIdentical) digest.isIdentical = true; + if (opts.isResume) digest.isResume = true; + if (opts.transferredSize != null) { + digest.transferredSize = Number(opts.transferredSize); + } return { fileResponse: { digest: digest } }; } diff --git a/web-nodejs/tests/filetransfer.logic.test.js b/web-nodejs/tests/filetransfer.logic.test.js new file mode 100644 index 00000000..c0adc72d --- /dev/null +++ b/web-nodejs/tests/filetransfer.logic.test.js @@ -0,0 +1,52 @@ +'use strict'; + +const { RDFileTransfer } = require('../public/js/rdclient/filetransfer.js'); + +describe('RDFileTransfer static helpers', () => { + it('uses 128 KB block size (hbb_common parity)', () => { + const ft = new RDFileTransfer({ + proto: {}, + sendMessage: () => {}, + emit: () => {} + }); + expect(ft.BLOCK_SIZE).toBe(131072); + }); + + it('computeOffsetBlk derives block index from transferred bytes', () => { + expect(RDFileTransfer.computeOffsetBlk(0, 131072)).toBe(0); + expect(RDFileTransfer.computeOffsetBlk(131072, 131072)).toBe(1); + expect(RDFileTransfer.computeOffsetBlk(262144, 131072)).toBe(2); + }); + + it('isPreCompressedFileName skips zstd for archives and media', () => { + expect(RDFileTransfer.isPreCompressedFileName('photo.jpg')).toBe(true); + expect(RDFileTransfer.isPreCompressedFileName('archive.zip')).toBe(true); + expect(RDFileTransfer.isPreCompressedFileName('readme.txt')).toBe(false); + expect(RDFileTransfer.isPreCompressedFileName('data.bin')).toBe(false); + }); + + it('buildRemoteFilePath joins Windows and Unix paths', () => { + expect(RDFileTransfer.buildRemoteFilePath('C:\\Users\\admin', 'test.txt')) + .toBe('C:\\Users\\admin\\test.txt'); + expect(RDFileTransfer.buildRemoteFilePath('/home/user/', 'a.bin')) + .toBe('/home/user/a.bin'); + }); +}); + +describe('RDFileTransfer overwrite strategy', () => { + it('confirmOverwrite resolves pending prompt and sets session strategy', () => { + const ft = new RDFileTransfer({ + proto: {}, + sendMessage: () => {}, + emit: () => {} + }); + let skipped = null; + ft._pendingOverwrite.set(1, { + resolve: (s) => { skipped = s; } + }); + ft.confirmOverwrite(1, false, true); + expect(skipped).toBe(false); + expect(ft._overwriteStrategy).toBe('overwrite'); + expect(ft._pendingOverwrite.has(1)).toBe(false); + }); +}); diff --git a/web-nodejs/tests/rdclient.protocol.test.js b/web-nodejs/tests/rdclient.protocol.test.js index 98cfbbe7..0edcbfe5 100644 --- a/web-nodejs/tests/rdclient.protocol.test.js +++ b/web-nodejs/tests/rdclient.protocol.test.js @@ -190,6 +190,32 @@ describe('RDClient file transfer message encoding', () => { expect(decoded.fileResponse.digest.fileSize).toBe('12345'); expect(decoded.fileResponse.digest.lastModified).toBe('1700000000'); }); + + it('encodes FileResponse.digest with resume fields', () => { + const msg = Message.create({ + fileResponse: { + digest: { + id: 9, + fileNum: 0, + fileSize: 999999, + lastModified: 1700000001, + isResume: true, + transferredSize: 131072 + } + } + }); + const decoded = Message.decode(Message.encode(msg).finish()).toJSON(); + expect(decoded.fileResponse.digest.isResume).toBe(true); + expect(decoded.fileResponse.digest.transferredSize).toBe('131072'); + }); + + it('encodes FileAction.send_confirm with offset_blk for resume', () => { + const action = FileAction.create({ + sendConfirm: { id: 2, fileNum: 0, offsetBlk: 4 } + }); + const decoded = FileAction.decode(FileAction.encode(action).finish()).toJSON(); + expect(decoded.sendConfirm.offsetBlk).toBe(4); + }); }); describe('RDClient KeyEvent protobuf encoding', () => {