From 663d355a48b3b82676c5361108db8551f14ff2e8 Mon Sep 17 00:00:00 2001 From: 21pages Date: Sun, 5 Nov 2023 15:55:09 +0800 Subject: [PATCH] cm file delete/create log Signed-off-by: 21pages --- flutter/lib/desktop/pages/server_page.dart | 72 +++++++--- flutter/lib/models/chat_model.dart | 11 ++ flutter/lib/models/cm_file_model.dart | 154 +++++++++++++++++++-- flutter/lib/models/model.dart | 2 +- src/flutter.rs | 4 +- src/ipc.rs | 2 +- src/server/connection.rs | 134 +++++++++++++++++- src/ui/cm.rs | 2 +- src/ui_cm_interface.rs | 10 +- 9 files changed, 345 insertions(+), 46 deletions(-) diff --git a/flutter/lib/desktop/pages/server_page.dart b/flutter/lib/desktop/pages/server_page.dart index 89a43adf6..ebad74bcc 100644 --- a/flutter/lib/desktop/pages/server_page.dart +++ b/flutter/lib/desktop/pages/server_page.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart'; import 'package:flutter_hbb/models/chat_model.dart'; +import 'package:flutter_hbb/models/cm_file_model.dart'; import 'package:flutter_hbb/utils/platform_channel.dart'; import 'package:get/get.dart'; import 'package:percent_indicator/linear_percent_indicator.dart'; @@ -482,8 +483,8 @@ class _CmHeaderState extends State<_CmHeader> client.type_() != ClientType.file), child: IconButton( onPressed: () => checkClickTime(client.id, () { - if (client.type_() != ClientType.file) { - gFFI.chatModel.toggleCMSidePage(); + if (client.type_() == ClientType.file) { + gFFI.chatModel.toggleCMFilePage(); } else { gFFI.chatModel .toggleCMChatPage(MessageKey(client.peerId, client.id)); @@ -975,6 +976,49 @@ class __FileTransferLogPageState extends State<_FileTransferLogPage> { ); } + iconLabel(CmFileLog item) { + switch (item.action) { + case CmFileAction.none: + return Container(); + case CmFileAction.localToRemote: + case CmFileAction.remoteToLocal: + return Column( + children: [ + Transform.rotate( + angle: item.action == CmFileAction.remoteToLocal ? 0 : pi, + child: SvgPicture.asset( + "assets/arrow.svg", + color: Theme.of(context).tabBarTheme.labelColor, + ), + ), + Text(item.action == CmFileAction.remoteToLocal + ? translate('Send') + : translate('Receive')) + ], + ); + case CmFileAction.remove: + return Column( + children: [ + Icon( + Icons.delete, + color: Theme.of(context).tabBarTheme.labelColor, + ), + Text(translate('Delete')) + ], + ); + case CmFileAction.createDir: + return Column( + children: [ + Icon( + Icons.create_new_folder, + color: Theme.of(context).tabBarTheme.labelColor, + ), + Text(translate('Create Folder')) + ], + ); + } + } + Widget statusList() { return PreferredSize( preferredSize: const Size(200, double.infinity), @@ -983,7 +1027,7 @@ class __FileTransferLogPageState extends State<_FileTransferLogPage> { child: Obx( () { final jobTable = gFFI.cmFileModel.currentJobTable; - statusListView(List jobs) => ListView.builder( + statusListView(List jobs) => ListView.builder( controller: ScrollController(), itemBuilder: (BuildContext context, int index) { final item = jobs[index]; @@ -998,22 +1042,7 @@ class __FileTransferLogPageState extends State<_FileTransferLogPage> { children: [ SizedBox( width: 50, - child: Column( - children: [ - Transform.rotate( - angle: item.isRemoteToLocal ? 0 : pi, - child: SvgPicture.asset( - "assets/arrow.svg", - color: Theme.of(context) - .tabBarTheme - .labelColor, - ), - ), - Text(item.isRemoteToLocal - ? translate('Send') - : translate('Receive')) - ], - ), + child: iconLabel(item), ).paddingOnly(left: 15), const SizedBox( width: 16.0, @@ -1048,8 +1077,9 @@ class __FileTransferLogPageState extends State<_FileTransferLogPage> { ), ), Offstage( - offstage: - item.state == JobState.inProgress, + offstage: !(item.isTransfer() && + item.state != + JobState.inProgress), child: Text( translate( item.display(), diff --git a/flutter/lib/models/chat_model.dart b/flutter/lib/models/chat_model.dart index 93cdbbed5..5f5a1d707 100644 --- a/flutter/lib/models/chat_model.dart +++ b/flutter/lib/models/chat_model.dart @@ -285,6 +285,10 @@ class ChatModel with ChangeNotifier { await toggleCMSidePage(); } + toggleCMFilePage() async { + await toggleCMSidePage(); + } + var _togglingCMSidePage = false; // protect order for await toggleCMSidePage() async { if (_togglingCMSidePage) return false; @@ -296,6 +300,13 @@ class ChatModel with ChangeNotifier { await windowManager.setSizeAlignment( kConnectionManagerWindowSizeClosedChat, Alignment.topRight); } else { + final currentSelectedTab = + gFFI.serverModel.tabController.state.value.selectedTabInfo; + final client = parent.target?.serverModel.clients.firstWhereOrNull( + (client) => client.id.toString() == currentSelectedTab.key); + if (client != null) { + client.unreadChatMessageCount.value = 0; + } requestChatInputFocus(); await windowManager.show(); await windowManager.setSizeAlignment( diff --git a/flutter/lib/models/cm_file_model.dart b/flutter/lib/models/cm_file_model.dart index d372db6c3..ebb0d6f7f 100644 --- a/flutter/lib/models/cm_file_model.dart +++ b/flutter/lib/models/cm_file_model.dart @@ -10,8 +10,8 @@ import 'file_model.dart'; class CmFileModel { final WeakReference parent; - final currentJobTable = RxList(); - final _jobTables = HashMap>.fromEntries([]); + final currentJobTable = RxList(); + final _jobTables = HashMap>.fromEntries([]); Stopwatch stopwatch = Stopwatch(); int _lastElapsed = 0; @@ -19,14 +19,24 @@ class CmFileModel { void updateCurrentClientId(int id) { if (_jobTables[id] == null) { - _jobTables[id] = RxList(); + _jobTables[id] = RxList(); } Future.delayed(Duration.zero, () { currentJobTable.value = _jobTables[id]!; }); } - onFileTransferLog(dynamic log) { + onFileTransferLog(Map evt) { + if (evt['transfer'] != null) { + _onFileTransfer(evt['transfer']); + } else if (evt['remove'] != null) { + _onFileRemove(evt['remove']); + } else if (evt['create_dir'] != null) { + _onDirCreate(evt['create_dir']); + } + } + + _onFileTransfer(dynamic log) { try { dynamic d = jsonDecode(log); if (!stopwatch.isRunning) stopwatch.start(); @@ -56,9 +66,9 @@ class CmFileModel { debugPrint("jobTable should not be null"); return; } - JobProgress? job = jobTable.firstWhereOrNull((e) => e.id == data.id); + CmFileLog? job = jobTable.firstWhereOrNull((e) => e.id == data.id); if (job == null) { - job = JobProgress(); + job = CmFileLog(); jobTable.add(job); final currentSelectedTab = gFFI.serverModel.tabController.state.value.selectedTabInfo; @@ -68,14 +78,14 @@ class CmFileModel { } } job.id = data.id; - job.isRemoteToLocal = data.isRemote; + job.action = + data.isRemote ? CmFileAction.remoteToLocal : CmFileAction.localToRemote; job.fileName = data.path; job.totalSize = data.totalSize; job.finishedSize = data.finishedSize; if (job.finishedSize > data.totalSize) { job.finishedSize = data.totalSize; } - job.isRemoteToLocal = data.isRemote; if (job.finishedSize > 0) { if (job.finishedSize < job.totalSize) { @@ -99,6 +109,112 @@ class CmFileModel { } jobTable.refresh(); } + + _onFileRemove(dynamic log) { + try { + dynamic d = jsonDecode(log); + FileActionLog data = FileActionLog.fromJson(d); + Client? client = + gFFI.serverModel.clients.firstWhereOrNull((e) => e.id == data.connId); + var jobTable = _jobTables[data.connId]; + if (jobTable == null) { + debugPrint("jobTable should not be null"); + return; + } + int removeUnreadCount = 0; + if (data.dir) { + removeUnreadCount = jobTable + .where((e) => + e.action == CmFileAction.remove && + e.fileName.startsWith(data.path)) + .length; + jobTable.removeWhere((e) => + e.action == CmFileAction.remove && + e.fileName.startsWith(data.path)); + } + jobTable.add(CmFileLog() + ..id = data.id + ..fileName = data.path + ..action = CmFileAction.remove + ..state = JobState.done); + final currentSelectedTab = + gFFI.serverModel.tabController.state.value.selectedTabInfo; + if (!(gFFI.chatModel.isShowCMSidePage && + currentSelectedTab.key == data.connId.toString())) { + // Wrong number if unreadCount changes during deletion, which rarely happens + RxInt? rx = client?.unreadChatMessageCount; + if (rx != null) { + if (rx.value >= removeUnreadCount) { + rx.value -= removeUnreadCount; + } + rx.value += 1; + } + } + jobTable.refresh(); + } catch (e) { + debugPrint('$e'); + } + } + + _onDirCreate(dynamic log) { + try { + dynamic d = jsonDecode(log); + FileActionLog data = FileActionLog.fromJson(d); + Client? client = + gFFI.serverModel.clients.firstWhereOrNull((e) => e.id == data.connId); + var jobTable = _jobTables[data.connId]; + if (jobTable == null) { + debugPrint("jobTable should not be null"); + return; + } + jobTable.add(CmFileLog() + ..id = data.id + ..fileName = data.path + ..action = CmFileAction.createDir + ..state = JobState.done); + final currentSelectedTab = + gFFI.serverModel.tabController.state.value.selectedTabInfo; + if (!(gFFI.chatModel.isShowCMSidePage && + currentSelectedTab.key == data.connId.toString())) { + client?.unreadChatMessageCount.value += 1; + } + jobTable.refresh(); + } catch (e) { + debugPrint('$e'); + } + } +} + +enum CmFileAction { + none, + remoteToLocal, + localToRemote, + remove, + createDir, +} + +class CmFileLog { + JobState state = JobState.none; + var id = 0; + var speed = 0.0; + var finishedSize = 0; + var totalSize = 0; + CmFileAction action = CmFileAction.none; + var fileName = ""; + var err = ""; + int lastTransferredSize = 0; + + String display() { + if (state == JobState.done && err == "skipped") { + return translate("Skipped"); + } + return state.display(); + } + + bool isTransfer() { + return action == CmFileAction.remoteToLocal || + action == CmFileAction.localToRemote; + } } class TransferJobSerdeData { @@ -140,3 +256,25 @@ class TransferJobSerdeData { error: d['error'] ?? '', ); } + +class FileActionLog { + int id = 0; + int connId = 0; + String path = ''; + bool dir = false; + + FileActionLog({ + required this.connId, + required this.id, + required this.path, + required this.dir, + }); + + FileActionLog.fromJson(dynamic d) + : this( + connId: d['connId'] ?? 0, + id: d['id'] ?? 0, + path: d['path'] ?? '', + dir: d['dir'] ?? false, + ); +} diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index 05d5985a2..12f2d5e4c 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -353,7 +353,7 @@ class FfiModel with ChangeNotifier { } } else if (name == "cm_file_transfer_log") { if (isDesktop) { - gFFI.cmFileModel.onFileTransferLog(evt['log']); + gFFI.cmFileModel.onFileTransferLog(evt); } } else { debugPrint('Unknown event name: $name'); diff --git a/src/flutter.rs b/src/flutter.rs index 8d77c9475..691b01b94 100644 --- a/src/flutter.rs +++ b/src/flutter.rs @@ -1031,8 +1031,8 @@ pub mod connection_manager { self.push_event("update_voice_call_state", vec![("client", &client_json)]); } - fn file_transfer_log(&self, log: String) { - self.push_event("cm_file_transfer_log", vec![("log", &log.to_string())]); + fn file_transfer_log(&self, action: &str, log: &str) { + self.push_event("cm_file_transfer_log", vec![(action, log)]); } } diff --git a/src/ipc.rs b/src/ipc.rs index 2a7bf085d..43cf96b79 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -232,7 +232,7 @@ pub enum Data { Plugin(Plugin), #[cfg(windows)] SyncWinCpuUsage(Option), - FileTransferLog(String), + FileTransferLog((String, String)), #[cfg(windows)] ControlledSessionCount(usize), } diff --git a/src/server/connection.rs b/src/server/connection.rs index da12f6fee..1d330aa9a 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -42,6 +42,7 @@ use hbb_common::{ }; #[cfg(any(target_os = "android", target_os = "ios"))] use scrap::android::call_main_service_pointer_input; +use serde_derive::Serialize; use serde_json::{json, value::Value}; use sha2::{Digest, Sha256}; #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -223,6 +224,7 @@ pub struct Connection { start_cm_ipc_para: Option, auto_disconnect_timer: Option<(Instant, u64)>, authed_conn_id: Option, + file_remove_log_control: FileRemoveLogControl, } impl ConnInner { @@ -365,6 +367,7 @@ impl Connection { }), auto_disconnect_timer: None, authed_conn_id: None, + file_remove_log_control: FileRemoveLogControl::new(id), }; let addr = hbb_common::try_into_v4(addr); if !conn.on_open(addr).await { @@ -556,11 +559,11 @@ impl Connection { }, _ = conn.file_timer.tick() => { if !conn.read_jobs.is_empty() { - conn.send_to_cm(ipc::Data::FileTransferLog(fs::serialize_transfer_jobs(&conn.read_jobs))); + conn.send_to_cm(ipc::Data::FileTransferLog(("transfer".to_string(), fs::serialize_transfer_jobs(&conn.read_jobs)))); match fs::handle_read_jobs(&mut conn.read_jobs, &mut conn.stream).await { Ok(log) => { if !log.is_empty() { - conn.send_to_cm(ipc::Data::FileTransferLog(log)); + conn.send_to_cm(ipc::Data::FileTransferLog(("transfer".to_string(), log))); } } Err(err) => { @@ -632,6 +635,7 @@ impl Connection { break; } } + conn.file_remove_log_control.on_timer().drain(..).map(|x| conn.send_to_cm(x)).count(); } _ = test_delay_timer.tick() => { if last_recv_time.elapsed() >= SEC30 { @@ -1911,30 +1915,43 @@ impl Connection { } Some(file_action::Union::RemoveDir(d)) => { self.send_fs(ipc::FS::RemoveDir { - path: d.path, + path: d.path.clone(), id: d.id, recursive: d.recursive, }); + self.file_remove_log_control.on_remove_dir(d); } Some(file_action::Union::RemoveFile(f)) => { self.send_fs(ipc::FS::RemoveFile { - path: f.path, + path: f.path.clone(), id: f.id, file_num: f.file_num, }); + self.file_remove_log_control.on_remove_file(f); } Some(file_action::Union::Create(c)) => { self.send_fs(ipc::FS::CreateDir { - path: c.path, + path: c.path.clone(), id: c.id, }); + self.send_to_cm(ipc::Data::FileTransferLog(( + "create_dir".to_string(), + serde_json::to_string(&FileActionLog { + id: c.id, + conn_id: self.inner.id(), + path: c.path, + dir: true, + }) + .unwrap_or_default(), + ))); } Some(file_action::Union::Cancel(c)) => { self.send_fs(ipc::FS::CancelWrite { id: c.id }); if let Some(job) = fs::get_job_immutable(c.id, &self.read_jobs) { - self.send_to_cm(ipc::Data::FileTransferLog( + self.send_to_cm(ipc::Data::FileTransferLog(( + "transfer".to_string(), fs::serialize_transfer_job(job, false, true, ""), - )); + ))); } fs::remove_job(c.id, &mut self.read_jobs); } @@ -2873,6 +2890,109 @@ pub enum FileAuditType { RemoteReceive = 1, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct FileActionLog { + id: i32, + conn_id: i32, + path: String, + dir: bool, +} + +struct FileRemoveLogControl { + conn_id: i32, + instant: Instant, + removed_files: Vec, + removed_dirs: Vec, +} + +impl FileRemoveLogControl { + fn new(conn_id: i32) -> Self { + FileRemoveLogControl { + conn_id, + instant: Instant::now(), + removed_files: vec![], + removed_dirs: vec![], + } + } + + fn on_remove_file(&mut self, f: FileRemoveFile) -> Option { + self.instant = Instant::now(); + self.removed_files.push(f.clone()); + Some(ipc::Data::FileTransferLog(( + "remove".to_string(), + serde_json::to_string(&FileActionLog { + id: f.id, + conn_id: self.conn_id, + path: f.path, + dir: false, + }) + .unwrap_or_default(), + ))) + } + + fn on_remove_dir(&mut self, d: FileRemoveDir) -> Option { + self.instant = Instant::now(); + self.removed_files.retain(|f| !f.path.starts_with(&d.path)); + self.removed_dirs.retain(|x| !x.path.starts_with(&d.path)); + if !self + .removed_dirs + .iter() + .any(|x| d.path.starts_with(&x.path)) + { + self.removed_dirs.push(d.clone()); + } + Some(ipc::Data::FileTransferLog(( + "remove".to_string(), + serde_json::to_string(&FileActionLog { + id: d.id, + conn_id: self.conn_id, + path: d.path, + dir: true, + }) + .unwrap_or_default(), + ))) + } + + fn on_timer(&mut self) -> Vec { + if self.instant.elapsed().as_secs() < 1 { + return vec![]; + } + let mut v: Vec = vec![]; + self.removed_files + .drain(..) + .map(|f| { + v.push(ipc::Data::FileTransferLog(( + "remove".to_string(), + serde_json::to_string(&FileActionLog { + id: f.id, + conn_id: self.conn_id, + path: f.path, + dir: false, + }) + .unwrap_or_default(), + ))); + }) + .count(); + self.removed_dirs + .drain(..) + .map(|d| { + v.push(ipc::Data::FileTransferLog(( + "remove".to_string(), + serde_json::to_string(&FileActionLog { + id: d.id, + conn_id: self.conn_id, + path: d.path, + dir: true, + }) + .unwrap_or_default(), + ))); + }) + .count(); + v + } +} + #[cfg(windows)] pub struct PortableState { pub last_uac: bool, diff --git a/src/ui/cm.rs b/src/ui/cm.rs index b827b76b1..56a01b946 100644 --- a/src/ui/cm.rs +++ b/src/ui/cm.rs @@ -63,7 +63,7 @@ impl InvokeUiCM for SciterHandler { ); } - fn file_transfer_log(&self, _log: String) {} + fn file_transfer_log(&self, _action: &str, _log: &str) {} } impl SciterHandler { diff --git a/src/ui_cm_interface.rs b/src/ui_cm_interface.rs index 7380b7e63..f259d61bd 100644 --- a/src/ui_cm_interface.rs +++ b/src/ui_cm_interface.rs @@ -101,7 +101,7 @@ pub trait InvokeUiCM: Send + Clone + 'static + Sized { fn update_voice_call_state(&self, client: &Client); - fn file_transfer_log(&self, log: String); + fn file_transfer_log(&self, action: &str, log: &str); } impl Deref for ConnectionManager { @@ -418,10 +418,10 @@ impl IpcTaskRunner { handle_fs(fs, &mut write_jobs, &self.tx, Some(&tx_log)).await; } let log = fs::serialize_transfer_jobs(&write_jobs); - self.cm.ui_handler.file_transfer_log(log); + self.cm.ui_handler.file_transfer_log("transfer", &log); } - Data::FileTransferLog(log) => { - self.cm.ui_handler.file_transfer_log(log); + Data::FileTransferLog((action, log)) => { + self.cm.ui_handler.file_transfer_log(&action, &log); } #[cfg(not(any(target_os = "android", target_os = "ios")))] Data::ClipboardFile(_clip) => { @@ -526,7 +526,7 @@ impl IpcTaskRunner { } }, Some(job_log) = rx_log.recv() => { - self.cm.ui_handler.file_transfer_log(job_log); + self.cm.ui_handler.file_transfer_log("transfer", &job_log); } } }