SFTP 与编辑器的文案接入多语言

explorer/、sftp/、editor/ 的文案移进 locales/explorer.yml、sftp.yml、editor.yml,中文逐字不变。
文件列表的列名、目录列表的分组跟着界面语言重设;列表占位改存枚举,拖动预览存方向,
「位置」改存 Place,显示时再翻译。动词拼句的地方(上传 / 下载、编辑器 / 预览、服务器上的 /
本机上的)拆成每种情况一个整句;废纸篓在 Windows 上写 Recycle Bin。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
XuzandClaude Opus 5.5 committed 2026-10-07 22:12:51 +08:00
1 parent eb6e43045a
commit a0ed1ac8bf
32 files changed
+2098 -592

No files matched your search

+71
View File
@@ -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
+706
View File
@@ -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)
+317
View File
@@ -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}"
+37 -37
View File
@@ -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())),
),
)
}
+7 -1
View File
@@ -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」.
+29 -26
View File
@@ -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>) {
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,
+24 -21
View File
@@ -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<InputState>,
label: &'static str,
label: SharedString,
}
impl Render for NameForm {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> 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(),
+48 -29
View File
@@ -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<Self>,
) {
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"))),
}
});
}
+86 -22
View File
@@ -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<ListGeometry>,
/// 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<Column> {
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::<Vec<String>>(), "下载"),
PaneSide::Local => drag_files(
cell,
paths.map(PathBuf::from).collect(),
TransferDirection::Upload,
),
PaneSide::Remote => drag_files(
cell,
paths.collect::<Vec<String>>(),
TransferDirection::Download,
),
}
}
@@ -549,7 +606,7 @@ impl TableDelegate for FileListing {
_: &mut Window,
cx: &mut Context<TableState<Self>>,
) -> 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<P> {
pub(super) type LocalFilesDrag = FilesDrag<PathBuf>;
pub(super) type RemoteFilesDrag = FilesDrag<String>;
/// Let `cell` be dragged to the other pane, carrying `paths`. `verb` is what
/// dropping them there does.
fn drag_files<P: 'static>(cell: Stateful<Div>, paths: Vec<P>, verb: &'static str) -> Stateful<Div> {
/// Let `cell` be dragged to the other pane, carrying `paths`, which dropping
/// there moves in `direction`.
fn drag_files<P: 'static>(
cell: Stateful<Div>,
paths: Vec<P>,
direction: TransferDirection,
) -> Stateful<Div> {
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()
+95 -62
View File
@@ -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<TableState<FileListing>>,
/// The path label part under the pointer, by the directory it opens.
pub(super) hovered_part: Option<String>,
@@ -224,7 +236,7 @@ impl FilePane {
explorer: ExplorerId,
host_id: HostId,
home: String,
places: Vec<(SharedString, String)>,
places: Vec<(Place, String)>,
store: Entity<HostStore>,
dispatch: FocusHandle,
window: &mut Window,
@@ -262,6 +274,15 @@ impl FilePane {
),
cx.observe(&store, |_, _, cx| cx.notify()),
cx.observe_global::<ShowHiddenFiles>(|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::<UiLocale>(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))
+31 -19
View File
@@ -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()
+17 -16
View File
@@ -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"),
}
}
+90 -61
View File
@@ -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<Self>) -> 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))
+36 -33
View File
@@ -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(),
)
+10 -9
View File
@@ -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<String>,
/// A new file, to open in the editor once it exists (WinSCP does).
edit: Option<String>,
}
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(
+1 -1
View File
@@ -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)
+9 -4
View File
@@ -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)),
),
)
+39 -21
View File
@@ -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<String> = 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| {
+54 -23
View File
@@ -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<ExplorerPanel>) -> 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<ExplorerPanel>) -> 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(
+121 -45
View File
@@ -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)"
);
}
}
+19 -14
View File
@@ -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(),
},
}
}
+8 -7
View File
@@ -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<S>(stream: S, ssh: Option<SshHandle>, fingerprint: String) -> Result<Self>
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<String> {
if value.contains('\u{fffd}') {
bail!("远端名称包含无法可靠表示的字符,已停止操作");
bail!(t!("sftp.client.ambiguous_name"));
}
Ok(value.to_string())
}
+2 -1
View File
@@ -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 {}
+32 -28
View File
@@ -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::<Cancelled>() =>
@@ -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<bool> {
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"))
}
+8 -7
View File
@@ -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<F: RemoteFs>(
text: bool,
) -> Result<(Vec<u8>, 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<F: RemoteFs>(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<F: RemoteFs>(
let metadata = fs
.stat(path)
.await?
.ok_or_else(|| anyhow!("保存后找不到文件"))?;
.ok_or_else(|| anyhow!(t!("sftp.edit.gone_after_save")))?;
Ok(FileStamp::of(&metadata))
}
+12 -10
View File
@@ -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<Self> {
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<Option<ResumeRecord>> {
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<T: DeserializeOwned>(path: &Path, damaged: &'static str) -> Result<Option<T>> {
async fn read_record<T: DeserializeOwned>(path: &Path, damaged: SharedString) -> Result<Option<T>> {
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<Option<DownloadRecord>> {
read_record(
&self.path(endpoint, source, target),
"续传记录损坏,临时文件已保留",
t!("sftp.journal.download_damaged"),
)
.await
}
+52 -33
View File
@@ -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<String>) -> Result<Self> {
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<Self> {
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<PathBuf>, destination: RemotePath) -> Result<Self> {
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<RemotePath>, destination: PathBuf) -> Result<Self> {
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<Self> {
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<DirectoryListing>;
/// 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<DirectoryListing> {
@@ -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<u8>, 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)),
}
}
+3 -2
View File
@@ -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<F: RemoteFs>(fs: &F, operation: &RemoteOperation) -> Result<()> {
@@ -15,13 +16,13 @@ pub(crate) async fn run<F: RemoteFs>(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
}
+3 -1
View File
@@ -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<bool> {
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?;
+40 -36
View File
@@ -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<F: RemoteFs>(&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 {}
+46 -23
View File
@@ -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;
}
}
+45
View File
@@ -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.