diff --git a/locales/editor.yml b/locales/editor.yml new file mode 100644 index 0000000..c9fbe91 --- /dev/null +++ b/locales/editor.yml @@ -0,0 +1,71 @@ +_version: 2 + +# The editor tab: its toolbar, status line text, and the questions about +# saving, reloading and closing. + +editor.status.cursor: + zh-CN: 行 %{line},列 %{column} + en: Ln %{line}, Col %{column} + +editor.state.saving: + zh-CN: 正在保存… + en: Saving… +editor.state.reloading: + zh-CN: 正在重新加载… + en: Reloading… +editor.state.modified: + zh-CN: 已修改 + en: Modified + +editor.command.reload: + zh-CN: 重新加载 + en: Reload +editor.command.copy_path: + zh-CN: 复制路径 + en: Copy path +editor.text_label: + zh-CN: 编辑 %{name} + en: Edit %{name} + +editor.reload.title: + zh-CN: 放弃对“%{name}”的修改并重新加载? + en: Discard changes to “%{name}” and reload? +editor.reload.description: + zh-CN: 编辑器里没有保存的修改会丢失。 + en: Changes not saved in the editor will be lost. +editor.reload.failed: + zh-CN: 无法重新加载“%{name}” + en: Couldn’t reload “%{name}” + +editor.changed.title: + zh-CN: “%{name}”在打开后已被修改 + en: “%{name}” changed after it was opened +editor.changed.remote: + zh-CN: 服务器上的这个文件在你打开或上次保存之后被改过或删除了。覆盖会用编辑器里的内容替换它。 + en: This file on the server was changed or deleted after you opened or last saved it. Overwriting replaces it with what’s in the editor. +editor.changed.local: + zh-CN: 本机上的这个文件在你打开或上次保存之后被改过或删除了。覆盖会用编辑器里的内容替换它。 + en: This file on this computer was changed or deleted after you opened or last saved it. Overwriting replaces it with what’s in the editor. +editor.changed.overwrite: + zh-CN: 覆盖 + en: Overwrite + +editor.save.interrupted: + zh-CN: "%{message}。文件可能只写了一部分,请重新连接后再保存一次。" + en: "%{message}. The file may be only partly written. Reconnect and save it again." +editor.save.interrupted_title: + zh-CN: 没有保存完“%{name}” + en: Couldn’t finish saving “%{name}” +editor.save.failed: + zh-CN: 无法保存“%{name}” + en: Couldn’t save “%{name}” + +editor.close.title: + zh-CN: 保存对“%{name}”的修改? + en: Save changes to “%{name}”? +editor.close.description: + zh-CN: 不保存的话,这些修改会丢失。 + en: Your changes will be lost if you don’t save them. +editor.close.discard: + zh-CN: 放弃修改 + en: Discard changes diff --git a/locales/explorer.yml b/locales/explorer.yml new file mode 100644 index 0000000..487fb4d --- /dev/null +++ b/locales/explorer.yml @@ -0,0 +1,706 @@ +_version: 2 + +# The SFTP tab: the two file lists, their toolbars and menus, the dialogs +# they open, the transfer queue and the preview. + +explorer.new.folder_title: + zh-CN: 新建文件夹 + en: New folder +explorer.new.file_title: + zh-CN: 新建文件 + en: New file +explorer.new.folder_name: + zh-CN: 新建文件夹 + en: New folder +explorer.new.file_name: + zh-CN: 新建文件.txt + en: New file.txt + +explorer.kind.folder: + zh-CN: 文件夹 + en: Folder +explorer.kind.file: + zh-CN: 文件 + en: File +explorer.kind.link: + zh-CN: 链接 + en: Link +explorer.kind.parent: + zh-CN: 上级目录 + en: Parent folder +explorer.kind.extension: + zh-CN: "%{ext} 文件" + en: "%{ext} file" + +explorer.size_format.bytes: + zh-CN: 字节 + en: Bytes +explorer.size_format.kilobytes: + zh-CN: 千字节(KB) + en: Kilobytes (KB) +explorer.size_format.short: + zh-CN: 简短格式(B、KB、MB、GB) + en: Short (B, KB, MB, GB) + +explorer.column.name: + zh-CN: 名称 + en: Name +explorer.column.size: + zh-CN: 大小 + en: Size +explorer.column.type: + zh-CN: 类型 + en: Type +explorer.column.modified: + zh-CN: 修改时间 + en: Modified +explorer.column.rights: + zh-CN: 权限 + en: Permissions +explorer.column.owner: + zh-CN: 所有者 + en: Owner + +explorer.list.connecting: + zh-CN: 正在连接 SFTP… + en: Connecting to SFTP… +explorer.list.reading: + zh-CN: 正在读取目录… + en: Reading folder… +explorer.list.only_hidden: + zh-CN: 只有隐藏文件 + en: Only hidden files +explorer.list.empty: + zh-CN: 空目录 + en: Empty folder +explorer.list.not_connected: + zh-CN: 未连接 + en: Not connected +explorer.list.unreadable: + zh-CN: 无法读取目录 + en: Couldn’t read the folder +explorer.list.read_failed: + zh-CN: 无法读取目录:%{error} + en: "Couldn’t read the folder: %{error}" + +explorer.drag.upload.one: + en: Upload %{count} item +explorer.drag.upload.other: + zh-CN: 上传 %{count} 个项目 + en: Upload %{count} items +explorer.drag.download.one: + en: Download %{count} item +explorer.drag.download.other: + zh-CN: 下载 %{count} 个项目 + en: Download %{count} items + +explorer.status.opening: + zh-CN: 正在打开 %{name}… + en: Opening %{name}… +explorer.status.items.one: + en: "%{count} item" +explorer.status.items.other: + zh-CN: "%{count} 个项目" + en: "%{count} items" +explorer.status.hidden.other: + zh-CN: 隐藏 %{count} 项 + en: "%{count} hidden" +explorer.status.selected.other: + zh-CN: 已选择 %{count} 项 + en: "%{count} selected" + +explorer.path.local: + zh-CN: 本地路径 + en: Local path +explorer.path.remote: + zh-CN: 远程路径 + en: Remote path +explorer.directory_select.local: + zh-CN: 本地目录 + en: Local folder +explorer.directory_select.remote: + zh-CN: 远程目录 + en: Remote folder +explorer.directory_select.current: + zh-CN: 当前路径 + en: Current path +explorer.directory_select.places: + zh-CN: 位置 + en: Places + +explorer.command.open_directory: + zh-CN: 打开目录/书签… + en: Open folder or bookmark… +explorer.command.up: + zh-CN: 上级目录 + en: Parent folder +explorer.command.root: + zh-CN: 根目录 + en: Root folder +explorer.command.home: + zh-CN: 主目录 + en: Home folder +explorer.command.refresh: + zh-CN: 刷新 + en: Refresh +explorer.command.back: + zh-CN: 后退 + en: Back +explorer.command.forward: + zh-CN: 前进 + en: Forward +explorer.command.show_hidden_files: + zh-CN: 显示隐藏文件 + en: Show hidden files +explorer.toolbar.hide_hidden_files: + zh-CN: 不显示隐藏文件 + en: Hide hidden files +explorer.command.upload: + zh-CN: 上传… + en: Upload… +explorer.toolbar.upload_tip: + zh-CN: 上传所选项目 + en: Upload the selected items +explorer.command.download: + zh-CN: 下载… + en: Download… +explorer.toolbar.download_tip: + zh-CN: 下载所选项目 + en: Download the selected items +explorer.toolbar.choose_files: + zh-CN: 选择文件上传… + en: Choose files to upload… +explorer.command.rename: + zh-CN: 重命名… + en: Rename… +explorer.command.properties: + zh-CN: 属性… + en: Properties… +explorer.command.new: + zh-CN: 新建 + en: New + +explorer.transfer.upload_title: + zh-CN: 上传文件 + en: Upload +explorer.transfer.download_title: + zh-CN: 下载 + en: Download +explorer.transfer.upload: + zh-CN: 上传 + en: Upload +explorer.transfer.download: + zh-CN: 下载 + en: Download +explorer.transfer.upload_summary: + zh-CN: 上传 %{items}到 %{endpoint} + en: Upload %{items} to %{endpoint} +explorer.transfer.download_summary: + zh-CN: 从 %{endpoint} 下载 %{items} + en: Download %{items} from %{endpoint} +explorer.transfer.files.one: + en: "%{count} file" +explorer.transfer.files.other: + zh-CN: "%{count} 个文件" + en: "%{count} files" +explorer.transfer.folders.one: + en: "%{count} folder" +explorer.transfer.folders.other: + zh-CN: "%{count} 个文件夹" + en: "%{count} folders" +explorer.transfer.items.other: + zh-CN: "%{count} 个项目(%{folders}、%{files})" + en: "%{count} items (%{folders}, %{files})" +explorer.transfer.in_folder: + zh-CN: 位于 %{folder} + en: In %{folder} +explorer.transfer.queued_note: + zh-CN: 当前有传输在进行,这一批会排在传输队列里,轮到时自动开始。 + en: Another transfer is running. This one waits in Transfers and starts when its turn comes. +explorer.transfer.target: + zh-CN: 目标目录 + en: Destination folder +explorer.transfer.download_to: + zh-CN: 下载到 + en: Download to +explorer.transfer.choose_download_folder: + zh-CN: 选择下载到的目录 + en: Choose where to download to +explorer.transfer.target_missing: + zh-CN: 请输入目标目录 + en: Enter a destination folder +explorer.transfer.local_target_relative: + zh-CN: 请输入本机目录的完整路径 + en: Enter the full path of a folder on this computer + +explorer.question.conflict_title: + zh-CN: 同名文件已存在 + en: A file with this name already exists +explorer.question.overwrite: + zh-CN: 覆盖 + en: Overwrite +explorer.question.apply_to_all: + zh-CN: 应用于本批剩余冲突 + en: Do the same for the rest of this batch +explorer.question.resume_upload_title: + zh-CN: 发现未完成的上传 + en: Unfinished upload found +explorer.question.resume_download_title: + zh-CN: 发现未完成的下载 + en: Unfinished download found +explorer.question.resume_upload: + zh-CN: 继续上传 + en: Resume upload +explorer.question.resume_download: + zh-CN: 继续下载 + en: Resume download +explorer.question.restart_upload: + zh-CN: 重新上传 + en: Upload again +explorer.question.restart_download: + zh-CN: 重新下载 + en: Download again +explorer.question.invalid_resume_title: + zh-CN: 无法继续此文件 + en: Can’t resume this file +explorer.question.upload_failed_title: + zh-CN: 无法上传此项目 + en: Couldn’t upload this item +explorer.question.download_failed_title: + zh-CN: 无法下载此项目 + en: Couldn’t download this item +explorer.question.retry: + zh-CN: 重试 + en: Retry +explorer.question.skip: + zh-CN: 跳过 + en: Skip +explorer.question.cancel_upload: + zh-CN: 取消上传 + en: Cancel upload +explorer.question.cancel_download: + zh-CN: 取消下载 + en: Cancel download + +explorer.close_transfer.upload_title: + zh-CN: 停止上传并关闭? + en: Stop the upload and close? +explorer.close_transfer.download_title: + zh-CN: 停止下载并关闭? + en: Stop the download and close? +explorer.close_transfer.upload_kept: + zh-CN: 上传进度会保留,下次选择相同来源和目标目录时可以继续上传。 + en: "Progress is kept: choose the same source and destination folder later to resume the upload." +explorer.close_transfer.download_kept: + zh-CN: 下载进度会保留,下次选择相同来源和目标目录时可以继续下载。 + en: "Progress is kept: choose the same source and destination folder later to resume the download." +explorer.close_transfer.two_sentences: + zh-CN: "%{first}%{second}" + en: "%{first} %{second}" +explorer.close_transfer.stop_upload: + zh-CN: 停止上传并关闭 + en: Stop and close +explorer.close_transfer.stop_download: + zh-CN: 停止下载并关闭 + en: Stop and close +explorer.close_transfer.keep_uploading: + zh-CN: 继续上传 + en: Keep uploading +explorer.close_transfer.keep_downloading: + zh-CN: 继续下载 + en: Keep downloading + +explorer.queue.title: + zh-CN: 传输队列 + en: Transfers +explorer.queue.title_count: + zh-CN: 传输队列 (%{unfinished}) + en: Transfers (%{unfinished}) +explorer.queue.resume: + zh-CN: 继续 + en: Resume +explorer.queue.stop: + zh-CN: 停止 + en: Stop +explorer.queue.remove: + zh-CN: 移出队列 + en: Remove from queue +explorer.queue.discard: + zh-CN: 丢弃续传进度 + en: Discard partial transfer +explorer.queue.clear_finished: + zh-CN: 清除已完成 + en: Clear finished +explorer.queue.column.operation: + zh-CN: 操作 + en: Direction +explorer.queue.column.source: + zh-CN: 来源 + en: Source +explorer.queue.column.target: + zh-CN: 目标 + en: Destination +explorer.queue.column.transferred: + zh-CN: 已传输 + en: Transferred +explorer.queue.column.time: + zh-CN: 时间 + en: Time +explorer.queue.column.speed: + zh-CN: 速度 + en: Speed +explorer.queue.column.progress: + zh-CN: 进度 + en: Progress +explorer.queue.hide_results: + zh-CN: 收起逐项结果 + en: Hide item results +explorer.queue.show_results: + zh-CN: 展开逐项结果 + en: Show item results +explorer.queue.not_started: + zh-CN: 还没有开始 + en: Not started yet +explorer.queue.nothing_uploaded: + zh-CN: 还没有上传完的项目,每完成一项会列在这里 + en: No items uploaded yet. Each one is listed here when it’s done. +explorer.queue.nothing_downloaded: + zh-CN: 还没有下载完的项目,每完成一项会列在这里 + en: No items downloaded yet. Each one is listed here when it’s done. +explorer.queue.outcome.done: + zh-CN: 完成 + en: Done +explorer.queue.outcome.skipped: + zh-CN: 已跳过 + en: Skipped +explorer.queue.outcome.failed: + zh-CN: 失败 + en: Failed +explorer.queue.several_places: + zh-CN: 多个位置 + en: Several places +explorer.queue.status.pending: + zh-CN: 等待中 + en: Waiting +explorer.queue.status.scanning: + zh-CN: 正在扫描 + en: Scanning +explorer.queue.status.waiting: + zh-CN: 等待回答 + en: Waiting for an answer +explorer.queue.status.reconnecting: + zh-CN: 正在重连 + en: Reconnecting +explorer.queue.status.stopped: + zh-CN: 已停止 + en: Stopped +explorer.queue.status.done: + zh-CN: 已完成 + en: Done +explorer.queue.status.partly_failed.one: + en: "%{count} item failed" +explorer.queue.status.partly_failed.other: + zh-CN: 部分失败(%{count} 项) + en: "%{count} items failed" +explorer.queue.status.discarded: + zh-CN: 已丢弃续传进度 + en: Partial transfer discarded +explorer.queue.status.not_started: + zh-CN: 无法开始:%{reason} + en: "Couldn’t start: %{reason}" +explorer.queue.status.removed: + zh-CN: 已移出 + en: Removed + +explorer.connection.start_failed: + zh-CN: 无法启动 SFTP:%{error} + en: "Couldn’t start SFTP: %{error}" +explorer.connection.server: + zh-CN: 服务器 + en: the server +explorer.connection.lost: + zh-CN: SFTP 已断开 + en: SFTP disconnected +explorer.connection.lost_title: + zh-CN: 与 %{name} 的 SFTP 连接已断开 + en: Lost the SFTP connection to %{name} +explorer.connection.lost_reconnect: + zh-CN: "%{reason}。重新连接后再操作远程文件。" + en: "%{reason}. Reconnect to work with remote files." +explorer.connection.lost_stopped_batch: + zh-CN: "%{reason}。传输队列里有停止的批次,点它的「继续」会重新连接并接着传输。" + en: "%{reason}. A stopped batch is waiting in Transfers; its Resume reconnects and carries on." +explorer.connection.lost_reconnecting: + zh-CN: "%{reason}。正在自动重新连接,请稍候。" + en: "%{reason}. Reconnecting automatically." +explorer.connection.reconnect: + zh-CN: 重新连接 + en: Reconnect + +explorer.ok: + zh-CN: 知道了 + en: OK +explorer.browse: + zh-CN: 浏览… + en: Browse… +explorer.choose_upload: + zh-CN: 选择要上传的文件或目录 + en: Choose files or folders to upload + +explorer.tab.open_sftp_tip: + zh-CN: 打开 SFTP 文件浏览 + en: Browse files over SFTP +explorer.tab.rename: + zh-CN: 重命名标签… + en: Rename tab… +explorer.tab.open_sftp: + zh-CN: 打开 SFTP + en: Open SFTP +explorer.tab.copy_ip: + zh-CN: 复制 IP 地址 + en: Copy IP address +explorer.tab.copy_host_name: + zh-CN: 复制主机名 + en: Copy host name + +explorer.name.empty: + zh-CN: 名称不能为空 + en: The name can’t be empty +explorer.name.dots: + zh-CN: 名称不能是「.」或「..」 + en: The name can’t be “.” or “..” +explorer.name.slash: + zh-CN: 名称不能包含「/」 + en: The name can’t contain “/” +explorer.name.unchanged: + zh-CN: 名称未改变 + en: The name hasn’t changed +explorer.name.exists: + zh-CN: 已有名为「%{name}」的项目 + en: An item named “%{name}” already exists + +explorer.delete.title_one: + zh-CN: 删除「%{name}」? + en: Delete “%{name}”? +explorer.delete.title_several.other: + zh-CN: 删除 %{count} 个项目? + en: Delete %{count} items? +explorer.delete.remote: + zh-CN: 远程文件无法恢复,目录会连同其中全部内容一起删除。 + en: Remote files can’t be recovered. A folder is deleted with everything in it. +explorer.delete.trash: + zh-CN: 项目会移到废纸篓。 + en: The items move to the Trash. +explorer.delete.recycle_bin: + zh-CN: 项目会移到废纸篓。 + en: The items move to the Recycle Bin. + +explorer.rename.title: + zh-CN: 重命名「%{name}」 + en: Rename “%{name}” +explorer.rename.commit: + zh-CN: 重命名 + en: Rename +explorer.rename.label: + zh-CN: 新名称 + en: New name +explorer.new.commit: + zh-CN: 创建 + en: Create +explorer.new.label: + zh-CN: 名称 + en: Name + +explorer.file.connection_lost: + zh-CN: SFTP 连接已断开 + en: The SFTP connection was lost +explorer.file.connecting: + zh-CN: 正在连接 SFTP,请稍候再试。 + en: Connecting to SFTP. Try again in a moment. +explorer.file.edit_refused: + zh-CN: 无法在编辑器中打开“%{name}” + en: Can’t open “%{name}” in the editor +explorer.file.preview_refused: + zh-CN: 无法预览“%{name}” + en: Can’t preview “%{name}” +explorer.file.too_large_for_editor: + zh-CN: 文件有 %{size},编辑器只打开 %{limit} 以内的文件。 + en: The file is %{size}. The editor opens files up to %{limit}. +explorer.file.too_large_for_preview: + zh-CN: 文件有 %{size},预览只打开 %{limit} 以内的文件。 + en: The file is %{size}. Preview opens files up to %{limit}. +explorer.file.not_text: + zh-CN: 它不是 UTF-8 编码的文本文件:可能是二进制文件,或用了 GBK 等其他编码。 + en: "It isn’t a UTF-8 text file: it may be binary, or in another encoding such as GBK." +explorer.file.not_file: + zh-CN: 它不是普通文件。 + en: It isn’t a regular file. +explorer.file.open_failed: + zh-CN: 无法打开“%{name}” + en: Couldn’t open “%{name}” +explorer.file.download_instead: + zh-CN: "%{reason}可以下载到本机,用别的程序打开。" + en: "%{reason} You can download it and open it in another app." +explorer.file.download: + zh-CN: 下载… + en: Download… + +explorer.preview.image_info: + zh-CN: "%{width} × %{height} 像素 · %{size}" + en: "%{width} × %{height} pixels · %{size}" +explorer.preview.zoom_out: + zh-CN: 缩小 + en: Zoom out +explorer.preview.zoom_in: + zh-CN: 放大 + en: Zoom in +explorer.preview.fit: + zh-CN: 适合窗口 + en: Fit to window +explorer.preview.actual_size: + zh-CN: 原图 + en: Actual size +explorer.preview.actual_size_tip: + zh-CN: 原图(100%) + en: Actual size (100%) +explorer.preview.undecodable: + zh-CN: 无法显示这张图片 + en: Can’t show this image +explorer.preview.decoding: + zh-CN: 正在显示图片… + en: Loading image… + +explorer.open_directory.title: + zh-CN: 打开目录 + en: Open folder +explorer.open_directory.folder: + zh-CN: 目录 + en: Folder +explorer.open_directory.bookmarks: + zh-CN: 书签 + en: Bookmarks +explorer.open_directory.add: + zh-CN: 添加 + en: Add +explorer.open_directory.remove: + zh-CN: 删除 + en: Remove +explorer.open_directory.up: + zh-CN: 上移 + en: Up +explorer.open_directory.down: + zh-CN: 下移 + en: Down +explorer.open_directory.open: + zh-CN: 打开 + en: Open +explorer.open_directory.missing: + zh-CN: 请输入要打开的目录 + en: Enter a folder to open +explorer.open_directory.choose: + zh-CN: 选择要打开的目录 + en: Choose a folder to open +explorer.bookmarks.none: + zh-CN: 暂无书签 + en: No bookmarks + +explorer.command.edit: + zh-CN: 编辑 + en: Edit +explorer.command.preview: + zh-CN: 预览 + en: Preview +explorer.command.copy_path: + zh-CN: 复制路径 + en: Copy path +explorer.command.go: + zh-CN: 前往 + en: Go to +explorer.command.add_bookmark: + zh-CN: 添加路径到书签 + en: Add path to bookmarks +explorer.command.remove_bookmark: + zh-CN: 从书签中移除路径 + en: Remove path from bookmarks +explorer.command.size_format: + zh-CN: 文件大小显示为 + en: Show file sizes as +explorer.command.new_folder: + zh-CN: 文件夹… + en: Folder… +explorer.command.new_file: + zh-CN: 文件… + en: File… + +explorer.operation.delete_failed: + zh-CN: 删除失败 + en: Couldn’t delete +explorer.operation.rename_failed: + zh-CN: 重命名失败 + en: Couldn’t rename +explorer.operation.create_failed: + zh-CN: 新建失败 + en: Couldn’t create +explorer.operation.permissions_failed: + zh-CN: 修改权限失败 + en: Couldn’t change permissions + +explorer.properties.title_one: + zh-CN: 「%{name}」的属性 + en: Properties of “%{name}” +explorer.properties.title_several.other: + zh-CN: "%{count} 个项目的属性" + en: Properties of %{count} items +explorer.properties.apply: + zh-CN: 应用 + en: Apply +explorer.properties.location: + zh-CN: 位置 + en: Location +explorer.properties.group: + zh-CN: 组 + en: Group +explorer.properties.size.one: + en: "%{size} (%{count} byte)" +explorer.properties.size.other: + zh-CN: "%{size}(%{count} 字节)" + en: "%{size} (%{count} bytes)" +explorer.properties.mixed: + zh-CN: (不一致) + en: (mixed) +explorer.properties.class_owner: + zh-CN: 所有者 + en: Owner +explorer.properties.class_group: + zh-CN: 组 + en: Group +explorer.properties.class_others: + zh-CN: 其他 + en: Others +explorer.properties.read: + zh-CN: 读 + en: Read +explorer.properties.write: + zh-CN: 写 + en: Write +explorer.properties.execute: + zh-CN: 执行 + en: Execute +explorer.properties.octal: + zh-CN: 八进制 + en: Octal +explorer.properties.mixed_note: + zh-CN: 部分项目权限不同,未改动的位保持原样。 + en: The items’ permissions differ. Bits you leave alone stay as they are. +explorer.properties.links: + zh-CN: 符号链接没有独立的权限。 + en: Symbolic links have no permissions of their own. +explorer.properties.unsupported: + zh-CN: 此系统不支持修改权限。 + en: This system doesn’t support changing permissions. +explorer.properties.recursive: + zh-CN: 同时应用到其中的文件和子目录 + en: Also apply to the files and subfolders inside +explorer.properties.dir_x: + zh-CN: 为目录添加执行权限(X) + en: Add execute permission to folders (X) diff --git a/locales/sftp.yml b/locales/sftp.yml new file mode 100644 index 0000000..46e5ea1 --- /dev/null +++ b/locales/sftp.yml @@ -0,0 +1,317 @@ +_version: 2 + +# SFTP connections, transfers and local file operations: mostly errors, +# which the SFTP tab, the editor and the external CLI show. + +sftp.place.home: + zh-CN: 主目录 + en: Home +sftp.place.desktop: + zh-CN: 桌面 + en: Desktop +sftp.place.documents: + zh-CN: 文稿 + en: Documents +sftp.place.downloads: + zh-CN: 下载 + en: Downloads + +sftp.error.remote_path_invalid: + zh-CN: 远程路径不能为空或包含空字符 + en: A remote path can’t be empty or contain a null character +sftp.error.file_name_invalid: + zh-CN: 无效的文件名:%{name} + en: "Invalid file name: %{name}" +sftp.error.nothing_to_upload: + zh-CN: 请选择要上传的文件或目录 + en: Choose files or folders to upload +sftp.error.nothing_to_download: + zh-CN: 请选择要下载的文件或目录 + en: Choose files or folders to download +sftp.error.download_target_relative: + zh-CN: 下载目标必须是绝对路径 + en: The download destination must be an absolute path +sftp.error.file_name_not_utf8: + zh-CN: 目录包含无法用 UTF-8 表示的文件名 + en: The folder has a file name that isn’t valid UTF-8 +sftp.error.path_not_utf8: + zh-CN: 路径不是有效的 UTF-8 + en: The path isn’t valid UTF-8 +sftp.error.trash_failed: + zh-CN: 无法移到废纸篓:%{error} + en: "Couldn’t move to the Trash: %{error}" +sftp.error.recycle_bin_failed: + zh-CN: 无法移到废纸篓:%{error} + en: "Couldn’t move to the Recycle Bin: %{error}" +sftp.error.exists: + zh-CN: 已有名为「%{name}」的项目 + en: An item named “%{name}” already exists +sftp.error.permission_denied: + zh-CN: 没有权限操作「%{name}」 + en: Permission denied for “%{name}” +sftp.error.not_found: + zh-CN: 「%{name}」不存在 + en: “%{name}” doesn’t exist +sftp.error.item: + zh-CN: 「%{name}」:%{error} + en: "“%{name}”: %{error}" +sftp.error.permissions_unsupported: + zh-CN: 此系统不支持修改权限 + en: This system doesn’t support changing permissions + +sftp.transfer.host_key_changed: + zh-CN: 服务器主机指纹已改变,已停止续传 + en: The server’s host key changed, so resuming stopped +sftp.transfer.cancelled: + zh-CN: 传输已停止,进度已保留 + en: Transfer stopped. Its progress is kept + +sftp.upload.source_name_not_utf8: + zh-CN: 来源文件名不是有效的 UTF-8 + en: The source file name isn’t valid UTF-8 +sftp.upload.target_busy: + zh-CN: 另一个传输正在上传同一目标,请稍后继续上传 + en: Another transfer is uploading to the same destination. Resume this upload later +sftp.upload.parent_missing: + zh-CN: 父目录未创建,无法上传 + en: Can’t upload because the parent folder wasn’t created +sftp.upload.failed: + zh-CN: 无法上传:%{error} + en: "Couldn’t upload: %{error}" +sftp.upload.folder_in_the_way: + zh-CN: 目录与文件类型冲突,不会删除远程目录 + en: A remote folder has this name, and it won’t be deleted to make way for a file +sftp.upload.exists.one: + en: A remote item with this name exists (%{count} byte, modified %{modified}). Overwriting replaces this file or link. +sftp.upload.exists.other: + zh-CN: 远程项目已存在(%{count} 字节,修改时间 %{modified})。覆盖会替换这个文件或链接。 + en: A remote item with this name exists (%{count} bytes, modified %{modified}). Overwriting replaces this file or link. +sftp.upload.exists_changed.one: + en: The destination changed during the upload. A remote item with this name exists (%{count} byte, modified %{modified}). Overwriting replaces this file or link. +sftp.upload.exists_changed.other: + zh-CN: 目标在上传期间发生变化。远程项目已存在(%{count} 字节,修改时间 %{modified})。覆盖会替换这个文件或链接。 + en: The destination changed during the upload. A remote item with this name exists (%{count} bytes, modified %{modified}). Overwriting replaces this file or link. +sftp.upload.target_gone: + zh-CN: 原目标在上传期间已被删除或移走。是否继续发布此文件? + en: The original destination was deleted or moved during the upload. Put this file in its place anyway? +sftp.upload.source_now_folder: + zh-CN: 来源已变为目录,请丢弃旧文件进度后重新上传,或跳过。 + en: The source is now a folder. Discard the old file’s progress and upload again, or skip it. +sftp.upload.target_not_folder: + zh-CN: 目标已存在且不是目录,不会跟随或替换链接 + en: The destination exists and isn’t a folder. Links aren’t followed or replaced +sftp.upload.special_file: + zh-CN: 不支持上传设备、套接字或其他特殊文件 + en: Devices, sockets and other special files can’t be uploaded +sftp.upload.resume_found: + zh-CN: 发现未完成的 .filepart 文件。续传会按它的现有大小跳过本地文件前缀,请确认本地文件仍是同一版本。 + en: Found an unfinished .filepart file. Resuming skips as much of the local file as it already holds, so make sure the local file is the same version. +sftp.upload.link_target_not_utf8: + zh-CN: 链接目标不是有效的 UTF-8 + en: The link target isn’t valid UTF-8 +sftp.upload.record_without_link: + zh-CN: 续传记录中缺少链接目标 + en: The resume record has no link target +sftp.upload.temporary_link_changed: + zh-CN: 临时链接已变化,无法继续上传 + en: The temporary link changed, so the upload can’t resume +sftp.upload.record_without_source: + zh-CN: 续传记录中缺少来源文件信息 + en: The resume record has no details of the source file +sftp.upload.temporary_kind_changed: + zh-CN: 远端临时文件类型已变化 + en: The remote temporary file is no longer a file +sftp.upload.partial_too_large: + zh-CN: 远端续传偏移大于本地文件 + en: The remote partial file is larger than the local file +sftp.upload.pipeline_ended: + zh-CN: 上传写入流水线意外结束 + en: The upload stopped writing unexpectedly +sftp.upload.publish_unconfirmed: + zh-CN: 无法确认文件发布结果,已保留恢复文件;请检查远端目录 + en: Couldn’t confirm the file was put in place. The recovery files are kept; check the remote folder +sftp.upload.temporary_changed: + zh-CN: 临时文件大小或类型已变化,未替换目标 + en: The temporary file’s size or type changed, so the destination wasn’t replaced +sftp.upload.target_created: + zh-CN: 替换期间目标被其他操作创建,已保留目标、备份和临时文件 + en: Something else created the destination during the replace. The destination, backup and temporary file are kept +sftp.upload.backup_changed: + zh-CN: 恢复备份已变化,停止替换 + en: The recovery backup changed, so the replace stopped +sftp.upload.backup_changed_after_publish: + zh-CN: 备份已变化,未清理;上传的目标文件已发布 + en: The backup changed, so it wasn’t cleaned up. The uploaded file is in place +sftp.upload.backup_and_target: + zh-CN: 存在恢复备份和目标文件,请先检查远端目录;未删除任何文件 + en: Both a recovery backup and the destination file exist. Check the remote folder first; nothing was deleted + +sftp.download.name_invalid: + zh-CN: 名称「%{name}」在本机无效 + en: The name “%{name}” isn’t valid on this computer +sftp.download.source_missing: + zh-CN: 远程项目不存在 + en: The remote item doesn’t exist +sftp.download.target_busy: + zh-CN: 另一个传输正在下载到同一位置,请稍后继续下载 + en: Another transfer is downloading to the same place. Resume this download later +sftp.download.parent_missing: + zh-CN: 父目录未创建,无法下载 + en: Can’t download because the parent folder wasn’t created +sftp.download.failed: + zh-CN: 无法下载:%{error} + en: "Couldn’t download: %{error}" +sftp.download.target_invalid: + zh-CN: 无效的下载目标 + en: Invalid download destination +sftp.download.folder_in_the_way: + zh-CN: 本地已有同名目录,不会删除它 + en: A local folder has this name, and it won’t be deleted +sftp.download.exists.one: + en: A local item with this name exists (%{count} byte, modified %{modified}). Overwriting replaces it. +sftp.download.exists.other: + zh-CN: 本地已有同名项目(%{count} 字节,修改时间 %{modified})。覆盖会替换它。 + en: A local item with this name exists (%{count} bytes, modified %{modified}). Overwriting replaces it. +sftp.download.exists_changed.one: + en: The destination changed during the download. A local item with this name exists (%{count} byte, modified %{modified}). Overwriting replaces it. +sftp.download.exists_changed.other: + zh-CN: 目标在下载期间发生变化。本地已有同名项目(%{count} 字节,修改时间 %{modified})。覆盖会替换它。 + en: The destination changed during the download. A local item with this name exists (%{count} bytes, modified %{modified}). Overwriting replaces it. +sftp.download.file_in_the_way: + zh-CN: 本地已有同名文件,不会替换为目录 + en: A local file has this name, and it won’t be replaced by a folder +sftp.download.special_file: + zh-CN: 不支持下载设备、套接字或其他特殊文件 + en: Devices, sockets and other special files can’t be downloaded +sftp.download.partial_not_file: + zh-CN: 临时文件 %{path} 不是普通文件 + en: The temporary file %{path} isn’t a regular file +sftp.download.partial_too_large: + zh-CN: 本地 .filepart 文件比远程文件大,无法续传。 + en: The local .filepart file is larger than the remote file, so the download can’t resume. +sftp.download.source_changed: + zh-CN: 远程文件在上次下载后已变化,无法续传。 + en: The remote file changed after the last download, so the download can’t resume. +sftp.download.resume_found: + zh-CN: 发现未完成的 .filepart 文件。续传会按它的现有大小跳过远程文件前缀,请确认远程文件仍是同一版本。 + en: Found an unfinished .filepart file. Resuming skips as much of the remote file as it already holds, so make sure the remote file is the same version. +sftp.download.pipeline_ended: + zh-CN: 下载读取流水线意外结束 + en: The download stopped reading unexpectedly +sftp.download.source_shrank: + zh-CN: 远程文件在下载期间变短,已停止 + en: The remote file got shorter during the download, so it stopped +sftp.download.links_unsupported: + zh-CN: 此系统不支持创建符号链接 + en: This system can’t create symbolic links + +sftp.journal.source_not_file: + zh-CN: 本地来源已不再是普通文件 + en: The local source is no longer a regular file +sftp.journal.mismatch: + zh-CN: 续传记录与当前来源或服务器不匹配 + en: The resume record doesn’t match this source or server +sftp.journal.temporary_invalid: + zh-CN: 无效的续传临时路径 + en: Invalid temporary path in the resume record +sftp.journal.replaces_folder: + zh-CN: 续传记录不能替换目录 + en: A resume record can’t replace a folder +sftp.journal.upload_damaged: + zh-CN: 续传记录损坏,原文件和临时文件均已保留 + en: The resume record is damaged. The original and temporary files are both kept +sftp.journal.download_damaged: + zh-CN: 续传记录损坏,临时文件已保留 + en: The resume record is damaged. The temporary file is kept +sftp.journal.unreadable: + zh-CN: 无法读取续传记录 + en: Couldn’t read the resume record +sftp.journal.unsaved: + zh-CN: 无法保存续传进度 + en: Couldn’t save the resume progress + +sftp.worker.not_connected: + zh-CN: SFTP 未连接,请重新连接 + en: SFTP isn’t connected. Reconnect to continue +sftp.worker.not_connected_short: + zh-CN: SFTP 未连接 + en: SFTP isn’t connected +sftp.worker.disconnected: + zh-CN: SFTP 已断开 + en: SFTP disconnected +sftp.worker.list_failed: + zh-CN: 无法读取目录:%{error} + en: "Couldn’t read the folder: %{error}" +sftp.worker.busy: + zh-CN: 已有传输批次正在处理 + en: A transfer batch is already running +sftp.worker.local_folder_missing: + zh-CN: 本地目录 %{path} 不存在 + en: The local folder %{path} doesn’t exist +sftp.worker.remote_folder_missing: + zh-CN: 远程目录 %{path} 不存在 + en: The remote folder %{path} doesn’t exist +sftp.worker.dropped_kept: + zh-CN: 连接中断,传输进度已保留 + en: The connection dropped. Transfer progress is kept +sftp.worker.reconnect_in.one: + en: Reconnecting in %{count} second (%{attempt}/3) +sftp.worker.reconnect_in.other: + zh-CN: 将在 %{count} 秒后重连(%{attempt}/3) + en: Reconnecting in %{count} seconds (%{attempt}/3) +sftp.worker.not_a_folder: + zh-CN: 远程路径 %{path} 不是目录 + en: The remote path %{path} isn’t a folder +sftp.worker.sync_to_root: + zh-CN: 不能同步到根目录 / + en: Can’t sync to the root folder / +sftp.worker.dropped: + zh-CN: SFTP 连接中断,请重新连接 + en: The SFTP connection dropped. Reconnect to continue + +sftp.edit.not_file: + zh-CN: 不是普通文件 + en: Not a regular file +sftp.edit.too_large.one: + en: File too large (%{count} byte) +sftp.edit.too_large.other: + zh-CN: 文件太大(%{count} 字节) + en: File too large (%{count} bytes) +sftp.edit.not_text: + zh-CN: 不是 UTF-8 文本文件 + en: Not a UTF-8 text file +sftp.edit.changed: + zh-CN: 文件在打开后已被修改 + en: The file changed after it was opened +sftp.edit.missing: + zh-CN: 文件不存在 + en: The file doesn’t exist +sftp.edit.shrank: + zh-CN: 文件在读取时变短了,请重新打开 + en: The file got shorter while it was read. Open it again +sftp.edit.gone_after_save: + zh-CN: 保存后找不到文件 + en: The file is gone after saving + +sftp.client.start_timeout: + zh-CN: 启动 SFTP 超时 + en: SFTP took too long to start +sftp.client.no_v3: + zh-CN: 服务器未提供 SFTP v3 + en: The server doesn’t offer SFTP v3 +sftp.client.no_path: + zh-CN: 服务器未返回目录路径 + en: The server didn’t return the folder’s path +sftp.client.no_link_target: + zh-CN: 无法读取链接目标 + en: Couldn’t read the link target +sftp.client.bad_rename_reply: + zh-CN: 服务器返回了无效的重命名响应 + en: The server sent an invalid reply to the rename +sftp.client.ambiguous_name: + zh-CN: 远端名称包含无法可靠表示的字符,已停止操作 + en: A remote name has characters that can’t be shown reliably, so the operation stopped + +sftp.sync.delete_failed: + zh-CN: 无法删除:%{error} + en: "Couldn’t delete: %{error}" diff --git a/src/editor/editor_panel.rs b/src/editor/editor_panel.rs index e20f0a6..d22d8d2 100644 --- a/src/editor/editor_panel.rs +++ b/src/editor/editor_panel.rs @@ -21,6 +21,7 @@ use crate::app::{ }; use crate::explorer::{ExplorerId, ExplorerPanel, FileLocation}; use crate::host::{HostId, HostStore}; +use crate::i18n::t; use crate::sftp::{ FileStamp, ReadFailure, SaveFailure, SharedLocalDirectoryProvider, TextFile, TextFormat, read_local_text, write_local_text, @@ -221,9 +222,9 @@ impl EditorPanel { let action = EditorAction::new(self.id, EditorCommand::ReloadConfirmed); let dispatch = self.dispatch.clone(); confirm_danger( - format!("放弃对“{}”的修改并重新加载?", self.name()).into(), - Some("编辑器里没有保存的修改会丢失。".into()), - "重新加载", + t!("editor.reload.title", name = self.name()), + Some(t!("editor.reload.description")), + t!("editor.command.reload"), std::rc::Rc::new(move |window, cx| send(&dispatch, &action, window, cx)), window, cx, @@ -326,22 +327,17 @@ impl EditorPanel { } } Err(SaveFailure::Changed) => { - let place = if self.location.is_remote() { - "服务器上的" + let description = if self.location.is_remote() { + t!("editor.changed.remote") } else { - "本机上的" + t!("editor.changed.local") }; let action = EditorAction::new(self.id, EditorCommand::Overwrite { close }); let dispatch = self.dispatch.clone(); confirm_danger( - format!("“{name}”在打开后已被修改").into(), - Some( - format!( - "{place}这个文件在你打开或上次保存之后被改过或删除了。覆盖会用编辑器里的内容替换它。" - ) - .into(), - ), - "覆盖", + t!("editor.changed.title", name = name), + Some(description), + t!("editor.changed.overwrite"), std::rc::Rc::new(move |window, cx| send(&dispatch, &action, window, cx)), window, cx, @@ -351,15 +347,13 @@ impl EditorPanel { // What is on disk is neither what was read nor what was meant. self.stamp = None; window.push_notification( - Notification::error(format!( - "{message}。文件可能只写了一部分,请重新连接后再保存一次。" - )) - .title(format!("没有保存完“{name}”")), + Notification::error(t!("editor.save.interrupted", message = message)) + .title(t!("editor.save.interrupted_title", name = name)), cx, ); } Err(SaveFailure::Failed(message)) => window.push_notification( - Notification::error(message).title(format!("无法保存“{name}”")), + Notification::error(message).title(t!("editor.save.failed", name = name)), cx, ), } @@ -424,7 +418,7 @@ impl EditorPanel { } Err(failure) => window.push_notification( Notification::error(failure.to_string()) - .title(format!("无法重新加载“{}”", self.name())), + .title(t!("editor.reload.failed", name = self.name())), cx, ), } @@ -442,20 +436,20 @@ impl EditorPanel { let focus = self.state.read(cx).focus_handle(cx); window.open_dialog(cx, move |dialog, _, _| { dialog - .title(format!("保存对“{name}”的修改?")) + .title(t!("editor.close.title", name = name)) .overlay_closable(false) - .child(div().text_sm().child("不保存的话,这些修改会丢失。")) + .child(div().text_sm().child(t!("editor.close.description"))) .footer( DialogFooter::new() .child( Button::new("editor-close-cancel") - .label("取消") + .label(t!("common.cancel")) .on_click(|_, window, cx| window.close_dialog(cx)), ) .child( Button::new("editor-close-discard") .danger() - .label("放弃修改") + .label(t!("editor.close.discard")) .on_click({ let (dispatch, discard) = (dispatch.clone(), discard.clone()); move |_, window, cx| { @@ -465,8 +459,11 @@ impl EditorPanel { }), ) .child( - DialogAction::new() - .child(Button::new("editor-close-save").primary().label("保存")), + DialogAction::new().child( + Button::new("editor-close-save") + .primary() + .label(t!("common.save")), + ), ), ) .on_ok({ @@ -518,9 +515,9 @@ impl EditorPanel { .into_any_element(), }; let state = match self.phase { - Phase::Saving => Some(("正在保存…", cx.theme().muted_foreground)), - Phase::Reloading => Some(("正在重新加载…", cx.theme().muted_foreground)), - Phase::Idle if self.dirty => Some(("已修改", cx.theme().warning)), + Phase::Saving => Some((t!("editor.state.saving"), cx.theme().muted_foreground)), + Phase::Reloading => Some((t!("editor.state.reloading"), cx.theme().muted_foreground)), + Phase::Idle if self.dirty => Some((t!("editor.state.modified"), cx.theme().warning)), Phase::Idle => None, }; let command = |command: EditorCommand| { @@ -565,7 +562,7 @@ impl EditorPanel { div() .id(("editor-state", id.0)) .test_support() - .aria_label(text) + .aria_label(text.clone()) .flex_shrink_0() .child(soft_tag(text, color)), ) @@ -575,7 +572,7 @@ impl EditorPanel { .ghost() .small() .icon(Icon::new(CatalogIcon::RefreshCw)) - .label("重新加载") + .label(t!("editor.command.reload")) .disabled(self.phase != Phase::Idle) .on_click(command(EditorCommand::Reload)), ) @@ -583,10 +580,10 @@ impl EditorPanel { Button::new(("editor-save", id.0)) .small() .icon(Icon::new(CatalogIcon::Save)) - .label("保存") + .label(t!("common.save")) .disabled(!self.dirty || self.phase != Phase::Idle) .tooltip_with_action( - "保存", + t!("common.save"), &EditorShortcut(EditorCommand::Save), Some(EDITOR_CONTEXT), ) @@ -682,16 +679,19 @@ fn tab_menu( let action = |command| Box::new(EditorAction::new(id, command)); let menu = menu .menu_with_icon( - "保存", + t!("common.save"), Icon::new(CatalogIcon::Save), action(EditorCommand::Save), ) .menu_with_icon( - "重新加载", + t!("editor.command.reload"), Icon::new(CatalogIcon::RefreshCw), action(EditorCommand::Reload), ) - .menu("复制路径", action(EditorCommand::CopyPath)) + .menu( + t!("editor.command.copy_path"), + action(EditorCommand::CopyPath), + ) .separator(); close_tab_items(menu, CenterTab::Editor(id), group, panel, cx) } @@ -717,7 +717,7 @@ impl Render for EditorPanel { .rounded_none() .h_full() .readonly(self.phase == Phase::Reloading) - .aria_label(format!("编辑 {}", self.name())), + .aria_label(t!("editor.text_label", name = self.name())), ), ) } diff --git a/src/editor/model.rs b/src/editor/model.rs index 4072087..996e3f4 100644 --- a/src/editor/model.rs +++ b/src/editor/model.rs @@ -2,6 +2,7 @@ //! how it indents, and what the status line says. use crate::host::HostId; +use crate::i18n::t; use crate::sftp::{RemotePath, TextFormat}; use gpui_kit::component::input::TabSize; use std::path::PathBuf; @@ -78,7 +79,12 @@ pub fn indentation(language: &str, text: &str) -> TabSize { /// 「行 12,列 5」, from the editor's zero-based position. pub fn cursor_label(line: u32, character: u32) -> String { - format!("行 {},列 {}", line + 1, character + 1) + t!( + "editor.status.cursor", + line = line + 1, + column = character + 1 + ) + .to_string() } /// 「UTF-8 · LF」, 「UTF-8 BOM · CRLF」. diff --git a/src/explorer/explorer_panel.rs b/src/explorer/explorer_panel.rs index 0d5518e..ba76893 100644 --- a/src/explorer/explorer_panel.rs +++ b/src/explorer/explorer_panel.rs @@ -13,6 +13,7 @@ use crate::{ }, connection::{ConnectionPrompt, ConnectionPromptReply, Latency}, host::{BookmarkSide, ConnectionState, HostId, HostStore}, + i18n::t, sftp::{ DownloadRequest, RemotePath, SftpCommand, SftpEvent, SharedLocalDirectoryProvider, SharedSftpTransportProvider, TextFile, TransferDirection, TransferPhase, TransferQuestion, @@ -119,7 +120,7 @@ impl ExplorerPanel { let places = local_provider .places() .into_iter() - .map(|(title, path)| (title.into(), path.to_string_lossy().into_owned())) + .map(|(place, path)| (place, path.to_string_lossy().into_owned())) .collect(); let local = cx.new(|cx| { FilePane::new( @@ -162,7 +163,9 @@ impl ExplorerPanel { } }) { - let _ = failed.try_send(SftpEvent::Disconnected(format!("无法启动 SFTP:{error}"))); + let _ = failed.try_send(SftpEvent::Disconnected( + t!("explorer.connection.start_failed", error = error).to_string(), + )); } // Poll snapshots as the terminal engine does. Worker threads never wake // a foreground-only GPUI task directly. @@ -296,24 +299,23 @@ impl ExplorerPanel { if window.has_active_dialog(cx) { return; } - let name = self - .store - .read(cx) - .host(self.host_id) - .map_or_else(|| "服务器".into(), |host| host.name.clone()); + let name = self.store.read(cx).host(self.host_id).map_or_else( + || t!("explorer.connection.server"), + |host| host.name.clone(), + ); let reason = self .remote .read(cx) .problem() - .unwrap_or("SFTP 已断开") - .to_string(); + .map(SharedString::from) + .unwrap_or_else(|| t!("explorer.connection.lost")); let can_reconnect = self.can_reconnect(); let description = if can_reconnect { - format!("{reason}。重新连接后再操作远程文件。") + t!("explorer.connection.lost_reconnect", reason = reason) } else if self.head_stopped() { - format!("{reason}。传输队列里有停止的批次,点它的「继续」会重新连接并接着传输。") + t!("explorer.connection.lost_stopped_batch", reason = reason) } else { - format!("{reason}。正在自动重新连接,请稍候。") + t!("explorer.connection.lost_reconnecting", reason = reason) }; let sender = self.sender(); let focus = window.focused(cx); @@ -328,14 +330,14 @@ impl ExplorerPanel { } }; let dialog = dialog - .title(format!("与 {name} 的 SFTP 连接已断开")) + .title(t!("explorer.connection.lost_title", name = name)) .description(description.clone()); if can_reconnect { dialog .button_props( DialogButtonProps::default() - .ok_text("重新连接") - .cancel_text("取消"), + .ok_text(t!("explorer.connection.reconnect")) + .cancel_text(t!("common.cancel")), ) .show_cancel(true) .on_cancel(restore.clone()) @@ -348,7 +350,7 @@ impl ExplorerPanel { }) } else { dialog - .button_props(DialogButtonProps::default().ok_text("知道了")) + .button_props(DialogButtonProps::default().ok_text(t!("explorer.ok"))) .on_ok(restore) } }); @@ -417,8 +419,9 @@ impl ExplorerPanel { pub fn disconnect(&mut self, window: &mut Window, cx: &mut Context) { self.send(SftpCommand::Disconnect); self.state = ConnectionState::Disconnected; - self.remote - .update(cx, |pane, cx| pane.disconnected("SFTP 已断开".into(), cx)); + self.remote.update(cx, |pane, cx| { + pane.disconnected(t!("explorer.connection.lost").to_string(), cx) + }); self.close_transfer_dialog(window, cx); self.sync_available(cx); cx.emit(ExplorerPanelEvent::StateChanged(self.id, self.host_id)); @@ -775,7 +778,7 @@ impl ExplorerPanel { files: true, directories: true, multiple: true, - prompt: Some("选择要上传的文件或目录".into()), + prompt: Some(t!("explorer.choose_upload")), }); let target = self.remote.read(cx).path(); cx.spawn_in(window, async move |this, cx| { @@ -935,13 +938,13 @@ impl Panel for ExplorerPanel { Button::new(("open-sftp", id.0)) .icon(Icon::new(CatalogIcon::FolderTree)) .label("SFTP") - .tooltip("打开 SFTP 文件浏览") + .tooltip(t!("explorer.tab.open_sftp_tip")) .on_click(move |_, window, cx| { window.dispatch_action(Box::new(OpenExplorer(host_id)), cx) }), Button::new(("reconnect-sftp", id.0)) .icon(Icon::new(CatalogIcon::RefreshCw)) - .tooltip("重新连接") + .tooltip(t!("explorer.connection.reconnect")) .disabled(!self.can_reconnect()) .on_click(move |_, window, cx| { window.dispatch_action( @@ -1068,18 +1071,18 @@ impl TabMenu { fn build(&self, menu: PopupMenu, cx: &App) -> PopupMenu { let (id, host_id) = (self.id, self.host_id); let copy_host = if self.host_is_ip { - "复制 IP 地址" + t!("explorer.tab.copy_ip") } else { - "复制主机名" + t!("explorer.tab.copy_host_name") }; let menu = menu .menu_with_icon( - "重命名标签…", + t!("explorer.tab.rename"), Icon::new(CatalogIcon::Pencil), Box::new(RenameExplorer(id)), ) .menu_with_icon( - "打开 SFTP", + t!("explorer.tab.open_sftp"), Icon::new(CatalogIcon::FolderTree), Box::new(OpenExplorer(host_id)), ) @@ -1089,7 +1092,7 @@ impl TabMenu { Box::new(CopyHostAddress(host_id)), ) .menu_with_icon_and_disabled( - "重新连接", + t!("explorer.connection.reconnect"), Icon::new(CatalogIcon::RefreshCw), Box::new(ExplorerAction::new(id, ExplorerCommand::Reconnect)), !self.can_reconnect, diff --git a/src/explorer/file_dialogs.rs b/src/explorer/file_dialogs.rs index d16e3d8..6cd63a4 100644 --- a/src/explorer/file_dialogs.rs +++ b/src/explorer/file_dialogs.rs @@ -2,6 +2,7 @@ use super::{ExplorerPanel, NewEntryKind, PaneOperation}; use crate::app::ExplorerCommand; +use crate::i18n::{t, tn}; use crate::shared::{commit_footer, dismiss_form_error, form_error_notification}; use gpui_kit::component::{ Sizable as _, WindowExt as _, @@ -21,19 +22,19 @@ pub fn validate_entry_name( existing: &[String], ) -> Result<(), String> { if name.trim().is_empty() { - return Err("名称不能为空".into()); + return Err(t!("explorer.name.empty").into()); } if matches!(name, "." | "..") { - return Err("名称不能是「.」或「..」".into()); + return Err(t!("explorer.name.dots").into()); } if name.contains(['/', '\0']) { - return Err("名称不能包含「/」".into()); + return Err(t!("explorer.name.slash").into()); } if original == Some(name) { - return Err("名称未改变".into()); + return Err(t!("explorer.name.unchanged").into()); } if existing.iter().any(|existing| existing == name) { - return Err(format!("已有名为「{name}」的项目")); + return Err(t!("explorer.name.exists", name = name).into()); } Ok(()) } @@ -47,14 +48,14 @@ enum NameIntent { struct NameForm { input: Entity, - label: &'static str, + label: SharedString, } impl Render for NameForm { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { v_flex().gap_2().child( Form::new().child( Field::new() - .label(self.label) + .label(self.label.clone()) .required(true) .child(Input::new(&self.input).id("entry-name").small()), ), @@ -76,26 +77,28 @@ impl ExplorerPanel { return; } let title = if let [name] = names.as_slice() { - format!("删除「{name}」?") + t!("explorer.delete.title_one", name = name) } else { - format!("删除 {} 个项目?", names.len()) + tn!("explorer.delete.title_several", names.len()) }; let description = if remote { - "远程文件无法恢复,目录会连同其中全部内容一起删除。" + t!("explorer.delete.remote") + } else if cfg!(windows) { + t!("explorer.delete.recycle_bin") } else { - "项目会移到废纸篓。" + t!("explorer.delete.trash") }; let sender = self.sender(); let focus = window.focused(cx); window.open_alert_dialog(cx, move |dialog, _, _| { dialog .title(title.clone()) - .description(description) + .description(description.clone()) .button_props( DialogButtonProps::default() - .ok_text("删除") + .ok_text(t!("common.delete")) .ok_variant(ButtonVariant::Danger) - .cancel_text("取消"), + .cancel_text(t!("common.cancel")), ) .show_cancel(true) .on_cancel({ @@ -163,16 +166,16 @@ impl ExplorerPanel { .collect(); let (title, initial, commit, label) = match &intent { NameIntent::Rename(name) => ( - format!("重命名「{name}」"), + t!("explorer.rename.title", name = name), name.clone(), - "重命名", - "新名称", + t!("explorer.rename.commit"), + t!("explorer.rename.label"), ), NameIntent::New(kind) => ( - kind.title().to_string(), + kind.title(), kind.default_name().to_string(), - "创建", - "名称", + t!("explorer.new.commit"), + t!("explorer.new.label"), ), }; let input = cx.new(|cx| InputState::new(window, cx).default_value(initial)); @@ -185,7 +188,7 @@ impl ExplorerPanel { // Closed by its buttons or Escape, not by a click beside it. .overlay_closable(false) .child(form.clone()) - .footer(commit_footer("commit", commit)) + .footer(commit_footer("commit", commit.clone())) .on_ok({ let (form, sender, intent, existing) = ( form.clone(), diff --git a/src/explorer/file_edit.rs b/src/explorer/file_edit.rs index 0ff86a1..8478665 100644 --- a/src/explorer/file_edit.rs +++ b/src/explorer/file_edit.rs @@ -10,6 +10,7 @@ use super::preview::{IMAGE_LIMIT, Preview, PreviewContent, PreviewKind, open_pre use super::{ExplorerPanel, ExplorerPanelEvent, format_size}; use crate::app::{ExplorerAction, ExplorerCommand, ExplorerDispatch as _}; use crate::host::ConnectionState; +use crate::i18n::t; use crate::sftp::{ EDIT_LIMIT, FileBytes, FileStamp, ReadFailure, RemotePath, SaveFailure, SftpCommand, TextFile, read_local_bytes, read_local_text, @@ -191,9 +192,11 @@ impl ExplorerPanel { path: path.clone(), }); Some(cx.spawn(async move |_, _| { - receiver - .await - .unwrap_or_else(|_| Err(ReadFailure::Failed("SFTP 连接已断开".into()))) + receiver.await.unwrap_or_else(|_| { + Err(ReadFailure::Failed( + t!("explorer.file.connection_lost").into(), + )) + }) })) } } @@ -227,9 +230,11 @@ impl ExplorerPanel { limit, }); Some(cx.spawn(async move |_, _| { - receiver - .await - .unwrap_or_else(|_| Err(ReadFailure::Failed("SFTP 连接已断开".into()))) + receiver.await.unwrap_or_else(|_| { + Err(ReadFailure::Failed( + t!("explorer.file.connection_lost").into(), + )) + }) })) } } @@ -259,9 +264,11 @@ impl ExplorerPanel { Some(cx.spawn(async move |_, _| { // The connection dropped before the answer: the write may have // stopped halfway. - receiver - .await - .unwrap_or_else(|_| Err(SaveFailure::Interrupted("SFTP 连接已断开".into()))) + receiver.await.unwrap_or_else(|_| { + Err(SaveFailure::Interrupted( + t!("explorer.file.connection_lost").into(), + )) + }) })) } @@ -310,7 +317,7 @@ impl ExplorerPanel { false } ConnectionState::Connecting => { - window.push_notification(Notification::warning("正在连接 SFTP,请稍候再试。"), cx); + window.push_notification(Notification::warning(t!("explorer.file.connecting")), cx); false } } @@ -393,28 +400,40 @@ impl ExplorerPanel { cx: &mut Context, ) { let name = location.name(); - let (title, what, limit) = match opening { - Opening::Edit => (format!("无法在编辑器中打开“{name}”"), "编辑器", EDIT_LIMIT), - Opening::Preview(PreviewKind::Image(_)) => { - (format!("无法预览“{name}”"), "预览", IMAGE_LIMIT) - } + let editing = opening == Opening::Edit; + let (title, limit) = match opening { + Opening::Edit => (t!("explorer.file.edit_refused", name = name), EDIT_LIMIT), + Opening::Preview(PreviewKind::Image(_)) => ( + t!("explorer.file.preview_refused", name = name), + IMAGE_LIMIT, + ), Opening::Preview(PreviewKind::Markdown) => { - (format!("无法预览“{name}”"), "预览", EDIT_LIMIT) + (t!("explorer.file.preview_refused", name = name), EDIT_LIMIT) } }; let reason = match &failure { - ReadFailure::TooLarge(size) => format!( - "文件有 {},{what}只打开 {} 以内的文件。", - format_size(*size), - format_size(limit) - ), - ReadFailure::NotText => { - "它不是 UTF-8 编码的文本文件:可能是二进制文件,或用了 GBK 等其他编码。".into() + ReadFailure::TooLarge(size) => { + let (size, limit) = (format_size(*size), format_size(limit)); + if editing { + t!( + "explorer.file.too_large_for_editor", + size = size, + limit = limit + ) + } else { + t!( + "explorer.file.too_large_for_preview", + size = size, + limit = limit + ) + } } - ReadFailure::NotFile => "它不是普通文件。".into(), + ReadFailure::NotText => t!("explorer.file.not_text"), + ReadFailure::NotFile => t!("explorer.file.not_file"), ReadFailure::Failed(message) => { window.push_notification( - Notification::error(message.clone()).title(format!("无法打开“{name}”")), + Notification::error(message.clone()) + .title(t!("explorer.file.open_failed", name = name)), cx, ); return; @@ -422,7 +441,7 @@ impl ExplorerPanel { }; let download = self.download_action(location, cx); let description = if download.is_some() { - format!("{reason}可以下载到本机,用别的程序打开。") + t!("explorer.file.download_instead", reason = reason) } else { reason }; @@ -433,8 +452,8 @@ impl ExplorerPanel { Some(download) => alert .button_props( DialogButtonProps::default() - .ok_text("下载…") - .cancel_text("取消"), + .ok_text(t!("explorer.file.download")) + .cancel_text(t!("common.cancel")), ) .show_cancel(true) .on_ok({ @@ -448,7 +467,7 @@ impl ExplorerPanel { true } }), - None => alert.button_props(DialogButtonProps::default().ok_text("知道了")), + None => alert.button_props(DialogButtonProps::default().ok_text(t!("explorer.ok"))), } }); } diff --git a/src/explorer/file_listing.rs b/src/explorer/file_listing.rs index e6885c1..26b727a 100644 --- a/src/explorer/file_listing.rs +++ b/src/explorer/file_listing.rs @@ -1,5 +1,7 @@ use crate::app::ExplorerDispatch as _; use crate::app::{CatalogIcon, ExplorerAction, ExplorerCommand}; +use crate::i18n::{t, tn}; +use crate::sftp::TransferDirection; use gpui_kit::component::{ ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, table::{Column, ColumnSort, TableDelegate, TableState}, @@ -33,7 +35,32 @@ pub(super) struct ListingContext { /// Where the rows are, for the selection rectangle. pub geometry: Rc, /// What the list says while it has no rows. - pub placeholder: SharedString, + pub placeholder: ListPlaceholder, +} + +/// What the list says while it has no rows, put in words as it is drawn. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(super) enum ListPlaceholder { + Connecting, + #[default] + Reading, + OnlyHidden, + Empty, + NotConnected, + Unreadable, +} + +impl ListPlaceholder { + fn text(self) -> SharedString { + match self { + Self::Connecting => t!("explorer.list.connecting"), + Self::Reading => t!("explorer.list.reading"), + Self::OnlyHidden => t!("explorer.list.only_hidden"), + Self::Empty => t!("explorer.list.empty"), + Self::NotConnected => t!("explorer.list.not_connected"), + Self::Unreadable => t!("explorer.list.unreadable"), + } + } } /// Where the list and its rows are on screen, recorded as they paint, so a @@ -112,27 +139,41 @@ pub struct FileListing { /// WinSCP's columns for each side. Column widths are an API boundary that /// takes `Pixels`. fn columns(side: PaneSide) -> Vec { + let column = |key: &'static str, width: f32| { + Column::new(key, column_title(key)) + .width(px(width)) + .sortable() + }; match side { PaneSide::Local => vec![ name_column(px(260.)), - Column::new("size", "大小").width(px(100.)).sortable(), - Column::new("type", "类型").width(px(100.)).sortable(), - Column::new("modified", "修改时间") - .width(px(150.)) - .sortable(), + column("size", 100.), + column("type", 100.), + column("modified", 150.), ], PaneSide::Remote => vec![ name_column(px(240.)), - Column::new("size", "大小").width(px(100.)).sortable(), - Column::new("modified", "修改时间") - .width(px(150.)) - .sortable(), - Column::new("rights", "权限").width(px(96.)).sortable(), - Column::new("owner", "所有者").width(px(80.)).sortable(), + column("size", 100.), + column("modified", 150.), + column("rights", 96.), + column("owner", 80.), ], } } +/// A column's title, by its key, in the interface language. +fn column_title(key: &str) -> SharedString { + match key { + "name" => t!("explorer.column.name"), + "size" => t!("explorer.column.size"), + "type" => t!("explorer.column.type"), + "modified" => t!("explorer.column.modified"), + "rights" => t!("explorer.column.rights"), + "owner" => t!("explorer.column.owner"), + _ => SharedString::default(), + } +} + impl FileListing { pub fn new(side: PaneSide) -> Self { Self { @@ -150,6 +191,14 @@ impl FileListing { self.context = context; } + /// Title the columns in the interface language again, widths as they + /// are; the table takes the titles on its next `refresh`. + pub(super) fn retitle_columns(&mut self) { + for column in &mut self.columns { + column.name = column_title(&column.key); + } + } + /// The rows shown, in display order. pub fn rows(&self) -> &[FileEntry] { &self.rows @@ -209,8 +258,16 @@ impl FileListing { }; let paths = names.iter().map(|name| self.child_path(name)); match self.side { - PaneSide::Local => drag_files(cell, paths.map(PathBuf::from).collect(), "上传"), - PaneSide::Remote => drag_files(cell, paths.collect::>(), "下载"), + PaneSide::Local => drag_files( + cell, + paths.map(PathBuf::from).collect(), + TransferDirection::Upload, + ), + PaneSide::Remote => drag_files( + cell, + paths.collect::>(), + TransferDirection::Download, + ), } } @@ -549,7 +606,7 @@ impl TableDelegate for FileListing { _: &mut Window, cx: &mut Context>, ) -> impl IntoElement { - let text = self.context.placeholder.clone(); + let text = self.context.placeholder.text(); div() .id("list-placeholder") .size_full() @@ -568,7 +625,7 @@ impl TableDelegate for FileListing { /// included, so it has none of the table's: the cell and the title pad /// themselves as the table would (`px_1p5`, the small table's 6 px). fn name_column(width: Pixels) -> Column { - Column::new("name", "名称") + Column::new("name", column_title("name")) .width(width) .paddings(Edges::all(px(0.))) .sortable() @@ -583,16 +640,20 @@ pub(super) struct FilesDrag

{ pub(super) type LocalFilesDrag = FilesDrag; pub(super) type RemoteFilesDrag = FilesDrag; -/// Let `cell` be dragged to the other pane, carrying `paths`. `verb` is what -/// dropping them there does. -fn drag_files(cell: Stateful

, paths: Vec

, verb: &'static str) -> Stateful

{ +/// Let `cell` be dragged to the other pane, carrying `paths`, which dropping +/// there moves in `direction`. +fn drag_files( + cell: Stateful
, + paths: Vec

, + direction: TransferDirection, +) -> Stateful

{ let drag = FilesDrag { paths, spot: DropSpot::default(), }; cell.on_drag(drag, move |drag, _, _, cx| { cx.new(|_| FileDragPreview { - verb, + direction, count: drag.paths.len(), spot: drag.spot.clone(), }) @@ -675,7 +736,7 @@ impl DropSpot { } struct FileDragPreview { - verb: &'static str, + direction: TransferDirection, count: usize, spot: DropSpot, } @@ -698,7 +759,10 @@ impl Render for FileDragPreview { if !droppable { return div().child(pointer).into_any_element(); } - let text = format!("{} {} 个项目", self.verb, self.count); + let text = match self.direction { + TransferDirection::Upload => tn!("explorer.drag.upload", self.count), + TransferDirection::Download => tn!("explorer.drag.download", self.count), + }; div() .id("file-drag-preview") .px_3() diff --git a/src/explorer/file_pane.rs b/src/explorer/file_pane.rs index 2622477..6e1b49c 100644 --- a/src/explorer/file_pane.rs +++ b/src/explorer/file_pane.rs @@ -1,7 +1,9 @@ use super::{ ClickMode, CursorMotion, ExplorerId, FileEntry, FileListing, FileSizeFormat, LoadIntent, NavigationHistory, PreviewKind, Selection, ShowHiddenFiles, child_path, - file_listing::{ListGeometry, ListingContext, MenuHit, Pressed, accept_drops, offer_drop}, + file_listing::{ + ListGeometry, ListPlaceholder, ListingContext, MenuHit, Pressed, accept_drops, offer_drop, + }, pane_menu::{ PaneMenuState, bookmark_menu, directory_menu, item_menu, new_menu, size_format_menu, }, @@ -15,7 +17,8 @@ use crate::{ REMOTE_FILE_LIST_CONTEXT, ToggleHiddenFiles, }, host::{BookmarkSide, ConnectionState, HostId, HostStore}, - sftp::{DirectoryListing, SharedLocalDirectoryProvider}, + i18n::{UiLocale, t, tn}, + sftp::{DirectoryListing, Place, SharedLocalDirectoryProvider}, }; use gpui_kit::component::{ ActiveTheme as _, Disableable as _, Icon, IconName, Sizable as _, @@ -80,11 +83,20 @@ impl PaneSide { pub fn from_remote(remote: bool) -> Self { if remote { Self::Remote } else { Self::Local } } - pub fn label(self) -> &'static str { + /// What assistive technology calls this side's path label. + pub(super) fn path_label_name(self) -> SharedString { if self == Self::Local { - "本地" + t!("explorer.path.local") } else { - "远程" + t!("explorer.path.remote") + } + } + /// What assistive technology calls this side's 目录列表 select. + fn directory_select_name(self) -> SharedString { + if self == Self::Local { + t!("explorer.directory_select.local") + } else { + t!("explorer.directory_select.remote") } } fn pane_id(self) -> &'static str { @@ -171,7 +183,7 @@ pub struct FilePane { pub(super) path: String, home: String, /// Well-known local places for the 目录列表 select; empty for remote. - places: Vec<(SharedString, String)>, + places: Vec<(Place, String)>, table: Entity>, /// The path label part under the pointer, by the directory it opens. pub(super) hovered_part: Option, @@ -224,7 +236,7 @@ impl FilePane { explorer: ExplorerId, host_id: HostId, home: String, - places: Vec<(SharedString, String)>, + places: Vec<(Place, String)>, store: Entity, dispatch: FocusHandle, window: &mut Window, @@ -262,6 +274,15 @@ impl FilePane { ), cx.observe(&store, |_, _, cx| cx.notify()), cx.observe_global::(|pane, cx| pane.sync_hidden_files(cx)), + // The column titles and the select's groups are kept by + // gpui-kit, so they are given again in the new language. + cx.observe_global_in::(window, |pane, window, cx| { + pane.table.update(cx, |table, cx| { + table.delegate_mut().retitle_columns(); + table.refresh(cx); + }); + pane.sync_path_select(window, cx); + }), ]; let mut pane = Self { side, @@ -489,9 +510,7 @@ impl FilePane { pane: Some(cx.entity().downgrade()), menu_hit: self.menu_hit.clone(), geometry: self.geometry.clone(), - placeholder: self - .placeholder(self.table.read(cx).delegate().hidden_count()) - .into(), + placeholder: self.placeholder(self.table.read(cx).delegate().hidden_count()), }; self.table.update(cx, |table, cx| { table.delegate_mut().configure(context); @@ -504,22 +523,22 @@ impl FilePane { /// the read is slow, like the status line, so a quick refresh does not /// flicker. A directory of nothing but hidden files, not shown, is not /// empty. - fn placeholder(&self, hidden: usize) -> &'static str { + fn placeholder(&self, hidden: usize) -> ListPlaceholder { if self.connection == ConnectionState::Connecting { - "正在连接 SFTP…" + ListPlaceholder::Connecting } else if self.loading && (!self.listed || self.slow_load) { - "正在读取目录…" + ListPlaceholder::Reading } else if self.listed && hidden > 0 { - "只有隐藏文件" + ListPlaceholder::OnlyHidden } else if self.listed { - "空目录" + ListPlaceholder::Empty } else if self.connection == ConnectionState::Disconnected { - "未连接" + ListPlaceholder::NotConnected } else if self.error.is_some() { - "无法读取目录" + ListPlaceholder::Unreadable } else { // Before the first read is asked for. - "正在读取目录…" + ListPlaceholder::Reading } } fn update_selection( @@ -686,21 +705,22 @@ impl FilePane { /// wrong goes to the window's status line instead, see `problem`. fn render_status(&self, file_count: usize, selected: usize, cx: &App) -> impl IntoElement { let hidden = self.table.read(cx).delegate().hidden_count(); - let status = if self.connection == ConnectionState::Connecting { - "正在连接 SFTP…".to_string() + let status: SharedString = if self.connection == ConnectionState::Connecting { + t!("explorer.list.connecting") } else if let Some(name) = &self.opening { - format!("正在打开 {name}…") + t!("explorer.status.opening", name = name) } else if self.slow_load { - "正在读取目录…".to_string() + t!("explorer.list.reading") } else { - let mut status = format!("{file_count} 个项目"); + // Counts, each a phrase of its own, joined by a dot. + let mut parts = vec![tn!("explorer.status.items", file_count)]; if hidden > 0 { - status.push_str(&format!(" · 隐藏 {hidden} 项")); + parts.push(tn!("explorer.status.hidden", hidden)); } if selected > 0 { - status.push_str(&format!(" · 已选择 {selected} 项")); + parts.push(tn!("explorer.status.selected", selected)); } - status + parts.join(" · ").into() }; h_flex() .id("pane-status") @@ -907,27 +927,30 @@ impl FilePane { let side = self.side; let chain = path_ancestors(&self.path, side == PaneSide::Remote); let mut groups = vec![ - SearchableGroup::new("当前路径").items(chain.into_iter().enumerate().map( - |(depth, (title, path))| PathChoice { - title: title.into(), - path: path.into(), - depth, - home: false, - side, - }, - )), + SearchableGroup::new(t!("explorer.directory_select.current")).items( + chain + .into_iter() + .enumerate() + .map(|(depth, (title, path))| PathChoice { + title: title.into(), + path: path.into(), + depth, + home: false, + side, + }), + ), ]; if !self.places.is_empty() { groups.push( - SearchableGroup::new("位置").items(self.places.iter().enumerate().map( - |(ix, (title, path))| PathChoice { - title: title.clone(), + SearchableGroup::new(t!("explorer.directory_select.places")).items( + self.places.iter().map(|(place, path)| PathChoice { + title: place.title(), path: path.clone().into(), depth: 0, - home: ix == 0, + home: *place == Place::Home, side, - }, - )), + }), + ), ); } let current: SharedString = self.path.clone().into(); @@ -950,7 +973,7 @@ impl FilePane { .background_spawn(async move { provider .list(&PathBuf::from(path)) - .map_err(|e| format!("无法读取目录:{e}")) + .map_err(|e| t!("explorer.list.read_failed", error = e).to_string()) }) .await; let _ = this.update_in(cx, |this, window, cx| { @@ -1000,14 +1023,14 @@ impl Render for FilePane { let navigable = self.takes_commands(); // A toolbar button dispatches the same command as its key binding, // and its tooltip shows that binding. - let tool = |id: &'static str, icon: Icon, tip: &'static str, command: ExplorerCommand| { + let tool = |id: &'static str, icon: Icon, tip: SharedString, command: ExplorerCommand| { let dispatch = self.dispatch.clone(); let shortcut = ExplorerShortcut(command.clone()); Button::new(id) .ghost() .small() .icon(icon) - .accessibility_label(tip) + .accessibility_label(tip.clone()) .tooltip_with_action(tip, &shortcut, Some(context)) .on_click(move |_, window, cx| { dispatch.dispatch_explorer_action( @@ -1039,9 +1062,9 @@ impl Render for FilePane { .child(row) }; let hidden_files_tip = if state.show_hidden { - "不显示隐藏文件" + t!("explorer.toolbar.hide_hidden_files") } else { - "显示隐藏文件" + t!("explorer.command.show_hidden_files") }; let navigation = toolbar() // `Select` fills its parent (`size_full`), so it needs a sized @@ -1052,7 +1075,7 @@ impl Render for FilePane { .id("path-select") .small() .menu_width(rems(18.)) - .accessibility_label(format!("{}目录", self.side.label())) + .accessibility_label(self.side.directory_select_name()) .disabled(!navigable), ), ) @@ -1067,7 +1090,7 @@ impl Render for FilePane { tool( "bookmarks", Icon::new(CatalogIcon::Bookmark), - "打开目录/书签…", + t!("explorer.command.open_directory"), ExplorerCommand::OpenDirectory { remote }, ) .disabled(!navigable), @@ -1080,7 +1103,7 @@ impl Render for FilePane { tool( "up", Icon::new(CatalogIcon::FolderUp), - "上级目录", + t!("explorer.command.up"), ExplorerCommand::Up { remote }, ) .disabled(!navigable || !state.can_go_up), @@ -1089,7 +1112,7 @@ impl Render for FilePane { tool( "root", Icon::new(CatalogIcon::FolderRoot), - "根目录", + t!("explorer.command.root"), ExplorerCommand::Root { remote }, ) .disabled(!navigable || !state.can_go_up), @@ -1098,7 +1121,7 @@ impl Render for FilePane { tool( "home", Icon::new(CatalogIcon::House), - "主目录", + t!("explorer.command.home"), ExplorerCommand::Home { remote }, ) .disabled(!navigable || !state.can_go_home), @@ -1107,7 +1130,7 @@ impl Render for FilePane { tool( "refresh", Icon::new(CatalogIcon::RefreshCw), - "刷新", + t!("explorer.command.refresh"), ExplorerCommand::Refresh { remote }, ) .disabled(!navigable), @@ -1117,7 +1140,7 @@ impl Render for FilePane { tool( "back", Icon::new(IconName::ArrowLeft), - "后退", + t!("explorer.command.back"), ExplorerCommand::Back { remote }, ) .disabled(!navigable || !state.can_go_back), @@ -1126,7 +1149,7 @@ impl Render for FilePane { tool( "forward", Icon::new(IconName::ArrowRight), - "前进", + t!("explorer.command.forward"), ExplorerCommand::Forward { remote }, ) .disabled(!navigable || !state.can_go_forward), @@ -1144,7 +1167,7 @@ impl Render for FilePane { } else { CatalogIcon::EyeOff })) - .accessibility_label(hidden_files_tip) + .accessibility_label(hidden_files_tip.clone()) .tooltip_with_action( hidden_files_tip, &ToggleHiddenFiles(self.side), @@ -1163,9 +1186,19 @@ impl Render for FilePane { ); let transfer_shortcut = ExplorerShortcut(ExplorerCommand::Transfer { remote }); let (id, icon, label, tip) = if remote { - ("download", CatalogIcon::Download, "下载…", "下载所选项目") + ( + "download", + CatalogIcon::Download, + t!("explorer.command.download"), + t!("explorer.toolbar.download_tip"), + ) } else { - ("upload", CatalogIcon::Upload, "上传…", "上传所选项目") + ( + "upload", + CatalogIcon::Upload, + t!("explorer.command.upload"), + t!("explorer.toolbar.upload_tip"), + ) }; let transfer = Button::new(id) .icon(Icon::new(icon)) @@ -1192,7 +1225,7 @@ impl Render for FilePane { .disabled(!state.can_transfer) .dropdown_menu(move |menu, _, _| { menu.menu( - "选择文件上传…", + t!("explorer.toolbar.choose_files"), Box::new(ExplorerAction::new(sid, ExplorerCommand::ChooseFiles)), ) }) @@ -1205,7 +1238,7 @@ impl Render for FilePane { tool( "delete", Icon::new(CatalogIcon::Trash), - "删除", + t!("common.delete"), ExplorerCommand::Delete { remote }, ) .disabled(selected == 0 || !state.can_modify), @@ -1214,7 +1247,7 @@ impl Render for FilePane { tool( "rename", Icon::new(CatalogIcon::SquarePen), - "重命名…", + t!("explorer.command.rename"), ExplorerCommand::Rename { remote }, ) .disabled(selected != 1 || !state.can_modify), @@ -1223,7 +1256,7 @@ impl Render for FilePane { tool( "properties", Icon::new(IconName::Info), - "属性…", + t!("explorer.command.properties"), ExplorerCommand::Properties { remote }, ) .disabled(selected == 0 || !state.can_modify), @@ -1235,7 +1268,7 @@ impl Render for FilePane { .ghost() .small() .icon(Icon::new(CatalogIcon::FolderPlus)) - .label("新建") + .label(t!("explorer.command.new")) .dropdown_caret(true) .disabled(!state.can_modify) .dropdown_menu(move |popup, _, _| new_menu(popup, &menu)) diff --git a/src/explorer/image_preview.rs b/src/explorer/image_preview.rs index fc55c73..bf6c11e 100644 --- a/src/explorer/image_preview.rs +++ b/src/explorer/image_preview.rs @@ -22,6 +22,7 @@ use crate::app::{ ActualSizePreview, CatalogIcon, FitPreview, IMAGE_PREVIEW_CONTEXT, ZoomPreviewIn, ZoomPreviewOut, }; +use crate::i18n::t; /// The zoom levels the buttons and keys step through, as a share of the /// image's own size (one image pixel to one point at 100%). @@ -234,15 +235,14 @@ impl Render for ImagePreview { .unwrap_or_else(|| size(viewport.width * 0.8 - px(48.), height)); let scale = pixels.map(|image| self.scale(frame, image)); let info: SharedString = match pixels { - Some(image) => format!( - "{} × {} 像素 · {}", - f32::from(image.width) as u32, - f32::from(image.height) as u32, - format_size(self.bytes) + Some(image) => t!( + "explorer.preview.image_info", + width = f32::from(image.width) as u32, + height = f32::from(image.height) as u32, + size = format_size(self.bytes) ), - None => format_size(self.bytes), - } - .into(); + None => format_size(self.bytes).into(), + }; // Only the scale: whether it fits is the 适合窗口 button's state. let zoom_label: SharedString = scale.map(percent).unwrap_or_default().into(); let focus = self.focus_handle.clone(); @@ -273,8 +273,12 @@ impl Render for ImagePreview { .ghost() .small() .icon(Icon::new(CatalogIcon::ZoomOut)) - .accessibility_label("缩小") - .tooltip_with_action("缩小", &ZoomPreviewOut, Some(IMAGE_PREVIEW_CONTEXT)) + .accessibility_label(t!("explorer.preview.zoom_out")) + .tooltip_with_action( + t!("explorer.preview.zoom_out"), + &ZoomPreviewOut, + Some(IMAGE_PREVIEW_CONTEXT), + ) .on_click(command(Box::new(ZoomPreviewOut))), ) .child( @@ -293,27 +297,35 @@ impl Render for ImagePreview { .ghost() .small() .icon(Icon::new(CatalogIcon::ZoomIn)) - .accessibility_label("放大") - .tooltip_with_action("放大", &ZoomPreviewIn, Some(IMAGE_PREVIEW_CONTEXT)) + .accessibility_label(t!("explorer.preview.zoom_in")) + .tooltip_with_action( + t!("explorer.preview.zoom_in"), + &ZoomPreviewIn, + Some(IMAGE_PREVIEW_CONTEXT), + ) .on_click(command(Box::new(ZoomPreviewIn))), ) .child( Button::new("preview-fit") .ghost() .small() - .label("适合窗口") + .label(t!("explorer.preview.fit")) .selected(self.zoom == Zoom::Fit) - .tooltip_with_action("适合窗口", &FitPreview, Some(IMAGE_PREVIEW_CONTEXT)) + .tooltip_with_action( + t!("explorer.preview.fit"), + &FitPreview, + Some(IMAGE_PREVIEW_CONTEXT), + ) .on_click(command(Box::new(FitPreview))), ) .child( Button::new("preview-actual-size") .ghost() .small() - .label("原图") + .label(t!("explorer.preview.actual_size")) .selected(self.zoom == Zoom::Scale(1.0)) .tooltip_with_action( - "原图(100%)", + t!("explorer.preview.actual_size_tip"), &ActualSizePreview, Some(IMAGE_PREVIEW_CONTEXT), ) @@ -412,14 +424,14 @@ impl Render for ImagePreview { .children(picture) .when(pixels.is_none(), |scroll| { let note = match &self.decoded { - Some(Err(_)) => "无法显示这张图片", - _ => "正在显示图片…", + Some(Err(_)) => t!("explorer.preview.undecodable"), + _ => t!("explorer.preview.decoding"), }; scroll.child( div() .id("preview-note") .test_support() - .aria_label(note) + .aria_label(note.clone()) .size_full() .flex() .items_center() diff --git a/src/explorer/model.rs b/src/explorer/model.rs index 1186d06..0a38704 100644 --- a/src/explorer/model.rs +++ b/src/explorer/model.rs @@ -1,6 +1,7 @@ //! Directory row snapshots and display formatting. use super::PaneSide; +use crate::i18n::t; use crate::sftp::{DirectoryEntry, EntryKind}; use gpui_kit::{App, Global, SharedString}; use serde::{Deserialize, Serialize}; @@ -18,17 +19,17 @@ pub enum NewEntryKind { } impl NewEntryKind { - pub fn title(self) -> &'static str { + pub fn title(self) -> SharedString { match self { - NewEntryKind::Folder => "新建文件夹", - NewEntryKind::File => "新建文件", + NewEntryKind::Folder => t!("explorer.new.folder_title"), + NewEntryKind::File => t!("explorer.new.file_title"), } } - pub fn default_name(self) -> &'static str { + pub fn default_name(self) -> SharedString { match self { - NewEntryKind::Folder => "新建文件夹", - NewEntryKind::File => "新建文件.txt", + NewEntryKind::Folder => t!("explorer.new.folder_name"), + NewEntryKind::File => t!("explorer.new.file_name"), } } } @@ -41,11 +42,11 @@ pub enum FileKind { } impl FileKind { - pub fn label(self) -> &'static str { + pub fn label(self) -> SharedString { match self { - FileKind::Dir => "文件夹", - FileKind::File => "文件", - FileKind::Symlink => "链接", + FileKind::Dir => t!("explorer.kind.folder"), + FileKind::File => t!("explorer.kind.file"), + FileKind::Symlink => t!("explorer.kind.link"), } } } @@ -149,12 +150,12 @@ impl FileEntry { /// The type shown in the 类型 column: the kind, or the extension for files. pub fn type_label(&self) -> String { if self.is_parent() { - return "上级目录".into(); + return t!("explorer.kind.parent").into(); } match self.kind { FileKind::File => match self.name.rsplit_once('.') { Some((stem, ext)) if !stem.is_empty() && !ext.is_empty() => { - format!("{} 文件", ext.to_uppercase()) + t!("explorer.kind.extension", ext = ext.to_uppercase()).into() } _ => self.kind.label().to_string(), }, @@ -317,11 +318,11 @@ impl FileSizeFormat { FileSizeFormat::Short, ]; - pub fn label(self) -> &'static str { + pub fn label(self) -> SharedString { match self { - FileSizeFormat::Bytes => "字节", - FileSizeFormat::Kilobytes => "千字节(KB)", - FileSizeFormat::Short => "简短格式(B、KB、MB、GB)", + FileSizeFormat::Bytes => t!("explorer.size_format.bytes"), + FileSizeFormat::Kilobytes => t!("explorer.size_format.kilobytes"), + FileSizeFormat::Short => t!("explorer.size_format.short"), } } diff --git a/src/explorer/open_directory_dialog.rs b/src/explorer/open_directory_dialog.rs index aeb279f..5c139ad 100644 --- a/src/explorer/open_directory_dialog.rs +++ b/src/explorer/open_directory_dialog.rs @@ -9,6 +9,7 @@ use crate::app::ExplorerDispatch as _; use crate::{ app::{CatalogIcon, ExplorerAction, ExplorerCommand}, host::{BookmarkSide, HostId, HostStore}, + i18n::t, shared::{commit_footer, dismiss_form_error, form_error_notification}, }; use gpui_kit::component::{ @@ -75,7 +76,10 @@ impl OpenDirectoryForm { fn open(&mut self, window: &mut Window, cx: &mut Context) -> bool { let path = self.directory(cx); if path.is_empty() { - window.push_notification(form_error_notification("请输入要打开的目录"), cx); + window.push_notification( + form_error_notification(t!("explorer.open_directory.missing")), + cx, + ); return false; } let remote = self.remote; @@ -151,7 +155,7 @@ impl OpenDirectoryForm { files: false, directories: true, multiple: false, - prompt: Some("选择要打开的目录".into()), + prompt: Some(t!("explorer.open_directory.choose")), }); cx.spawn_in(window, async move |this, cx| { if let Ok(Ok(Some(paths))) = choice.await @@ -177,7 +181,7 @@ impl OpenDirectoryForm { .id("bookmark-list") .test_support() .role(Role::ListBox) - .aria_label("书签") + .aria_label(t!("explorer.open_directory.bookmarks")) .track_focus(&self.list_focus) .flex_1() .min_w_0() @@ -203,7 +207,7 @@ impl OpenDirectoryForm { .px_2() .py_1() .text_color(theme.muted_foreground) - .child("暂无书签"), + .child(t!("explorer.bookmarks.none")), ) }) .children(bookmarks.iter().enumerate().map(|(ix, path)| { @@ -247,74 +251,96 @@ impl Render for OpenDirectoryForm { let bookmarks = self.bookmarks(cx); let selected = self.selected(cx); let typed = !self.directory(cx).is_empty(); - let button = |id: &'static str, label: &'static str| Button::new(id).small().label(label); + let button = |id: &'static str, label: SharedString| Button::new(id).small().label(label); v_flex().gap_3().child( Form::new() .child( - Field::new().label("目录").child( - h_flex() - .gap_2() - .child( - Input::new(&self.input) - .id("open-directory-path") - .small() - .flex_1(), - ) - .when(!self.remote, |this| { - this.child(button("open-directory-browse", "浏览…").on_click( - cx.listener(|this, _, window, cx| this.browse(window, cx)), - )) - }), - ), + Field::new() + .label(t!("explorer.open_directory.folder")) + .child( + h_flex() + .gap_2() + .child( + Input::new(&self.input) + .id("open-directory-path") + .small() + .flex_1(), + ) + .when(!self.remote, |this| { + this.child( + button("open-directory-browse", t!("explorer.browse")) + .on_click(cx.listener(|this, _, window, cx| { + this.browse(window, cx) + })), + ) + }), + ), ) .child( - Field::new().label("书签").child( - h_flex() - .gap_2() - .items_start() - .child(self.render_list(&bookmarks, selected, window, cx)) - .child( - v_flex() - .gap_1() - .w(rems(5.)) - .flex_shrink_0() - .child( - button("bookmark-add", "添加") + Field::new() + .label(t!("explorer.open_directory.bookmarks")) + .child( + h_flex() + .gap_2() + .items_start() + .child(self.render_list(&bookmarks, selected, window, cx)) + .child( + v_flex() + .gap_1() + .w(rems(5.)) + .flex_shrink_0() + .child( + button( + "bookmark-add", + t!("explorer.open_directory.add"), + ) .w_full() .disabled(!typed || selected.is_some()) - .on_click(cx.listener(|this, _, window, cx| { - this.add(window, cx) - })), - ) - .child( - button("bookmark-remove", "删除") + .on_click( + cx.listener(|this, _, window, cx| { + this.add(window, cx) + }), + ), + ) + .child( + button( + "bookmark-remove", + t!("explorer.open_directory.remove"), + ) .w_full() .disabled(selected.is_none()) - .on_click(cx.listener(|this, _, window, cx| { - this.remove(window, cx) - })), - ) - .child(div().h_4()) - .child( - button("bookmark-up", "上移") - .w_full() - .disabled(selected.is_none_or(|ix| ix == 0)) - .on_click(cx.listener(|this, _, window, cx| { - this.shift(-1, window, cx) - })), - ) - .child( - button("bookmark-down", "下移") + .on_click( + cx.listener(|this, _, window, cx| { + this.remove(window, cx) + }), + ), + ) + .child(div().h_4()) + .child( + button("bookmark-up", t!("explorer.open_directory.up")) + .w_full() + .disabled(selected.is_none_or(|ix| ix == 0)) + .on_click(cx.listener(|this, _, window, cx| { + this.shift(-1, window, cx) + })), + ) + .child( + button( + "bookmark-down", + t!("explorer.open_directory.down"), + ) .w_full() .disabled( selected.is_none_or(|ix| ix + 1 >= bookmarks.len()), ) - .on_click(cx.listener(|this, _, window, cx| { - this.shift(1, window, cx) - })), - ), - ), - ), + .on_click( + cx.listener(|this, _, window, cx| { + this.shift(1, window, cx) + }), + ), + ), + ), + ), ), ) } @@ -351,11 +377,14 @@ impl ExplorerPanel { }); window.open_dialog(cx, move |dialog, _, _| { dialog - .title("打开目录") + .title(t!("explorer.open_directory.title")) // Closed by its buttons or Escape, not by a click beside it. .overlay_closable(false) .child(form.clone()) - .footer(commit_footer("open-directory-confirm", "打开")) + .footer(commit_footer( + "open-directory-confirm", + t!("explorer.open_directory.open"), + )) .on_ok({ let form = form.clone(); move |_, window, cx| form.update(cx, |form, cx| form.open(window, cx)) diff --git a/src/explorer/pane_menu.rs b/src/explorer/pane_menu.rs index c2a9d95..8be5f7f 100644 --- a/src/explorer/pane_menu.rs +++ b/src/explorer/pane_menu.rs @@ -6,6 +6,7 @@ use super::{ExplorerId, FileSizeFormat, NewEntryKind, PaneSide, PreviewKind}; use crate::app::{ CatalogIcon, ExplorerAction, ExplorerCommand, SetFileSizeFormat, ToggleHiddenFiles, }; +use crate::i18n::t; use gpui_kit::component::{Icon, IconName, menu::PopupMenu}; use gpui_kit::*; @@ -54,7 +55,7 @@ pub(super) fn item_menu(menu: PopupMenu, state: &PaneMenuState) -> PopupMenu { menu } else { menu.menu_with_icon( - "编辑", + t!("explorer.command.edit"), Icon::new(CatalogIcon::FilePenLine), state.action(ExplorerCommand::Edit { remote, @@ -64,7 +65,7 @@ pub(super) fn item_menu(menu: PopupMenu, state: &PaneMenuState) -> PopupMenu { }; let menu = if kind.is_some() { menu.menu_with_icon( - "预览", + t!("explorer.command.preview"), Icon::new(CatalogIcon::Eye), state.action(ExplorerCommand::Preview { remote, @@ -79,9 +80,9 @@ pub(super) fn item_menu(menu: PopupMenu, state: &PaneMenuState) -> PopupMenu { menu }; let (verb, icon) = if remote { - ("下载…", CatalogIcon::Download) + (t!("explorer.command.download"), CatalogIcon::Download) } else { - ("上传…", CatalogIcon::Upload) + (t!("explorer.command.upload"), CatalogIcon::Upload) }; menu.menu_with_icon_and_disabled( verb, @@ -90,27 +91,27 @@ pub(super) fn item_menu(menu: PopupMenu, state: &PaneMenuState) -> PopupMenu { state.targets.is_empty() || !state.can_transfer, ) .menu_with_icon_and_disabled( - "复制路径", + t!("explorer.command.copy_path"), Icon::new(IconName::Copy), state.action(ExplorerCommand::CopySelectedPaths { remote }), state.targets.is_empty(), ) .separator() .menu_with_icon_and_disabled( - "删除", + t!("common.delete"), Icon::new(CatalogIcon::Trash), state.action(ExplorerCommand::Delete { remote }), none, ) .menu_with_icon_and_disabled( - "重命名…", + t!("explorer.command.rename"), Icon::new(CatalogIcon::SquarePen), state.action(ExplorerCommand::Rename { remote }), none || state.targets.len() != 1, ) .separator() .menu_with_icon_and_disabled( - "属性…", + t!("explorer.command.properties"), Icon::new(IconName::Info), state.action(ExplorerCommand::Properties { remote }), none, @@ -127,16 +128,17 @@ pub(super) fn directory_menu( let create = state.clone(); let menu = refresh_items(menu, state, window, cx) .menu_with_check( - "显示隐藏文件", + t!("explorer.command.show_hidden_files"), state.show_hidden, Box::new(ToggleHiddenFiles(PaneSide::from_remote(state.remote))), ) .separator(); - add_bookmark_item(menu, state) - .separator() - .submenu("新建", window, cx, move |menu, _, _| { - new_menu(menu, &create) - }) + add_bookmark_item(menu, state).separator().submenu( + t!("explorer.command.new"), + window, + cx, + move |menu, _, _| new_menu(menu, &create), + ) } /// Right-click on the path label: WinSCP's panel menu. @@ -149,13 +151,13 @@ pub(super) fn path_menu( let remote = state.remote; add_bookmark_item(refresh_items(menu, state, window, cx).separator(), state) .menu_with_icon( - "复制路径", + t!("explorer.command.copy_path"), Icon::new(IconName::Copy), state.action(ExplorerCommand::CopyPath { remote }), ) .separator() .menu_with_icon( - "打开目录/书签…", + t!("explorer.command.open_directory"), Icon::new(IconName::FolderOpen), state.action(ExplorerCommand::OpenDirectory { remote }), ) @@ -170,41 +172,41 @@ fn refresh_items( ) -> PopupMenu { let remote = state.remote; let go = state.clone(); - menu.submenu("前往", window, cx, move |menu, _, _| { + menu.submenu(t!("explorer.command.go"), window, cx, move |menu, _, _| { menu.menu_with_icon_and_disabled( - "上级目录", + t!("explorer.command.up"), Icon::new(CatalogIcon::FolderUp), go.action(ExplorerCommand::Up { remote }), !go.can_go_up, ) .menu_with_icon_and_disabled( - "根目录", + t!("explorer.command.root"), Icon::new(CatalogIcon::FolderRoot), go.action(ExplorerCommand::Root { remote }), !go.can_go_up, ) .menu_with_icon_and_disabled( - "主目录", + t!("explorer.command.home"), Icon::new(CatalogIcon::House), go.action(ExplorerCommand::Home { remote }), !go.can_go_home, ) .separator() .menu_with_icon_and_disabled( - "后退", + t!("explorer.command.back"), Icon::new(IconName::ArrowLeft), go.action(ExplorerCommand::Back { remote }), !go.can_go_back, ) .menu_with_icon_and_disabled( - "前进", + t!("explorer.command.forward"), Icon::new(IconName::ArrowRight), go.action(ExplorerCommand::Forward { remote }), !go.can_go_forward, ) }) .menu_with_icon( - "刷新", + t!("explorer.command.refresh"), Icon::new(CatalogIcon::RefreshCw), state.action(ExplorerCommand::Refresh { remote }), ) @@ -212,7 +214,7 @@ fn refresh_items( fn add_bookmark_item(menu: PopupMenu, state: &PaneMenuState) -> PopupMenu { menu.menu_with_icon_and_disabled( - "添加路径到书签", + t!("explorer.command.add_bookmark"), Icon::new(CatalogIcon::Bookmark), state.action(ExplorerCommand::AddBookmark { remote: state.remote, @@ -225,22 +227,23 @@ fn add_bookmark_item(menu: PopupMenu, state: &PaneMenuState) -> PopupMenu { /// The 大小 column title's menu: WinSCP's 文件大小显示为, with the format in /// use checked. The choice holds for every SFTP tab and is saved. pub(super) fn size_format_menu(menu: PopupMenu, current: FileSizeFormat) -> PopupMenu { - FileSizeFormat::ALL - .into_iter() - .fold(menu.label("文件大小显示为"), |menu, format| { + FileSizeFormat::ALL.into_iter().fold( + menu.label(t!("explorer.command.size_format")), + |menu, format| { menu.menu_with_check( format.label(), format == current, Box::new(SetFileSizeFormat(format)), ) - }) + }, + ) } /// The toolbar's 新建 menu, also the context menu's 新建 submenu. pub(super) fn new_menu(menu: PopupMenu, state: &PaneMenuState) -> PopupMenu { let remote = state.remote; menu.menu_with_icon_and_disabled( - "文件夹…", + t!("explorer.command.new_folder"), Icon::new(CatalogIcon::FolderPlus), state.action(ExplorerCommand::New { remote, @@ -249,7 +252,7 @@ pub(super) fn new_menu(menu: PopupMenu, state: &PaneMenuState) -> PopupMenu { !state.can_modify, ) .menu_with_icon_and_disabled( - "文件…", + t!("explorer.command.new_file"), Icon::new(CatalogIcon::FilePlus), state.action(ExplorerCommand::New { remote, @@ -264,7 +267,7 @@ pub(super) fn new_menu(menu: PopupMenu, state: &PaneMenuState) -> PopupMenu { pub(super) fn bookmark_menu(menu: PopupMenu, state: &PaneMenuState) -> PopupMenu { let remote = state.remote; let menu = if state.bookmarks.is_empty() { - menu.label("暂无书签") + menu.label(t!("explorer.bookmarks.none")) } else { state.bookmarks.iter().fold(menu, |menu, path| { menu.menu_with_check( @@ -280,7 +283,7 @@ pub(super) fn bookmark_menu(menu: PopupMenu, state: &PaneMenuState) -> PopupMenu let menu = menu.separator(); if state.bookmarks.contains(&state.path) { menu.menu( - "从书签中移除路径", + t!("explorer.command.remove_bookmark"), state.action(ExplorerCommand::RemoveBookmark { remote, path: state.path.clone(), @@ -288,7 +291,7 @@ pub(super) fn bookmark_menu(menu: PopupMenu, state: &PaneMenuState) -> PopupMenu ) } else { menu.menu_with_disabled( - "添加路径到书签", + t!("explorer.command.add_bookmark"), state.action(ExplorerCommand::AddBookmark { remote, path: None }), state.path.is_empty(), ) diff --git a/src/explorer/pane_operations.rs b/src/explorer/pane_operations.rs index e664b6e..31a2ff6 100644 --- a/src/explorer/pane_operations.rs +++ b/src/explorer/pane_operations.rs @@ -5,6 +5,7 @@ use super::{ExplorerPanel, NewEntryKind}; use crate::app::{ExplorerAction, ExplorerCommand, ExplorerDispatch as _}; use crate::host::ConnectionState; +use crate::i18n::t; use crate::sftp::{ LocalDirectoryProvider, PermissionEdit, RemoteOperation, RemotePath, SftpCommand, }; @@ -37,19 +38,20 @@ pub enum PaneOperation { /// What to do when an operation answers. pub(super) struct PendingOperation { remote: bool, - failure: &'static str, + /// The title of the notification that says it failed. + failure: fn() -> SharedString, select: Option, /// A new file, to open in the editor once it exists (WinSCP does). edit: Option, } impl PaneOperation { - fn failure_title(&self) -> &'static str { + fn failure_title(&self) -> fn() -> SharedString { match self { - PaneOperation::Delete(_) => "删除失败", - PaneOperation::Rename { .. } => "重命名失败", - PaneOperation::Create { .. } => "新建失败", - PaneOperation::Permissions { .. } => "修改权限失败", + PaneOperation::Delete(_) => || t!("explorer.operation.delete_failed"), + PaneOperation::Rename { .. } => || t!("explorer.operation.rename_failed"), + PaneOperation::Create { .. } => || t!("explorer.operation.create_failed"), + PaneOperation::Permissions { .. } => || t!("explorer.operation.permissions_failed"), } } @@ -212,9 +214,8 @@ impl ExplorerPanel { } }); match result { - Err(message) => { - window.push_notification(Notification::error(message).title(pending.failure), cx) - } + Err(message) => window + .push_notification(Notification::error(message).title((pending.failure)()), cx), Ok(()) => { if let Some(path) = pending.edit { self.dispatch.dispatch_explorer_action( diff --git a/src/explorer/path_label.rs b/src/explorer/path_label.rs index 50f4d55..eb084d0 100644 --- a/src/explorer/path_label.rs +++ b/src/explorer/path_label.rs @@ -75,7 +75,7 @@ impl FilePane { let bar = h_flex() .id(self.side.path_id()) .test_support() - .aria_label(format!("{}路径", self.side.label())) + .aria_label(self.side.path_label_name()) // WinSCP's current pane, which takes focus back when the tab // is shown again; nothing marks it on screen. .aria_selected(self.current) diff --git a/src/explorer/preview.rs b/src/explorer/preview.rs index 2fe06c2..3fa679d 100644 --- a/src/explorer/preview.rs +++ b/src/explorer/preview.rs @@ -11,6 +11,7 @@ use gpui_kit::*; use super::format_size; use super::image_preview::ImagePreview; use crate::app::{ExplorerAction, ExplorerDispatch as _}; +use crate::i18n::t; /// The largest image a preview reads. pub const IMAGE_LIMIT: u64 = 20 * 1024 * 1024; @@ -126,7 +127,7 @@ pub(super) fn open_preview_dialog(preview: Preview, window: &mut Window, cx: &mu }; // A command at the foot closes the preview first: what it opens // (the download question, the editor) goes in front. - let command = |id: &'static str, label: &'static str, action: ExplorerAction| { + let command = |id: &'static str, label: SharedString, action: ExplorerAction| { let dispatch = dispatch.clone(); Button::new(id).label(label).on_click(move |_, window, cx| { window.close_dialog(cx); @@ -148,14 +149,18 @@ pub(super) fn open_preview_dialog(preview: Preview, window: &mut Window, cx: &mu .footer( DialogFooter::new() .when_some(download.clone(), |footer, action| { - footer.child(command("preview-download", "下载…", action)) + footer.child(command( + "preview-download", + t!("explorer.file.download"), + action, + )) }) .when_some(edit.clone(), |footer, action| { - footer.child(command("preview-edit", "编辑", action)) + footer.child(command("preview-edit", t!("explorer.command.edit"), action)) }) .child( Button::new("preview-close") - .label("关闭") + .label(t!("common.close")) .on_click(|_, window, cx| window.close_dialog(cx)), ), ) diff --git a/src/explorer/properties_dialog.rs b/src/explorer/properties_dialog.rs index cee9a9b..0fb97e9 100644 --- a/src/explorer/properties_dialog.rs +++ b/src/explorer/properties_dialog.rs @@ -4,6 +4,7 @@ use super::{ExplorerPanel, FileEntry, PaneOperation, format_changed, format_size}; use crate::app::ExplorerCommand; +use crate::i18n::{t, tn}; use crate::sftp::PermissionEdit; use crate::shared::commit_footer; use gpui_kit::component::{ @@ -152,7 +153,7 @@ impl PropertiesForm { } } - fn row(label: &'static str, value: String, cx: &App) -> impl IntoElement { + fn row(label: SharedString, value: String, cx: &App) -> impl IntoElement { h_flex() .gap_3() .text_sm() @@ -173,7 +174,7 @@ impl PropertiesForm { Some(first) if values.iter().all(|value| value == first) => { first.clone().unwrap_or_else(|| "—".into()) } - Some(_) => "(不一致)".into(), + Some(_) => t!("explorer.properties.mixed").into(), None => "—".into(), } } @@ -185,22 +186,30 @@ impl Render for PropertiesForm { "—".to_string() } else { let bytes: u64 = self.items.iter().map(|item| item.size).sum(); - format!("{}({bytes} 字节)", format_size(bytes)) + tn!("explorer.properties.size", bytes, size = format_size(bytes)).into() }; - let classes = ["所有者", "组", "其他"]; - let verbs = ["读", "写", "执行"]; + let classes = [ + t!("explorer.properties.class_owner"), + t!("explorer.properties.class_group"), + t!("explorer.properties.class_others"), + ]; + let verbs = [ + t!("explorer.properties.read"), + t!("explorer.properties.write"), + t!("explorer.properties.execute"), + ]; let grid = v_flex() .gap_1() .children(classes.iter().enumerate().map(|(row, class)| { h_flex() .gap_3() .text_sm() - .child(div().w_16().flex_shrink_0().child(*class)) + .child(div().w_16().flex_shrink_0().child(class.clone())) .children(verbs.iter().enumerate().map(|(column, verb)| { let index = row * 3 + column; let bit = BITS[index]; Checkbox::new(("perm", index)) - .label(*verb) + .label(verb.clone()) .small() .checked(self.draft.is_checked(bit)) .disabled(!self.editable) @@ -215,11 +224,15 @@ impl Render for PropertiesForm { })); v_flex() .gap_3() - .child(Self::row("位置", self.location.clone(), cx)) - .child(Self::row("大小", size, cx)) + .child(Self::row( + t!("explorer.properties.location"), + self.location.clone(), + cx, + )) + .child(Self::row(t!("explorer.column.size"), size, cx)) .when(self.items.len() == 1, |this| { this.child(Self::row( - "修改时间", + t!("explorer.column.modified"), self.items[0] .modified .map(format_changed) @@ -229,7 +242,7 @@ impl Render for PropertiesForm { }) .when(self.remote, |this| { this.child(Self::row( - "所有者", + t!("explorer.column.owner"), Self::shared( self.items .iter() @@ -238,7 +251,7 @@ impl Render for PropertiesForm { cx, )) .child(Self::row( - "组", + t!("explorer.properties.group"), Self::shared( self.items .iter() @@ -253,7 +266,12 @@ impl Render for PropertiesForm { h_flex() .gap_3() .text_sm() - .child(div().w_16().flex_shrink_0().child("八进制")) + .child( + div() + .w_16() + .flex_shrink_0() + .child(t!("explorer.properties.octal")), + ) .child( div().w_20().child( Input::new(&self.octal) @@ -268,7 +286,7 @@ impl Render for PropertiesForm { div() .text_xs() .text_color(cx.theme().muted_foreground) - .child("部分项目权限不同,未改动的位保持原样。"), + .child(t!("explorer.properties.mixed_note")), ) }) .when(!self.editable, |this| { @@ -277,9 +295,9 @@ impl Render for PropertiesForm { .text_xs() .text_color(cx.theme().muted_foreground) .child(if self.items.iter().all(FileEntry::is_link) { - "符号链接没有独立的权限。" + t!("explorer.properties.links") } else { - "此系统不支持修改权限。" + t!("explorer.properties.unsupported") }), ) }) @@ -289,7 +307,7 @@ impl Render for PropertiesForm { .gap_1() .child( Checkbox::new("perm-recursive") - .label("同时应用到其中的文件和子目录") + .label(t!("explorer.properties.recursive")) .small() .checked(self.recursive) .on_change(cx.listener(|this, checked: &bool, _, cx| { @@ -299,7 +317,7 @@ impl Render for PropertiesForm { ) .child( Checkbox::new("perm-dir-x") - .label("为目录添加执行权限(X)") + .label(t!("explorer.properties.dir_x")) .small() .checked(self.add_x_to_dirs) .disabled(!self.recursive) @@ -327,9 +345,9 @@ impl ExplorerPanel { return; } let title = if let [item] = items.as_slice() { - format!("「{}」的属性", item.name) + t!("explorer.properties.title_one", name = item.name) } else { - format!("{} 个项目的属性", items.len()) + tn!("explorer.properties.title_several", items.len()) }; let names: Vec = items .iter() @@ -345,7 +363,7 @@ impl ExplorerPanel { // Closed by its buttons or Escape, not by a click beside it. .overlay_closable(false) .child(form.clone()) - .footer(commit_footer("commit", "应用")) + .footer(commit_footer("commit", t!("explorer.properties.apply"))) .on_ok({ let (form, sender, names) = (form.clone(), sender.clone(), names.clone()); move |_, window, cx| { diff --git a/src/explorer/queue_panel.rs b/src/explorer/queue_panel.rs index 5455974..507c0c7 100644 --- a/src/explorer/queue_panel.rs +++ b/src/explorer/queue_panel.rs @@ -16,6 +16,7 @@ use std::time::Duration; use super::{ExplorerPanel, FileSizeFormat, QueueEntry, QueueState, format_size, percent}; use crate::app::{CatalogIcon, ExplorerAction, ExplorerCommand, ExplorerDispatch as _}; +use crate::i18n::t; use crate::sftp::{TransferDirection, TransferOutcome, TransferPhase}; // Column widths, which the layout takes in pixels. @@ -45,7 +46,7 @@ impl ExplorerPanel { let stopped = head == Some(QueueState::Stopped) && !self.engine_busy; let tool = |id: &'static str, icon: Icon, - tip: &'static str, + tip: SharedString, enabled: bool, command: ExplorerCommand| { Button::new(id) @@ -74,43 +75,43 @@ impl ExplorerPanel { .py_1() .gap_1() .child(div().text_sm().child(if unfinished > 0 { - format!("传输队列 ({unfinished})") + t!("explorer.queue.title_count", unfinished = unfinished) } else { - "传输队列".into() + t!("explorer.queue.title") })) .child(div().flex_1()) .child(tool( "resume-transfer", Icon::new(CatalogIcon::Play), - "继续", + t!("explorer.queue.resume"), stopped, ExplorerCommand::ResumeTransfer, )) .child(tool( "cancel-transfer", Icon::new(CatalogIcon::Square), - "停止", + t!("explorer.queue.stop"), head == Some(QueueState::Active), ExplorerCommand::CancelTransfer, )) .child(tool( "remove-transfer", Icon::new(IconName::Close), - "移出队列", + t!("explorer.queue.remove"), queue.removable().is_some(), ExplorerCommand::RemoveQueueEntry, )) .child(tool( "discard-transfer", Icon::new(CatalogIcon::Trash), - "丢弃续传进度", + t!("explorer.queue.discard"), stopped, ExplorerCommand::DiscardTransfer, )) .child(tool( "clear-finished-transfers", Icon::new(CatalogIcon::Eraser), - "清除已完成", + t!("explorer.queue.clear_finished"), queue.has_finished(), ExplorerCommand::ClearFinishedTransfers, )), @@ -122,13 +123,29 @@ impl ExplorerPanel { .text_xs() .text_color(theme.muted_foreground) .bg(theme.muted) - .child(fixed(OPERATION).child("操作")) - .child(flexible().child("来源")) - .child(flexible().child("目标")) - .child(fixed(TRANSFERRED).justify_end().child("已传输")) - .child(fixed(TIME).justify_end().child("时间")) - .child(fixed(SPEED).justify_end().child("速度")) - .child(fixed(PROGRESS).pl_3().child("进度")), + .child(fixed(OPERATION).child(t!("explorer.queue.column.operation"))) + .child(flexible().child(t!("explorer.queue.column.source"))) + .child(flexible().child(t!("explorer.queue.column.target"))) + .child( + fixed(TRANSFERRED) + .justify_end() + .child(t!("explorer.queue.column.transferred")), + ) + .child( + fixed(TIME) + .justify_end() + .child(t!("explorer.queue.column.time")), + ) + .child( + fixed(SPEED) + .justify_end() + .child(t!("explorer.queue.column.speed")), + ) + .child( + fixed(PROGRESS) + .pl_3() + .child(t!("explorer.queue.column.progress")), + ), ) .child( v_flex() @@ -228,9 +245,9 @@ fn batch_row( IconName::ChevronRight })) .tooltip(if row.expanded { - "收起逐项结果" + t!("explorer.queue.hide_results") } else { - "展开逐项结果" + t!("explorer.queue.show_results") }) .on_click(toggle), ) @@ -319,7 +336,7 @@ fn item_results(entry: &QueueEntry, cx: &Context) -> impl IntoEle .progress() .map(|progress| progress.details()) .unwrap_or_default(); - let verb = entry.job().direction().verb(); + let upload = entry.job().direction() == TransferDirection::Upload; v_flex() .id(("transfer-detail-list", entry.id().0)) .test_support() @@ -335,17 +352,31 @@ fn item_results(entry: &QueueEntry, cx: &Context) -> impl IntoEle .py_0p5() .text_color(muted) .child(if entry.state() == QueueState::Pending { - "还没有开始".to_string() + t!("explorer.queue.not_started") + } else if upload { + t!("explorer.queue.nothing_uploaded") } else { - format!("还没有{verb}完的项目,每完成一项会列在这里") + t!("explorer.queue.nothing_downloaded") }), ) }) .children(details.iter().rev().map(|detail| { let (icon, color, outcome) = match detail.outcome() { - TransferOutcome::Done => (Icon::new(IconName::CircleCheck), success, "完成"), - TransferOutcome::Skipped => (Icon::new(CatalogIcon::CircleMinus), muted, "已跳过"), - TransferOutcome::Failed => (Icon::new(IconName::CircleX), danger, "失败"), + TransferOutcome::Done => ( + Icon::new(IconName::CircleCheck), + success, + t!("explorer.queue.outcome.done"), + ), + TransferOutcome::Skipped => ( + Icon::new(CatalogIcon::CircleMinus), + muted, + t!("explorer.queue.outcome.skipped"), + ), + TransferOutcome::Failed => ( + Icon::new(IconName::CircleX), + danger, + t!("explorer.queue.outcome.failed"), + ), }; h_flex() .id(ElementId::Name( diff --git a/src/explorer/transfer_dialog.rs b/src/explorer/transfer_dialog.rs index ea81116..f8ea604 100644 --- a/src/explorer/transfer_dialog.rs +++ b/src/explorer/transfer_dialog.rs @@ -3,6 +3,7 @@ use crate::app::ExplorerDispatch as _; use crate::{ app::{ExplorerAction, ExplorerCommand}, host::ConnectionState, + i18n::{t, tn}, sftp::{TransferAnswer, TransferChoice, TransferDirection, TransferQuestionKind}, shared::commit_footer, }; @@ -99,8 +100,16 @@ impl ExplorerPanel { error: None, }); let (title, confirm_id, confirm_label) = match direction { - TransferDirection::Upload => ("上传文件", "upload-confirm", "上传"), - TransferDirection::Download => ("下载", "download-confirm", "下载"), + TransferDirection::Upload => ( + t!("explorer.transfer.upload_title"), + "upload-confirm", + t!("explorer.transfer.upload"), + ), + TransferDirection::Download => ( + t!("explorer.transfer.download_title"), + "download-confirm", + t!("explorer.transfer.download"), + ), }; let sender = self.sender(); let job = Rc::new(job); @@ -109,26 +118,26 @@ impl ExplorerPanel { self.dialog_open = true; window.open_dialog(cx, move |dialog, _, _| { dialog - .title(title) + .title(title.clone()) .child(form.clone()) .overlay_closable(false) - .footer(commit_footer(confirm_id, confirm_label)) + .footer(commit_footer(confirm_id, confirm_label.clone())) .on_ok({ let (form, sender, job) = (form.clone(), sender.clone(), job.clone()); move |_, window, cx| { let target = form.read(cx).target.read(cx).value().to_string(); let problem = match direction { TransferDirection::Upload if target.is_empty() => { - Some("请输入目标目录") + Some(t!("explorer.transfer.target_missing")) } TransferDirection::Download if !Path::new(&target).is_absolute() => { - Some("请输入本机目录的完整路径") + Some(t!("explorer.transfer.local_target_relative")) } _ => None, }; if let Some(problem) = problem { form.update(cx, |form, cx| { - form.error = Some(problem.into()); + form.error = Some(problem.to_string()); cx.notify(); }); return false; @@ -197,32 +206,52 @@ impl ExplorerPanel { let focus = window.focused(cx).unwrap_or_else(|| self.focus_handle(cx)); self.dialog_open = true; self.question_shown = true; - let verb = self.transfer_direction().verb(); + let upload = self.transfer_direction() == TransferDirection::Upload; + // Each direction says its own words: English puts them in another + // order than Chinese. + let either = |upload_text: SharedString, download_text: SharedString| { + if upload { upload_text } else { download_text } + }; + let restart_label = either( + t!("explorer.question.restart_upload"), + t!("explorer.question.restart_download"), + ); let (title, primary_label, primary_choice): (SharedString, SharedString, _) = match question.kind() { TransferQuestionKind::Conflict => ( - "同名文件已存在".into(), - "覆盖".into(), + t!("explorer.question.conflict_title"), + t!("explorer.question.overwrite"), TransferChoice::Overwrite, ), TransferQuestionKind::Resume => ( - format!("发现未完成的{verb}").into(), - format!("继续{verb}").into(), + either( + t!("explorer.question.resume_upload_title"), + t!("explorer.question.resume_download_title"), + ), + either( + t!("explorer.question.resume_upload"), + t!("explorer.question.resume_download"), + ), TransferChoice::Resume, ), TransferQuestionKind::InvalidResume => ( - "无法继续此文件".into(), - format!("重新{verb}").into(), + t!("explorer.question.invalid_resume_title"), + restart_label.clone(), TransferChoice::Restart, ), TransferQuestionKind::Error => ( - format!("无法{verb}此项目").into(), - "重试".into(), + either( + t!("explorer.question.upload_failed_title"), + t!("explorer.question.download_failed_title"), + ), + t!("explorer.question.retry"), TransferChoice::Retry, ), }; - let cancel_label: SharedString = format!("取消{verb}").into(); - let restart_label: SharedString = format!("重新{verb}").into(); + let cancel_label = either( + t!("explorer.question.cancel_upload"), + t!("explorer.question.cancel_download"), + ); window.open_dialog(cx, move |dialog, _, _| { let answer_button = |id: &'static str, label: SharedString, choice: TransferChoice| { let (answered, answer, form) = (answered.clone(), answer.clone(), form.clone()); @@ -251,7 +280,7 @@ impl ExplorerPanel { )) .child(answer_button( "transfer-question-skip", - "跳过".into(), + t!("explorer.question.skip"), TransferChoice::Skip, )) .when(question.kind() == TransferQuestionKind::Resume, |footer| { @@ -346,17 +375,17 @@ impl TransferSources { } /// `2 个文件`, `1 个文件夹`, or `3 个项目(1 个文件夹、2 个文件)`. - fn count(&self) -> String { + fn count(&self) -> SharedString { let folders = self.items.iter().filter(|item| item.is_dir).count(); match (folders, self.items.len() - folders) { - (0, files) => format!("{files} 个文件"), - (folders, 0) => format!("{folders} 个文件夹"), - (folders, files) => { - format!( - "{} 个项目({folders} 个文件夹、{files} 个文件)", - folders + files - ) - } + (0, files) => tn!("explorer.transfer.files", files), + (folders, 0) => tn!("explorer.transfer.folders", folders), + (folders, files) => tn!( + "explorer.transfer.items", + folders + files, + folders = tn!("explorer.transfer.folders", folders), + files = tn!("explorer.transfer.files", files) + ), } } @@ -370,7 +399,7 @@ impl TransferSources { div() .text_xs() .text_color(muted) - .child(format!("位于 {folder}")), + .child(t!("explorer.transfer.in_folder", folder = folder)), ) }) .child( @@ -427,11 +456,11 @@ fn queued_note(cx: &App) -> impl IntoElement { .test_support() .text_xs() .text_color(cx.theme().muted_foreground) - .child("当前有传输在进行,这一批会排在传输队列里,轮到时自动开始。") + .child(t!("explorer.transfer.queued_note")) } /// The dialog's first line: what goes where. -fn summary(text: String) -> impl IntoElement { +fn summary(text: SharedString) -> impl IntoElement { div() .id("transfer-summary") .test_support() @@ -457,7 +486,7 @@ impl TransferForm { files: false, directories: true, multiple: false, - prompt: Some("选择下载到的目录".into()), + prompt: Some(t!("explorer.transfer.choose_download_folder")), }); cx.spawn_in(window, async move |this, cx| { if let Ok(Ok(Some(paths))) = choice.await @@ -480,9 +509,13 @@ impl Render for TransferForm { let count = self.sources.count(); let (text, list_id, label, error_id, target) = match self.direction { TransferDirection::Upload => ( - format!("上传 {count}到 {}", self.endpoint), + t!( + "explorer.transfer.upload_summary", + items = count, + endpoint = self.endpoint + ), "upload-source-list", - "目标目录", + t!("explorer.transfer.target"), "upload-form-error", Input::new(&self.target) .id("upload-target") @@ -490,9 +523,13 @@ impl Render for TransferForm { .into_any_element(), ), TransferDirection::Download => ( - format!("从 {} 下载 {count}", self.endpoint), + t!( + "explorer.transfer.download_summary", + items = count, + endpoint = self.endpoint + ), "download-source-list", - "下载到", + t!("explorer.transfer.download_to"), "download-form-error", h_flex() .gap_2() @@ -505,7 +542,7 @@ impl Render for TransferForm { .child( Button::new("download-browse") .small() - .label("浏览…") + .label(t!("explorer.browse")) .on_click(cx.listener(|this, _, window, cx| this.browse(window, cx))), ) .into_any_element(), @@ -537,7 +574,7 @@ impl Render for ConflictForm { div().when(self.kind == TransferQuestionKind::Conflict, |this| { this.child( Checkbox::new("transfer-apply-all") - .label("应用于本批剩余冲突") + .label(t!("explorer.question.apply_to_all")) .checked(self.apply_all) .on_change(cx.listener(|this, value, _, cx| { this.apply_all = *value; @@ -561,19 +598,42 @@ pub fn confirm_close_transfer( cx: &mut App, ) { let focus = window.focused(cx); - let verb = direction.verb(); - let description = format!( - "{verb}进度会保留,下次选择相同来源和目标目录时可以继续{verb}。{}", - unsaved.unwrap_or_default() + let upload = direction == TransferDirection::Upload; + let either = |upload_text: SharedString, download_text: SharedString| { + if upload { upload_text } else { download_text } + }; + let kept = either( + t!("explorer.close_transfer.upload_kept"), + t!("explorer.close_transfer.download_kept"), + ); + let description = match unsaved { + Some(unsaved) => t!( + "explorer.close_transfer.two_sentences", + first = kept, + second = unsaved + ), + None => kept, + }; + let title = either( + t!("explorer.close_transfer.upload_title"), + t!("explorer.close_transfer.download_title"), + ); + let ok = either( + t!("explorer.close_transfer.stop_upload"), + t!("explorer.close_transfer.stop_download"), + ); + let cancel = either( + t!("explorer.close_transfer.keep_uploading"), + t!("explorer.close_transfer.keep_downloading"), ); window.open_alert_dialog(cx, move |dialog, _, _| { dialog - .title(format!("停止{verb}并关闭?")) + .title(title.clone()) .description(description.clone()) .button_props( gpui_kit::component::dialog::DialogButtonProps::default() - .ok_text(format!("停止{verb}并关闭")) - .cancel_text(format!("继续{verb}")), + .ok_text(ok.clone()) + .cancel_text(cancel.clone()), ) .show_cancel(true) .on_cancel({ @@ -641,4 +701,20 @@ mod tests { assert_eq!(root.items[0].name, "boot"); assert_eq!(root.count(), "1 个文件夹"); } + + #[test] + fn english_counts_each_kind_in_its_own_number() { + crate::i18n::isolate_thread(); + crate::i18n::set_locale("en"); + let count = |items: &[(&str, bool, bool)]| sources(items).count(); + assert_eq!(count(&[("/a", true, false)]), "1 file"); + assert_eq!( + count(&[("/a", true, true), ("/b", true, true)]), + "2 folders" + ); + assert_eq!( + count(&[("/a", true, true), ("/b", true, false)]), + "2 items (1 folder, 1 file)" + ); + } } diff --git a/src/explorer/transfer_queue.rs b/src/explorer/transfer_queue.rs index 786312e..9e2ea0f 100644 --- a/src/explorer/transfer_queue.rs +++ b/src/explorer/transfer_queue.rs @@ -5,6 +5,7 @@ use std::{collections::HashSet, path::PathBuf}; +use crate::i18n::{t, tn}; use crate::sftp::{TransferDirection, TransferPhase, TransferProgress}; /// Why a batch left the queue before it was done. @@ -66,7 +67,7 @@ impl TransferJob { match (paths.as_slice(), folders.first()) { ([one], _) => one.clone(), (_, Some(first)) if folders.iter().all(|folder| folder == first) => first.clone(), - _ => "多个位置".to_string(), + _ => t!("explorer.queue.several_places").to_string(), } } @@ -141,25 +142,29 @@ impl QueueEntry { pub fn status(&self) -> String { let progress = self.progress.as_ref(); match self.state() { - QueueState::Pending => "等待中".into(), + QueueState::Pending => t!("explorer.queue.status.pending").into(), QueueState::Active => match progress.map(TransferProgress::phase) { - None | Some(TransferPhase::Scanning) => "正在扫描".into(), - Some(TransferPhase::Waiting) => "等待回答".into(), - Some(TransferPhase::Reconnecting) => progress - .and_then(TransferProgress::note) - .unwrap_or("正在重连") - .into(), + None | Some(TransferPhase::Scanning) => t!("explorer.queue.status.scanning").into(), + Some(TransferPhase::Waiting) => t!("explorer.queue.status.waiting").into(), + Some(TransferPhase::Reconnecting) => { + match progress.and_then(TransferProgress::note) { + Some(note) => note.into(), + None => t!("explorer.queue.status.reconnecting").into(), + } + } Some(_) => percent(progress.map_or(0.0, TransferProgress::fraction)), }, - QueueState::Stopped => "已停止".into(), + QueueState::Stopped => t!("explorer.queue.status.stopped").into(), QueueState::Done => match progress.map_or(0, TransferProgress::failed) { - 0 => "已完成".into(), - failed => format!("部分失败({failed} 项)"), + 0 => t!("explorer.queue.status.done").into(), + failed => tn!("explorer.queue.status.partly_failed", failed).into(), }, QueueState::Removed => match &self.removed { - Some(Removal::Discarded) => "已丢弃续传进度".into(), - Some(Removal::NotStarted(reason)) => format!("无法开始:{reason}"), - _ => "已移出".into(), + Some(Removal::Discarded) => t!("explorer.queue.status.discarded").into(), + Some(Removal::NotStarted(reason)) => { + t!("explorer.queue.status.not_started", reason = reason).into() + } + _ => t!("explorer.queue.status.removed").into(), }, } } diff --git a/src/sftp/client.rs b/src/sftp/client.rs index 6d66f32..79dbb64 100644 --- a/src/sftp/client.rs +++ b/src/sftp/client.rs @@ -1,5 +1,6 @@ use super::{DirectoryEntry, DirectoryListing, EntryKind, FileMetadata, RemotePath}; use crate::connection::Latency; +use crate::i18n::t; use crate::ssh::{SshConnectionConfig, SshConnector, SshHandle, SshPrompts}; use anyhow::{Result, anyhow, bail}; use futures::StreamExt as _; @@ -76,7 +77,7 @@ impl SftpClient { Self::initialize(channel.into_stream(), Some(ssh), fingerprint).await }) .await - .map_err(|_| anyhow!("启动 SFTP 超时"))? + .map_err(|_| anyhow!(t!("sftp.client.start_timeout")))? } async fn initialize(stream: S, ssh: Option, fingerprint: String) -> Result where @@ -89,7 +90,7 @@ impl SftpClient { }); let version = raw.init().await?; if version.version != 3 { - bail!("服务器未提供 SFTP v3"); + bail!(t!("sftp.client.no_v3")); } let atomic_replace = version .extensions @@ -116,7 +117,7 @@ impl SftpClient { let executable = ["/usr/libexec/sftp-server", "/usr/lib/openssh/sftp-server"] .into_iter() .find(|p| std::path::Path::new(p).exists()) - .ok_or_else(|| anyhow!("本机没有 OpenSSH sftp-server"))?; + .ok_or_else(|| anyhow!("no OpenSSH sftp-server on this machine"))?; let mut child = tokio::process::Command::new(executable) .current_dir(directory) .stdin(std::process::Stdio::piped()) @@ -152,7 +153,7 @@ impl SftpClient { &response .files .first() - .ok_or_else(|| anyhow!("服务器未返回目录路径"))? + .ok_or_else(|| anyhow!(t!("sftp.client.no_path")))? .filename, )?) } @@ -332,7 +333,7 @@ impl RemoteFs for SftpClient { .await? .files .first() - .ok_or_else(|| anyhow!("无法读取链接目标"))? + .ok_or_else(|| anyhow!(t!("sftp.client.no_link_target")))? .filename, ) } @@ -350,7 +351,7 @@ impl RemoteFs for SftpClient { { Packet::Status(status) if status.status_code == StatusCode::Ok => {} Packet::Status(status) => return Err(Error::Status(status).into()), - _ => bail!("服务器返回了无效的重命名响应"), + _ => bail!(t!("sftp.client.bad_rename_reply")), } } else { self.raw.rename(from.as_str(), to.as_str()).await?; @@ -521,7 +522,7 @@ pub(crate) fn parse_longname(line: &str) -> Option<(String, String)> { // they can become a path for navigation or mutation. fn checked_text(value: &str) -> Result { if value.contains('\u{fffd}') { - bail!("远端名称包含无法可靠表示的字符,已停止操作"); + bail!(t!("sftp.client.ambiguous_name")); } Ok(value.to_string()) } diff --git a/src/sftp/control.rs b/src/sftp/control.rs index ee051a7..a62a579 100644 --- a/src/sftp/control.rs +++ b/src/sftp/control.rs @@ -1,4 +1,5 @@ use super::{SftpEvent, TransferAnswer, TransferChoice, TransferQuestion, TransferQuestionKind}; +use crate::i18n::t; use anyhow::Result; use async_channel::{Receiver, Sender}; use std::{ @@ -12,7 +13,7 @@ static NEXT_QUESTION: AtomicU64 = AtomicU64::new(1); pub(crate) struct Cancelled; impl std::fmt::Display for Cancelled { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("传输已停止,进度已保留") + f.write_str(&t!("sftp.transfer.cancelled")) } } impl std::error::Error for Cancelled {} diff --git a/src/sftp/download.rs b/src/sftp/download.rs index 91e9303..7289f3d 100644 --- a/src/sftp/download.rs +++ b/src/sftp/download.rs @@ -13,6 +13,7 @@ use super::{ journal::{DownloadJournal, DownloadRecord, partial_path, remove_if_present}, meter::TransferMeter, }; +use crate::i18n::{t, tn}; use anyhow::{Result, anyhow, bail}; use futures::{StreamExt as _, stream::FuturesUnordered}; use std::{ @@ -58,7 +59,7 @@ fn local_name(name: &str) -> Result<&str> { name.contains(['/', '\0']) }; if invalid || matches!(name, "" | "." | "..") { - bail!("名称「{name}」在本机无效"); + bail!(t!("sftp.download.name_invalid", name = name)); } Ok(name) } @@ -114,7 +115,7 @@ impl DownloadBatch { let found = control.run(fs.metadata(&source)).await; let error = match found { Ok(Some(metadata)) => Ok(metadata), - Ok(None) => Err("远程项目不存在".to_string()), + Ok(None) => Err(t!("sftp.download.source_missing").to_string()), Err(error) if super::client::is_network_error(&error) || error.is::() => @@ -183,7 +184,7 @@ impl DownloadBatch { pub fn verify_host(&self, fingerprint: &str) -> Result<()> { if self.host_key != fingerprint { - bail!("服务器主机指纹已改变,已停止续传"); + bail!(t!("sftp.transfer.host_key_changed")); } Ok(()) } @@ -203,14 +204,14 @@ impl DownloadBatch { .begin(item.source.to_string(), target.clone(), size, control); let _target_guard = TargetGuard::acquire( format!("local\0{}", item.target.display()), - "另一个传输正在下载到同一位置,请稍后继续下载", + &t!("sftp.download.target_busy"), )?; let result = if self .blocked_directories .iter() .any(|dir| item.target.starts_with(dir) && item.target != *dir) { - Err(anyhow!("父目录未创建,无法下载")) + Err(anyhow!(t!("sftp.download.parent_missing"))) } else if let Some(error) = &item.error { Err(anyhow!("{error}")) } else { @@ -235,7 +236,7 @@ impl DownloadBatch { .ask( TransferQuestionKind::Error, item.source.as_str(), - &format!("无法下载:{error:#}"), + &t!("sftp.download.failed", error = format!("{error:#}")), ) .await?; if answer.choice() == TransferChoice::Retry { @@ -245,7 +246,7 @@ impl DownloadBatch { item.target .parent() .map(Path::to_path_buf) - .ok_or_else(|| anyhow!("无效的下载目标"))?, + .ok_or_else(|| anyhow!(t!("sftp.download.target_invalid")))?, )?; let scanned = Self::scan( &request, @@ -290,7 +291,7 @@ impl DownloadBatch { control: &TransferControl, ) -> Result { if existing.is_dir() { - bail!("本地已有同名目录,不会删除它"); + bail!(t!("sftp.download.folder_in_the_way")); } if !changed && let Some(choice) = self.all_conflicts { return Ok(choice == TransferChoice::Overwrite); @@ -301,16 +302,16 @@ impl DownloadBatch { .ok() .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) .map(|time| time.as_secs()); - let message = format!( - "{}本地已有同名项目({} 字节,修改时间 {:?})。覆盖会替换它。", - if changed { - "目标在下载期间发生变化。" - } else { - "" - }, - existing.len(), - modified - ); + let modified = format!("{modified:?}"); + let message = if changed { + tn!( + "sftp.download.exists_changed", + existing.len(), + modified = modified + ) + } else { + tn!("sftp.download.exists", existing.len(), modified = modified) + }; let answer = control .ask( TransferQuestionKind::Conflict, @@ -335,7 +336,7 @@ impl DownloadBatch { match item.metadata.kind() { EntryKind::Directory => match existing { Some(metadata) if metadata.is_dir() => Ok(true), - Some(_) => bail!("本地已有同名文件,不会替换为目录"), + Some(_) => bail!(t!("sftp.download.file_in_the_way")), None => { tokio::fs::create_dir(&item.target).await?; Ok(true) @@ -351,7 +352,7 @@ impl DownloadBatch { create_link(&link, &item.target, existing.is_some()).await?; Ok(true) } - EntryKind::Other => bail!("不支持下载设备、套接字或其他特殊文件"), + EntryKind::Other => bail!(t!("sftp.download.special_file")), EntryKind::File => self.download_regular(fs, item, existing, control).await, } } @@ -388,20 +389,23 @@ impl DownloadBatch { let mut offset = 0; if let Some(partial) = &partial { if !partial.is_file() { - bail!("临时文件 {} 不是普通文件", temporary.display()); + bail!(t!( + "sftp.download.partial_not_file", + path = temporary.display() + )); } if partial.len() > item.metadata.size() || (record.is_some() && !resumable) { self.meter.phase(TransferPhase::Waiting, control); let reason = if partial.len() > item.metadata.size() { - "本地 .filepart 文件比远程文件大,无法续传。" + t!("sftp.download.partial_too_large") } else { - "远程文件在上次下载后已变化,无法续传。" + t!("sftp.download.source_changed") }; let answer = control .ask( TransferQuestionKind::InvalidResume, item.source.as_str(), - reason, + &reason, ) .await?; if answer.choice() != TransferChoice::Restart { @@ -415,7 +419,7 @@ impl DownloadBatch { .ask( TransferQuestionKind::Resume, item.source.as_str(), - "发现未完成的 .filepart 文件。续传会按它的现有大小跳过远程文件前缀,请确认远程文件仍是同一版本。", + &t!("sftp.download.resume_found"), ) .await?; match answer.choice() { @@ -505,10 +509,10 @@ impl DownloadBatch { let (at, len, bytes) = reads .next() .await - .ok_or_else(|| anyhow!("下载读取流水线意外结束"))?; + .ok_or_else(|| anyhow!(t!("sftp.download.pipeline_ended")))?; let bytes = match bytes? { Some(bytes) if !bytes.is_empty() => bytes, - _ => bail!("远程文件在下载期间变短,已停止"), + _ => bail!(t!("sftp.download.source_shrank")), }; let got = bytes.len().min(len as usize); if got < len as usize { @@ -632,5 +636,5 @@ async fn create_link(link: &str, target: &Path, replace: bool) -> Result<()> { #[cfg(not(unix))] async fn create_link(_: &str, _: &Path, _: bool) -> Result<()> { - bail!("此系统不支持创建符号链接") + bail!(t!("sftp.download.links_unsupported")) } diff --git a/src/sftp/edit.rs b/src/sftp/edit.rs index c86fdc1..0af9e98 100644 --- a/src/sftp/edit.rs +++ b/src/sftp/edit.rs @@ -7,6 +7,7 @@ //! size and modification time the file had when it was read or last saved. use super::{EntryKind, FileMetadata, LocalDirectoryProvider, RemotePath, client::RemoteFs}; +use crate::i18n::{t, tn}; use anyhow::{Result, anyhow, bail}; use futures::{StreamExt as _, stream::FuturesUnordered}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -160,9 +161,9 @@ pub enum ReadFailure { impl std::fmt::Display for ReadFailure { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - ReadFailure::NotFile => f.write_str("不是普通文件"), - ReadFailure::TooLarge(size) => write!(f, "文件太大({size} 字节)"), - ReadFailure::NotText => f.write_str("不是 UTF-8 文本文件"), + ReadFailure::NotFile => f.write_str(&t!("sftp.edit.not_file")), + ReadFailure::TooLarge(size) => f.write_str(&tn!("sftp.edit.too_large", *size)), + ReadFailure::NotText => f.write_str(&t!("sftp.edit.not_text")), ReadFailure::Failed(message) => f.write_str(message), } } @@ -196,7 +197,7 @@ pub enum SaveFailure { impl std::fmt::Display for SaveFailure { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - SaveFailure::Changed => f.write_str("文件在打开后已被修改"), + SaveFailure::Changed => f.write_str(&t!("sftp.edit.changed")), SaveFailure::Interrupted(message) | SaveFailure::Failed(message) => { f.write_str(message) } @@ -268,7 +269,7 @@ async fn read_bytes( text: bool, ) -> Result<(Vec, FileStamp)> { let Some(metadata) = fs.stat(path).await? else { - bail!("文件不存在"); + bail!(t!("sftp.edit.missing")); }; if metadata.kind() != EntryKind::File { return Err(ReadFailure::NotFile.into()); @@ -342,7 +343,7 @@ async fn read_all(fs: &F, handle: &str, size: u64, text: bool) -> R }; let bytes = match bytes? { Some(bytes) if !bytes.is_empty() => bytes, - _ => bail!("文件在读取时变短了,请重新打开"), + _ => bail!(t!("sftp.edit.shrank")), }; let got = bytes.len().min(len as usize); let start = at as usize; @@ -390,7 +391,7 @@ pub(crate) async fn write_in_place( let metadata = fs .stat(path) .await? - .ok_or_else(|| anyhow!("保存后找不到文件"))?; + .ok_or_else(|| anyhow!(t!("sftp.edit.gone_after_save")))?; Ok(FileStamp::of(&metadata)) } diff --git a/src/sftp/journal.rs b/src/sftp/journal.rs index deb67f9..1023245 100644 --- a/src/sftp/journal.rs +++ b/src/sftp/journal.rs @@ -1,5 +1,7 @@ use super::{EntryKind, FileMetadata, RemotePath}; +use crate::i18n::t; use anyhow::{Context as _, Result, bail}; +use gpui_kit::SharedString; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use sha2::{Digest as _, Sha256}; use std::{ @@ -17,7 +19,7 @@ impl SourceMetadata { pub async fn read(path: &Path) -> Result { let metadata = tokio::fs::symlink_metadata(path).await?; if !metadata.is_file() { - bail!("本地来源已不再是普通文件"); + bail!(t!("sftp.journal.source_not_file")); } Ok(Self { size: metadata.len(), @@ -86,19 +88,19 @@ impl ResumeRecord { || self.source != source || &self.target != target { - bail!("续传记录与当前来源或服务器不匹配"); + bail!(t!("sftp.journal.mismatch")); } if self.temporary != RemotePath::new(format!("{}.filepart", target.as_str()))? || !managed_backup_path(&self.backup, target) { - bail!("无效的续传临时路径"); + bail!(t!("sftp.journal.temporary_invalid")); } if self .original .as_ref() .is_some_and(|m| m.kind() == EntryKind::Directory) { - bail!("续传记录不能替换目录"); + bail!(t!("sftp.journal.replaces_folder")); } Ok(()) } @@ -140,7 +142,7 @@ impl Journal { ) -> Result> { read_record( &self.path(endpoint, source, target), - "续传记录损坏,原文件和临时文件均已保留", + t!("sftp.journal.upload_damaged"), ) .await } @@ -155,11 +157,11 @@ impl Journal { /// Read a record, if there is one. `damaged` is what to say when the file /// is there and cannot be understood. -async fn read_record(path: &Path, damaged: &'static str) -> Result> { +async fn read_record(path: &Path, damaged: SharedString) -> Result> { match tokio::fs::read(path).await { Ok(bytes) => Ok(Some(serde_json::from_slice(&bytes).context(damaged)?)), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(error) => Err(error).context("无法读取续传记录"), + Err(error) => Err(error).context(t!("sftp.journal.unreadable")), } } @@ -188,7 +190,7 @@ async fn write_atomic(root: &Path, path: &Path, bytes: &[u8]) -> Result<()> { drop(file); tokio::fs::rename(&temp, path) .await - .context("无法保存续传进度")?; + .context(t!("sftp.journal.unsaved"))?; #[cfg(unix)] { tokio::fs::File::open(root).await?.sync_all().await?; @@ -250,7 +252,7 @@ impl DownloadRecord { || self.target != target || self.temporary != partial_path(target) { - bail!("续传记录与当前来源或服务器不匹配"); + bail!(t!("sftp.journal.mismatch")); } Ok(()) } @@ -290,7 +292,7 @@ impl DownloadJournal { ) -> Result> { read_record( &self.path(endpoint, source, target), - "续传记录损坏,临时文件已保留", + t!("sftp.journal.download_damaged"), ) .await } diff --git a/src/sftp/model.rs b/src/sftp/model.rs index f2e3b01..75a69fa 100644 --- a/src/sftp/model.rs +++ b/src/sftp/model.rs @@ -1,5 +1,7 @@ use super::{FileStamp, ReadFailure, SaveFailure}; +use crate::i18n::t; use anyhow::{Result, bail}; +use gpui_kit::SharedString; use serde::{Deserialize, Serialize}; use std::{ path::{Path, PathBuf}, @@ -14,7 +16,7 @@ impl RemotePath { pub fn new(path: impl Into) -> Result { let path = path.into(); if path.is_empty() || path.contains('\0') { - bail!("远程路径不能为空或包含空字符"); + bail!(t!("sftp.error.remote_path_invalid")); } Ok(Self(path)) } @@ -23,7 +25,7 @@ impl RemotePath { } pub fn join(&self, name: &str) -> Result { if name.is_empty() || matches!(name, "." | "..") || name.contains(['/', '\0']) { - bail!("无效的文件名:{name}"); + bail!(t!("sftp.error.file_name_invalid", name = name)); } Self::new(format!("{}/{name}", self.0.trim_end_matches('/'))) } @@ -180,7 +182,7 @@ enum UploadMode { impl UploadRequest { pub fn new(sources: Vec, destination: RemotePath) -> Result { if sources.is_empty() { - bail!("请选择要上传的文件或目录"); + bail!(t!("sftp.error.nothing_to_upload")); } Ok(Self { sources, @@ -281,15 +283,6 @@ pub enum TransferDirection { Upload, Download, } -impl TransferDirection { - /// The verb used in progress and confirmation copy. - pub fn verb(self) -> &'static str { - match self { - Self::Upload => "上传", - Self::Download => "下载", - } - } -} /// Remote files and directories to copy into one local directory. #[derive(Clone, Debug, PartialEq, Eq)] pub struct DownloadRequest { @@ -301,10 +294,10 @@ pub struct DownloadRequest { impl DownloadRequest { pub fn new(sources: Vec, destination: PathBuf) -> Result { if sources.is_empty() { - bail!("请选择要下载的文件或目录"); + bail!(t!("sftp.error.nothing_to_download")); } if !destination.is_absolute() { - bail!("下载目标必须是绝对路径"); + bail!(t!("sftp.error.download_target_relative")); } Ok(Self { sources, @@ -316,7 +309,7 @@ impl DownloadRequest { /// One source copied the way scp copies it; see [`UploadRequest::scp`]. pub fn scp(source: RemotePath, destination: PathBuf) -> Result { if !destination.is_absolute() { - bail!("下载目标必须是绝对路径"); + bail!(t!("sftp.error.download_target_relative")); } Ok(Self { sources: vec![source], @@ -693,13 +686,33 @@ impl PermissionEdit { } } +/// A well-known local folder, offered by the 目录列表 select. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Place { + Home, + Desktop, + Documents, + Downloads, +} + +impl Place { + pub fn title(self) -> SharedString { + match self { + Self::Home => t!("sftp.place.home"), + Self::Desktop => t!("sftp.place.desktop"), + Self::Documents => t!("sftp.place.documents"), + Self::Downloads => t!("sftp.place.downloads"), + } + } +} + /// Local I/O is blocking and must be called on a background executor. pub trait LocalDirectoryProvider: Send + Sync + 'static { fn home(&self) -> PathBuf; fn list(&self, path: &Path) -> Result; /// Well-known folders for the 目录列表 select, home first. Looked up /// from the platform, without touching the file system. - fn places(&self) -> Vec<(String, PathBuf)> { + fn places(&self) -> Vec<(Place, PathBuf)> { Vec::new() } /// Move items to the system trash. Links go as links. @@ -739,15 +752,15 @@ impl LocalDirectoryProvider for SystemLocalDirectoryProvider { fn home(&self) -> PathBuf { dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")) } - fn places(&self) -> Vec<(String, PathBuf)> { + fn places(&self) -> Vec<(Place, PathBuf)> { [ - ("主目录", dirs::home_dir()), - ("桌面", dirs::desktop_dir()), - ("文稿", dirs::document_dir()), - ("下载", dirs::download_dir()), + (Place::Home, dirs::home_dir()), + (Place::Desktop, dirs::desktop_dir()), + (Place::Documents, dirs::document_dir()), + (Place::Downloads, dirs::download_dir()), ] .into_iter() - .filter_map(|(title, path)| Some((title.to_string(), path?))) + .filter_map(|(place, path)| Some((place, path?))) .collect() } fn list(&self, path: &Path) -> Result { @@ -761,7 +774,7 @@ impl LocalDirectoryProvider for SystemLocalDirectoryProvider { let name = entry .file_name() .into_string() - .map_err(|_| anyhow::anyhow!("目录包含无法用 UTF-8 表示的文件名"))?; + .map_err(|_| anyhow::anyhow!(t!("sftp.error.file_name_not_utf8")))?; let metadata = std::fs::symlink_metadata(entry.path())?; let mut row = DirectoryEntry::new(name, local_metadata(&metadata)); if metadata.is_symlink() { @@ -775,7 +788,7 @@ impl LocalDirectoryProvider for SystemLocalDirectoryProvider { } Ok(DirectoryListing::new( path.to_str() - .ok_or_else(|| anyhow::anyhow!("路径不是有效的 UTF-8"))?, + .ok_or_else(|| anyhow::anyhow!(t!("sftp.error.path_not_utf8")))?, entries, )) } @@ -789,14 +802,18 @@ impl LocalDirectoryProvider for SystemLocalDirectoryProvider { use trash::macos::{DeleteMethod, TrashContextExtMacos as _}; context.set_delete_method(DeleteMethod::NsFileManager); } - context - .delete_all(paths) - .map_err(|error| anyhow::anyhow!("无法移到废纸篓:{error}")) + context.delete_all(paths).map_err(|error| { + anyhow::anyhow!(if cfg!(windows) { + t!("sftp.error.recycle_bin_failed", error = error) + } else { + t!("sftp.error.trash_failed", error = error) + }) + }) } fn rename(&self, from: &Path, to: &Path) -> Result<()> { // POSIX rename silently replaces the target. if std::fs::symlink_metadata(to).is_ok() { - bail!("已有名为「{}」的项目", display_name(to)); + bail!(t!("sftp.error.exists", name = display_name(to))); } std::fs::rename(from, to).map_err(|error| describe_io(error, to)) } @@ -847,7 +864,7 @@ impl LocalDirectoryProvider for SystemLocalDirectoryProvider { } #[cfg(not(unix))] fn set_permissions(&self, _: &[PathBuf], _: PermissionEdit, _: bool, _: bool) -> Result<()> { - bail!("此系统不支持修改权限") + bail!(t!("sftp.error.permissions_unsupported")) } fn read_file(&self, path: &Path, limit: u64) -> Result<(Vec, FileStamp)> { let metadata = std::fs::metadata(path).map_err(|error| describe_io(error, path))?; @@ -898,10 +915,12 @@ fn display_name(path: &Path) -> String { fn describe_io(error: std::io::Error, path: &Path) -> anyhow::Error { let name = display_name(path); match error.kind() { - std::io::ErrorKind::AlreadyExists => anyhow::anyhow!("已有名为「{name}」的项目"), - std::io::ErrorKind::PermissionDenied => anyhow::anyhow!("没有权限操作「{name}」"), - std::io::ErrorKind::NotFound => anyhow::anyhow!("「{name}」不存在"), - _ => anyhow::anyhow!("「{name}」:{error}"), + std::io::ErrorKind::AlreadyExists => anyhow::anyhow!(t!("sftp.error.exists", name = name)), + std::io::ErrorKind::PermissionDenied => { + anyhow::anyhow!(t!("sftp.error.permission_denied", name = name)) + } + std::io::ErrorKind::NotFound => anyhow::anyhow!(t!("sftp.error.not_found", name = name)), + _ => anyhow::anyhow!(t!("sftp.error.item", name = name, error = error)), } } diff --git a/src/sftp/operations.rs b/src/sftp/operations.rs index 1ea073e..06b16f3 100644 --- a/src/sftp/operations.rs +++ b/src/sftp/operations.rs @@ -3,6 +3,7 @@ //! are never followed. use super::{EntryKind, RemoteOperation, RemotePath, client::RemoteFs}; +use crate::i18n::t; use anyhow::{Result, bail}; pub(crate) async fn run(fs: &F, operation: &RemoteOperation) -> Result<()> { @@ -15,13 +16,13 @@ pub(crate) async fn run(fs: &F, operation: &RemoteOperation) -> Res } RemoteOperation::Rename { from, to } => { if fs.metadata(to).await?.is_some() { - bail!("已有名为「{}」的项目", to.file_name()); + bail!(t!("sftp.error.exists", name = to.file_name())); } fs.rename(from, to, false).await } RemoteOperation::CreateDirectory { path } => { if fs.metadata(path).await?.is_some() { - bail!("已有名为「{}」的项目", path.file_name()); + bail!(t!("sftp.error.exists", name = path.file_name())); } fs.mkdir(path).await } diff --git a/src/sftp/sync.rs b/src/sftp/sync.rs index 4e77739..e7b4e07 100644 --- a/src/sftp/sync.rs +++ b/src/sftp/sync.rs @@ -7,6 +7,8 @@ use std::{collections::HashMap, path::Path}; use anyhow::Result; +use crate::i18n::t; + use super::{ EntryKind, RemotePath, TransferChoice, TransferDetail, TransferQuestionKind, client::{RemoteFs, is_network_error}, @@ -97,7 +99,7 @@ async fn fail( error: &anyhow::Error, control: &TransferControl, ) -> Result { - let message = format!("无法删除:{error:#}"); + let message = t!("sftp.sync.delete_failed", error = format!("{error:#}")); let answer = control .ask(TransferQuestionKind::Error, path.as_str(), &message) .await?; diff --git a/src/sftp/upload.rs b/src/sftp/upload.rs index a1e6c11..5ab49d5 100644 --- a/src/sftp/upload.rs +++ b/src/sftp/upload.rs @@ -8,8 +8,10 @@ use super::{ meter::TransferMeter, model::local_metadata, }; +use crate::i18n::{t, tn}; use anyhow::{Result, anyhow, bail}; use futures::{StreamExt as _, stream::FuturesUnordered}; +use gpui_kit::SharedString; use std::{collections::HashSet, path::PathBuf}; use tokio::io::{AsyncReadExt as _, AsyncSeekExt as _}; const CHUNK: usize = 32 * 1024; @@ -78,7 +80,7 @@ impl UploadBatch { None => source .file_name() .and_then(|n| n.to_str()) - .ok_or_else(|| anyhow!("来源文件名不是有效的 UTF-8"))?, + .ok_or_else(|| anyhow!(t!("sftp.upload.source_name_not_utf8")))?, }; let target = request.destination().join(name)?; stack.push((source, target)); @@ -108,7 +110,7 @@ impl UploadBatch { let name = entry .file_name() .into_string() - .map_err(|_| anyhow!("目录包含无法用 UTF-8 表示的文件名"))?; + .map_err(|_| anyhow!(t!("sftp.error.file_name_not_utf8")))?; children.push((entry.path(), target.join(&name)?)); } children.sort_by(|a, b| a.0.cmp(&b.0)); @@ -155,7 +157,7 @@ impl UploadBatch { } pub fn verify_host(&self, fingerprint: &str) -> Result<()> { if self.host_key != fingerprint { - bail!("服务器主机指纹已改变,已停止续传"); + bail!(t!("sftp.transfer.host_key_changed")); } Ok(()) } @@ -176,14 +178,14 @@ impl UploadBatch { ); let _target_guard = TargetGuard::acquire( format!("{}\0{}", self.endpoint, item.target), - "另一个传输正在上传同一目标,请稍后继续上传", + &t!("sftp.upload.target_busy"), )?; let result = if self .blocked_directories .iter() .any(|p| item.target.as_str().starts_with(&format!("{p}/"))) { - Err(anyhow!("父目录未创建,无法上传")) + Err(anyhow!(t!("sftp.upload.parent_missing"))) } else if let Some(error) = &item.error { Err(anyhow!("{error}")) } else { @@ -208,7 +210,7 @@ impl UploadBatch { .ask( TransferQuestionKind::Error, item.target.as_str(), - &format!("无法上传:{error:#}"), + &t!("sftp.upload.failed", error = format!("{error:#}")), ) .await?; if answer.choice() == TransferChoice::Retry { @@ -264,24 +266,26 @@ impl UploadBatch { .as_ref() .is_some_and(|m| m.kind() == EntryKind::Directory) { - bail!("目录与文件类型冲突,不会删除远程目录"); + bail!(t!("sftp.upload.folder_in_the_way")); } if !changed && let Some(choice) = self.all_conflicts { return Ok(choice == TransferChoice::Overwrite); } self.meter.phase(TransferPhase::Waiting, control); let message = match original { - Some(metadata) => format!( - "{}远程项目已存在({} 字节,修改时间 {:?})。覆盖会替换这个文件或链接。", + Some(metadata) => { + let modified = format!("{:?}", metadata.modified()); if changed { - "目标在上传期间发生变化。" + tn!( + "sftp.upload.exists_changed", + metadata.size(), + modified = modified + ) } else { - "" - }, - metadata.size(), - metadata.modified() - ), - None => "原目标在上传期间已被删除或移走。是否继续发布此文件?".into(), + tn!("sftp.upload.exists", metadata.size(), modified = modified) + } + } + None => t!("sftp.upload.target_gone"), }; let answer = control .ask(TransferQuestionKind::Conflict, target.as_str(), &message) @@ -310,7 +314,7 @@ impl UploadBatch { .ask( TransferQuestionKind::InvalidResume, item.target.as_str(), - "来源已变为目录,请丢弃旧文件进度后重新上传,或跳过。", + &t!("sftp.upload.source_now_folder"), ) .await?; if answer.choice() != TransferChoice::Restart { @@ -320,7 +324,7 @@ impl UploadBatch { } return match control.run(fs.metadata(&item.target)).await? { Some(metadata) if metadata.kind() == EntryKind::Directory => Ok(!self.sync), - Some(_) => bail!("目标已存在且不是目录,不会跟随或替换链接"), + Some(_) => bail!(t!("sftp.upload.target_not_folder")), None => { control.run(fs.mkdir(&item.target)).await?; Ok(true) @@ -328,7 +332,7 @@ impl UploadBatch { }; } if item.metadata.kind() == EntryKind::Other { - bail!("不支持上传设备、套接字或其他特殊文件"); + bail!(t!("sftp.upload.special_file")); } let mut record = if let Some(record) = existing_record { record.validate(&self.endpoint, &self.host_key, &item.source, &item.target)?; @@ -338,7 +342,7 @@ impl UploadBatch { .ask( TransferQuestionKind::Resume, item.target.as_str(), - "发现未完成的 .filepart 文件。续传会按它的现有大小跳过本地文件前缀,请确认本地文件仍是同一版本。", + &t!("sftp.upload.resume_found"), ) .await?; match answer.choice() { @@ -378,7 +382,7 @@ impl UploadBatch { .ask( TransferQuestionKind::Resume, item.target.as_str(), - "发现未完成的 .filepart 文件。续传会按它的现有大小跳过本地文件前缀,请确认本地文件仍是同一版本。", + &t!("sftp.upload.resume_found"), ) .await?; match answer.choice() { @@ -396,7 +400,7 @@ impl UploadBatch { .await? .into_os_string() .into_string() - .map_err(|_| anyhow!("链接目标不是有效的 UTF-8"))?, + .map_err(|_| anyhow!(t!("sftp.upload.link_target_not_utf8")))?, ); } self.journal.save(&record).await?; @@ -414,12 +418,12 @@ impl UploadBatch { let target = record .link_target .as_ref() - .ok_or_else(|| anyhow!("续传记录中缺少链接目标"))?; + .ok_or_else(|| anyhow!(t!("sftp.upload.record_without_link")))?; if let Some(existing) = control.run(fs.metadata(&record.temporary)).await? { if existing.kind() != EntryKind::Symlink || control.run(fs.readlink(&record.temporary)).await? != *target { - bail!("临时链接已变化,无法继续上传"); + bail!(t!("sftp.upload.temporary_link_changed")); } } else { control.run(fs.symlink(target, &record.temporary)).await?; @@ -487,14 +491,14 @@ impl UploadBatch { let source_metadata = record .source_metadata .clone() - .ok_or_else(|| anyhow!("续传记录中缺少来源文件信息"))?; + .ok_or_else(|| anyhow!(t!("sftp.upload.record_without_source")))?; let remote = control.run(fs.metadata(&record.temporary)).await?; if remote.as_ref().is_some_and(|m| m.kind() != EntryKind::File) { - return Err(InvalidResume("远端临时文件类型已变化").into()); + return Err(InvalidResume(t!("sftp.upload.temporary_kind_changed")).into()); } let resume_offset = remote.as_ref().map(FileMetadata::size).unwrap_or(0); if resume_offset > source_metadata.size { - return Err(InvalidResume("远端续传偏移大于本地文件").into()); + return Err(InvalidResume(t!("sftp.upload.partial_too_large")).into()); } let handle = control .run(fs.open(&record.temporary, remote.is_none())) @@ -524,7 +528,7 @@ impl UploadBatch { let length = writes .next() .await - .ok_or_else(|| anyhow!("上传写入流水线意外结束"))??; + .ok_or_else(|| anyhow!(t!("sftp.upload.pipeline_ended")))??; uploaded += length; self.meter.advance(length, uploaded, control); } @@ -601,22 +605,22 @@ impl UploadBatch { { fs.rename(&record.backup, &record.target, false).await?; } - bail!("无法确认文件发布结果,已保留恢复文件;请检查远端目录"); + bail!(t!("sftp.upload.publish_unconfirmed")); } if !self .matches_expected_item(fs, &record.temporary, record, control) .await? { - bail!("临时文件大小或类型已变化,未替换目标"); + bail!(t!("sftp.upload.temporary_changed")); } let backup = control.run(fs.metadata(&record.backup)).await?; let current = control.run(fs.metadata(&record.target)).await?; if backup.is_some() { if current.is_some() { - bail!("替换期间目标被其他操作创建,已保留目标、备份和临时文件"); + bail!(t!("sftp.upload.target_created")); } if backup != record.original { - bail!("恢复备份已变化,停止替换"); + bail!(t!("sftp.upload.backup_changed")); } record.phase = PublishPhase::BackedUp; self.journal.save(record).await?; @@ -682,7 +686,7 @@ impl UploadBatch { async fn cleanup(&self, fs: &F, record: &ResumeRecord) -> Result<()> { if let Some(backup) = fs.metadata(&record.backup).await? { if Some(backup) != record.original { - bail!("备份已变化,未清理;上传的目标文件已发布"); + bail!(t!("sftp.upload.backup_changed_after_publish")); } fs.remove(&record.backup).await?; } @@ -699,7 +703,7 @@ impl UploadBatch { if fs.metadata(&record.target).await?.is_none() { fs.rename(&record.backup, &record.target, false).await?; } else { - bail!("存在恢复备份和目标文件,请先检查远端目录;未删除任何文件"); + bail!(t!("sftp.upload.backup_and_target")); } } fs.remove(&record.temporary).await?; @@ -719,10 +723,10 @@ impl UploadBatch { } } #[derive(Debug)] -struct InvalidResume(&'static str); +struct InvalidResume(SharedString); impl std::fmt::Display for InvalidResume { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.0) + f.write_str(&self.0) } } impl std::error::Error for InvalidResume {} diff --git a/src/sftp/worker.rs b/src/sftp/worker.rs index ffb8fe5..2f61b49 100644 --- a/src/sftp/worker.rs +++ b/src/sftp/worker.rs @@ -14,6 +14,7 @@ use super::{ }; use crate::{ host::HostLogin, + i18n::{t, tn}, ssh::{LATENCY_INTERVAL, SshConnectionConfig, SshConnector, SshPrompts, describe_login_error}, }; use anyhow::{Result, anyhow}; @@ -29,7 +30,9 @@ use std::{ use tokio::sync::{RwLock, watch}; /// The answer to a request that needs the connection while there is none. -const NOT_CONNECTED: &str = "SFTP 未连接,请重新连接"; +fn not_connected() -> String { + t!("sftp.worker.not_connected").into() +} /// How often the latency task looks for a connection to measure, and for /// the one it is measuring to be dropped. @@ -183,7 +186,9 @@ impl SshSftpTransport { prompts.cancel_all(); *client.write().await = None; let _ = events - .send(SftpEvent::Disconnected("SFTP 已断开".into())) + .send(SftpEvent::Disconnected( + t!("sftp.worker.disconnected").into(), + )) .await; } SftpCommand::Shutdown => break, @@ -197,10 +202,14 @@ impl SshSftpTransport { Ok(listing) => Ok(listing), Err(error) => { drop_broken(&shared, &connected, &events, &error).await; - Err(format!("无法读取目录:{error:#}")) + Err(t!( + "sftp.worker.list_failed", + error = format!("{error:#}") + ) + .into()) } }, - None => Err(NOT_CONNECTED.into()), + None => Err(not_connected()), }; let _ = events.send(SftpEvent::Listed { request_id, result }).await; }); @@ -225,7 +234,7 @@ impl SshSftpTransport { } } } - None => Err(NOT_CONNECTED.into()), + None => Err(not_connected()), }; let _ = events .send(SftpEvent::Operated { request_id, result }) @@ -248,7 +257,7 @@ impl SshSftpTransport { } } } - None => Err(ReadFailure::Failed(NOT_CONNECTED.into())), + None => Err(ReadFailure::Failed(not_connected())), }; let _ = events .send(SftpEvent::FileRead { request_id, result }) @@ -277,7 +286,7 @@ impl SshSftpTransport { } } } - None => Err(ReadFailure::Failed(NOT_CONNECTED.into())), + None => Err(ReadFailure::Failed(not_connected())), }; let _ = events .send(SftpEvent::BytesRead { request_id, result }) @@ -309,7 +318,7 @@ impl SshSftpTransport { Err(SaveFailure::from_error(error)) } }, - None => Err(SaveFailure::Failed(NOT_CONNECTED.into())), + None => Err(SaveFailure::Failed(not_connected())), }; let _ = events .send(SftpEvent::FileWritten { request_id, result }) @@ -319,7 +328,7 @@ impl SshSftpTransport { command => { if busy.swap(true, Ordering::AcqRel) { let _ = events - .send(SftpEvent::Notice("已有传输批次正在处理".into())) + .send(SftpEvent::Notice(t!("sftp.worker.busy").into())) .await; } else { cancel_tx.send_replace(false); @@ -426,7 +435,12 @@ impl SshSftpTransport { .ok() .filter(|path| path.is_dir()) .map(|path| dunce::simplified(&path).to_path_buf()) - .ok_or_else(|| anyhow!("本地目录 {} 不存在", source.display()))?; + .ok_or_else(|| { + anyhow!(t!( + "sftp.worker.local_folder_missing", + path = source.display() + )) + })?; let target = sync_target(connected.as_ref(), request.destination(), &control) .await?; @@ -449,7 +463,8 @@ impl SshSftpTransport { let (directory, name) = scp_remote_target(destination, is_directory); // Some servers resolve a path that is not there, so // ask whether it is a directory too. - let missing = || anyhow!("远程目录 {directory} 不存在"); + let missing = + || anyhow!(t!("sftp.worker.remote_folder_missing", path = directory)); let resolved = control .run(connected.canonicalize(&directory)) .await @@ -492,7 +507,10 @@ impl SshSftpTransport { .await .is_ok_and(|metadata| metadata.is_dir()) { - anyhow::bail!("本地目录 {} 不存在", directory.display()); + anyhow::bail!(t!( + "sftp.worker.local_folder_missing", + path = directory.display() + )); } request.clone().resolved(directory, name) } else { @@ -568,7 +586,7 @@ impl SshSftpTransport { } else { Err(std::io::Error::new( std::io::ErrorKind::NotConnected, - "SFTP 未连接", + t!("sftp.worker.not_connected_short").to_string(), ) .into()) } @@ -579,15 +597,20 @@ impl SshSftpTransport { Err(error) if is_network_error(&error) => { *client.write().await = None; events - .send(SftpEvent::Disconnected("连接中断,传输进度已保留".into())) + .send(SftpEvent::Disconnected( + t!("sftp.worker.dropped_kept").into(), + )) .await?; let mut recovered = false; while reconnects < 3 { - batch.set_note(format!( - "将在 {} 秒后重连({}/3)", - [1, 3, 10][reconnects], - reconnects + 1 - )); + batch.set_note( + tn!( + "sftp.worker.reconnect_in", + [1, 3, 10][reconnects], + attempt = reconnects + 1 + ) + .into(), + ); batch.phase(TransferPhase::Reconnecting, &control); let delay = Duration::from_secs([1, 3, 10][reconnects]); reconnects += 1; @@ -670,10 +693,10 @@ async fn sync_target( Some(metadata) if metadata.kind() == EntryKind::Directory => { control.run(fs.canonicalize(destination)).await? } - Some(_) => anyhow::bail!("远程路径 {destination} 不是目录"), + Some(_) => anyhow::bail!(t!("sftp.worker.not_a_folder", path = destination)), None => { let parent = destination.parent(); - let missing = || anyhow!("远程目录 {parent} 不存在"); + let missing = || anyhow!(t!("sftp.worker.remote_folder_missing", path = parent)); let parent = control .run(fs.canonicalize(&parent)) .await @@ -693,7 +716,7 @@ async fn sync_target( } }; if target.is_root() { - anyhow::bail!("不能同步到根目录 /"); + anyhow::bail!(t!("sftp.worker.sync_to_root")); } Ok(target) } @@ -766,7 +789,7 @@ async fn forget( if current.as_ref().is_some_and(|c| Arc::ptr_eq(c, connected)) { *current = None; let _ = events - .send(SftpEvent::Disconnected("SFTP 连接中断,请重新连接".into())) + .send(SftpEvent::Disconnected(t!("sftp.worker.dropped").into())) .await; } } diff --git a/tests/ui/sftp.rs b/tests/ui/sftp.rs index 57efa23..17b62b2 100644 --- a/tests/ui/sftp.rs +++ b/tests/ui/sftp.rs @@ -235,6 +235,51 @@ async fn sftp_panes_list_winscp_columns_and_open_links_to_directories(cx: &mut T .await; } +/// The panes follow a change of interface language at once, the column +/// titles too, which the table keeps for itself. +#[gpui_kit::test] +async fn sftp_panes_follow_a_change_of_interface_language(cx: &mut TestAppContext) { + let provider = Arc::new(FakeSftpProvider::default()); + let (handle, workspace) = open_workspace_with_sftp(cx, provider); + open_test_explorer(cx, handle).await; + let settings = cx.update(|cx| workspace.read(cx).settings().clone()); + cx.update(|cx| { + settings.update(cx, |settings, cx| { + settings.update( + |settings| settings.language = InterfaceLanguage::English, + cx, + ) + }) + }); + cx.run_until_parked(); + cx.update(|cx| { + let explorer = workspace + .read(cx) + .explorer(ExplorerId(SFTP_TAB)) + .unwrap() + .read(cx); + assert_eq!( + explorer.local().read(cx).column_names(cx), + ["Name", "Size", "Type", "Modified"] + ); + assert_eq!( + explorer.remote().read(cx).column_names(cx), + ["Name", "Size", "Modified", "Permissions", "Owner"] + ); + }); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + let remote = window.within(("remote-pane", SFTP_TAB)); + assert_eq!(remote.find("pane-status").label(), Some("4 items")); + assert_eq!( + remote.find("hidden-files").label(), + Some("Show hidden files") + ); + assert_eq!(window.find("remote-path").label(), Some("Remote path")); + }) + .unwrap(); +} + /// The 大小 column shows whole kilobytes, as WinSCP does, until its title's /// menu picks another format, which then holds for both panes and is saved. /// The menu itself is not driven; the test dispatches what its items do.