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:
1 parent
eb6e43045a
commit
a0ed1ac8bf
32 files changed
+2098
-592
No files matched your search
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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」.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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"))),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user