diff --git a/locales/docker.yml b/locales/docker.yml new file mode 100644 index 0000000..2eb5399 --- /dev/null +++ b/locales/docker.yml @@ -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} diff --git a/locales/history.yml b/locales/history.yml new file mode 100644 index 0000000..1e7e595 --- /dev/null +++ b/locales/history.yml @@ -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" diff --git a/locales/monitor.yml b/locales/monitor.yml new file mode 100644 index 0000000..7e90766 --- /dev/null +++ b/locales/monitor.yml @@ -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}" diff --git a/locales/netstat.yml b/locales/netstat.yml new file mode 100644 index 0000000..eed9cdc --- /dev/null +++ b/locales/netstat.yml @@ -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)" diff --git a/locales/processes.yml b/locales/processes.yml new file mode 100644 index 0000000..2ff84b2 --- /dev/null +++ b/locales/processes.yml @@ -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//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 diff --git a/locales/services.yml b/locales/services.yml new file mode 100644 index 0000000..6335f43 --- /dev/null +++ b/locales/services.yml @@ -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 diff --git a/locales/snippets.yml b/locales/snippets.yml new file mode 100644 index 0000000..ccb420b --- /dev/null +++ b/locales/snippets.yml @@ -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 diff --git a/locales/tools.yml b/locales/tools.yml new file mode 100644 index 0000000..545d313 --- /dev/null +++ b/locales/tools.yml @@ -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}" diff --git a/src/app/actions.rs b/src/app/actions.rs index e985286..1d0c080 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -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, pub command: ContainerCommand, } diff --git a/src/docker/container_details.rs b/src/docker/container_details.rs index a312179..2d71bae 100644 --- a/src/docker/container_details.rs +++ b/src/docker/container_details.rs @@ -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")), ), ), ) diff --git a/src/docker/details.rs b/src/docker/details.rs index 751418b..9ceba25 100644 --- a/src/docker/details.rs +++ b/src/docker/details.rs @@ -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( 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( /// 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() } diff --git a/src/docker/docker_panel.rs b/src/docker/docker_panel.rs index cb8c0c4..46687d9 100644 --- a/src/docker/docker_panel.rs +++ b/src/docker/docker_panel.rs @@ -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, 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. diff --git a/src/docker/linux.rs b/src/docker/linux.rs index 5a4d609..9bd51f6 100644 --- a/src/docker/linux.rs +++ b/src/docker/linux.rs @@ -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 { )) } -/// 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 { _ => 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 Option Option> { 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> { 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")); diff --git a/src/docker/mod.rs b/src/docker/mod.rs index 5a79102..e91e72c 100644 --- a/src/docker/mod.rs +++ b/src/docker/mod.rs @@ -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; diff --git a/src/docker/model.rs b/src/docker/model.rs index eda6a50..689f7b4 100644 --- a/src/docker/model.rs +++ b/src/docker/model.rs @@ -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, } @@ -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 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), /// 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, impl Into)>) -> SectionBody { +pub fn rows( + rows: impl IntoIterator, impl Into)>, +) -> 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, diff --git a/src/docker/object_details.rs b/src/docker/object_details.rs index 11aa86d..130b6f0 100644 --- a/src/docker/object_details.rs +++ b/src/docker/object_details.rs @@ -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"), + } +} diff --git a/src/history/history_panel.rs b/src/history/history_panel.rs index 84df5cb..5237581 100644 --- a/src/history/history_panel.rs +++ b/src/history/history_panel.rs @@ -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 { 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::(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, ) -> 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, 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, 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, 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. diff --git a/src/history/model.rs b/src/history/model.rs index 5291272..7bf2ab1 100644 --- a/src/history/model.rs +++ b/src/history/model.rs @@ -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 { diff --git a/src/monitor/monitor_panel.rs b/src/monitor/monitor_panel.rs index e22008c..751aa97 100644 --- a/src/monitor/monitor_panel.rs +++ b/src/monitor/monitor_panel.rs @@ -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, - title: &'static str, + title: impl Into, meta: Option, 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, cx: &App) -> Div { } fn render_system(system: &SystemInfo, uptime: Option, 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, 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, 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, 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::>() - .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| 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), + ) + })) } diff --git a/src/netstat/model.rs b/src/netstat/model.rs index 7a6b585..9008281 100644 --- a/src/netstat/model.rs +++ b/src/netstat/model.rs @@ -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 { let query = filter.query.trim().to_lowercase(); + // The states as the list says them now, which the search finds. + let mut labels: HashMap = 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 = 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, diff --git a/src/netstat/netstat_panel.rs b/src/netstat/netstat_panel.rs index a027daf..e3a4b61 100644 --- a/src/netstat/netstat_panel.rs +++ b/src/netstat/netstat_panel.rs @@ -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 { 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::(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() diff --git a/src/processes/linux.rs b/src/processes/linux.rs index 036d4bc..66a503f 100644 --- a/src/processes/linux.rs +++ b/src/processes/linux.rs @@ -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() }) diff --git a/src/processes/model.rs b/src/processes/model.rs index 22e036c..2c85f72 100644 --- a/src/processes/model.rs +++ b/src/processes/model.rs @@ -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"), } } } diff --git a/src/processes/process_details.rs b/src/processes/process_details.rs index 33e011a..d37341e 100644 --- a/src/processes/process_details.rs +++ b/src/processes/process_details.rs @@ -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, 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") diff --git a/src/processes/process_panel.rs b/src/processes/process_panel.rs index bce5de1..74dc295 100644 --- a/src/processes/process_panel.rs +++ b/src/processes/process_panel.rs @@ -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 { 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::(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, ) -> 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, 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( diff --git a/src/services/linux.rs b/src/services/linux.rs index 83461e0..1441f77 100644 --- a/src/services/linux.rs +++ b/src/services/linux.rs @@ -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 { 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() }, diff --git a/src/services/model.rs b/src/services/model.rs index 8488a6e..58cbcc5 100644 --- a/src/services/model.rs +++ b/src/services/model.rs @@ -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 { 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"), "运行正常"); diff --git a/src/services/service_details.rs b/src/services/service_details.rs index a39a79a..1cff5dc 100644 --- a/src/services/service_details.rs +++ b/src/services/service_details.rs @@ -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| 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")), ), ), ) diff --git a/src/services/service_panel.rs b/src/services/service_panel.rs index 6d67562..7459f91 100644 --- a/src/services/service_panel.rs +++ b/src/services/service_panel.rs @@ -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 { 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::(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) diff --git a/src/snippets/snippet_dialog.rs b/src/snippets/snippet_dialog.rs index d792e04..ea1dde6 100644 --- a/src/snippets/snippet_dialog.rs +++ b/src/snippets/snippet_dialog.rs @@ -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> = vec![None]; - let mut labels: Vec = vec![NO_CATEGORY.into()]; + // The category list's row for a snippet of none. + let mut labels: Vec = 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) -> 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(); diff --git a/src/snippets/snippet_panel.rs b/src/snippets/snippet_panel.rs index 6340499..5fd2aaa 100644 --- a/src/snippets/snippet_panel.rs +++ b/src/snippets/snippet_panel.rs @@ -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::(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, store: &HostStore, menu: PopupMenu) -> PopupMenu { let new_snippet = |menu: PopupMenu, action: Box| { - 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, 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, 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, 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. diff --git a/src/workspace/tool_sidebar.rs b/src/workspace/tool_sidebar.rs index 7b36efe..34fd3c0 100644 --- a/src/workspace/tool_sidebar.rs +++ b/src/workspace/tool_sidebar.rs @@ -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"), } } diff --git a/src/workspace/tools.rs b/src/workspace/tools.rs index 1a9b0e8..667b3a3 100644 --- a/src/workspace/tools.rs +++ b/src/workspace/tools.rs @@ -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, ) { 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 SharedString>, /// What the sidebar does after: read the tool's list again. then: fn(&mut ToolSidebar, &mut Context), } @@ -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), + } +} diff --git a/tests/ui/docker.rs b/tests/ui/docker.rs index 4445dd0..10aa22e 100644 --- a/tests/ui/docker.rs +++ b/tests/ui/docker.rs @@ -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) }); diff --git a/tests/ui/processes.rs b/tests/ui/processes.rs index bcd77a8..3fbeaf4 100644 --- a/tests/ui/processes.rs +++ b/tests/ui/processes.rs @@ -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}"); } }); diff --git a/tests/ui/services.rs b/tests/ui/services.rs index fb95c07..2fc64d5 100644 --- a/tests/ui/services.rs +++ b/tests/ui/services.rs @@ -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()); diff --git a/tests/ui/snippets.rs b/tests/ui/snippets.rs index 8cb94c9..089e04e 100644 --- a/tests/ui/snippets.rs +++ b/tests/ui/snippets.rs @@ -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") + ); + }); +}