From a9dd831752956dd68fb2edb1395683e47f8a08ff Mon Sep 17 00:00:00 2001 From: kong <135376906+3316891527@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:01:27 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(workspace):=20=E6=B5=81=E5=BC=8F?= =?UTF-8?q?=E5=A4=8D=E5=88=B6=E6=96=87=E4=BB=B6=E5=B9=B6=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E7=B2=98=E8=B4=B4=E8=BF=9B=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/files/transfer/FileCopyObservers.kt | 21 ++ .../core/files/transfer/LocalTransferPaths.kt | 27 +++ .../core/files/transfer/StreamingFileCopy.kt | 182 ++++++++++++++++++ .../standard/StandardFileSystemTools.kt | 94 ++++++--- .../chat/webview/workspace/FileManager.kt | 20 +- .../browser/WorkspaceBrowserOperations.kt | 98 +++++++--- .../browser/transfer/WorkspaceCopyProgress.kt | 16 ++ .../transfer/WorkspaceCopyProgressDialog.kt | 102 ++++++++++ .../values-en/strings_workspace_transfer.xml | 18 ++ .../values-es/strings_workspace_transfer.xml | 18 ++ .../values-id/strings_workspace_transfer.xml | 18 ++ .../values-ja/strings_workspace_transfer.xml | 18 ++ .../values-ko/strings_workspace_transfer.xml | 18 ++ .../values-ms/strings_workspace_transfer.xml | 18 ++ .../strings_workspace_transfer.xml | 18 ++ .../values-ro/strings_workspace_transfer.xml | 18 ++ .../res/values/strings_workspace_transfer.xml | 18 ++ .../files/transfer/StreamingFileCopyTest.kt | 176 +++++++++++++++++ docs/TODO/fix/README.md | 2 + .../01-validation.md | 61 ++++++ .../index.md | 25 +++ 21 files changed, 930 insertions(+), 56 deletions(-) create mode 100644 app/src/main/java/com/ai/assistance/operit/core/files/transfer/FileCopyObservers.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/core/files/transfer/LocalTransferPaths.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/core/files/transfer/StreamingFileCopy.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/browser/transfer/WorkspaceCopyProgress.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/browser/transfer/WorkspaceCopyProgressDialog.kt create mode 100644 app/src/main/res/values-en/strings_workspace_transfer.xml create mode 100644 app/src/main/res/values-es/strings_workspace_transfer.xml create mode 100644 app/src/main/res/values-id/strings_workspace_transfer.xml create mode 100644 app/src/main/res/values-ja/strings_workspace_transfer.xml create mode 100644 app/src/main/res/values-ko/strings_workspace_transfer.xml create mode 100644 app/src/main/res/values-ms/strings_workspace_transfer.xml create mode 100644 app/src/main/res/values-pt-rBR/strings_workspace_transfer.xml create mode 100644 app/src/main/res/values-ro/strings_workspace_transfer.xml create mode 100644 app/src/main/res/values/strings_workspace_transfer.xml create mode 100644 app/src/test/java/com/ai/assistance/operit/core/files/transfer/StreamingFileCopyTest.kt create mode 100644 docs/TODO/fix/workspace-copy-streaming-progress/01-validation.md create mode 100644 docs/TODO/fix/workspace-copy-streaming-progress/index.md diff --git a/app/src/main/java/com/ai/assistance/operit/core/files/transfer/FileCopyObservers.kt b/app/src/main/java/com/ai/assistance/operit/core/files/transfer/FileCopyObservers.kt new file mode 100644 index 0000000000..26eb2d20ce --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/core/files/transfer/FileCopyObservers.kt @@ -0,0 +1,21 @@ +package com.ai.assistance.operit.core.files.transfer + +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +internal class FileCopyObserver( + val onProgress: (FileCopyProgress) -> Unit, + val checkCancelled: () -> Unit, +) + +/** 工具执行跨越同步入口和 IO 协程;用一次性令牌关联进度,避免并行粘贴串到同一个全局状态。 */ +internal object FileCopyObservers { + const val PARAMETER = "__workspace_copy_id" + private val observers = ConcurrentHashMap() + + fun register(observer: FileCopyObserver): String = UUID.randomUUID().toString().also { observers[it] = observer } + + fun find(id: String?): FileCopyObserver? = id?.let(observers::get) + + fun remove(id: String) { observers.remove(id) } +} \ No newline at end of file diff --git a/app/src/main/java/com/ai/assistance/operit/core/files/transfer/LocalTransferPaths.kt b/app/src/main/java/com/ai/assistance/operit/core/files/transfer/LocalTransferPaths.kt new file mode 100644 index 0000000000..a439abc5e1 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/core/files/transfer/LocalTransferPaths.kt @@ -0,0 +1,27 @@ +package com.ai.assistance.operit.core.files.transfer + +import android.content.Context +import com.ai.assistance.operit.terminal.provider.filesystem.PRootMountMapping +import java.io.File + +/** 与终端共用软挂载映射,不能把 /sdcard 简单拼接到 Ubuntu rootfs 下。 */ +internal object LocalTransferPaths { + fun resolve(context: Context, path: String, environment: String): File { + if (environment.equals("android", ignoreCase = true)) return File(path) + require(environment.equals("linux", ignoreCase = true)) { "Not a local file environment: $environment" } + val expanded = when { + path == "~" -> "/root" + path.startsWith("~/") -> "/root/" + path.substring(2) + else -> path + } + return File(PRootMountMapping.mapLinuxPathToHostPath( + linuxPath = expanded, + ubuntuRoot = File(context.filesDir, "usr/var/lib/proot-distro/installed-rootfs/ubuntu"), + homeDir = context.filesDir.absolutePath, + appDataDir = context.applicationInfo.dataDir, + packageName = context.packageName, + chrootEnabled = context.getSharedPreferences("terminal_settings", Context.MODE_PRIVATE) + .getBoolean("chroot_enabled", false), + )) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ai/assistance/operit/core/files/transfer/StreamingFileCopy.kt b/app/src/main/java/com/ai/assistance/operit/core/files/transfer/StreamingFileCopy.kt new file mode 100644 index 0000000000..acbb488d56 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/core/files/transfer/StreamingFileCopy.kt @@ -0,0 +1,182 @@ +package com.ai.assistance.operit.core.files.transfer + +import java.io.File +import java.io.IOException +import java.nio.file.FileAlreadyExistsException +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.Path +import java.nio.file.OpenOption +import java.nio.file.SimpleFileVisitor +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption +import java.nio.file.attribute.BasicFileAttributes + +internal enum class FileCopyPhase { SCANNING, COPYING, COMPLETED } + +internal data class FileCopyProgress( + val phase: FileCopyPhase, + val currentFile: String, + val copiedBytes: Long, + val totalBytes: Long?, + val copiedFiles: Long, + val totalFiles: Long, + val bytesPerSecond: Double?, +) { + val fraction: Float? + get() = when { + phase == FileCopyPhase.COMPLETED -> 1f + phase == FileCopyPhase.SCANNING || totalBytes == null -> null + totalBytes > 0 -> (copiedBytes.toDouble() / totalBytes).toFloat().coerceIn(0f, 0.999f) + totalFiles > 0 -> (copiedFiles.toDouble() / totalFiles).toFloat().coerceIn(0f, 0.999f) + else -> 0f + } + + val remainingBytes: Long? + get() = totalBytes?.let { (it - copiedBytes).coerceAtLeast(0L) } +} + +/** 两次逐层遍历分别统计和复制,不缓存整棵目录树,也不将文件内容转成字符串。 */ +internal object StreamingFileCopy { + const val BUFFER_SIZE = 256 * 1024 + private const val REPORT_INTERVAL_NANOS = 200_000_000L + private const val SPEED_WINDOW_NANOS = 500_000_000L + + fun copy( + source: File, + destination: File, + recursive: Boolean = true, + overwrite: Boolean = false, + onProgress: (FileCopyProgress) -> Unit = {}, + checkCancelled: () -> Unit = {}, + ): FileCopyProgress { + val sourcePath = source.absoluteFile.toPath().normalize() + val destinationPath = destination.absoluteFile.toPath().normalize() + val attributes = Files.readAttributes(sourcePath, BasicFileAttributes::class.java, LinkOption.NOFOLLOW_LINKS) + val realSource = source.canonicalFile.toPath() + val realDestination = destination.canonicalFile.toPath() + require(realSource != realDestination && (!attributes.isDirectory || !realDestination.startsWith(realSource))) { + "Cannot copy a path onto itself or into its own directory" + } + require(recursive || !attributes.isDirectory) { "Cannot copy a directory without recursive=true" } + if (!overwrite && Files.exists(destinationPath, LinkOption.NOFOLLOW_LINKS)) { + throw FileAlreadyExistsException(destination.path) + } + + var phase = FileCopyPhase.SCANNING + var currentFile = source.name + var totalBytes = 0L + var totalFiles = 0L + var copiedBytes = 0L + var copiedFiles = 0L + var lastReportedAt = System.nanoTime() + var windowStartedAt = lastReportedAt + var windowStartedBytes = 0L + var speed: Double? = null + + fun report(force: Boolean = false): FileCopyProgress? { + checkCancelled() + val now = System.nanoTime() + if (phase == FileCopyPhase.COPYING && now - windowStartedAt >= SPEED_WINDOW_NANOS) { + speed = (copiedBytes - windowStartedBytes) * 1_000_000_000.0 / (now - windowStartedAt) + windowStartedAt = now + windowStartedBytes = copiedBytes + } + if (!force && now - lastReportedAt < REPORT_INTERVAL_NANOS) return null + lastReportedAt = now + return FileCopyProgress( + phase, currentFile, copiedBytes, + totalBytes.takeUnless { phase == FileCopyPhase.SCANNING }, totalFiles = totalFiles, + copiedFiles = copiedFiles, bytesPerSecond = speed, + ).also(onProgress) + } + + fun select(path: Path) { + checkCancelled() + currentFile = sourcePath.relativize(path).toString().ifEmpty { source.name } + } + + report(force = true) + Files.walkFileTree(sourcePath, object : SimpleFileVisitor() { + override fun preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult { + select(dir) + report() + return FileVisitResult.CONTINUE + } + + override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + select(file) + require(attrs.isRegularFile || attrs.isSymbolicLink) { "Unsupported file type: $file" } + totalFiles = Math.addExact(totalFiles, 1L) + if (attrs.isRegularFile) totalBytes = Math.addExact(totalBytes, attrs.size()) + report() + return FileVisitResult.CONTINUE + } + }) + + phase = FileCopyPhase.COPYING + windowStartedAt = System.nanoTime() + windowStartedBytes = 0L + report(force = true) + if (attributes.isDirectory && !overwrite) { + destinationPath.parent?.let { Files.createDirectories(it) } + // 原子创建顶层目标,统计期间出现同名目录时也不能合并写入。 + Files.createDirectory(destinationPath) + } + // 整个任务复用这一块缓冲区,内存占用不随文件大小或文件数量增长。 + val buffer = ByteArray(BUFFER_SIZE) + Files.walkFileTree(sourcePath, object : SimpleFileVisitor() { + private fun target(path: Path): Path = destinationPath.resolve(sourcePath.relativize(path)) + + override fun preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult { + select(dir) + Files.createDirectories(target(dir)) + report() + return FileVisitResult.CONTINUE + } + + override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + select(file) + val target = target(file) + target.parent?.let { Files.createDirectories(it) } + if (attrs.isSymbolicLink) { + // 不遍历链接目标,避免目录环和将链接指向的外部目录一并复制。 + if (overwrite) Files.copy(file, target, LinkOption.NOFOLLOW_LINKS, StandardCopyOption.REPLACE_EXISTING) + else Files.copy(file, target, LinkOption.NOFOLLOW_LINKS) + } else { + require(attrs.isRegularFile) { "Unsupported file type: $file" } + val options = if (overwrite) { + arrayOf(StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS) + } else { + arrayOf(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS) + } + var fileBytes = 0L + Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS).use { input -> + Files.newOutputStream(target, *options).use { output -> + while (true) { + checkCancelled() + val count = input.read(buffer) + if (count < 0) break + if (count == 0) continue + output.write(buffer, 0, count) + fileBytes += count + copiedBytes += count + report() + } + } + } + if (fileBytes != attrs.size()) throw IOException("Source size changed while copying: $file") + } + copiedFiles++ + report() + return FileVisitResult.CONTINUE + } + }) + if (copiedBytes != totalBytes || copiedFiles != totalFiles) { + throw IOException("Source directory changed while copying: $source") + } + phase = FileCopyPhase.COMPLETED + return checkNotNull(report(force = true)) + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/core/tools/defaultTool/standard/StandardFileSystemTools.kt b/app/src/main/java/com/ai/assistance/operit/core/tools/defaultTool/standard/StandardFileSystemTools.kt index 59f36e65c8..9b36d9c790 100644 --- a/app/src/main/java/com/ai/assistance/operit/core/tools/defaultTool/standard/StandardFileSystemTools.kt +++ b/app/src/main/java/com/ai/assistance/operit/core/tools/defaultTool/standard/StandardFileSystemTools.kt @@ -1,6 +1,13 @@ package com.ai.assistance.operit.core.tools.defaultTool.standard import android.content.Context +import com.ai.assistance.operit.core.files.transfer.FileCopyObserver +import com.ai.assistance.operit.core.files.transfer.FileCopyObservers +import com.ai.assistance.operit.core.files.transfer.LocalTransferPaths +import com.ai.assistance.operit.core.files.transfer.StreamingFileCopy +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import com.ai.assistance.operit.R import com.ai.assistance.operit.util.logging.AppLogger import com.ai.assistance.operit.api.chat.EnhancedAIService @@ -2417,30 +2424,52 @@ open class StandardFileSystemTools(protected val context: Context) { } } - /** Helper method to recursively copy a directory */ - private fun copyDirectory(sourceDir: File, destDir: File): Boolean { - try { - if (!destDir.exists()) { - destDir.mkdirs() - } - - sourceDir.listFiles()?.forEach { file -> - val destFile = File(destDir, file.name) - if (file.isDirectory) { - copyDirectory(file, destFile) - } else { - file.inputStream().use { input -> - destFile.outputStream().use { output -> - input.copyTo(output) - } - } - } - } + /** 目录内任一项失败都必须失败,避免移动操作在不完整复制后删除来源。 */ + private fun copyDirectory(sourceDir: File, destDir: File): Boolean = try { + StreamingFileCopy.copy(sourceDir, destDir, overwrite = true) + true + } catch (e: Exception) { + AppLogger.e(TAG, "Error copying directory", e) + false + } - return true + /** 本机复制直接连接两个文件流,保持二进制内容并提供任务独立的进度。 */ + private suspend fun copyLocalFile( + toolName: String, + sourcePath: String, + destPath: String, + sourceEnvironment: String, + destEnvironment: String, + recursive: Boolean, + observer: FileCopyObserver?, + ): ToolResult = withContext(Dispatchers.IO) { + try { + val executionContext = currentCoroutineContext() + val progress = StreamingFileCopy.copy( + source = LocalTransferPaths.resolve(context, sourcePath, sourceEnvironment), + destination = LocalTransferPaths.resolve(context, destPath, destEnvironment), + recursive = recursive, + overwrite = observer == null, + onProgress = { observer?.onProgress?.invoke(it) }, + checkCancelled = { + executionContext.ensureActive() + observer?.checkCancelled?.invoke() + }, + ) + ToolResult( + toolName = toolName, + success = true, + result = FileOperationData(operation = "copy", path = sourcePath, successful = true, + details = "Copied $sourceEnvironment:$sourcePath to $destEnvironment:$destPath (${progress.copiedBytes} bytes)"), + error = "", + ) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { - AppLogger.e(TAG, "Error copying directory", e) - return false + AppLogger.e(TAG, "Error streaming local file copy", e) + ToolResult(toolName = toolName, success = false, + result = FileOperationData(operation = "copy", path = sourcePath, successful = false, details = e.message.orEmpty()), + error = "Failed to copy $sourcePath: ${e.message}") } } @@ -2704,7 +2733,7 @@ open class StandardFileSystemTools(protected val context: Context) { if (result.success) { copiedDirs++ } else { - AppLogger.w(TAG, "Failed to copy directory: $srcFullPath") + return result } } else { val result = copyFileCrossEnvironment( @@ -2718,7 +2747,7 @@ open class StandardFileSystemTools(protected val context: Context) { if (result.success) { copiedFiles++ } else { - AppLogger.w(TAG, "Failed to copy file: $srcFullPath") + return result } } } @@ -2799,6 +2828,23 @@ open class StandardFileSystemTools(protected val context: Context) { ) } + val observer = FileCopyObservers.find(tool.parameters.find { it.name == FileCopyObservers.PARAMETER }?.value) + val localEnvironments = listOf(srcEnv, dstEnv).all { + it.equals("android", ignoreCase = true) || it.equals("linux", ignoreCase = true) + } + val usesLinux = isLinuxEnvironment(srcEnv) || isLinuxEnvironment(dstEnv) + // 按实际后端选择传输:SSH 保持远程提供方,本机路径使用终端相同的软挂载映射。 + if (localEnvironments && (!usesLinux || !isSshFileSystemActive()) && + (usesLinux || observer != null)) { + if (!isLinuxEnvironment(srcEnv)) { + PathValidator.validateAndroidPath(sourcePath, tool.name, "source")?.let { return it } + } + if (!isLinuxEnvironment(dstEnv)) { + PathValidator.validateAndroidPath(destPath, tool.name, "destination")?.let { return it } + } + return copyLocalFile(tool.name, sourcePath, destPath, srcEnv, dstEnv, recursive, observer) + } + // 检查是否是跨环境复制 val isCrossEnvironment = srcEnv.lowercase() != dstEnv.lowercase() diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/FileManager.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/FileManager.kt index 3181112a10..2d60b018af 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/FileManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/FileManager.kt @@ -68,6 +68,7 @@ import com.ai.assistance.operit.ui.features.conversation.chat.webview.workspace. import com.ai.assistance.operit.ui.features.conversation.chat.webview.workspace.text.readWorkspaceTextWithinLimit import com.ai.assistance.operit.util.files.FileUtils import com.ai.assistance.operit.ui.features.conversation.chat.webview.workspace.browser.* +import com.ai.assistance.operit.ui.features.conversation.chat.webview.workspace.browser.transfer.WorkspaceCopyProgressDialog import com.ai.assistance.operit.ui.features.conversation.chat.webview.workspace.browser.FileBrowserClipboard import com.ai.assistance.operit.ui.features.conversation.chat.webview.workspace.browser.FileBrowserClipboardItem import com.ai.assistance.operit.ui.features.conversation.chat.webview.workspace.browser.FileBrowserContextMenu @@ -114,6 +115,8 @@ fun FileBrowser( var currentEnvironment by rememberSaveable(initialPath, environment) { mutableStateOf(environment) } var fileList by remember { mutableStateOf>(emptyList()) } var isOperationLoading by remember { mutableStateOf(false) } + var fileOperationJob by remember { mutableStateOf(null) } + var cancellingFileOperation by remember { mutableStateOf(false) } var isDirectoryLoading by remember { mutableStateOf(false) } var directoryLoadJob by remember { mutableStateOf(null) } var directoryLoadVersion by remember { mutableIntStateOf(0) } @@ -143,6 +146,7 @@ fun FileBrowser( val listState = rememberLazyListState() val operations = remember(context) { FileBrowserOperations(context) } val clipboard by FileBrowserClipboard.content.collectAsState() + val copyProgress by operations.copyProgress.collectAsState() var showSortMenu by remember { mutableStateOf(false) } var selectionMode by rememberSaveable(currentPath, currentEnvironment) { mutableStateOf(false) } var selectedNames by rememberSaveable(currentPath, currentEnvironment) { mutableStateOf(emptySet()) } @@ -450,7 +454,8 @@ fun FileBrowser( fun runOperation(block: suspend () -> Unit) { if (isLoading) return isOperationLoading = true - coroutineScope.launch { + cancellingFileOperation = false + fileOperationJob = coroutineScope.launch { try { block() errorMessage = null @@ -461,8 +466,9 @@ fun FileBrowser( errorMessage = e.message } finally { isOperationLoading = false + fileOperationJob = null + loadDirectory(currentPath) } - loadDirectory(currentPath) } } @@ -658,6 +664,14 @@ fun FileBrowser( }) { Text(stringResource(R.string.workspace_open_external)) } } }, confirmButton = {}) } + copyProgress?.let { progress -> + key(progress.sourceEnvironment, progress.source, progress.destinationEnvironment, progress.destination) { + WorkspaceCopyProgressDialog(progress, cancelling = cancellingFileOperation, onCancel = { + cancellingFileOperation = true + fileOperationJob?.cancel() + }) + } + } propertiesTarget?.let { target -> WorkspacePropertiesDialog(target, onDismiss = { propertiesTarget = null }, @@ -891,7 +905,7 @@ fun FileBrowser( } // 文件列表 - if (isLoading) { + if (isLoading && copyProgress == null) { Box( modifier = Modifier.fillMaxSize().weight(1f), contentAlignment = Alignment.Center diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/browser/WorkspaceBrowserOperations.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/browser/WorkspaceBrowserOperations.kt index 40de9edb8d..765626d03c 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/browser/WorkspaceBrowserOperations.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/browser/WorkspaceBrowserOperations.kt @@ -5,6 +5,13 @@ import android.content.Intent import android.util.Base64 import androidx.core.content.FileProvider import com.ai.assistance.operit.R +import com.ai.assistance.operit.core.files.transfer.FileCopyObserver +import com.ai.assistance.operit.core.files.transfer.FileCopyObservers +import com.ai.assistance.operit.ui.features.conversation.chat.webview.workspace.browser.transfer.WorkspaceCopyProgress +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import com.ai.assistance.operit.core.tools.AIToolHandler import com.ai.assistance.operit.core.tools.BinaryFileContentData import com.ai.assistance.operit.core.tools.FileExistsData @@ -19,6 +26,8 @@ import kotlinx.coroutines.withContext internal class FileBrowserOperations(context: Context) { private val context = context.applicationContext private val handler = AIToolHandler.getInstance(this.context) + private val copyState = MutableStateFlow(null) + val copyProgress = copyState.asStateFlow() private fun parameters(environment: String?, vararg values: Pair) = buildList { values.forEach { (name, value) -> add(ToolParameter(name, value)) } @@ -51,37 +60,68 @@ internal class FileBrowserOperations(context: Context) { val items = FileBrowserClipboard.content.value if (items.isEmpty()) return@withContext val destinationEnv = environment ?: "android" - val parent = execute("file_exists", parameters(environment, "path" to directory)).result - check(parent is FileExistsData && parent.exists && parent.isDirectory) { - context.getString(R.string.workspace_error_invalid_path, directory) - } - val targets = items.map { item -> - val sourceEnv = item.environment ?: "android" - val destination = directory.trimEnd('/') + "/" + item.path.substringAfterLast('/') - check(sourceEnv != destinationEnv || - (destination != item.path && !destination.startsWith(item.path.trimEnd('/') + "/"))) { - context.getString(R.string.workspace_invalid_paste) + val executionContext = currentCoroutineContext() + fun progress(item: FileBrowserClipboardItem, index: Int) = WorkspaceCopyProgress( + source = item.path, + destination = directory.trimEnd('/') + "/" + item.path.trimEnd('/').substringAfterLast('/'), + sourceEnvironment = item.environment ?: "android", + destinationEnvironment = destinationEnv, + itemIndex = index + 1, + itemCount = items.size, + moving = item.cut, + ) + copyState.value = progress(items.first(), 0) + try { + val parent = execute("file_exists", parameters(environment, "path" to directory)).result + check(parent is FileExistsData && parent.exists && parent.isDirectory) { + context.getString(R.string.workspace_error_invalid_path, directory) } - item to destination - } - check(targets.map { it.second }.distinct().size == targets.size) { - context.getString(R.string.file_op_confirm_overwrite) - } - // 全批次先检查目标,避免遇到重名时已经移动了前面的文件。 - targets.forEach { (_, destination) -> requireNewDestination(destination, environment) } - for ((item, destination) in targets) { - val sourceEnv = item.environment ?: "android" - requireNewDestination(destination, environment) - if (item.cut && sourceEnv == destinationEnv) { - execute("move_file", parameters(environment, "source" to item.path, "destination" to destination)) - } else { - execute("copy_file", listOf(ToolParameter("source", item.path), ToolParameter("destination", destination), - ToolParameter("source_environment", sourceEnv), ToolParameter("dest_environment", destinationEnv), - ToolParameter("recursive", item.isDirectory.toString()))) - // 每项复制成功才删除其来源,失败后剩余剪切项仍可继续粘贴。 - if (item.cut) delete(item.path, item.environment, item.isDirectory) + val targets = items.map { item -> + val sourceEnv = item.environment ?: "android" + val destination = directory.trimEnd('/') + "/" + item.path.trimEnd('/').substringAfterLast('/') + check(sourceEnv != destinationEnv || + (destination != item.path && !destination.startsWith(item.path.trimEnd('/') + "/"))) { + context.getString(R.string.workspace_invalid_paste) + } + item to destination + } + check(targets.map { it.second }.distinct().size == targets.size) { + context.getString(R.string.file_op_confirm_overwrite) + } + // 全批次先检查目标,避免遇到重名时已经移动了前面的文件。 + targets.forEach { (_, destination) -> requireNewDestination(destination, environment) } + for ((index, target) in targets.withIndex()) { + executionContext.ensureActive() + val (item, destination) = target + val sourceEnv = item.environment ?: "android" + val itemProgress = progress(item, index) + copyState.value = itemProgress + requireNewDestination(destination, environment) + if (item.cut && sourceEnv == destinationEnv) { + execute("move_file", parameters(environment, "source" to item.path, "destination" to destination)) + } else { + val token = FileCopyObservers.register(FileCopyObserver( + onProgress = { copyState.value = itemProgress.copy(detail = it) }, + checkCancelled = { executionContext.ensureActive() }, + )) + try { + execute("copy_file", listOf(ToolParameter("source", item.path), ToolParameter("destination", destination), + ToolParameter("source_environment", sourceEnv), ToolParameter("dest_environment", destinationEnv), + ToolParameter("recursive", item.isDirectory.toString()), ToolParameter(FileCopyObservers.PARAMETER, token))) + } finally { + FileCopyObservers.remove(token) + } + // 取消或复制失败时不删除来源;整项成功后才允许完成跨环境剪切。 + executionContext.ensureActive() + if (item.cut) { + copyState.value = checkNotNull(copyState.value).copy(finishing = true) + delete(item.path, item.environment, item.isDirectory) + } + } + if (item.cut) FileBrowserClipboard.clear(item) } - if (item.cut) FileBrowserClipboard.clear(item) + } finally { + copyState.value = null } } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/browser/transfer/WorkspaceCopyProgress.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/browser/transfer/WorkspaceCopyProgress.kt new file mode 100644 index 0000000000..d8b0c6b0ba --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/browser/transfer/WorkspaceCopyProgress.kt @@ -0,0 +1,16 @@ +package com.ai.assistance.operit.ui.features.conversation.chat.webview.workspace.browser.transfer + +import com.ai.assistance.operit.core.files.transfer.FileCopyProgress + +/** 一项可包含整个目录;批量粘贴同时显示项目序号,字节进度对应当前项目。 */ +internal data class WorkspaceCopyProgress( + val source: String, + val destination: String, + val sourceEnvironment: String, + val destinationEnvironment: String, + val itemIndex: Int, + val itemCount: Int, + val moving: Boolean, + val detail: FileCopyProgress? = null, + val finishing: Boolean = false, +) \ No newline at end of file diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/browser/transfer/WorkspaceCopyProgressDialog.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/browser/transfer/WorkspaceCopyProgressDialog.kt new file mode 100644 index 0000000000..3253b38bcd --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/browser/transfer/WorkspaceCopyProgressDialog.kt @@ -0,0 +1,102 @@ +package com.ai.assistance.operit.ui.features.conversation.chat.webview.workspace.browser.transfer + +import android.text.format.Formatter +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.ai.assistance.operit.R +import com.ai.assistance.operit.core.files.transfer.FileCopyPhase + +@Composable +internal fun WorkspaceCopyProgressDialog(progress: WorkspaceCopyProgress, cancelling: Boolean, onCancel: () -> Unit) { + val context = LocalContext.current + val detail = progress.detail + val fraction = detail?.fraction?.let { if (progress.moving || cancelling) it.coerceAtMost(0.999f) else it } + val animatedFraction by animateFloatAsState(fraction ?: 0f, tween(180), label = "workspaceCopyProgress") + val unknown = "—" + fun size(bytes: Long): String = Formatter.formatFileSize(context, bytes) + val currentFile = if (detail == null) progress.source.trimEnd('/').substringAfterLast('/') else detail.currentFile + val status = when { + cancelling -> R.string.workspace_transfer_cancelling + progress.finishing -> R.string.workspace_transfer_finishing + detail?.phase == FileCopyPhase.SCANNING -> R.string.workspace_transfer_scanning + detail == null -> R.string.workspace_transfer_processing + progress.moving -> R.string.workspace_transfer_moving + else -> R.string.workspace_transfer_copying + } + Dialog(onDismissRequest = {}, properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)) { + Surface(shape = MaterialTheme.shapes.extraLarge, color = MaterialTheme.colorScheme.surface, tonalElevation = 6.dp) { + Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + stringResource(if (progress.moving) R.string.workspace_transfer_moving else R.string.workspace_transfer_copying), + modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleLarge, + ) + if (progress.itemCount > 1) { + Text(stringResource(R.string.workspace_transfer_item, progress.itemIndex, progress.itemCount), + style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + TransferDetail(stringResource(R.string.workspace_transfer_name), currentFile) + TransferDetail(stringResource(R.string.workspace_transfer_source), "${progress.sourceEnvironment}:${progress.source}") + TransferDetail(stringResource(R.string.workspace_transfer_destination), "${progress.destinationEnvironment}:${progress.destination}") + TransferDetail(stringResource(R.string.workspace_transfer_copied), + if (detail == null) unknown else "${size(detail.copiedBytes)} / ${detail.totalBytes?.let(::size) ?: unknown}") + TransferDetail(stringResource(R.string.workspace_transfer_remaining), detail?.remainingBytes?.let(::size) ?: unknown) + TransferDetail(stringResource(R.string.workspace_transfer_speed), detail?.bytesPerSecond?.let { "${size(it.toLong())}/s" } ?: unknown) + if (detail != null) { + Text(stringResource(R.string.workspace_transfer_files, detail.copiedFiles, detail.totalFiles), + style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Spacer(Modifier.height(2.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(status), Modifier.weight(1f), style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant) + if (fraction != null) Text(stringResource(R.string.workspace_transfer_percent, (fraction * 100).toInt()), + style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) + } + val bar = Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp)) + if (fraction == null) LinearProgressIndicator(modifier = bar) + else LinearProgressIndicator(progress = { animatedFraction }, modifier = bar) + TextButton(onClick = onCancel, enabled = !cancelling, modifier = Modifier.align(Alignment.End)) { + Text(stringResource(R.string.cancel)) + } + } + } + } +} + +@Composable +private fun TransferDetail(label: String, value: String) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Text(label, Modifier.weight(0.25f), style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, Modifier.weight(0.75f), maxLines = 2, overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface) + } +} \ No newline at end of file diff --git a/app/src/main/res/values-en/strings_workspace_transfer.xml b/app/src/main/res/values-en/strings_workspace_transfer.xml new file mode 100644 index 0000000000..7e798bc680 --- /dev/null +++ b/app/src/main/res/values-en/strings_workspace_transfer.xml @@ -0,0 +1,18 @@ + + + Copying... + Moving... + Counting files... + Processing... + Removing source... + Name + From + To + Copied + Remaining + Speed + Item %1$d / %2$d · Current item progress + Files: %1$d / %2$d + %1$d%% + Cancelling... + diff --git a/app/src/main/res/values-es/strings_workspace_transfer.xml b/app/src/main/res/values-es/strings_workspace_transfer.xml new file mode 100644 index 0000000000..4065b7a347 --- /dev/null +++ b/app/src/main/res/values-es/strings_workspace_transfer.xml @@ -0,0 +1,18 @@ + + + Copiando... + Moviendo... + Contando archivos... + Procesando... + Eliminando el origen... + Nombre + Origen + Destino + Copiado + Restante + Velocidad + Elemento %1$d / %2$d · Progreso del elemento actual + Archivos: %1$d / %2$d + %1$d%% + Cancelando... + diff --git a/app/src/main/res/values-id/strings_workspace_transfer.xml b/app/src/main/res/values-id/strings_workspace_transfer.xml new file mode 100644 index 0000000000..a9ff34ac8c --- /dev/null +++ b/app/src/main/res/values-id/strings_workspace_transfer.xml @@ -0,0 +1,18 @@ + + + Menyalin... + Memindahkan... + Menghitung file... + Memproses... + Menghapus sumber... + Nama + Dari + Ke + Disalin + Tersisa + Kecepatan + Item %1$d / %2$d · Progres item saat ini + File: %1$d / %2$d + %1$d%% + Membatalkan... + diff --git a/app/src/main/res/values-ja/strings_workspace_transfer.xml b/app/src/main/res/values-ja/strings_workspace_transfer.xml new file mode 100644 index 0000000000..d458f1ca1f --- /dev/null +++ b/app/src/main/res/values-ja/strings_workspace_transfer.xml @@ -0,0 +1,18 @@ + + + コピー中... + 移動中... + ファイルを集計中... + 処理中... + コピー元を削除中... + 名前 + コピー元 + コピー先 + コピー済み + 残り + 速度 + %1$d / %2$d 項目 · 現在の項目の進捗 + ファイル:%1$d / %2$d + %1$d%% + キャンセル中... + diff --git a/app/src/main/res/values-ko/strings_workspace_transfer.xml b/app/src/main/res/values-ko/strings_workspace_transfer.xml new file mode 100644 index 0000000000..15f9a2eac9 --- /dev/null +++ b/app/src/main/res/values-ko/strings_workspace_transfer.xml @@ -0,0 +1,18 @@ + + + 복사 중... + 이동 중... + 파일 집계 중... + 처리 중... + 원본 삭제 중... + 이름 + 원본 + 대상 + 복사됨 + 남은 용량 + 속도 + 항목 %1$d / %2$d · 현재 항목 진행률 + 파일: %1$d / %2$d + %1$d%% + 취소 중... + diff --git a/app/src/main/res/values-ms/strings_workspace_transfer.xml b/app/src/main/res/values-ms/strings_workspace_transfer.xml new file mode 100644 index 0000000000..842b71f202 --- /dev/null +++ b/app/src/main/res/values-ms/strings_workspace_transfer.xml @@ -0,0 +1,18 @@ + + + Menyalin... + Memindahkan... + Mengira fail... + Memproses... + Memadam sumber... + Nama + Dari + Ke + Disalin + Baki + Kelajuan + Item %1$d / %2$d · Kemajuan item semasa + Fail: %1$d / %2$d + %1$d%% + Membatalkan... + diff --git a/app/src/main/res/values-pt-rBR/strings_workspace_transfer.xml b/app/src/main/res/values-pt-rBR/strings_workspace_transfer.xml new file mode 100644 index 0000000000..94ac601a6e --- /dev/null +++ b/app/src/main/res/values-pt-rBR/strings_workspace_transfer.xml @@ -0,0 +1,18 @@ + + + Copiando... + Movendo... + Contando arquivos... + Processando... + Removendo origem... + Nome + Origem + Destino + Copiado + Restante + Velocidade + Item %1$d / %2$d · Progresso do item atual + Arquivos: %1$d / %2$d + %1$d%% + Cancelando... + diff --git a/app/src/main/res/values-ro/strings_workspace_transfer.xml b/app/src/main/res/values-ro/strings_workspace_transfer.xml new file mode 100644 index 0000000000..096290f8da --- /dev/null +++ b/app/src/main/res/values-ro/strings_workspace_transfer.xml @@ -0,0 +1,18 @@ + + + Se copiază... + Se mută... + Se numără fișierele... + Se procesează... + Se șterge sursa... + Nume + Sursă + Destinație + Copiat + Rămas + Viteză + Element %1$d / %2$d · Progresul elementului curent + Fișiere: %1$d / %2$d + %1$d%% + Se anulează... + diff --git a/app/src/main/res/values/strings_workspace_transfer.xml b/app/src/main/res/values/strings_workspace_transfer.xml new file mode 100644 index 0000000000..47aed9510d --- /dev/null +++ b/app/src/main/res/values/strings_workspace_transfer.xml @@ -0,0 +1,18 @@ + + + 正在复制... + 正在移动... + 正在统计文件... + 正在处理... + 正在清理来源... + 名称 + 来源 + 目标 + 已复制 + 剩余 + 速度 + 第 %1$d / %2$d 项 · 当前项进度 + 文件:%1$d / %2$d + %1$d%% + 正在取消... + diff --git a/app/src/test/java/com/ai/assistance/operit/core/files/transfer/StreamingFileCopyTest.kt b/app/src/test/java/com/ai/assistance/operit/core/files/transfer/StreamingFileCopyTest.kt new file mode 100644 index 0000000000..b7d29ae84a --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/core/files/transfer/StreamingFileCopyTest.kt @@ -0,0 +1,176 @@ +package com.ai.assistance.operit.core.files.transfer + +import java.io.File +import java.io.IOException +import java.nio.file.FileAlreadyExistsException +import java.nio.file.Files +import java.nio.file.Paths +import java.security.MessageDigest +import java.util.concurrent.CancellationException +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class StreamingFileCopyTest { + @get:Rule val temporary = TemporaryFolder() + + private fun digest(file: File): ByteArray { + val digest = MessageDigest.getInstance("SHA-256") + val buffer = ByteArray(64 * 1024) + file.inputStream().use { input -> + while (true) { + val count = input.read(buffer) + if (count < 0) break + digest.update(buffer, 0, count) + } + } + return digest.digest() + } + + @Test fun binaryLargerThanFiftyMiBIsCopiedWithoutTextConversion() { + val source = temporary.newFile("binary.dat") + val destination = File(temporary.root, "result.dat") + // 测试输入自身也逐块生成,不能让测试通过分配整文件字节数组掩盖问题。 + val pattern = ByteArray(256 * 1024) { ((it * 37 + 129) % 256).toByte() } + source.outputStream().use { output -> repeat(256) { output.write(pattern) } } + val progress = mutableListOf() + val result = StreamingFileCopy.copy(source, destination, onProgress = { progress += it }) + assertEquals(64L * 1024 * 1024, destination.length()) + assertArrayEquals(digest(source), digest(destination)) + assertEquals(source.length(), result.copiedBytes) + assertEquals(source.length(), result.totalBytes) + assertEquals(FileCopyPhase.SCANNING, progress.first().phase) + assertEquals(null, progress.first().totalBytes) + assertEquals(FileCopyPhase.COMPLETED, progress.last().phase) + assertEquals(1f, result.fraction) + assertTrue(progress.filter { it.phase != FileCopyPhase.COMPLETED }.all { it.fraction != 1f }) + } + + @Test fun directoryIncludesHiddenFilesEmptyDirectoriesAndZeroByteFiles() { + val source = temporary.newFolder("source") + File(source, ".git/objects").mkdirs() + File(source, ".git/objects/binary").writeBytes(byteArrayOf(0, -1, -2, 10)) + File(source, "empty").mkdir() + File(source, "zero").createNewFile() + repeat(80) { File(source, "item-$it").writeText("内容$it") } + val destination = File(temporary.root, "copied") + val result = StreamingFileCopy.copy(source, destination) + assertTrue(File(destination, "empty").isDirectory) + assertTrue(File(destination, "zero").isFile) + assertArrayEquals(byteArrayOf(0, -1, -2, 10), File(destination, ".git/objects/binary").readBytes()) + repeat(80) { assertEquals("内容$it", File(destination, "item-$it").readText()) } + assertEquals(82L, result.copiedFiles) + assertEquals(0L, result.remainingBytes) + } + + @Test fun symbolicDirectoryLinksAreCopiedWithoutFollowingTheirTargets() { + val source = temporary.newFolder("links") + File(source, "data").writeBytes(byteArrayOf(1, 2, 3)) + Files.createSymbolicLink(File(source, "loop").toPath(), Paths.get(".")) + val destination = File(temporary.root, "links-copy") + val result = StreamingFileCopy.copy(source, destination) + assertEquals(Paths.get("."), Files.readSymbolicLink(File(destination, "loop").toPath())) + assertEquals(2L, result.copiedFiles) + assertEquals(3L, result.copiedBytes) + } + + @Test fun samePathDescendantAndAliasedDescendantAreRejectedBeforeWriting() { + val source = temporary.newFolder("parent") + File(source, "child").mkdir() + assertThrows(IllegalArgumentException::class.java) { StreamingFileCopy.copy(source, source) } + val descendant = File(source, "child/../copy") + assertThrows(IllegalArgumentException::class.java) { StreamingFileCopy.copy(source, descendant) } + val alias = File(temporary.root, "alias") + Files.createSymbolicLink(alias.toPath(), source.toPath()) + assertThrows(IllegalArgumentException::class.java) { StreamingFileCopy.copy(source, File(alias, "copy")) } + assertFalse(File(source, "copy").exists()) + } + + @Test fun existingDestinationIsProtectedAndExplicitOverwriteTruncatesIt() { + val source = temporary.newFile("new-data").apply { writeBytes(byteArrayOf(0, -1)) } + val destination = temporary.newFile("old-data").apply { writeText("original content") } + assertThrows(FileAlreadyExistsException::class.java) { StreamingFileCopy.copy(source, destination) } + assertEquals("original content", destination.readText()) + StreamingFileCopy.copy(source, destination, overwrite = true) + assertArrayEquals(byteArrayOf(0, -1), destination.readBytes()) + } + + @Test fun failedDirectoryCopyDoesNotReportCompletionOrRemoveItsSource() { + val source = temporary.newFolder("failure-source") + File(source, "data").writeText("保留来源") + val blockedParent = temporary.newFile("blocked") + val progress = mutableListOf() + assertThrows(IOException::class.java) { + StreamingFileCopy.copy(source, File(blockedParent, "result"), onProgress = { progress += it }) + } + assertEquals("保留来源", File(source, "data").readText()) + assertTrue(progress.none { it.phase == FileCopyPhase.COMPLETED }) + } + + @Test fun cancellationDuringAFileStopsBeforeCompletionAndRetainsSource() { + val source = temporary.newFile("cancel-source") + val buffer = ByteArray(StreamingFileCopy.BUFFER_SIZE) { 31 } + source.outputStream().use { output -> repeat(4) { output.write(buffer) } } + val destination = File(temporary.root, "cancel-target") + val progress = mutableListOf() + assertThrows(CancellationException::class.java) { + StreamingFileCopy.copy(source, destination, onProgress = { progress += it }, checkCancelled = { + if (destination.length() >= StreamingFileCopy.BUFFER_SIZE) throw CancellationException("取消复制") + }) + } + assertEquals(4L * StreamingFileCopy.BUFFER_SIZE, source.length()) + assertTrue(destination.length() < source.length()) + assertTrue(progress.none { it.phase == FileCopyPhase.COMPLETED }) + } + + @Test fun sourceTreeChangesAreReportedAsFailureRatherThanCompleteProgress() { + val source = temporary.newFolder("changing-source") + File(source, "first").writeText("first") + val destination = File(temporary.root, "changing-target") + var changed = false + val progress = mutableListOf() + assertThrows(IOException::class.java) { + StreamingFileCopy.copy(source, destination, onProgress = { + progress += it + if (!changed && it.phase == FileCopyPhase.COPYING) { + changed = true + File(source, "added").writeText("added") + } + }) + } + assertTrue(source.exists()) + assertTrue(progress.none { it.phase == FileCopyPhase.COMPLETED }) + } + + @Test fun destinationCreatedDuringScanningIsNotMergedOrOverwritten() { + val source = temporary.newFolder("conflict-source") + File(source, "data").writeText("source") + val destination = File(temporary.root, "conflict-target") + assertThrows(FileAlreadyExistsException::class.java) { + StreamingFileCopy.copy(source, destination, onProgress = { + if (it.phase == FileCopyPhase.COPYING && !destination.exists()) { + destination.mkdir() + File(destination, "existing").writeText("保留目标") + } + }) + } + assertEquals("保留目标", File(destination, "existing").readText()) + assertFalse(File(destination, "data").exists()) + } + + @Test fun emptyDirectoryCompletesWithZeroBytesAndRecursiveFlagIsEnforced() { + val source = temporary.newFolder("empty-source") + val destination = File(temporary.root, "empty-target") + assertThrows(IllegalArgumentException::class.java) { StreamingFileCopy.copy(source, destination, recursive = false) } + assertFalse(destination.exists()) + val result = StreamingFileCopy.copy(source, destination) + assertTrue(destination.isDirectory) + assertEquals(0L, result.copiedBytes) + assertEquals(1f, result.fraction) + } +} \ No newline at end of file diff --git a/docs/TODO/fix/README.md b/docs/TODO/fix/README.md index 46c524e841..17b32a4175 100644 --- a/docs/TODO/fix/README.md +++ b/docs/TODO/fix/README.md @@ -6,6 +6,8 @@ ## 计划索引 +- [工作区复制内存与进度](workspace-copy-streaming-progress/index.md) + - [流式气泡停更修复](streaming-bubble-freeze/index.md) - [提示、插件配置入口与 Waifu 首次展示](ux-stream-ui-followup/index.md) - [语音条 WebView 挂载焦点修复](toolpkg-webview-focus/index.md) · `toolpkg-webview-focus` diff --git a/docs/TODO/fix/workspace-copy-streaming-progress/01-validation.md b/docs/TODO/fix/workspace-copy-streaming-progress/01-validation.md new file mode 100644 index 0000000000..c0bbd20ea4 --- /dev/null +++ b/docs/TODO/fix/workspace-copy-streaming-progress/01-validation.md @@ -0,0 +1,61 @@ +# 工作区复制验证 + +## 已完成的静态核对 + +- `git diff --check` 通过 +- 9 组新增语言资源 XML 均能解析,每组包含 15 个界面条目 +- 核对复制入口继续经过既有文件工具及权限路由,本机传输复用终端挂载映射 +- 核对传输缓冲区为任务级复用的 256 KiB,目录遍历不缓存完整文件清单 +- 核对界面通过单次调用令牌接收进度,结束、失败时均移除观察者并清空面板状态 + +未执行本地编译、构建或测试。新增 10 条 JVM 回归用例由仓库 Android Tests 运行,远端结果以对应工作流为准;Android 实机内存峰值和 UI 尚未测量。 + +## 回归范围 + +| 编号 | 做什么 | 预期行为 | +| --- | --- | --- | +| A1 | 复制 64 MiB 二进制文件并流式计算 SHA-256 | 大小与摘要一致;统计阶段总量未知,成功完成后才出现 100% | +| A2 | 复制含隐藏文件、80 个小文件、空目录和零字节文件的目录 | 内容和目录齐全,文件数及剩余字节正确 | +| A3 | 复制带指向自身目录链接的目录 | 复制链接本身,不遍历链接目标 | +| A4 | 复制到自身、含 `..` 的子目录及别名子目录 | 写入前拒绝,不创建目标 | +| A5 | 复制到已存在文件并分别关闭、开启覆盖 | 关闭时保留目标,开启时正确截断并写入 | +| A6 | 将目录复制到无法创建文件的目标 | 返回失败,不报告完成,来源保留 | +| A7 | 在文件传输中取消调用 | 及时停止,不报告完成,来源保留 | +| A8 | 在统计完成后增加来源文件 | 来源变更导致失败,不报告完整成功 | +| A9 | 在统计完成后创建同名目标目录 | 不合并或覆盖刚出现的目标 | +| A10 | 复制空目录并检查递归开关 | 关闭递归时拒绝;开启时以零字节完整完成 | +| M1 | 将 `/root/Operit` 从本机 Linux 复制到手机目录,再反向复制大文件 | 原始字节保持一致,不出现整文件字符串或字节数组累积 | +| M2 | 在 Linux 使用 `~/`、`/sdcard` 和宿主挂载路径复制 | 映射与终端一致,同一实际位置仍被拒绝 | +| M3 | 单选、全选、多选和跨目录粘贴 | 全批次预检重名;多项显示序号及当前项目进度 | +| M4 | 跨环境剪切,并制造目标写入失败 | 对应项完整复制成功后才删除来源;未完成的剪切项留在剪贴板 | +| M5 | 在标准、管理员和 Root 模式执行既有文件操作 | 权限后端沿用既有路由;Shell 路径没有字节数据时显示处理中 | +| M6 | 使用远程 SSH 和 SAF 粘贴 | 使用既有提供方;未知总量不显示虚构字节数或百分比 | +| M7 | 在深浅主题、大字体及横屏查看复制面板 | 标题居中、路径可辨认、百分比位于 6dp 进度条右上方;超高内容可滚动 | +| M8 | 成功、失败、点击取消和离开文件浏览器 | 面板与观察者正确结束,错误仍显示在文件浏览器中 | + +复制链接到不支持符号链接的目标存储会明确失败并保留来源。失败时已经写入的部分目标保留,不自动删除其他文件。扫描与复制期间持续修改来源不会构成目录快照。 + +## 结果记录 + +自动用例尚未运行,实机项尚未验证。下表用于填写实际结果。 + +| 编号 | 结果与证据 | +| --- | --- | +| A1 | | +| A2 | | +| A3 | | +| A4 | | +| A5 | | +| A6 | | +| A7 | | +| A8 | | +| A9 | | +| A10 | | +| M1 | | +| M2 | | +| M3 | | +| M4 | | +| M5 | | +| M6 | | +| M7 | | +| M8 | | \ No newline at end of file diff --git a/docs/TODO/fix/workspace-copy-streaming-progress/index.md b/docs/TODO/fix/workspace-copy-streaming-progress/index.md new file mode 100644 index 0000000000..4a7a37abf7 --- /dev/null +++ b/docs/TODO/fix/workspace-copy-streaming-progress/index.md @@ -0,0 +1,25 @@ +--- +fork: https://github.com/3316891527/Operit +status: implemented-awaiting-ci +--- + +# 工作区复制内存与进度 + +工作区将内置 Ubuntu 文件复制到手机目录时,跨环境实现会完整读取文件、经过字符串转换,反向复制还会累积完整字节数组。目录递归会忽略子项失败,粘贴界面只显示旋转圆环。 + +本批优化本机 Android 与内置 Ubuntu 的文件复制,保持公开工具参数、权限路由和覆盖规则。传输逻辑放入 `core/files/transfer/`,不修改终端子模块、应用身份或数据库。 + +- [x] 使用固定缓冲区复制二进制,逐层遍历目录,不保留完整目录清单 +- [x] 使用终端现有挂载映射处理 Ubuntu、手机共享目录及 `~` +- [x] 为单次粘贴隔离进度,显示当前文件、来源、目标、已复制量、剩余量、速度及百分比 +- [x] 统计期间显示明确状态,已知总量后显示确定进度;完成前不显示 100% +- [x] 子项失败立即返回失败,剪切来源只在对应项完整成功后删除 +- [x] 补充二进制大文件、隐藏文件、空目录、同路径、符号链接及失败/取消的 10 条 JVM 回归用例 +- [x] 完成差异空白、XML 和调用关系的静态核对 +- [ ] 远端构建与测试结果确认 + +远程 SSH、SAF 和独立 Shell 复制继续使用各自原有工具实现;只有后端提供真实字节进度时才显示字节百分比,否则显示当前项目和处理中状态。不会以经过时间推算百分比。 + +完整范围和可填写结果见[验证记录](01-validation.md)。本地未编译、构建或执行测试;CI 与 PR 交付到独立工作分支,目标为 `dev`。 + +[DONE] 实现与静态核对,编译、测试和实机结果仍待验证。 From 9b9bb75d93b96b72204067fa576db30e5279d960 Mon Sep 17 00:00:00 2001 From: kong <135376906+3316891527@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:00:42 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(workspace):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E5=8D=95=E9=A1=B9=E5=92=8C=E6=89=B9=E9=87=8F=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/webview/workspace/FileManager.kt | 19 ++- .../browser/WorkspaceBrowserOperations.kt | 23 ++- .../transfer/WorkspaceCopyProgressDialog.kt | 96 +++--------- .../transfer/WorkspaceDeleteProgress.kt | 51 +++++++ .../transfer/WorkspaceDeleteProgressDialog.kt | 28 ++++ .../transfer/WorkspaceFileOperationDialog.kt | 79 ++++++++++ .../values-en/strings_workspace_transfer.xml | 4 + .../values-es/strings_workspace_transfer.xml | 4 + .../values-id/strings_workspace_transfer.xml | 4 + .../values-ja/strings_workspace_transfer.xml | 4 + .../values-ko/strings_workspace_transfer.xml | 4 + .../values-ms/strings_workspace_transfer.xml | 4 + .../strings_workspace_transfer.xml | 4 + .../values-ro/strings_workspace_transfer.xml | 4 + .../res/values/strings_workspace_transfer.xml | 4 + .../transfer/WorkspaceDeleteOperationTest.kt | 141 ++++++++++++++++++ docs/TODO/fix/README.md | 2 +- .../01-validation.md | 37 ++++- .../index.md | 9 +- 19 files changed, 430 insertions(+), 91 deletions(-) create mode 100644 app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/browser/transfer/WorkspaceDeleteProgress.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/browser/transfer/WorkspaceDeleteProgressDialog.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/browser/transfer/WorkspaceFileOperationDialog.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/browser/transfer/WorkspaceDeleteOperationTest.kt diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/FileManager.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/FileManager.kt index 2d60b018af..d7965608b7 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/FileManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/FileManager.kt @@ -69,6 +69,7 @@ import com.ai.assistance.operit.ui.features.conversation.chat.webview.workspace. import com.ai.assistance.operit.util.files.FileUtils import com.ai.assistance.operit.ui.features.conversation.chat.webview.workspace.browser.* import com.ai.assistance.operit.ui.features.conversation.chat.webview.workspace.browser.transfer.WorkspaceCopyProgressDialog +import com.ai.assistance.operit.ui.features.conversation.chat.webview.workspace.browser.transfer.WorkspaceDeleteProgressDialog import com.ai.assistance.operit.ui.features.conversation.chat.webview.workspace.browser.FileBrowserClipboard import com.ai.assistance.operit.ui.features.conversation.chat.webview.workspace.browser.FileBrowserClipboardItem import com.ai.assistance.operit.ui.features.conversation.chat.webview.workspace.browser.FileBrowserContextMenu @@ -147,6 +148,7 @@ fun FileBrowser( val operations = remember(context) { FileBrowserOperations(context) } val clipboard by FileBrowserClipboard.content.collectAsState() val copyProgress by operations.copyProgress.collectAsState() + val deleteProgress by operations.deleteProgress.collectAsState() var showSortMenu by remember { mutableStateOf(false) } var selectionMode by rememberSaveable(currentPath, currentEnvironment) { mutableStateOf(false) } var selectedNames by rememberSaveable(currentPath, currentEnvironment) { mutableStateOf(emptySet()) } @@ -481,11 +483,12 @@ fun FileBrowser( TextButton(onClick = { pendingBatchDelete = null runOperation { - for (target in targets) { - operations.delete(target.path, target.environment, target.isDirectory) - selectedNames = selectedNames - target.path.substringAfterLast('/') + operations.delete(targets) { target -> + withContext(Dispatchers.Main) { + selectedNames = selectedNames - target.path.substringAfterLast('/') + if (selectedNames.isEmpty()) selectionMode = false + } } - if (selectedNames.isEmpty()) selectionMode = false } }, enabled = !isLoading) { Text(stringResource(R.string.file_manager_delete), color = MaterialTheme.colorScheme.error) } }, @@ -672,6 +675,12 @@ fun FileBrowser( }) } } + deleteProgress?.let { progress -> + WorkspaceDeleteProgressDialog(progress, cancelling = cancellingFileOperation, onCancel = { + cancellingFileOperation = true + fileOperationJob?.cancel() + }) + } propertiesTarget?.let { target -> WorkspacePropertiesDialog(target, onDismiss = { propertiesTarget = null }, @@ -905,7 +914,7 @@ fun FileBrowser( } // 文件列表 - if (isLoading && copyProgress == null) { + if (isLoading && copyProgress == null && deleteProgress == null) { Box( modifier = Modifier.fillMaxSize().weight(1f), contentAlignment = Alignment.Center diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/browser/WorkspaceBrowserOperations.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/browser/WorkspaceBrowserOperations.kt index 765626d03c..0dad1cdc14 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/browser/WorkspaceBrowserOperations.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/browser/WorkspaceBrowserOperations.kt @@ -8,6 +8,7 @@ import com.ai.assistance.operit.R import com.ai.assistance.operit.core.files.transfer.FileCopyObserver import com.ai.assistance.operit.core.files.transfer.FileCopyObservers import com.ai.assistance.operit.ui.features.conversation.chat.webview.workspace.browser.transfer.WorkspaceCopyProgress +import com.ai.assistance.operit.ui.features.conversation.chat.webview.workspace.browser.transfer.WorkspaceDeleteOperation import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow @@ -28,6 +29,10 @@ internal class FileBrowserOperations(context: Context) { private val handler = AIToolHandler.getInstance(this.context) private val copyState = MutableStateFlow(null) val copyProgress = copyState.asStateFlow() + private val deletion = WorkspaceDeleteOperation { target -> + deleteEntry(target.path, target.environment, target.isDirectory) + } + val deleteProgress = deletion.progress private fun parameters(environment: String?, vararg values: Pair) = buildList { values.forEach { (name, value) -> add(ToolParameter(name, value)) } @@ -51,9 +56,18 @@ internal class FileBrowserOperations(context: Context) { Unit } - suspend fun delete(path: String, environment: String?, directory: Boolean) = withContext(Dispatchers.IO) { + private fun deleteEntry(path: String, environment: String?, directory: Boolean) { execute("delete_file", parameters(environment, "path" to path, "recursive" to directory.toString())) - Unit + } + + suspend fun delete(path: String, environment: String?, directory: Boolean): Unit = + delete(listOf(FileBrowserClipboardItem(path, environment, cut = false, isDirectory = directory))) + + suspend fun delete( + targets: List, + onDeleted: suspend (FileBrowserClipboardItem) -> Unit = {}, + ): Unit = withContext(Dispatchers.IO) { + deletion.run(targets, onDeleted) } suspend fun paste(directory: String, environment: String?) = withContext(Dispatchers.IO) { @@ -115,7 +129,8 @@ internal class FileBrowserOperations(context: Context) { executionContext.ensureActive() if (item.cut) { copyState.value = checkNotNull(copyState.value).copy(finishing = true) - delete(item.path, item.environment, item.isDirectory) + // 剪切收尾仍属于移动任务,不弹出第二个删除进度窗口。 + deleteEntry(item.path, item.environment, item.isDirectory) } } if (item.cut) FileBrowserClipboard.clear(item) @@ -150,4 +165,4 @@ internal class FileBrowserOperations(context: Context) { context.startActivity(Intent.createChooser(intent, null).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/browser/transfer/WorkspaceCopyProgressDialog.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/browser/transfer/WorkspaceCopyProgressDialog.kt index 3253b38bcd..34265e85aa 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/browser/transfer/WorkspaceCopyProgressDialog.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/browser/transfer/WorkspaceCopyProgressDialog.kt @@ -1,35 +1,11 @@ package com.ai.assistance.operit.ui.features.conversation.chat.webview.workspace.browser.transfer import android.text.format.Formatter -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties import com.ai.assistance.operit.R import com.ai.assistance.operit.core.files.transfer.FileCopyPhase @@ -37,66 +13,38 @@ import com.ai.assistance.operit.core.files.transfer.FileCopyPhase internal fun WorkspaceCopyProgressDialog(progress: WorkspaceCopyProgress, cancelling: Boolean, onCancel: () -> Unit) { val context = LocalContext.current val detail = progress.detail - val fraction = detail?.fraction?.let { if (progress.moving || cancelling) it.coerceAtMost(0.999f) else it } - val animatedFraction by animateFloatAsState(fraction ?: 0f, tween(180), label = "workspaceCopyProgress") + val fraction = detail?.fraction?.let { if (progress.moving) it.coerceAtMost(0.999f) else it } val unknown = "—" fun size(bytes: Long): String = Formatter.formatFileSize(context, bytes) val currentFile = if (detail == null) progress.source.trimEnd('/').substringAfterLast('/') else detail.currentFile val status = when { - cancelling -> R.string.workspace_transfer_cancelling progress.finishing -> R.string.workspace_transfer_finishing detail?.phase == FileCopyPhase.SCANNING -> R.string.workspace_transfer_scanning detail == null -> R.string.workspace_transfer_processing progress.moving -> R.string.workspace_transfer_moving else -> R.string.workspace_transfer_copying } - Dialog(onDismissRequest = {}, properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)) { - Surface(shape = MaterialTheme.shapes.extraLarge, color = MaterialTheme.colorScheme.surface, tonalElevation = 6.dp) { - Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - stringResource(if (progress.moving) R.string.workspace_transfer_moving else R.string.workspace_transfer_copying), - modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleLarge, - ) - if (progress.itemCount > 1) { - Text(stringResource(R.string.workspace_transfer_item, progress.itemIndex, progress.itemCount), - style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - TransferDetail(stringResource(R.string.workspace_transfer_name), currentFile) - TransferDetail(stringResource(R.string.workspace_transfer_source), "${progress.sourceEnvironment}:${progress.source}") - TransferDetail(stringResource(R.string.workspace_transfer_destination), "${progress.destinationEnvironment}:${progress.destination}") - TransferDetail(stringResource(R.string.workspace_transfer_copied), - if (detail == null) unknown else "${size(detail.copiedBytes)} / ${detail.totalBytes?.let(::size) ?: unknown}") - TransferDetail(stringResource(R.string.workspace_transfer_remaining), detail?.remainingBytes?.let(::size) ?: unknown) - TransferDetail(stringResource(R.string.workspace_transfer_speed), detail?.bytesPerSecond?.let { "${size(it.toLong())}/s" } ?: unknown) - if (detail != null) { - Text(stringResource(R.string.workspace_transfer_files, detail.copiedFiles, detail.totalFiles), - style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - Spacer(Modifier.height(2.dp)) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(status), Modifier.weight(1f), style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant) - if (fraction != null) Text(stringResource(R.string.workspace_transfer_percent, (fraction * 100).toInt()), - style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) - } - val bar = Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp)) - if (fraction == null) LinearProgressIndicator(modifier = bar) - else LinearProgressIndicator(progress = { animatedFraction }, modifier = bar) - TextButton(onClick = onCancel, enabled = !cancelling, modifier = Modifier.align(Alignment.End)) { - Text(stringResource(R.string.cancel)) - } - } + WorkspaceFileOperationDialog( + title = stringResource(if (progress.moving) R.string.workspace_transfer_moving else R.string.workspace_transfer_copying), + status = stringResource(status), + fraction = fraction, + cancelling = cancelling, + onCancel = onCancel, + ) { + if (progress.itemCount > 1) { + Text(stringResource(R.string.workspace_transfer_item, progress.itemIndex, progress.itemCount), + style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + WorkspaceOperationDetail(stringResource(R.string.workspace_transfer_name), currentFile) + WorkspaceOperationDetail(stringResource(R.string.workspace_transfer_source), "${progress.sourceEnvironment}:${progress.source}") + WorkspaceOperationDetail(stringResource(R.string.workspace_transfer_destination), "${progress.destinationEnvironment}:${progress.destination}") + WorkspaceOperationDetail(stringResource(R.string.workspace_transfer_copied), + if (detail == null) unknown else "${size(detail.copiedBytes)} / ${detail.totalBytes?.let(::size) ?: unknown}") + WorkspaceOperationDetail(stringResource(R.string.workspace_transfer_remaining), detail?.remainingBytes?.let(::size) ?: unknown) + WorkspaceOperationDetail(stringResource(R.string.workspace_transfer_speed), detail?.bytesPerSecond?.let { "${size(it.toLong())}/s" } ?: unknown) + if (detail != null) { + Text(stringResource(R.string.workspace_transfer_files, detail.copiedFiles, detail.totalFiles), + style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } } } - -@Composable -private fun TransferDetail(label: String, value: String) { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Text(label, Modifier.weight(0.25f), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) - Text(value, Modifier.weight(0.75f), maxLines = 2, overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/browser/transfer/WorkspaceDeleteProgress.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/browser/transfer/WorkspaceDeleteProgress.kt new file mode 100644 index 0000000000..9d94c1ec07 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/browser/transfer/WorkspaceDeleteProgress.kt @@ -0,0 +1,51 @@ +package com.ai.assistance.operit.ui.features.conversation.chat.webview.workspace.browser.transfer + +import com.ai.assistance.operit.ui.features.conversation.chat.webview.workspace.browser.FileBrowserClipboardItem +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** 只按所选项目统计,目录内部没有上报时不能把项目比例冒充文件或字节进度。 */ +internal data class WorkspaceDeleteProgress( + val target: FileBrowserClipboardItem, + val completedItems: Int, + val totalItems: Int, +) { + val fraction: Float? + get() = when { + completedItems == totalItems -> 1f + totalItems > 1 -> completedItems.toFloat() / totalItems + else -> null + } +} + +/** 删除动作由既有文件工具执行;这里只管理进度、逐项成功通知和停止后续项。 */ +internal class WorkspaceDeleteOperation( + private val deleteItem: suspend (FileBrowserClipboardItem) -> Unit, +) { + private val state = MutableStateFlow(null) + val progress = state.asStateFlow() + + suspend fun run( + targets: List, + onDeleted: suspend (FileBrowserClipboardItem) -> Unit = {}, + ) { + if (targets.isEmpty()) return + val executionContext = currentCoroutineContext() + try { + for ((index, target) in targets.withIndex()) { + executionContext.ensureActive() + val current = WorkspaceDeleteProgress(target, index, targets.size) + state.value = current + deleteItem(target) + state.value = current.copy(completedItems = index + 1) + // 当前工具返回成功才更新选择;失败不计入完成,也不开始下一个项目。 + onDeleted(target) + executionContext.ensureActive() + } + } finally { + state.value = null + } + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/browser/transfer/WorkspaceDeleteProgressDialog.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/browser/transfer/WorkspaceDeleteProgressDialog.kt new file mode 100644 index 0000000000..e9eb01a54e --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/browser/transfer/WorkspaceDeleteProgressDialog.kt @@ -0,0 +1,28 @@ +package com.ai.assistance.operit.ui.features.conversation.chat.webview.workspace.browser.transfer + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.ai.assistance.operit.R + +@Composable +internal fun WorkspaceDeleteProgressDialog(progress: WorkspaceDeleteProgress, cancelling: Boolean, onCancel: () -> Unit) { + val target = progress.target + WorkspaceFileOperationDialog( + title = stringResource(R.string.workspace_transfer_deleting), + status = stringResource(R.string.workspace_transfer_deleting), + fraction = progress.fraction, + cancelling = cancelling, + onCancel = onCancel, + ) { + WorkspaceOperationDetail(stringResource(R.string.workspace_transfer_name), target.path.trimEnd('/').substringAfterLast('/')) + WorkspaceOperationDetail(stringResource(R.string.workspace_transfer_source), "${target.environment ?: "android"}:${target.path}") + Text(stringResource(R.string.workspace_transfer_deleted_items, progress.completedItems, progress.totalItems), + style = MaterialTheme.typography.bodyMedium) + Text(stringResource(R.string.workspace_transfer_delete_scope), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(stringResource(R.string.workspace_transfer_delete_cancel_note), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/browser/transfer/WorkspaceFileOperationDialog.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/browser/transfer/WorkspaceFileOperationDialog.kt new file mode 100644 index 0000000000..7558e364b3 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/browser/transfer/WorkspaceFileOperationDialog.kt @@ -0,0 +1,79 @@ +package com.ai.assistance.operit.ui.features.conversation.chat.webview.workspace.browser.transfer + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.ai.assistance.operit.R + +/** 复制、移动和删除共用外观;只有调用方提供真实比例时才显示百分比。 */ +@Composable +internal fun WorkspaceFileOperationDialog( + title: String, + status: String, + fraction: Float?, + cancelling: Boolean, + onCancel: () -> Unit, + content: @Composable ColumnScope.() -> Unit, +) { + val visibleFraction = fraction?.let { if (cancelling) it.coerceAtMost(0.999f) else it } + val animatedFraction by animateFloatAsState(visibleFraction ?: 0f, tween(180), label = "workspaceFileOperationProgress") + Dialog(onDismissRequest = {}, properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)) { + Surface(shape = MaterialTheme.shapes.extraLarge, color = MaterialTheme.colorScheme.surface, tonalElevation = 6.dp) { + Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(title, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleLarge) + content() + Spacer(Modifier.height(2.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text(if (cancelling) stringResource(R.string.workspace_transfer_cancelling) else status, + Modifier.weight(1f), style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant) + if (visibleFraction != null) Text(stringResource(R.string.workspace_transfer_percent, (visibleFraction * 100).toInt()), + style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) + } + val bar = Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp)) + if (visibleFraction == null) LinearProgressIndicator(modifier = bar) + else LinearProgressIndicator(progress = { animatedFraction }, modifier = bar) + TextButton(onClick = onCancel, enabled = !cancelling, modifier = Modifier.align(Alignment.End)) { + Text(stringResource(R.string.cancel)) + } + } + } + } +} + +@Composable +internal fun WorkspaceOperationDetail(label: String, value: String) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Text(label, Modifier.weight(0.25f), style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, Modifier.weight(0.75f), maxLines = 2, overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface) + } +} diff --git a/app/src/main/res/values-en/strings_workspace_transfer.xml b/app/src/main/res/values-en/strings_workspace_transfer.xml index 7e798bc680..5c2bf61d9c 100644 --- a/app/src/main/res/values-en/strings_workspace_transfer.xml +++ b/app/src/main/res/values-en/strings_workspace_transfer.xml @@ -15,4 +15,8 @@ Files: %1$d / %2$d %1$d%% Cancelling... + Deleting... + Deleted %1$d / %2$d items + Progress counts selected items, not files inside each directory. + Cancel stops subsequent items after the current deletion returns. Deleted content is not restored. diff --git a/app/src/main/res/values-es/strings_workspace_transfer.xml b/app/src/main/res/values-es/strings_workspace_transfer.xml index 4065b7a347..7f793effe2 100644 --- a/app/src/main/res/values-es/strings_workspace_transfer.xml +++ b/app/src/main/res/values-es/strings_workspace_transfer.xml @@ -15,4 +15,8 @@ Archivos: %1$d / %2$d %1$d%% Cancelando... + Eliminando... + %1$d / %2$d elementos eliminados + El progreso cuenta los elementos seleccionados, no los archivos de cada carpeta. + Cancelar detiene los siguientes elementos al terminar la eliminación actual. El contenido eliminado no se restaura. diff --git a/app/src/main/res/values-id/strings_workspace_transfer.xml b/app/src/main/res/values-id/strings_workspace_transfer.xml index a9ff34ac8c..af2f292f31 100644 --- a/app/src/main/res/values-id/strings_workspace_transfer.xml +++ b/app/src/main/res/values-id/strings_workspace_transfer.xml @@ -15,4 +15,8 @@ File: %1$d / %2$d %1$d%% Membatalkan... + Menghapus... + %1$d / %2$d item dihapus + Progres menghitung item yang dipilih, bukan file di dalam setiap folder. + Pembatalan menghentikan item berikutnya setelah penghapusan saat ini selesai. Konten yang dihapus tidak dipulihkan. diff --git a/app/src/main/res/values-ja/strings_workspace_transfer.xml b/app/src/main/res/values-ja/strings_workspace_transfer.xml index d458f1ca1f..7775728275 100644 --- a/app/src/main/res/values-ja/strings_workspace_transfer.xml +++ b/app/src/main/res/values-ja/strings_workspace_transfer.xml @@ -15,4 +15,8 @@ ファイル:%1$d / %2$d %1$d%% キャンセル中... + 削除中... + %1$d / %2$d 項目を削除済み + 進捗は選択した項目数です。各フォルダー内の進捗は不明です。 + キャンセルすると現在の削除処理が戻った後に次の項目を停止します。削除済みの内容は復元されません。 diff --git a/app/src/main/res/values-ko/strings_workspace_transfer.xml b/app/src/main/res/values-ko/strings_workspace_transfer.xml index 15f9a2eac9..2fe0e21a47 100644 --- a/app/src/main/res/values-ko/strings_workspace_transfer.xml +++ b/app/src/main/res/values-ko/strings_workspace_transfer.xml @@ -15,4 +15,8 @@ 파일: %1$d / %2$d %1$d%% 취소 중... + 삭제 중... + %1$d / %2$d개 항목 삭제됨 + 진행률은 선택한 항목 기준이며 각 폴더 내부의 진행률은 알 수 없습니다. + 취소하면 현재 삭제가 반환된 후 다음 항목부터 중지합니다. 이미 삭제된 내용은 복원되지 않습니다. diff --git a/app/src/main/res/values-ms/strings_workspace_transfer.xml b/app/src/main/res/values-ms/strings_workspace_transfer.xml index 842b71f202..bcf1415ce8 100644 --- a/app/src/main/res/values-ms/strings_workspace_transfer.xml +++ b/app/src/main/res/values-ms/strings_workspace_transfer.xml @@ -15,4 +15,8 @@ Fail: %1$d / %2$d %1$d%% Membatalkan... + Memadam... + %1$d / %2$d item dipadam + Kemajuan mengira item yang dipilih, bukan fail dalam setiap folder. + Pembatalan menghentikan item seterusnya selepas pemadaman semasa selesai. Kandungan yang dipadam tidak dipulihkan. diff --git a/app/src/main/res/values-pt-rBR/strings_workspace_transfer.xml b/app/src/main/res/values-pt-rBR/strings_workspace_transfer.xml index 94ac601a6e..ea8dc5b9ba 100644 --- a/app/src/main/res/values-pt-rBR/strings_workspace_transfer.xml +++ b/app/src/main/res/values-pt-rBR/strings_workspace_transfer.xml @@ -15,4 +15,8 @@ Arquivos: %1$d / %2$d %1$d%% Cancelando... + Excluindo... + %1$d / %2$d itens excluídos + O progresso conta os itens selecionados, não os arquivos de cada pasta. + Cancelar interrompe os próximos itens após a exclusão atual retornar. O conteúdo excluído não é restaurado. diff --git a/app/src/main/res/values-ro/strings_workspace_transfer.xml b/app/src/main/res/values-ro/strings_workspace_transfer.xml index 096290f8da..684a77b435 100644 --- a/app/src/main/res/values-ro/strings_workspace_transfer.xml +++ b/app/src/main/res/values-ro/strings_workspace_transfer.xml @@ -15,4 +15,8 @@ Fișiere: %1$d / %2$d %1$d%% Se anulează... + Se șterge... + %1$d / %2$d elemente șterse + Progresul numără elementele selectate, nu fișierele din fiecare dosar. + Anularea oprește elementele următoare după încheierea ștergerii curente. Conținutul șters nu este restaurat. diff --git a/app/src/main/res/values/strings_workspace_transfer.xml b/app/src/main/res/values/strings_workspace_transfer.xml index 47aed9510d..aae17048c3 100644 --- a/app/src/main/res/values/strings_workspace_transfer.xml +++ b/app/src/main/res/values/strings_workspace_transfer.xml @@ -15,4 +15,8 @@ 文件:%1$d / %2$d %1$d%% 正在取消... + 正在删除... + 已删除 %1$d / %2$d 项 + 进度按所选项目计数,单个目录内部的进度未知。 + 取消将在当前删除返回后停止后续项目,已删除内容不会恢复。 diff --git a/app/src/test/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/browser/transfer/WorkspaceDeleteOperationTest.kt b/app/src/test/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/browser/transfer/WorkspaceDeleteOperationTest.kt new file mode 100644 index 0000000000..339d586fb0 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/ui/features/conversation/chat/webview/workspace/browser/transfer/WorkspaceDeleteOperationTest.kt @@ -0,0 +1,141 @@ +package com.ai.assistance.operit.ui.features.conversation.chat.webview.workspace.browser.transfer + +import com.ai.assistance.operit.ui.features.conversation.chat.webview.workspace.browser.FileBrowserClipboardItem +import java.io.IOException +import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +class WorkspaceDeleteOperationTest { + private fun item(name: String, environment: String? = null, directory: Boolean = false) = + FileBrowserClipboardItem("/selected/$name", environment, cut = false, isDirectory = directory) + + @Test fun singleDirectoryRemainsIndeterminateUntilItsToolReturnsSuccess() = runBlocking { + val target = item("folder", "linux", directory = true) + lateinit var operation: WorkspaceDeleteOperation + operation = WorkspaceDeleteOperation { + assertEquals(target, it) + val progress = checkNotNull(operation.progress.value) + assertEquals(target, progress.target) + assertEquals(0, progress.completedItems) + assertEquals(1, progress.totalItems) + assertNull(progress.fraction) + } + var notified = false + operation.run(listOf(target)) { + notified = true + val progress = checkNotNull(operation.progress.value) + assertEquals(1, progress.completedItems) + assertEquals(1f, checkNotNull(progress.fraction), 0f) + } + assertTrue(notified) + assertNull(operation.progress.value) + } + + @Test fun batchProgressCountsOnlySuccessfulSelectedItemsAcrossEnvironments() = runBlocking { + val targets = listOf(item("local"), item("ubuntu", "linux", true), item("document", "repo:docs")) + val before = mutableListOf() + val after = mutableListOf() + val deleted = mutableListOf() + lateinit var operation: WorkspaceDeleteOperation + operation = WorkspaceDeleteOperation { before += checkNotNull(operation.progress.value) } + operation.run(targets) { + deleted += it + after += checkNotNull(operation.progress.value) + } + assertEquals(targets, before.map { it.target }) + assertEquals(listOf(0, 1, 2), before.map { it.completedItems }) + assertEquals(listOf(1, 2, 3), after.map { it.completedItems }) + assertEquals(targets, deleted) + assertEquals(1f / 3f, checkNotNull(before[1].fraction), 0f) + assertTrue(before.all { checkNotNull(it.fraction) < 1f }) + assertEquals(1f, checkNotNull(after.last().fraction), 0f) + assertNull(operation.progress.value) + } + + @Test fun failureIsNotCountedAndRemainingItemsAreNotStarted() = runBlocking { + val targets = listOf(item("first"), item("failed"), item("remaining")) + val attempted = mutableListOf() + val deleted = mutableListOf() + val operation = WorkspaceDeleteOperation { + attempted += it + if (it == targets[1]) throw IOException("删除失败") + } + try { + operation.run(targets) { deleted += it } + fail("应传播工具删除错误") + } catch (e: IOException) { + assertEquals("删除失败", e.message) + } + assertEquals(targets.take(2), attempted) + assertEquals(targets.take(1), deleted) + assertNull(operation.progress.value) + } + + @Test fun cancellingWhileCurrentToolRunsStopsTheNextItemWithoutUndoingSuccess() = runBlocking { + val targets = listOf(item("current"), item("remaining")) + val attempted = mutableListOf() + val deleted = mutableListOf() + val operation = WorkspaceDeleteOperation { + attempted += it + // 模拟同步工具收到取消后仍返回成功:已删除项不能被视作撤销。 + currentCoroutineContext().cancel() + } + val task = launch { operation.run(targets) { deleted += it } } + task.join() + assertTrue(task.isCancelled) + assertEquals(targets.take(1), attempted) + assertEquals(targets.take(1), deleted) + assertNull(operation.progress.value) + } + + @Test fun alreadyCancelledOperationNeverStartsDeletion() = runBlocking { + var invoked = false + val operation = WorkspaceDeleteOperation { invoked = true } + val task = launch { + currentCoroutineContext().cancel() + operation.run(listOf(item("not-started"))) + } + task.join() + assertTrue(task.isCancelled) + assertFalse(invoked) + assertNull(operation.progress.value) + } + + @Test fun emptySelectionDoesNotOpenProgressOrInvokeTools() = runBlocking { + var invoked = false + var notified = false + val operation = WorkspaceDeleteOperation { invoked = true } + operation.run(emptyList()) { notified = true } + assertFalse(invoked) + assertFalse(notified) + assertNull(operation.progress.value) + } + + @Test fun nextOperationStartsWithFreshProgressAfterFailure() = runBlocking { + val failed = item("failed") + val next = item("next") + lateinit var operation: WorkspaceDeleteOperation + operation = WorkspaceDeleteOperation { + if (it == failed) throw IOException("删除失败") + assertEquals(next, checkNotNull(operation.progress.value).target) + assertEquals(0, checkNotNull(operation.progress.value).completedItems) + assertEquals(1, checkNotNull(operation.progress.value).totalItems) + } + try { + operation.run(listOf(failed, item("unprocessed"))) + fail("应传播工具删除错误") + } catch (e: IOException) { + assertNull(operation.progress.value) + } + operation.run(listOf(next)) + assertNull(operation.progress.value) + } +} diff --git a/docs/TODO/fix/README.md b/docs/TODO/fix/README.md index 17b32a4175..5d7d328ae5 100644 --- a/docs/TODO/fix/README.md +++ b/docs/TODO/fix/README.md @@ -6,7 +6,7 @@ ## 计划索引 -- [工作区复制内存与进度](workspace-copy-streaming-progress/index.md) +- [工作区复制内存与文件操作进度](workspace-copy-streaming-progress/index.md) - [流式气泡停更修复](streaming-bubble-freeze/index.md) - [提示、插件配置入口与 Waifu 首次展示](ux-stream-ui-followup/index.md) diff --git a/docs/TODO/fix/workspace-copy-streaming-progress/01-validation.md b/docs/TODO/fix/workspace-copy-streaming-progress/01-validation.md index c0bbd20ea4..cab32693cb 100644 --- a/docs/TODO/fix/workspace-copy-streaming-progress/01-validation.md +++ b/docs/TODO/fix/workspace-copy-streaming-progress/01-validation.md @@ -1,14 +1,15 @@ -# 工作区复制验证 +# 工作区复制与删除验证 ## 已完成的静态核对 - `git diff --check` 通过 -- 9 组新增语言资源 XML 均能解析,每组包含 15 个界面条目 +- 9 组新增语言资源 XML 均能解析,每组包含 19 个界面条目 - 核对复制入口继续经过既有文件工具及权限路由,本机传输复用终端挂载映射 - 核对传输缓冲区为任务级复用的 256 KiB,目录遍历不缓存完整文件清单 -- 核对界面通过单次调用令牌接收进度,结束、失败时均移除观察者并清空面板状态 +- 核对界面通过单次调用令牌接收复制进度,结束、失败时均移除观察者并清空面板状态 +- 核对单项和批量删除复用进度窗口,既有 `delete_file` 参数、权限路由和确认入口未变;剪切收尾不弹出删除窗口 -未执行本地编译、构建或测试。新增 10 条 JVM 回归用例由仓库 Android Tests 运行,远端结果以对应工作流为准;Android 实机内存峰值和 UI 尚未测量。 +未执行本地编译、构建或测试。共新增 17 条 JVM 回归用例,包含 10 条流式复制用例及 7 条删除状态用例。这些用例由仓库 Android Tests 运行,远端结果以对应工作流为准;Android 实机内存峰值和 UI 尚未测量。 ## 回归范围 @@ -24,6 +25,13 @@ | A8 | 在统计完成后增加来源文件 | 来源变更导致失败,不报告完整成功 | | A9 | 在统计完成后创建同名目标目录 | 不合并或覆盖刚出现的目标 | | A10 | 复制空目录并检查递归开关 | 关闭递归时拒绝;开启时以零字节完整完成 | +| D1 | 删除单个目录并观察工具返回前后的状态 | 执行期间不显示未知百分比,成功后才计入完成并清理窗口 | +| D2 | 删除不同来源环境的多个所选项目 | 逐项报告当前路径,完成数和比例只随成功返回推进 | +| D3 | 批量删除第二项失败 | 失败项不计完成,不开始后续项,已完成通知和错误均保留 | +| D4 | 当前删除调用期间取消 | 当前调用返回后停止后续项,不撤销已完成项,清理窗口 | +| D5 | 操作开始前已取消 | 不调用删除工具,不打开窗口 | +| D6 | 提交空删除列表 | 不调用工具或成功通知,不打开窗口 | +| D7 | 删除失败后重新开始操作 | 新任务从零开始,路径和总数不残留上次状态 | | M1 | 将 `/root/Operit` 从本机 Linux 复制到手机目录,再反向复制大文件 | 原始字节保持一致,不出现整文件字符串或字节数组累积 | | M2 | 在 Linux 使用 `~/`、`/sdcard` 和宿主挂载路径复制 | 映射与终端一致,同一实际位置仍被拒绝 | | M3 | 单选、全选、多选和跨目录粘贴 | 全批次预检重名;多项显示序号及当前项目进度 | @@ -32,9 +40,16 @@ | M6 | 使用远程 SSH 和 SAF 粘贴 | 使用既有提供方;未知总量不显示虚构字节数或百分比 | | M7 | 在深浅主题、大字体及横屏查看复制面板 | 标题居中、路径可辨认、百分比位于 6dp 进度条右上方;超高内容可滚动 | | M8 | 成功、失败、点击取消和离开文件浏览器 | 面板与观察者正确结束,错误仍显示在文件浏览器中 | +| M9 | 在文件菜单确认单个文件或目录删除 | 确认后显示居中删除标题、名称和来源路径;不显示复制目标、字节数或速度 | +| M10 | 多选并确认批量删除 | 全批次显示同一窗口,所选项目完成数和百分比正确;单个目录内进度保持未知 | +| M11 | 批量删除时制造权限错误,或点击取消 | 失败/取消后不开始后续项,列表刷新,未完成项不误清选择;已删除内容不恢复 | +| M12 | 在标准、管理员、Root、Linux 和 SAF 环境删除 | 沿用原删除工具、环境及递归参数;同一删除窗口不改变后端权限 | +| M13 | 跨环境剪切进入清理来源阶段 | 保持移动窗口,不叠加删除窗口;完成、失败与取消后状态正确清理 | 复制链接到不支持符号链接的目标存储会明确失败并保留来源。失败时已经写入的部分目标保留,不自动删除其他文件。扫描与复制期间持续修改来源不会构成目录快照。 +删除百分比仅按所选顶层项目计数,不代表目录内文件数或字节数。已有同步删除工具不能在调用中途保证停止,取消在当前调用返回后阻止后续项;不撤销已完成删除。 + ## 结果记录 自动用例尚未运行,实机项尚未验证。下表用于填写实际结果。 @@ -51,6 +66,13 @@ | A8 | | | A9 | | | A10 | | +| D1 | | +| D2 | | +| D3 | | +| D4 | | +| D5 | | +| D6 | | +| D7 | | | M1 | | | M2 | | | M3 | | @@ -58,4 +80,9 @@ | M5 | | | M6 | | | M7 | | -| M8 | | \ No newline at end of file +| M8 | | +| M9 | | +| M10 | | +| M11 | | +| M12 | | +| M13 | | diff --git a/docs/TODO/fix/workspace-copy-streaming-progress/index.md b/docs/TODO/fix/workspace-copy-streaming-progress/index.md index 4a7a37abf7..571ed2c568 100644 --- a/docs/TODO/fix/workspace-copy-streaming-progress/index.md +++ b/docs/TODO/fix/workspace-copy-streaming-progress/index.md @@ -3,11 +3,11 @@ fork: https://github.com/3316891527/Operit status: implemented-awaiting-ci --- -# 工作区复制内存与进度 +# 工作区复制内存与文件操作进度 工作区将内置 Ubuntu 文件复制到手机目录时,跨环境实现会完整读取文件、经过字符串转换,反向复制还会累积完整字节数组。目录递归会忽略子项失败,粘贴界面只显示旋转圆环。 -本批优化本机 Android 与内置 Ubuntu 的文件复制,保持公开工具参数、权限路由和覆盖规则。传输逻辑放入 `core/files/transfer/`,不修改终端子模块、应用身份或数据库。 +本批优化本机 Android 与内置 Ubuntu 的文件复制,并为单项和批量删除补齐共用进度窗口,保持公开工具参数、权限路由、覆盖规则及删除确认。传输逻辑放入 `core/files/transfer/`,不修改终端子模块、应用身份或数据库。 - [x] 使用固定缓冲区复制二进制,逐层遍历目录,不保留完整目录清单 - [x] 使用终端现有挂载映射处理 Ubuntu、手机共享目录及 `~` @@ -16,10 +16,15 @@ status: implemented-awaiting-ci - [x] 子项失败立即返回失败,剪切来源只在对应项完整成功后删除 - [x] 补充二进制大文件、隐藏文件、空目录、同路径、符号链接及失败/取消的 10 条 JVM 回归用例 - [x] 完成差异空白、XML 和调用关系的静态核对 +- [x] 为单项和批量删除复用进度窗口,显示真实的所选项目完成数,保留既有删除后端和确认提示 +- [x] 删除失败、取消时清理窗口状态,停止后续项目;跨环境剪切清理不另弹删除窗口 +- [x] 补充删除进度、部分失败及取消的 7 条回归用例,更新完整验证范围 - [ ] 远端构建与测试结果确认 远程 SSH、SAF 和独立 Shell 复制继续使用各自原有工具实现;只有后端提供真实字节进度时才显示字节百分比,否则显示当前项目和处理中状态。不会以经过时间推算百分比。 +删除仅按所选项目计数,单个目录内部的进度未知。取消将在当前删除调用返回后停止后续项目;已删除内容不恢复,错误继续显示在文件浏览器。跨环境剪切清理不另弹删除窗口。 + 完整范围和可填写结果见[验证记录](01-validation.md)。本地未编译、构建或执行测试;CI 与 PR 交付到独立工作分支,目标为 `dev`。 [DONE] 实现与静态核对,编译、测试和实机结果仍待验证。