右侧栏七个工具的文案接入多语言

命令片段、历史命令、Docker、系统服务、进程管理、网络连接、系统监控和工作区里它们的确认、
通知改用 t! / tn!,文案在 locales/<工具>.yml 与 locales/tools.yml。容器操作的对象改成
ContainerSubject,每种操作一整句;详情里的字段存键、按键找行,切换语言时跟着变;
搜索框的占位随界面语言重设。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
XuzandClaude Opus 5.5 committed 2026-10-07 22:07:14 +08:00
1 parent 70b329066f
commit 5514612524
37 files changed
+2605 -810

No files matched your search

+253
View File
@@ -0,0 +1,253 @@
_version: 2
# Docker: the right sidebar's tool for containers, volumes, images and
# networks. Docker's own terms stay as Docker says them.
docker.tab.containers:
zh-CN: 容器
en: Containers
docker.tab.volumes:
zh-CN: 卷
en: Volumes
docker.tab.images:
zh-CN: 镜像
en: Images
docker.tab.networks:
zh-CN: 网络
en: Networks
docker.panel.no_containers:
zh-CN: 没有容器
en: No containers
docker.panel.no_volumes:
zh-CN: 没有卷
en: No volumes
docker.panel.no_images:
zh-CN: 没有镜像
en: No images
docker.panel.no_networks:
zh-CN: 没有网络
en: No networks
docker.panel.standalone:
zh-CN: 独立容器
en: Standalone containers
docker.panel.not_connected:
zh-CN: 终端没有连接。连接后这里显示主机上的容器。
en: The terminal isn’t connected. Once it is, the host’s containers show here.
docker.panel.missing:
zh-CN: 这台主机没有安装 Docker。
en: Docker isn’t installed on this host.
docker.panel.unsupported:
zh-CN: 暂不支持这台主机上的 Docker。
en: Docker on this host isn’t supported yet.
docker.panel.unreachable:
zh-CN: 无法读取 Docker:%{error}
en: "Couldn’t read Docker: %{error}"
docker.project.running:
zh-CN: "%{running}/%{total} 运行中"
en: "%{running}/%{total} running"
# What a container is doing, as `docker ps` says.
docker.state.running:
zh-CN: 运行中
en: Running
docker.state.paused:
zh-CN: 已暂停
en: Paused
docker.state.restarting:
zh-CN: 正在重启
en: Restarting
docker.state.created:
zh-CN: 已创建
en: Created
docker.state.exited:
zh-CN: 已停止
en: Exited
docker.state.dead:
zh-CN: 异常
en: Dead
docker.state.removing:
zh-CN: 正在删除
en: Removing
docker.command.start:
zh-CN: 启动
en: Start
docker.command.stop:
zh-CN: 停止
en: Stop
docker.command.restart:
zh-CN: 重启
en: Restart
docker.object.container:
zh-CN: 容器
en: Container
docker.object.image:
zh-CN: 镜像
en: Image
docker.object.volume:
zh-CN: 卷
en: Volume
docker.object.network:
zh-CN: 网络
en: Network
docker.object.in_use:
zh-CN: 使用中
en: In use
docker.object.unused:
zh-CN: 未使用
en: Unused
docker.object.in_use_by_containers:
zh-CN: 有容器在用,不能删除
en: A container uses it, so it can’t be deleted
docker.image.unnamed:
zh-CN: 未命名镜像
en: Untagged image
docker.network.builtin:
zh-CN: 内置
en: Built-in
docker.network.builtin_kept:
zh-CN: Docker 自带的网络,不能删除
en: Docker’s own network can’t be deleted
docker.network.containers.one:
en: "%{count} container"
docker.network.containers.other:
zh-CN: "%{count} 个容器"
en: "%{count} containers"
docker.network.in_use:
zh-CN: 有容器连着,不能删除
en: Containers are attached, so it can’t be deleted
# Docker's complaints, where the user can do something about them.
docker.error.permission:
zh-CN: 没有权限:以 root 登录、把这个用户加入 docker 组,或给它配置免密码的 sudo
en: "Permission denied: log in as root, add this user to the docker group, or give it passwordless sudo"
docker.error.not_running:
zh-CN: Docker 没有运行
en: Docker isn’t running
docker.error.status:
zh-CN: docker 返回 %{status}
en: docker exited with status %{status}
# The details dialogs.
docker.details.tab_details:
zh-CN: 详情
en: Details
docker.details.tab_logs:
zh-CN: 日志
en: Logs
docker.details.no_output:
zh-CN: 没有输出
en: No output
docker.details.container_gone:
zh-CN: 读不到这个容器:它可能已经被删除
en: Couldn’t read this container. It may have been deleted.
docker.details.image_gone:
zh-CN: 读不到这个镜像:它可能已经被删除
en: Couldn’t read this image. It may have been deleted.
docker.details.volume_gone:
zh-CN: 读不到这个卷:它可能已经被删除
en: Couldn’t read this volume. It may have been deleted.
docker.details.network_gone:
zh-CN: 读不到这个网络:它可能已经被删除
en: Couldn’t read this network. It may have been deleted.
docker.section.basics:
zh-CN: 基础信息
en: Overview
docker.section.ports:
zh-CN: 端口映射
en: Ports
docker.section.mounts:
zh-CN: 挂载
en: Mounts
docker.section.environment:
zh-CN: 环境变量
en: Environment
docker.section.labels:
zh-CN: 标签
en: Labels
docker.section.users:
zh-CN: 使用它的容器
en: Used by
docker.section.options:
zh-CN: 选项
en: Options
docker.section.config:
zh-CN: 运行配置
en: Configuration
docker.section.subnets:
zh-CN: 子网
en: Subnets
docker.section.containers:
zh-CN: 容器
en: Containers
docker.field.name:
zh-CN: 名称
en: Name
docker.field.image:
zh-CN: 镜像
en: Image
docker.field.created:
zh-CN: 创建时间
en: Created
docker.field.entrypoint:
zh-CN: 入口
en: Entrypoint
docker.field.command:
zh-CN: 命令
en: Command
docker.field.driver:
zh-CN: 驱动
en: Driver
docker.field.mountpoint:
zh-CN: 挂载点
en: Mountpoint
docker.field.scope:
zh-CN: 范围
en: Scope
docker.field.tags:
zh-CN: 标签
en: Tags
docker.field.digests:
zh-CN: 摘要
en: Digests
docker.field.size:
zh-CN: 大小
en: Size
docker.field.platform:
zh-CN: 平台
en: Platform
docker.field.layers:
zh-CN: 层数
en: Layers
docker.field.author:
zh-CN: 作者
en: Author
docker.field.working_dir:
zh-CN: 工作目录
en: Working directory
docker.field.user:
zh-CN: 用户
en: User
docker.field.exposed_ports:
zh-CN: 暴露端口
en: Exposed ports
docker.field.internal:
zh-CN: 内部网络
en: Internal
docker.value.yes:
zh-CN: 是
en: "Yes"
docker.value.no:
zh-CN: 否
en: "No"
docker.mount.read_only:
zh-CN: "%{path}(只读)"
en: "%{path} (read-only)"
docker.network.gateway:
zh-CN: 网关 %{address}
en: Gateway %{address}
+61
View File
@@ -0,0 +1,61 @@
_version: 2
# 历史命令: the commands bash kept in the host's ~/.bash_history.
history.panel.search:
zh-CN: 搜索命令
en: Search commands
history.panel.count.one:
en: "%{count} command"
history.panel.count.other:
zh-CN: 共 %{count} 条
en: "%{count} commands"
history.panel.truncated:
zh-CN: 历史文件太长,只读了最后 %{size} KB。
en: The history file is long, so only its last %{size} KB were read.
history.panel.note:
zh-CN: 这个终端里执行的命令,bash 退出后才会出现在这里。
en: Commands run in this terminal show up here once bash exits.
history.panel.empty:
zh-CN: 没有历史命令
en: No history
history.panel.no_match:
zh-CN: 没有符合条件的命令
en: No matching commands
history.panel.not_connected:
zh-CN: 终端没有连接。连接后这里显示主机上 bash 的历史命令。
en: The terminal isn’t connected. Once it is, the host’s bash history shows here.
history.panel.missing:
zh-CN: 这台主机上还没有 bash 的历史命令(~/.bash_history)。bash 退出时才会写入它。
en: This host has no bash history yet (~/.bash_history). bash writes it when it exits.
history.panel.unsupported:
zh-CN: 暂不支持读取这台主机的历史命令,只支持 bash 的 ~/.bash_history。
en: This host’s history can’t be read yet. Only bash’s ~/.bash_history is supported.
history.menu.copy:
zh-CN: 复制
en: Copy
# Under each command: when it last ran, and how often.
history.entry.runs.one:
en: Ran once
history.entry.runs.other:
zh-CN: 执行 %{count} 次
en: Ran %{count} times
history.when.just_now:
zh-CN: 刚刚
en: Just now
history.when.minutes.one:
en: "%{count} minute ago"
history.when.minutes.other:
zh-CN: "%{count} 分钟前"
en: "%{count} minutes ago"
history.when.hours.one:
en: "%{count} hour ago"
history.when.hours.other:
zh-CN: "%{count} 小时前"
en: "%{count} hours ago"
history.when.days.one:
en: "%{count} day ago"
history.when.days.other:
zh-CN: "%{count} 天前"
en: "%{count} days ago"
+92
View File
@@ -0,0 +1,92 @@
_version: 2
# 系统监控: a Linux host's CPU, memory, network and disks.
monitor.panel.not_connected:
zh-CN: 终端没有连接。连接后这里显示主机的 CPU、内存、网络和磁盘。
en: The terminal isn’t connected. Once it is, the host’s CPU, memory, network and disks show here.
monitor.panel.unsupported_system:
zh-CN: 暂不支持 %{system} 的监控,目前只支持 Linux 主机。
en: Monitoring %{system} isn’t supported yet. Only Linux hosts are supported.
monitor.panel.unsupported:
zh-CN: 暂不支持这台主机的监控,目前只支持 Linux 主机。
en: Monitoring this host isn’t supported yet. Only Linux hosts are supported.
monitor.system.title:
zh-CN: 系统
en: System
monitor.system.host:
zh-CN: 主机
en: Host
monitor.system.arch:
zh-CN: 架构
en: Architecture
monitor.system.os:
zh-CN: 系统
en: OS
monitor.system.uptime:
zh-CN: 运行时长
en: Uptime
monitor.cpu.cores.one:
en: "%{count} core"
monitor.cpu.cores.other:
zh-CN: "%{count} 核"
en: "%{count} cores"
monitor.cpu.unfold_cores:
zh-CN: 显示更多核的占用
en: Show every core
monitor.cpu.fold_cores:
zh-CN: 收起更多核的占用
en: Show fewer cores
monitor.cpu.average:
zh-CN: 平均使用率
en: Average
# 「核 0:100.0%,核 1:33.3%」: each core, and the list they make.
monitor.cpu.core:
zh-CN: 核 %{index}:%{load}
en: "Core %{index}: %{load}"
monitor.cpu.core_list:
zh-CN: "%{list},%{core}"
en: "%{list}, %{core}"
monitor.memory.title:
zh-CN: 内存
en: Memory
monitor.memory.physical:
zh-CN: 物理内存
en: Physical memory
monitor.memory.swap:
zh-CN: 交换空间
en: Swap
monitor.network.title:
zh-CN: 网络
en: Network
monitor.network.interfaces.one:
en: "%{count} interface"
monitor.network.interfaces.other:
zh-CN: "%{count} 个网卡"
en: "%{count} interfaces"
monitor.network.unfold:
zh-CN: 显示全部网卡
en: Show all interfaces
monitor.network.fold:
zh-CN: 只显示主网卡
en: Show only the main interface
monitor.network.none:
zh-CN: 没有在用的网卡
en: No interfaces in use
monitor.network.label:
zh-CN: 上传 %{up},下载 %{down}
en: Up %{up}, down %{down}
monitor.disks.title:
zh-CN: 磁盘
en: Disks
monitor.disks.none:
zh-CN: 没有读到磁盘
en: No disks found
monitor.disks.label:
zh-CN: "%{percent},%{used} / %{total}"
en: "%{percent}, %{used} / %{total}"
+100
View File
@@ -0,0 +1,100 @@
_version: 2
# 网络连接: a Linux host's sockets, as `ss` lists them.
netstat.panel.search:
zh-CN: 搜索地址、端口、状态、PID 或进程
en: Search addresses, ports, states, PIDs and processes
netstat.panel.truncated:
zh-CN: 连接太多,只列出了前 %{limit} 条。
en: There are too many sockets, so only the first %{limit} are listed.
netstat.panel.own_processes:
zh-CN: 当前用户不是 root,只能看到自己的进程。
en: You’re not logged in as root, so only your own processes show.
netstat.panel.no_ss:
zh-CN: 这台主机没有 ss 命令,看不到占用连接的进程。
en: This host has no ss command, so the processes behind the sockets don’t show.
netstat.panel.protocol:
zh-CN: 协议
en: Protocol
netstat.panel.state:
zh-CN: 状态
en: State
netstat.panel.matches.one:
en: "%{count} match"
netstat.panel.matches.other:
zh-CN: 筛选出 %{count} 条
en: "%{count} matches"
netstat.panel.empty:
zh-CN: 没有网络连接
en: No sockets
netstat.panel.no_match:
zh-CN: 没有符合条件的连接
en: No matching sockets
netstat.panel.not_connected:
zh-CN: 终端没有连接。连接后这里显示主机的网络连接。
en: The terminal isn’t connected. Once it is, the host’s sockets show here.
netstat.panel.unsupported_system:
zh-CN: 暂不支持查看 %{system} 的网络连接,目前只支持 Linux 主机。
en: Sockets on %{system} can’t be listed yet. Only Linux hosts are supported.
netstat.panel.unsupported:
zh-CN: 暂不支持查看这台主机的网络连接,目前只支持 Linux 主机。
en: This host’s sockets can’t be listed yet. Only Linux hosts are supported.
# 「共 96 条 · 15 个监听端口 · 21 条已连接」.
netstat.summary.total.one:
en: "%{count} socket"
netstat.summary.total.other:
zh-CN: 共 %{count} 条
en: "%{count} sockets"
netstat.summary.listening.one:
en: "%{count} listening port"
netstat.summary.listening.other:
zh-CN: "%{count} 个监听端口"
en: "%{count} listening ports"
netstat.summary.connected.other:
zh-CN: "%{count} 条已连接"
en: "%{count} established"
netstat.filter.all_protocols:
zh-CN: 全部协议
en: All protocols
netstat.filter.all_states:
zh-CN: 全部状态
en: All states
netstat.filter.other_states:
zh-CN: 其他状态
en: Other states
# The two states said in words; the rest keep their TCP names.
netstat.state.listen:
zh-CN: 监听
en: Listening
netstat.state.established:
zh-CN: 已连接
en: Established
netstat.role.listening:
zh-CN: 监听端口
en: Listening port
netstat.role.inbound:
zh-CN: 入站连接
en: Inbound
netstat.role.outbound:
zh-CN: 出站连接
en: Outbound
netstat.card.label:
zh-CN: "%{protocol} %{state} · 本地 %{local} · 远端 %{peer} · %{process} · %{user}"
en: "%{protocol} %{state} · Local %{local} · Remote %{peer} · %{process} · %{user}"
netstat.card.local:
zh-CN: 本地
en: Local
netstat.card.peer:
zh-CN: 远端
en: Remote
netstat.card.process:
zh-CN: 进程
en: Process
netstat.process.several:
zh-CN: "%{name} (PID %{pid} 等 %{count} 个)"
en: "%{name} (PID %{pid} and others, %{count} in all)"
+166
View File
@@ -0,0 +1,166 @@
_version: 2
# 进程管理: a Linux host's processes.
processes.panel.search:
zh-CN: 搜索进程名称、PID 或用户
en: Search names, PIDs and users
processes.panel.summary.one:
en: "%{count} process · refreshed every %{seconds} s"
processes.panel.summary.other:
zh-CN: 共 %{count} 个进程 · 每 %{seconds} 秒刷新
en: "%{count} processes · refreshed every %{seconds} s"
processes.panel.truncated:
zh-CN: 进程太多,只列出了前 %{limit} 个。
en: There are too many processes, so only the first %{limit} are listed.
processes.panel.empty:
zh-CN: 没有进程
en: No processes
processes.panel.no_match:
zh-CN: 没有符合条件的进程
en: No matching processes
processes.panel.not_connected:
zh-CN: 终端没有连接。连接后这里显示主机的进程。
en: The terminal isn’t connected. Once it is, the host’s processes show here.
processes.panel.unsupported_system:
zh-CN: 暂不支持管理 %{system} 的进程,目前只支持 Linux 主机。
en: Processes on %{system} can’t be managed yet. Only Linux hosts are supported.
processes.panel.unsupported:
zh-CN: 暂不支持管理这台主机的进程,目前只支持 Linux 主机。
en: This host’s processes can’t be managed yet. Only Linux hosts are supported.
processes.memory:
zh-CN: 内存
en: Memory
processes.card.label:
zh-CN: "%{name} · PID %{pid} · %{state} · %{user} · 内存 %{memory} · CPU %{cpu}"
en: "%{name} · PID %{pid} · %{state} · %{user} · Memory %{memory} · CPU %{cpu}"
processes.sort.memory_descending:
zh-CN: 按内存从多到少排列
en: Sort by memory, most first
processes.sort.memory_ascending:
zh-CN: 改为按内存从少到多排列
en: Sort by memory, least first
processes.sort.cpu_descending:
zh-CN: 按CPU从多到少排列
en: Sort by CPU, most first
processes.sort.cpu_ascending:
zh-CN: 改为按CPU从少到多排列
en: Sort by CPU, least first
processes.menu.end:
zh-CN: 结束进程…
en: End process…
processes.menu.kill:
zh-CN: 强制结束进程…
en: Kill process…
# What a process is doing: the letter in /proc/<pid>/stat.
processes.state.running:
zh-CN: 运行中
en: Running
processes.state.sleeping:
zh-CN: 休眠
en: Sleeping
processes.state.uninterruptible:
zh-CN: 等待 I/O
en: Waiting for I/O
processes.state.stopped:
zh-CN: 已暂停
en: Stopped
processes.state.zombie:
zh-CN: 僵尸进程
en: Zombie
processes.state.idle:
zh-CN: 空闲
en: Idle
processes.state.misc:
zh-CN: 其他
en: Other
processes.error.not_permitted:
zh-CN: 没有权限:只能结束自己的进程,root 才能结束所有进程
en: "Permission denied: only root can end another user’s process"
processes.error.gone:
zh-CN: 进程已经退出
en: The process has already exited
processes.error.status:
zh-CN: kill 返回 %{status}
en: kill exited with status %{status}
# A process's details.
processes.details.info:
zh-CN: 进程信息
en: Overview
processes.details.command:
zh-CN: 完整命令
en: Command line
processes.details.copy_pid:
zh-CN: 复制 PID
en: Copy PID
processes.details.copy_command:
zh-CN: 复制命令
en: Copy command
processes.details.copied:
zh-CN: 已复制
en: Copied
processes.details.end:
zh-CN: 结束进程
en: End process
processes.details.kill:
zh-CN: 强制结束
en: Kill
processes.details.none:
zh-CN: 无
en: None
processes.details.no_command:
zh-CN: 没有命令行:进程已经退出,或是僵尸进程
en: No command line. The process has exited, or it’s a zombie.
processes.field.pid:
zh-CN: PID
en: PID
processes.field.parent:
zh-CN: 父进程
en: Parent
processes.field.user:
zh-CN: 用户
en: User
processes.field.state:
zh-CN: 状态
en: State
processes.field.started:
zh-CN: 启动时间
en: Started
processes.field.running:
zh-CN: 运行时长
en: Elapsed
processes.field.terminal:
zh-CN: 终端
en: TTY
processes.field.priority:
zh-CN: 优先级
en: Priority
processes.field.nice:
zh-CN: Nice
en: Nice
processes.field.cpu:
zh-CN: CPU 使用率
en: CPU usage
processes.field.cpu_time:
zh-CN: 累计 CPU 时间
en: CPU time
processes.field.memory:
zh-CN: 常驻内存
en: Memory (RSS)
processes.field.virtual_memory:
zh-CN: 虚拟内存
en: Virtual memory
processes.field.threads:
zh-CN: 线程
en: Threads
processes.field.children:
zh-CN: 直接子进程
en: Children
processes.field.descendants:
zh-CN: 全部后代进程
en: Descendants
+251
View File
@@ -0,0 +1,251 @@
_version: 2
# 系统服务: a Linux host's systemd services. systemd's states keep the
# words systemd uses.
services.panel.search:
zh-CN: 搜索服务名称或描述
en: Search names and descriptions
services.panel.empty:
zh-CN: 没有服务
en: No services
services.panel.no_match:
zh-CN: 没有符合条件的服务
en: No matching services
services.panel.no_description:
zh-CN: 无描述
en: No description
services.panel.not_connected:
zh-CN: 终端没有连接。连接后这里显示主机的系统服务。
en: The terminal isn’t connected. Once it is, the host’s services show here.
services.panel.no_systemd:
zh-CN: 这台主机没有使用 systemd,暂不支持管理它的服务。
en: This host doesn’t use systemd, so its services can’t be managed yet.
services.panel.unsupported_system:
zh-CN: 暂不支持管理 %{system} 的服务,目前只支持使用 systemd 的 Linux 主机。
en: Services on %{system} can’t be managed yet. Only Linux hosts with systemd are supported.
services.panel.unsupported:
zh-CN: 暂不支持管理这台主机的服务,目前只支持使用 systemd 的 Linux 主机。
en: This host’s services can’t be managed yet. Only Linux hosts with systemd are supported.
services.group.custom:
zh-CN: 自定义服务
en: Custom services
services.group.system:
zh-CN: 系统服务
en: System services
services.filter.all:
zh-CN: 全部
en: All
services.filter.running:
zh-CN: 运行中
en: Active
services.filter.stopped:
zh-CN: 已停止
en: Inactive
services.filter.failed:
zh-CN: 失败
en: Failed
services.command.start:
zh-CN: 启动
en: Start
services.command.stop:
zh-CN: 停止
en: Stop
services.command.restart:
zh-CN: 重启
en: Restart
services.command.enable:
zh-CN: 启用开机启动
en: Enable at boot
services.command.disable:
zh-CN: 禁用开机启动
en: Disable at boot
# systemd's ActiveState.
services.active.active:
zh-CN: 运行中
en: Active
services.active.reloading:
zh-CN: 正在重载
en: Reloading
services.active.inactive:
zh-CN: 已停止
en: Inactive
services.active.failed:
zh-CN: 失败
en: Failed
services.active.activating:
zh-CN: 正在启动
en: Activating
services.active.deactivating:
zh-CN: 正在停止
en: Deactivating
# systemd's SubState.
services.sub.running:
zh-CN: 运行中
en: Running
services.sub.exited:
zh-CN: 已退出
en: Exited
services.sub.dead:
zh-CN: 未运行
en: Dead
services.sub.failed:
zh-CN: 失败
en: Failed
services.sub.waiting:
zh-CN: 等待中
en: Waiting
services.sub.listening:
zh-CN: 监听中
en: Listening
services.sub.reload:
zh-CN: 正在重载
en: Reloading
services.sub.auto_restart:
zh-CN: 等待自动重启
en: Waiting to restart
services.sub.condition:
zh-CN: 条件不满足
en: Condition not met
services.sub.starting:
zh-CN: 正在启动
en: Starting
services.sub.stopping:
zh-CN: 正在停止
en: Stopping
# systemd's UnitFileState: whether it starts at boot.
services.file.enabled:
zh-CN: 已启用
en: Enabled
services.file.enabled_runtime:
zh-CN: 已启用(本次运行)
en: Enabled until reboot
services.file.disabled:
zh-CN: 已禁用
en: Disabled
services.file.static:
zh-CN: 静态
en: Static
services.file.masked:
zh-CN: 已屏蔽
en: Masked
services.file.generated:
zh-CN: 自动生成
en: Generated
services.file.indirect:
zh-CN: 间接启用
en: Indirect
services.file.linked:
zh-CN: 已链接
en: Linked
services.file.transient:
zh-CN: 临时
en: Transient
services.file.alias:
zh-CN: 别名
en: Alias
services.file.bad:
zh-CN: 出错
en: Bad
# systemd's LoadState.
services.load.loaded:
zh-CN: 已加载
en: Loaded
services.load.not_found:
zh-CN: 未找到
en: Not found
services.load.masked:
zh-CN: 已屏蔽
en: Masked
services.load.error:
zh-CN: 出错
en: Error
services.load.bad_setting:
zh-CN: 配置有误
en: Bad setting
# `systemctl is-system-running`.
services.system.running:
zh-CN: 运行正常
en: Running
services.system.degraded:
zh-CN: 降级运行
en: Degraded
services.system.starting:
zh-CN: 正在启动
en: Starting
services.system.initializing:
zh-CN: 正在初始化
en: Initializing
services.system.maintenance:
zh-CN: 维护模式
en: Maintenance
services.system.stopping:
zh-CN: 正在关机
en: Shutting down
services.system.offline:
zh-CN: 离线
en: Offline
services.error.needs_root:
zh-CN: 需要 root 权限:以 root 登录,或给这个用户配置免密码的 sudo
en: "Needs root: log in as root, or give this user passwordless sudo"
services.error.needs_root_no_sudo:
zh-CN: 需要 root 权限:这台主机没有 sudo,请以 root 登录
en: "Needs root: this host has no sudo, so log in as root"
services.error.status:
zh-CN: systemctl 返回 %{status}
en: systemctl exited with status %{status}
# A service's details.
services.details.tab_status:
zh-CN: 状态
en: Status
services.details.tab_journal:
zh-CN: 日志
en: Journal
services.details.unreadable:
zh-CN: 读不到这个服务的状态
en: Couldn’t read this service’s state
services.details.no_journal:
zh-CN: 没有日志
en: No journal entries
services.field.load:
zh-CN: 加载状态
en: Load state
services.field.active:
zh-CN: 运行状态
en: Active state
services.field.boot:
zh-CN: 开机启动
en: At boot
services.field.main_pid:
zh-CN: 主进程 PID
en: Main PID
services.field.memory:
zh-CN: 内存
en: Memory
services.field.tasks:
zh-CN: 任务数
en: Tasks
services.field.restarts:
zh-CN: 重启次数
en: Restarts
services.field.exit_status:
zh-CN: 退出状态
en: Exit status
services.field.started:
zh-CN: 启动时间
en: Started
services.field.stopped:
zh-CN: 停止时间
en: Stopped
services.field.unit_file:
zh-CN: Unit 文件
en: Unit file
+114
View File
@@ -0,0 +1,114 @@
_version: 2
# 命令片段: the commands kept for every host, in categories.
snippets.uncategorized:
zh-CN: 未分类
en: Uncategorized
snippets.panel.search:
zh-CN: 搜索名称或命令
en: Search names and commands
snippets.panel.count.one:
en: "%{count} snippet"
snippets.panel.count.other:
zh-CN: 共 %{count} 个片段
en: "%{count} snippets"
snippets.panel.categories.one:
en: "%{count} category"
snippets.panel.categories.other:
zh-CN: "%{count} 个分类"
en: "%{count} categories"
snippets.panel.new_snippet:
zh-CN: 新建片段
en: New snippet
snippets.panel.new_category:
zh-CN: 新建分类
en: New category
snippets.panel.empty:
zh-CN: 还没有命令片段
en: No snippets yet
snippets.panel.empty_description:
zh-CN: 把常用的命令存成片段,点一下就输入到当前终端。
en: Save the commands you use often as snippets, then type one into the terminal with a click.
snippets.panel.no_match:
zh-CN: 没有符合条件的片段
en: No matching snippets
snippets.card.runs_on_click:
zh-CN: 点击时自动执行
en: Runs on click
snippets.menu.new_snippet:
zh-CN: 新建片段…
en: New snippet…
snippets.menu.new_category:
zh-CN: 新建分类…
en: New category…
snippets.menu.rename_category:
zh-CN: 重命名分类…
en: Rename category…
snippets.menu.delete_category:
zh-CN: 删除分类…
en: Delete category…
snippets.menu.copy:
zh-CN: 复制命令
en: Copy command
snippets.menu.edit:
zh-CN: 编辑…
en: Edit…
snippets.menu.delete:
zh-CN: 删除…
en: Delete…
snippets.dialog.new_title:
zh-CN: 新建命令片段
en: New snippet
snippets.dialog.edit_title:
zh-CN: 编辑命令片段
en: Edit snippet
snippets.dialog.create:
zh-CN: 创建
en: Create
snippets.dialog.name:
zh-CN: 名称
en: Name
snippets.dialog.name_placeholder:
zh-CN: 例如 查看容器
en: e.g. List containers
snippets.dialog.category:
zh-CN: 分类
en: Category
snippets.dialog.command:
zh-CN: 命令
en: Command
snippets.dialog.command_placeholder:
zh-CN: 例如 docker ps -a
en: e.g. docker ps -a
snippets.dialog.command_description:
zh-CN: 可以有多行。
en: It can span several lines.
snippets.dialog.run_on_click:
zh-CN: 点击时自动执行
en: Run on click
snippets.dialog.run_on_click_description:
zh-CN: 启用后将自动执行命令,否则只将内容填入终端
en: A click runs the command; otherwise it’s only typed into the terminal
snippets.category.new_title:
zh-CN: 新建分类
en: New category
snippets.category.rename_title:
zh-CN: 重命名分类
en: Rename category
snippets.category.name:
zh-CN: 名称
en: Name
snippets.category.name_placeholder:
zh-CN: 例如 Docker
en: e.g. Docker
snippets.category.name_missing:
zh-CN: 请输入分类名称
en: Enter a category name
snippets.category.name_taken:
zh-CN: 已有同名分类
en: A category with this name already exists
+251
View File
@@ -0,0 +1,251 @@
_version: 2
# The right sidebar: its seven tools, the words they share, and the
# workspace's confirmations and notifications for what they run.
tools.kind.snippets:
zh-CN: 命令片段
en: Snippets
tools.kind.history:
zh-CN: 历史命令
en: History
tools.kind.services:
zh-CN: 系统服务
en: Services
tools.kind.processes:
zh-CN: 进程管理
en: Processes
tools.kind.connections:
zh-CN: 网络连接
en: Network
tools.kind.monitor:
zh-CN: 系统监控
en: Monitor
# Shared by the tools.
tools.refresh:
zh-CN: 刷新
en: Refresh
tools.reading:
zh-CN: 正在读取…
en: Loading…
tools.read_failed:
zh-CN: 读取失败:%{error}
en: "Couldn’t load: %{error}"
tools.not_connected:
zh-CN: 终端没有连接
en: The terminal isn’t connected
tools.no_answer:
zh-CN: 主机没有回答
en: The host didn’t answer
tools.details_and_logs:
zh-CN: 详情和日志
en: Details and logs
tools.run:
zh-CN: 执行
en: Run
tools.menu.insert:
zh-CN: 输入到终端
en: Insert into terminal
tools.menu.details:
zh-CN: 查看详情
en: View details
# A command of 命令片段 or 历史命令 onto the terminal's input line.
tools.command.run_failed:
zh-CN: 无法执行命令:%{error}
en: "Couldn’t run the command: %{error}"
tools.command.enter_failed:
zh-CN: 无法输入命令:%{error}
en: "Couldn’t insert the command: %{error}"
tools.command.copied:
zh-CN: 已复制命令
en: Command copied
# Docker: a container, or a compose project's containers.
tools.container.start.done:
zh-CN: 容器“%{name}”已启动
en: Container “%{name}” started
tools.container.start.failed:
zh-CN: 无法启动容器“%{name}”:%{error}
en: "Couldn’t start container “%{name}”: %{error}"
tools.container.stop.title:
zh-CN: 停止容器“%{name}”?
en: Stop container “%{name}”?
tools.container.stop.description:
zh-CN: 停止后它提供的服务就不可用了,直到再次启动。
en: What it serves is unavailable until it’s started again.
tools.container.stop.done:
zh-CN: 容器“%{name}”已停止
en: Container “%{name}” stopped
tools.container.stop.failed:
zh-CN: 无法停止容器“%{name}”:%{error}
en: "Couldn’t stop container “%{name}”: %{error}"
tools.container.restart.title:
zh-CN: 重启容器“%{name}”?
en: Restart container “%{name}”?
tools.container.restart.description:
zh-CN: 会先停止再启动,中间短暂不可用。
en: It stops and starts again, so it’s briefly unavailable.
tools.container.restart.done:
zh-CN: 容器“%{name}”已重启
en: Container “%{name}” restarted
tools.container.restart.failed:
zh-CN: 无法重启容器“%{name}”:%{error}
en: "Couldn’t restart container “%{name}”: %{error}"
tools.project.start.done:
zh-CN: 项目“%{name}”已启动
en: Project “%{name}” started
tools.project.start.failed:
zh-CN: 无法启动项目“%{name}”:%{error}
en: "Couldn’t start project “%{name}”: %{error}"
tools.project.stop.title:
zh-CN: 停止项目“%{name}”?
en: Stop project “%{name}”?
tools.project.stop.description:
zh-CN: 停止后它提供的服务就不可用了,直到再次启动。
en: What its containers serve is unavailable until they’re started again.
tools.project.stop.done:
zh-CN: 项目“%{name}”已停止
en: Project “%{name}” stopped
tools.project.stop.failed:
zh-CN: 无法停止项目“%{name}”:%{error}
en: "Couldn’t stop project “%{name}”: %{error}"
tools.project.restart.title:
zh-CN: 重启项目“%{name}”?
en: Restart project “%{name}”?
tools.project.restart.description:
zh-CN: 会先停止再启动,中间短暂不可用。
en: Its containers stop and start again, so they’re briefly unavailable.
tools.project.restart.done:
zh-CN: 项目“%{name}”已重启
en: Project “%{name}” restarted
tools.project.restart.failed:
zh-CN: 无法重启项目“%{name}”:%{error}
en: "Couldn’t restart project “%{name}”: %{error}"
tools.remove.container.title:
zh-CN: 删除容器“%{name}”?
en: Delete container “%{name}”?
tools.remove.container.description:
zh-CN: 容器和它里面没有存进卷的数据会一起删除,不能恢复。
en: The container is deleted with any data not kept in a volume. This can’t be undone.
tools.remove.container.done:
zh-CN: 容器“%{name}”已删除
en: Container “%{name}” deleted
tools.remove.container.failed:
zh-CN: 无法删除容器“%{name}”:%{error}
en: "Couldn’t delete container “%{name}”: %{error}"
tools.remove.image.title:
zh-CN: 删除镜像“%{name}”?
en: Delete image “%{name}”?
tools.remove.image.description:
zh-CN: 删除后再要用它,需要重新拉取或构建。
en: To use it again, you’ll need to pull or build it again.
tools.remove.image.done:
zh-CN: 镜像“%{name}”已删除
en: Image “%{name}” deleted
tools.remove.image.failed:
zh-CN: 无法删除镜像“%{name}”:%{error}
en: "Couldn’t delete image “%{name}”: %{error}"
tools.remove.volume.title:
zh-CN: 删除卷“%{name}”?
en: Delete volume “%{name}”?
tools.remove.volume.description:
zh-CN: 卷里的数据会一起删除,不能恢复。
en: The data in it is deleted too. This can’t be undone.
tools.remove.volume.done:
zh-CN: 卷“%{name}”已删除
en: Volume “%{name}” deleted
tools.remove.volume.failed:
zh-CN: 无法删除卷“%{name}”:%{error}
en: "Couldn’t delete volume “%{name}”: %{error}"
tools.remove.network.title:
zh-CN: 删除网络“%{name}”?
en: Delete network “%{name}”?
tools.remove.network.description:
zh-CN: 删除后再要用它,需要重新创建。
en: To use it again, you’ll need to create it again.
tools.remove.network.done:
zh-CN: 网络“%{name}”已删除
en: Network “%{name}” deleted
tools.remove.network.failed:
zh-CN: 无法删除网络“%{name}”:%{error}
en: "Couldn’t delete network “%{name}”: %{error}"
# 系统服务: a systemd service.
tools.service.start.done:
zh-CN: "%{name} 已启动"
en: "%{name} started"
tools.service.start.failed:
zh-CN: 无法启动 %{name}:%{error}
en: "Couldn’t start %{name}: %{error}"
tools.service.stop.title:
zh-CN: 停止服务“%{name}”?
en: Stop service “%{name}”?
tools.service.stop.description:
zh-CN: 停止后它提供的功能就不可用了,直到再次启动。
en: What it provides is unavailable until it’s started again.
tools.service.stop.description_ssh:
zh-CN: 这是 SSH 服务:停止后新的连接都连不上这台主机,直到它再次启动。
en: This is the SSH service. Until it’s started again, no new connection can reach this host.
tools.service.stop.done:
zh-CN: "%{name} 已停止"
en: "%{name} stopped"
tools.service.stop.failed:
zh-CN: 无法停止 %{name}:%{error}
en: "Couldn’t stop %{name}: %{error}"
tools.service.restart.title:
zh-CN: 重启服务“%{name}”?
en: Restart service “%{name}”?
tools.service.restart.description:
zh-CN: 服务会先停止再启动,中间短暂不可用。
en: The service stops and starts again, so it’s briefly unavailable.
tools.service.restart.done:
zh-CN: "%{name} 已重启"
en: "%{name} restarted"
tools.service.restart.failed:
zh-CN: 无法重启 %{name}:%{error}
en: "Couldn’t restart %{name}: %{error}"
tools.service.enable.done:
zh-CN: "%{name} 已设为开机启动"
en: "%{name} now starts at boot"
tools.service.enable.failed:
zh-CN: 无法启用开机启动 %{name}:%{error}
en: "Couldn’t enable %{name} at boot: %{error}"
tools.service.disable.done:
zh-CN: "%{name} 已取消开机启动"
en: "%{name} no longer starts at boot"
tools.service.disable.failed:
zh-CN: 无法禁用开机启动 %{name}:%{error}
en: "Couldn’t disable %{name} at boot: %{error}"
# 进程管理: ending a process.
tools.process.end.title:
zh-CN: 结束进程“%{name}”?
en: End process “%{name}”?
tools.process.end.description:
zh-CN: PID %{pid},用户 %{user}。进程会收到结束信号(SIGTERM),可以先做完收尾再退出。
en: PID %{pid}, user %{user}. The process gets SIGTERM, so it can clean up before it exits.
tools.process.end.confirm:
zh-CN: 结束进程
en: End process
tools.process.kill.title:
zh-CN: 强制结束进程“%{name}”?
en: Kill process “%{name}”?
tools.process.kill.description:
zh-CN: PID %{pid},用户 %{user}。进程会立即被终止(SIGKILL),来不及保存数据;只在「结束进程」不起作用时使用。
en: PID %{pid}, user %{user}. The process is stopped at once (SIGKILL), with no chance to save its data. Use this only when “End process” doesn’t work.
tools.process.kill.confirm:
zh-CN: 强制结束
en: Kill
tools.process.ended:
zh-CN: 已向 %{name}(PID %{pid})发送结束信号
en: Sent SIGTERM to %{name} (PID %{pid})
tools.process.killed:
zh-CN: 已强制结束 %{name}(PID %{pid})
en: Killed %{name} (PID %{pid})
tools.process.end_failed:
zh-CN: 无法结束 %{name}(PID %{pid}):%{error}
en: "Couldn’t end %{name} (PID %{pid}): %{error}"
+2 -2
View File
@@ -5,7 +5,7 @@ use gpui_kit::*;
use crate::{
cli::AgentKind,
docker::{ContainerCommand, DockerObject},
docker::{ContainerCommand, ContainerSubject, DockerObject},
editor::EditorId,
explorer::{ExplorerId, FileSizeFormat, PaneSide},
host::{
@@ -327,7 +327,7 @@ pub struct ControlService {
pub struct ControlContainers {
/// What they are, for the question and the notification: 「容器“web”」,
/// 「项目“php-56”」.
pub subject: String,
pub subject: ContainerSubject,
pub ids: Vec<String>,
pub command: ContainerCommand,
}
+14 -7
View File
@@ -15,8 +15,9 @@ use gpui_kit::*;
use super::details::{Read, message, reading, render_sections, run};
use super::docker_panel::state_color;
use super::linux::{inspect_command, logs_command, parse_details};
use super::model::{Container, ContainerCommand, ContainerDetails, DockerObject};
use super::model::{Container, ContainerCommand, ContainerDetails, ContainerSubject, DockerObject};
use crate::app::{CatalogIcon, ControlContainers, RemoveDockerObject};
use crate::i18n::t;
use crate::shared::soft_tag;
use crate::terminal::TerminalView;
@@ -52,7 +53,7 @@ pub fn open_container_dialog(
.text_color(cx.theme().muted_foreground)
.child(container.image.clone()),
);
let subject = format!("容器“{}”", container.name);
let subject = ContainerSubject::Container(container.name.clone());
let control = |command: ContainerCommand| {
let (dispatch, subject, id) = (dispatch.clone(), subject.clone(), container.id.clone());
let button = Button::new(SharedString::from(format!(
@@ -94,7 +95,7 @@ pub fn open_container_dialog(
Button::new("container-dialog-remove")
.outline()
.icon(Icon::new(CatalogIcon::Trash))
.label("删除")
.label(t!("common.delete"))
.on_click(move |_, window, cx| {
window.close_dialog(cx);
dispatch.dispatch_action(&action, window, cx);
@@ -152,7 +153,7 @@ impl ContainerDetailsView {
this.details = match answer {
Ok(output) => match parse_details(&output) {
Some(details) => Read::Known(details),
None => Read::Unknown("读不到这个容器:它可能已经被删除".into()),
None => Read::Unknown(t!("docker.details.container_gone").into()),
},
Err(why) => Read::Unknown(why),
};
@@ -180,7 +181,9 @@ impl ContainerDetailsView {
cx,
|this, answer| {
this.output = match answer {
Ok(output) if output.trim().is_empty() => Read::Unknown("没有输出".into()),
Ok(output) if output.trim().is_empty() => {
Read::Unknown(t!("docker.details.no_output").into())
}
Ok(output) => Read::Known(output.trim_end().to_owned()),
Err(why) => Read::Unknown(why),
};
@@ -238,11 +241,15 @@ impl Render for ContainerDetailsView {
.small()
.selected_index(self.tab)
.on_click(cx.listener(|this, index: &usize, _, cx| this.select(*index, cx)))
.child(Tab::new().icon(Icon::new(IconName::Info)).label("详情"))
.child(
Tab::new()
.icon(Icon::new(IconName::Info))
.label(t!("docker.details.tab_details")),
)
.child(
Tab::new()
.icon(Icon::new(CatalogIcon::FileText))
.label("日志"),
.label(t!("docker.details.tab_logs")),
),
),
)
+9 -8
View File
@@ -6,6 +6,7 @@ use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use super::model::{DetailSection, SectionBody};
use crate::i18n::t;
use crate::terminal::{ExecResult, TerminalView, exec_answer};
/// Something read off the host for a dialog.
@@ -29,10 +30,10 @@ pub(super) fn run<V: 'static>(
let reply = command.and_then(|command| terminal.upgrade()?.read(cx).exec(command, cx));
cx.spawn(async move |this, cx| {
let answer: ExecResult = match reply {
None => Err("终端没有连接".into()),
None => Err(t!("tools.not_connected").into()),
Some(reply) => match exec_answer(reply, cx).await {
None => Err("终端没有连接".into()),
Some(Err(error)) => Err(format!("读取失败:{error}")),
None => Err(t!("tools.not_connected").into()),
Some(Err(error)) => Err(t!("tools.read_failed", error = error).into()),
Some(Ok(output)) => Ok(output),
},
};
@@ -46,7 +47,7 @@ pub(super) fn run<V: 'static>(
/// The sections one under another, each under its heading; 「—」 for one
/// with nothing in it. Their lines are found as 「{prefix}-{section}:{label}」
/// (「container-basics:名称」), a text section as 「{prefix}-{section}」.
/// (「container-basics:name」), a text section as 「{prefix}-{section}」.
pub(super) fn render_sections(
prefix: &'static str,
sections: &[DetailSection],
@@ -59,7 +60,7 @@ pub(super) fn render_sections(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(section.title),
.child(t!(section.title)),
)
.child(render_body(prefix, section, cx))
}))
@@ -98,7 +99,7 @@ fn render_body(prefix: &'static str, section: &DetailSection, cx: &App) -> AnyEl
rows.iter()
.map(|(label, value)| {
h_flex()
.id(line_id(label))
.id(line_id(label.id()))
.test_support()
.aria_label(value.clone())
.items_start()
@@ -111,7 +112,7 @@ fn render_body(prefix: &'static str, section: &DetailSection, cx: &App) -> AnyEl
.w(rems(7.))
.flex_shrink_0()
.text_color(cx.theme().muted_foreground)
.child(label.clone()),
.child(label.text()),
)
// Long IDs and names wrap rather than go off the edge.
.child(div().flex_1().min_w_0().child(value.clone()))
@@ -164,7 +165,7 @@ pub(super) fn reading(id: &'static str, cx: &App) -> AnyElement {
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(Spinner::new().small())
.child("正在读取…")
.child(t!("tools.reading"))
.into_any_element()
}
+39 -30
View File
@@ -16,14 +16,15 @@ use gpui_kit::*;
use super::linux::{self, Parsed};
use super::model::{
Container, ContainerCommand, ContainerState, DockerObject, DockerRow, DockerTab, DockerTable,
ObjectSummary, Project,
Container, ContainerCommand, ContainerState, ContainerSubject, DockerObject, DockerRow,
DockerTab, DockerTable, ObjectSummary, Project,
};
use super::object_details::tone_color;
use crate::app::{
CatalogIcon, ControlContainers, RefreshDocker, RemoveDockerObject, ShowDockerObject,
ToggleDockerProject,
};
use crate::i18n::t;
use crate::shared::{count_tabs, soft_tag, tinted};
use crate::terminal::{ExecResult, ExecTarget, RemoteTerminalId, exec_answer};
@@ -275,8 +276,8 @@ impl DockerPanel {
.xsmall()
.icon(Icon::new(CatalogIcon::RefreshCw))
.loading(self.loading)
.tooltip("刷新")
.accessibility_label("刷新")
.tooltip(t!("tools.refresh"))
.accessibility_label(t!("tools.refresh"))
.on_click(move |_, window, cx| {
refresh.dispatch_action(&RefreshDocker, window, cx)
}),
@@ -299,10 +300,10 @@ impl DockerPanel {
));
let empty = match self.tab {
DockerTab::Containers => "没有容器",
DockerTab::Volumes => "没有卷",
DockerTab::Images => "没有镜像",
DockerTab::Networks => "没有网络",
DockerTab::Containers => t!("docker.panel.no_containers"),
DockerTab::Volumes => t!("docker.panel.no_volumes"),
DockerTab::Images => t!("docker.panel.no_images"),
DockerTab::Networks => t!("docker.panel.no_networks"),
};
// The scrollbar goes on the list's box, which does not scroll: on
// the scrolled lines it would scroll away with them.
@@ -320,7 +321,7 @@ impl DockerPanel {
div()
.id("docker-empty")
.test_support()
.aria_label(empty)
.aria_label(empty.clone())
.py_8()
.text_sm()
.text_center()
@@ -347,7 +348,7 @@ impl DockerPanel {
.pt_2()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("独立容器")
.child(t!("docker.panel.standalone"))
.into_any_element(),
DockerRow::Container(index) => render_container(
&table.containers()[*index],
@@ -430,7 +431,7 @@ impl Render for DockerPanel {
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(Spinner::new().small())
.child("正在读取…")
.child(t!("tools.reading"))
.into_any_element(),
};
v_flex().id("docker").test_support().size_full().child(body)
@@ -440,19 +441,16 @@ impl Render for DockerPanel {
fn render_problem(problem: &Problem, cx: &App) -> impl IntoElement {
let (text, color) = match problem {
Problem::NotConnected => (
"终端没有连接。连接后这里显示主机上的容器。".to_string(),
t!("docker.panel.not_connected"),
cx.theme().muted_foreground,
),
Problem::Missing => (
"这台主机没有安装 Docker。".to_string(),
cx.theme().muted_foreground,
Problem::Missing => (t!("docker.panel.missing"), cx.theme().muted_foreground),
Problem::Unsupported => (t!("docker.panel.unsupported"), cx.theme().muted_foreground),
Problem::Unreachable(why) => (
t!("docker.panel.unreachable", error = why),
cx.theme().danger,
),
Problem::Unsupported => (
"暂不支持这台主机上的 Docker。".to_string(),
cx.theme().muted_foreground,
),
Problem::Unreachable(why) => (format!("无法读取 Docker:{why}"), cx.theme().danger),
Problem::Failed(error) => (format!("读取失败:{error}"), cx.theme().danger),
Problem::Failed(error) => (t!("tools.read_failed", error = error), cx.theme().danger),
};
div()
.id("docker-message")
@@ -484,7 +482,7 @@ fn dot(color: Hsla) -> Div {
/// stop while something in it is up.
fn command_buttons(
id: &str,
subject: String,
subject: ContainerSubject,
ids: Vec<String>,
up: bool,
dispatch: &FocusHandle,
@@ -563,8 +561,13 @@ fn render_project(
)))
.test_support()
.aria_label(format!(
"{} · {}/{total} 运行中",
project.name, project.running
"{} · {}",
project.name,
t!(
"docker.project.running",
running = project.running,
total = total
)
))
.rounded(cx.theme().radius)
.bg(cx.theme().tokens.group_box)
@@ -628,7 +631,7 @@ fn render_project(
)
.child(command_buttons(
&format!("project:{}", project.name),
format!("项目“{}”", project.name),
ContainerSubject::Project(project.name.clone()),
ids,
project.running > 0,
dispatch,
@@ -720,7 +723,7 @@ fn render_container(
)
.child(command_buttons(
&container.name,
format!("容器“{}”", container.name),
ContainerSubject::Container(container.name.clone()),
vec![container.id.clone()],
container.state.is_up(),
dispatch,
@@ -734,8 +737,8 @@ fn render_container(
.ghost()
.small()
.icon(IconName::Ellipsis)
.tooltip("详情和日志")
.accessibility_label("详情和日志")
.tooltip(t!("tools.details_and_logs"))
.accessibility_label(t!("tools.details_and_logs"))
.on_click(move |_, window, cx| {
cx.stop_propagation();
more.dispatch_action(
@@ -831,8 +834,14 @@ fn render_object(
.ghost()
.small()
.icon(Icon::new(CatalogIcon::Trash))
.tooltip(summary.removable.err().unwrap_or("删除"))
.accessibility_label("删除")
.tooltip(
summary
.removable
.clone()
.err()
.unwrap_or_else(|| t!("common.delete")),
)
.accessibility_label(t!("common.delete"))
.disabled(summary.removable.is_err())
.on_click(move |_, window, cx| {
// Not the card's click too, which opens the details.
+138 -61
View File
@@ -7,8 +7,9 @@ use serde_json::Value;
use super::model::{
Container, ContainerCommand, ContainerDetails, ContainerState, DetailSection, DockerObject,
DockerTable, Image, Network, SectionBody, Volume, rows,
DockerTable, Image, Network, RowLabel, SectionBody, Volume, rows,
};
use crate::i18n::t;
use crate::shared::format_bytes;
/// Where `docker` may be besides the login's `PATH` (Docker Desktop on a
@@ -107,15 +108,15 @@ pub fn logs_command(id: &str) -> Option<String> {
))
}
/// Docker's complaint, in Chinese where it is one a user can do something
/// about.
/// Docker's complaint, in ShellRS's words where it is one a user can do
/// something about.
fn complaint(said: &str) -> String {
if said.contains("password is required")
|| (said.contains("permission denied") && said.contains("docker"))
{
"没有权限:以 root 登录、把这个用户加入 docker 组,或给它配置免密码的 sudo".into()
t!("docker.error.permission").into()
} else if said.contains("Cannot connect to the Docker daemon") {
"Docker 没有运行".into()
t!("docker.error.not_running").into()
} else {
let line = said.lines().next().unwrap_or(said).trim();
line.trim_start_matches("Error response from daemon: ")
@@ -128,10 +129,10 @@ fn complaint(said: &str) -> String {
pub fn done(output: &str) -> Result<(), String> {
let (said, status) = output
.rsplit_once("@@status")
.ok_or_else(|| "主机没有回答".to_string())?;
.ok_or_else(|| t!("tools.no_answer").to_string())?;
match status.trim() {
"0" => Ok(()),
status if said.trim().is_empty() => Err(format!("docker 返回 {status}")),
status if said.trim().is_empty() => Err(t!("docker.error.status", status = status).into()),
_ => Err(complaint(said.trim())),
}
}
@@ -374,13 +375,14 @@ pub fn parse_details(output: &str) -> Option<ContainerDetails> {
_ => text(mount, "Source"),
};
let read_only = mount.get("RW").and_then(Value::as_bool) == Some(false);
let destination = text(mount, "Destination");
(
source.to_owned(),
format!(
"{}{}",
text(mount, "Destination"),
if read_only { "(只读)" } else { "" }
),
if read_only {
t!("docker.mount.read_only", path = destination).into()
} else {
destination.to_owned()
},
)
})
.collect();
@@ -462,8 +464,8 @@ fn pairs_at(value: &Value, keys: &[&str]) -> Vec<(String, String)> {
fn yes_no(value: &Value, keys: &[&str]) -> String {
match at(value, keys).and_then(Value::as_bool) {
Some(true) => "是".into(),
Some(false) => "否".into(),
Some(true) => t!("docker.value.yes").into(),
Some(false) => t!("docker.value.no").into(),
None => String::new(),
}
}
@@ -473,7 +475,7 @@ fn yes_no(value: &Value, keys: &[&str]) -> String {
fn used_by(used_by: &[String]) -> DetailSection {
DetailSection {
id: "users",
title: "使用它的容器",
title: "docker.section.users",
body: SectionBody::List(used_by.to_vec()),
}
}
@@ -485,24 +487,39 @@ pub fn volume_details(output: &str, users: &[String]) -> Option<Vec<DetailSectio
Some(vec![
DetailSection {
id: "basics",
title: "基础信息",
title: "docker.section.basics",
body: rows([
("名称", string_at(&volume, &["Name"])),
("驱动", string_at(&volume, &["Driver"])),
("挂载点", string_at(&volume, &["Mountpoint"])),
("范围", string_at(&volume, &["Scope"])),
("创建时间", string_at(&volume, &["CreatedAt"])),
(
RowLabel::Field("docker.field.name"),
string_at(&volume, &["Name"]),
),
(
RowLabel::Field("docker.field.driver"),
string_at(&volume, &["Driver"]),
),
(
RowLabel::Field("docker.field.mountpoint"),
string_at(&volume, &["Mountpoint"]),
),
(
RowLabel::Field("docker.field.scope"),
string_at(&volume, &["Scope"]),
),
(
RowLabel::Field("docker.field.created"),
string_at(&volume, &["CreatedAt"]),
),
]),
},
used_by(users),
DetailSection {
id: "options",
title: "选项",
title: "docker.section.options",
body: rows(pairs_at(&volume, &["Options"])),
},
DetailSection {
id: "labels",
title: "标签",
title: "docker.section.labels",
body: rows(pairs_at(&volume, &["Labels"])),
},
])
@@ -531,51 +548,75 @@ pub fn image_details(output: &str, users: &[String]) -> Option<Vec<DetailSection
Some(vec![
DetailSection {
id: "basics",
title: "基础信息",
title: "docker.section.basics",
body: rows([
(
"ID",
RowLabel::Raw("ID".into()),
string_at(&image, &["Id"])
.trim_start_matches("sha256:")
.to_owned(),
),
("标签", strings_at(&image, &["RepoTags"]).join("\n")),
("摘要", strings_at(&image, &["RepoDigests"]).join("\n")),
("创建时间", string_at(&image, &["Created"])),
(
"大小",
RowLabel::Field("docker.field.tags"),
strings_at(&image, &["RepoTags"]).join("\n"),
),
(
RowLabel::Field("docker.field.digests"),
strings_at(&image, &["RepoDigests"]).join("\n"),
),
(
RowLabel::Field("docker.field.created"),
string_at(&image, &["Created"]),
),
(
RowLabel::Field("docker.field.size"),
at(&image, &["Size"])
.and_then(Value::as_u64)
.map_or(String::new(), format_bytes),
),
("平台", platform),
("层数", layers),
("作者", string_at(&image, &["Author"])),
(RowLabel::Field("docker.field.platform"), platform),
(RowLabel::Field("docker.field.layers"), layers),
(
RowLabel::Field("docker.field.author"),
string_at(&image, &["Author"]),
),
]),
},
used_by(users),
DetailSection {
id: "config",
title: "运行配置",
title: "docker.section.config",
body: rows([
(
"入口",
RowLabel::Field("docker.field.entrypoint"),
strings_at(&image, &["Config", "Entrypoint"]).join(" "),
),
("命令", strings_at(&image, &["Config", "Cmd"]).join(" ")),
("工作目录", string_at(&image, &["Config", "WorkingDir"])),
("用户", string_at(&image, &["Config", "User"])),
("暴露端口", ports.join(", ")),
(
RowLabel::Field("docker.field.command"),
strings_at(&image, &["Config", "Cmd"]).join(" "),
),
(
RowLabel::Field("docker.field.working_dir"),
string_at(&image, &["Config", "WorkingDir"]),
),
(
RowLabel::Field("docker.field.user"),
string_at(&image, &["Config", "User"]),
),
(
RowLabel::Field("docker.field.exposed_ports"),
ports.join(", "),
),
]),
},
DetailSection {
id: "environment",
title: "环境变量",
title: "docker.section.environment",
body: SectionBody::Text(strings_at(&image, &["Config", "Env"])),
},
DetailSection {
id: "labels",
title: "标签",
title: "docker.section.labels",
body: rows(pairs_at(&image, &["Config", "Labels"])),
},
])
@@ -594,7 +635,7 @@ pub fn network_details(output: &str) -> Option<Vec<DetailSection>> {
text(config, "Subnet").to_owned(),
match text(config, "Gateway") {
"" => String::new(),
gateway => format!("网关 {gateway}"),
gateway => t!("docker.network.gateway", address = gateway).into(),
},
)
})
@@ -621,35 +662,53 @@ pub fn network_details(output: &str) -> Option<Vec<DetailSection>> {
Some(vec![
DetailSection {
id: "basics",
title: "基础信息",
title: "docker.section.basics",
body: rows([
("名称", string_at(&network, &["Name"])),
("ID", string_at(&network, &["Id"])),
("驱动", string_at(&network, &["Driver"])),
("范围", string_at(&network, &["Scope"])),
("创建时间", string_at(&network, &["Created"])),
("IPv6", yes_no(&network, &["EnableIPv6"])),
("内部网络", yes_no(&network, &["Internal"])),
(
RowLabel::Field("docker.field.name"),
string_at(&network, &["Name"]),
),
(RowLabel::Raw("ID".into()), string_at(&network, &["Id"])),
(
RowLabel::Field("docker.field.driver"),
string_at(&network, &["Driver"]),
),
(
RowLabel::Field("docker.field.scope"),
string_at(&network, &["Scope"]),
),
(
RowLabel::Field("docker.field.created"),
string_at(&network, &["Created"]),
),
(
RowLabel::Raw("IPv6".into()),
yes_no(&network, &["EnableIPv6"]),
),
(
RowLabel::Field("docker.field.internal"),
yes_no(&network, &["Internal"]),
),
]),
},
DetailSection {
id: "subnets",
title: "子网",
title: "docker.section.subnets",
body: rows(subnets),
},
DetailSection {
id: "containers",
title: "容器",
title: "docker.section.containers",
body: rows(containers),
},
DetailSection {
id: "options",
title: "选项",
title: "docker.section.options",
body: rows(pairs_at(&network, &["Options"])),
},
DetailSection {
id: "labels",
title: "标签",
title: "docker.section.labels",
body: rows(pairs_at(&network, &["Labels"])),
},
])
@@ -846,7 +905,7 @@ v2.25.0
panic!("{id} has no rows");
};
rows.into_iter()
.find(|(key, _)| key == label)
.find(|(key, _)| key.id() == label)
.unwrap_or_else(|| panic!("no {label} in {id}"))
.1
}
@@ -860,7 +919,7 @@ v2.25.0
)
.expect("volume");
assert_eq!(
row(&volume, "basics", "挂载点"),
row(&volume, "basics", "mountpoint"),
"/var/lib/docker/volumes/vw-data/_data"
);
assert_eq!(
@@ -882,13 +941,13 @@ v2.25.0
)
.expect("image");
assert_eq!(row(&image, "basics", "ID"), "4a3b5c6d7e8f9a");
assert_eq!(row(&image, "basics", "大小"), "9.84 KB");
assert_eq!(row(&image, "basics", "平台"), "linux/amd64");
assert_eq!(row(&image, "basics", "层数"), "2");
assert_eq!(row(&image, "basics", "size"), "9.84 KB");
assert_eq!(row(&image, "basics", "platform"), "linux/amd64");
assert_eq!(row(&image, "basics", "layers"), "2");
// Nothing to say is said with a dash.
assert_eq!(row(&image, "basics", "作者"), "—");
assert_eq!(row(&image, "config", "命令"), "/hello");
assert_eq!(row(&image, "config", "暴露端口"), "80/tcp");
assert_eq!(row(&image, "basics", "author"), "—");
assert_eq!(row(&image, "config", "command"), "/hello");
assert_eq!(row(&image, "config", "exposed_ports"), "80/tcp");
assert_eq!(
section(&image, "environment"),
SectionBody::Text(vec!["PATH=/usr/bin".into()])
@@ -907,6 +966,24 @@ v2.25.0
assert_eq!(volume_details("Error: no such volume", &[]), None);
}
#[test]
fn every_heading_and_label_has_its_text() {
let mut sections = ContainerDetails::default().sections();
sections.extend(volume_details("[{}]", &[]).expect("volume"));
sections.extend(image_details("[{}]", &[]).expect("image"));
sections.extend(network_details("[{}]").expect("network"));
for section in sections {
assert!(crate::i18n::has(section.title), "{}", section.title);
if let SectionBody::Rows(rows) = section.body {
for (label, _) in rows {
if let RowLabel::Field(key) = label {
assert!(crate::i18n::has(key), "{key}");
}
}
}
}
}
#[test]
fn only_an_id_goes_into_a_command() {
assert!(valid_id("f8664a4a9b8d"));
+1 -1
View File
@@ -14,5 +14,5 @@ mod object_details;
pub use container_details::open_container_dialog;
pub use docker_panel::DockerPanel;
pub use linux::{control_command, done, remove_command};
pub use model::{Container, ContainerCommand, DockerObject, ObjectSummary};
pub use model::{Container, ContainerCommand, ContainerSubject, DockerObject, ObjectSummary};
pub use object_details::open_object_dialog;
+110 -57
View File
@@ -3,6 +3,10 @@
use std::collections::BTreeMap;
use gpui_kit::SharedString;
use crate::i18n::{t, tn};
/// What a container is doing, as `docker ps` says.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ContainerState {
@@ -37,15 +41,15 @@ impl ContainerState {
}
}
pub fn label(self) -> &'static str {
pub fn label(self) -> SharedString {
match self {
ContainerState::Running => "运行中",
ContainerState::Paused => "已暂停",
ContainerState::Restarting => "正在重启",
ContainerState::Created => "已创建",
ContainerState::Exited => "已停止",
ContainerState::Dead => "异常",
ContainerState::Removing => "正在删除",
ContainerState::Running => t!("docker.state.running"),
ContainerState::Paused => t!("docker.state.paused"),
ContainerState::Restarting => t!("docker.state.restarting"),
ContainerState::Created => t!("docker.state.created"),
ContainerState::Exited => t!("docker.state.exited"),
ContainerState::Dead => t!("docker.state.dead"),
ContainerState::Removing => t!("docker.state.removing"),
}
}
@@ -159,22 +163,23 @@ impl ContainerCommand {
}
}
pub fn label(self) -> &'static str {
pub fn label(self) -> SharedString {
match self {
ContainerCommand::Start => "启动",
ContainerCommand::Stop => "停止",
ContainerCommand::Restart => "重启",
ContainerCommand::Start => t!("docker.command.start"),
ContainerCommand::Stop => t!("docker.command.stop"),
ContainerCommand::Restart => t!("docker.command.restart"),
}
}
}
/// What it did, after the name: 「已停止」.
pub fn done(self) -> &'static str {
match self {
ContainerCommand::Start => "已启动",
ContainerCommand::Stop => "已停止",
ContainerCommand::Restart => "已重启",
}
}
/// What a container command is run on, for the question and the
/// notification: 「容器“web”」, 「项目“php-56”」.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ContainerSubject {
/// One container, by name.
Container(String),
/// A compose project's containers, by the project's name.
Project(String),
}
/// What kind of thing a removal removes.
@@ -187,12 +192,12 @@ pub enum DockerObject {
}
impl DockerObject {
pub fn label(self) -> &'static str {
pub fn label(self) -> SharedString {
match self {
DockerObject::Container => "容器",
DockerObject::Image => "镜像",
DockerObject::Volume => "卷",
DockerObject::Network => "网络",
DockerObject::Container => t!("docker.object.container"),
DockerObject::Image => t!("docker.object.image"),
DockerObject::Volume => t!("docker.object.volume"),
DockerObject::Network => t!("docker.object.network"),
}
}
}
@@ -215,12 +220,12 @@ impl DockerTab {
DockerTab::Networks,
];
pub fn label(self) -> &'static str {
pub fn label(self) -> SharedString {
match self {
DockerTab::Containers => "容器",
DockerTab::Volumes => "卷",
DockerTab::Images => "镜像",
DockerTab::Networks => "网络",
DockerTab::Containers => t!("docker.tab.containers"),
DockerTab::Volumes => t!("docker.tab.volumes"),
DockerTab::Images => t!("docker.tab.images"),
DockerTab::Networks => t!("docker.tab.networks"),
}
}
}
@@ -468,7 +473,7 @@ pub struct ObjectSummary {
pub detail: String,
pub tag: Option<(String, Tone)>,
/// Whether it can be removed, and why not.
pub removable: Result<(), &'static str>,
pub removable: Result<(), SharedString>,
/// The containers using it, by name.
pub used_by: Vec<String>,
}
@@ -491,12 +496,12 @@ impl Volume {
name: self.name.clone(),
detail: joined(&[&self.driver, &self.mountpoint]),
tag: Some(if in_use {
("使用中".into(), Tone::Good)
(t!("docker.object.in_use").into(), Tone::Good)
} else {
("未使用".into(), Tone::Quiet)
(t!("docker.object.unused").into(), Tone::Quiet)
}),
removable: if in_use {
Err("有容器在用,不能删除")
Err(t!("docker.object.in_use_by_containers"))
} else {
Ok(())
},
@@ -514,11 +519,11 @@ impl Image {
name: self
.reference
.clone()
.unwrap_or_else(|| "未命名镜像".into()),
.unwrap_or_else(|| t!("docker.image.unnamed").into()),
detail: joined(&[&self.id, &self.size, &self.created]),
tag: in_use.then(|| ("使用中".into(), Tone::Good)),
tag: in_use.then(|| (t!("docker.object.in_use").into(), Tone::Good)),
removable: if in_use {
Err("有容器在用,不能删除")
Err(t!("docker.object.in_use_by_containers"))
} else {
Ok(())
},
@@ -531,16 +536,19 @@ impl Network {
pub fn summary(&self) -> ObjectSummary {
let (tag, removable) = if self.builtin() {
(
("内置".to_owned(), Tone::Quiet),
Err("Docker 自带的网络,不能删除"),
(t!("docker.network.builtin").into(), Tone::Quiet),
Err(t!("docker.network.builtin_kept")),
)
} else if !self.used_by.is_empty() {
(
(format!("{} 个容器", self.used_by.len()), Tone::Good),
Err("有容器连着,不能删除"),
(
tn!("docker.network.containers", self.used_by.len()).into(),
Tone::Good,
),
Err(t!("docker.network.in_use")),
)
} else {
(("未使用".to_owned(), Tone::Quiet), Ok(()))
((t!("docker.object.unused").into(), Tone::Quiet), Ok(()))
};
ObjectSummary {
object: DockerObject::Network,
@@ -559,14 +567,48 @@ impl Network {
pub struct DetailSection {
/// For the test ids of its lines: 「basics」.
pub id: &'static str,
/// The heading's text, by its key: translated where it is drawn.
pub title: &'static str,
pub body: SectionBody,
}
/// What a line of details is labelled with.
#[derive(Clone, Debug, PartialEq)]
pub enum RowLabel {
/// One of ShellRS's labels, by its text's key: 「docker.field.mountpoint」.
Field(&'static str),
/// One of the host's, as it is: a port, a path, a label's key.
Raw(String),
}
impl RowLabel {
/// What the line is found by: a field by the last part of its key
/// (「mountpoint」), the host's own as it is.
pub fn id(&self) -> &str {
match self {
RowLabel::Field(key) => key.rsplit('.').next().unwrap_or(key),
RowLabel::Raw(label) => label,
}
}
pub fn text(&self) -> SharedString {
match self {
RowLabel::Field(key) => t!(*key),
RowLabel::Raw(label) => label.clone().into(),
}
}
}
impl From<String> for RowLabel {
fn from(label: String) -> Self {
RowLabel::Raw(label)
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum SectionBody {
/// Labels and their values: 「挂载点」 「/var/lib/docker/volumes/…」.
Rows(Vec<(String, String)>),
Rows(Vec<(RowLabel, String)>),
/// Names, one a line: the containers using it.
List(Vec<String>),
/// Lines of text as they are, in a fixed-width face: the environment.
@@ -583,7 +625,9 @@ impl SectionBody {
}
/// Labels and values, with 「—」 for the empty values.
pub fn rows(rows: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>) -> SectionBody {
pub fn rows(
rows: impl IntoIterator<Item = (impl Into<RowLabel>, impl Into<String>)>,
) -> SectionBody {
SectionBody::Rows(
rows.into_iter()
.map(|(label, value)| {
@@ -607,34 +651,43 @@ impl ContainerDetails {
vec![
DetailSection {
id: "basics",
title: "基础信息",
title: "docker.section.basics",
body: rows([
("名称", self.name.clone()),
("ID", self.id.clone()),
("镜像", self.image.clone()),
("创建时间", self.created.clone()),
("入口", self.entrypoint.clone()),
("命令", self.command.clone()),
(RowLabel::Field("docker.field.name"), self.name.clone()),
(RowLabel::Raw("ID".into()), self.id.clone()),
(RowLabel::Field("docker.field.image"), self.image.clone()),
(
RowLabel::Field("docker.field.created"),
self.created.clone(),
),
(
RowLabel::Field("docker.field.entrypoint"),
self.entrypoint.clone(),
),
(
RowLabel::Field("docker.field.command"),
self.command.clone(),
),
]),
},
DetailSection {
id: "ports",
title: "端口映射",
title: "docker.section.ports",
body: rows(self.ports.clone()),
},
DetailSection {
id: "mounts",
title: "挂载",
title: "docker.section.mounts",
body: rows(self.mounts.clone()),
},
DetailSection {
id: "environment",
title: "环境变量",
title: "docker.section.environment",
body: SectionBody::Text(self.environment.clone()),
},
DetailSection {
id: "labels",
title: "标签",
title: "docker.section.labels",
body: rows(self.labels.clone()),
},
]
@@ -832,7 +885,7 @@ mod tests {
let summary = |object, id: &str| table.summary_of(object, id).expect(id);
let data = summary(DockerObject::Volume, "web-data");
assert_eq!(data.tag, Some(("使用中".into(), Tone::Good)));
assert_eq!(data.removable, Err("有容器在用,不能删除"));
assert_eq!(data.removable, Err("有容器在用,不能删除".into()));
assert_eq!(data.used_by, ["web"]);
assert_eq!(summary(DockerObject::Volume, "old-data").removable, Ok(()));
let dangling = summary(DockerObject::Image, "aaa");
@@ -840,7 +893,7 @@ mod tests {
assert_eq!(dangling.tag, None);
assert_eq!(
summary(DockerObject::Network, "bridge").removable,
Err("Docker 自带的网络,不能删除")
Err("Docker 自带的网络,不能删除".into())
);
assert_eq!(
summary(DockerObject::Network, "web_default").tag,
+15 -7
View File
@@ -12,6 +12,7 @@ use super::details::{Read, message, reading, render_sections, run};
use super::linux::{image_details, inspect_command, network_details, volume_details};
use super::model::{DetailSection, DockerObject, ObjectSummary, Tone};
use crate::app::{CatalogIcon, RemoveDockerObject};
use crate::i18n::t;
use crate::shared::soft_tag;
use crate::terminal::TerminalView;
@@ -26,7 +27,7 @@ pub(super) fn tone_color(tone: Tone, cx: &App) -> Hsla {
}
}
/// What the lines of `object`'s details are found by: 「volume-basics:挂载点」.
/// What the lines of `object`'s details are found by: 「volume-basics:mountpoint」.
fn prefix(object: DockerObject) -> &'static str {
match object {
DockerObject::Container => "container",
@@ -76,8 +77,8 @@ pub fn open_object_dialog(
Button::new("docker-dialog-remove")
.outline()
.icon(Icon::new(CatalogIcon::Trash))
.label("删除")
.when_some(summary.removable.err(), |button, why| {
.label(t!("common.delete"))
.when_some(summary.removable.clone().err(), |button, why| {
button.disabled(true).tooltip(why)
})
.on_click(move |_, window, cx| {
@@ -126,10 +127,7 @@ impl ObjectDetailsView {
DockerObject::Container => None,
} {
Some(sections) => Read::Known(sections),
None => Read::Unknown(format!(
"读不到这个{}:它可能已经被删除",
summary.object.label()
)),
None => Read::Unknown(gone(summary.object).into()),
},
Err(why) => Read::Unknown(why),
};
@@ -155,3 +153,13 @@ impl Render for ObjectDetailsView {
div().id("docker-details").test_support().child(body)
}
}
/// Why there are no details: it went since the list was read.
fn gone(object: DockerObject) -> SharedString {
match object {
DockerObject::Container => t!("docker.details.container_gone"),
DockerObject::Image => t!("docker.details.image_gone"),
DockerObject::Volume => t!("docker.details.volume_gone"),
DockerObject::Network => t!("docker.details.network_gone"),
}
}
+29 -31
View File
@@ -19,6 +19,7 @@ use gpui_kit::*;
use super::bash::{self, Parsed};
use super::model::{Entry, History};
use crate::app::{CatalogIcon, CopyCommand, EnterCommand, RefreshHistory};
use crate::i18n::{UiLocale, t, tn};
use crate::shared::command_tooltip;
use crate::terminal::{ExecResult, ExecTarget, RemoteTerminalId, exec_answer};
@@ -78,10 +79,15 @@ impl HistoryPanel {
pub fn new(dispatch: FocusHandle, window: &mut Window, cx: &mut Context<Self>) -> Self {
let search = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("搜索命令")
.placeholder(t!("history.panel.search"))
.clean_on_escape()
});
let subscriptions = vec![
cx.observe_global_in::<UiLocale>(window, |this, window, cx| {
this.search.update(cx, |search, cx| {
search.set_placeholder(t!("history.panel.search"), window, cx)
});
}),
cx.subscribe(&search, |this, input, event: &InputEvent, cx| {
if matches!(event, InputEvent::Change) {
this.query = input.read(cx).value().to_string();
@@ -192,7 +198,10 @@ impl HistoryPanel {
cx: &Context<Self>,
) -> impl IntoElement {
let rows = Rc::new(history.matching(&self.query));
let summary = format!("共 {} 条 · ~/.bash_history", history.entries().len());
let summary = format!(
"{} · ~/.bash_history",
tn!("history.panel.count", history.entries().len())
);
let refresh = self.dispatch.clone();
let header = v_flex()
.flex_shrink_0()
@@ -221,8 +230,8 @@ impl HistoryPanel {
.xsmall()
.icon(Icon::new(CatalogIcon::RefreshCw))
.loading(self.loading)
.tooltip("刷新")
.accessibility_label("刷新")
.tooltip(t!("tools.refresh"))
.accessibility_label(t!("tools.refresh"))
.on_click(move |_, window, cx| {
refresh.dispatch_action(&RefreshHistory, window, cx)
}),
@@ -234,16 +243,12 @@ impl HistoryPanel {
.when(history.truncated(), |header| {
header.child(note(
"history-truncated",
format!("历史文件太长,只读了最后 {} KB。", bash::LIMIT / 1024),
t!("history.panel.truncated", size = bash::LIMIT / 1024),
cx,
))
})
// Why the command just run is not there.
.child(note(
"history-note",
"这个终端里执行的命令,bash 退出后才会出现在这里。".into(),
cx,
))
.child(note("history-note", t!("history.panel.note"), cx))
.child(
Input::new(&self.search)
.id("history-search")
@@ -269,15 +274,15 @@ impl HistoryPanel {
.map(|list| {
if rows.is_empty() {
let empty = if self.query.trim().is_empty() {
"没有历史命令"
t!("history.panel.empty")
} else {
"没有符合条件的命令"
t!("history.panel.no_match")
};
list.child(
div()
.id("history-empty")
.test_support()
.aria_label(empty)
.aria_label(empty.clone())
.py_8()
.text_sm()
.text_center()
@@ -361,7 +366,7 @@ impl Render for HistoryPanel {
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(Spinner::new().small())
.child("正在读取…"),
.child(t!("tools.reading")),
),
})
}
@@ -370,19 +375,12 @@ impl Render for HistoryPanel {
fn render_problem(problem: &Problem, cx: &App) -> impl IntoElement {
let (text, color) = match problem {
Problem::NotConnected => (
"终端没有连接。连接后这里显示主机上 bash 的历史命令。".to_string(),
t!("history.panel.not_connected"),
cx.theme().muted_foreground,
),
Problem::Missing => (
"这台主机上还没有 bash 的历史命令(~/.bash_history)。bash 退出时才会写入它。"
.to_string(),
cx.theme().muted_foreground,
),
Problem::Unsupported => (
"暂不支持读取这台主机的历史命令,只支持 bash 的 ~/.bash_history。".to_string(),
cx.theme().muted_foreground,
),
Problem::Failed(error) => (format!("读取失败:{error}"), cx.theme().danger),
Problem::Missing => (t!("history.panel.missing"), cx.theme().muted_foreground),
Problem::Unsupported => (t!("history.panel.unsupported"), cx.theme().muted_foreground),
Problem::Failed(error) => (t!("tools.read_failed", error = error), cx.theme().danger),
};
div()
.id("history-message")
@@ -394,7 +392,7 @@ fn render_problem(problem: &Problem, cx: &App) -> impl IntoElement {
}
/// A line under the summary.
fn note(id: &'static str, text: String, cx: &App) -> impl IntoElement {
fn note(id: &'static str, text: SharedString, cx: &App) -> impl IntoElement {
div()
.id(id)
.test_support()
@@ -409,7 +407,7 @@ fn build_context_menu(hit: Option<String>, menu: PopupMenu) -> PopupMenu {
return menu;
};
menu.menu_with_icon(
"输入到终端",
t!("tools.menu.insert"),
Icon::new(CatalogIcon::SquareTerminal),
Box::new(EnterCommand {
command: command.clone(),
@@ -417,7 +415,7 @@ fn build_context_menu(hit: Option<String>, menu: PopupMenu) -> PopupMenu {
}),
)
.menu_with_icon(
"执行",
t!("tools.run"),
Icon::new(CatalogIcon::Play),
Box::new(EnterCommand {
command: command.clone(),
@@ -426,7 +424,7 @@ fn build_context_menu(hit: Option<String>, menu: PopupMenu) -> PopupMenu {
)
.separator()
.menu_with_icon(
"复制",
t!("history.menu.copy"),
Icon::new(IconName::Copy),
Box::new(CopyCommand(command)),
)
@@ -510,8 +508,8 @@ fn render_entry(
Button::new(SharedString::from(format!("history-run:{command}")))
.ghost()
.icon(Icon::new(CatalogIcon::Play))
.tooltip("执行")
.accessibility_label("执行")
.tooltip(t!("tools.run"))
.accessibility_label(t!("tools.run"))
.on_click(move |_, window, cx| {
// Not the card's click too, which would only put it on
// the line.
+36 -6
View File
@@ -2,6 +2,8 @@
use std::collections::HashMap;
use crate::i18n::{t, tn};
/// A command of the history, listed once however often it ran.
#[derive(Clone, Debug, PartialEq)]
pub struct Entry {
@@ -22,9 +24,10 @@ impl Entry {
/// 「3 分钟前 · 执行 5 次」, or without a time 「执行 5 次」.
pub fn summary(&self, now: i64) -> String {
let runs = tn!("history.entry.runs", self.runs);
match self.last_run {
Some(at) => format!("{} · 执行 {} 次", format_when(at, now), self.runs),
None => format!("执行 {} 次", self.runs),
Some(at) => format!("{} · {runs}", format_when(at, now)),
None => runs.into(),
}
}
}
@@ -92,10 +95,10 @@ pub fn format_when(at: i64, now: i64) -> String {
// A host whose clock runs ahead says its commands ran in the future.
let ago = now - at;
match ago {
..MINUTE => "刚刚".into(),
MINUTE..HOUR => format!("{} 分钟前", ago / MINUTE),
HOUR..DAY => format!("{} 小时前", ago / HOUR),
DAY..=MONTH => format!("{} 天前", ago / DAY),
..MINUTE => t!("history.when.just_now").into(),
MINUTE..HOUR => tn!("history.when.minutes", ago / MINUTE).into(),
HOUR..DAY => tn!("history.when.hours", ago / HOUR).into(),
DAY..=MONTH => tn!("history.when.days", ago / DAY).into(),
_ => chrono::DateTime::from_timestamp(at, 0)
.map(|time| {
time.with_timezone(&chrono::Local)
@@ -157,6 +160,33 @@ mod tests {
assert!(long_ago.starts_with("2024-08-0"), "{long_ago}");
}
#[test]
fn english_says_how_long_ago_and_how_often() {
crate::i18n::isolate_thread();
crate::i18n::set_locale("en");
let now = 1_727_846_400;
assert_eq!(format_when(now - 20, now), "Just now");
assert_eq!(format_when(now - 60, now), "1 minute ago");
assert_eq!(format_when(now - 5 * 60, now), "5 minutes ago");
assert_eq!(format_when(now - 3600, now), "1 hour ago");
assert_eq!(format_when(now - 2 * 86_400, now), "2 days ago");
let entry = Entry {
command: "ls".into(),
last_run: Some(now - 86_400),
runs: 1,
};
assert_eq!(entry.summary(now), "1 day ago · Ran once");
assert_eq!(
Entry {
last_run: None,
runs: 4,
..entry
}
.summary(now),
"Ran 4 times"
);
}
#[test]
fn a_command_of_several_lines_shows_on_one() {
let entry = Entry {
+171 -116
View File
@@ -24,6 +24,7 @@ use super::model::{
format_rate,
};
use crate::app::{CatalogIcon, ToggleMonitorDetail};
use crate::i18n::{t, tn};
use crate::shared::{format_bytes, format_duration, format_percent};
use crate::terminal::{ExecResult, ExecTarget, RemoteTerminalId, exec_answer};
@@ -276,7 +277,7 @@ impl Render for MonitorPanel {
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(Spinner::new().small())
.child("正在读取…"),
.child(t!("tools.reading")),
),
})
.overflow_y_scrollbar();
@@ -291,18 +292,17 @@ impl Render for MonitorPanel {
fn render_problem(problem: &Problem, cx: &App) -> impl IntoElement {
let (text, color) = match problem {
Problem::NotConnected => (
"终端没有连接。连接后这里显示主机的 CPU、内存、网络和磁盘。".to_string(),
t!("monitor.panel.not_connected"),
cx.theme().muted_foreground,
),
Problem::Unsupported(Some(system)) => (
format!("暂不支持 {system} 的监控,目前只支持 Linux 主机。"),
t!("monitor.panel.unsupported_system", system = system),
cx.theme().muted_foreground,
),
Problem::Unsupported(None) => (
"暂不支持这台主机的监控,目前只支持 Linux 主机。".to_string(),
cx.theme().muted_foreground,
),
Problem::Failed(error) => (format!("读取失败:{error}"), cx.theme().danger),
Problem::Unsupported(None) => {
(t!("monitor.panel.unsupported"), cx.theme().muted_foreground)
}
Problem::Failed(error) => (t!("tools.read_failed", error = error), cx.theme().danger),
};
div()
.id("monitor-message")
@@ -317,7 +317,7 @@ fn render_problem(problem: &Problem, cx: &App) -> impl IntoElement {
fn card(
id: &'static str,
icon: impl Into<Icon>,
title: &'static str,
title: impl Into<SharedString>,
meta: Option<AnyElement>,
cx: &App,
) -> GroupBox {
@@ -330,14 +330,14 @@ fn card(
.flex_1()
.text_sm()
.font_weight(FontWeight::MEDIUM)
.child(title),
.child(title.into()),
)
.children(meta),
)
}
/// A card's quiet note at the end of its title row: 「1 核」.
fn note(text: String, cx: &App) -> AnyElement {
fn note(text: SharedString, cx: &App) -> AnyElement {
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
@@ -348,10 +348,10 @@ fn note(text: String, cx: &App) -> AnyElement {
/// In place of the note, when the card folds something away: 「8 核 ⌄」.
fn unfold_button(
id: &'static str,
label: String,
label: SharedString,
detail: MonitorDetail,
unfolded: bool,
tooltip: &'static str,
tooltip: SharedString,
dispatch: &FocusHandle,
) -> AnyElement {
let dispatch = dispatch.clone();
@@ -388,7 +388,7 @@ fn numeric(text: impl Into<SharedString>, cx: &App) -> Div {
}
fn render_system(system: &SystemInfo, uptime: Option<Duration>, cx: &App) -> impl IntoElement {
let field = |id: &'static str, label: &'static str, value: String| {
let field = |id: &'static str, label: SharedString, value: String| {
v_flex()
.min_w_0()
.gap_0p5()
@@ -408,18 +408,37 @@ fn render_system(system: &SystemInfo, uptime: Option<Duration>, cx: &App) -> imp
.child(value),
)
};
card("monitor-system", CatalogIcon::Monitor, "系统", None, cx).child(
card(
"monitor-system",
CatalogIcon::Monitor,
t!("monitor.system.title"),
None,
cx,
)
.child(
div()
.grid()
.grid_cols(2)
.gap_x_4()
.gap_y_3()
.child(field("monitor-host", "主机", system.host_name.clone()))
.child(field("monitor-arch", "架构", system.arch.clone()))
.child(field("monitor-os", "系统", system.os.clone()))
.child(field(
"monitor-host",
t!("monitor.system.host"),
system.host_name.clone(),
))
.child(field(
"monitor-arch",
t!("monitor.system.arch"),
system.arch.clone(),
))
.child(field(
"monitor-os",
t!("monitor.system.os"),
system.os.clone(),
))
.child(field(
"monitor-uptime",
"运行时长",
t!("monitor.system.uptime"),
uptime.map(format_duration).unwrap_or_default(),
)),
)
@@ -452,7 +471,7 @@ fn gauge(id: &'static str, percent: Option<f32>, cx: &App) -> impl IntoElement {
/// A labelled bar: the label and the share above it.
fn meter(
id: &'static str,
label: &'static str,
label: SharedString,
percent: Option<f32>,
cx: &App,
) -> impl IntoElement {
@@ -512,7 +531,7 @@ fn render_cpu(
let more = layout
.per_row
.filter(|per_row| cores.is_some_and(|cores| cores.len() > *per_row));
let count = format!("{} 核", snapshot.cores);
let count = tn!("monitor.cpu.cores", snapshot.cores);
let meta = if more.is_some() {
unfold_button(
"monitor-cores-toggle",
@@ -520,9 +539,9 @@ fn render_cpu(
MonitorDetail::Cores,
layout.unfolded,
if layout.unfolded {
"收起更多核的占用"
t!("monitor.cpu.fold_cores")
} else {
"显示更多核的占用"
t!("monitor.cpu.unfold_cores")
},
dispatch,
)
@@ -552,7 +571,12 @@ fn render_cpu(
.flex_1()
.min_w_0()
.gap_3()
.child(meter("monitor-cpu-usage", "平均使用率", total, cx))
.child(meter(
"monitor-cpu-usage",
t!("monitor.cpu.average"),
total,
cx,
))
.when_some(shown, |column, shown| {
column.child(render_cores(shown, layout, cx))
}),
@@ -565,12 +589,19 @@ fn render_cpu(
/// the first row; it measures its width for the next frame to know how many
/// make one. Pointing at a bar tells its core's number and share.
fn render_cores(cores: &[f32], layout: &CoresLayout, cx: &App) -> impl IntoElement {
let core = |index: usize, load: f32| {
t!(
"monitor.cpu.core",
index = index,
load = format_percent(load)
)
};
let label = cores
.iter()
.enumerate()
.map(|(index, load)| format!("核 {index}:{}", format_percent(*load)))
.collect::<Vec<_>>()
.join(",");
.map(|(index, load)| core(index, *load))
.reduce(|list, next| t!("monitor.cpu.core_list", list = list, core = next))
.unwrap_or_default();
let (width, panel) = (layout.width.clone(), layout.panel);
h_flex()
.id("monitor-cpu-cores")
@@ -586,7 +617,7 @@ fn render_cores(cores: &[f32], layout: &CoresLayout, cx: &App) -> impl IntoEleme
})
.children(cores.iter().enumerate().map(|(index, load)| {
let color = load_color(*load, cx);
let tooltip: SharedString = format!("核 {index}:{}", format_percent(*load)).into();
let tooltip = core(index, *load);
div()
.id(("monitor-core", index as u64))
.test_support()
@@ -638,7 +669,14 @@ fn amount(id: &'static str, used: u64, total: u64, cx: &App) -> impl IntoElement
}
fn render_memory(memory: &Memory, cx: &App) -> impl IntoElement {
card("monitor-memory", IconName::MemoryStick, "内存", None, cx).child(
card(
"monitor-memory",
IconName::MemoryStick,
t!("monitor.memory.title"),
None,
cx,
)
.child(
h_flex()
.gap_4()
.child(gauge("monitor-memory-gauge", Some(memory.percent()), cx))
@@ -652,7 +690,7 @@ fn render_memory(memory: &Memory, cx: &App) -> impl IntoElement {
.gap_1p5()
.child(meter(
"monitor-memory-usage",
"物理内存",
t!("monitor.memory.physical"),
Some(memory.percent()),
cx,
))
@@ -669,7 +707,7 @@ fn render_memory(memory: &Memory, cx: &App) -> impl IntoElement {
.gap_1p5()
.child(meter(
"monitor-swap-usage",
"交换空间",
t!("monitor.memory.swap"),
Some(memory.swap_percent()),
cx,
))
@@ -695,13 +733,13 @@ fn render_network(
let meta = (busy.len() > 1).then(|| {
unfold_button(
"monitor-interfaces-toggle",
format!("{} 个网卡", busy.len()),
tn!("monitor.network.interfaces", busy.len()),
MonitorDetail::Interfaces,
unfolded,
if unfolded {
"只显示主网卡"
t!("monitor.network.fold")
} else {
"显示全部网卡"
t!("monitor.network.unfold")
},
dispatch,
)
@@ -712,41 +750,47 @@ fn render_network(
snapshot.main_interface().into_iter().collect()
};
let rate = |rate: Option<f64>| rate.map_or("—".into(), format_rate);
card("monitor-network", IconName::Network, "网络", meta, cx)
.when(shown.is_empty(), |card| {
card.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child("没有在用的网卡"),
)
})
.children(shown.into_iter().map(|interface| {
let (up, down) = (rate(interface.transmitted), rate(interface.received));
h_flex()
.id(SharedString::from(format!(
"monitor-interface:{}",
interface.name
)))
.test_support()
.aria_label(format!("上传 {up},下载 {down}"))
.gap_3()
card(
"monitor-network",
IconName::Network,
t!("monitor.network.title"),
meta,
cx,
)
.when(shown.is_empty(), |card| {
card.child(
div()
.text_sm()
.child(
numeric(interface.name.clone(), cx)
.flex_1()
.min_w_0()
.truncate(),
)
.child(
h_flex()
.flex_shrink_0()
.gap_3()
.text_xs()
.child(direction(IconName::ArrowUp, up, cx))
.child(direction(IconName::ArrowDown, down, cx)),
)
}))
.text_color(cx.theme().muted_foreground)
.child(t!("monitor.network.none")),
)
})
.children(shown.into_iter().map(|interface| {
let (up, down) = (rate(interface.transmitted), rate(interface.received));
h_flex()
.id(SharedString::from(format!(
"monitor-interface:{}",
interface.name
)))
.test_support()
.aria_label(t!("monitor.network.label", up = up, down = down))
.gap_3()
.text_sm()
.child(
numeric(interface.name.clone(), cx)
.flex_1()
.min_w_0()
.truncate(),
)
.child(
h_flex()
.flex_shrink_0()
.gap_3()
.text_xs()
.child(direction(IconName::ArrowUp, up, cx))
.child(direction(IconName::ArrowDown, down, cx)),
)
}))
}
fn direction(icon: IconName, rate: String, cx: &App) -> impl IntoElement {
@@ -761,54 +805,65 @@ fn direction(icon: IconName, rate: String, cx: &App) -> impl IntoElement {
}
fn render_disks(disks: &[DiskUsage], cx: &App) -> impl IntoElement {
card("monitor-disks", IconName::HardDrive, "磁盘", None, cx)
.when(disks.is_empty(), |card| {
card.child(
div()
card(
"monitor-disks",
IconName::HardDrive,
t!("monitor.disks.title"),
None,
cx,
)
.when(disks.is_empty(), |card| {
card.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(t!("monitor.disks.none")),
)
})
.children(disks.iter().map(|disk| {
let percent = disk.percent();
let color = load_color(percent, cx);
let (used, total) = (format_bytes(disk.used), format_bytes(disk.total));
v_flex()
.id(SharedString::from(format!("monitor-disk:{}", disk.mount)))
.test_support()
.aria_label(t!(
"monitor.disks.label",
percent = format_percent(percent),
used = used,
total = total
))
.gap_1p5()
.child(
h_flex()
.gap_3()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child("没有读到磁盘"),
.child(
numeric(disk.mount.clone(), cx)
.flex_1()
.min_w_0()
.truncate(),
)
.child(
numeric(format_percent(percent), cx)
.flex_shrink_0()
.text_color(color)
.font_weight(FontWeight::SEMIBOLD),
),
)
})
.children(disks.iter().map(|disk| {
let percent = disk.percent();
let color = load_color(percent, cx);
let (used, total) = (format_bytes(disk.used), format_bytes(disk.total));
v_flex()
.id(SharedString::from(format!("monitor-disk:{}", disk.mount)))
.test_support()
.aria_label(format!("{},{used} / {total}", format_percent(percent)))
.gap_1p5()
.child(
h_flex()
.gap_3()
.text_sm()
.child(
numeric(disk.mount.clone(), cx)
.flex_1()
.min_w_0()
.truncate(),
)
.child(
numeric(format_percent(percent), cx)
.flex_shrink_0()
.text_color(color)
.font_weight(FontWeight::SEMIBOLD),
),
)
.child(
Progress::new(SharedString::from(format!(
"monitor-disk-bar:{}",
disk.mount
)))
.small()
.value(percent)
.color(color),
)
.child(
numeric(format!("{used} / {total}"), cx)
.text_xs()
.text_color(cx.theme().muted_foreground),
)
}))
.child(
Progress::new(SharedString::from(format!(
"monitor-disk-bar:{}",
disk.mount
)))
.small()
.value(percent)
.color(color),
)
.child(
numeric(format!("{used} / {total}"), cx)
.text_xs()
.text_color(cx.theme().muted_foreground),
)
}))
}
+58 -31
View File
@@ -2,6 +2,10 @@
use std::collections::{HashMap, HashSet};
use gpui_kit::SharedString;
use crate::i18n::{t, tn};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Protocol {
Tcp,
@@ -80,10 +84,19 @@ impl SocketState {
/// How the list says it: 「监听」「已连接」, and the TCP name for the
/// states only the TCP-minded look for: 「TIME_WAIT」.
pub fn label(self) -> &'static str {
pub fn label(self) -> SharedString {
match self {
SocketState::Listen => "监听",
SocketState::Established => "已连接",
SocketState::Listen => t!("netstat.state.listen"),
SocketState::Established => t!("netstat.state.established"),
other => other.netstat_name().into(),
}
}
/// The name netstat gives it, which a search finds as well.
fn netstat_name(self) -> &'static str {
match self {
SocketState::Listen => "LISTEN",
SocketState::Established => "ESTABLISHED",
SocketState::SynSent => "SYN_SENT",
SocketState::SynReceived => "SYN_RECV",
SocketState::FinWait1 => "FIN_WAIT1",
@@ -96,15 +109,6 @@ impl SocketState {
}
}
/// The name netstat gives it, which a search finds as well.
fn netstat_name(self) -> &'static str {
match self {
SocketState::Listen => "LISTEN",
SocketState::Established => "ESTABLISHED",
other => other.label(),
}
}
/// Where the list puts it: what listens first, then the live
/// connections, the ones on their way out last.
fn rank(self) -> u8 {
@@ -135,11 +139,11 @@ pub enum Role {
}
impl Role {
pub fn label(self) -> &'static str {
pub fn label(self) -> SharedString {
match self {
Role::Listening => "监听端口",
Role::Inbound => "入站连接",
Role::Outbound => "出站连接",
Role::Listening => t!("netstat.role.listening"),
Role::Inbound => t!("netstat.role.inbound"),
Role::Outbound => t!("netstat.role.outbound"),
}
}
}
@@ -192,16 +196,22 @@ impl Socket {
let first = self.processes.first()?;
Some(match self.processes.len() {
1 => format!("{} (PID {})", first.name, first.pid),
count => format!("{} (PID {} 等 {count} 个)", first.name, first.pid),
count => t!(
"netstat.process.several",
name = first.name,
pid = first.pid,
count = count
)
.into(),
})
}
/// What the search looks in, lowercased.
/// What the search looks in, lowercased; besides the state as the
/// list says it, which follows the interface language.
fn haystack(&self) -> String {
let mut haystack = format!(
"{} {} {} {} {}",
"{} {} {} {}",
self.protocol.label(),
self.state.label(),
self.state.netstat_name(),
self.local,
self.peer,
@@ -243,8 +253,10 @@ impl Counts {
/// 「共 96 条 · 15 个监听端口 · 21 条已连接」.
pub fn summary(&self) -> String {
format!(
"共 {} 条 · {} 个监听端口 · {} 条已连接",
self.total, self.listening, self.connected
"{} · {} · {}",
tn!("netstat.summary.total", self.total),
tn!("netstat.summary.listening", self.listening),
tn!("netstat.summary.connected", self.connected)
)
}
}
@@ -369,6 +381,13 @@ impl Table {
/// The sockets `filter` lets through, by their place in `sockets()`.
pub fn filtered(&self, filter: &Filter) -> Vec<usize> {
let query = filter.query.trim().to_lowercase();
// The states as the list says them now, which the search finds.
let mut labels: HashMap<SocketState, bool> = HashMap::new();
let mut label_matches = |state: SocketState| {
*labels
.entry(state)
.or_insert_with(|| state.label().to_lowercase().contains(&query))
};
self.sockets
.iter()
.zip(&self.haystacks)
@@ -376,7 +395,9 @@ impl Table {
.filter(|(_, (socket, haystack))| {
filter.protocol.lets_through(socket.protocol)
&& filter.state.lets_through(socket.state)
&& (query.is_empty() || haystack.contains(&query))
&& (query.is_empty()
|| haystack.contains(&query)
|| label_matches(socket.state))
})
.map(|(index, _)| index)
.collect()
@@ -398,11 +419,11 @@ impl ProtocolFilter {
ProtocolFilter::Udp,
];
pub fn label(self) -> &'static str {
pub fn label(self) -> SharedString {
match self {
ProtocolFilter::All => "全部协议",
ProtocolFilter::Tcp => "TCP",
ProtocolFilter::Udp => "UDP",
ProtocolFilter::All => t!("netstat.filter.all_protocols"),
ProtocolFilter::Tcp => "TCP".into(),
ProtocolFilter::Udp => "UDP".into(),
}
}
@@ -437,14 +458,14 @@ impl StateFilter {
StateFilter::Other,
];
pub fn label(self) -> &'static str {
pub fn label(self) -> SharedString {
match self {
StateFilter::All => "全部状态",
StateFilter::All => t!("netstat.filter.all_states"),
StateFilter::Listen => SocketState::Listen.label(),
StateFilter::Established => SocketState::Established.label(),
StateFilter::TimeWait => SocketState::TimeWait.label(),
StateFilter::CloseWait => SocketState::CloseWait.label(),
StateFilter::Other => "其他状态",
StateFilter::Other => t!("netstat.filter.other_states"),
}
}
@@ -565,10 +586,16 @@ mod tests {
#[test]
fn what_listens_comes_first_and_shares_of_a_port_make_one_line() {
let table = sample();
let labels: Vec<SharedString> = table
.sockets()
.iter()
.map(|socket| socket.state.label())
.collect();
let lines: Vec<(&str, &str, Role)> = table
.sockets()
.iter()
.map(|socket| (socket.state.label(), socket.local.as_str(), socket.role))
.zip(&labels)
.map(|(socket, label)| (label.as_ref(), socket.local.as_str(), socket.role))
.collect();
assert_eq!(
lines,
+41 -40
View File
@@ -22,6 +22,7 @@ use super::model::{
Filter, Processes, ProtocolFilter, Role, Socket, SocketState, StateFilter, Table,
};
use crate::app::{CatalogIcon, RefreshConnections};
use crate::i18n::{UiLocale, t, tn};
use crate::terminal::{ExecResult, ExecTarget, RemoteTerminalId, exec_answer};
/// How long a terminal not connected is left before it is asked again.
@@ -77,7 +78,7 @@ impl SearchableListItem for ProtocolFilter {
type Value = Self;
fn title(&self) -> SharedString {
self.label().into()
self.label()
}
fn value(&self) -> &Self::Value {
@@ -89,7 +90,7 @@ impl SearchableListItem for StateFilter {
type Value = Self;
fn title(&self) -> SharedString {
self.label().into()
self.label()
}
fn value(&self) -> &Self::Value {
@@ -101,7 +102,7 @@ impl NetstatPanel {
pub fn new(dispatch: FocusHandle, window: &mut Window, cx: &mut Context<Self>) -> Self {
let search = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("搜索地址、端口、状态、PID 或进程")
.placeholder(t!("netstat.panel.search"))
.clean_on_escape()
});
let protocol = cx.new(|cx| {
@@ -121,6 +122,11 @@ impl NetstatPanel {
)
});
let subscriptions = vec![
cx.observe_global_in::<UiLocale>(window, |this, window, cx| {
this.search.update(cx, |search, cx| {
search.set_placeholder(t!("netstat.panel.search"), window, cx)
});
}),
cx.subscribe(&search, |this, input, event: &InputEvent, cx| {
if matches!(event, InputEvent::Change) {
let query = input.read(cx).value().to_string();
@@ -297,8 +303,8 @@ impl NetstatPanel {
.xsmall()
.icon(Icon::new(CatalogIcon::RefreshCw))
.loading(self.loading)
.tooltip("刷新")
.accessibility_label("刷新")
.tooltip(t!("tools.refresh"))
.accessibility_label(t!("tools.refresh"))
.on_click(move |_, window, cx| {
dispatch.dispatch_action(&RefreshConnections, window, cx)
}),
@@ -310,21 +316,17 @@ impl NetstatPanel {
.when(table.truncated(), |header| {
header.child(note(
"netstat-truncated",
format!("连接太多,只列出了前 {} 条。", linux::LIMIT),
t!("netstat.panel.truncated", limit = linux::LIMIT),
cx.theme().warning,
))
})
.map(|header| {
let text = match table.processes() {
Processes::All => return header,
Processes::Own => "当前用户不是 root,只能看到自己的进程。",
Processes::None => "这台主机没有 ss 命令,看不到占用连接的进程。",
Processes::Own => t!("netstat.panel.own_processes"),
Processes::None => t!("netstat.panel.no_ss"),
};
header.child(note(
"netstat-processes",
text.into(),
cx.theme().muted_foreground,
))
header.child(note("netstat-processes", text, cx.theme().muted_foreground))
})
.child(
Input::new(&self.search)
@@ -342,7 +344,7 @@ impl NetstatPanel {
Select::new(&self.protocol)
.id("netstat-protocol")
.small()
.accessibility_label("协议"),
.accessibility_label(t!("netstat.panel.protocol")),
),
)
.child(
@@ -350,12 +352,12 @@ impl NetstatPanel {
Select::new(&self.state)
.id("netstat-state")
.small()
.accessibility_label("状态"),
.accessibility_label(t!("netstat.panel.state")),
),
),
)
.when(self.filter.is_active(), |header| {
let text = format!("筛选出 {} 条", rows.len());
let text = tn!("netstat.panel.matches", rows.len());
header.child(
div()
.id("netstat-matches")
@@ -368,9 +370,9 @@ impl NetstatPanel {
});
let empty = if self.filter.is_active() {
"没有符合条件的连接"
t!("netstat.panel.no_match")
} else {
"没有网络连接"
t!("netstat.panel.empty")
};
// The scrollbar goes on the list's box, which does not scroll: on
// the scrolled rows it would scroll away with them.
@@ -388,7 +390,7 @@ impl NetstatPanel {
div()
.id("netstat-empty")
.test_support()
.aria_label(empty)
.aria_label(empty.clone())
.py_8()
.text_sm()
.text_center()
@@ -450,7 +452,7 @@ impl Render for NetstatPanel {
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(Spinner::new().small())
.child("正在读取…"),
.child(t!("tools.reading")),
),
})
}
@@ -459,18 +461,17 @@ impl Render for NetstatPanel {
fn render_problem(problem: &Problem, cx: &App) -> impl IntoElement {
let (text, color) = match problem {
Problem::NotConnected => (
"终端没有连接。连接后这里显示主机的网络连接。".to_string(),
t!("netstat.panel.not_connected"),
cx.theme().muted_foreground,
),
Problem::Unsupported(Some(system)) => (
format!("暂不支持查看 {system} 的网络连接,目前只支持 Linux 主机。"),
t!("netstat.panel.unsupported_system", system = system),
cx.theme().muted_foreground,
),
Problem::Unsupported(None) => (
"暂不支持查看这台主机的网络连接,目前只支持 Linux 主机。".to_string(),
cx.theme().muted_foreground,
),
Problem::Failed(error) => (format!("读取失败:{error}"), cx.theme().danger),
Problem::Unsupported(None) => {
(t!("netstat.panel.unsupported"), cx.theme().muted_foreground)
}
Problem::Failed(error) => (t!("tools.read_failed", error = error), cx.theme().danger),
};
div()
.id("netstat-message")
@@ -482,7 +483,7 @@ fn render_problem(problem: &Problem, cx: &App) -> impl IntoElement {
}
/// A line under the summary: 「连接太多,只列出了前 3000 条。」.
fn note(id: &'static str, text: String, color: Hsla) -> impl IntoElement {
fn note(id: &'static str, text: SharedString, color: Hsla) -> impl IntoElement {
div()
.id(id)
.test_support()
@@ -517,16 +518,16 @@ fn role_icon(role: Role) -> CatalogIcon {
fn render_socket(socket: &Socket, cx: &App) -> impl IntoElement {
let color = state_color(socket.state, cx);
let process = socket.process_label();
let label = format!(
"{} {} · 本地 {} · 远端 {} · {} · {}",
socket.protocol.label(),
socket.state.label(),
socket.local,
socket.peer,
process.as_deref().unwrap_or("—"),
socket.user.as_deref().unwrap_or("—"),
let label = t!(
"netstat.card.label",
protocol = socket.protocol.label(),
state = socket.state.label(),
local = socket.local,
peer = socket.peer,
process = process.as_deref().unwrap_or("—"),
user = socket.user.as_deref().unwrap_or("—")
);
let address = |name: &'static str, value: &str| {
let address = |name: SharedString, value: &str| {
h_flex()
.gap_2()
.text_xs()
@@ -577,8 +578,8 @@ fn render_socket(socket: &Socket, cx: &App) -> impl IntoElement {
.child(socket.role.label()),
),
)
.child(address("本地", &socket.local))
.child(address("远端", &socket.peer))
.child(address(t!("netstat.card.local"), &socket.local))
.child(address(t!("netstat.card.peer"), &socket.peer))
.child(
h_flex()
.gap_2()
@@ -591,7 +592,7 @@ fn render_socket(socket: &Socket, cx: &App) -> impl IntoElement {
div()
.flex_shrink_0()
.text_color(cx.theme().muted_foreground)
.child("进程"),
.child(t!("netstat.card.process")),
)
.child(
div()
+5 -4
View File
@@ -4,6 +4,7 @@
use std::collections::HashMap;
use super::model::{ProcessInfo, ProcessState, ProcessTimes, Reading};
use crate::i18n::t;
/// The most processes a reading lists: some 300 bytes of `/proc` each, and
/// a reading's output holds 1 MB.
@@ -194,17 +195,17 @@ pub fn end_command(pid: u32, force: bool) -> String {
pub fn ended(output: &str) -> Result<(), String> {
let (said, status) = output
.rsplit_once("@@status")
.ok_or_else(|| "主机没有回答".to_string())?;
.ok_or_else(|| t!("tools.no_answer").to_string())?;
if status.trim() == "0" {
return Ok(());
}
let said = said.trim();
Err(if said.contains("not permitted") {
"没有权限:只能结束自己的进程,root 才能结束所有进程".into()
t!("processes.error.not_permitted").into()
} else if said.contains("No such process") {
"进程已经退出".into()
t!("processes.error.gone").into()
} else if said.is_empty() {
format!("kill 返回 {}", status.trim())
t!("processes.error.status", status = status.trim()).into()
} else {
said.to_owned()
})
+12 -8
View File
@@ -3,6 +3,10 @@
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use gpui_kit::SharedString;
use crate::i18n::t;
/// One look at a host's processes.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Reading {
@@ -81,15 +85,15 @@ impl ProcessState {
}
}
pub fn label(self) -> &'static str {
pub fn label(self) -> SharedString {
match self {
ProcessState::Running => "运行中",
ProcessState::Sleeping => "休眠",
ProcessState::Uninterruptible => "等待 I/O",
ProcessState::Stopped => "已暂停",
ProcessState::Zombie => "僵尸进程",
ProcessState::Idle => "空闲",
ProcessState::Other => "其他",
ProcessState::Running => t!("processes.state.running"),
ProcessState::Sleeping => t!("processes.state.sleeping"),
ProcessState::Uninterruptible => t!("processes.state.uninterruptible"),
ProcessState::Stopped => t!("processes.state.stopped"),
ProcessState::Zombie => t!("processes.state.zombie"),
ProcessState::Idle => t!("processes.state.idle"),
ProcessState::Other => t!("processes.state.misc"),
}
}
}
+102 -69
View File
@@ -16,6 +16,7 @@ use gpui_kit::*;
use super::linux::{command_line, command_line_command};
use super::model::{ProcessDetails, format_started};
use crate::app::{CatalogIcon, EndProcess};
use crate::i18n::t;
use crate::shared::{format_bytes, format_duration, format_percent};
use crate::terminal::{TerminalView, exec_answer};
@@ -74,7 +75,7 @@ pub fn open_process_dialog(
.justify_between()
.child(copy_button(
"process-copy-pid",
"复制 PID",
t!("processes.details.copy_pid"),
Some(pid.to_string()),
this.copied == Some(Copied::Pid),
Copied::Pid,
@@ -87,13 +88,13 @@ pub fn open_process_dialog(
end("process-end", false)
.outline()
.icon(Icon::new(CatalogIcon::CircleStop))
.label("结束进程"),
.label(t!("processes.details.end")),
)
.child(
end("process-force-end", true)
.danger()
.icon(Icon::new(CatalogIcon::OctagonX))
.label("强制结束"),
.label(t!("processes.details.kill")),
),
);
dialog
@@ -148,15 +149,15 @@ impl ProcessDetailsView {
});
let reading = cx.spawn(async move |this, cx| {
let command = match reply {
None => CommandLine::Unknown("终端没有连接".into()),
None => CommandLine::Unknown(t!("tools.not_connected").into()),
Some(reply) => match exec_answer(reply, cx).await {
None => CommandLine::Unknown("终端没有连接".into()),
Some(Err(error)) => CommandLine::Unknown(format!("读取失败:{error}")),
None => CommandLine::Unknown(t!("tools.not_connected").into()),
Some(Err(error)) => {
CommandLine::Unknown(t!("tools.read_failed", error = error).into())
}
Some(Ok(output)) => match command_line(&output) {
Some(line) => CommandLine::Known(line),
None => {
CommandLine::Unknown("没有命令行:进程已经退出,或是僵尸进程".into())
}
None => CommandLine::Unknown(t!("processes.details.no_command").into()),
},
},
};
@@ -193,7 +194,7 @@ impl ProcessDetailsView {
/// 「复制命令」, which says 「已复制」 for a moment once it has.
fn copy_button(
id: &'static str,
label: &'static str,
label: SharedString,
text: Option<String>,
copied: bool,
which: Copied,
@@ -207,7 +208,11 @@ fn copy_button(
} else {
IconName::Copy
}))
.label(if copied { "已复制" } else { label })
.label(if copied {
t!("processes.details.copied")
} else {
label
})
.disabled(text.is_none())
.on_click(move |_, _, cx| {
if let Some(text) = text.clone() {
@@ -221,38 +226,59 @@ impl Render for ProcessDetailsView {
let details = &self.details;
let process = &details.process;
let info = &process.info;
let fields: [(&str, String); 16] = [
("PID", process.pid.to_string()),
// Each label by its text's key; its line is found by the key's
// last part: 「process-field:parent」.
let none = || t!("processes.details.none").to_string();
let fields: [(&'static str, String); 16] = [
("processes.field.pid", process.pid.to_string()),
(
"父进程",
details.parent.clone().unwrap_or_else(|| "无".into()),
"processes.field.parent",
details.parent.clone().unwrap_or_else(none),
),
("用户", process.user.clone().unwrap_or_else(|| "—".into())),
(
"状态",
"processes.field.user",
process.user.clone().unwrap_or_else(|| "—".into()),
),
(
"processes.field.state",
format!("{} ({})", process.state.label(), info.letters),
),
("启动时间", format_started(process.started)),
("运行时长", format_duration(process.running)),
("终端", info.terminal.clone().unwrap_or_else(|| "无".into())),
("优先级", info.priority.to_string()),
("Nice", info.nice.to_string()),
("CPU 使用率", process.cpu.map_or("—".into(), format_percent)),
("累计 CPU 时间", format_duration(process.cpu_time)),
("processes.field.started", format_started(process.started)),
("processes.field.running", format_duration(process.running)),
(
"常驻内存",
"processes.field.terminal",
info.terminal.clone().unwrap_or_else(none),
),
("processes.field.priority", info.priority.to_string()),
("processes.field.nice", info.nice.to_string()),
(
"processes.field.cpu",
process.cpu.map_or("—".into(), format_percent),
),
(
"processes.field.cpu_time",
format_duration(process.cpu_time),
),
(
"processes.field.memory",
format!(
"{} ({})",
format_bytes(process.memory),
format_percent(process.memory_percent)
),
),
("虚拟内存", format_bytes(info.virtual_memory)),
("线程", info.threads.to_string()),
("直接子进程", details.children.to_string()),
("全部后代进程", details.descendants.to_string()),
(
"processes.field.virtual_memory",
format_bytes(info.virtual_memory),
),
("processes.field.threads", info.threads.to_string()),
("processes.field.children", details.children.to_string()),
(
"processes.field.descendants",
details.descendants.to_string(),
),
];
let heading = |text: &'static str| {
let heading = |text: SharedString| {
div()
.text_sm()
.font_weight(FontWeight::SEMIBOLD)
@@ -268,49 +294,56 @@ impl Render for ProcessDetailsView {
.test_support()
.gap_5()
.child(
v_flex().gap_1().child(heading("进程信息")).child(
div()
.grid()
.grid_cols(2)
.gap_x_8()
.children(fields.into_iter().map(|(label, value)| {
h_flex()
.id(SharedString::from(format!("process-field:{label}")))
.test_support()
.aria_label(value.clone())
.min_w_0()
.gap_3()
.py_2()
.border_b_1()
.border_color(cx.theme().border)
.text_sm()
.child(
div()
.w(rems(6.5))
.flex_shrink_0()
.text_color(cx.theme().muted_foreground)
.child(label),
)
.child(div().flex_1().min_w_0().truncate().child(value))
})),
),
v_flex()
.gap_1()
.child(heading(t!("processes.details.info")))
.child(
div()
.grid()
.grid_cols(2)
.gap_x_8()
.children(fields.into_iter().map(|(label, value)| {
let id = label.rsplit('.').next().unwrap_or(label);
h_flex()
.id(SharedString::from(format!("process-field:{id}")))
.test_support()
.aria_label(value.clone())
.min_w_0()
.gap_3()
.py_2()
.border_b_1()
.border_color(cx.theme().border)
.text_sm()
.child(
div()
.w(rems(6.5))
.flex_shrink_0()
.text_color(cx.theme().muted_foreground)
.child(t!(label)),
)
.child(div().flex_1().min_w_0().truncate().child(value))
})),
),
)
.child(
v_flex()
.gap_2()
.child(
h_flex().justify_between().child(heading("完整命令")).child(
copy_button(
"process-copy-command",
"复制命令",
command_text,
self.copied == Some(Copied::Command),
Copied::Command,
&view,
)
.ghost()
.small(),
),
h_flex()
.justify_between()
.child(heading(t!("processes.details.command")))
.child(
copy_button(
"process-copy-command",
t!("processes.details.copy_command"),
command_text,
self.copied == Some(Copied::Command),
Copied::Command,
&view,
)
.ghost()
.small(),
),
)
.child(match &self.command {
CommandLine::Reading => h_flex()
@@ -321,7 +354,7 @@ impl Render for ProcessDetailsView {
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(Spinner::new().small())
.child("正在读取…")
.child(t!("tools.reading"))
.into_any_element(),
CommandLine::Known(line) => div()
.id("process-command")
+46 -30
View File
@@ -23,6 +23,7 @@ use super::model::{
Order, Process, ProcessDetails, ProcessSort, ProcessState, Snapshot, Tracker, format_started,
};
use crate::app::{CatalogIcon, EndProcess, RefreshProcesses, ShowProcess, SortProcesses};
use crate::i18n::{UiLocale, t, tn};
use crate::shared::{format_bytes, format_percent};
use crate::terminal::{ExecResult, ExecTarget, RemoteTerminalId, exec_answer};
@@ -88,10 +89,15 @@ impl ProcessPanel {
pub fn new(dispatch: FocusHandle, window: &mut Window, cx: &mut Context<Self>) -> Self {
let search = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("搜索进程名称、PID 或用户")
.placeholder(t!("processes.panel.search"))
.clean_on_escape()
});
let subscriptions = vec![
cx.observe_global_in::<UiLocale>(window, |this, window, cx| {
this.search.update(cx, |search, cx| {
search.set_placeholder(t!("processes.panel.search"), window, cx)
});
}),
cx.subscribe(&search, |this, input, event: &InputEvent, cx| {
if matches!(event, InputEvent::Change) {
this.query = input.read(cx).value().to_string();
@@ -252,10 +258,10 @@ impl ProcessPanel {
cx: &Context<Self>,
) -> impl IntoElement {
let rows = Rc::new(snapshot.listed(&self.query, self.order));
let summary = format!(
"共 {} 个进程 · 每 {} 秒刷新",
let summary = tn!(
"processes.panel.summary",
snapshot.processes().len(),
INTERVAL.as_secs()
seconds = INTERVAL.as_secs()
);
let refresh = self.dispatch.clone();
let header = v_flex()
@@ -285,8 +291,8 @@ impl ProcessPanel {
.xsmall()
.icon(Icon::new(CatalogIcon::RefreshCw))
.loading(self.loading)
.tooltip("刷新")
.accessibility_label("刷新")
.tooltip(t!("tools.refresh"))
.accessibility_label(t!("tools.refresh"))
.on_click(move |_, window, cx| {
refresh.dispatch_action(&RefreshProcesses, window, cx)
}),
@@ -296,7 +302,7 @@ impl ProcessPanel {
header.child(render_problem(&problem, cx))
})
.when(snapshot.truncated(), |header| {
let text = format!("进程太多,只列出了前 {} 个。", linux::LIMIT);
let text = t!("processes.panel.truncated", limit = linux::LIMIT);
header.child(
div()
.id("processes-truncated")
@@ -362,15 +368,15 @@ impl ProcessPanel {
.map(|list| {
if rows.is_empty() {
let empty = if self.query.trim().is_empty() {
"没有进程"
t!("processes.panel.empty")
} else {
"没有符合条件的进程"
t!("processes.panel.no_match")
};
list.child(
div()
.id("processes-empty")
.test_support()
.aria_label(empty)
.aria_label(empty.clone())
.py_8()
.text_sm()
.text_center()
@@ -452,7 +458,7 @@ impl Render for ProcessPanel {
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(Spinner::new().small())
.child("正在读取…"),
.child(t!("tools.reading")),
),
})
}
@@ -461,18 +467,18 @@ impl Render for ProcessPanel {
fn render_problem(problem: &Problem, cx: &App) -> impl IntoElement {
let (text, color) = match problem {
Problem::NotConnected => (
"终端没有连接。连接后这里显示主机的进程。".to_string(),
t!("processes.panel.not_connected"),
cx.theme().muted_foreground,
),
Problem::Unsupported(Some(system)) => (
format!("暂不支持管理 {system} 的进程,目前只支持 Linux 主机。"),
t!("processes.panel.unsupported_system", system = system),
cx.theme().muted_foreground,
),
Problem::Unsupported(None) => (
"暂不支持管理这台主机的进程,目前只支持 Linux 主机。".to_string(),
t!("processes.panel.unsupported"),
cx.theme().muted_foreground,
),
Problem::Failed(error) => (format!("读取失败:{error}"), cx.theme().danger),
Problem::Failed(error) => (t!("tools.read_failed", error = error), cx.theme().danger),
};
div()
.id("processes-message")
@@ -493,13 +499,15 @@ fn sort_button(
dispatch: &FocusHandle,
) -> impl IntoElement {
let active = order.by == by;
let label = match by {
ProcessSort::Memory => "内存",
ProcessSort::Cpu => "CPU",
let label: SharedString = match by {
ProcessSort::Memory => t!("processes.memory"),
ProcessSort::Cpu => "CPU".into(),
};
let tooltip = match (active, order.descending) {
(true, true) => format!("改为按{label}从少到多排列"),
_ => format!("按{label}从多到少排列"),
let tooltip = match (by, active && order.descending) {
(ProcessSort::Memory, true) => t!("processes.sort.memory_ascending"),
(ProcessSort::Memory, false) => t!("processes.sort.memory_descending"),
(ProcessSort::Cpu, true) => t!("processes.sort.cpu_ascending"),
(ProcessSort::Cpu, false) => t!("processes.sort.cpu_descending"),
};
let dispatch = dispatch.clone();
Button::new(id)
@@ -525,18 +533,18 @@ fn build_context_menu(hit: Option<u32>, menu: PopupMenu) -> PopupMenu {
return menu;
};
menu.menu_with_icon(
"查看详情",
t!("tools.menu.details"),
Icon::new(IconName::Info),
Box::new(ShowProcess(pid)),
)
.separator()
.menu_with_icon(
"结束进程…",
t!("processes.menu.end"),
Icon::new(CatalogIcon::CircleStop),
Box::new(EndProcess { pid, force: false }),
)
.menu_with_icon(
"强制结束进程…",
t!("processes.menu.kill"),
Icon::new(CatalogIcon::OctagonX),
Box::new(EndProcess { pid, force: true }),
)
@@ -573,12 +581,16 @@ fn render_process(
format_percent(process.memory_percent)
);
let cpu = process.cpu.map_or("—".into(), format_percent);
let label = format!(
"{} · PID {pid} · {} · {user} · 内存 {memory} · CPU {cpu}",
process.name,
process.state.label(),
let label = t!(
"processes.card.label",
name = process.name,
pid = pid,
state = process.state.label(),
user = user,
memory = memory,
cpu = cpu
);
let state: SharedString = process.state.label().into();
let state = process.state.label();
let numeric = |text: String| {
div()
.font_family(cx.theme().mono_font_family.clone())
@@ -652,7 +664,11 @@ fn render_process(
.flex_1()
.min_w_0()
.gap_1()
.child(div().text_color(cx.theme().muted_foreground).child("内存"))
.child(
div()
.text_color(cx.theme().muted_foreground)
.child(t!("processes.memory")),
)
.child(numeric(memory).text_color(cx.theme().info)),
)
.child(
+5 -4
View File
@@ -4,6 +4,7 @@
use std::collections::HashMap;
use super::model::{Service, ServiceCommand, ServiceStatus, ServiceTable};
use crate::i18n::t;
/// What the list asks `systemctl show` for each service.
const LIST_PROPERTIES: [&str; 6] = [
@@ -102,7 +103,7 @@ pub fn control_command(name: &str, command: ServiceCommand) -> Option<String> {
pub fn controlled(output: &str) -> Result<(), String> {
let (said, status) = output
.rsplit_once("@@status")
.ok_or_else(|| "主机没有回答".to_string())?;
.ok_or_else(|| t!("tools.no_answer").to_string())?;
if status.trim() == "0" {
return Ok(());
}
@@ -113,11 +114,11 @@ pub fn controlled(output: &str) -> Result<(), String> {
|| said.contains("Access denied")
|| said.contains("not in the sudoers")
{
"需要 root 权限:以 root 登录,或给这个用户配置免密码的 sudo".into()
t!("services.error.needs_root").into()
} else if said.contains("command not found") && said.contains("sudo") {
"需要 root 权限:这台主机没有 sudo,请以 root 登录".into()
t!("services.error.needs_root_no_sudo").into()
} else if said.is_empty() {
format!("systemctl 返回 {}", status.trim())
t!("services.error.status", status = status.trim()).into()
} else {
said.lines().next().unwrap_or(said).trim().to_owned()
},
+69 -75
View File
@@ -1,6 +1,10 @@
//! What 系统服务 reads off a host, and how its tabs, groups and search
//! pick from it.
use gpui_kit::SharedString;
use crate::i18n::t;
/// Where a service's unit file lives, which the list groups by.
const CUSTOM_UNITS: &str = "/etc/systemd/system/";
@@ -29,14 +33,14 @@ impl ActiveState {
}
}
pub fn label(self) -> &'static str {
pub fn label(self) -> SharedString {
match self {
ActiveState::Active => "运行中",
ActiveState::Reloading => "正在重载",
ActiveState::Inactive | ActiveState::Other => "已停止",
ActiveState::Failed => "失败",
ActiveState::Activating => "正在启动",
ActiveState::Deactivating => "正在停止",
ActiveState::Active => t!("services.active.active"),
ActiveState::Reloading => t!("services.active.reloading"),
ActiveState::Inactive | ActiveState::Other => t!("services.active.inactive"),
ActiveState::Failed => t!("services.active.failed"),
ActiveState::Activating => t!("services.active.activating"),
ActiveState::Deactivating => t!("services.active.deactivating"),
}
}
@@ -62,70 +66,69 @@ pub enum ServiceKind {
/// The finer state under `ActiveState`, as systemd's `SubState` says: 「已退出」
/// for a one-shot service that did its work.
pub fn sub_state_label(state: &str) -> String {
pub fn sub_state_label(state: &str) -> SharedString {
match state {
"running" => "运行中",
"exited" => "已退出",
"dead" => "未运行",
"failed" => "失败",
"waiting" => "等待中",
"listening" => "监听中",
"reload" => "正在重载",
"auto-restart" => "等待自动重启",
"condition" => "条件不满足",
state if state.starts_with("start") => "正在启动",
state if state.starts_with("stop") || state.starts_with("final") => "正在停止",
state => return state.to_owned(),
"running" => t!("services.sub.running"),
"exited" => t!("services.sub.exited"),
"dead" => t!("services.sub.dead"),
"failed" => t!("services.sub.failed"),
"waiting" => t!("services.sub.waiting"),
"listening" => t!("services.sub.listening"),
"reload" => t!("services.sub.reload"),
"auto-restart" => t!("services.sub.auto_restart"),
"condition" => t!("services.sub.condition"),
state if state.starts_with("start") => t!("services.sub.starting"),
state if state.starts_with("stop") || state.starts_with("final") => {
t!("services.sub.stopping")
}
state => state.to_owned().into(),
}
.to_owned()
}
/// Whether it starts at boot, as systemd's `UnitFileState` says; `None`
/// with nothing to say.
pub fn file_state_label(state: &str) -> Option<&'static str> {
pub fn file_state_label(state: &str) -> Option<SharedString> {
Some(match state {
"enabled" => "已启用",
"enabled-runtime" => "已启用(本次运行)",
"disabled" => "已禁用",
"static" => "静态",
"masked" | "masked-runtime" => "已屏蔽",
"generated" => "自动生成",
"indirect" => "间接启用",
"linked" | "linked-runtime" => "已链接",
"transient" => "临时",
"alias" => "别名",
"bad" => "出错",
"enabled" => t!("services.file.enabled"),
"enabled-runtime" => t!("services.file.enabled_runtime"),
"disabled" => t!("services.file.disabled"),
"static" => t!("services.file.static"),
"masked" | "masked-runtime" => t!("services.file.masked"),
"generated" => t!("services.file.generated"),
"indirect" => t!("services.file.indirect"),
"linked" | "linked-runtime" => t!("services.file.linked"),
"transient" => t!("services.file.transient"),
"alias" => t!("services.file.alias"),
"bad" => t!("services.file.bad"),
_ => return None,
})
}
/// Whether the unit file was found and read, as systemd's `LoadState` says.
pub fn load_state_label(state: &str) -> String {
pub fn load_state_label(state: &str) -> SharedString {
match state {
"loaded" => "已加载",
"not-found" => "未找到",
"masked" => "已屏蔽",
"error" => "出错",
"bad-setting" => "配置有误",
state => return state.to_owned(),
"loaded" => t!("services.load.loaded"),
"not-found" => t!("services.load.not_found"),
"masked" => t!("services.load.masked"),
"error" => t!("services.load.error"),
"bad-setting" => t!("services.load.bad_setting"),
state => state.to_owned().into(),
}
.to_owned()
}
/// How the machine as a whole is doing, as `systemctl is-system-running`
/// says: 「降级运行」 when some unit failed.
pub fn system_state_label(state: &str) -> String {
pub fn system_state_label(state: &str) -> SharedString {
match state {
"running" => "运行正常",
"degraded" => "降级运行",
"starting" => "正在启动",
"initializing" => "正在初始化",
"maintenance" => "维护模式",
"stopping" => "正在关机",
"offline" => "离线",
state => return state.to_owned(),
"running" => t!("services.system.running"),
"degraded" => t!("services.system.degraded"),
"starting" => t!("services.system.starting"),
"initializing" => t!("services.system.initializing"),
"maintenance" => t!("services.system.maintenance"),
"stopping" => t!("services.system.stopping"),
"offline" => t!("services.system.offline"),
state => state.to_owned().into(),
}
.to_owned()
}
/// What can be done to a service.
@@ -153,24 +156,13 @@ impl ServiceCommand {
}
/// The button: 「停止」「禁用开机启动」.
pub fn label(self) -> &'static str {
pub fn label(self) -> SharedString {
match self {
ServiceCommand::Start => "启动",
ServiceCommand::Stop => "停止",
ServiceCommand::Restart => "重启",
ServiceCommand::Enable => "启用开机启动",
ServiceCommand::Disable => "禁用开机启动",
}
}
/// What it did, after the service's name: 「已停止」.
pub fn done(self) -> &'static str {
match self {
ServiceCommand::Start => "已启动",
ServiceCommand::Stop => "已停止",
ServiceCommand::Restart => "已重启",
ServiceCommand::Enable => "已设为开机启动",
ServiceCommand::Disable => "已取消开机启动",
ServiceCommand::Start => t!("services.command.start"),
ServiceCommand::Stop => t!("services.command.stop"),
ServiceCommand::Restart => t!("services.command.restart"),
ServiceCommand::Enable => t!("services.command.enable"),
ServiceCommand::Disable => t!("services.command.disable"),
}
}
}
@@ -243,12 +235,12 @@ impl ServiceFilter {
ServiceFilter::Failed,
];
pub fn label(self) -> &'static str {
pub fn label(self) -> SharedString {
match self {
ServiceFilter::All => "全部",
ServiceFilter::Running => "运行中",
ServiceFilter::Stopped => "已停止",
ServiceFilter::Failed => "失败",
ServiceFilter::All => t!("services.filter.all"),
ServiceFilter::Running => t!("services.filter.running"),
ServiceFilter::Stopped => t!("services.filter.stopped"),
ServiceFilter::Failed => t!("services.filter.failed"),
}
}
@@ -303,7 +295,9 @@ impl ServiceTable {
pub fn summary(&self) -> String {
[
self.version.clone(),
self.state.as_deref().map(system_state_label),
self.state
.as_deref()
.map(|state| system_state_label(state).into()),
]
.into_iter()
.flatten()
@@ -525,7 +519,7 @@ mod tests {
assert_eq!(sub_state_label("exited"), "已退出");
assert_eq!(sub_state_label("stop-sigterm"), "正在停止");
assert_eq!(sub_state_label("mounted"), "mounted");
assert_eq!(file_state_label("generated"), Some("自动生成"));
assert_eq!(file_state_label("generated"), Some("自动生成".into()));
assert_eq!(file_state_label(""), None);
assert_eq!(load_state_label("loaded"), "已加载");
assert_eq!(system_state_label("running"), "运行正常");
+35 -26
View File
@@ -18,6 +18,7 @@ use super::model::{
};
use super::service_panel::{command_icon, state_color};
use crate::app::{CatalogIcon, ControlService};
use crate::i18n::t;
use crate::shared::{format_bytes, soft_tag};
use crate::terminal::{ExecResult, TerminalView, exec_answer};
@@ -131,7 +132,7 @@ impl ServiceDetailsView {
this.status = match answer {
Ok(output) => match parse_status(&output) {
Some(status) => Read::Known(status),
None => Read::Unknown("读不到这个服务的状态".into()),
None => Read::Unknown(t!("services.details.unreadable").into()),
},
Err(why) => Read::Unknown(why),
};
@@ -173,7 +174,9 @@ impl ServiceDetailsView {
cx,
|this, answer, _| {
this.journal = match answer {
Ok(output) if output.trim().is_empty() => Read::Unknown("没有日志".into()),
Ok(output) if output.trim().is_empty() => {
Read::Unknown(t!("services.details.no_journal").into())
}
Ok(output) => Read::Known(output.trim_end().to_owned()),
Err(why) => Read::Unknown(why),
};
@@ -193,34 +196,38 @@ impl ServiceDetailsView {
};
let active = ActiveState::parse(&status.active);
let number = |value: Option<u64>| value.map_or("—".to_owned(), |value| value.to_string());
let fields: [(&str, String); 10] = [
("加载状态", load_state_label(&status.load)),
let fields: [(&'static str, String); 10] = [
("services.field.load", load_state_label(&status.load).into()),
(
"运行状态",
"services.field.active",
format!("{} / {}", active.label(), sub_state_label(&status.sub)),
),
(
"开机启动",
file_state_label(&status.file_state)
.unwrap_or("—")
.to_owned(),
"services.field.boot",
file_state_label(&status.file_state).map_or("—".into(), Into::into),
),
(
"主进程 PID",
"services.field.main_pid",
status.main_pid.map_or("—".into(), |pid| pid.to_string()),
),
("内存", status.memory.map_or("—".into(), format_bytes)),
("任务数", number(status.tasks)),
("重启次数", number(status.restarts)),
(
"退出状态",
"services.field.memory",
status.memory.map_or("—".into(), format_bytes),
),
("services.field.tasks", number(status.tasks)),
("services.field.restarts", number(status.restarts)),
(
"services.field.exit_status",
status
.exit_status
.map_or("—".into(), |status| status.to_string()),
),
("启动时间", status.started.clone().unwrap_or("—".into())),
(
"停止时间",
"services.field.started",
status.started.clone().unwrap_or("—".into()),
),
(
"services.field.stopped",
// When it last went down; while it runs, that is history.
status
.stopped
@@ -238,7 +245,7 @@ impl ServiceDetailsView {
),
)
.child(field(
"Unit 文件",
"services.field.unit_file",
status.path.clone().unwrap_or("—".into()),
cx,
))
@@ -282,10 +289,10 @@ fn run(
let reply = command.and_then(|command| terminal.upgrade()?.read(cx).exec(command, cx));
cx.spawn(async move |this, cx| {
let answer: ExecResult = match reply {
None => Err("终端没有连接".into()),
None => Err(t!("tools.not_connected").into()),
Some(reply) => match exec_answer(reply, cx).await {
None => Err("终端没有连接".into()),
Some(Err(error)) => Err(format!("读取失败:{error}")),
None => Err(t!("tools.not_connected").into()),
Some(Err(error)) => Err(t!("tools.read_failed", error = error).into()),
Some(Ok(output)) => Ok(output),
},
};
@@ -297,10 +304,12 @@ fn run(
})
}
/// A label and its value, over a line.
/// A label, by its text's key, and its value, over a line. The line is
/// found by the last part of the key: 「service-field:main_pid」.
fn field(label: &'static str, value: String, cx: &App) -> impl IntoElement {
let id = label.rsplit('.').next().unwrap_or(label);
h_flex()
.id(SharedString::from(format!("service-field:{label}")))
.id(SharedString::from(format!("service-field:{id}")))
.test_support()
.aria_label(value.clone())
.min_w_0()
@@ -314,7 +323,7 @@ fn field(label: &'static str, value: String, cx: &App) -> impl IntoElement {
.w(rems(6.5))
.flex_shrink_0()
.text_color(cx.theme().muted_foreground)
.child(label),
.child(t!(label)),
)
.child(div().flex_1().min_w_0().truncate().child(value))
}
@@ -329,7 +338,7 @@ fn reading(id: &'static str, cx: &App) -> AnyElement {
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(Spinner::new().small())
.child("正在读取…")
.child(t!("tools.reading"))
.into_any_element()
}
@@ -362,12 +371,12 @@ impl Render for ServiceDetailsView {
.child(
Tab::new()
.icon(Icon::new(CatalogIcon::CircleDot))
.label("状态"),
.label(t!("services.details.tab_status")),
)
.child(
Tab::new()
.icon(Icon::new(CatalogIcon::FileText))
.label("日志"),
.label(t!("services.details.tab_journal")),
),
),
)
+30 -25
View File
@@ -22,6 +22,7 @@ use super::model::{
ActiveState, Service, ServiceCommand, ServiceFilter, ServiceRow, ServiceTable, file_state_label,
};
use crate::app::{CatalogIcon, ControlService, RefreshServices, ShowService};
use crate::i18n::{UiLocale, t};
use crate::shared::{soft_tag, tinted};
use crate::terminal::{ExecResult, ExecTarget, RemoteTerminalId, exec_answer};
@@ -87,10 +88,15 @@ impl ServicePanel {
pub fn new(dispatch: FocusHandle, window: &mut Window, cx: &mut Context<Self>) -> Self {
let search = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("搜索服务名称或描述")
.placeholder(t!("services.panel.search"))
.clean_on_escape()
});
let subscriptions = vec![
cx.observe_global_in::<UiLocale>(window, |this, window, cx| {
this.search.update(cx, |search, cx| {
search.set_placeholder(t!("services.panel.search"), window, cx)
});
}),
cx.subscribe(&search, |this, input, event: &InputEvent, cx| {
if matches!(event, InputEvent::Change) {
this.query = input.read(cx).value().to_string();
@@ -267,8 +273,8 @@ impl ServicePanel {
.xsmall()
.icon(Icon::new(CatalogIcon::RefreshCw))
.loading(self.loading)
.tooltip("刷新")
.accessibility_label("刷新")
.tooltip(t!("tools.refresh"))
.accessibility_label(t!("tools.refresh"))
.on_click(move |_, window, cx| {
refresh.dispatch_action(&RefreshServices, window, cx)
}),
@@ -324,9 +330,9 @@ impl ServicePanel {
let clear_hit = self.menu_hit.clone();
let menu_hit = self.menu_hit.clone();
let empty = if self.query.trim().is_empty() {
"没有服务"
t!("services.panel.empty")
} else {
"没有符合条件的服务"
t!("services.panel.no_match")
};
// The scrollbar goes on the list's box, which does not scroll: on
// the scrolled lines it would scroll away with them. The menu hangs
@@ -344,7 +350,7 @@ impl ServicePanel {
div()
.id("services-empty")
.test_support()
.aria_label(empty)
.aria_label(empty.clone())
.py_8()
.text_sm()
.text_center()
@@ -427,7 +433,7 @@ impl Render for ServicePanel {
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(Spinner::new().small())
.child("正在读取…")
.child(t!("tools.reading"))
.into_any_element(),
};
v_flex()
@@ -442,22 +448,21 @@ impl Render for ServicePanel {
fn render_problem(problem: &Problem, cx: &App) -> impl IntoElement {
let (text, color) = match problem {
Problem::NotConnected => (
"终端没有连接。连接后这里显示主机的系统服务。".to_string(),
cx.theme().muted_foreground,
),
Problem::Unsupported(Some(system)) if system == "Linux" => (
"这台主机没有使用 systemd,暂不支持管理它的服务。".to_string(),
t!("services.panel.not_connected"),
cx.theme().muted_foreground,
),
Problem::Unsupported(Some(system)) if system == "Linux" => {
(t!("services.panel.no_systemd"), cx.theme().muted_foreground)
}
Problem::Unsupported(Some(system)) => (
format!("暂不支持管理 {system} 的服务,目前只支持使用 systemd 的 Linux 主机。"),
t!("services.panel.unsupported_system", system = system),
cx.theme().muted_foreground,
),
Problem::Unsupported(None) => (
"暂不支持管理这台主机的服务,目前只支持使用 systemd 的 Linux 主机。".to_string(),
t!("services.panel.unsupported"),
cx.theme().muted_foreground,
),
Problem::Failed(error) => (format!("读取失败:{error}"), cx.theme().danger),
Problem::Failed(error) => (t!("tools.read_failed", error = error), cx.theme().danger),
};
div()
.id("services-message")
@@ -480,7 +485,7 @@ fn build_context_menu(hit: Option<&Service>, menu: PopupMenu) -> PopupMenu {
};
let menu = menu
.menu_with_icon(
"查看详情",
t!("tools.menu.details"),
Icon::new(IconName::Info),
Box::new(ShowService(service.name.clone())),
)
@@ -521,9 +526,9 @@ pub(super) fn state_color(state: ActiveState, cx: &App) -> Hsla {
/// 「自定义服务 3」.
fn render_group(custom: bool, count: usize, cx: &App) -> impl IntoElement {
let label = if custom {
"自定义服务"
t!("services.group.custom")
} else {
"系统服务"
t!("services.group.system")
};
h_flex()
.id(if custom {
@@ -599,10 +604,10 @@ fn render_service(
let color = state_color(service.active, cx);
let boot = file_state_label(&service.file_state);
let label = [
Some(service.name.as_str()),
Some(service.description.as_str()).filter(|text| !text.is_empty()),
Some(SharedString::from(service.name.clone())),
Some(SharedString::from(service.description.clone())).filter(|text| !text.is_empty()),
Some(service.active.label()),
boot,
boot.clone(),
]
.into_iter()
.flatten()
@@ -663,9 +668,9 @@ fn render_service(
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(if service.description.is_empty() {
"无描述".to_owned()
t!("services.panel.no_description")
} else {
service.description.clone()
service.description.clone().into()
}),
)
.child(
@@ -703,8 +708,8 @@ fn render_service(
.ghost()
.small()
.icon(IconName::Ellipsis)
.tooltip("详情和日志")
.accessibility_label("详情和日志")
.tooltip(t!("tools.details_and_logs"))
.accessibility_label(t!("tools.details_and_logs"))
.on_click(move |_, window, cx| {
cx.stop_propagation();
dispatch.dispatch_action(&ShowService(name.clone()), window, cx)
+25 -20
View File
@@ -10,11 +10,9 @@ use gpui_kit::component::{
use gpui_kit::*;
use crate::host::{HostStore, SnippetCategoryId, SnippetDraft, SnippetId, SnippetScope, by_name};
use crate::i18n::t;
use crate::shared::{commit_footer, dismiss_form_error, form_error_notification};
/// The category list's row for a snippet of none.
const NO_CATEGORY: &str = "未分类";
/// How wide the snippet dialog is: room for a command of a fair length on
/// one line.
const DIALOG_WIDTH: f32 = 36.;
@@ -71,7 +69,8 @@ impl SnippetForm {
};
categories.sort_by(|a, b| by_name((&a.1, a.0.0), (&b.1, b.0.0)));
let mut category_ids: Vec<Option<SnippetCategoryId>> = vec![None];
let mut labels: Vec<SharedString> = vec![NO_CATEGORY.into()];
// The category list's row for a snippet of none.
let mut labels: Vec<SharedString> = vec![t!("snippets.uncategorized")];
for (id, name) in categories {
category_ids.push(Some(id));
labels.push(name);
@@ -83,7 +82,7 @@ impl SnippetForm {
let name = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("例如 查看容器")
.placeholder(t!("snippets.dialog.name_placeholder"))
.default_value(draft.name.clone())
});
let category =
@@ -91,7 +90,7 @@ impl SnippetForm {
let command = cx.new(|cx| {
TextareaState::new(window, cx)
.auto_grow(4, 12)
.placeholder("例如 docker ps -a")
.placeholder(t!("snippets.dialog.command_placeholder"))
.default_value(draft.command.clone())
});
Self {
@@ -146,12 +145,12 @@ impl Render for SnippetForm {
Form::new()
.child(
Field::new()
.label("名称")
.label(t!("snippets.dialog.name"))
.required(true)
.child(Input::new(&self.name).id("snippet-name").small()),
)
.child(
Field::new().label("分类").child(
Field::new().label(t!("snippets.dialog.category")).child(
div()
.id("snippet-category")
.test_support()
@@ -161,9 +160,9 @@ impl Render for SnippetForm {
)
.child(
Field::new()
.label("命令")
.label(t!("snippets.dialog.command"))
.required(true)
.description("可以有多行。")
.description(t!("snippets.dialog.command_description"))
.child(
div().id("snippet-command").test_support().w_full().child(
Textarea::new(&self.command)
@@ -175,7 +174,7 @@ impl Render for SnippetForm {
.child(
Field::new().child(
Checkbox::new("snippet-run-on-click")
.label("点击时自动执行")
.label(t!("snippets.dialog.run_on_click"))
.checked(self.run_on_click)
.small()
.on_click(cx.listener(|this, checked: &bool, _, cx| {
@@ -186,7 +185,7 @@ impl Render for SnippetForm {
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("启用后将自动执行命令,否则只将内容填入终端"),
.child(t!("snippets.dialog.run_on_click_description")),
),
),
)
@@ -203,9 +202,12 @@ pub fn open_snippet_dialog(
let form = cx.new(|cx| SnippetForm::new(dialog, store, window, cx));
let editing = form.read(cx).editing.is_some();
let (title, commit): (SharedString, SharedString) = if editing {
("编辑命令片段".into(), "保存".into())
(t!("snippets.dialog.edit_title"), t!("common.save"))
} else {
("新建命令片段".into(), "创建".into())
(
t!("snippets.dialog.new_title"),
t!("snippets.dialog.create"),
)
};
window.open_dialog(cx, {
let form = form.clone();
@@ -262,7 +264,7 @@ impl CategoryForm {
};
let name = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("例如 Docker")
.placeholder(t!("snippets.category.name_placeholder"))
.default_value(current)
});
Self {
@@ -282,9 +284,9 @@ impl CategoryForm {
.iter()
.any(|category| category.name == name.as_str() && Some(category.id) != editing);
let error = if name.is_empty() {
Some("请输入分类名称")
Some(t!("snippets.category.name_missing"))
} else if taken {
Some("已有同名分类")
Some(t!("snippets.category.name_taken"))
} else {
None
};
@@ -308,7 +310,7 @@ impl Render for CategoryForm {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
Form::new().child(
Field::new()
.label("名称")
.label(t!("snippets.category.name"))
.required(true)
.child(Input::new(&self.name).id("snippet-category-name").small()),
)
@@ -325,9 +327,12 @@ pub fn open_category_dialog(
let form = cx.new(|cx| CategoryForm::new(dialog, store, window, cx));
let renaming = form.read(cx).editing.is_some();
let (title, commit): (SharedString, SharedString) = if renaming {
("重命名分类".into(), "保存".into())
(t!("snippets.category.rename_title"), t!("common.save"))
} else {
("新建分类".into(), "创建".into())
(
t!("snippets.category.new_title"),
t!("snippets.dialog.create"),
)
};
window.open_dialog(cx, {
let form = form.clone();
+52 -31
View File
@@ -20,11 +20,9 @@ use crate::app::{
NewSnippet, NewSnippetCategory, NewSnippetIn, RenameSnippetCategory, ToggleSnippetCategory,
};
use crate::host::{HostStore, Snippet, SnippetCategoryId, SnippetId};
use crate::i18n::{UiLocale, t, tn};
use crate::shared::{command_tooltip, one_line};
/// What 未分类 is called, as a category's heading.
const UNCATEGORIZED: &str = "未分类";
/// 命令片段: the commands kept for every host, in categories one level
/// deep. A click puts one on the input line of the SSH terminal in front to
/// edit; 执行 runs it there.
@@ -68,11 +66,16 @@ impl SnippetPanel {
) -> Self {
let search = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("搜索名称或命令")
.placeholder(t!("snippets.panel.search"))
.clean_on_escape()
});
let subscriptions = vec![
cx.observe(&store, |_, _, cx| cx.notify()),
cx.observe_global_in::<UiLocale>(window, |this, window, cx| {
this.search.update(cx, |search, cx| {
search.set_placeholder(t!("snippets.panel.search"), window, cx)
});
}),
cx.subscribe(&search, |this, input, event: &InputEvent, cx| {
if matches!(event, InputEvent::Change) {
this.query = input.read(cx).value().to_string();
@@ -116,10 +119,15 @@ impl SnippetPanel {
}
fn render_header(&self, snippets: usize, categories: usize, cx: &App) -> impl IntoElement {
let summary = if categories == 0 {
format!("共 {snippets} 个片段")
let summary: SharedString = if categories == 0 {
tn!("snippets.panel.count", snippets)
} else {
format!("共 {snippets} 个片段 · {categories} 个分类")
format!(
"{} · {}",
tn!("snippets.panel.count", snippets),
tn!("snippets.panel.categories", categories)
)
.into()
};
let (new_category, new_snippet) = (self.dispatch.clone(), self.dispatch.clone());
v_flex()
@@ -148,8 +156,8 @@ impl SnippetPanel {
.ghost()
.xsmall()
.icon(Icon::new(IconName::Plus))
.tooltip("新建片段")
.accessibility_label("新建片段")
.tooltip(t!("snippets.panel.new_snippet"))
.accessibility_label(t!("snippets.panel.new_snippet"))
.on_click(move |_, window, cx| {
new_snippet.dispatch_action(&NewSnippet, window, cx)
}),
@@ -159,8 +167,8 @@ impl SnippetPanel {
.ghost()
.xsmall()
.icon(Icon::new(CatalogIcon::FolderPlus))
.tooltip("新建分类")
.accessibility_label("新建分类")
.tooltip(t!("snippets.panel.new_category"))
.accessibility_label(t!("snippets.panel.new_category"))
.on_click(move |_, window, cx| {
new_category.dispatch_action(&NewSnippetCategory, window, cx)
}),
@@ -182,18 +190,18 @@ impl SnippetPanel {
v_flex()
.id("snippets-empty")
.test_support()
.aria_label("还没有命令片段")
.aria_label(t!("snippets.panel.empty"))
.items_center()
.gap_2()
.px_4()
.py_8()
.child(div().text_sm().child("还没有命令片段"))
.child(div().text_sm().child(t!("snippets.panel.empty")))
.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().muted_foreground)
.child("把常用的命令存成片段,点一下就输入到当前终端。"),
.child(t!("snippets.panel.empty_description")),
)
.child(
Button::new("snippets-empty-new")
@@ -201,7 +209,7 @@ impl SnippetPanel {
.primary()
.mt_2()
.icon(Icon::new(IconName::Plus))
.label("新建片段")
.label(t!("snippets.panel.new_snippet"))
.on_click(move |_, window, cx| {
dispatch.dispatch_action(&NewSnippet, window, cx)
}),
@@ -255,12 +263,12 @@ impl Render for SnippetPanel {
if nothing {
list.child(self.render_empty(cx))
} else if rows.is_empty() {
let empty = "没有符合条件的片段";
let empty = t!("snippets.panel.no_match");
list.child(
div()
.id("snippets-no-match")
.test_support()
.aria_label(empty)
.aria_label(empty.clone())
.py_8()
.text_sm()
.text_center()
@@ -281,7 +289,10 @@ impl Render for SnippetPanel {
SnippetRow::Category { id, count, folded } => {
let name = id
.and_then(|id| names.iter().find(|(each, _)| *each == id))
.map_or(UNCATEGORIZED.into(), |(_, name)| name.clone());
.map_or_else(
|| t!("snippets.uncategorized"),
|(_, name)| name.clone(),
);
render_category(
Heading {
id,
@@ -354,24 +365,28 @@ impl Render for SnippetPanel {
/// is then.
fn build_context_menu(hit: Option<MenuHit>, store: &HostStore, menu: PopupMenu) -> PopupMenu {
let new_snippet = |menu: PopupMenu, action: Box<dyn Action>| {
menu.menu_with_icon("新建片段…", Icon::new(IconName::Plus), action)
menu.menu_with_icon(
t!("snippets.menu.new_snippet"),
Icon::new(IconName::Plus),
action,
)
};
match hit {
None => new_snippet(menu, Box::new(NewSnippet)).menu_with_icon(
"新建分类…",
t!("snippets.menu.new_category"),
Icon::new(CatalogIcon::FolderPlus),
Box::new(NewSnippetCategory),
),
Some(MenuHit::Category(None)) => new_snippet(menu, Box::new(NewSnippet)),
Some(MenuHit::Category(Some(id))) => new_snippet(menu, Box::new(NewSnippetIn(id)))
.menu_with_icon(
"重命名分类…",
t!("snippets.menu.rename_category"),
Icon::new(CatalogIcon::Pencil),
Box::new(RenameSnippetCategory(id)),
)
.separator()
.menu_with_icon(
"删除分类…",
t!("snippets.menu.delete_category"),
Icon::new(CatalogIcon::Trash),
Box::new(DeleteSnippetCategory(id)),
),
@@ -381,7 +396,7 @@ fn build_context_menu(hit: Option<MenuHit>, store: &HostStore, menu: PopupMenu)
};
let command = snippet.command.clone();
menu.menu_with_icon(
"输入到终端",
t!("tools.menu.insert"),
Icon::new(CatalogIcon::SquareTerminal),
Box::new(EnterCommand {
command: command.clone(),
@@ -389,7 +404,7 @@ fn build_context_menu(hit: Option<MenuHit>, store: &HostStore, menu: PopupMenu)
}),
)
.menu_with_icon(
"执行",
t!("tools.run"),
Icon::new(CatalogIcon::Play),
Box::new(EnterCommand {
command: command.clone(),
@@ -397,18 +412,18 @@ fn build_context_menu(hit: Option<MenuHit>, store: &HostStore, menu: PopupMenu)
}),
)
.menu_with_icon(
"复制命令",
t!("snippets.menu.copy"),
Icon::new(IconName::Copy),
Box::new(CopyCommand(command)),
)
.separator()
.menu_with_icon(
"编辑…",
t!("snippets.menu.edit"),
Icon::new(CatalogIcon::Pencil),
Box::new(EditSnippet(id)),
)
.menu_with_icon(
"删除…",
t!("snippets.menu.delete"),
Icon::new(CatalogIcon::Trash),
Box::new(DeleteSnippet(id)),
)
@@ -520,7 +535,11 @@ fn render_snippet(
.test_support()
.group(card.clone())
.aria_label(if runs {
format!("{} · {line} · 点击时自动执行", snippet.name)
format!(
"{} · {line} · {}",
snippet.name,
t!("snippets.card.runs_on_click")
)
} else {
format!("{} · {line}", snippet.name)
})
@@ -587,12 +606,14 @@ fn render_snippet(
.icon(
Icon::new(CatalogIcon::Zap).text_color(cx.theme().warning),
)
.tooltip("点击时自动执行")
.tooltip(t!("snippets.card.runs_on_click"))
} else {
button.icon(Icon::new(CatalogIcon::Play)).tooltip("执行")
button
.icon(Icon::new(CatalogIcon::Play))
.tooltip(t!("tools.run"))
}
})
.accessibility_label("执行")
.accessibility_label(t!("tools.run"))
.on_click(move |_, window, cx| {
// Not the card's click too, which would run it
// again or only type it.
+9 -8
View File
@@ -11,6 +11,7 @@ use crate::app::{CatalogIcon, DOCKER_ICON, ToggleTool, ToolKind};
use crate::docker::{Container, DockerObject, DockerPanel, ObjectSummary};
use crate::history::HistoryPanel;
use crate::host::{HostId, HostOs, HostStore, SnippetCategoryId};
use crate::i18n::t;
use crate::monitor::{MonitorDetail, MonitorPanel};
use crate::netstat::NetstatPanel;
use crate::processes::{Process, ProcessDetails, ProcessPanel, ProcessSort};
@@ -19,15 +20,15 @@ use crate::snippets::SnippetPanel;
use crate::terminal::{ExecTarget, RemoteTerminalId, TerminalView};
impl ToolKind {
pub fn label(self) -> &'static str {
pub fn label(self) -> SharedString {
match self {
ToolKind::Snippets => "命令片段",
ToolKind::History => "历史命令",
ToolKind::Docker => "Docker",
ToolKind::Services => "系统服务",
ToolKind::Processes => "进程管理",
ToolKind::Connections => "网络连接",
ToolKind::Monitor => "系统监控",
ToolKind::Snippets => t!("tools.kind.snippets"),
ToolKind::History => t!("tools.kind.history"),
ToolKind::Docker => "Docker".into(),
ToolKind::Services => t!("tools.kind.services"),
ToolKind::Processes => t!("tools.kind.processes"),
ToolKind::Connections => t!("tools.kind.connections"),
ToolKind::Monitor => t!("tools.kind.monitor"),
}
}
+194 -72
View File
@@ -25,9 +25,11 @@ use crate::app::{
ToggleDockerProject, ToggleMonitorDetail, ToggleTool, ToggleToolSidebar, ToolKind,
};
use crate::docker::{
self, ContainerCommand, DockerObject, open_container_dialog, open_object_dialog,
self, ContainerCommand, ContainerSubject, DockerObject, open_container_dialog,
open_object_dialog,
};
use crate::host::HostOs;
use crate::i18n::t;
use crate::processes::{end_command, ended, open_process_dialog};
use crate::services::{ServiceCommand, control_command, controlled, open_service_dialog};
use crate::shared::confirm_danger;
@@ -226,8 +228,12 @@ impl Workspace {
});
}
Err(why) => {
let verb = if *run { "执行" } else { "输入" };
window.push_notification(Notification::error(format!("无法{verb}命令:{why}")), cx);
let message = if *run {
t!("tools.command.run_failed", error = why)
} else {
t!("tools.command.enter_failed", error = why)
};
window.push_notification(Notification::error(message), cx);
}
}
}
@@ -240,7 +246,7 @@ impl Workspace {
cx: &mut Context<Self>,
) {
cx.write_to_clipboard(ClipboardItem::new_string(action.0.clone()));
window.push_notification(Notification::success("已复制命令"), cx);
window.push_notification(Notification::success(t!("tools.command.copied")), cx);
}
/// Docker's 刷新: read the host's Docker again.
@@ -307,12 +313,13 @@ impl Workspace {
let run = Rc::new({
let subject = subject.clone();
move |window: &mut Window, cx: &mut App| {
let failed = subject.clone();
run_on_host(
HostCommand {
script: docker::control_command(&ids, command),
outcome: docker::done,
done: format!("{subject}{}", command.done()),
failed: format!("无法{}{subject}", command.label()),
done: containers_done(&subject, command),
failed: Box::new(move |why| containers_failed(&failed, command, why)),
then: ToolSidebar::refresh_docker,
},
&terminal,
@@ -322,19 +329,26 @@ impl Workspace {
)
}
});
let description = match command {
ContainerCommand::Start => return run(window, cx),
ContainerCommand::Stop => "停止后它提供的服务就不可用了,直到再次启动。",
ContainerCommand::Restart => "会先停止再启动,中间短暂不可用。",
let (title, description) = match (&subject, command) {
(_, ContainerCommand::Start) => return run(window, cx),
(ContainerSubject::Container(name), ContainerCommand::Stop) => (
t!("tools.container.stop.title", name = name),
t!("tools.container.stop.description"),
),
(ContainerSubject::Project(name), ContainerCommand::Stop) => (
t!("tools.project.stop.title", name = name),
t!("tools.project.stop.description"),
),
(ContainerSubject::Container(name), ContainerCommand::Restart) => (
t!("tools.container.restart.title", name = name),
t!("tools.container.restart.description"),
),
(ContainerSubject::Project(name), ContainerCommand::Restart) => (
t!("tools.project.restart.title", name = name),
t!("tools.project.restart.description"),
),
};
confirm_danger(
format!("{}{subject}?", command.label()).into(),
Some(description.into()),
command.label(),
run,
window,
cx,
);
confirm_danger(title, Some(description), command.label(), run, window, cx);
}
/// Ask, then remove a container, an image, a volume or a network.
@@ -348,25 +362,37 @@ impl Workspace {
return;
};
let RemoveDockerObject { object, id, name } = action.clone();
let subject = format!("{}“{name}”", object.label());
let description = match object {
DockerObject::Container => "容器和它里面没有存进卷的数据会一起删除,不能恢复。",
DockerObject::Image => "删除后再要用它,需要重新拉取或构建。",
DockerObject::Volume => "卷里的数据会一起删除,不能恢复。",
DockerObject::Network => "删除后再要用它,需要重新创建。",
let (title, description) = match object {
DockerObject::Container => (
t!("tools.remove.container.title", name = name),
t!("tools.remove.container.description"),
),
DockerObject::Image => (
t!("tools.remove.image.title", name = name),
t!("tools.remove.image.description"),
),
DockerObject::Volume => (
t!("tools.remove.volume.title", name = name),
t!("tools.remove.volume.description"),
),
DockerObject::Network => (
t!("tools.remove.network.title", name = name),
t!("tools.remove.network.description"),
),
};
let workspace = cx.entity().downgrade();
confirm_danger(
format!("删除{subject}?").into(),
Some(description.into()),
"删除",
title,
Some(description),
t!("common.delete"),
Rc::new(move |window, cx| {
let failed = name.clone();
run_on_host(
HostCommand {
script: docker::remove_command(object, &id),
outcome: docker::done,
done: format!("{subject}已删除"),
failed: format!("无法删除{subject}"),
done: removed(object, &name),
failed: Box::new(move |why| not_removed(object, &failed, why)),
then: ToolSidebar::refresh_docker,
},
&terminal,
@@ -430,12 +456,13 @@ impl Workspace {
let run = Rc::new({
let name = name.clone();
move |window: &mut Window, cx: &mut App| {
let failed = name.clone();
run_on_host(
HostCommand {
script: control_command(&name, command),
outcome: controlled,
done: format!("{name} {}", command.done()),
failed: format!("无法{} {name}", command.label()),
done: service_done(&name, command),
failed: Box::new(move |why| service_failed(&failed, command, why)),
then: ToolSidebar::refresh_services,
},
&terminal,
@@ -450,27 +477,20 @@ impl Workspace {
let ssh = matches!(name.as_str(), "ssh.service" | "sshd.service");
let (title, description) = match command {
ServiceCommand::Stop => (
format!("停止服务“{name}”?"),
t!("tools.service.stop.title", name = name),
if ssh {
"这是 SSH 服务:停止后新的连接都连不上这台主机,直到它再次启动。"
t!("tools.service.stop.description_ssh")
} else {
"停止后它提供的功能就不可用了,直到再次启动。"
t!("tools.service.stop.description")
},
),
ServiceCommand::Restart => (
format!("重启服务“{name}”?"),
"服务会先停止再启动,中间短暂不可用。",
t!("tools.service.restart.title", name = name),
t!("tools.service.restart.description"),
),
_ => return run(window, cx),
};
confirm_danger(
title.into(),
Some(description.into()),
command.label(),
run,
window,
cx,
);
confirm_danger(title, Some(description), command.label(), run, window, cx);
}
/// A process's details, from 进程管理's list and its command line.
@@ -515,25 +535,21 @@ impl Workspace {
let user = process.user.unwrap_or_else(|| "—".into());
let (title, description, verb) = if force {
(
format!("强制结束进程“{name}”?"),
format!(
"PID {pid},用户 {user}。进程会立即被终止(SIGKILL),来不及保存数据; 只在「结束进程」不起作用时使用。"
),
"强制结束",
t!("tools.process.kill.title", name = name),
t!("tools.process.kill.description", pid = pid, user = user),
t!("tools.process.kill.confirm"),
)
} else {
(
format!("结束进程“{name}”?"),
format!(
"PID {pid},用户 {user}。进程会收到结束信号(SIGTERM),可以先做完收尾再退出。"
),
"结束进程",
t!("tools.process.end.title", name = name),
t!("tools.process.end.description", pid = pid, user = user),
t!("tools.process.end.confirm"),
)
};
let workspace = cx.entity().downgrade();
confirm_danger(
title.into(),
Some(description.into()),
title,
Some(description),
verb,
Rc::new(move |window, cx| {
let Some(view) = terminal.view.upgrade() else {
@@ -541,7 +557,12 @@ impl Workspace {
};
let Some(reply) = view.read(cx).exec(end_command(pid, force), cx) else {
window.push_notification(
Notification::error(format!("无法结束 {name}(PID {pid}):终端没有连接")),
Notification::error(t!(
"tools.process.end_failed",
name = name,
pid = pid,
error = t!("tools.not_connected")
)),
cx,
);
return;
@@ -550,20 +571,27 @@ impl Workspace {
window
.spawn(cx, async move |cx| {
let outcome = match exec_answer(reply, cx).await {
None => Err("终端没有连接".to_string()),
None => Err(t!("tools.not_connected").to_string()),
Some(Err(error)) => Err(error),
Some(Ok(output)) => ended(&output),
};
cx.update(|window, cx| {
let notification = match outcome {
Ok(()) if force => {
Notification::success(format!("已强制结束 {name}(PID {pid})"))
}
Ok(()) => Notification::success(format!(
"已向 {name}(PID {pid})发送结束信号"
Ok(()) if force => Notification::success(t!(
"tools.process.killed",
name = name,
pid = pid
)),
Err(why) => Notification::error(format!(
"无法结束 {name}(PID {pid}):{why}"
Ok(()) => Notification::success(t!(
"tools.process.ended",
name = name,
pid = pid
)),
Err(why) => Notification::error(t!(
"tools.process.end_failed",
name = name,
pid = pid,
error = why
)),
};
window.push_notification(notification, cx);
@@ -663,10 +691,10 @@ struct HostCommand {
/// How it went, from what it printed.
outcome: fn(&str) -> Result<(), String>,
/// The notification when it went: 「nginx.service 已停止」.
done: String,
/// The notification's start when it did not, before why: 「无法停止
/// nginx.service」.
failed: String,
done: SharedString,
/// The notification when it did not, from why: 「无法停止
/// nginx.service:…」.
failed: Box<dyn FnOnce(&str) -> SharedString>,
/// What the sidebar does after: read the tool's list again.
then: fn(&mut ToolSidebar, &mut Context<ToolSidebar>),
}
@@ -689,20 +717,20 @@ fn run_on_host(
} = command;
let reply = script.and_then(|script| terminal.view.upgrade()?.read(cx).exec(script, cx));
let Some(reply) = reply else {
window.push_notification(Notification::error(format!("{failed}:终端没有连接")), cx);
window.push_notification(Notification::error(failed(&t!("tools.not_connected"))), cx);
return;
};
window
.spawn(cx, async move |cx| {
let result = match exec_answer(reply, cx).await {
None => Err("终端没有连接".to_string()),
None => Err(t!("tools.not_connected").to_string()),
Some(Err(error)) => Err(error),
Some(Ok(output)) => outcome(&output),
};
cx.update(|window, cx| {
let notification = match result {
Ok(()) => Notification::success(done),
Err(why) => Notification::error(format!("{failed}:{why}")),
Err(why) => Notification::error(failed(&why)),
};
window.push_notification(notification, cx);
workspace
@@ -713,3 +741,97 @@ fn run_on_host(
})
.detach();
}
/// The notification when containers did as `command` said.
fn containers_done(subject: &ContainerSubject, command: ContainerCommand) -> SharedString {
match (subject, command) {
(ContainerSubject::Container(name), ContainerCommand::Start) => {
t!("tools.container.start.done", name = name)
}
(ContainerSubject::Container(name), ContainerCommand::Stop) => {
t!("tools.container.stop.done", name = name)
}
(ContainerSubject::Container(name), ContainerCommand::Restart) => {
t!("tools.container.restart.done", name = name)
}
(ContainerSubject::Project(name), ContainerCommand::Start) => {
t!("tools.project.start.done", name = name)
}
(ContainerSubject::Project(name), ContainerCommand::Stop) => {
t!("tools.project.stop.done", name = name)
}
(ContainerSubject::Project(name), ContainerCommand::Restart) => {
t!("tools.project.restart.done", name = name)
}
}
}
/// The notification when they did not, and why.
fn containers_failed(
subject: &ContainerSubject,
command: ContainerCommand,
why: &str,
) -> SharedString {
match (subject, command) {
(ContainerSubject::Container(name), ContainerCommand::Start) => {
t!("tools.container.start.failed", name = name, error = why)
}
(ContainerSubject::Container(name), ContainerCommand::Stop) => {
t!("tools.container.stop.failed", name = name, error = why)
}
(ContainerSubject::Container(name), ContainerCommand::Restart) => {
t!("tools.container.restart.failed", name = name, error = why)
}
(ContainerSubject::Project(name), ContainerCommand::Start) => {
t!("tools.project.start.failed", name = name, error = why)
}
(ContainerSubject::Project(name), ContainerCommand::Stop) => {
t!("tools.project.stop.failed", name = name, error = why)
}
(ContainerSubject::Project(name), ContainerCommand::Restart) => {
t!("tools.project.restart.failed", name = name, error = why)
}
}
}
/// The notification when a container, an image, a volume or a network went.
fn removed(object: DockerObject, name: &str) -> SharedString {
match object {
DockerObject::Container => t!("tools.remove.container.done", name = name),
DockerObject::Image => t!("tools.remove.image.done", name = name),
DockerObject::Volume => t!("tools.remove.volume.done", name = name),
DockerObject::Network => t!("tools.remove.network.done", name = name),
}
}
/// The notification when it did not, and why.
fn not_removed(object: DockerObject, name: &str, why: &str) -> SharedString {
match object {
DockerObject::Container => t!("tools.remove.container.failed", name = name, error = why),
DockerObject::Image => t!("tools.remove.image.failed", name = name, error = why),
DockerObject::Volume => t!("tools.remove.volume.failed", name = name, error = why),
DockerObject::Network => t!("tools.remove.network.failed", name = name, error = why),
}
}
/// The notification when a service did as `command` said.
fn service_done(name: &str, command: ServiceCommand) -> SharedString {
match command {
ServiceCommand::Start => t!("tools.service.start.done", name = name),
ServiceCommand::Stop => t!("tools.service.stop.done", name = name),
ServiceCommand::Restart => t!("tools.service.restart.done", name = name),
ServiceCommand::Enable => t!("tools.service.enable.done", name = name),
ServiceCommand::Disable => t!("tools.service.disable.done", name = name),
}
}
/// The notification when it did not, and why.
fn service_failed(name: &str, command: ServiceCommand, why: &str) -> SharedString {
match command {
ServiceCommand::Start => t!("tools.service.start.failed", name = name, error = why),
ServiceCommand::Stop => t!("tools.service.stop.failed", name = name, error = why),
ServiceCommand::Restart => t!("tools.service.restart.failed", name = name, error = why),
ServiceCommand::Enable => t!("tools.service.enable.failed", name = name, error = why),
ServiceCommand::Disable => t!("tools.service.disable.failed", name = name, error = why),
}
}
+7 -7
View File
@@ -278,7 +278,7 @@ async fn a_click_on_a_container_shows_its_details_and_output(cx: &mut TestAppCon
wait_for_label(
cx,
handle,
"container-basics:命令",
"container-basics:command",
"node ./dist/server/entry.mjs",
)
.await;
@@ -289,9 +289,9 @@ async fn a_click_on_a_container_shows_its_details_and_output(cx: &mut TestAppCon
.label()
.map(str::to_owned)
};
assert_eq!(row("container-basics:名称").as_deref(), Some("forex-web"));
assert_eq!(row("container-basics:name").as_deref(), Some("forex-web"));
assert_eq!(
row("container-basics:入口").as_deref(),
row("container-basics:entrypoint").as_deref(),
Some("docker-entrypoint.sh")
);
assert_eq!(
@@ -400,7 +400,7 @@ async fn a_click_on_a_volume_an_image_or_a_network_shows_its_details(cx: &mut Te
wait_for_label(
cx,
handle,
"volume-basics:挂载点",
"volume-basics:mountpoint",
"/var/lib/docker/volumes/vw-data/_data",
)
.await;
@@ -419,9 +419,9 @@ async fn a_click_on_a_volume_an_image_or_a_network_shows_its_details(cx: &mut Te
in_frame(cx, handle, |window, cx| {
window.click("docker-image:hello-world:latest", cx)
});
wait_for_label(cx, handle, "image-basics:平台", "linux/amd64").await;
wait_for_label(cx, handle, "image-basics:platform", "linux/amd64").await;
in_frame(cx, handle, |window, _| {
assert_eq!(window.find("image-basics:大小").label(), Some("13.00 KB"));
assert_eq!(window.find("image-basics:size").label(), Some("13.00 KB"));
assert!(window.try_find("image-users:exciting_tesla").is_some());
assert_eq!(
window.find("image-environment").label(),
@@ -470,7 +470,7 @@ async fn an_unused_volume_is_removed_from_its_details_after_asking(cx: &mut Test
in_frame(cx, handle, |window, cx| {
window.click("docker-volume:old", cx)
});
wait_for_label(cx, handle, "volume-basics:名称", "old").await;
wait_for_label(cx, handle, "volume-basics:name", "old").await;
in_frame(cx, handle, |window, cx| {
window.click("docker-dialog-remove", cx)
});
+19 -19
View File
@@ -294,32 +294,32 @@ async fn a_click_on_a_process_shows_its_details_and_command_line(cx: &mut TestAp
.await;
assert_eq!(factory.exec_commands()[2], "sh -c 'cat /proc/2202/cmdline'");
in_frame(cx, handle, |window, _| {
let field = |label: &str| {
let field = |id: &str| {
window
.find(SharedString::from(format!("process-field:{label}")))
.find(SharedString::from(format!("process-field:{id}")))
.label()
.map(str::to_owned)
};
for (label, value) in [
("PID", "2202"),
("父进程", "systemd (1)"),
("用户", "root"),
for (id, value) in [
("pid", "2202"),
("parent", "systemd (1)"),
("user", "root"),
// A session leader with threads.
("状态", "休眠 (Ssl)"),
("state", "休眠 (Ssl)"),
// Up since tick 5000 of a host up 1002 s.
("运行时长", "00:15:52"),
("终端", "无"),
("优先级", "20"),
("Nice", "0"),
("CPU 使用率", "5.0%"),
("累计 CPU 时间", "00:00:10"),
("常驻内存", "468.75 MB (24.0%)"),
("虚拟内存", "858.31 MB"),
("线程", "30"),
("直接子进程", "0"),
("全部后代进程", "0"),
("running", "00:15:52"),
("terminal", "无"),
("priority", "20"),
("nice", "0"),
("cpu", "5.0%"),
("cpu_time", "00:00:10"),
("memory", "468.75 MB (24.0%)"),
("virtual_memory", "858.31 MB"),
("threads", "30"),
("children", "0"),
("descendants", "0"),
] {
assert_eq!(field(label).as_deref(), Some(value), "{label}");
assert_eq!(field(id).as_deref(), Some(value), "{id}");
}
});
+15 -15
View File
@@ -253,27 +253,27 @@ async fn a_click_on_a_service_shows_its_state_and_journal(cx: &mut TestAppContex
in_frame(cx, handle, |window, cx| {
window.click("service:nginx.service", cx)
});
wait_for_label(cx, handle, "service-field:主进程 PID", "781").await;
wait_for_label(cx, handle, "service-field:main_pid", "781").await;
in_frame(cx, handle, |window, _| {
let field = |label: &str| {
let field = |id: &str| {
window
.find(SharedString::from(format!("service-field:{label}")))
.find(SharedString::from(format!("service-field:{id}")))
.label()
.map(str::to_owned)
};
for (label, value) in [
("加载状态", "已加载"),
("运行状态", "运行中 / 运行中"),
("开机启动", "已启用"),
("内存", "1.90 MB"),
("任务数", "5"),
("重启次数", "0"),
("退出状态", "0"),
("启动时间", "Tue 2026-09-15 17:49:07 CST"),
("停止时间", "—"),
("Unit 文件", "/lib/systemd/system/nginx.service"),
for (id, value) in [
("load", "已加载"),
("active", "运行中 / 运行中"),
("boot", "已启用"),
("memory", "1.90 MB"),
("tasks", "5"),
("restarts", "0"),
("exit_status", "0"),
("started", "Tue 2026-09-15 17:49:07 CST"),
("stopped", "—"),
("unit_file", "/lib/systemd/system/nginx.service"),
] {
assert_eq!(field(label).as_deref(), Some(value), "{label}");
assert_eq!(field(id).as_deref(), Some(value), "{id}");
}
assert!(window.try_find("service-dialog-stop").is_some());
assert!(window.try_find("service-dialog-disable").is_some());
+29
View File
@@ -344,3 +344,32 @@ async fn on_windows_a_snippet_is_typed_after_what_is_there(cx: &mut TestAppConte
// No Ctrl-E, Ctrl-U: its shells do not edit the line with them.
assert_eq!(&factory.written_text()[before..], "df -h\r");
}
#[gpui_kit::test]
async fn the_sidebar_follows_a_switch_to_english(cx: &mut TestAppContext) {
let (handle, workspace, _) = open_snippets(cx, store_with_snippets()).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();
in_frame(cx, handle, |window, _| {
assert_eq!(
window.find(("tool-sidebar", INITIAL_WEB_TERMINAL)).label(),
Some("Snippets")
);
assert_eq!(
window.find("snippets-summary").label(),
Some("4 snippets · 2 categories")
);
assert_eq!(
window.find("snippet-category:none").label(),
Some("Uncategorized 1")
);
});
}