Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2df67bc639 | ||
|
|
e09adeefb7 | ||
|
|
3c937a9be2 | ||
|
|
2dd1f1995a | ||
|
|
51bdab74f4 | ||
|
|
4d70be98a9 | ||
|
|
77da643999 | ||
|
|
97addfbc00 | ||
|
|
7b0dad9b41 | ||
|
|
82c2c8b277 | ||
|
|
d4d079579a | ||
|
|
a0ed1ac8bf | ||
|
|
eb6e43045a | ||
|
|
b7ebd893d3 | ||
|
|
575ca42258 | ||
|
|
acd0e238d4 | ||
|
|
5514612524 | ||
|
|
70b329066f | ||
|
|
85189e4412 | ||
|
|
da546b5351 | ||
|
|
f6286b776c | ||
|
|
17dc087f52 | ||
|
|
1c61269c52 | ||
|
|
352ffcef55 | ||
|
|
1c855f1508 | ||
|
|
f8f7473e42 | ||
|
|
3ddc7ebe62 | ||
|
|
baa6745c1a | ||
|
|
cd3d41c496 | ||
|
|
56491658a6 |
No files matched your search
@@ -0,0 +1,47 @@
|
||||
# `shellrs exec --terminal` against the shells it types into: bash 4.2 to
|
||||
# 5.2, dash, busybox ash and a bash started as sh, each in a container on a
|
||||
# real terminal (`terminal::run::tests::shells`). The Macs this is
|
||||
# developed on run it only with Docker Desktop up.
|
||||
#
|
||||
# A push runs it when the run's code, the test helpers or the dependencies
|
||||
# change; run it by hand otherwise (Actions › shells › Run workflow).
|
||||
name: shells
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
paths:
|
||||
- "src/terminal/**"
|
||||
- "src/testing.rs"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "rust-toolchain.toml"
|
||||
- ".github/workflows/shells.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
shells:
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y cmake clang pkg-config libfontconfig-dev \
|
||||
libfreetype-dev libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev \
|
||||
libx11-xcb-dev libxcb1-dev libvulkan-dev libdbus-1-dev libssl-dev
|
||||
# Pulled first, so that no test waits on a download.
|
||||
- name: Images
|
||||
run: |
|
||||
for image in bash:4.2 bash:4.4 bash:5.0 bash:5.1 bash:5.2 \
|
||||
debian:stable-slim alpine:3 rockylinux:9; do
|
||||
docker pull --quiet "$image"
|
||||
done
|
||||
# rust-toolchain.toml picks the toolchain; this installs it with the
|
||||
# components it lists before the cache key is computed.
|
||||
- run: rustup toolchain install
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo test --lib terminal::run -- --include-ignored
|
||||
@@ -15,6 +15,7 @@ on:
|
||||
branches: ["**"]
|
||||
paths:
|
||||
- "src/cli/**"
|
||||
- "src/analytics/**"
|
||||
- "src/update/**"
|
||||
- "src/app/paths.rs"
|
||||
- "src/app/window_hiding.rs"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 项目是什么
|
||||
|
||||
ShellRS(crate 与二进制都叫 `shellrs`)是一个类似 Xshell / WinSCP 的 SSH 主机管理工具,基于 `gpui-kit` 0.6.1(GPUI + gpui-base + gpui-component)。界面文案用中文,标识符用英文。
|
||||
ShellRS(crate 与二进制都叫 `shellrs`)是一个类似 Xshell / WinSCP 的 SSH 主机管理工具,基于 `gpui-kit` 0.6.1(GPUI + gpui-base + gpui-component)。界面文案有简体中文和英文两种(中文是源语言,文案在 `locales/`,经 `crate::i18n::t!`),标识符用英文。
|
||||
|
||||
**保存的一项叫「主机」,代码里叫 `Host`。** 左侧列表里保存的一项(地址、端口、用户、认证方式)在界面上叫「主机」,量词用「台」,代码和数据库里一律叫 host(`Host`、`HostId`、`HostStore`、`host/` 模块、`hosts` 表)。它以前叫「会话」/ `Session`,界面、代码和表名都已改掉,新写的文案和标识符不要再用「会话」/ session 指它。填 IP / 域名的那个字段叫「地址」(`Host.address`、`hosts.address`),不叫「主机」(「主机密钥」「首次连接此主机」这些照旧)。SSH 协议里的 session 不在此列:「SSH 会话通道」、russh 的 `client::Session` / `server::Session`、`channel_open_session`、SFTP 会话照旧。
|
||||
|
||||
|
||||
@@ -2,6 +2,31 @@
|
||||
|
||||
每个版本一节,标题写成 `## [版本号]`,可以在后面加日期。发布流程(`.github/workflows/release.yml`)把与 tag 同版本的那一节原样作为更新说明:显示在 GitHub Release 和官网的更新日志里(ShellRS 的更新对话框用「查看更新内容」打开官网的更新日志),用 Markdown 书写,面向使用者。
|
||||
|
||||
## [0.1.6-beta.1]
|
||||
|
||||
### 新增
|
||||
|
||||
- 外部 CLI 新增 `shellrs exec --terminal`:在 ShellRS 已打开的终端里执行命令,适用于只允许登录一次的堡垒机。
|
||||
|
||||
## [0.1.5]
|
||||
|
||||
### 新增
|
||||
|
||||
- 界面支持英文:默认跟随系统语言,也可在「设置 › 外观 › 界面语言」切换,改了立即生效。
|
||||
- 英文用户手册;README 默认显示英文版。
|
||||
- 匿名使用统计,可在「设置 › 关于 › 隐私」关闭。
|
||||
|
||||
### 改进
|
||||
|
||||
- 默认深色外观改为不刺眼的炭灰。
|
||||
- 列表里悬停和选中的行更容易分清。
|
||||
- 分屏时右侧栏跟着点进去的远程终端,标题显示主机名。
|
||||
- Docker 的标签放不下时横向滚动,不再截断。
|
||||
|
||||
### 修复
|
||||
|
||||
- 把标签拖进另一组盖住终端后,右侧栏随之收起。
|
||||
|
||||
## [0.1.4]
|
||||
|
||||
### 新增
|
||||
|
||||
@@ -4,11 +4,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## 项目是什么
|
||||
|
||||
ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH 主机管理工具,基于 `gpui-kit` 0.6.1(GPUI + gpui-base + gpui-component)。界面文案用中文,标识符用英文。功能说明见 `docs/manual.md`(`README.md` / `README.en.md` 是开源首页,截图在 `docs/screenshots/`)。
|
||||
ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH 主机管理工具,基于 `gpui-kit` 0.6.1(GPUI + gpui-base + gpui-component)。界面文案有简体中文和英文两种(中文是源语言,见「多语言」),标识符用英文。功能说明见 `docs/manual.md`(开源首页 `README.md` 是英文、默认显示,中文版是 `README.zh-CN.md`;截图在 `docs/screenshots/`,英文界面的在 `docs/screenshots/en/`)。
|
||||
|
||||
- **术语。** 保存的一项叫「主机」(量词「台」),代码和库里叫 host(`Host`、`HostId`、`HostStore`、`hosts` 表)。它以前叫「会话」/ `Session`,已全部改掉,新代码和文案别再用。填 IP / 域名的字段叫「地址」(`Host.address`)。SSH 协议层的 session(「SSH 会话通道」、russh 的 `Session`、SFTP 会话)照旧。
|
||||
- **术语。** 保存的一项叫「主机」(量词「台」),代码和库里叫 host(`Host`、`HostId`、`HostStore`、`hosts` 表)。它以前叫「会话」/ `Session`,已全部改掉,新代码和文案别再用。填 IP / 域名的字段叫「地址」(`Host.address`)。SSH 协议层的 session(「SSH 会话通道」、russh 的 `Session`、SFTP 会话)照旧。英文用词见「多语言」的用词表。
|
||||
- **两种不保存的连接。** 「外部连接」:堡垒机像调 Xshell(`ssh://`、`-url`)或 WinSCP(`sftp://`)一样拉起 ShellRS,只开一个终端或 SFTP 标签(`HostStore::is_external`)。「临时连接」:标题栏「临时连接…」打开,功能齐全。两者共同的「不保存」在代码里叫 temporary(`HostInfo::temporary`)。
|
||||
- **全是真的。** SQLite(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)、本地终端(`portable-pty` + `alacritty_terminal`)、SSH(`russh`)、SFTP(双栏、传输队列、断点续传)、端口转发(`-L` / `-R` / `-D`)、凭据、连接方式(直连、多级跳板、HTTP / SOCKS5 代理)、右侧栏七个工具、内置编辑器和预览、外部 CLI、在线升级。密码和口令只进系统钥匙串(`keyring`),**数据库里永远不出现秘密**。
|
||||
- **全是真的。** SQLite(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)、本地终端(`portable-pty` + `alacritty_terminal`)、SSH(`russh`)、SFTP(双栏、传输队列、断点续传)、端口转发(`-L` / `-R` / `-D`)、凭据、连接方式(直连、多级跳板、HTTP / SOCKS5 代理)、右侧栏七个工具、内置编辑器和预览、外部 CLI、在线升级、匿名使用统计(Aptabase)。密码和口令只进系统钥匙串(`keyring`),**数据库里永远不出现秘密**。
|
||||
|
||||
做 UI 之前先读 `gpui-kit` 与 `gpui-kit-design-guides` 两个技能(其中的 Coding Guides 和 Design Guides 在本仓库是规范,不是参考)。不要凭记忆臆造 gpui-kit API:以 `~/.cargo/registry/src/*/gpui-component-0.6.1/`、`gpui-base-0.6.1/` 的源码或 `https://gpui-kit.com/component/<name>.md` 为准。
|
||||
|
||||
@@ -16,6 +16,7 @@ ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH
|
||||
|
||||
用户定过的事,不要改回去。细节以 `docs/manual.md` 和代码为准。
|
||||
|
||||
- **界面语言**:「设置 › 外观 › 界面语言」有跟随系统(默认:中文系统用简体中文,其余用英文,系统没说时用中文)、简体中文、English。改了立刻生效,不用重启。老用户的 settings.json 里已存着 `zh-CN`,不迁移。框架用 gpui-kit 自带的 `rust-i18n`,不另引 Fluent 之类。
|
||||
- **SFTP**:Dock 标签,每次「打开 SFTP」新开一个、各自连接。操作对标 WinSCP Commander:两行工具栏、路径标签、WinSCP 的列、高亮多选、「名称」格才是项目、框选。本地侧与远程侧对称,本地删除进废纸篓;书签按主机、按侧持久化。传输进行中还能再发起,新批次在标签底部的传输队列里排队,不做前台进度对话框。大小列默认整 KB,右键列标题可切换。「显示隐藏文件」(`.` 开头的)本地、远程各一个开关、互不影响,每侧对所有标签生效,存进设置,默认不显示;工具栏用睁眼 / 闭眼两个图标表示状态,不用选中效果;显示时隐藏文件的文字浅一些。SFTP 标签暂不做目录同步(外部 CLI 有 `shellrs sync`)、过滤、查找、目录树和 Dock 布局持久化。
|
||||
- **主机树**:搜索匹配主机(名称、地址、用户)和分组名;分组名匹配时列出整个分组,回车只连本身匹配的第一台主机(按树的顺序)。首次启动是空库;分组可任意嵌套,主机可以不属于分组;删除分组连同子分组和主机,确认框写明数量。
|
||||
- **主机对话框**:
|
||||
@@ -40,30 +41,38 @@ ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH
|
||||
- 秘密只写不读:JSON 里的 `password` / `passphrase` / `private_key` 进钥匙串或 `keys/`,任何输出都没有,`show` 只说有没有保存,ShellRS 保存的私钥连路径也不给。
|
||||
- 删除不确认:`hosts delete` 遇到有打开标签的主机报 `host_in_use`,`--force` 才关标签;删凭据时用它的主机改回自己登录。临时连接和外部连接只能 `show`。
|
||||
- `sync` 只从本机到主机,大小和修改时间都没变的跳过;`--delete` 不删 `.filepart` 和 `.shellrs-….backup`,本地读不了的目录不清理,拒绝同步到 `/`。
|
||||
- `exec --terminal`(`exec --json` 里 `"terminal": true`):给只允许登录一次的堡垒机(外部连接上 exec 重新登录会被拒,另开通道又会断会话)。把命令打进这台主机已打开的远程终端(右侧栏跟的那个,否则最新已连接的),所有远程终端都能用,不限外部连接。先发 ^C,**不做忙碌检测**(用户定的:用的人有基础,交给 Agent 的标签由用户负责;别加回光标行、密码提示之类的检查),只拒绝备用屏和同一终端已有一次运行。只支持 Linux 上的 bash、dash、busybox ash。输出经管道(没有颜色和分页器)、stderr 并入;`--json` 多一项 `context`(user、host、cwd、shell、interp)。
|
||||
- **开始页**:没有标签时显示「最近连接」。「快速连接」搜索保存的主机、回车连接;多选做好但关着(常量 `MULTIPLE`)。
|
||||
- **右侧栏**:窗口最右一列竖排的工具切换。
|
||||
- 显示规则:只跟着 SSH 远程终端出现,SFTP、本地终端、设置、开始页都不显示。它是工作区级的一份,开合和所选工具在所有终端间共用。默认收起,宽 320 px,只能往宽里拉。⌘⌥B / Ctrl+Alt+B 切换。
|
||||
- 中间区分屏时不隐藏:跟着最近一次在前(切到或焦点点进)、而且还在屏幕上的远程终端;点进另一半的 SFTP、本地终端等不换;屏幕上一个远程终端都没有才隐藏。标题写出主机(「系统监控 · web-01」),状态栏也跟焦点走。用户比较过「分屏就隐藏」,选了这个。
|
||||
- 工具从上到下:命令片段、历史命令、Docker、系统服务、进程管理、网络连接、系统监控。
|
||||
- 已知不是 Linux 的主机不显示系统监控、网络连接、进程管理、系统服务;已知是 Windows 的再去掉 Docker 和历史命令;外部连接只有命令片段。
|
||||
- 系统监控每 2 秒读一次(运行时长随读数刷新,不自己走),磁盘每 30 秒,只在显示时读。≥ 70% 黄、≥ 90% 红。
|
||||
- 网络连接、系统服务、Docker、历史命令的列表不自己刷新:打开、换终端、点刷新、执行命令之后才读。进程管理每 15 秒读一次。
|
||||
- 结束进程,停止 / 重启服务,停止 / 重启 / 删除容器都先确认。不是 root 时用 `sudo -n`,要密码就报「需要 root 权限…」。
|
||||
- 系统服务的标签用 gpui-kit 的下划线 `TabBar`(均分放不下「已停止 202」),Docker 用均分的 `shared::count_tabs`。
|
||||
- 系统服务和 Docker 的标签都用 gpui-kit 的下划线 `TabBar`:每个标签按文字和数量的宽度排,放不下时横向滚动,不截断。均分放不下「已停止 202」,英文的 "Containers 12" 也放不下,不管什么语言都这样。
|
||||
- 历史命令读主机的 `~/.bash_history`,不自己记录;点一条是放进输入行、不执行,另有执行按钮。
|
||||
- 命令片段所有主机共用(库里预留了「适用范围」列);「点击时自动执行」新建时默认勾上;执行按钮鼠标移上去才出现,位置一直留着。
|
||||
- **在线升级**:
|
||||
- 清单地址 `https://dl.shellrs.com/update/v1/{channel}.json`、平台 key 的名字和公钥槽位一经发布就不能改。
|
||||
- 默认在后台检查并下载,下好、校验完才在标题栏亮按钮(`success` 色,出错时 `warning` 色)。
|
||||
- 更新对话框不列更新内容:「查看更新内容」打开整页的 `https://shellrs.com/changelog`,另一个按钮是「重启并安装」,没有「稍后」。不点也在退出时装好。
|
||||
- 「设置 › 关于」两组:「应用更新」三行(当前版本、更新渠道(稳定版 / Beta,运行时切换)、自动升级),「ShellRS」一行「官网」(打开 `https://shellrs.com`)。
|
||||
- 「设置 › 关于」三组:「应用更新」三行(当前版本、更新渠道(稳定版 / Beta,运行时切换)、自动升级),「隐私」一行(见下条),「ShellRS」一行「官网」(打开 `https://shellrs.com`)。
|
||||
- 检查只发 User-Agent,不发安装标识。
|
||||
- **匿名使用统计**:发给 Aptabase(没有 Rust SDK,自己调 `POST /api/v0/events`;App Key 是 `analytics::APP_KEY`,不是秘密)。「设置 › 关于 › 隐私 › 发送匿名使用统计」默认开,关掉就清空本机攒下的计数。
|
||||
- 只发版本、系统名和版本、CPU 架构、设置快照(取值固定,主机等数量只报档位)和功能使用次数。不发主机、凭据、命令、文件名、路径、语言,也没有任何标识(Aptabase 用 IP + UA 加每日的盐算日活)。
|
||||
- 四种事件:`app_installed`(`analytics.json` 记着已报过,送达后才记)、`app_started`、`app_active`(窗口到前台且当天 UTC 还没报过,macOS 藏在后台不算)、`usage`(功能次数,从第一次使用起满 4 小时汇总一条,不是每次使用一条:免费额度每月 2 万个事件)。
|
||||
- 不用定时器:只在启动、窗口激活、用到功能、改设置时动作,满 4 小时的汇总和失败后的重试都等下一个信号。用户明确不要定时任务,数据有误差可以接受。
|
||||
- 只有发布构建发;开发构建要设 `SHELLRS_ANALYTICS_DEBUG=1`,发的是调试事件,汇总周期 1 分钟、失败 10 秒后重试(`Pace::Debug`),好在面板上马上看到。
|
||||
- **编辑器**:中间区的「编辑器」Dock 标签,不用系统程序打开。
|
||||
- 双击、回车、F4、右键「编辑」都打开它,本地文件也行;新建文件后直接打开。
|
||||
- 只认 UTF-8(不做 GBK),上限 5 MB,约 15 种语言语法高亮。
|
||||
- 远程文件原地改写:截断后写原文件,不走 `.filepart` 加改名。保存前核对,被改过就问「覆盖」。
|
||||
- **设置 › 应用 › 窗口**:「记住窗口大小」(含最大化)和「记住窗口位置」两个开关,默认都开,下次启动生效。屏幕不在了或窗口不在屏幕上就居中,比屏幕大就缩小;全屏不记。
|
||||
- **终端通知**:只做程序请求的(OSC 9 / 777)和响铃,不做 OSC 133 长命令通知:那要每台服务器装 shell 集成,收益不抵成本。不在前台发系统通知,在前台而终端在别的标签发应用内通知;响铃在终端就在眼前时不提醒。
|
||||
- **主题**:「应用外观」(浅色 / 深色 / 跟随系统)决定深浅;「外观 › 主题」左右两栏里浅色、深色各选一套,按当前外观生效。组里第一项「界面跟随主题」默认关闭:界面是中性的黑白灰(gpui-kit 默认主题),只有终端按主题配色;打开后界面也按主题配色。用户试过默认跟随,觉得中性界面更合适,别改回去。页面「重置」对卡片和开关各管各的。
|
||||
- **主题**:「应用外观」(浅色 / 深色 / 跟随系统)决定深浅;「外观 › 主题」左右两栏里浅色、深色各选一套,按当前外观生效。组里第一项「界面跟随主题」默认关闭:界面是中性的灰(浅色是 gpui-kit 默认主题,深色见下条),只有终端按主题配色;打开后界面也按主题配色。用户试过默认跟随,觉得中性界面更合适,别改回去。
|
||||
- 默认深色(ShellRS Dark)不用 gpui-kit 的近黑,用参照微信深色的炭灰:底色 `#1e1e1f`,标题栏、标签栏、状态栏 `#2f2f30`,边框和悬停 `#3b3b3d`,文字 `#e0e0e4`。用户嫌近黑刺眼。次要文字比微信的 `#7b7b7f` 亮一些(`#929296`),保持对比度 ≥ 4.5。侧栏三个列表和「最近连接」的选中行同色(`selected_row_color`):浅色是悬停色三倍的对比,深色那样太刺眼,用加深后的悬停色,列表和表格的悬停只有它的一半(`DARK_HOVER_SHARE`)。两种外观下悬停和选中都要分得开。页面「重置」对卡片和开关各管各的。
|
||||
- 左栏只列浅色主题,右栏只列深色主题;卡片是名称加示例输出,选中的整张加底色,右上角是蓝底(主题的 `blue`)对勾。
|
||||
- 只有内置的,浅色、深色各 10 套,默认 ShellRS Light / Dark(底色与界面一致),Solarized 第二,Tokyo Day / Tokyo Night 第三。同一主题的浅、深两版在两栏同一行,没有另一版的(Rosé Pine Dawn、Everforest Light、Dracula、Nord)排最后。只收宽松许可(MIT、Apache-2.0)的配色。不做自定义主题、导入、按主机指定。
|
||||
- **键盘快捷键**:设置左栏单独一个分类,排在「关键字高亮」下面,分通用、标签、终端、编辑器四组。
|
||||
@@ -89,6 +98,7 @@ cargo test --lib # 只跑单元测试
|
||||
cargo test --test ui # 只跑 UI 集成测试
|
||||
cargo test --test ui new_host -- --nocapture # 按名称片段跑单个 UI 测试
|
||||
cargo test --test cli_bin # 以子进程运行 shellrs-cli 的端到端测试(Windows CI 也跑)
|
||||
cargo test --lib terminal::run -- --include-ignored # exec --terminal 对 Docker 里的真实 shell(CI:shells.yml)
|
||||
cargo test --lib update:: # 在线升级;装了 minisign 和 jq 时还验证发布脚本签出的清单客户端认
|
||||
cargo clippy --all-targets -- -D warnings # 必须无警告
|
||||
cargo fmt --check
|
||||
@@ -134,6 +144,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
- `settings/`:`AppSettings`、写 `settings.json` 的 `SettingsStore`、把设置落到窗口上的 `apply`、设置标签。
|
||||
- `cli/`:外部 CLI(`shellrs hosts/credentials/exec/upload/download/sync`,给 AI Agent 用)、套接字协议、应用里的服务端、主机和凭据改动的校验(`manage.rs`)、PATH 和 Agent skill 的安装。
|
||||
- `update/`:在线升级,包括清单、验签、下载、各平台安装器和 `Updater`。
|
||||
- `analytics/`:匿名使用统计。`model.rs` 是纯的计数、事件和发送时机(`Reporter`),`system.rs` 用 `os_info` 读系统名和版本,`client.rs` 是投递线程和工作区拿着的 `Analytics` 句柄;工作区的接线在 `workspace/analytics.rs`。
|
||||
- `workspace/`:窗口壳。持有 store、`DockArea`、各类标签的注册表和**全部动作处理器**,按领域拆成多个文件(`forwards.rs`、`credentials.rs`、`links.rs`、`editors.rs`、`tools.rs`、`updates.rs`…)。另有标题栏、状态栏、左侧栏 `Sidebar`、右侧栏 `ToolSidebar`、开始页。
|
||||
- `shared/`:多个功能共用的展示片段(`ClosableTabTitle`、`HostMark`、`SegmentedControl`、`RowTooltips`、`confirm_danger`、`form_error_notification` 等)。
|
||||
|
||||
@@ -161,6 +172,53 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
- **一个数据目录只跑一个 ShellRS。** 起图形界面之前先连外部 CLI 的套接字:连得上就发 `Request::Activate`(带启动链接)然后退出。为兼容已装的 CLI,不升 `PROTOCOL_VERSION`,新请求只加变体。
|
||||
- **临时主机在 `HostStore` 的 `temporary` 里**:`host()` / `login()` 找得到,`hosts()` 看不到,也不写库。会写外键的检查用 `saved_host`。最后一个标签关掉就 `remove_temporary`。
|
||||
|
||||
### 多语言
|
||||
|
||||
- **文案都在 `locales/<模块>.yml`**(rust-i18n 的 `_version: 2`):最外层直接写键的全名(`host.dialog.title_new:`),下面 `zh-CN`、`en` 并排。代码用 `crate::i18n::t!`(返回 `SharedString`)和 `tn!`(带数量,填 `%{count}`:英文数量为 1 取 `.one`,否则 `.other`;中文只写 `.other`)。不用 rust-i18n / gpui-kit 自己的 `t!`、`set_locale`。
|
||||
- **键说用途**(`host.dialog.title_new`),不拿中文句子当键。一句话一个键,不拼片段:`format!("无法{}{}", 动词, 对象)` 改成每种情况一个整句。插值写 `%{name}`;列表用 `i18n::join_list`(「A、B 和 C」/ "A, B and C")。以 `%`、`{` 开头的值、含 `: ` 的值和 `true`、纯数字这类值要加引号,否则 YAML 不当它是字符串,那条文案就悄悄没了。
|
||||
- **中文是源语言**:改中文时同一个键的英文一起改。转换旧代码时中文输出要和原来逐字相同。
|
||||
- **实时切换**:语言变了,`settings::apply` 调 `i18n::set_locale`、设全局量 `UiLocale`、`refresh`。文案在 render 时翻译,实体里不存翻译好的字符串,存键、枚举或原始数据。gpui-kit 要预先存下的(`InputState` 的占位、表格的列)用 `cx.observe_global_in::<UiLocale>` 重设。终端回滚里已写下的提示、已弹出的通知、已存成 `String` 的报错留在旧语言,可以接受。
|
||||
- **启动**:`main` 读完设置、建任何东西之前先 `set_locale`;外部 CLI 的客户端按 settings.json 设,与应用一致。
|
||||
- **标签和静态表**:返回文案的函数返回 `SharedString`,不是 `&'static str`。`static` 表里存键,显示时 `t!(key)`;键要以完整的字面量出现在源码里,守卫测试靠它找没人用的文案。
|
||||
- **英文文风**按 Design Guides:sentence case;省略号用单字符 `…`;按钮写结果(Delete,不写 Confirm deletion);不写 Please、successfully、Are you sure;引号用 “ ” 和 ’。语言名用各自的语言写(「简体中文」「English」),这样的中文字面量所在行加 `// i18n: keep`。
|
||||
- **守卫测试**在 `src/i18n.rs`:两种语言都齐、占位一致、英文里没有中文和 `...`、代码里的键都有文案且文案都被用到、`src/` 里测试以外没有中文字面量(扫描时跳过注释和 `#[cfg(test)]` / `#[cfg(any(test…))]` / `#[cfg(all(test…))]` 下的条目以及 `*tests.rs`)。键名不要以 `.one`、`.other` 结尾,除非它就是复数形式:守卫按复数处理这两个后缀。
|
||||
- **测试**:单元测试默认就是中文。UI 夹具经 `support::init_app` 和 `settings_store()`:本线程的语言与别的线程隔开(`i18n::isolate_thread`),并固定中文;切到英文的测试不影响并行的其他测试,也碰不到 gpui-kit 的全局语言。
|
||||
|
||||
用词表(新文案照此,改要整体改):
|
||||
|
||||
| 中文 | English |
|
||||
|---|---|
|
||||
| 主机(台) | host(1 host / 3 hosts) |
|
||||
| 分组 / 地址 / 用户名 | group / address / username |
|
||||
| 凭据 | credential |
|
||||
| 密码 / 密钥 / 私钥 / 公钥 / 口令 | password / key / private key / public key / passphrase |
|
||||
| 认证:密码 / 使用凭据 / 无密码 | Password / Credential / No password |
|
||||
| 连接方式:直接连接 / SSH 跳板 / 代理连接 | Direct / Jump hosts / Proxy |
|
||||
| 跳板主机 | jump host |
|
||||
| 测试连接 | Test connection |
|
||||
| 端口转发:本地 / 远程 / 动态;经由主机 | port forwarding: Local / Remote / Dynamic; via host |
|
||||
| 临时连接… | Quick connect… |
|
||||
| 开始页「快速连接」 | Find a host |
|
||||
| 外部连接 | external connection |
|
||||
| 最近连接 | Recent |
|
||||
| 本地终端 / 远程终端 | local terminal / remote terminal |
|
||||
| 传输队列 / 书签 | Transfers / Bookmarks |
|
||||
| 显示隐藏文件 | Show hidden files |
|
||||
| 废纸篓 | macOS 写 Trash,Windows 写 Recycle Bin |
|
||||
| 右侧栏七个工具 | Snippets / History / Docker / Services / Processes / Network / Monitor |
|
||||
| 设置七类(外观、终端、关键字高亮、键盘快捷键、外部 CLI、应用、关于) | Appearance / Terminal / Highlighting / Keyboard shortcuts / External CLI / General / About |
|
||||
| 应用更新 / 稳定版 / 重启并安装 / 查看更新内容 | Updates / Stable / Restart to update / What’s new |
|
||||
| 连接 / 断开连接 / 重新连接 | Connect / Disconnect / Reconnect |
|
||||
| 已连接 / 未连接 / 连接中 | Connected / Not connected / Connecting |
|
||||
| 目录、文件夹 | folder(上级目录 Parent folder,主目录 Home folder) |
|
||||
| SFTP 列:名称 / 大小 / 类型 / 修改时间 / 权限 / 所有者 | Name / Size / Type / Modified / Permissions / Owner |
|
||||
| 目标(转发、传输) | destination |
|
||||
| 本机 | this computer |
|
||||
| 系统钥匙串 / 主机信任文件 | system keychain / known hosts file |
|
||||
| SSH Agent(句子里) | the SSH agent |
|
||||
| 删除(容器、规则等) | Delete(不用 Remove) |
|
||||
| 正在读取… / 读取失败 | Loading… / Couldn’t load |
|
||||
|
||||
### GPUI / gpui-kit
|
||||
|
||||
- **一个命令一个处理器。** 按钮、菜单、快捷键都只派发 `app/actions.rs` 里的动作,由 `Workspace` 处理,不写临时闭包直接改状态。例外是对话框内部自己的状态(预览的缩放):工作区够不着对话框里的视图,由视图自己处理。
|
||||
@@ -194,7 +252,8 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
- 显示按键别写死文字,用 `tooltip_with_action` / `Kbd` 从 keymap 取,否则改绑后会过期。
|
||||
- **标签页全部可关。** 关闭走 `DockArea::remove_panel`(`TabGroup::close_panel` 拒绝关最后一个)。中间区面板的 `closable()` 返回 `false`,否则「…」菜单会多一个绕过确认的「关闭」。标题用 `ClosableTabTitle`,它带标签菜单,双击切换侧栏。`panel_name()` 是持久化键,不能改。
|
||||
- **左右侧栏各是一个面板。** 左侧 Dock 里只有 `Sidebar`(三个列表),右侧 Dock 里只有 `ToolSidebar`。不用 `set_dock` 换面板,否则焦点会丢。
|
||||
- 当前标签只经 `Workspace::set_active_tab` 改,否则右侧栏不跟着变。
|
||||
- 当前标签只经 `Workspace::set_active_tab` 改,否则右侧栏不跟着变。分屏时焦点决定谁在前:每个中间区标签创建时都要用 `front::follow_focus` 登记焦点(它在焦点阶段触发,处理要 `defer_in`);布局变化(拖动合并标签组)后 `sync_front` 重新核对。右侧栏跟的终端由 `visible_center_tabs`(各组显示的标签,放大时只算放大的那组)判断是否还在屏幕上。
|
||||
- 测试里拆分、合并标签组用 `DockArea::move_panel` 加 `InsertTarget::Split` / `Tabs`,同拖放;`split_at` 不会把面板从原组移走。
|
||||
- 右侧 Dock 不可折叠,否则标签栏会多出 gpui-kit 的折叠按钮;关闭时临时设成可折叠。
|
||||
- 最窄 320 px,由 `hold_tool_sidebar_width` 维持。
|
||||
- **动画用 gpui-kit 的 `animate_keyframes`**(转发示意图),不自己写播放。
|
||||
@@ -267,6 +326,12 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
- 协议只加变体、不升 `PROTOCOL_VERSION`:旧应用对新请求回 serde 的「unknown variant」,客户端改说成 `version_mismatch`。服务端先读版本再解析请求。
|
||||
- `exec --json` 的输入输出都是 JSON,输出只用 ASCII(`ascii_json` 转义其余字符),出错也是 JSON:给 Windows PowerShell 用,绕开引号和代码页。不做 MCP:主流 Agent 都能执行命令,CLI 加 skill 已经够用。
|
||||
- UI 测试不监听真实套接字,`CliIntegration` 默认没有路径。
|
||||
- **`exec --terminal`**(`terminal/run.rs`,纯逻辑,状态都在 `TerminalRun` 的锁里):
|
||||
- 流程:引擎发 ^C → 请求线程等 500 ms(同时到的字节可能被 tty 随 ^C 冲掉)输入探测行 → 解析线程见到标记 P 就输入命令行 → 标记 B(上下文)、输出、标记 E(退出码)。时限在请求线程(`TerminalRun::drive`):P 5 秒(`terminal_busy`),B 15 秒(发 ^C 再报,可能被堡垒机命令过滤或复核拦下);执行中没有时限,每秒一个空 Stdout 帧,写失败就是调用方没了,发 ^C。
|
||||
- 标记是 OSC 6973 带每次随机的 NONCE(单独的参数,回显里的 `\033` 只是文字,伪造不了)。P 带 `readlink /proc/$PPID/exe`(去掉 ` (deleted)`;不用 `comm`,以 sh 名义启动的 bash 的 comm 是 sh)。E 由外层 sh 在管道之后打:`trap : INT` 挺过 ^C,状态经 fd 3 带出,被打断按 130 报,所以 ^C 后也一定有 E。
|
||||
- 命令按 `printf %b` 编码(控制字符、`'`、`\`、`!`、`"`、≥ 0x80 写成 `\0NNN`),打出的行里没有引号和控制字符,命令仍大体可读(审计、命令过滤看得见,所以不用 base64)。从不用 bracketed paste:命令行是在探测还在跑、tty 处于规范模式时预输入的,readline 那时没开 2004,一行最多 4095 字节,所以 bash、dash 上限 4000,busybox 的行编辑 1000。
|
||||
- 运行槽归运行时(`TerminalRuntime::run`):解析线程在 `notices.scan` 旁边喂字节,循环结束时报断开。服务端另有 `runs` 队列,工作区另起 100 ms 的 `serve_cli_runs`(不能排在逐条等改动的 `serve_cli` 后面)。
|
||||
- 真实 shell 的测试在 `run_tests.rs` 的 `shells`(`#[ignore]`,Docker 里 bash 4.2–5.2、dash、busybox、Rocky 的 sh),CI 是 `shells.yml`。
|
||||
- **Windows**:
|
||||
- 发布版是 GUI 子系统,命令改由 `shellrs-cli.exe` 承担;PATH 里放的是副本,加进用户 PATH。
|
||||
- `main` 先用 `link_arguments` 认堡垒机的链接参数,认出来就不走 CLI。
|
||||
@@ -274,6 +339,11 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
- **在线升级**:
|
||||
- `new_with_services` 建的 `Updater` 没有服务(测试从不联网),生产路径再 `set_services` 加 `start`。
|
||||
- 重启走 `set_restart_path` 加 `restart`。Windows 不在退出过程中起进程,交给 `run` 返回之后的 `start_handed_over`。
|
||||
- **匿名使用统计**:
|
||||
- 只在 `Workspace::new` 里启动(`AnalyticsServices::system`),`new_with_services` 没有;UI 测试用 `Analytics::new(sender)` 读信号。
|
||||
- 计数只能是 `Counter` 的变体,快照字段只有 `&'static str`、开关和数量,用户输入的东西放不进去。计数的键发布后不能改,新加的要同步 `docs/manual.md`「隐私」一节的清单。
|
||||
- UI 线程只往通道里发信号;系统检测(`os_info` 会起 `uname` 等子进程)、HTTP、写 `analytics.json` 都在 `shellrs-analytics` 线程上,退出不冲队列。
|
||||
- SSH 连上靠 `TerminalEvent::Started` → `TerminalPanelEvent::Connected`,不能在每批输出都发的 `StatusChanged` 里记;SFTP 看 `StateChanged` 时的 `connection_state()`。外部 CLI 的请求种类由 `CliServer::take_usage` 交给 `serve_cli` 的轮询。
|
||||
- **发布走 tag** `v<版本>`(`release.yml`,需要人工批准,清单最后才上传)。`CHANGELOG.md` 那一节由改版本号的提交写,功能提交不改。一次性准备见 `docs/release.md`。
|
||||
- **macOS 上关窗口是隐藏**(`hide_when_closed`),⌘Q 才退出。测试平台的 `hide()` 没实现,所以这条只挂在生产路径上。
|
||||
|
||||
@@ -283,7 +353,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
- **清屏只在本地模拟器里做**,全屏程序运行时不做。终端里的 ⌘K 是清屏,比全局的 `FocusSearch` 深一层,所以优先。
|
||||
- **终端字体走全局量 `TerminalFont`**(终端模块不依赖 settings),格子在下一帧 prepaint 时按新尺寸重排。
|
||||
- **终端颜色走全局量 `TerminalColors`**(`theme.rs`):`settings::apply` 按当前外观设成那一栏选中的主题,系统外观变化时也重设。终端格子、边距、选区、链接、颜色查询(OSC 4 / 10 / 11 / 12)和设置里的两个预览都从它取,不用界面主题的 `background` / `foreground`;查找高亮仍用界面的 `warning`。主题的 `key` 存进 `settings.json`,发布后不能改;读不出或深浅不符的 key 退回该外观的默认。
|
||||
- **界面配色由所选主题推出**(`settings/app_theme.rs`):从 gpui-kit 默认的浅色或深色主题出发重新着色。灰色按它在默认背景与文字之间的位置,换到该主题背景与文字之间;默认里用作可读文字的灰色(对比度 ≥ 4.5)至少保留该主题正文对比度的 80%(最高 4.5)。`base.*` 按名字换成对应的 ANSI 色,其余彩色按色相换;`.light` 和彩色的 `*foreground` 删掉,交给 gpui-kit 自己推。ShellRS Light / Dark 和「界面跟随主题」关闭时都直接用 gpui-kit 的默认主题(`TerminalThemeSettings::app_theme`)。`settings::apply` 按名字(`theme_name`)判断要不要重建,把结果放进 `Theme` 的浅色或深色槽再 `Theme::change`。
|
||||
- **界面配色由所选主题推出**(`settings/app_theme.rs`):从 gpui-kit 默认的浅色或深色主题出发重新着色。灰色按它在默认背景与文字之间的位置,换到该主题背景与文字之间;默认里用作可读文字的灰色(对比度 ≥ 4.5)至少保留该主题正文对比度的 80%(最高 4.5)。`base.*` 按名字换成对应的 ANSI 色,其余彩色按色相换;`.light` 和彩色的 `*foreground` 删掉,交给 gpui-kit 自己推。ShellRS Light(以及「界面跟随主题」关闭时的浅色,`TerminalThemeSettings::app_theme`)直接用 gpui-kit 的默认浅色主题;ShellRS Dark(以及关闭时的深色)是 gpui-kit 默认深色主题按 `SOFT_DARK` 把每级灰换成炭灰,彩色不动。`settings::apply` 按名字(`theme_name`)判断要不要重建,把结果放进 `Theme` 的浅色或深色槽再 `Theme::change`。
|
||||
- **链接**:正则找出的地址和 OSC 8 标出的都算,只认 http / https(`links.rs`);OSC 8 的格子优先于正则。一直画成蓝色(终端主题的 ANSI 蓝,悬停时亮蓝;界面主题的 `link` 和正文同色)加下划线,⌘ / Ctrl 单击才打开,普通单击照旧选文字。
|
||||
- **鼠标上报**(`mouse.rs` 是纯编码):按住 Shift 不上报,右键始终是自己的菜单。写入走 `engine.write`,不算用户输入。
|
||||
- **关键字高亮只叠加在显示上**(`highlight.rs`)。`snapshot()` 每帧对可见行把折行接成逻辑行,用 `regex` crate 匹配,结果写进 `TerminalCell.highlight`,prepaint 改文字色和粗细;不存坐标,所以缩放重排、回滚都不用失效处理。
|
||||
|
||||
@@ -5476,6 +5476,27 @@ dependencies = [
|
||||
"objc2-metal 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-ui-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.6.2",
|
||||
"objc2 0.6.4",
|
||||
"objc2-cloud-kit",
|
||||
"objc2-core-data 0.3.2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-core-image 0.3.2",
|
||||
"objc2-core-location",
|
||||
"objc2-core-text",
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-quartz-core 0.3.2",
|
||||
"objc2-user-notifications",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-user-notifications"
|
||||
version = "0.3.2"
|
||||
@@ -5604,6 +5625,21 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_info"
|
||||
version = "3.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"log",
|
||||
"nix 0.31.3",
|
||||
"objc2 0.6.4",
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-ui-kit",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "p256"
|
||||
version = "0.14.0"
|
||||
@@ -7702,7 +7738,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shellrs"
|
||||
version = "0.1.4"
|
||||
version = "0.1.6-beta.1"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
@@ -7722,6 +7758,7 @@ dependencies = [
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"os_info",
|
||||
"percent-encoding",
|
||||
"portable-pty",
|
||||
"rand 0.9.5",
|
||||
@@ -7730,6 +7767,7 @@ dependencies = [
|
||||
"rusqlite",
|
||||
"russh",
|
||||
"russh-sftp",
|
||||
"rust-i18n",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -8373,7 +8411,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "shellrs"
|
||||
version = "0.1.4"
|
||||
version = "0.1.6-beta.1"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
description = "SSH client and host manager: terminal, SFTP, port forwarding"
|
||||
@@ -59,6 +59,9 @@ rand = "0.9"
|
||||
# search, whose DFA cannot build `\b` and loses `^` after the first line.
|
||||
regex = "1"
|
||||
russh = "0.63"
|
||||
# 多语言: the same crate gpui-kit translates its own text with, so one
|
||||
# locale switches both.
|
||||
rust-i18n = "4.2"
|
||||
russh-sftp = "=3.0.0"
|
||||
serde_json = "1"
|
||||
sys-locale = "0.3"
|
||||
@@ -81,6 +84,9 @@ trash = "5"
|
||||
minisign-verify = "0.2"
|
||||
reqwest = { version = "0.13", features = ["blocking"] }
|
||||
semver = { version = "1", features = ["serde"] }
|
||||
# 匿名使用统计: the system's name and version for Aptabase, as its own Tauri
|
||||
# plugin reads them.
|
||||
os_info = { version = "3", default-features = false }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
# `access(2)`: whether the folder ShellRS runs from can take its update.
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
<p align="center">
|
||||
<img src="assets/logo/shellrs.svg" width="96" alt="ShellRS">
|
||||
</p>
|
||||
|
||||
<h1 align="center">ShellRS</h1>
|
||||
|
||||
<p align="center">
|
||||
A high-performance, native, cross-platform SSH client written in Rust, with a GPU-rendered interface: Xshell-style host management, WinSCP-style SFTP and port forwarding, all in one window.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/since2006/shell-rs/releases"><img src="https://img.shields.io/github/v/release/since2006/shell-rs?include_prereleases&label=release" alt="Release"></a>
|
||||
<a href="#license"><img src="https://img.shields.io/badge/license-GPL--3.0-blue" alt="License: GPL-3.0"></a>
|
||||
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey" alt="Platform: macOS | Windows | Linux">
|
||||
<img src="https://img.shields.io/badge/rust-1.98-orange" alt="Rust 1.98">
|
||||
</p>
|
||||
|
||||
<p align="center"><a href="README.md">简体中文</a> | <b>English</b></p>
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/main-dark.png">
|
||||
<img src="docs/screenshots/main-light.png" alt="ShellRS main window: a grouped host tree on the left, htop running in a remote terminal on the right, connection latency in the tab bar">
|
||||
</picture>
|
||||
|
||||
## About
|
||||
|
||||
ShellRS brings Xshell-style host management and tabbed terminals, a WinSCP-style dual-pane SFTP browser and SSH port forwarding together in one cross-platform app, which bastion hosts such as JumpServer can launch the way they launch Xshell and WinSCP. It is written in Rust, and its UI is built on GPUI, the GPU-accelerated UI framework of the Zed editor (through [gpui-kit](https://gpui-kit.com)). There is no Electron and no JVM.
|
||||
|
||||
Hosts, groups, credentials and forwarding rules live in a local SQLite database. Passwords and key passphrases go only into the system keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service); the database never holds a secret. ShellRS also ships a `shellrs` command that lets AI agents such as Claude Code or Codex run commands and transfer files on your saved hosts without ever seeing a password.
|
||||
|
||||
> [!NOTE]
|
||||
> ShellRS's interface is currently in Simplified Chinese only. An English translation is in progress.
|
||||
|
||||
## Features
|
||||
|
||||
**Hosts**
|
||||
|
||||
- Groups nest to any depth; drag to rearrange, type to search.
|
||||
- The remote OS is detected on connect and shown as a badge in the host tree and on tabs (Linux distributions, the BSDs, macOS, Windows).
|
||||
- Three ways to log in: password; no password (the server's `none` auth, then the SSH agent, then the default keys in `~/.ssh`); or a saved credential.
|
||||
- Three ways to connect: directly, through a chain of SSH jump hosts, or through an HTTP or SOCKS5 proxy. Terminals, SFTP, port forwards and the CLI all follow it.
|
||||
- "Test connection" performs a real login and explains any failure.
|
||||
|
||||
**Terminal**
|
||||
|
||||
- Tabbed remote and local terminals (built on `alacritty_terminal`); full-screen programs like vim and htop work as expected.
|
||||
- Each tab shows the SSH round-trip latency live.
|
||||
- Find (smart case) and local clear; configurable font, size and line height.
|
||||
- Drag tabs into side-by-side or stacked splits to watch a terminal and SFTP together.
|
||||
|
||||
**Right sidebar tools**
|
||||
|
||||
- They follow the current remote terminal and read and run on that terminal's own connection, with no second login. ⌘⌥B (Ctrl+Alt+B elsewhere) shows or hides them.
|
||||
- System monitor: system details, CPU (per core too), memory, network rates and disk use, refreshed every 2 seconds.
|
||||
- Network connections, processes and services: search and filter, process details, ending processes, starting, stopping and restarting systemd services, switching them on at boot, and their logs. These three and the monitor are for Linux only.
|
||||
- Docker: containers grouped by compose project, with start, stop, restart, details and logs, plus volumes, images and networks. When not root, passwordless sudo is used (for services as well).
|
||||
- Command history reads `~/.bash_history` on the host, and snippets, shared by every host, can be sorted into categories; click one to type it into the terminal, or run it straight away.
|
||||
|
||||
**SFTP**
|
||||
|
||||
- A dual-pane browser modeled on WinSCP Commander: symmetric local and remote panes with WinSCP's columns, path labels, bookmarks and shortcuts (F5, F2, F7, F8…).
|
||||
- Upload and download by dragging or by shortcut, recursively. Start new transfers while one runs; they wait in a per-tab transfer queue.
|
||||
- Resumable transfers: data goes to a `.filepart` first, dropped connections are retried after 1, 3 and 10 seconds, and after a restart ShellRS offers to resume the same transfer.
|
||||
- Delete (local items go to the Trash), rename, create, and change permissions (a 3×3 grid plus octal, optionally recursive).
|
||||
- Built-in editor: double-click a text file to edit it, ⌘S writes it back in place, after checking nobody else changed it; syntax highlighting for common formats. Images and Markdown can be previewed.
|
||||
- Each SFTP tab has its own connection and asks only for the SFTP subsystem, so no shell is needed on the server.
|
||||
|
||||
**Opening from a bastion host**
|
||||
|
||||
- Launched the way Xshell and WinSCP are: in the client of a bastion host such as JumpServer, set ShellRS as the SSH or SFTP client. An `ssh://` link opens a terminal, an `sftp://` link an SFTP tab.
|
||||
- Understands Xshell's `-url` and `-newtab` and WinSCP's `/sessionname=`. When ShellRS is already running, the link opens there.
|
||||
- What opens is an unsaved external connection: it stays out of the host list, keeps the link's password in memory only and goes away with its tabs. Each connection uses a single channel, so bastion hosts that allow no more still work. See the [user manual](docs/manual.md#从堡垒机打开外部连接) (in Chinese).
|
||||
|
||||
**Port forwarding**
|
||||
|
||||
- Local (`-L`), remote (`-R`) and dynamic (`-D`, SOCKS5 / SOCKS4) forwards.
|
||||
- The rule dialog draws a live diagram and a one-line explanation of where connections enter and where they go.
|
||||
- Every rule keeps its own SSH connection and reconnects three times after a drop; rules can start with ShellRS.
|
||||
|
||||
**Credentials**
|
||||
|
||||
- Password, key and SSH agent credentials that many hosts can share. Change a credential and every host using it follows.
|
||||
- Keys can reference a file on disk, be pasted in, or be generated on the spot (Ed25519 or RSA 4096), with the public key one click away.
|
||||
|
||||
**A CLI for AI agents**
|
||||
|
||||
- `shellrs hosts` / `credentials` / `exec` / `upload` / `download` / `sync`: manage hosts and credentials, run commands, move and sync files. The running ShellRS logs in on the command's behalf, so the command itself never reads a password or private key.
|
||||
- Install `shellrs` into your PATH and an Agent Skill for Claude Code, Codex, OpenCode or WorkBuddy from the settings page.
|
||||
|
||||
**Security and updates**
|
||||
|
||||
- Secrets stay in the system keychain. Host keys are recorded in ShellRS's own `known_hosts`, never in `~/.ssh`.
|
||||
- In-app updates: the update manifest is minisign-signed and each package is checked against its SHA-256. Updates download in the background and install on restart or quit. There are stable and beta channels, and an update check sends only the version, OS and CPU architecture.
|
||||
|
||||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/split.png" alt="A split view: a remote terminal on top and SFTP for the same host below"><br>
|
||||
<em>Dock splits: a remote terminal and SFTP stacked in one window</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/tools.png" alt="Five right sidebar tools side by side: system monitor, processes, services, Docker and network connections"><br>
|
||||
<em>Right sidebar tools: system monitor, processes, services, Docker and network connections</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/sftp.png" alt="Dual-pane SFTP: three local items selected on the left, remote /etc on the right, and a transfer queue with one batch uploading and one waiting"><br>
|
||||
<em>WinSCP-style SFTP: highlight selection and a transfer queue with progress, speed and time left</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/forward.png" alt="Edit port forward dialog with the diagram and explanation of a local forward, and two running rules in the forward list"><br>
|
||||
<em>Port forwarding: the diagram shows where each connection goes</em>
|
||||
</p>
|
||||
|
||||
## Download
|
||||
|
||||
Download from [shellrs.com/download](https://shellrs.com/download), which offers the package for your system, or from GitHub [Releases](https://github.com/since2006/shell-rs/releases). Betas are marked Pre-release there; to get new features early, switch the update channel to Beta in Settings › About (设置 › 关于).
|
||||
|
||||
| System | Package | Notes |
|
||||
| --- | --- | --- |
|
||||
| macOS 11+ (Apple Silicon and Intel) | `ShellRS-<version>-macos-universal.dmg` | Signed and notarized. Open the DMG and drag ShellRS into Applications |
|
||||
| Windows x64 | `ShellRS-<version>-windows-x86_64-setup.exe` | Installs per user, no administrator rights needed. The installer is not signed yet; if SmartScreen stops it, choose "More info › Run anyway" |
|
||||
| Linux x64 | `ShellRS-<version>-linux-x86_64.AppImage` | `chmod +x` and run. Needs glibc 2.35 or newer (Ubuntu 22.04, Debian 12 and later) |
|
||||
|
||||
Every package comes with a minisign signature (`.minisig`), and each release has a `SHA256SUMS` file.
|
||||
|
||||
Once installed, ShellRS keeps itself up to date: it checks, downloads and verifies new versions in the background, then shows an icon in the top-right corner of the title bar; click it and choose "重启并安装" (restart and install), or just quit and the update is installed on the way out. Automatic updates are unavailable when ShellRS runs from inside the DMG or outside the Applications folder, or on Linux when it isn't run as the AppImage.
|
||||
|
||||
## Getting started
|
||||
|
||||
1. Click "新建主机…" (New host) in the title bar (⌘N, or Ctrl+N on Windows and Linux) and fill in the address, port and login. Passwords go into the system keychain.
|
||||
2. Double-click a host to open a terminal. On the first connection you're asked to confirm the host key.
|
||||
3. Right-click a host and choose "打开 SFTP" (Open SFTP), or click "SFTP" at the right of the terminal's tab bar. Drag files between the two panes to transfer them.
|
||||
4. The three icons after "ShellRS" in the title bar switch the sidebar between hosts, port forwards and credentials.
|
||||
5. ⌘T (Ctrl+T) opens a local terminal. Drag a tab to the top, bottom, left or right edge of the tab area to split it.
|
||||
|
||||
## For AI agents
|
||||
|
||||
In 设置 › 外部 CLI (Settings › External CLI), turn on 启用外部 CLI (Enable external CLI), then install the `shellrs` command and the Agent Skills. Your agent can then use the hosts you have saved:
|
||||
|
||||
```sh
|
||||
shellrs hosts list -q web # list hosts and their IDs
|
||||
shellrs exec <ID> "uptime && df -h" # run a command; the exit code is the remote one
|
||||
shellrs upload <ID> ./dist/app.tar.gz /opt/app/ # upload
|
||||
shellrs download <ID> /var/log/syslog ./logs/ # download
|
||||
shellrs sync <ID> ./dist /opt/app --delete # sync a folder, skipping unchanged files
|
||||
```
|
||||
|
||||
The command hands each request to the running ShellRS, which logs in with the saved settings and keychain. An unknown host or a missing password fails right away with a stable error code instead of a prompt that would hang the agent. See the [user manual](docs/manual.md#外部-cli) (in Chinese) for details.
|
||||
|
||||
## Building from source
|
||||
|
||||
Install [rustup](https://rustup.rs). The repository's `rust-toolchain.toml` pins Rust 1.98.1, which rustup installs on the first build.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/since2006/shell-rs.git
|
||||
cd shell-rs
|
||||
cargo run
|
||||
```
|
||||
|
||||
The first build compiles the whole GPUI stack and takes a while. See [development notes](docs/development.md) (in Chinese) for the Linux system packages and how to run the tests.
|
||||
|
||||
## Documentation
|
||||
|
||||
The documentation is in Chinese for now:
|
||||
|
||||
- [User manual](docs/manual.md): every feature in detail, with shortcuts
|
||||
- [Development](docs/development.md): building, testing, the data directory
|
||||
- [Releasing](docs/release.md): packaging, signing and publishing updates
|
||||
- [Changelog](CHANGELOG.md)
|
||||
|
||||
## Roadmap
|
||||
|
||||
Not implemented yet:
|
||||
|
||||
- An English interface (today only the text built into GPUI Kit's components switches language)
|
||||
- Directory sync, filtering, file search and a directory tree in SFTP
|
||||
- Keeping the Dock layout across restarts
|
||||
- Combining a jump host chain with a proxy, and HTTPS proxies
|
||||
- Managing port forwards and credentials through the external CLI
|
||||
- Code signing for the Windows installer
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and pull requests are welcome on [GitHub](https://github.com/since2006/shell-rs/issues). Architecture notes and code conventions are in [CLAUDE.md](CLAUDE.md) (in Chinese). Before sending a change, make sure these pass:
|
||||
|
||||
```sh
|
||||
cargo fmt --check
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo test
|
||||
```
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
- [GPUI](https://github.com/zed-industries/zed) (Zed) and [gpui-kit](https://gpui-kit.com): the UI framework
|
||||
- [russh](https://github.com/Eugeny/russh) and [russh-sftp](https://github.com/AspectUnk/russh-sftp): SSH and SFTP
|
||||
- [alacritty_terminal](https://crates.io/crates/alacritty_terminal) and [portable-pty](https://crates.io/crates/portable-pty): terminal emulation and local PTYs
|
||||
- [keyring](https://crates.io/crates/keyring): the system keychain
|
||||
- [Simple Icons](https://simpleicons.org): the OS logos on host badges and the Docker logo in the right sidebar (CC0)
|
||||
- [WinSCP](https://winscp.net) and Xshell: the interaction designs ShellRS follows
|
||||
|
||||
## License
|
||||
|
||||
ShellRS is licensed under the [GNU General Public License v3.0](LICENSE). You may use, modify and redistribute it, commercially or not, but any modified version you distribute must come with its complete source code under the GPL-3.0 as well.
|
||||
@@ -5,154 +5,152 @@
|
||||
<h1 align="center">ShellRS</h1>
|
||||
|
||||
<p align="center">
|
||||
用 Rust 编写、由 GPU 渲染界面的高性能原生跨平台 SSH 客户端:Xshell 式的主机管理、WinSCP 式 SFTP 和端口转发,都在一个窗口里。
|
||||
A high-performance, native, cross-platform SSH client written in Rust, with a GPU-rendered interface: Xshell-style host management, WinSCP-style SFTP and port forwarding, all in one window.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/since2006/shell-rs/releases"><img src="https://img.shields.io/github/v/release/since2006/shell-rs?include_prereleases&label=release" alt="Release"></a>
|
||||
<a href="#许可证"><img src="https://img.shields.io/badge/license-GPL--3.0-blue" alt="License: GPL-3.0"></a>
|
||||
<a href="#license"><img src="https://img.shields.io/badge/license-GPL--3.0-blue" alt="License: GPL-3.0"></a>
|
||||
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey" alt="Platform: macOS | Windows | Linux">
|
||||
<img src="https://img.shields.io/badge/rust-1.98-orange" alt="Rust 1.98">
|
||||
</p>
|
||||
|
||||
<p align="center"><b>简体中文</b> | <a href="README.en.md">English</a></p>
|
||||
<p align="center"><b>English</b> | <a href="README.zh-CN.md">简体中文</a></p>
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/main-dark.png">
|
||||
<img src="docs/screenshots/main-light.png" alt="ShellRS 主界面:左侧是分组的主机树,右侧远程终端里运行着 htop,标签栏显示连接延迟">
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/en/main-dark.png">
|
||||
<img src="docs/screenshots/en/main-light.png" alt="ShellRS main window: a grouped host tree on the left, htop running in a remote terminal on the right, connection latency in the tab bar">
|
||||
</picture>
|
||||
|
||||
## 简介
|
||||
## About
|
||||
|
||||
ShellRS 把 Xshell 式的主机管理和多标签终端、WinSCP 式的双栏 SFTP 文件管理、SSH 端口转发放进同一个跨平台应用,JumpServer 等堡垒机也可以像调用 Xshell、WinSCP 一样调用它。它用 Rust 编写,界面基于 Zed 编辑器的 GPU 加速 UI 框架 GPUI(通过 [gpui-kit](https://gpui-kit.com)),不依赖 Electron 或 JVM。
|
||||
ShellRS brings Xshell-style host management and tabbed terminals, a WinSCP-style dual-pane SFTP browser and SSH port forwarding together in one cross-platform app, which bastion hosts such as JumpServer can launch the way they launch Xshell and WinSCP. It is written in Rust, and its UI is built on GPUI, the GPU-accelerated UI framework of the Zed editor (through [gpui-kit](https://gpui-kit.com)). There is no Electron and no JVM.
|
||||
|
||||
主机、分组、凭据和转发规则保存在本地的 SQLite 数据库里。密码和私钥口令只存进系统钥匙串(macOS 钥匙串、Windows 凭据管理器、Linux Secret Service),数据库里没有任何秘密。ShellRS 还带一个 `shellrs` 命令,让 Claude Code、Codex 等 AI Agent 用你保存的主机执行命令、传输文件,不用把密码交给它们。
|
||||
Hosts, groups, credentials and forwarding rules live in a local SQLite database. Passwords and key passphrases go only into the system keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service); the database never holds a secret. ShellRS also ships a `shellrs` command that lets AI agents such as Claude Code or Codex run commands and transfer files on your saved hosts without ever seeing a password.
|
||||
|
||||
> [!NOTE]
|
||||
> 界面目前只有简体中文。
|
||||
## Features
|
||||
|
||||
## 功能
|
||||
**Hosts**
|
||||
|
||||
**主机管理**
|
||||
- Groups nest to any depth; drag to rearrange, type to search.
|
||||
- The remote OS is detected on connect and shown as a badge in the host tree and on tabs (Linux distributions, the BSDs, macOS, Windows).
|
||||
- Three ways to log in: password; no password (the server's `none` auth, then the SSH agent, then the default keys in `~/.ssh`); or a saved credential.
|
||||
- Three ways to connect: directly, through a chain of SSH jump hosts, or through an HTTP or SOCKS5 proxy. Terminals, SFTP, port forwards and the CLI all follow it.
|
||||
- "Test connection" performs a real login and explains any failure.
|
||||
|
||||
- 分组可以任意层级嵌套,支持拖动整理和搜索。
|
||||
- 连上主机后自动识别远程系统,在主机树和标签上显示对应的系统徽章(各 Linux 发行版、BSD、macOS、Windows)。
|
||||
- 认证方式三选一:密码、无密码(依次尝试服务器免认证、SSH Agent 和 `~/.ssh` 下的默认私钥)、使用凭据。
|
||||
- 连接方式三选一:直接连接、多级 SSH 跳板、HTTP / SOCKS5 代理。终端、SFTP、端口转发和外部 CLI 都按它连接。
|
||||
- 「测试连接」会真实登录一次,失败时写明原因。
|
||||
**Terminal**
|
||||
|
||||
**终端**
|
||||
- Tabbed remote and local terminals (built on `alacritty_terminal`); full-screen programs like vim and htop work as expected.
|
||||
- Each tab shows the SSH round-trip latency live.
|
||||
- Find (smart case) and local clear; configurable font, size and line height.
|
||||
- Drag tabs into side-by-side or stacked splits to watch a terminal and SFTP together.
|
||||
|
||||
- 多标签的远程终端和本地终端(基于 `alacritty_terminal`),vim、htop 等全屏程序正常显示。
|
||||
- 标签栏实时显示这条 SSH 连接的往返延迟。
|
||||
- 支持查找(智能大小写)和本地清屏,字体、字号、行高都可以设置。
|
||||
- 标签可以拖成左右或上下分栏,终端和 SFTP 并排看。
|
||||
**Right sidebar tools**
|
||||
|
||||
**右侧栏工具**
|
||||
|
||||
- 跟着当前的远程终端,在这条终端自己的连接上读取和执行,不另外登录;⌘⌥B(其他平台 Ctrl+Alt+B)显示或隐藏。
|
||||
- 系统监控:系统信息、CPU(含每个核)、内存、网卡速率和磁盘占用,每 2 秒刷新。
|
||||
- 网络连接、进程管理和系统服务:搜索和筛选,查看进程详情、结束进程,启动、停止、重启 systemd 服务,开关开机启动,查看日志。这三项和系统监控只支持 Linux。
|
||||
- Docker:按 compose 项目列出容器,启停、重启、看详情和日志,管理卷、镜像和网络。不是 root 时用免密码的 sudo(系统服务也是)。
|
||||
- 历史命令读主机上的 `~/.bash_history`,命令片段所有主机共用、可以分类;点一下输入到终端,或直接执行。
|
||||
- They follow the current remote terminal and read and run on that terminal's own connection, with no second login. ⌘⌥B (Ctrl+Alt+B elsewhere) shows or hides them.
|
||||
- System monitor: system details, CPU (per core too), memory, network rates and disk use, refreshed every 2 seconds.
|
||||
- Network connections, processes and services: search and filter, process details, ending processes, starting, stopping and restarting systemd services, switching them on at boot, and their logs. These three and the monitor are for Linux only.
|
||||
- Docker: containers grouped by compose project, with start, stop, restart, details and logs, plus volumes, images and networks. When not root, passwordless sudo is used (for services as well).
|
||||
- Command history reads `~/.bash_history` on the host, and snippets, shared by every host, can be sorted into categories; click one to type it into the terminal, or run it straight away.
|
||||
|
||||
**SFTP**
|
||||
|
||||
- 对标 WinSCP Commander 的双栏文件浏览器:本地和远程两侧对称,WinSCP 的列、路径标签、书签和快捷键(F5、F2、F7、F8……)。
|
||||
- 拖动或快捷键上传、下载,支持递归。传输进行中还能继续发起,新批次进传输队列依次执行。
|
||||
- 断点续传:先写 `.filepart`,断线后按 1、3、10 秒自动重连;重启后再传同样的文件,会询问是否续传。
|
||||
- 删除(本地移到废纸篓)、重命名、新建、修改权限(3×3 复选框加八进制,可递归)。
|
||||
- 内置编辑器:双击文本文件直接编辑,⌘S 原地写回服务器,保存前检查文件是否被别人改过;常见格式语法高亮。图片和 Markdown 可以预览。
|
||||
- 每个 SFTP 标签单独建立连接,只请求 SFTP 子系统,不要求服务器提供 shell。
|
||||
- A dual-pane browser modeled on WinSCP Commander: symmetric local and remote panes with WinSCP's columns, path labels, bookmarks and shortcuts (F5, F2, F7, F8…).
|
||||
- Upload and download by dragging or by shortcut, recursively. Start new transfers while one runs; they wait in a per-tab transfer queue.
|
||||
- Resumable transfers: data goes to a `.filepart` first, dropped connections are retried after 1, 3 and 10 seconds, and after a restart ShellRS offers to resume the same transfer.
|
||||
- Delete (local items go to the Trash), rename, create, and change permissions (a 3×3 grid plus octal, optionally recursive).
|
||||
- Built-in editor: double-click a text file to edit it, ⌘S writes it back in place, after checking nobody else changed it; syntax highlighting for common formats. Images and Markdown can be previewed.
|
||||
- Each SFTP tab has its own connection and asks only for the SFTP subsystem, so no shell is needed on the server.
|
||||
|
||||
**从堡垒机打开**
|
||||
**Opening from a bastion host**
|
||||
|
||||
- 兼容 Xshell 和 WinSCP 的命令行调用方式:在 JumpServer 等堡垒机的客户端里把 ShellRS 设成 SSH / SFTP 客户端,`ssh://` 链接打开终端,`sftp://` 链接打开 SFTP。
|
||||
- 认得 Xshell 的 `-url`、`-newtab` 和 WinSCP 的 `/sessionname=`;ShellRS 已在运行时,链接交给正在运行的那个打开。
|
||||
- 打开的是不保存的外部连接:不进主机列表,链接里的密码只留在内存里,标签关掉就没了。每条连接只用一个通道,不允许多开通道的堡垒机也能用。详见[使用手册](docs/manual.md#从堡垒机打开外部连接)。
|
||||
- Launched the way Xshell and WinSCP are: in the client of a bastion host such as JumpServer, set ShellRS as the SSH or SFTP client. An `ssh://` link opens a terminal, an `sftp://` link an SFTP tab.
|
||||
- Understands Xshell's `-url` and `-newtab` and WinSCP's `/sessionname=`. When ShellRS is already running, the link opens there.
|
||||
- What opens is an unsaved external connection: it stays out of the host list, keeps the link's password in memory only and goes away with its tabs. Each connection uses a single channel, so bastion hosts that allow no more still work. See the [user manual](docs/manual.en.md#opening-from-a-bastion-host-external-connections).
|
||||
|
||||
**端口转发**
|
||||
**Port forwarding**
|
||||
|
||||
- 本地(`-L`)、远程(`-R`)和动态(`-D`,SOCKS5 / SOCKS4)转发。
|
||||
- 配置对话框里有实时示意图和一句话说明,看得出连接从哪进、到哪去。
|
||||
- 每条规则自己建立 SSH 连接,断线后自动重连三次;可以设为随 ShellRS 启动自动开启。
|
||||
- Local (`-L`), remote (`-R`) and dynamic (`-D`, SOCKS5 / SOCKS4) forwards.
|
||||
- The rule dialog draws a live diagram and a one-line explanation of where connections enter and where they go.
|
||||
- Every rule keeps its own SSH connection and reconnects three times after a drop; rules can start with ShellRS.
|
||||
|
||||
**凭据**
|
||||
**Credentials**
|
||||
|
||||
- 密码、密钥、SSH Agent 三种凭据,多台主机可以共用一条;改凭据,用它的主机一起变。
|
||||
- 密钥可以引用本机文件、直接粘贴,或当场生成 Ed25519 / RSA 4096,并复制公钥。
|
||||
- Password, key and SSH agent credentials that many hosts can share. Change a credential and every host using it follows.
|
||||
- Keys can reference a file on disk, be pasted in, or be generated on the spot (Ed25519 or RSA 4096), with the public key one click away.
|
||||
|
||||
**给 AI Agent 用的 CLI**
|
||||
**A CLI for AI agents**
|
||||
|
||||
- `shellrs hosts` / `credentials` / `exec` / `upload` / `download` / `sync`:管理主机和凭据、执行命令、传输和同步文件。由正在运行的 ShellRS 代为登录,命令本身不读密码和私钥。
|
||||
- 在设置页一键把 `shellrs` 放进 PATH,并为 Claude Code、Codex、OpenCode、WorkBuddy 安装 Agent Skill。
|
||||
- `shellrs hosts` / `credentials` / `exec` / `upload` / `download` / `sync`: manage hosts and credentials, run commands, move and sync files. The running ShellRS logs in on the command's behalf, so the command itself never reads a password or private key.
|
||||
- Install `shellrs` into your PATH and an Agent Skill for Claude Code, Codex, OpenCode or WorkBuddy from the settings page.
|
||||
|
||||
**安全与更新**
|
||||
**Security and updates**
|
||||
|
||||
- 秘密只进系统钥匙串;主机信任记录存在 ShellRS 自己的 `known_hosts` 里,不改动 `~/.ssh`。
|
||||
- 在线升级:更新清单经 minisign 签名,安装包按 SHA-256 校验;后台下载,重启或退出时安装。分稳定版和 Beta 两个渠道,检查更新只发送版本号、系统和 CPU 架构。
|
||||
- Secrets stay in the system keychain. Host keys are recorded in ShellRS's own `known_hosts`, never in `~/.ssh`.
|
||||
- In-app updates: the update manifest is minisign-signed and each package is checked against its SHA-256. Updates download in the background and install on restart or quit. There are stable and beta channels, and an update check sends only the version, OS and CPU architecture.
|
||||
- Anonymous usage statistics: sent to Aptabase with only the version, OS, CPU architecture and how often each feature is used, never hosts, credentials, commands or any identifier. Turn them off in Settings › About.
|
||||
|
||||
## 截图
|
||||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/split.png" alt="Dock 分栏:上面是远程终端,下面是同一台主机的 SFTP"><br>
|
||||
<em>Dock 分栏:远程终端和 SFTP 上下并排</em>
|
||||
<img src="docs/screenshots/en/split.png" alt="A split view: a remote terminal on top and SFTP for the same host below"><br>
|
||||
<em>Dock splits: a remote terminal and SFTP stacked in one window</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/tools.png" alt="右侧栏的五个工具并排:系统监控、进程管理、系统服务、Docker 和网络连接"><br>
|
||||
<em>右侧栏工具:系统监控、进程管理、系统服务、Docker 和网络连接</em>
|
||||
<img src="docs/screenshots/en/tools.png" alt="Five right sidebar tools side by side: Monitor, Processes, Services, Docker and Network"><br>
|
||||
<em>Right sidebar tools: Monitor, Processes, Services, Docker and Network</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/sftp.png" alt="双栏 SFTP:左侧本地目录多选了三项,右侧是远程 /etc,下方传输队列里一批正在上传、一批等待中"><br>
|
||||
<em>WinSCP 式双栏 SFTP:高亮多选,传输队列显示进度、速度和剩余时间</em>
|
||||
<img src="docs/screenshots/en/sftp.png" alt="Dual-pane SFTP: three local items selected on the left, remote /etc on the right, and a transfer queue with one batch uploading and one waiting"><br>
|
||||
<em>WinSCP-style SFTP: highlight selection and a transfer queue with progress, speed and time left</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/forward.png" alt="编辑端口转发对话框:本地转发的示意图和说明,左侧转发列表里两条规则正在运行"><br>
|
||||
<em>端口转发:示意图实时说明连接怎么走</em>
|
||||
<img src="docs/screenshots/en/forward.png" alt="Edit port forward dialog with the diagram and explanation of a local forward, and two running rules in the forward list"><br>
|
||||
<em>Port forwarding: the diagram shows where each connection goes</em>
|
||||
</p>
|
||||
|
||||
## 下载
|
||||
## Download
|
||||
|
||||
到官网 [shellrs.com/download](https://shellrs.com/download) 下载,页面会按你的系统给出对应的安装包;也可以到 GitHub [Releases](https://github.com/since2006/shell-rs/releases) 下载。Beta 版在 Releases 里标为 Pre-release,想提前用上新功能,可以在「设置 › 关于」里把更新渠道切到 Beta。
|
||||
Download from [shellrs.com/download](https://shellrs.com/download), which offers the package for your system, or from GitHub [Releases](https://github.com/since2006/shell-rs/releases). Betas are marked Pre-release there; to get new features early, switch the update channel to Beta in Settings › About.
|
||||
|
||||
| 系统 | 安装包 | 说明 |
|
||||
| System | Package | Notes |
|
||||
| --- | --- | --- |
|
||||
| macOS 11+(Apple Silicon 和 Intel) | `ShellRS-<版本>-macos-universal.dmg` | 已签名并经过 Apple 公证。打开 DMG,把 ShellRS 拖进「应用程序」 |
|
||||
| Windows x64 | `ShellRS-<版本>-windows-x86_64-setup.exe` | 按当前用户安装,不需要管理员权限。安装程序暂未签名,SmartScreen 拦下时点「更多信息 › 仍要运行」 |
|
||||
| Linux x64 | `ShellRS-<版本>-linux-x86_64.AppImage` | `chmod +x` 后直接运行,需要 glibc 2.35 以上(Ubuntu 22.04、Debian 12 及更新的版本) |
|
||||
| macOS 11+ (Apple Silicon and Intel) | `ShellRS-<version>-macos-universal.dmg` | Signed and notarized. Open the DMG and drag ShellRS into Applications |
|
||||
| Windows x64 | `ShellRS-<version>-windows-x86_64-setup.exe` | Installs per user, no administrator rights needed. The installer is not signed yet; if SmartScreen stops it, choose "More info › Run anyway" |
|
||||
| Linux x64 | `ShellRS-<version>-linux-x86_64.AppImage` | `chmod +x` and run. Needs glibc 2.35 or newer (Ubuntu 22.04, Debian 12 and later) |
|
||||
|
||||
每个安装包都附带 minisign 签名(`.minisig`),另有一份 `SHA256SUMS`。
|
||||
Every package comes with a minisign signature (`.minisig`), and each release has a `SHA256SUMS` file.
|
||||
|
||||
装好之后不用再手动下载:ShellRS 会在后台检查、下载并校验新版本,标题栏右上角出现提示后点「重启并安装」;不点的话,退出时也会自动装好。直接在 DMG 里运行、没放进「应用程序」文件夹,或者在 Linux 上不是用 AppImage 运行时,无法自动更新。
|
||||
Once installed, ShellRS keeps itself up to date: it checks, downloads and verifies new versions in the background, then shows an icon in the top-right corner of the title bar; click it and choose "Restart to update", or just quit and the update is installed on the way out. Automatic updates are unavailable when ShellRS runs from inside the DMG or outside the Applications folder, or on Linux when it isn't run as the AppImage.
|
||||
|
||||
## 快速上手
|
||||
## Getting started
|
||||
|
||||
1. 点标题栏的「新建主机…」(⌘N,Windows / Linux 上 Ctrl+N),填写地址、端口和认证方式,密码会存进系统钥匙串。
|
||||
2. 双击主机打开终端。第一次连接时会请你确认主机密钥。
|
||||
3. 右键主机选「打开 SFTP」,或点终端标签栏右侧的「SFTP」,在两栏之间拖动文件即可上传、下载。
|
||||
4. 标题栏「ShellRS」后面的三个图标切换侧栏:主机、端口转发、凭据。
|
||||
5. ⌘T(Ctrl+T)打开本地终端。把标签拖到中间区域的上、下、左、右边缘,就能分栏。
|
||||
1. Click "New host…" in the title bar (⌘N, or Ctrl+N on Windows and Linux) and fill in the address, port and login. Passwords go into the system keychain.
|
||||
2. Double-click a host to open a terminal. On the first connection you're asked to confirm the host key.
|
||||
3. Right-click a host and choose "Open SFTP", or click "SFTP" at the right of the terminal's tab bar. Drag files between the two panes to transfer them.
|
||||
4. The three icons after "ShellRS" in the title bar switch the sidebar between hosts, port forwards and credentials.
|
||||
5. ⌘T (Ctrl+T) opens a local terminal. Drag a tab to the top, bottom, left or right edge of the tab area to split it.
|
||||
|
||||
## 给 AI Agent 用
|
||||
## For AI agents
|
||||
|
||||
在「设置 › 外部 CLI」里打开「启用外部 CLI」,安装 `shellrs` 命令和 Agent Skills。之后 Agent 就能直接用你保存的主机:
|
||||
In Settings › External CLI, turn on Enable external CLI, then install the `shellrs` command and the Agent Skills. Your agent can then use the hosts you have saved:
|
||||
|
||||
```sh
|
||||
shellrs hosts list -q web # 列出主机和它们的 ID
|
||||
shellrs exec <ID> "uptime && df -h" # 执行命令,退出码就是远程命令的退出码
|
||||
shellrs upload <ID> ./dist/app.tar.gz /opt/app/ # 上传
|
||||
shellrs download <ID> /var/log/syslog ./logs/ # 下载
|
||||
shellrs sync <ID> ./dist /opt/app --delete # 同步目录,没变的文件跳过
|
||||
shellrs hosts list -q web # list hosts and their IDs
|
||||
shellrs exec <ID> "uptime && df -h" # run a command; the exit code is the remote one
|
||||
shellrs upload <ID> ./dist/app.tar.gz /opt/app/ # upload
|
||||
shellrs download <ID> /var/log/syslog ./logs/ # download
|
||||
shellrs sync <ID> ./dist /opt/app --delete # sync a folder, skipping unchanged files
|
||||
```
|
||||
|
||||
命令把请求交给正在运行的 ShellRS,由它用保存的配置和钥匙串登录。遇到陌生主机或缺少密码时,命令直接失败、返回稳定的错误码,不会弹框卡住 Agent。详见[使用手册](docs/manual.md#外部-cli)。
|
||||
The command hands each request to the running ShellRS, which logs in with the saved settings and keychain. An unknown host or a missing password fails right away with a stable error code instead of a prompt that would hang the agent. See the [user manual](docs/manual.en.md#external-cli) for details.
|
||||
|
||||
## 从源码构建
|
||||
## Building from source
|
||||
|
||||
需要先装 [rustup](https://rustup.rs)。仓库里的 `rust-toolchain.toml` 固定了 Rust 1.98.1,第一次构建时会自动安装。
|
||||
Install [rustup](https://rustup.rs). The repository's `rust-toolchain.toml` pins Rust 1.98.1, which rustup installs on the first build.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/since2006/shell-rs.git
|
||||
@@ -160,29 +158,30 @@ cd shell-rs
|
||||
cargo run
|
||||
```
|
||||
|
||||
首次构建要编译整个 GPUI 栈,需要一段时间。Linux 需要的系统依赖和测试方法见[开发与测试](docs/development.md)。
|
||||
The first build compiles the whole GPUI stack and takes a while. See [development notes](docs/development.md) (in Chinese) for the Linux system packages and how to run the tests.
|
||||
|
||||
## 文档
|
||||
## Documentation
|
||||
|
||||
- [使用手册](docs/manual.md):每个功能的详细说明和快捷键
|
||||
- [开发与测试](docs/development.md):构建、测试和数据目录
|
||||
- [发布流程](docs/release.md):打包、签名和在线更新的发布步骤
|
||||
- [更新日志](CHANGELOG.md)
|
||||
Apart from the user manual, the documentation is in Chinese for now:
|
||||
|
||||
## 路线图
|
||||
- [User manual](docs/manual.en.md): every feature in detail, with shortcuts
|
||||
- [Development](docs/development.md): building, testing, the data directory
|
||||
- [Releasing](docs/release.md): packaging, signing and publishing updates
|
||||
- [Changelog](CHANGELOG.md)
|
||||
|
||||
以下功能尚未实现:
|
||||
## Roadmap
|
||||
|
||||
- 英文界面(目前只有 GPUI Kit 组件自带的文字会切换语言)
|
||||
- SFTP 的目录同步、过滤、查找文件和目录树
|
||||
- Dock 布局在重启后保留
|
||||
- 跳板和代理叠加使用、HTTPS 代理
|
||||
- 通过外部 CLI 管理端口转发和凭据
|
||||
- Windows 安装程序的代码签名
|
||||
Not implemented yet:
|
||||
|
||||
## 参与贡献
|
||||
- Directory sync, filtering, file search and a directory tree in SFTP
|
||||
- Keeping the Dock layout across restarts
|
||||
- Combining a jump host chain with a proxy, and HTTPS proxies
|
||||
- Managing port forwards and credentials through the external CLI
|
||||
- Code signing for the Windows installer
|
||||
|
||||
欢迎提交 [Issue](https://github.com/since2006/shell-rs/issues) 和 Pull Request。架构说明和代码约定见 [CLAUDE.md](CLAUDE.md)。提交前请确认下面三条都通过:
|
||||
## Contributing
|
||||
|
||||
Issues and pull requests are welcome on [GitHub](https://github.com/since2006/shell-rs/issues). Architecture notes and code conventions are in [CLAUDE.md](CLAUDE.md) (in Chinese). Before sending a change, make sure these pass:
|
||||
|
||||
```sh
|
||||
cargo fmt --check
|
||||
@@ -190,15 +189,15 @@ cargo clippy --all-targets -- -D warnings
|
||||
cargo test
|
||||
```
|
||||
|
||||
## 致谢
|
||||
## Acknowledgements
|
||||
|
||||
- [GPUI](https://github.com/zed-industries/zed)(Zed)和 [gpui-kit](https://gpui-kit.com):界面框架
|
||||
- [russh](https://github.com/Eugeny/russh) 和 [russh-sftp](https://github.com/AspectUnk/russh-sftp):SSH 与 SFTP 协议
|
||||
- [alacritty_terminal](https://crates.io/crates/alacritty_terminal) 和 [portable-pty](https://crates.io/crates/portable-pty):终端模拟与本地 PTY
|
||||
- [keyring](https://crates.io/crates/keyring):系统钥匙串
|
||||
- [Simple Icons](https://simpleicons.org):系统徽章和右侧栏 Docker 工具使用的 logo(CC0)
|
||||
- [WinSCP](https://winscp.net) 和 Xshell:交互设计的参照
|
||||
- [GPUI](https://github.com/zed-industries/zed) (Zed) and [gpui-kit](https://gpui-kit.com): the UI framework
|
||||
- [russh](https://github.com/Eugeny/russh) and [russh-sftp](https://github.com/AspectUnk/russh-sftp): SSH and SFTP
|
||||
- [alacritty_terminal](https://crates.io/crates/alacritty_terminal) and [portable-pty](https://crates.io/crates/portable-pty): terminal emulation and local PTYs
|
||||
- [keyring](https://crates.io/crates/keyring): the system keychain
|
||||
- [Simple Icons](https://simpleicons.org): the OS logos on host badges and the Docker logo in the right sidebar (CC0)
|
||||
- [WinSCP](https://winscp.net) and Xshell: the interaction designs ShellRS follows
|
||||
|
||||
## 许可证
|
||||
## License
|
||||
|
||||
ShellRS 以 [GNU 通用公共许可证第 3 版(GPL-3.0)](LICENSE)发布。你可以自由使用、修改和再分发,包括用于商业用途;但分发修改后的版本时,必须同样以 GPL-3.0 公开完整的源代码。
|
||||
ShellRS is licensed under the [GNU General Public License v3.0](LICENSE). You may use, modify and redistribute it, commercially or not, but any modified version you distribute must come with its complete source code under the GPL-3.0 as well.
|
||||
@@ -0,0 +1,201 @@
|
||||
<p align="center">
|
||||
<img src="assets/logo/shellrs.svg" width="96" alt="ShellRS">
|
||||
</p>
|
||||
|
||||
<h1 align="center">ShellRS</h1>
|
||||
|
||||
<p align="center">
|
||||
用 Rust 编写、由 GPU 渲染界面的高性能原生跨平台 SSH 客户端:Xshell 式的主机管理、WinSCP 式 SFTP 和端口转发,都在一个窗口里。
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/since2006/shell-rs/releases"><img src="https://img.shields.io/github/v/release/since2006/shell-rs?include_prereleases&label=release" alt="Release"></a>
|
||||
<a href="#许可证"><img src="https://img.shields.io/badge/license-GPL--3.0-blue" alt="License: GPL-3.0"></a>
|
||||
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey" alt="Platform: macOS | Windows | Linux">
|
||||
<img src="https://img.shields.io/badge/rust-1.98-orange" alt="Rust 1.98">
|
||||
</p>
|
||||
|
||||
<p align="center"><a href="README.md">English</a> | <b>简体中文</b></p>
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/main-dark.png">
|
||||
<img src="docs/screenshots/main-light.png" alt="ShellRS 主界面:左侧是分组的主机树,右侧远程终端里运行着 htop,标签栏显示连接延迟">
|
||||
</picture>
|
||||
|
||||
## 简介
|
||||
|
||||
ShellRS 把 Xshell 式的主机管理和多标签终端、WinSCP 式的双栏 SFTP 文件管理、SSH 端口转发放进同一个跨平台应用,JumpServer 等堡垒机也可以像调用 Xshell、WinSCP 一样调用它。它用 Rust 编写,界面基于 Zed 编辑器的 GPU 加速 UI 框架 GPUI(通过 [gpui-kit](https://gpui-kit.com)),不依赖 Electron 或 JVM。
|
||||
|
||||
主机、分组、凭据和转发规则保存在本地的 SQLite 数据库里。密码和私钥口令只存进系统钥匙串(macOS 钥匙串、Windows 凭据管理器、Linux Secret Service),数据库里没有任何秘密。ShellRS 还带一个 `shellrs` 命令,让 Claude Code、Codex 等 AI Agent 用你保存的主机执行命令、传输文件,不用把密码交给它们。
|
||||
|
||||
## 功能
|
||||
|
||||
**主机管理**
|
||||
|
||||
- 分组可以任意层级嵌套,支持拖动整理和搜索。
|
||||
- 连上主机后自动识别远程系统,在主机树和标签上显示对应的系统徽章(各 Linux 发行版、BSD、macOS、Windows)。
|
||||
- 认证方式三选一:密码、无密码(依次尝试服务器免认证、SSH Agent 和 `~/.ssh` 下的默认私钥)、使用凭据。
|
||||
- 连接方式三选一:直接连接、多级 SSH 跳板、HTTP / SOCKS5 代理。终端、SFTP、端口转发和外部 CLI 都按它连接。
|
||||
- 「测试连接」会真实登录一次,失败时写明原因。
|
||||
|
||||
**终端**
|
||||
|
||||
- 多标签的远程终端和本地终端(基于 `alacritty_terminal`),vim、htop 等全屏程序正常显示。
|
||||
- 标签栏实时显示这条 SSH 连接的往返延迟。
|
||||
- 支持查找(智能大小写)和本地清屏,字体、字号、行高都可以设置。
|
||||
- 标签可以拖成左右或上下分栏,终端和 SFTP 并排看。
|
||||
|
||||
**右侧栏工具**
|
||||
|
||||
- 跟着当前的远程终端,在这条终端自己的连接上读取和执行,不另外登录;⌘⌥B(其他平台 Ctrl+Alt+B)显示或隐藏。
|
||||
- 系统监控:系统信息、CPU(含每个核)、内存、网卡速率和磁盘占用,每 2 秒刷新。
|
||||
- 网络连接、进程管理和系统服务:搜索和筛选,查看进程详情、结束进程,启动、停止、重启 systemd 服务,开关开机启动,查看日志。这三项和系统监控只支持 Linux。
|
||||
- Docker:按 compose 项目列出容器,启停、重启、看详情和日志,管理卷、镜像和网络。不是 root 时用免密码的 sudo(系统服务也是)。
|
||||
- 历史命令读主机上的 `~/.bash_history`,命令片段所有主机共用、可以分类;点一下输入到终端,或直接执行。
|
||||
|
||||
**SFTP**
|
||||
|
||||
- 对标 WinSCP Commander 的双栏文件浏览器:本地和远程两侧对称,WinSCP 的列、路径标签、书签和快捷键(F5、F2、F7、F8……)。
|
||||
- 拖动或快捷键上传、下载,支持递归。传输进行中还能继续发起,新批次进传输队列依次执行。
|
||||
- 断点续传:先写 `.filepart`,断线后按 1、3、10 秒自动重连;重启后再传同样的文件,会询问是否续传。
|
||||
- 删除(本地移到废纸篓)、重命名、新建、修改权限(3×3 复选框加八进制,可递归)。
|
||||
- 内置编辑器:双击文本文件直接编辑,⌘S 原地写回服务器,保存前检查文件是否被别人改过;常见格式语法高亮。图片和 Markdown 可以预览。
|
||||
- 每个 SFTP 标签单独建立连接,只请求 SFTP 子系统,不要求服务器提供 shell。
|
||||
|
||||
**从堡垒机打开**
|
||||
|
||||
- 兼容 Xshell 和 WinSCP 的命令行调用方式:在 JumpServer 等堡垒机的客户端里把 ShellRS 设成 SSH / SFTP 客户端,`ssh://` 链接打开终端,`sftp://` 链接打开 SFTP。
|
||||
- 认得 Xshell 的 `-url`、`-newtab` 和 WinSCP 的 `/sessionname=`;ShellRS 已在运行时,链接交给正在运行的那个打开。
|
||||
- 打开的是不保存的外部连接:不进主机列表,链接里的密码只留在内存里,标签关掉就没了。每条连接只用一个通道,不允许多开通道的堡垒机也能用。详见[使用手册](docs/manual.md#从堡垒机打开外部连接)。
|
||||
|
||||
**端口转发**
|
||||
|
||||
- 本地(`-L`)、远程(`-R`)和动态(`-D`,SOCKS5 / SOCKS4)转发。
|
||||
- 配置对话框里有实时示意图和一句话说明,看得出连接从哪进、到哪去。
|
||||
- 每条规则自己建立 SSH 连接,断线后自动重连三次;可以设为随 ShellRS 启动自动开启。
|
||||
|
||||
**凭据**
|
||||
|
||||
- 密码、密钥、SSH Agent 三种凭据,多台主机可以共用一条;改凭据,用它的主机一起变。
|
||||
- 密钥可以引用本机文件、直接粘贴,或当场生成 Ed25519 / RSA 4096,并复制公钥。
|
||||
|
||||
**给 AI Agent 用的 CLI**
|
||||
|
||||
- `shellrs hosts` / `credentials` / `exec` / `upload` / `download` / `sync`:管理主机和凭据、执行命令、传输和同步文件。由正在运行的 ShellRS 代为登录,命令本身不读密码和私钥。
|
||||
- 在设置页一键把 `shellrs` 放进 PATH,并为 Claude Code、Codex、OpenCode、WorkBuddy 安装 Agent Skill。
|
||||
|
||||
**安全与更新**
|
||||
|
||||
- 秘密只进系统钥匙串;主机信任记录存在 ShellRS 自己的 `known_hosts` 里,不改动 `~/.ssh`。
|
||||
- 在线升级:更新清单经 minisign 签名,安装包按 SHA-256 校验;后台下载,重启或退出时安装。分稳定版和 Beta 两个渠道,检查更新只发送版本号、系统和 CPU 架构。
|
||||
- 匿名使用统计:经 Aptabase 只发送版本、系统、CPU 架构和各功能的使用次数,不含主机、凭据、命令或任何标识,可在「设置 › 关于」关闭。详见[使用手册](docs/manual.md#隐私)。
|
||||
|
||||
## 截图
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/split.png" alt="Dock 分栏:上面是远程终端,下面是同一台主机的 SFTP"><br>
|
||||
<em>Dock 分栏:远程终端和 SFTP 上下并排</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/tools.png" alt="右侧栏的五个工具并排:系统监控、进程管理、系统服务、Docker 和网络连接"><br>
|
||||
<em>右侧栏工具:系统监控、进程管理、系统服务、Docker 和网络连接</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/sftp.png" alt="双栏 SFTP:左侧本地目录多选了三项,右侧是远程 /etc,下方传输队列里一批正在上传、一批等待中"><br>
|
||||
<em>WinSCP 式双栏 SFTP:高亮多选,传输队列显示进度、速度和剩余时间</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/forward.png" alt="编辑端口转发对话框:本地转发的示意图和说明,左侧转发列表里两条规则正在运行"><br>
|
||||
<em>端口转发:示意图实时说明连接怎么走</em>
|
||||
</p>
|
||||
|
||||
## 下载
|
||||
|
||||
到官网 [shellrs.com/download](https://shellrs.com/download) 下载,页面会按你的系统给出对应的安装包;也可以到 GitHub [Releases](https://github.com/since2006/shell-rs/releases) 下载。Beta 版在 Releases 里标为 Pre-release,想提前用上新功能,可以在「设置 › 关于」里把更新渠道切到 Beta。
|
||||
|
||||
| 系统 | 安装包 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| macOS 11+(Apple Silicon 和 Intel) | `ShellRS-<版本>-macos-universal.dmg` | 已签名并经过 Apple 公证。打开 DMG,把 ShellRS 拖进「应用程序」 |
|
||||
| Windows x64 | `ShellRS-<版本>-windows-x86_64-setup.exe` | 按当前用户安装,不需要管理员权限。安装程序暂未签名,SmartScreen 拦下时点「更多信息 › 仍要运行」 |
|
||||
| Linux x64 | `ShellRS-<版本>-linux-x86_64.AppImage` | `chmod +x` 后直接运行,需要 glibc 2.35 以上(Ubuntu 22.04、Debian 12 及更新的版本) |
|
||||
|
||||
每个安装包都附带 minisign 签名(`.minisig`),另有一份 `SHA256SUMS`。
|
||||
|
||||
装好之后不用再手动下载:ShellRS 会在后台检查、下载并校验新版本,标题栏右上角出现提示后点「重启并安装」;不点的话,退出时也会自动装好。直接在 DMG 里运行、没放进「应用程序」文件夹,或者在 Linux 上不是用 AppImage 运行时,无法自动更新。
|
||||
|
||||
## 快速上手
|
||||
|
||||
1. 点标题栏的「新建主机…」(⌘N,Windows / Linux 上 Ctrl+N),填写地址、端口和认证方式,密码会存进系统钥匙串。
|
||||
2. 双击主机打开终端。第一次连接时会请你确认主机密钥。
|
||||
3. 右键主机选「打开 SFTP」,或点终端标签栏右侧的「SFTP」,在两栏之间拖动文件即可上传、下载。
|
||||
4. 标题栏「ShellRS」后面的三个图标切换侧栏:主机、端口转发、凭据。
|
||||
5. ⌘T(Ctrl+T)打开本地终端。把标签拖到中间区域的上、下、左、右边缘,就能分栏。
|
||||
|
||||
## 给 AI Agent 用
|
||||
|
||||
在「设置 › 外部 CLI」里打开「启用外部 CLI」,安装 `shellrs` 命令和 Agent Skills。之后 Agent 就能直接用你保存的主机:
|
||||
|
||||
```sh
|
||||
shellrs hosts list -q web # 列出主机和它们的 ID
|
||||
shellrs exec <ID> "uptime && df -h" # 执行命令,退出码就是远程命令的退出码
|
||||
shellrs upload <ID> ./dist/app.tar.gz /opt/app/ # 上传
|
||||
shellrs download <ID> /var/log/syslog ./logs/ # 下载
|
||||
shellrs sync <ID> ./dist /opt/app --delete # 同步目录,没变的文件跳过
|
||||
```
|
||||
|
||||
命令把请求交给正在运行的 ShellRS,由它用保存的配置和钥匙串登录。遇到陌生主机或缺少密码时,命令直接失败、返回稳定的错误码,不会弹框卡住 Agent。详见[使用手册](docs/manual.md#外部-cli)。
|
||||
|
||||
## 从源码构建
|
||||
|
||||
需要先装 [rustup](https://rustup.rs)。仓库里的 `rust-toolchain.toml` 固定了 Rust 1.98.1,第一次构建时会自动安装。
|
||||
|
||||
```sh
|
||||
git clone https://github.com/since2006/shell-rs.git
|
||||
cd shell-rs
|
||||
cargo run
|
||||
```
|
||||
|
||||
首次构建要编译整个 GPUI 栈,需要一段时间。Linux 需要的系统依赖和测试方法见[开发与测试](docs/development.md)。
|
||||
|
||||
## 文档
|
||||
|
||||
- [使用手册](docs/manual.md):每个功能的详细说明和快捷键
|
||||
- [开发与测试](docs/development.md):构建、测试和数据目录
|
||||
- [发布流程](docs/release.md):打包、签名和在线更新的发布步骤
|
||||
- [更新日志](CHANGELOG.md)
|
||||
|
||||
## 路线图
|
||||
|
||||
以下功能尚未实现:
|
||||
|
||||
- SFTP 的目录同步、过滤、查找文件和目录树
|
||||
- Dock 布局在重启后保留
|
||||
- 跳板和代理叠加使用、HTTPS 代理
|
||||
- 通过外部 CLI 管理端口转发和凭据
|
||||
- Windows 安装程序的代码签名
|
||||
|
||||
## 参与贡献
|
||||
|
||||
欢迎提交 [Issue](https://github.com/since2006/shell-rs/issues) 和 Pull Request。架构说明和代码约定见 [CLAUDE.md](CLAUDE.md)。提交前请确认下面三条都通过:
|
||||
|
||||
```sh
|
||||
cargo fmt --check
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo test
|
||||
```
|
||||
|
||||
## 致谢
|
||||
|
||||
- [GPUI](https://github.com/zed-industries/zed)(Zed)和 [gpui-kit](https://gpui-kit.com):界面框架
|
||||
- [russh](https://github.com/Eugeny/russh) 和 [russh-sftp](https://github.com/AspectUnk/russh-sftp):SSH 与 SFTP 协议
|
||||
- [alacritty_terminal](https://crates.io/crates/alacritty_terminal) 和 [portable-pty](https://crates.io/crates/portable-pty):终端模拟与本地 PTY
|
||||
- [keyring](https://crates.io/crates/keyring):系统钥匙串
|
||||
- [Simple Icons](https://simpleicons.org):系统徽章和右侧栏 Docker 工具使用的 logo(CC0)
|
||||
- [WinSCP](https://winscp.net) 和 Xshell:交互设计的参照
|
||||
|
||||
## 许可证
|
||||
|
||||
ShellRS 以 [GNU 通用公共许可证第 3 版(GPL-3.0)](LICENSE)发布。你可以自由使用、修改和再分发,包括用于商业用途;但分发修改后的版本时,必须同样以 GPL-3.0 公开完整的源代码。
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
// The text `rust_i18n::i18n!` reads while compiling, which the macro
|
||||
// does not tell Cargo about.
|
||||
println!("cargo:rerun-if-changed=locales");
|
||||
println!("cargo:rerun-if-changed=assets/logo/shellrs.ico");
|
||||
#[cfg(windows)]
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 开发与测试
|
||||
|
||||
[返回 README](../README.md)
|
||||
[返回 README](../README.zh-CN.md)
|
||||
|
||||
## 构建
|
||||
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
# ShellRS user manual
|
||||
|
||||
[Back to README](../README.md)
|
||||
|
||||
An SSH host manager built on GPUI Kit, with saved hosts and groups, login credentials that several hosts can share, local terminals, multiple SSH terminals, a WinSCP-style dual-pane SFTP browser and SSH port forwarding.
|
||||
|
||||
The three icons after “ShellRS” in the title bar switch what the left sidebar shows: the host list, the port forwarding list or the credential list. When the sidebar is hidden, clicking one of them shows it again.
|
||||
|
||||
Only one ShellRS runs at a time: opening it again while it’s running brings the existing window to the front, and the newly launched copy quits. Two copies would each load the hosts into memory and write them back to the same database, overwriting each other’s changes. (Copies pointed at different data folders with `SHELLRS_DATA_DIR` count as different and can run side by side.) To see two terminals or SFTP tabs side by side, drag a tab into a split.
|
||||
|
||||
On macOS, the close button at the top left of the window only hides ShellRS: open connections, SFTP transfers, port forwards and the external CLI keep running. Click the Dock icon to bring the window back with all its tabs. To actually quit, press ⌘Q or right-click the Dock icon and choose Quit. On Windows and Linux, closing the window quits.
|
||||
|
||||
## Hosts
|
||||
|
||||
Each item saved in the left sidebar is a host: an address, a port and a way to authenticate. To log in to the same machine as different users, save it as two hosts.
|
||||
|
||||
The host dialog’s “Authentication” has three options:
|
||||
|
||||
- **Password**: enter a username and password; the password is kept in the system keychain. Leave the password empty to be asked at each connection. Only the password is used: the SSH agent and private keys aren’t tried first.
|
||||
- **Credential**: choose a saved credential. The username and the way to log in both come from the credential (see “Credentials” below), so the dialog no longer asks for a username or password. “Test connection” also uses the password saved in the credential. To log in with a private key, create a key credential.
|
||||
- **No password**: enter only the username. ShellRS tries logging in without authentication, then the SSH agent, then the default private keys in `~/.ssh` (`id_ed25519`, `id_ecdsa`, `id_rsa`), the same as the `ssh` command does by default. If the server asks for a password, the connection fails with the reason instead of asking; if the server asks for a verification code after the key, you’re still asked for it.
|
||||
|
||||
Whichever you choose, ShellRS first asks the server whether it allows logging in without authentication, and logs straight in if it does. If “Test connection” runs with “Password” chosen but no password entered, it tries what “No password” would (no authentication, the SSH agent, the default private keys) and explains why if it can’t connect.
|
||||
|
||||
“Connection”, below “Group” in the host dialog, decides how to reach the host. Terminals, SFTP, port forwarding, the external CLI and “Test connection” all follow it:
|
||||
|
||||
- **Direct**: the default. This computer connects straight to the host’s address and port.
|
||||
- **Jump hosts**: connects through other saved hosts, with as many hops as needed. A row of labels in the box shows the whole route (“This computer → aliyun-99 → zentao → This host”), and below it each jump host has a row: “×” removes it, and “Add jump host” searches by name or address. To change the order, remove jump hosts and add them again. ShellRS logs in to the first jump host, which connects to the second, and so on, until the last one connects to the host. Each jump host uses its own address, port, authentication (password, credential or no password) and known hosts entries; on the first connection you’re asked about each one’s host key, and the prompt says which jump host it is. A jump host’s own “Connection” has no effect here: for several hops, list them all here in order. Enter the host’s address as the jump hosts’ network sees it, such as an internal address.
|
||||
- **Proxy**: connects through an HTTP proxy (`CONNECT`) or a SOCKS5 proxy, chosen in the drop-down. Enter the proxy’s address and port (such as `127.0.0.1:7890` on this computer); if the proxy needs authentication, also enter a username and password. The password is kept in the system keychain and shared by hosts that go through the same proxy with the same username. The proxy resolves the host’s address. If the proxy requires authentication but has no password, or rejects the username and password, the connection fails with the reason instead of asking.
|
||||
|
||||
When you change a host’s address or authentication, hosts that jump through it reconnect too (changing only the name doesn’t). When you delete a host that other hosts use as a jump host, the confirmation says how many hosts use it. Afterwards their jump host lists keep a “Deleted host” row, and connecting fails right away instead of quietly connecting directly or skipping a hop; edit those hosts to remove it or choose another. Open connections aren’t affected.
|
||||
|
||||
**Not implemented yet: combining jump hosts with a proxy, reordering jump hosts by dragging, HTTPS proxies, and showing the connection type in the host list.**
|
||||
|
||||
The last field in the dialog is “Notes”, which can span several lines (Enter starts a new line): what the machine is for, who looks after it, when it expires and so on. Notes are kept only in the local database and don’t affect connecting.
|
||||
|
||||
Hover over a host and `user@address:port` appears at the right of its row, with its notes below if it has any (only the first 6 lines of long ones). A host’s context menu has “Connect”, “Open SFTP”, “Edit host…”, “Duplicate”, “Copy IP address” (“Copy hostname” when the address is a domain name), “Copy ID” and “Delete”. “Copy ID” copies the host’s 16-character random identifier (like `Jwg5rHvXCxw89paM`), which other tools use to identify the host: it’s generated when the host is created and stays the same when you rename the host or change its address; a duplicated host gets a new ID, and deleted IDs are never handed out again. When no tabs are open, the center area shows the “Recent” list; double-click a host or press Enter to connect. Right-clicking a host there opens the same menu and selects the host.
|
||||
|
||||
“Find a host”, to the left of “New host…” above that list, opens a search box listing every saved host: recently connected ones first, the rest by name, each row showing its group and `user@address:port`. Type part of a name, address, username, notes or group to filter; with several words separated by spaces, all of them must match. Nothing is highlighted when it opens; once you type, the first match is highlighted and Enter connects to it. You can also move with ↑↓, or click a host to connect to it.
|
||||
|
||||
The search box above the host list (⌘K, or Ctrl+K on Windows and Linux) filters hosts by name, address and username, and finds groups by name: when a group’s name matches, the group is listed with all its subgroups and hosts. Results are expanded, and matching hosts appear together with every level of group above them. Enter connects to the first matching host (in list order; hosts listed only because their group matched don’t count). Esc clears the search.
|
||||
|
||||
Drag a group or host above or below another item of the same kind to reorder items at the same level; drag a host onto a group to move it into the group, and drag a group onto the middle of another group to make it a subgroup. Drag to an empty part of the list to move an item back to the top level. Groups always come before hosts at the same level, and the order and which groups are expanded are saved in the local database. Reordering by dragging is paused while you search.
|
||||
|
||||
## Quick connect
|
||||
|
||||
“Quick connect…” at the top right of the title bar connects to a host without saving it, for machines you log in to only once. As for a new host, the dialog asks for the address, port and authentication (Password, Credential or No password); there are no Group, Connection or Notes fields, and the name is optional (the address is used instead). You can “Test connection” first. “Connect” opens a terminal tab:
|
||||
|
||||
- **Fully featured**: “Connect in new tab”, “Open SFTP”, “Reconnect” and all the right sidebar tools work. The connection is direct.
|
||||
- **Not saved**: it stays out of the host list, the start page’s Recent list and the database, and is gone once its terminal and SFTP tabs are all closed. Its tab menu has no “Edit host…”.
|
||||
- **Password in memory only**: the password you enter doesn’t go into the keychain and is reused when reconnecting; if you left it empty and the server wants a password, you’re asked. With “Credential”, the password or private key saved in the credential is used.
|
||||
- The external CLI’s `shellrs hosts list` lists it too (`"temporary": true` in the JSON, “(not saved)” in the table’s group column), and you can use its ID while its tabs are open.
|
||||
|
||||
Hosts you want to keep are created with “+” at the top of the host list, the context menu, or ⌘N (Ctrl+N on Windows and Linux).
|
||||
|
||||
## Port forwarding
|
||||
|
||||
Click the port forwarding icon in the title bar and the left sidebar switches to the list of forwards. Each forwarding rule goes through a saved host and opens an SSH connection of its own when it starts, independent of that host’s terminal or SFTP tabs: closing the tabs or choosing “Disconnect” on the host doesn’t affect it, and the host’s connection state doesn’t count it. While forwards are running, the number running appears next to the port forwarding icon in the title bar.
|
||||
|
||||
There are three kinds of forward. Think of each as “entrance → exit → destination”: a connection comes in at the entrance, goes through the SSH tunnel to the exit, and the exit connects to the destination:
|
||||
|
||||
| Type | Entrance (listens) | Exit | Use |
|
||||
| --- | --- | --- | --- |
|
||||
| Local (`ssh -L`) | This computer | Server | Reach a service on the server’s side from this computer, such as connecting a local client to a database that only the server can reach |
|
||||
| Remote (`ssh -R`) | Server | This computer | Let the server’s side reach a service on this computer, such as showing someone a site you’re developing locally, or receiving webhooks |
|
||||
| Dynamic (`ssh -D`) | This computer (SOCKS proxy) | Server | Use the server as a proxy, with the destination chosen by the app, such as browsing the web through the server |
|
||||
|
||||
- **Creating and editing**: “+” at the top of the list opens the dialog. A row of three cards at the top chooses the type; the diagram in the middle fills in the addresses as you type and marks which machine the entrance is on; and a sentence below explains what the rule does (such as “Connecting to 127.0.0.1:8080 on this computer is the same as connecting to db.internal:3306 from the SSH server.”). The “SSH server” in the diagram and the sentence is the host chosen in “Via host”; the diagram calls it by its role, not by the host’s name. A dot moves slowly along the diagram in the direction of the connection: through the SSH tunnel first, then on to the destination, pausing and starting over; switching type sends it off again from the new entrance. The name is optional; without one, the list shows a summary of the forward (`8080 → db.internal:3306`, `9000 ← localhost:3000`, `SOCKS 1080`).
|
||||
- **Starting and stopping**: the button at the end of each row, double-click the row, or select it and press Enter; the context menu also has “Edit port forward…” and “Delete”. “Create” only saves the rule and doesn’t start it. Rules with “Start when ShellRS opens” checked start along with the app.
|
||||
- **Status**: the end of the row shows “Connecting”, “Running” (with the current number of connections), “Connection lost, reconnecting”, or “Stopped:” with the reason. When a single connection can’t be forwarded (for example, the server can’t reach the destination), only that row says so, and the forward keeps running.
|
||||
- **Login**: uses the host’s settings, the password saved in the keychain and ShellRS’s own known hosts file. A host you’re connecting to for the first time, or a password that has to be entered, brings up the same prompt as in a terminal, saying which forward is connecting; cancelling the prompt stops the forward.
|
||||
- **Reconnecting after a drop**: when the connection drops, ShellRS reconnects automatically up to three times, after 1, 3 and 10 seconds. While it reconnects, the local listening port stays open, and new incoming connections are closed straight away. Automatic reconnects never prompt: if a password needs entering again or a host key needs confirming, the forward stops with the reason, and you can start it by hand. It also stops after three failed attempts, with a notification.
|
||||
- **After changes**: editing a running rule’s type, host, listening address or destination, or the address, authentication or connection of the host it goes through (including the username, type or private key file of the credential that host uses, and its jump hosts), restarts the forward with the new settings; changing only the name or auto start doesn’t.
|
||||
- **Deleting a host**: deleting a host (or a group that contains it) also deletes the forwarding rules that go through it, stopping running ones first; the confirmation says how many.
|
||||
- **Dynamic forwarding** supports SOCKS5 (no authentication, `CONNECT`, IPv4, domain names, IPv6) and SOCKS4 / 4a. Domain names are resolved by the server.
|
||||
- **The listening address** defaults to `127.0.0.1`. With another address, local and dynamic forwards can be used by other devices on the same network; for a remote forward, other machines can connect only if the server’s `sshd_config` sets `GatewayPorts clientspecified` or `yes`. The default is `no`, in which case the forward still runs without an error but listens only on the server’s own `127.0.0.1` (the same as a hand-written `ssh -R`). The dialog points this out when you enter a non-loopback address. Ports below 1024 need administrator rights (root, on the server). Port 0 (letting the system pick a port) isn’t supported.
|
||||
|
||||
**Not implemented yet: managing forwards through the external CLI, and ordering rules. The system’s “Reduce motion” setting doesn’t turn off the diagram’s animation yet.**
|
||||
|
||||
## Credentials
|
||||
|
||||
Click the credentials (key) icon in the title bar and the left sidebar switches to the list of credentials. A credential is a set of login details that several hosts can share: a name, a username and a way to log in:
|
||||
|
||||
| Type | What it keeps | When logging in |
|
||||
| --- | --- | --- |
|
||||
| Password | Username and password | Uses the password saved in the credential; asks if none is saved or it’s rejected |
|
||||
| Key | Username, private key and passphrase | Uses only this private key; the passphrase is saved per key file and shared by credentials that use the same file |
|
||||
| SSH Agent | Username | Uses only the keys in the SSH agent: on macOS / Linux the agent that `SSH_AUTH_SOCK` points to, on Windows the OpenSSH Authentication Agent service |
|
||||
|
||||
- **Creating and editing**: “+” at the top of the list, “New credential…” in the empty list, or the context menu on empty space opens the dialog; to edit a credential, double-click it, select it and press Enter, or right-click it and choose “Edit credential…”. Passwords and private key passphrases go into the system keychain; the database holds only the name, username, type and private key path.
|
||||
- **Where the key comes from**: a key credential’s “Private key” has three sources.
|
||||
- **Local file**: choose a private key file; only its path is saved. If the file moves or is deleted, choose it again.
|
||||
- **Paste**: paste a private key in OpenSSH, PEM or PuTTY format. If you paste a public key or something unrecognizable, ShellRS says why. The passphrase field appears only when the private key is encrypted with one.
|
||||
- **Generate**: choose Ed25519 (the default) or RSA 4096, and the key is generated as soon as you choose. The dialog shows the public key, with a button to copy it. The passphrase can be left empty; if you enter one, it encrypts the private key and goes into the keychain. A generated key has the credential’s name as its comment.
|
||||
- ShellRS writes pasted and generated private keys to files in `keys/` in the data folder (with random file names, readable only by your account), and the credential refers to that file; when you edit the credential, it shows as “Local file” with that path. Pasting or generating again overwrites the file directly, and connected hosts don’t reconnect because of it; switching to another file or another type, or deleting the credential, deletes the file together with its passphrase, and the dialog warns you first. ShellRS never changes or deletes your own private key files.
|
||||
- The empty list and the context menu on empty space also have “Generate key…”, which opens the same dialog with “Key · Generate” already chosen. A key credential’s context menu has “Copy public key”, for adding the public key to `~/.ssh/authorized_keys` on a server.
|
||||
- **Hosts follow the credential**: changing a credential’s username changes it for every host that uses the credential. Changing the username, type or private key file reconnects the connected hosts that use it (changing only the name doesn’t); the dialog says how many hosts use it.
|
||||
- **Deleting**: right-click and choose “Delete”. If hosts use the credential, the confirmation says how many and how they’ll log in afterwards: hosts using a password credential switch to “Password” and ask when connecting; hosts using a key or SSH Agent credential switch to “No password”. Usernames stay the same; passwords and private keys aren’t copied over. Open connections aren’t affected, and the credential’s password is deleted from the keychain along with it.
|
||||
- **An SSH Agent credential** says why right away when it can’t reach the agent, the agent has no keys, or the server accepts none of them; it never falls back to asking for a password. Hosts set to “No password” try the same agent too, skip it silently if it can’t be reached, and go on to the default private keys (on Windows as well).
|
||||
|
||||
**Not implemented yet: creating a credential right from the host dialog, ordering credentials, a custom SSH agent socket, installing the public key on a host automatically, and exporting the private keys ShellRS keeps.**
|
||||
|
||||
## Settings
|
||||
|
||||
The “Settings” button at the bottom of the sidebar, or ⌘, (Ctrl+, on Windows and Linux), opens a “Settings” tab in the center area; if it’s already open, it switches to that tab instead of opening another. The settings page has two columns: on the left the categories (Appearance, Terminal, Highlighting, Keyboard shortcuts, External CLI, General, About), with a search box at the top and a width you can drag; on the right the settings of the chosen category. Changes take effect at once and are saved in `settings.json` in the data folder.
|
||||
|
||||
- **Appearance → Interface → Language**: System, 简体中文 or English. The default is System: Simplified Chinese when the system language is Chinese, English for any other language. Changes apply at once, without a restart: the text in the window, open tabs and search box hints all switch to the new language; prompts already written into terminals and notifications already shown stay as they were. The external CLI’s output uses this language too.
|
||||
- **Appearance → Interface → Appearance**: Dark, Light or System, System by default; System switches along with the system’s light and dark mode. The theme button in the title bar also changes this setting.
|
||||
- **Appearance → Theme**: the left column has light themes and the right column dark themes; choose one in each. The light appearance uses the one chosen on the left, the dark appearance the one on the right; with System they switch along with the system, and the same happens when you switch with the title bar’s theme button. By default only terminals take the chosen theme’s colors, and the interface stays a neutral gray (the dark one is an easy-on-the-eyes charcoal, not pure black). Turn on “Match interface to theme” at the top and the interface takes the theme’s colors too: the sidebar, tab bar, dialogs and so on use the theme’s background and text colors, and states such as success, warning and error use the theme’s green, yellow and red. Each card shows a sample in that theme’s colors, and the chosen one has a blue check mark at its top right. The defaults are ShellRS Light and ShellRS Dark, which match the interface. There are 10 built-in light themes and 10 dark ones: light ShellRS Light, Solarized Light, Tokyo Day, Catppuccin Latte, Flexoki Light, One Half Light, Gruvbox Light, Kanagawa Lotus, Rosé Pine Dawn, Everforest Light; dark ShellRS Dark, Solarized Dark, Tokyo Night, Catppuccin Mocha, Flexoki Dark, One Half Dark, Gruvbox Dark, Kanagawa Wave, Dracula, Nord. The light and dark versions of the same theme sit on the same row of the two columns. Changes apply at once to every open terminal, and the font and highlighting previews use the current theme too; when vim or another program asks the terminal for its background color, it gets the current theme’s. **Not implemented yet: custom themes, importing color schemes, and per-host themes.**
|
||||
- **Terminal → Font**: Font family (lists only the monospaced fonts installed on this computer; the default is the system’s monospaced font, Menlo on macOS), Font size (10–28 pixels, default 13) and Line height (1.0–2.0 times the font size, default 1.54, the same as the earlier 20-pixel line height). Changes apply at once to every open terminal; in a terminal, ⌘= / ⌘- (Ctrl on Windows and Linux) also change the font size step by step, and ⌘0 goes back to the default. The preview below lays out letters, digits, easily confused characters, symbols, Chinese, tab stops and a command line the way the terminal does. If the font in the settings file isn’t installed on this computer, the default font is used.
|
||||
- **Highlighting**: the main switch, the preview and the rules table, which color text in terminals by rule and alert you on matches; see “Terminal”.
|
||||
- **Keyboard shortcuts**: the shortcuts of the main features, in four groups (General, Tabs, Terminal, Editor); each row is a feature and its keys. Click the keys to get “Press a key combination”, then press a new combination to use it; Esc or clicking elsewhere cancels. The two buttons at the end of the row are Disable (the row then shows “None”; click again to restore the default) and Restore default; the “Reset All” button at the top of the page restores them all. Changes take effect at once, and the keys shown in tooltips and context menus follow.
|
||||
- A combination needs ⌘, ⌥ or ⌃ (Ctrl or Alt on Windows and Linux), or must be one of F1–F12; for “Switch to tab 1…9”, press any digit with a modifier, and all nine change together.
|
||||
- A combination already taken by another shortcut in the same place, or by text fields and other built-in features, is refused, and you’re told what uses it; to use it anyway, change or disable that one first. Terminal and editor shortcuts can share keys with general shortcuts, and in the terminal or editor they take precedence (in a terminal, for example, ⌘K clears the screen).
|
||||
- Defaults (macOS / Windows and Linux): New host ⌘N / Ctrl+N, New group ⌘⇧N / Ctrl+Shift+N, Open settings ⌘, / Ctrl+,, Show or hide sidebar ⌘B / Ctrl+B, Show or hide right sidebar ⌘⌥B / Ctrl+Alt+B, Focus search ⌘K / Ctrl+K, Zoom in, Zoom out and Actual size ⌘= ⌘- ⌘0 / Ctrl+= Ctrl+- Ctrl+0, Quit ⌘Q / Ctrl+Q; New local terminal ⌘T / Ctrl+T, Close tab ⌘W / Ctrl+W, Next tab ⌘⇧] / Ctrl+Tab, Previous tab ⌘⇧[ / Ctrl+Shift+Tab, Switch to tab 1…9 ⌘1…⌘9 / Alt+1…9 (9 is the last tab); in the terminal, Copy, Paste, Find, Find next, Find previous and Clear screen ⌘C ⌘V ⌘F ⌘G ⌘⇧G ⌘K / Ctrl+Shift+C, Ctrl+Shift+V, Ctrl+Shift+F, F3, Shift+F3, Ctrl+Shift+K, and Bigger text, Smaller text and Default text size ⌘= ⌘- ⌘0 / Ctrl+= Ctrl+- Ctrl+0; in the editor, Save ⌘S / Ctrl+S. Switching tabs stays within the group of tabs the current tab is in, wrapping around at either end. In a terminal, ⌘= / ⌘- / ⌘0 change the terminal font size (1 pixel at a time, within the same 10–28 range as “Terminal › Font size”; saved in the settings and kept for the next launch); outside a terminal they zoom the whole interface (five steps, back to the default after a restart).
|
||||
- Shortcuts in the SFTP file list and the image preview, and fixed keys such as Enter, the arrow keys, Tab and Esc, aren’t changed here.
|
||||
- **External CLI**: see the next section.
|
||||
- **General → Window**: “Remember window size” restores the last window size and whether it was maximized, and “Remember window position” puts the window back on the screen and in the place it last was. Both are on by default and take effect at the next launch. If that screen is gone, or the window would be mostly off screen, it opens in the middle of the screen; a window larger than the screen shrinks to fit. Full screen isn’t remembered: the window comes back at its size from before full screen. The window’s size and position are recorded in `window.json` in the data folder as it moves.
|
||||
- **About**: the “Updates” group, with the current version and checking for updates, the update channel and automatic updates (see “Download and updates”); “Send anonymous usage statistics” in the “Privacy” group, on by default (see “Privacy”); and “Website” in the “ShellRS” group, which opens shellrs.com in your browser.
|
||||
|
||||
## External CLI
|
||||
|
||||
The `shellrs` command lets AI agents such as Codex, Claude Code and OpenCode run remote commands and transfer and sync files on the hosts saved in ShellRS, and also view, create, change and delete hosts and credentials. The command itself reads no passwords or private keys, and no database: it hands the request to the running ShellRS, which connects with the saved settings and the keychain. Requests travel over local interprocess communication: the socket `cli.sock` in the data folder on macOS and Linux, and the named pipe `\\.\pipe\shellrs-cli-<hash of the data folder>` on Windows.
|
||||
|
||||
In Settings → External CLI:
|
||||
|
||||
- **Enable external CLI**: off by default. While it’s off, ShellRS still listens but refuses every request, and the command tells you to turn it on here. Once it’s on, any program running as the current user on this computer can use the saved hosts this way, and can also change hosts and credentials (there’s no separate switch). Only the current user can read and write the socket, and ShellRS also checks the identity of the user connecting. On Windows, the pipe likewise admits only the current user and only local connections, and before sending a request the command checks that the pipe belongs to the current user (or an administrator), so nobody else can grab a pipe with the same name.
|
||||
- **Command**: puts `shellrs` on the PATH.
|
||||
- macOS: links it to `/usr/local/bin/shellrs`; if that folder isn’t writable, the system asks for authorization.
|
||||
- Linux: links it to `~/.local/bin/shellrs`.
|
||||
- On macOS and Linux, “Remove” deletes only a link that points to shellrs; other files with the same name are left alone.
|
||||
- Windows: a GUI app has no console, so the command is a separate console program, `shellrs-cli.exe` (next to `shellrs.exe`). “Install” copies it to `%LOCALAPPDATA%\ShellRS\bin\shellrs.exe` and adds that folder to the current user’s PATH (no administrator needed); terminals and agents find it once they’re reopened. After ShellRS updates, it refreshes this copy when it starts; when it can’t, “Update” appears. The copy can be updated even while it’s in use. “Remove” deletes the copy and takes the folder off the PATH.
|
||||
- **Agent Skills**: writes the skill that teaches agents to use `shellrs` to the standard folder (`~/.agents/skills`) or to the skills folder of Codex, Claude Code, OpenCode or WorkBuddy; when the content differs from the current version, “Update” appears. “Copy skills” copies the whole skill to the clipboard.
|
||||
|
||||
Commands (`shellrs --help` has the full details):
|
||||
|
||||
```sh
|
||||
shellrs hosts list [-q <keyword>] [--json] # list hosts; JSON when the output isn't a terminal
|
||||
shellrs hosts show <ID> [--json] # one host's full settings
|
||||
shellrs exec <ID> "<command>" # or --stdin to read the command from stdin
|
||||
shellrs exec --json # read {"host", "command"} from stdin, print JSON
|
||||
shellrs upload <ID> <local-path> <remote-path>
|
||||
shellrs download <ID> <remote-path> <local-path>
|
||||
shellrs sync <ID> <local-folder> <remote-folder> [--delete]
|
||||
shellrs hosts create | update <ID> | delete <ID> [--force]
|
||||
shellrs credentials list | show <ID> | create | update <ID> | delete <ID>
|
||||
```
|
||||
|
||||
- `<ID>` is the 16-character host ID: the `id` in `shellrs hosts list`, which is also what “Copy ID” on a host’s context menu copies. The list also includes quick connections and external connections whose tabs are open (`"temporary": true`); see “Quick connect” and “Opening from a bastion host (external connections)”.
|
||||
- `exec` opens a new connection each time and disconnects when it’s done. The remote command gets no stdin, and the exit code is the remote command’s; if a signal killed it, the exit code is 128 plus the signal number.
|
||||
- Uploads and downloads use the SFTP tab’s transfer engine (recursive, `.filepart`, reconnecting after a drop), and destination paths follow scp’s rules: if the destination is an existing folder, the item goes into it under its own name; otherwise the destination is the path of the copy itself, and its parent folder must exist. Existing files are overwritten; new files keep the source’s executable permission. `~` means the login folder.
|
||||
- `sync` goes only from this computer to the host: it syncs the contents of the local folder into the remote folder. If the remote folder doesn’t exist, it’s created (its parent must exist); if it’s a file, that’s an error; and it can’t be the root folder `/`. Files whose size and modification time haven’t changed, and links whose target hasn’t changed, are skipped and counted as “unchanged” in the summary, so syncing again sends only what changed; other files are overwritten, and new files keep their executable permission. `--delete` first deletes items in the remote folder that aren’t in the local one, or are of a different type (without following links), but never the `.filepart` and `.shellrs-….backup` files that transfers in progress leave behind, and it doesn’t clean up folders that can’t be read locally; items it can’t delete count as failures.
|
||||
- `hosts` and `credentials` manage the saved hosts and credentials. `create` and `update` read a JSON object from stdin (`update` changes only the fields given); the fields are in `shellrs hosts create --help` and `shellrs credentials create --help`. What `show --json` prints can be edited and passed straight back to `update`. The checks are the same as in the dialogs, and errors use the dialogs’ wording:
|
||||
- Hosts: `name`, `host` (the address), `port` (default 22), `user` (default root), `group` (a group path such as `Production/Databases`, created level by level if it doesn’t exist; an error if more than one group has that path), `auth` (`password` / `credential` / `no_password`), `credential` (a credential ID), `password`, `route` (direct, jump hosts listed by ID, or an HTTP / SOCKS5 proxy) and `notes`. Jump hosts must be saved hosts, and not the host itself. If you change the address, port or user without giving a password, the saved password moves along, as in the dialog.
|
||||
- Credentials: `name`, `kind` (`password` / `key` / `agent`), `user`, `password`, `key_path` (a private key file on this computer) or `private_key` (the full text of a private key, like “Paste” in the dialog, kept in `keys/` in the data folder), and `passphrase`. A credential’s ID is a 16-character random string, shown by `credentials list`.
|
||||
- Passwords, passphrases and private keys are write-only: they go into the system keychain or `keys/`, no command ever prints them, and `show` only says whether one is saved; for a private key ShellRS keeps, it doesn’t even show the path.
|
||||
- Quick connections and external connections can be shown with `show`, but not changed or deleted.
|
||||
- `hosts delete` doesn’t ask for confirmation and deletes the host’s port forwarding rules along with it; if the host has tabs open in ShellRS, it refuses (`host_in_use`), and only with `--force` does it close the tabs and delete the host. Hosts that jump through it are left with a “Deleted host” hop. `credentials delete` doesn’t ask either; hosts that use the credential switch to logging in on their own (as in the app).
|
||||
- ShellRS makes changes one at a time: a change that hasn’t had its turn within 10 seconds is withdrawn and not made; once it has started, the command waits for it to finish.
|
||||
- On failure, the command prints `shellrs: [code] message` to stderr and exits with 255. The error codes are `not_running` (ShellRS isn’t running), `not_enabled` (the external CLI isn’t enabled), `host_not_found`, `credential_not_found`, `host_key_unknown` (you haven’t connected to this host in ShellRS yet), `host_key_changed`, `missing_credential` (no saved password or passphrase), `connect_failed`, `transfer_failed`, `bad_request`, `host_in_use`, `save_failed` (the change was made but couldn’t all be written to the database or keychain) and `version_mismatch` (the command and the running ShellRS are different versions, or ShellRS is too old to know the command). The CLI never prompts: an unknown host or a missing password fails right away, so connect once in ShellRS first. When a transfer finishes but some items failed, the exit code is 1.
|
||||
- `exec --json` reads `{"host": "<ID>", "command": "<command>"}` from stdin and, once the command ends, prints `{"exit_code", "stdout", "stderr"}`, or `{"error": {"code", "message"}}` on an error, with the same exit codes as without `--json`. The output is ASCII only: Chinese and other characters are escaped as `\uXXXX`, and output that isn’t UTF-8 becomes U+FFFD. The command doesn’t go through the local shell’s quoting, and the console code page can’t garble the output, which suits Windows PowerShell and agents that need to read stdout and stderr separately.
|
||||
- macOS, Linux and Windows are supported. In Windows PowerShell, prefer `exec --json` for running commands (`@{ host = '<ID>'; command = '…' } | ConvertTo-Json -Compress | shellrs exec --json`). To watch the output while the command runs (long builds, lots of logs), pass the command as a here-string through `--stdin` instead (`@'…'@ | shellrs exec <ID> --stdin`), and, as for `list`, `upload` and `download`, first run `$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new()`, or Chinese and other non-ASCII text turns into question marks or garbage. Any BOM at the start of the command text is removed and CRLF becomes LF, so the remote shell sees no stray carriage returns.
|
||||
|
||||
## Opening from a bastion host (external connections)
|
||||
|
||||
The local client of a bastion host such as JumpServer can launch ShellRS to open a terminal the way it launches Xshell, or to open SFTP the way it launches WinSCP: set ShellRS as the SSH client or the SFTP client, with the arguments written as for Xshell or WinSCP. When ShellRS gets an `ssh://` link, it opens a terminal tab and connects; when it gets an `sftp://` link, it opens an SFTP tab:
|
||||
|
||||
```sh
|
||||
ShellRS ssh://user[:password]@address[:port]
|
||||
ShellRS -url ssh://user[:password]@address[:port] -newtab tab-name
|
||||
ShellRS sftp://user[:password]@address[:port]
|
||||
ShellRS /sessionname=tab-name sftp://user[:password]@address[:port]
|
||||
```
|
||||
|
||||
- ShellRS understands Xshell’s `-url` (the link), `-newtab` (the tab name; the address if it isn’t given) and `-newwin` (ignored: ShellRS has only one window), and WinSCP’s `/sessionname=tab-name` (which can also be written `-sessionname=`), in any letter case; other options such as `/newinstance` and `/ini=nul` are ignored. Only `ssh://` and `sftp://` are supported, and the port defaults to 22. Special characters in the username and password can be escaped by URL rules (`@` as `%40`); a username that itself contains `@` also works, since only what follows the last `@` is the address. The path in an `sftp://` link (which WinSCP uses to choose the folder to open) isn’t read yet; the remote side opens the home folder as usual.
|
||||
- **The tab fills the window**: when an external connection opens, the host list on the left collapses automatically (as with ⌘B); press ⌘B (Ctrl+B on Windows and Linux) or double-click the tab to show it again.
|
||||
- **External connection, not saved**: it stays out of the host list, the start page’s Recent list and the database, and is gone once its terminal and SFTP tabs are all closed. Its tab menu has no “Edit host…”; everything else (“Connect in new tab”, “Open SFTP”, “Reconnect”) works as for a saved host.
|
||||
- **One channel only**: many bastion hosts end the whole session when a second channel opens on the same connection. An SFTP tab opens only one anyway; a terminal doesn’t detect the host’s system (its tab shows the first letter of the name), and the right sidebar has only Snippets: History, Docker, Services, Processes, Network and Monitor all run commands over another channel, so they aren’t offered here.
|
||||
- The password in the link stays only in memory, never in the keychain, and is reused when reconnecting; if the link has no password and the server wants one, you’re asked. The first time you connect to a bastion host, you trust its host key, which is recorded in ShellRS’s own `known_hosts` and not asked about again.
|
||||
- When ShellRS is already running, the link goes to the running ShellRS, its window comes to the front, and no second ShellRS starts. If the link has a problem (not `ssh://` or `sftp://`, no username or address, a wrong port), ShellRS shows a notification saying why.
|
||||
- The program for the bastion host to launch:
|
||||
- Windows: `shellrs.exe` in the installation folder (by default `%LOCALAPPDATA%\Programs\ShellRS\shellrs.exe`). Don’t use the `shellrs.exe` on the PATH: that’s the external CLI’s copy.
|
||||
- macOS: `/Applications/ShellRS.app/Contents/MacOS/shellrs`. Don’t use `open -a ShellRS --args …`: when ShellRS is already running, the system drops the arguments.
|
||||
- Linux: the AppImage itself.
|
||||
- The external CLI’s `shellrs hosts list` lists external connections too (`"temporary": true` in the JSON, “(not saved)” in the table’s group column). While the tabs are open, an agent can use the connection’s ID to run commands and transfer files, and the ID stays the same all that time. Once the tabs close, the ID stops working; the next time the bastion host launches ShellRS (even for the same asset), it’s a new external connection with a new ID. As with saved hosts, each command logs in again with the username and password from the link; if the bastion host passed a one-time token, that login is refused.
|
||||
- A password in command-line arguments is visible in this computer’s process list; that comes with how the bastion host passes arguments.
|
||||
|
||||
## Tabs
|
||||
|
||||
Remote terminal tabs and SFTP tabs use the host’s system badge as their icon (the same one as in the host list). A remote terminal tab’s context menu has “Rename tab…” (for this run only; leave the name empty to go back to the host’s name; handy for telling several connections to the same host apart), “Connect in new tab”, “Open SFTP”, “Copy IP address” / “Copy hostname”, “Reconnect”, “Edit host…”, and “Close”, “Close to the left”, “Close to the right”, “Close others” and “Close all”. An SFTP tab’s menu has “Rename tab…” (leave the name empty to go back to “host name · SFTP”), “Open SFTP”, “Copy IP address” / “Copy hostname”, “Reconnect” and the close commands, and the right of its tab bar has the same “SFTP” and “Reconnect” buttons as a remote terminal’s; a local terminal tab’s menu has “Restart” and the close commands. The “…” button at the right of the tab bar has the same menu as the current tab’s context menu. Double-click any tab’s title to hide or show the left sidebar (like ⌘B and the title bar button). While a remote terminal is running, or an SFTP tab is connected, the right of the tab bar shows the round-trip latency of that SSH connection, updated every 5 seconds: green below 100 ms, yellow at 100–200 ms, red above 200 ms or on a timeout. It’s measured during SFTP transfers too, and the reading rises when a transfer takes up the bandwidth. When closing tabs in bulk, an SFTP tab that’s uploading still asks first.
|
||||
|
||||
## Terminal
|
||||
|
||||
The terminal’s context menu has “Copy”, “Paste”, “Find…” and “Clear screen”; a remote terminal’s also has “Open SFTP”, “Reconnect” and “Disconnect”. “Disconnect” disconnects only this tab: the tab stays and can reconnect at any time, and other tabs and SFTP for the same host aren’t affected.
|
||||
|
||||
- **Find** (⌘F, or Ctrl+Shift+F on Windows and Linux): opens the find bar at the top right of the terminal and searches the screen and the scrollback for the literal text. An all-lowercase query ignores case; one with capital letters matches case. All matches are highlighted, the current one darker, and the find bar shows “current/total”. Enter or ⌘G (F3) goes to the next match (newer output), Shift+Enter or ⌘⇧G (Shift+F3) to the previous one (older output), wrapping around at the ends. Esc closes it. If text is selected when it opens, that text becomes the query.
|
||||
- **Clear screen** (⌘K, or Ctrl+Shift+K on Windows and Linux; when the terminal doesn’t have focus, ⌘K still focuses the sidebar’s search box): clears the screen and the scrollback, leaving only the prompt line the cursor is on, including anything typed but not yet entered. Clearing happens only locally; nothing is sent to the server. It isn’t available while a full-screen program such as vim or top is running.
|
||||
- **Links**: `http://` and `https://` addresses in the output, and links that programs mark explicitly (such as `ls --hyperlink` and gcc’s error messages), are shown in blue and underlined. Hold ⌘ (Ctrl on Windows and Linux) and click to open one in the system browser; a plain click still selects text. Rest the pointer on a link for a moment to see its real address: for a link a program marks, the text shown can differ from the address. A trailing period, comma or unmatched closing parenthesis isn’t part of the address, and a long address wrapped over two lines is still one link. Other schemes (such as `file://`) aren’t treated as links.
|
||||
- **Mouse**: when programs such as vim (`:set mouse=a`), htop, tmux (`set -g mouse on`) and mc ask for the mouse, clicks, drags and the scroll wheel all go to the program. Dragging with Shift held still selects text, and scrolling with Shift held still scrolls through the scrollback; right-clicking always opens ShellRS’s menu.
|
||||
- **Notifications**: programs can ask for a notification (OSC 9, or OSC 777’s `notify;title;body`), and a terminal bell alerts you too. When ShellRS isn’t in front, you get a system notification (macOS asks for permission the first time), and clicking it takes you back to that terminal; when ShellRS is in front but the terminal is in another tab, a notice appears at the top right of the window, and clicking it switches there. Notifications that programs ask for appear even when the terminal is right in front of you; the bell alerts you only when you can’t see that terminal (Tab completion and the like ring it too). The notification title is the tab name, so you can tell which host it came from. Each terminal alerts at most once every 2 seconds for program notifications and once every 10 seconds for the bell. Settings › Terminal › Notifications can turn each one off. To be told when a command finishes, append `; printf '\e]9;Done\a'` or `; tput bel` to it; command-line tools such as Claude Code alert you the same way when they need you.
|
||||
- **Highlighting**: the rules in Settings › Highlighting show matching text in the rule’s color in every terminal, scrollback included. Only the display changes: the server’s output and copied text stay the same; matching keeps working after the window is resized and long lines wrap, and a word wrapped over two lines is colored in both parts. The feature is off by default and works only once you turn on “Highlight keywords” at the top of the page; turning it off disables all the rules at once. “Preview” shows sample output in the current terminal’s font and colors, and rule changes show up at once in the preview and in open terminals. The rules table has one rule per row, edited right in the table: “On”, “Regular expression” (case-sensitive; start it with `(?i)` to ignore case), “Note”, a color (click the swatch for a color picker, or type `#rrggbb` next to it) and “Notify”; the trash can at the end of the row deletes the rule. A mistake in a regular expression is pointed out below the field, and the rule doesn’t apply until it’s fixed. Rules apply from top to bottom, and where they overlap, the earlier rule wins; drag the handle at the start of a row to reorder. Three sample rules come to start with: `ERROR` in red, `WARN` in amber and IPv4 addresses in blue, all on and not notifying, so turning on the main switch shows the effect right away; you can change or delete them. A rule with “Notify” checked alerts you when you can’t see the terminal, the same way as the bell: a system notification when ShellRS isn’t in front, and a notice at the top right of the window when the terminal is in another tab. The title is “tab name: note” (the regular expression when the note is empty), and the body is the matching line. Only new output is checked, each line once it ends; each terminal alerts at most once every 10 seconds, and the same line redrawn over and over at most once every 5 minutes. Nothing is highlighted or alerted in full-screen programs such as vim, less, top and tmux. **Not implemented yet: per-host rules, text styles such as bold, and reordering with the keyboard.**
|
||||
- **Remote copy**: what remote programs copy with OSC 52 (such as tmux with `set -g set-clipboard on`, or vim’s OSC 52 plugins) goes straight to this computer’s clipboard. Remote programs can’t read this computer’s clipboard.
|
||||
|
||||
## Right sidebar
|
||||
|
||||
When the current tab is a remote terminal, a column of tool buttons runs down the right edge of the window: Snippets, History, Docker, Services, Processes, Network and Monitor. Click one to open that tool on the right, and click it again to collapse it; ⌘⌥B (Ctrl+Alt+B on Windows and Linux) shows or hides the right sidebar. Drag the right sidebar’s left edge to widen it; it can’t get narrower than it was when it opened. The right sidebar appears only with remote terminals: switching to SFTP, a local terminal or Settings hides it together with its buttons, and going back to any remote terminal brings it back as it was, now for the host of the current terminal. With tabs dragged into a split, the right sidebar stays: it works on the remote terminal you switched to or clicked into last, stays put when you click into SFTP or a local terminal in the other half, and hides only when no remote terminal is on screen. Its title names the host it works on (such as “Monitor · web-01”), and the status bar follows the half you clicked into too. Hosts known not to run Linux (macOS, Windows, BSD) don’t show the Services, Processes, Network and Monitor icons; History and Docker are there for every host except Windows ones (on a Mac, Docker works too if Docker Desktop is installed).
|
||||
|
||||
**Snippets** keeps the commands you use often, shared by every host. Click the plus at the top to create a snippet: a name, a category and a command, which can span several lines. Snippets are grouped by category, with the ones without a category last, under “Uncategorized”; click a category heading to collapse or expand it, and right-click it to create a snippet in that category, rename the category or delete it (its snippets are deleted with it, and the confirmation says how many). The folder button at the top creates a category. You can search by name or command; with several words, snippets that contain all of them are listed.
|
||||
|
||||
Clicking a snippet runs its command in the terminal (snippets are set to “Run on click” by default). When the pointer is on a snippet, an icon at its right shows what a click will do: a lightning bolt means a click runs it, ▶ means a click only types it in. Uncheck “Run on click” in the snippet dialog, and clicking the snippet only puts the command on the terminal’s input line (replacing anything already typed), for you to edit and press Enter. Clicking the lightning bolt or ▶ at the right always runs it right away; the context menu also has “Insert into terminal”, “Copy command”, “Edit…” and “Delete…”. Nothing is typed in while a full-screen program such as vim or less is running in the terminal. On Windows hosts, what’s already typed isn’t replaced; the command is typed after it.
|
||||
|
||||
**History** lists the remote host’s bash history (`~/.bash_history`), most recent first, each command once, with how many times it ran below it; when bash recorded times (`HISTTIMEFORMAT` is set), it also says when it last ran, and multi-line commands are recognized. You can search; with several words, commands that contain all of them are listed, ignoring case. Click a command to put it on the terminal’s input line (replacing anything already typed, without running it), for you to edit and press Enter; the “Run” button at the right runs it right away, and the context menu can also copy it. Nothing is typed in while a full-screen program such as vim or less is running in the terminal.
|
||||
|
||||
bash writes a session’s commands to the history file only when the shell exits, so commands you’ve just run in the current terminal show up only after it exits. The list is read when you open History or switch terminals, and after that only when you click the refresh button; when the history file is long, only its last 512 KB is read.
|
||||
|
||||
**Docker** shows the Docker and Compose versions at the top, and below them four tabs with counts: “Containers”, “Volumes”, “Images” and “Networks”. On “Containers”, the containers of the same compose project are grouped under a project card (project name, running / total, project folder); click the project’s row to collapse or expand it. A project with a single container that’s running starts expanded, and a project with several containers starts collapsed; containers that belong to no project are under “Standalone containers”. Each project and container has “Stop” (or “Start”) and “Restart” at the right, and a project’s buttons act on all its containers. Click a container or its “…” to open its details: the “Details” tab has the name, ID, image, creation time, entrypoint, command, ports, mounts, environment and labels, and the “Logs” tab the container’s last 200 lines of output; at the bottom you can start, stop and restart it, and delete it once it’s stopped. The “Volumes”, “Images” and “Networks” tabs list each item’s name and basic information and mark whether a container uses it; those no container uses can be deleted with the delete button at the right (Docker’s own bridge, host and none networks can’t be deleted). Click a card to open its details: for a volume, the driver, mountpoint, creation time, the containers using it, options and labels; for an image, the full ID, tags, digests, creation time, size, platform, number of layers, the containers using it, configuration (entrypoint, command, working directory, user, exposed ports), environment and labels; for a network, the driver, scope, IPv6, subnets and gateways, the containers attached with their addresses, options and labels. Items can be deleted from the bottom of the details too.
|
||||
|
||||
Stopping, restarting and deleting ask first; starting happens right away. The result appears as a notification, and the list is read again. When you’re not logged in as root and not in the docker group, passwordless sudo is used. The list is read when you open Docker or switch terminals, and after that only when you click the refresh button. If Docker isn’t installed or isn’t running on the host, it says so.
|
||||
|
||||
**Services** lists the systemd services on the remote host (not templates) in two groups: “Custom services” (unit files in `/etc/systemd/system`, the ones an administrator added) and “System services”. The top shows the systemd version and the overall state of the machine (“Degraded” when a service has failed); there are four tabs with counts, “All”, “Active”, “Inactive” and “Failed”, and you can search by service name or description. Each service has a card: a status dot, the name, description, active state and whether it starts at boot. A running service has “Stop” and “Restart” at the right, one that isn’t running has “Start”, and “…” or clicking the card opens its details. The details’ “Status” tab has the load state, active state, at boot, main PID, memory, tasks, restarts, exit status, start and stop times, and unit file; the “Journal” tab has the service’s last 200 lines in the journal. At the bottom of the details and in the context menu you can likewise start, stop and restart it, and enable or disable it at boot.
|
||||
|
||||
Stopping and restarting ask first (with a special warning when you stop the SSH service); starting and the at-boot setting happen right away. The result appears as a notification, and the list is read again. These actions need root: when you’re not logged in as root, they run through passwordless sudo, and if sudo needs a password, you’re told that root is needed. The list is read when you open Services or switch terminals, and after that only when you click the refresh button. For hosts that don’t use systemd, it says they’re not supported yet.
|
||||
|
||||
**Processes** lists the processes on a remote Linux host (without kernel threads), each on a card: a status dot (green running, gray sleeping, yellow waiting for I/O or stopped, red zombie; hover over it to see the state), the process name, PID, user, start time (in this computer’s time zone), memory use with its share of physical memory, and CPU usage. CPU usage is computed the way `top` does it, per core: two full cores is 200%. It takes two readings to compute, so right after the tool opens it shows “—”, and the figure appears 2 seconds later. Processes are sorted by memory, most first, by default; click “CPU” to sort by CPU instead, and click the same button again to reverse the order. You can search by process name, PID or user, and the number at the right is how many are listed. The list refreshes every 15 seconds, reading only while Processes is showing; click the refresh button at the top right to read it at once.
|
||||
|
||||
Click a process to open its details: PID, parent, user, state (with the `ps` state letters in parentheses, such as `Ssl`), start time, elapsed time, TTY, priority, nice, CPU usage, CPU time, resident memory, virtual memory, number of threads, of direct children and of all descendants, and below them the full command line (read when the details open; “Copy command” copies it). At the bottom are “Copy PID”, “End process” and “Kill”.
|
||||
|
||||
Right-click a process for “View details”, “End process…” (sends SIGTERM, so the process can clean up before it exits) or “Kill process…” (sends SIGKILL, ending it immediately); ending it from the details works the same way, and both ask first. The result appears as a notification, and the list is read again 1 second later. When you’re not logged in as root, you can end only your own processes. With more than 2500 processes, only the first 2500 are listed, and the top of the list says so.
|
||||
|
||||
**Network** lists every TCP and UDP connection and listening port on a remote Linux host, each on a card: protocol, state, local and remote addresses, the process using it with its PID, and its user. The top right of each card says whether it’s a “Listening port”, “Inbound” (connected to a port this host listens on) or “Outbound”. The top of the panel shows the total, the number of listening ports and the number established; you can search by address, port, state, PID, process name or user, and filter by protocol and state. Listening sockets come first, then established ones, with TIME_WAIT and other closing ones last; when several processes listen on the same port together (such as nginx’s workers), they’re merged into one card.
|
||||
|
||||
The list is read once when you open Network or switch terminals and doesn’t change by itself after that; click the refresh button at the top right to read it again. The data is read over this terminal’s existing SSH connection, with no second login, using the host’s `ss` command; on hosts without `ss` (such as BusyBox) it’s read from `/proc/net` instead, and then processes don’t show; when you’re not logged in as root, you see only your own processes. In both cases the top of the list says so. With more than 3000 connections, only the first 3000 are listed (listening ports are always among them), and the total stays accurate.
|
||||
|
||||
**Monitor** shows, for a remote Linux host:
|
||||
|
||||
- **System**: host name, architecture, distribution and uptime (such as “32 days 23:40:26”).
|
||||
- **CPU**: model, number of cores and average usage; with several cores, a small bar for each core below, and hovering over a bar shows that core’s number and usage. Only one row shows by default; when there are too many cores for one row, click “N cores” at the top right to show them all.
|
||||
- **Memory**: physical memory usage and the amount used; a second row when there’s swap.
|
||||
- **Network**: upload and download rates of the main interface (the one with the default route); with several interfaces, click “N interfaces” at the top right to show them all (loopback interfaces and interfaces that have never sent or received data aren’t listed).
|
||||
- **Disks**: the usage of each local mount point (without in-memory file systems such as tmpfs, or mounts under system folders).
|
||||
|
||||
The data is read over this terminal’s existing SSH connection, with no second login and nothing to install; it refreshes every 2 seconds (disks every 30 seconds), reading only while Monitor is showing. Each refresh reads only a few system files, and starts no processes on the server besides the shell that runs it. Usage of 70% or more shows in yellow, 90% or more in red. When the terminal isn’t connected, it says so; when a host whose system wasn’t known yet turns out not to be Linux, it says it isn’t supported yet.
|
||||
|
||||
## SFTP
|
||||
|
||||
Open a host’s “SFTP” tab to browse local and remote folders. As with terminals, each “Open SFTP” opens a new tab, so one host can have several, each with its own connection, each showing its own folders. It works like WinSCP’s Commander interface. SFTP uses a separate SSH connection that asks only for the SFTP subsystem, so the server doesn’t need to provide a shell; closing the terminal doesn’t stop transfers.
|
||||
|
||||
### Toolbars
|
||||
|
||||
Each pane has two rows of toolbar, with the path label below them. When a pane is too narrow, buttons hide whole, from right to left, rather than wrapping (their commands are still in the context menus and shortcuts). A new SFTP tab splits evenly between the local and remote sides; wherever you drag the dividers between the sides and the queue, they stay there until the tab closes, even when you switch to another tab and back:
|
||||
|
||||
- First row (navigation):
|
||||
- The folder list: lists every level from the root to the current folder, plus Home, Desktop, Documents and Downloads on the local side; choose one to go there.
|
||||
- Bookmarks: the button opens the “Open folder” dialog (see below); the drop-down arrow next to it lists this host’s bookmarks for this side, which you click to go there, and can also add or remove the current path. Bookmarks are saved in the local database and deleted along with the host.
|
||||
- Parent folder, Root folder, Home folder, Refresh, Back, Forward.
|
||||
- Show / hide hidden files: files and folders whose names start with `.`. An open eye means they’re shown, a crossed-out eye that they’re hidden. Local and remote each have their own switch, independent of each other; each side’s choice applies to every SFTP tab and is saved in the settings; both are hidden by default. While they’re hidden, the bottom of the pane says “N hidden”; while they’re shown, hidden files’ text is lighter.
|
||||
- Second row (files):
|
||||
- “Download…” on the remote side and “Upload…” on the local side, whose drop-down also has “Choose files to upload…”.
|
||||
- Delete, Rename…, Properties…, New (Folder… / File…).
|
||||
|
||||
### Path label
|
||||
|
||||
Works like WinSCP’s path label:
|
||||
|
||||
- It shows the full path of the current folder, such as `/home/tester/`.
|
||||
- Every level can be clicked: hovering highlights the path from the root to that level, and clicking goes there.
|
||||
- When the path doesn’t fit, the root and the last few levels stay, and the middle folds into `…`; clicking `…` goes to the last folded level.
|
||||
- Clicking the current folder, or double-clicking the empty space to the right of the path, opens the “Open folder” dialog.
|
||||
- Context menu: Go to, Refresh, Add path to bookmarks, Copy path, Open folder or bookmark….
|
||||
|
||||
### Open folder or bookmark
|
||||
|
||||
Works like WinSCP’s Open directory dialog. Open it from the path label, the bookmarks button, ⌘O (Ctrl+O on Windows and Linux; also ⌘⇧G on macOS) or the path label’s context menu:
|
||||
|
||||
- “Folder” is filled in with the current folder, already selected, so typing replaces it. `~` means the home folder, and relative paths start from the current folder. The local side also has “Browse…”.
|
||||
- Below are this host’s bookmarks for this side. A bookmark that matches the folder is selected; clicking a bookmark puts it in the folder field, and double-clicking opens it directly.
|
||||
- “Add” adds the folder to the bookmarks, “Remove” (or Delete in the list) removes the selected bookmark and selects the one next to it, and “Up” and “Down” change the order.
|
||||
- Bookmark changes are saved at once and aren’t undone by “Cancel”, the same as in WinSCP.
|
||||
- Enter or “Open” goes to the folder; Esc or “Cancel” closes the dialog.
|
||||
|
||||
The status bar below the list normally shows the number of items and how many are selected; while connecting, or when reading a folder takes longer than 0.3 seconds, it shows “Connecting to SFTP…” or “Reading folder…”. When a folder can’t be read or the connection drops, the reason appears in red at the far bottom left of the window (where “Connected to” and the host’s name normally are). A dropped connection is noticed even while idle, without waiting for a refresh, as soon as the same host’s terminal would notice. To reconnect, use the “Reconnect” button in the tab bar or the tab menu. While disconnected, any action in the remote list (refreshing, opening a folder, deleting, uploading, downloading and so on) brings up a “Lost the SFTP connection to …” dialog that explains why and can “Reconnect” right away. None of this makes the list move.
|
||||
|
||||
The columns are the same as in WinSCP: Name, Size, Type and Modified on the local side; Name, Size, Modified, Permissions and Owner on the remote side.
|
||||
|
||||
- Sizes are shown in KB by default (`4,008,960 KB`, rounded up; only empty files are 0 KB), as in WinSCP. Right-click the “Size” column header to switch to “Bytes” (`4,105,175,040 B`), “Kilobytes (KB)” or “Short (B, KB, MB, GB)”, which uses B, KB, MB or GB depending on the size, such as `3.8 GB`; the choice applies to every SFTP tab and is saved in the settings. Sorting by size always uses the exact number of bytes.
|
||||
- Times look like `2026/4/22 12:44:53`.
|
||||
- Permissions look like `rwxr-xr-x`.
|
||||
- The owner comes from the server’s `ls -l` line.
|
||||
- A symbolic link to a folder shows as a folder with an arrow, and you can double-click it to go in.
|
||||
|
||||
### Selection and context menus
|
||||
|
||||
- As in WinSCP with “Full row select” turned off, the “Name” cell is the item: on hover only the name cell turns gray; clicks, double-clicks and right-clicks all act on the name cell; and a selected item’s whole name cell is highlighted. The row’s other columns and the area below the list count as empty space.
|
||||
- Mouse: click a name cell to select it (deselecting everything else), ⌘-click (Ctrl-click on Windows and Linux) to add or remove items, Shift-click to select a range, double-click to open. Clicking empty space deselects everything (unless ⌘ or Shift is held).
|
||||
- Box selection: press and drag in the empty space to the right of the names, in the size, type, time and other columns, or below the list to draw a selection box; the rows inside it are selected. Start dragging with ⌘ or Shift held to add to the current selection. Dragging to the top or bottom edge of the list scrolls it. Dragging a file name is what drags files to the other side for transfer: pressing on an unselected file name selects only it (deselecting everything else), and only it is dragged; pressing on a selected file name drags everything selected. While dragging, “Upload N items” or “Download N items” appears only over the other side’s list; over your own list or anywhere else it can’t be dropped, the pointer shows that it isn’t allowed.
|
||||
- Keyboard: ↑↓, Home, End, PageUp and PageDown move, with Shift to extend the selection; Space or Insert toggles the current row; ⌘A selects all; Tab switches to the other side.
|
||||
- Right-clicking an unselected name cell selects it first; right-clicking empty space clears the selection and opens the empty-space menu.
|
||||
- The menu on file names: Edit (for files; images, SVG and Markdown also have Preview, and images other than SVG have only Preview), Upload… / Download…, Copy path (the full paths of the selected items, one per line), Delete, Rename…, Properties…. Double-clicking a folder goes into it, so the menu has no “Open”.
|
||||
- The empty-space menu: Go to (Parent folder / Root folder / Home folder / Back / Forward), Refresh, Show hidden files (checked means this side shows them), Add path to bookmarks, New.
|
||||
|
||||
### Shortcuts
|
||||
|
||||
| Command | Shortcut |
|
||||
|---|---|
|
||||
| Upload / Download (depending on the pane) | F5 |
|
||||
| Rename | F2 |
|
||||
| New folder | F7 |
|
||||
| Delete | F8, Delete; also ⌘⌫ on macOS |
|
||||
| Properties | F9; also ⌘I on macOS |
|
||||
| Edit file | F4 |
|
||||
| Refresh | ⌘R / Ctrl+R |
|
||||
| Parent folder | Backspace; also ⌘↑ on macOS |
|
||||
| Root folder | ⌘\ / Ctrl+\ |
|
||||
| Home folder | ⌘⇧H on macOS, Ctrl+H on Windows and Linux |
|
||||
| Back / Forward | ⌥← / ⌥→; also ⌘[ / ⌘] on macOS |
|
||||
| Add path to bookmarks | ⌘D / Ctrl+D |
|
||||
| Open folder / Edit file | Enter; also ⌘↓ on macOS |
|
||||
| Open folder or bookmark | ⌘O / Ctrl+O; also ⌘⇧G on macOS |
|
||||
| Show / hide hidden files (depending on the pane) | ⌘⇧. on macOS, Ctrl+Alt+H on Windows and Linux |
|
||||
|
||||
### File operations
|
||||
|
||||
- Deleting asks first, saying what will be deleted and what happens:
|
||||
- Local items move to the Trash (the Recycle Bin on Windows).
|
||||
- Remote items are deleted permanently, a folder together with everything in it.
|
||||
- Deleting a symbolic link deletes only the link itself.
|
||||
- Renaming and creating:
|
||||
- The name is checked against the current list: it can’t be empty, can’t be `.` or `..`, and can’t be the same as an existing item’s.
|
||||
- Renaming never overwrites an existing item.
|
||||
- Properties:
|
||||
- Shows the location, size and modification time, plus the owner and group for remote items.
|
||||
- Permissions are edited with a 3×3 grid of checkboxes and an octal number, kept in sync.
|
||||
- With several items selected, permission bits that differ between them stay as they are unless you change them.
|
||||
- When folders are included, the change can be applied recursively; a recursive change checks “Add execute permission to folders (X)” by default, so the folders can still be opened.
|
||||
- Symbolic links are left out of permission changes.
|
||||
|
||||
### Editing files
|
||||
|
||||
Text files can be edited right in ShellRS, remote and local alike (like WinSCP’s internal editor):
|
||||
|
||||
- Opening: double-click a file, select it and press Enter or F4, or right-click it and choose “Edit”. A file made with “New › File…” opens right away too. Files open in an editor tab in the center area, which can be dragged into a split like terminals and SFTP; each file has only one tab, and opening it again switches to that tab.
|
||||
- The editor has line numbers, find and replace (⌘F / Ctrl+F; replace ⌘⇧F / Ctrl+H), and coloring by file name for Shell, YAML, TOML, JSON, Python, JavaScript, HTML, CSS, Markdown, SQL, PHP, Go, Lua, Makefile and Diff; other files are plain text. In Makefiles and files indented with tabs, the Tab key types a tab character.
|
||||
- Saving: ⌘S / Ctrl+S or “Save” above the tab. A remote file is written straight back to the original file (in place) over the connection of the SFTP tab that opened it, so its owner, permissions and the links pointing to it stay the same. Before saving, ShellRS checks the file’s size and modification time: if someone else changed or deleted it after you opened it, you’re asked whether to “Overwrite” first, rather than having it overwritten silently. If the connection drops partway through writing, the file on the server may be only partly written; the editor still has your text, so reconnect and save again.
|
||||
- “Reload” reads the file again from the server or the disk, asking first if there are unsaved changes.
|
||||
- A dot on the tab means unsaved changes. Closing such a tab asks whether to “Save”, “Discard changes” or “Cancel”; closing several tabs at once, closing an SFTP tab (which also closes the remote files it opened) or quitting ShellRS with unsaved files asks once first. On macOS, quitting from the Dock and logging out or shutting down don’t ask.
|
||||
- The bottom left of the window shows the SFTP connection the file goes through (“Local file” for local files), and the bottom right the cursor position, the encoding and the line endings (such as “UTF-8 · LF”).
|
||||
- Limits: only UTF-8 text files of 5 MB or less open (with or without a BOM; the BOM and CRLF line endings are kept on save). Larger files, binary files and files in other encodings such as GBK are refused with the reason; remote ones can be downloaded with “Download…” instead.
|
||||
|
||||
### Preview
|
||||
|
||||
Images (PNG, JPG, GIF, WebP, SVG, BMP, ICO, TIFF) and Markdown can be previewed, remote and local alike:
|
||||
|
||||
- Opening: double-click an image. Markdown and SVG are text, so double-clicking them opens the editor; to preview them, choose “Preview” in the context menu.
|
||||
- The preview is a dialog that covers most of the window; Esc, the close button at the top right or the “Close” button closes it. Markdown is shown formatted: you can scroll, select and copy, and click links to open them in the browser.
|
||||
- Images start at “Fit to window”: scaled down in proportion until they fit (small images aren’t enlarged) and centered, with the size in pixels, the file size and the current scale above. The “Zoom in” and “Zoom out” buttons (or ⌘= / ⌘-, Ctrl on Windows and Linux) step through 10%–800%, and scrolling the wheel or trackpad with ⌘ (Ctrl) held zooms smoothly; “Actual size” (⌘0) shows one image pixel per point, and “Fit to window” (⌘9) goes back to the size that fits; double-click the image to switch between the two. When the image is larger than the preview area, you can scroll in every direction, and zooming keeps the point in the middle of the view in place.
|
||||
- At the bottom, “Download…” downloads a remote file to this computer (opening the download confirmation), and for Markdown, “Edit” opens the editor (or switches to it if it’s already open).
|
||||
- The preview shows the file on the server or the disk; changes not yet saved in the editor don’t appear in it.
|
||||
- Limits: images up to 20 MB, Markdown up to 5 MB and in UTF-8; larger files are refused with the reason, and remote ones can be downloaded with “Download…” instead.
|
||||
|
||||
### Uploading and downloading
|
||||
|
||||
- Ways to start:
|
||||
- Upload: “Upload…”, F5 in the local list, “Choose files to upload…”, dragging local items by their file names to the remote list, or dragging files from Finder to the remote list.
|
||||
- Download: “Download…”, F5 in the remote list, or dragging remote items by their file names to the local list.
|
||||
- Dropping on a folder’s row puts the items in that folder; dropping anywhere else puts them in the current folder.
|
||||
- Every way shows a confirmation where you can change the destination folder; for downloads, “Browse…” chooses a folder on this computer. The confirmation says how many items of which kinds (such as “Upload 2 files to …”), shows the source folder once, and lists each item on its own line, with the full path on hover.
|
||||
- Once confirmed, the source, endpoints and destination are fixed. Each SFTP tab runs one batch at a time (uploads and downloads share it), handling the batch’s items one after another; while a transfer runs, you can start more uploads or downloads, and each new batch waits in Transfers and starts automatically when the one before it ends. Other SFTP tabs, hosts and terminals stay usable.
|
||||
- Recursive transfers include empty folders. Symbolic links are recreated as links, not followed. Transfers are binary by default, and regular files keep their modification time.
|
||||
- New items get the destination side’s default permissions, and overwriting keeps the destination’s existing basic permissions. Owners, ACLs and extended attributes aren’t copied.
|
||||
- Folders with the same name are merged. When a file or link conflicts, you’re asked whether to overwrite, skip or cancel, and can apply the answer to the rest of the batch’s conflicts; a folder conflicting with something that isn’t a folder is never deleted automatically.
|
||||
- On an error, you can retry, skip or cancel, and the summary at the end lists what succeeded, what was skipped and what failed.
|
||||
|
||||
### Transfers
|
||||
|
||||
Works like WinSCP’s queue panel: it appears below the two panes when there are transfers, its height can be dragged, and it collapses once all finished batches are cleared.
|
||||
|
||||
- One row per batch: Direction (upload / download), Source (the item’s path for a single item, the folder they’re in for several), Destination, Transferred (in the same units as the “Size” column), Time (time left), Speed (over the last few seconds) and Progress.
|
||||
- A batch in progress shows an overall progress bar, with a second line below for the file being transferred and its own progress bar, like the two progress bars in WinSCP’s progress dialog.
|
||||
- The Progress column shows: Waiting, Scanning, a percentage, Waiting for an answer, a note about reconnecting, Stopped, Done, N items failed, Removed.
|
||||
- Click the arrow at the start of a row to expand the batch’s results item by item (Done, Skipped, or Failed with the reason), newest first.
|
||||
- Toolbar: Resume (a stopped batch), Stop (a batch in progress), Remove from queue (the selected waiting or finished batches, or a stopped batch), Discard partial transfer, Clear finished.
|
||||
- A stopped batch stays at the front of the queue; the batches after it start only once you resume it, discard its partial transfer or remove it from the queue. Removing it from the queue doesn’t delete its resume record, so transferring the same files again still asks whether to resume.
|
||||
- The queue lasts only for this run and is cleared when the SFTP tab closes; closing the tab with unfinished batches asks first.
|
||||
|
||||
## Resuming and replacing
|
||||
|
||||
Files are first written to `<destination file name>.filepart`. Write requests go through a bounded pipeline, and the real file is published only after every write has been acknowledged and the file has closed successfully; when the server supports it, `fsync@openssh.com` is called first. Servers that support `posix-rename@openssh.com` replace the file atomically; on other servers, the old file is first moved to `.shellrs-<UUID>.backup` and then the new file is published, a compatibility path that isn’t guaranteed to be atomic. If the reply to a publish is lost, ShellRS recovers using the managed temporary path, the stage, the type and the size; when it can’t tell safely, it keeps the recovery files and reports it.
|
||||
|
||||
Resume records are kept in `upload-resume/` in the app’s data folder, as versioned JSON written atomically; they never contain passwords, passphrases or private keys, and they don’t change the database schema. A record ties together the endpoint, host fingerprint, source, destination and safe-replace stage. Resuming works as in WinSCP: ShellRS reads the current size of the remote `.filepart`, skips that much of the local file and carries on; it’s up to you to make sure the source is still the same version. Uploads never read the remote file back to verify it.
|
||||
|
||||
Downloads likewise write first to `<destination file name>.filepart` on this computer; when done, ShellRS sets the remote modification time, runs fsync, and renames it to the real file within the same file system (an atomic replace). Concurrent read requests may come back out of order or short, but the temporary file is written strictly in order, because resuming trusts only its length. Download resume records are kept in `download-resume/` and tie together the endpoint, host fingerprint, remote source with its size and modification time, and local destination; if the remote file changes, the download can’t resume and can only start over or be skipped.
|
||||
|
||||
On network failures, each batch reconnects automatically up to three times, after 1, 3 and 10 seconds. After that, resume the batch with “Resume” in Transfers; resuming by hand resets the count. Authentication failures, a changed host key and a deliberate cancel need you to deal with them. Reconnecting uses the original connection settings, so editing the host doesn’t change the destination of a transfer in progress.
|
||||
|
||||
Cancelling, closing the tab, disconnecting or quitting keeps the resume records; closing a tab with a transfer in progress asks first. After a restart, transfers don’t continue by themselves; choosing the same source and destination again asks whether to resume. While a batch is stopped, “Discard partial transfer” cleans up its managed temporary files; if there’s a recovery backup that can’t be judged safely, the files are kept and the reason is given. Items published successfully have their records and backups cleaned up automatically.
|
||||
|
||||
When a file name can’t be represented exactly, ShellRS reports an error. The remote string decoding of the current `russh-sftp 3.0.0` replaces invalid UTF-8, so the browser refuses remote names that contain the replacement character `U+FFFD`, to avoid acting on the wrong path. Symbolic links use OpenSSH’s SFTP v3 argument order.
|
||||
|
||||
**Not implemented yet: folder sync in SFTP tabs (the external CLI’s `shellrs sync` can sync), a foreground transfer progress dialog, reordering the queue, filtering, finding files, a folder tree, and keeping the Dock layout.**
|
||||
|
||||
## Download and updates
|
||||
|
||||
Packages are on [GitHub Releases](https://github.com/since2006/shell-rs/releases):
|
||||
|
||||
| System | First install | Automatic updates |
|
||||
|---|---|---|
|
||||
| macOS (one package for Apple Silicon and Intel) | DMG; drag ShellRS into Applications | Yes |
|
||||
| Windows x64 | Setup program, which installs for the current user into `%LOCALAPPDATA%\Programs\ShellRS`, with no administrator rights needed | Yes |
|
||||
| Linux x64 | AppImage | Yes |
|
||||
|
||||
ShellRS checks for a new version 30 seconds after it starts, then every 6 hours. When there is one, it downloads and verifies it in the background, and then a green update button appears at the top right of the title bar. Click it: “What’s new” opens the changelog on the website, and “Restart to update” switches to the new version and reopens. Restarting closes open connections and stops transfers (keeping their progress for resuming) and port forwards; the dialog lists exactly which.
|
||||
|
||||
“Updates” in “Settings › About” has just three rows:
|
||||
|
||||
- **Current version**: below it, the current state (up to date, downloading, downloaded and so on); at the right, the version number and a button, normally “Check for updates”, which becomes “Restart to update…” once an update is downloaded. The build number and platform are in the version number’s tooltip.
|
||||
- **Update channel**: Stable is for everyday use; Beta gets test versions early, and stable releases too when they come out. After you switch, ShellRS checks the new channel right away (with automatic updates off, it waits until you check by hand).
|
||||
- **Automatic updates**: when this is off, ShellRS goes online only when you check by hand, and when it finds a new version, you decide whether to download it.
|
||||
|
||||
- **Verification**: the update manifest is signed with ShellRS’s release key, and the package is checked against the size and SHA-256 in the manifest; if any step doesn’t match, nothing is installed. On macOS, the new version’s developer signature is checked as well.
|
||||
- **Privacy**: checking for updates sends only ShellRS’s version number, the operating system and the CPU architecture, nothing that could identify this computer or you. For anonymous usage statistics, see “Privacy”.
|
||||
- **When automatic updates aren’t possible**: when ShellRS runs straight from the DMG or outside the Applications folder, you can’t write to where it’s installed, on Linux it isn’t run as the AppImage, or it’s a development build you compiled yourself. The About page then says why, and offers “Open download page” when a new version is out.
|
||||
- **Going back to an older version**: before the database is upgraded to a new schema, it’s backed up as `shellrs.db.v<old version>.bak`. An older version refuses to open a database with a newer schema, so as not to mess up data a newer version saved; to go back, rename the backup to `shellrs.db`.
|
||||
|
||||
## Privacy
|
||||
|
||||
ShellRS never sends hosts, credentials, session content, commands, file names or paths, or any identifier that could identify this computer or you. Only these two things go online (besides connecting to your hosts):
|
||||
|
||||
- **Checking for updates**: sends only ShellRS’s version number, the operating system and the CPU architecture; see “Download and updates”.
|
||||
- **Anonymous usage statistics**: sent to [Aptabase](https://aptabase.com) to learn how many installs there are, how many devices use ShellRS each day, how the versions are spread, and which features are used most. “Send anonymous usage statistics” in “Settings › About › Privacy” is on by default; turn it off and nothing more is sent, and the counts collected on this computer are cleared too.
|
||||
|
||||
Every statistics event carries ShellRS’s version number and the operating system’s name and version (such as macOS 15.1, Windows 10.0.22631, Ubuntu 24.04). There are only four events:
|
||||
|
||||
| Event | When it’s sent |
|
||||
|---|---|
|
||||
| `app_installed` | Once, on the first launch. The data folder records that it was reported; a different data folder, or deleting it, reports it again |
|
||||
| `app_started` | Every launch |
|
||||
| `app_active` | The first time each day (UTC) the window comes to the front; not again on the day ShellRS started. On macOS, a closed window with ShellRS left in the background doesn’t count |
|
||||
| `usage` | How many times each feature was used: once 4 hours have passed since the first use, summed up into one event at the next launch, activation or use |
|
||||
|
||||
The first three carry these settings and counts, each with a fixed set of possible values: CPU architecture, appearance, interface language, light and dark themes (names of built-in themes), whether the interface matches the theme, whether highlighting, the external CLI and automatic updates are on, the update channel, whether any shortcut was changed, and how many hosts, credentials, port forwards and snippets are saved (only as a band: 0, 1-5, 6-20, 21-100, 100+).
|
||||
|
||||
`usage` has only counts, no content. It counts: SSH terminals connected (also counted separately for jump hosts, proxies, credentials, no password, quick connections and external connections), SFTP connected (also counted for quick connections and external connections), local terminals, port forwards started (local, remote, dynamic), upload and download batches, the editor opening local and remote files, previews of images and Markdown, opening each of the seven right sidebar tools, snippets and history commands sent to the terminal, terminal find, notifications (program, bell, keyword), the external CLI’s `exec`, `upload`, `download`, `sync`, `hosts` and `credentials` commands, and turning highlighting and the external CLI on and off.
|
||||
|
||||
- Counts are first kept in `analytics.json` in the data folder, so quitting doesn’t lose them, and they’re sent later. Being offline or on an intranet doesn’t get in the way: when sending fails, ShellRS waits 1 minute, then doubles the wait each time up to 6 hours, and retries only while you’re using ShellRS, sending no requests while it’s idle.
|
||||
- Aptabase infers the country and region from the request’s IP address but doesn’t store the IP. It tells devices apart by a hash of the IP and User-Agent with a random salt that changes every day, so it can’t follow the same device across days.
|
||||
- Development builds you compile yourself don’t send statistics, and neither does the external CLI (the `shellrs` command) itself.
|
||||
@@ -1,6 +1,6 @@
|
||||
# ShellRS 使用手册
|
||||
|
||||
[返回 README](../README.md)
|
||||
[返回 README](../README.zh-CN.md)
|
||||
|
||||
基于 GPUI Kit 的中文 SSH 主机管理工具,提供持久化的主机与分组、可在多台主机间共用的登录凭据、本地终端、SSH 多终端、对标 WinSCP 的 SFTP 双栏浏览器和 SSH 端口转发。
|
||||
|
||||
@@ -104,9 +104,9 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已
|
||||
|
||||
侧栏最下方的「设置」按钮(或 ⌘,,其他平台 Ctrl+,)在中间区打开「设置」标签页;已经打开时切换到该标签,不会重复打开。设置页分两栏:左边是分类(外观、终端、关键字高亮、键盘快捷键、外部 CLI、应用、关于),上方可搜索,栏宽可拖动;右边是所选分类的设置。设置改动立即生效并保存在数据目录的 `settings.json` 里。
|
||||
|
||||
- **外观 → 常规 → 界面语言**:跟随系统、简体中文、English,默认简体中文。英文界面尚在翻译中,目前只有 GPUI Kit 组件自带的文字(如搜索框提示)会切换。
|
||||
- **外观 → 常规 → 界面语言**:跟随系统、简体中文、English,默认跟随系统:系统语言是中文时用简体中文,其他语言用英文。改动立即生效,不用重启:窗口里的文字、已打开的标签和搜索框提示一起换成新的语言;已经写进终端的提示和已经弹出的通知保持原样。外部 CLI 的输出也用这里的语言。
|
||||
- **外观 → 常规 → 应用外观**:深色、浅色、跟随系统,默认跟随系统;选「跟随系统」时随系统的深浅色切换。标题栏的主题按钮也会改写这一项。
|
||||
- **外观 → 主题**:左栏是浅色主题,右栏是深色主题,各选一套:应用外观为浅色时用左栏选中的那套,深色时用右栏的;跟随系统时随系统一起切换,标题栏的主题按钮切换时也一样。默认只有终端按所选主题配色,界面保持中性的黑白灰;打开上方的「界面跟随主题」,界面也按主题配色:侧栏、标签栏、对话框等用主题的底色和文字色,成功、警告、错误等状态用主题的绿、黄、红。每张卡片用该主题的颜色显示一段示例,选中的右上角有蓝色对勾。默认是 ShellRS Light 和 ShellRS Dark,即原来的界面配色。内置浅色、深色各 10 套:浅色 ShellRS Light、Solarized Light、Tokyo Day、Catppuccin Latte、Flexoki Light、One Half Light、Gruvbox Light、Kanagawa Lotus、Rosé Pine Dawn、Everforest Light;深色 ShellRS Dark、Solarized Dark、Tokyo Night、Catppuccin Mocha、Flexoki Dark、One Half Dark、Gruvbox Dark、Kanagawa Wave、Dracula、Nord。同一主题的浅色版和深色版在两栏的同一行。改动立即作用于所有已打开的终端,字体和关键字高亮的预览也用当前主题;vim 等程序询问终端的背景色时,得到的是当前主题的颜色。**尚未实现:自定义主题、导入配色、按主机指定主题。**
|
||||
- **外观 → 主题**:左栏是浅色主题,右栏是深色主题,各选一套:应用外观为浅色时用左栏选中的那套,深色时用右栏的;跟随系统时随系统一起切换,标题栏的主题按钮切换时也一样。默认只有终端按所选主题配色,界面保持中性的灰色调(深色是不刺眼的炭灰,不是纯黑);打开上方的「界面跟随主题」,界面也按主题配色:侧栏、标签栏、对话框等用主题的底色和文字色,成功、警告、错误等状态用主题的绿、黄、红。每张卡片用该主题的颜色显示一段示例,选中的右上角有蓝色对勾。默认是 ShellRS Light 和 ShellRS Dark,与界面同色。内置浅色、深色各 10 套:浅色 ShellRS Light、Solarized Light、Tokyo Day、Catppuccin Latte、Flexoki Light、One Half Light、Gruvbox Light、Kanagawa Lotus、Rosé Pine Dawn、Everforest Light;深色 ShellRS Dark、Solarized Dark、Tokyo Night、Catppuccin Mocha、Flexoki Dark、One Half Dark、Gruvbox Dark、Kanagawa Wave、Dracula、Nord。同一主题的浅色版和深色版在两栏的同一行。改动立即作用于所有已打开的终端,字体和关键字高亮的预览也用当前主题;vim 等程序询问终端的背景色时,得到的是当前主题的颜色。**尚未实现:自定义主题、导入配色、按主机指定主题。**
|
||||
- **终端 → 字体配置**:字体(只列出本机安装的等宽字体,默认是系统的等宽字体,macOS 上为 Menlo)、字号(10–28 像素,默认 13)、行高(字号的 1.0–2.0 倍,默认 1.54,即原来的 20 像素行高)。改动立即作用于所有已打开的终端;在终端里按 ⌘= / ⌘-(其他平台 Ctrl)也能逐级调字号,⌘0 回到默认。下方的预览按终端的排法显示字母、数字、易混字符、符号、中文、制表符和一行命令。设置文件里的字体在本机没有安装时,使用默认字体。
|
||||
- **关键字高亮**:总开关、预览和规则表,终端里按规则给文字上色、匹配时提醒,见「终端」一节。
|
||||
- **键盘快捷键**:主要功能的快捷键,分通用、标签、终端、编辑器四组,每行是功能名和它的按键。点按键进入「按下组合键」,按下新的组合就换上;Esc 或点别处放弃。行尾两个按钮:禁用(之后显示「无」,再点一下恢复默认)、恢复默认;页面上方的「重置」也能逐项恢复。改动立即生效,工具栏提示和右键菜单里显示的按键跟着变。
|
||||
@@ -116,7 +116,7 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已
|
||||
- SFTP 文件列表、图片预览的快捷键和 Enter、方向键、Tab、Esc 这些固定键不在这里改。
|
||||
- **外部 CLI**:见下一节。
|
||||
- **应用 → 窗口**:「记住窗口大小」恢复上次的窗口大小和最大化状态,「记住窗口位置」把窗口放回上次所在的屏幕和位置,默认都打开,下次启动时生效。那块屏幕不在了、或窗口大半在屏幕外时,放在屏幕中间;窗口比屏幕大时缩到能放下。全屏不记,按退出全屏后的大小恢复。窗口的大小和位置随移动随时记在数据目录的 `window.json` 里。
|
||||
- **关于**:「应用更新」一组:当前版本和检查更新、更新渠道、自动升级,见「下载与更新」;「ShellRS」一组的「官网」用浏览器打开 shellrs.com。
|
||||
- **关于**:「应用更新」一组:当前版本和检查更新、更新渠道、自动升级,见「下载与更新」;「隐私」一组的「发送匿名使用统计」,默认打开,见「隐私」;「ShellRS」一组的「官网」用浏览器打开 shellrs.com。
|
||||
|
||||
## 外部 CLI
|
||||
|
||||
@@ -124,7 +124,7 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已
|
||||
|
||||
在 设置 → 外部 CLI 中:
|
||||
|
||||
- **启用外部 CLI**:默认关闭。关闭时 ShellRS 仍在监听,但拒绝所有请求,命令会提示去这里打开。开启后,本机当前用户下的任何程序都能这样使用已保存的主机,也能修改主机和凭据(没有另外的开关)。套接字只对当前用户可读写,连接时还会核对对方的用户身份;Windows 的管道同样只允许当前用户、只接受本机连接,命令在发请求之前还会确认管道属于当前用户(或管理员),防止别人抢注同名管道。
|
||||
- **启用外部 CLI**:默认关闭。关闭时 ShellRS 仍在监听,但拒绝所有请求,命令会提示去这里打开。开启后,本机当前用户下的任何程序都能这样使用已保存的主机,也能修改主机和凭据,还能在已打开的远程终端里输入命令(`exec --terminal`,包括已切到 root 的终端),都没有另外的开关。套接字只对当前用户可读写,连接时还会核对对方的用户身份;Windows 的管道同样只允许当前用户、只接受本机连接,命令在发请求之前还会确认管道属于当前用户(或管理员),防止别人抢注同名管道。
|
||||
- **CLI 二进制**:把 `shellrs` 放进 PATH。
|
||||
- macOS:链接到 `/usr/local/bin/shellrs`,目录不可写时弹系统授权框。
|
||||
- Linux:链接到 `~/.local/bin/shellrs`。
|
||||
@@ -139,6 +139,7 @@ shellrs hosts list [-q <关键词>] [--json] # 列出主机;输出不是
|
||||
shellrs hosts show <ID> [--json] # 一台主机的全部配置
|
||||
shellrs exec <ID> "<命令>" # 或 --stdin 从标准输入读命令
|
||||
shellrs exec --json # 从标准输入读 {"host", "command"},输出 JSON
|
||||
shellrs exec <ID> --terminal "<命令>" # 在 ShellRS 已打开的终端里执行,见下文
|
||||
shellrs upload <ID> <本地路径> <远程路径>
|
||||
shellrs download <ID> <远程路径> <本地路径>
|
||||
shellrs sync <ID> <本地目录> <远程目录> [--delete]
|
||||
@@ -148,6 +149,11 @@ shellrs credentials list | show <ID> | create | update <ID> | delete <ID>
|
||||
|
||||
- `<ID>` 是 16 位的主机 ID,即 `shellrs hosts list` 的 `id`,也就是主机右键「复制 ID」复制的内容。列表里还有标签开着的临时连接和外部连接(`"temporary": true`),见「临时连接」和「从堡垒机打开(外部连接)」。
|
||||
- `exec` 每次临时建立连接,执行完即断开。远程命令没有标准输入,退出码就是远程命令的退出码;被信号终止时是 128 加信号编号。
|
||||
- `exec --terminal`(`exec --json` 里写 `"terminal": true`)不重新登录,而是把命令打进这台主机在 ShellRS 里已打开的远程终端,给只允许登录一次的堡垒机用(见「从堡垒机打开(外部连接)」)。它先发 Ctrl-C,打断终端里正在运行的程序、清掉输入行里没提交的内容,再输入一行探测和一行命令;这两行和命令的输出都显示在终端里。命令在终端当前的用户、目录和机器上执行(`su`、`sudo -i`、嵌套 `ssh` 之后也是),`--json` 的结果多一项 `context`(`user`、`host`、`cwd`、`shell`、`interp`)写明在哪里。命令的输出经管道,程序看不到终端:没有颜色和分页器,stderr 并进 stdout;命令没有标准输入,要密码的程序会停在终端里等。用哪个终端:右侧栏正对着的终端是这台主机的就用它,否则用这台主机最新的、已连接的终端。
|
||||
- 只支持 Linux 上的 bash、dash 和 busybox ash(否则报 `unsupported_shell`)。编码后的命令行,bash 和 dash 最多 4000 字节,busybox 最多 1000 字节(否则报 `too_long`)。
|
||||
- 以下情况报 `terminal_busy`,命令没有执行:全屏程序(vim、less、top、tmux 等)占着终端;上一条 `--terminal` 命令还没结束;5 秒内 shell 没有回应(前台程序不理 Ctrl-C,如 python、mysql);15 秒内 shell 没有开始执行(可能被堡垒机的命令过滤或复核拦下,这时会再发一次 Ctrl-C)。终端没打开或没连接报 `no_terminal`。
|
||||
- 执行过程中没有时限;命令行工具中途被结束时(如 Agent 超时),终端里的命令收到 Ctrl-C。
|
||||
- 那两行行首有空格,bash 设了 `ignorespace` 时不记进历史,否则会记下。停在不带提示的 `read -s`、或 vi 编辑模式的命令模式时认不出,会报「没有回应」。
|
||||
- 上传和下载沿用 SFTP 标签的传输引擎(递归、`.filepart`、断线重连),目标路径按 scp 的规则:目标是已存在的目录就放进去并保留原名,否则目标就是副本自己的路径,其父目录必须存在。已存在的文件直接覆盖;新建的文件保留源文件的可执行权限。`~` 表示登录目录。
|
||||
- `sync` 只从本机到主机:把本地目录里的内容同步进远程目录。远程目录不存在就新建(父目录必须存在),是文件就报错,不能是根目录 `/`。大小和修改时间都没变的文件、指向不变的链接跳过,摘要里计为「未变」,所以再同步一次只传改过的;其余文件直接覆盖,新文件保留可执行权限。`--delete` 先删掉远程目录里本地没有的、或类型不同的项(不跟随链接),但不删传输进行中留下的 `.filepart` 和 `.shellrs-….backup`,本地读不了的目录也不清理;删不掉的项计为失败。
|
||||
- `hosts` 和 `credentials` 管理保存的主机和凭据。`create`、`update` 从标准输入读一个 JSON 对象(`update` 只改给出的字段),字段见 `shellrs hosts create --help` 和 `shellrs credentials create --help`;`show --json` 的输出可以改了直接交回 `update`。校验和对话框一样,报错用对话框的说法:
|
||||
@@ -157,7 +163,7 @@ shellrs credentials list | show <ID> | create | update <ID> | delete <ID>
|
||||
- 临时连接和外部连接能 `show`,不能修改或删除。
|
||||
- `hosts delete` 不确认,连同主机的端口转发规则一起删;主机在 ShellRS 里有打开的标签时拒绝(`host_in_use`),加 `--force` 才关掉标签再删。经它跳转的主机,那一跳变成「已删除的主机」。`credentials delete` 也不确认,用它的主机改为自己登录(同界面)。
|
||||
- 改动由 ShellRS 一条一条执行:10 秒内没轮到就撤回、不做;开始执行后会等到完成。
|
||||
- 失败时在标准错误输出 `shellrs: [错误码] 说明`,退出码 255。错误码有 `not_running`(ShellRS 未运行)、`not_enabled`(未启用外部 CLI)、`host_not_found`、`credential_not_found`、`host_key_unknown`(还没在 ShellRS 里连过这台主机)、`host_key_changed`、`missing_credential`(没有保存密码或口令)、`connect_failed`、`transfer_failed`、`bad_request`、`host_in_use`、`save_failed`(改动已做,但没能全部写进数据库或钥匙串)、`version_mismatch`(命令和正在运行的 ShellRS 版本不同,或 ShellRS 太旧、不认识这个命令)。CLI 不弹任何询问:陌生主机、缺少密码都直接失败,请先在 ShellRS 里连接一次。传输完成但有项目失败时退出码为 1。
|
||||
- 失败时在标准错误输出 `shellrs: [错误码] 说明`,退出码 255。错误码有 `not_running`(ShellRS 未运行)、`not_enabled`(未启用外部 CLI)、`host_not_found`、`credential_not_found`、`host_key_unknown`(还没在 ShellRS 里连过这台主机)、`host_key_changed`、`missing_credential`(没有保存密码或口令)、`connect_failed`、`transfer_failed`、`bad_request`、`host_in_use`、`save_failed`(改动已做,但没能全部写进数据库或钥匙串)、`version_mismatch`(命令和正在运行的 ShellRS 版本不同,或 ShellRS 太旧、不认识这个命令),以及 `exec --terminal` 才有的 `no_terminal`、`terminal_busy`、`unsupported_shell`、`too_long`。CLI 不弹任何询问:陌生主机、缺少密码都直接失败,请先在 ShellRS 里连接一次。传输完成但有项目失败时退出码为 1。
|
||||
- `exec --json` 从标准输入读 `{"host": "<ID>", "command": "<命令>"}`,命令结束后输出 `{"exit_code", "stdout", "stderr"}`,出错时输出 `{"error": {"code", "message"}}`,退出码和不加 `--json` 时一样。输出只含 ASCII,中文等字符都转成 `\uXXXX`;不是 UTF-8 的输出换成 U+FFFD。命令不经过本机 shell 的引号处理,控制台的代码页也不会把输出弄乱,适合 Windows PowerShell 和需要分开读标准输出、标准错误的 Agent。
|
||||
- 支持 macOS、Linux 和 Windows。Windows 的 PowerShell 里执行命令优先用 `exec --json`(`@{ host = '<ID>'; command = '…' } | ConvertTo-Json -Compress | shellrs exec --json`);要边执行边看输出时(长时间构建、大量日志),改用 here-string 经 `--stdin` 传入(`@'…'@ | shellrs exec <ID> --stdin`),并和 `list`、`upload`、`download` 一样,先执行 `$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new()`,否则中文会变成问号或乱码。命令文本会去掉开头的 BOM、把 CRLF 换成 LF,远程 shell 不会看到多余的回车符。
|
||||
|
||||
@@ -182,7 +188,7 @@ ShellRS /sessionname=标签名 sftp://用户[:密码]@地址[:端口]
|
||||
- Windows:安装目录里的 `shellrs.exe`(默认 `%LOCALAPPDATA%\Programs\ShellRS\shellrs.exe`)。不要用 PATH 里的 `shellrs.exe`:那是外部 CLI 的副本。
|
||||
- macOS:`/Applications/ShellRS.app/Contents/MacOS/shellrs`。不要用 `open -a ShellRS --args …`:ShellRS 已在运行时,系统会把参数丢掉。
|
||||
- Linux:AppImage 本身。
|
||||
- 外部 CLI 的 `shellrs hosts list` 也列出外部连接(JSON 里 `"temporary": true`,表格的分组一栏写「(未保存)」),标签开着时 Agent 可以用它的 ID 执行命令、传输文件,这期间 ID 不变,可以一直用。标签关掉后 ID 失效;堡垒机再拉起一次(即使是同一台资产)是新的外部连接,ID 也是新的。和保存的主机一样,每条命令都用链接里的用户名和密码重新登录一次;堡垒机给的若是只能用一次的令牌,这次登录会被拒绝。
|
||||
- 外部 CLI 的 `shellrs hosts list` 也列出外部连接(JSON 里 `"temporary": true`,表格的分组一栏写「(未保存)」),标签开着时 Agent 可以用它的 ID 执行命令、传输文件,这期间 ID 不变,可以一直用。标签关掉后 ID 失效;堡垒机再拉起一次(即使是同一台资产)是新的外部连接,ID 也是新的。和保存的主机一样,每条命令都用链接里的用户名和密码重新登录一次;堡垒机给的若是只能用一次的令牌,这次登录会被拒绝,这时改用 `shellrs exec --terminal`,在外部连接已打开的终端里执行命令(见「外部 CLI」)。
|
||||
- 命令行参数里的密码在本机的进程列表里看得到,这是堡垒机这样传参本身的问题。
|
||||
|
||||
## 标签页
|
||||
@@ -203,7 +209,7 @@ ShellRS /sessionname=标签名 sftp://用户[:密码]@地址[:端口]
|
||||
|
||||
## 右侧栏
|
||||
|
||||
当前标签是远程终端时,窗口最右边有一列工具按钮:命令片段、历史命令、Docker、系统服务、进程管理、网络连接、系统监控。点一个就在右侧打开这个工具,再点一次收起;⌘⌥B(其他平台 Ctrl+Alt+B)显示或隐藏右侧栏。拖动右侧栏的左边缘可以加宽,最窄就是打开时的宽度。右侧栏只跟着远程终端出现,切到 SFTP、本地终端或设置时连同按钮一起隐藏,回到任一远程终端时按原样恢复,内容对应当前这个终端的主机。已知不是 Linux 的主机(macOS、Windows、BSD)不显示系统服务、进程管理、网络连接和系统监控的图标;历史命令和 Docker 除了 Windows 主机都有(Mac 上装了 Docker Desktop 也能用)。
|
||||
当前标签是远程终端时,窗口最右边有一列工具按钮:命令片段、历史命令、Docker、系统服务、进程管理、网络连接、系统监控。点一个就在右侧打开这个工具,再点一次收起;⌘⌥B(其他平台 Ctrl+Alt+B)显示或隐藏右侧栏。拖动右侧栏的左边缘可以加宽,最窄就是打开时的宽度。右侧栏只跟着远程终端出现,切到 SFTP、本地终端或设置时连同按钮一起隐藏,回到任一远程终端时按原样恢复,内容对应当前这个终端的主机。把标签拖成分屏后,右侧栏不隐藏:它对应最近一次切到或点进去的远程终端,点进另一半的 SFTP 或本地终端时不变,屏幕上一个远程终端都没有时才隐藏;标题里写着它对应哪台主机(如「系统监控 · web-01」),状态栏也跟着点进去的那一半。已知不是 Linux 的主机(macOS、Windows、BSD)不显示系统服务、进程管理、网络连接和系统监控的图标;历史命令和 Docker 除了 Windows 主机都有(Mac 上装了 Docker Desktop 也能用)。
|
||||
|
||||
**命令片段**保存常用的命令,所有主机共用。点顶部的加号新建片段:名称、分类和命令,命令可以有多行。片段按分类分组显示,没有分类的放在最后的「未分类」下;点分类标题折叠或展开,右键分类标题可以在这个分类里新建片段、重命名或删除分类(分类里的片段会一起删除,确认时写明个数);顶部的文件夹按钮新建分类。可以按名称或命令搜索,输入几个词时列出同时包含这些词的片段。
|
||||
|
||||
@@ -417,6 +423,30 @@ ShellRS 启动 30 秒后检查一次有没有新版本,之后每 6 小时检
|
||||
- **自动升级**:关掉之后只在手动检查时才联网,发现新版本时由你决定是否下载。
|
||||
|
||||
- **校验**:更新清单由 ShellRS 的发布密钥签名,安装包按清单里的大小和 SHA-256 核对,任何一步对不上都不会安装。macOS 上还会核对新版本的开发者签名。
|
||||
- **隐私**:检查更新只发送 ShellRS 的版本号、操作系统和 CPU 架构,不发送任何能识别这台电脑或用户的信息。
|
||||
- **隐私**:检查更新只发送 ShellRS 的版本号、操作系统和 CPU 架构,不发送任何能识别这台电脑或用户的信息。匿名使用统计见「隐私」一节。
|
||||
- **不能自动更新的情况**:从 DMG 里直接运行、没有放进「应用程序」文件夹、对安装位置没有写权限、Linux 上不是用 AppImage 运行,或者是自己编译的开发构建。这时「关于」页会说明原因,有新版本时给出「前往下载页」。
|
||||
- **退回旧版本**:数据库在升级到新结构之前会备份成 `shellrs.db.v<旧版本>.bak`。旧版本打开新结构的数据库时会拒绝打开,以免把新版本保存的数据弄乱;需要退回时,把备份改名回 `shellrs.db`。
|
||||
|
||||
## 隐私
|
||||
|
||||
ShellRS 不发送主机、凭据、会话内容、命令、文件名或路径,也不发送任何能识别这台电脑或用户的标识。联网的只有这两件事(连接主机本身除外):
|
||||
|
||||
- **检查更新**:只发送 ShellRS 的版本号、操作系统和 CPU 架构,见「下载与更新」。
|
||||
- **匿名使用统计**:发给 [Aptabase](https://aptabase.com),用来了解安装量、每天有多少台设备在用、各版本的分布,以及哪些功能用得多。「设置 › 关于 › 隐私」里的「发送匿名使用统计」默认打开;关掉后不再发送,本机攒下的计数也一并清除。
|
||||
|
||||
每个统计事件都带 ShellRS 的版本号、操作系统的名称和版本(如 macOS 15.1、Windows 10.0.22631、Ubuntu 24.04)。事件只有四种:
|
||||
|
||||
| 事件 | 什么时候发 |
|
||||
|---|---|
|
||||
| `app_installed` | 第一次启动时发一次。数据目录里记着已经报过,换一个数据目录或删掉它会再报 |
|
||||
| `app_started` | 每次启动 |
|
||||
| `app_active` | 当天(UTC)第一次把窗口切到前台时;启动当天不再重复。macOS 上关了窗口、留在后台不算 |
|
||||
| `usage` | 各功能的使用次数:从第一次使用算起满 4 小时,在下一次启动、切到前台或使用时汇总成一条 |
|
||||
|
||||
前三种附带这些设置和数量,取值都是固定的几种:CPU 架构、应用外观、界面语言、浅色和深色主题(内置主题的名字)、界面是否跟随主题、关键字高亮、外部 CLI 和自动升级是否打开、更新渠道、是否改过快捷键,以及保存的主机、凭据、端口转发、命令片段各有多少(只报档位:0、1-5、6-20、21-100、100+)。
|
||||
|
||||
`usage` 只有次数,没有内容,计的是:SSH 终端连上(另按跳板、代理、凭据、无密码、临时连接、外部连接分别计数)、SFTP 连上(另按临时连接、外部连接计数)、本地终端、端口转发开启(本地、远程、动态)、上传和下载批次、编辑器打开本地和远程文件、预览图片和 Markdown、打开右侧栏的七个工具、命令片段和历史命令送进终端、终端查找、通知(程序、响铃、关键字),外部 CLI 的 `exec`、`exec --terminal`、`upload`、`download`、`sync`、`hosts`、`credentials` 命令,以及打开和关闭关键字高亮、外部 CLI。
|
||||
|
||||
- 计数先存在数据目录的 `analytics.json` 里,退出不会丢,之后再补发。离线或在内网里不影响使用:发不出去时等 1 分钟,之后每次加倍、最长 6 小时再试,而且只在使用 ShellRS 时顺带重试,空闲时不发任何请求。
|
||||
- Aptabase 按请求的 IP 地址推断国家和地区,但不保存 IP。它区分设备用的是 IP 和 User-Agent 加上每天更换的随机盐算出的散列,所以看不到同一台设备跨天的行为。
|
||||
- 自己编译的开发构建不发送统计;外部 CLI(`shellrs` 命令)本身也不发送。
|
||||
@@ -1,6 +1,6 @@
|
||||
# 发布
|
||||
|
||||
[返回 README](../README.md)
|
||||
[返回 README](../README.zh-CN.md)
|
||||
|
||||
在 `CHANGELOG.md` 里写好 `## [x.y.z]` 一节,把 `Cargo.toml` 的版本改成 `x.y.z`,提交后推送 tag `vx.y.z`。`.github/workflows/release.yml` 依次:
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 495 KiB |
|
After Width: | Height: | Size: 865 KiB |
|
After Width: | Height: | Size: 852 KiB |
|
After Width: | Height: | Size: 544 KiB |
|
After Width: | Height: | Size: 594 KiB |
|
After Width: | Height: | Size: 691 KiB |
|
Before Width: | Height: | Size: 404 KiB After Width: | Height: | Size: 553 KiB |
|
Before Width: | Height: | Size: 810 KiB After Width: | Height: | Size: 866 KiB |
|
Before Width: | Height: | Size: 814 KiB After Width: | Height: | Size: 852 KiB |
|
Before Width: | Height: | Size: 537 KiB After Width: | Height: | Size: 553 KiB |
|
Before Width: | Height: | Size: 565 KiB After Width: | Height: | Size: 596 KiB |
|
Before Width: | Height: | Size: 675 KiB After Width: | Height: | Size: 680 KiB |
@@ -0,0 +1,114 @@
|
||||
_version: 2
|
||||
|
||||
app.database_unavailable:
|
||||
zh-CN: 无法打开本地数据库,本次运行的改动不会被保存:%{error}
|
||||
en: "Couldn’t open the local database, so changes last only until ShellRS quits: %{error}"
|
||||
|
||||
# 设置 › 键盘快捷键: each shortcut by its id, in the order the page lists them.
|
||||
app.shortcut.new_host:
|
||||
zh-CN: 新建主机
|
||||
en: New host
|
||||
app.shortcut.new_group:
|
||||
zh-CN: 新建分组
|
||||
en: New group
|
||||
app.shortcut.open_settings:
|
||||
zh-CN: 打开设置
|
||||
en: Open settings
|
||||
app.shortcut.toggle_sidebar:
|
||||
zh-CN: 显示或隐藏侧栏
|
||||
en: Show or hide sidebar
|
||||
app.shortcut.toggle_tool_sidebar:
|
||||
zh-CN: 显示或隐藏右侧栏
|
||||
en: Show or hide right sidebar
|
||||
app.shortcut.focus_search:
|
||||
zh-CN: 聚焦搜索
|
||||
en: Focus search
|
||||
app.shortcut.zoom_in:
|
||||
zh-CN: 放大界面
|
||||
en: Zoom in
|
||||
app.shortcut.zoom_out:
|
||||
zh-CN: 缩小界面
|
||||
en: Zoom out
|
||||
app.shortcut.zoom_reset:
|
||||
zh-CN: 界面实际大小
|
||||
en: Actual size
|
||||
app.shortcut.quit:
|
||||
zh-CN: 退出
|
||||
en: Quit
|
||||
app.shortcut.new_local_terminal:
|
||||
zh-CN: 新建本地终端
|
||||
en: New local terminal
|
||||
app.shortcut.close_tab:
|
||||
zh-CN: 关闭标签
|
||||
en: Close tab
|
||||
app.shortcut.next_tab:
|
||||
zh-CN: 下一个标签
|
||||
en: Next tab
|
||||
app.shortcut.previous_tab:
|
||||
zh-CN: 上一个标签
|
||||
en: Previous tab
|
||||
app.shortcut.terminal_copy:
|
||||
zh-CN: 复制
|
||||
en: Copy
|
||||
app.shortcut.terminal_paste:
|
||||
zh-CN: 粘贴
|
||||
en: Paste
|
||||
app.shortcut.terminal_find:
|
||||
zh-CN: 查找
|
||||
en: Find
|
||||
app.shortcut.terminal_find_next:
|
||||
zh-CN: 查找下一个
|
||||
en: Find next
|
||||
app.shortcut.terminal_find_previous:
|
||||
zh-CN: 查找上一个
|
||||
en: Find previous
|
||||
app.shortcut.terminal_clear:
|
||||
zh-CN: 清屏
|
||||
en: Clear screen
|
||||
app.shortcut.terminal_zoom_in:
|
||||
zh-CN: 放大字号
|
||||
en: Bigger text
|
||||
app.shortcut.terminal_zoom_out:
|
||||
zh-CN: 缩小字号
|
||||
en: Smaller text
|
||||
app.shortcut.terminal_zoom_reset:
|
||||
zh-CN: 默认字号
|
||||
en: Default text size
|
||||
app.shortcut.editor_save:
|
||||
zh-CN: 保存
|
||||
en: Save
|
||||
app.shortcut.switch_to_tab:
|
||||
zh-CN: 切换到标签 1…9
|
||||
en: Switch to tab 1…9
|
||||
|
||||
app.shortcut_group.general:
|
||||
zh-CN: 通用
|
||||
en: General
|
||||
app.shortcut_group.tabs:
|
||||
zh-CN: 标签
|
||||
en: Tabs
|
||||
app.shortcut_group.terminal:
|
||||
zh-CN: 终端
|
||||
en: Terminal
|
||||
app.shortcut_group.editor:
|
||||
zh-CN: 编辑器
|
||||
en: Editor
|
||||
|
||||
app.shortcut_refusal.needs_modifier_mac:
|
||||
zh-CN: 快捷键要带 ⌘、⌥ 或 ⌃,或者使用 F1–F12。
|
||||
en: A shortcut needs ⌘, ⌥ or ⌃, or one of F1–F12.
|
||||
app.shortcut_refusal.needs_modifier:
|
||||
zh-CN: 快捷键要带 Ctrl 或 Alt,或者使用 F1–F12。
|
||||
en: A shortcut needs Ctrl or Alt, or one of F1–F12.
|
||||
app.shortcut_refusal.needs_digit_mac:
|
||||
zh-CN: 这一项要按带修饰键的数字 1,例如 ⌘1。
|
||||
en: Press the digit 1 with a modifier, such as ⌘1.
|
||||
app.shortcut_refusal.needs_digit:
|
||||
zh-CN: 这一项要按带修饰键的数字 1,例如 Alt+1。
|
||||
en: Press the digit 1 with a modifier, such as Alt+1.
|
||||
app.shortcut_refusal.used_by:
|
||||
zh-CN: "%{keys} 已用于「%{label}」。"
|
||||
en: "%{keys} is already used by “%{label}”."
|
||||
app.shortcut_refusal.reserved:
|
||||
zh-CN: "%{keys} 已被输入框等内置功能使用。"
|
||||
en: "%{keys} is used by text fields and other built-in features."
|
||||
@@ -0,0 +1,432 @@
|
||||
_version: 2
|
||||
|
||||
# 外部 CLI: what the `shellrs` command prints, and the messages the app
|
||||
# sends back to it. CLI output uses plain ASCII quotes and apostrophes, so
|
||||
# it copies into a shell unchanged; the texts shown in the app's window
|
||||
# (the settings page, notifications) use “ ” and ’ like the rest of the UI.
|
||||
|
||||
# The command could not reach the app.
|
||||
cli.client.not_running:
|
||||
zh-CN: ShellRS 未运行:请先打开 ShellRS,并在 设置 → 外部 CLI 中打开「启用外部 CLI」
|
||||
en: ShellRS isn't running. Open ShellRS and turn on Enable external CLI in Settings → External CLI.
|
||||
cli.client.broken:
|
||||
zh-CN: 与 ShellRS 的连接意外中断:%{error}
|
||||
en: "The connection to ShellRS broke off: %{error}"
|
||||
cli.client.no_permission:
|
||||
zh-CN: 没有权限连接 ShellRS:Agent 可能运行在沙箱中,需要允许它访问本机的进程间通信
|
||||
en: No permission to connect to ShellRS. The agent may be running in a sandbox; allow it local interprocess communication.
|
||||
cli.client.foreign_pipe:
|
||||
zh-CN: 外部 CLI 的管道不属于当前用户,已拒绝连接
|
||||
en: The external CLI's pipe belongs to another user, so the connection was refused.
|
||||
cli.client.older_app:
|
||||
zh-CN: 正在运行的 ShellRS 不认识这个命令:请把 ShellRS 升级到最新版本,然后重新启动它
|
||||
en: The running ShellRS doesn't know this command. Update ShellRS to the latest version, then restart it.
|
||||
# Also the app's notification when the server cannot start.
|
||||
cli.unsupported:
|
||||
zh-CN: 此系统暂不支持外部 CLI
|
||||
en: The external CLI is not supported on this system.
|
||||
|
||||
# Reading the command line and stdin.
|
||||
cli.exec.empty:
|
||||
zh-CN: 命令不能为空
|
||||
en: The command is empty.
|
||||
cli.exec.bad_json:
|
||||
zh-CN: 标准输入不是 %{form} 形式的 JSON:%{error}
|
||||
en: "The input on stdin isn't JSON of the form %{form}: %{error}"
|
||||
cli.json.from_terminal:
|
||||
zh-CN: 请从标准输入传入 JSON,例如 %{example}
|
||||
en: "Pass the JSON on stdin, for example: %{example}"
|
||||
cli.json.not_json:
|
||||
zh-CN: 标准输入不是 JSON:%{error}
|
||||
en: "The input on stdin isn't JSON: %{error}"
|
||||
cli.json.invalid:
|
||||
zh-CN: JSON 有误:%{error}
|
||||
en: "The JSON is invalid: %{error}"
|
||||
cli.stdin.unreadable:
|
||||
zh-CN: 无法读取标准输入:%{error}
|
||||
en: "Couldn't read stdin: %{error}"
|
||||
cli.path.unresolvable:
|
||||
zh-CN: 无法解析路径 %{path}:%{error}
|
||||
en: "Couldn't resolve the path %{path}: %{error}"
|
||||
|
||||
# `hosts list` and `credentials list`: the column headers, upper case in
|
||||
# English as command line tables have them.
|
||||
cli.column.id:
|
||||
zh-CN: ID
|
||||
en: ID
|
||||
cli.column.name:
|
||||
zh-CN: 名称
|
||||
en: NAME
|
||||
cli.column.group:
|
||||
zh-CN: 分组
|
||||
en: GROUP
|
||||
cli.column.address:
|
||||
zh-CN: 地址
|
||||
en: ADDRESS
|
||||
cli.column.os:
|
||||
zh-CN: 系统
|
||||
en: SYSTEM
|
||||
cli.column.kind:
|
||||
zh-CN: 类型
|
||||
en: TYPE
|
||||
cli.column.user:
|
||||
zh-CN: 用户名
|
||||
en: USERNAME
|
||||
cli.column.hosts:
|
||||
zh-CN: 主机数
|
||||
en: HOSTS
|
||||
cli.hosts.none:
|
||||
zh-CN: 没有匹配的主机
|
||||
en: No matching hosts
|
||||
cli.credentials.none:
|
||||
zh-CN: 没有匹配的凭据
|
||||
en: No matching credentials
|
||||
# The group of a host connected to without saving.
|
||||
cli.group.not_saved:
|
||||
zh-CN: (未保存)
|
||||
en: (not saved)
|
||||
|
||||
# `hosts show` and `credentials show`: the labels, then the values.
|
||||
cli.field.id:
|
||||
zh-CN: ID
|
||||
en: ID
|
||||
cli.field.name:
|
||||
zh-CN: 名称
|
||||
en: Name
|
||||
cli.field.group:
|
||||
zh-CN: 分组
|
||||
en: Group
|
||||
cli.field.address:
|
||||
zh-CN: 地址
|
||||
en: Address
|
||||
cli.field.auth:
|
||||
zh-CN: 认证
|
||||
en: Authentication
|
||||
cli.field.route:
|
||||
zh-CN: 连接方式
|
||||
en: Connection
|
||||
cli.field.os:
|
||||
zh-CN: 系统
|
||||
en: System
|
||||
cli.field.notes:
|
||||
zh-CN: 备注
|
||||
en: Notes
|
||||
cli.field.kind:
|
||||
zh-CN: 类型
|
||||
en: Type
|
||||
cli.field.user:
|
||||
zh-CN: 用户名
|
||||
en: Username
|
||||
cli.field.private_key:
|
||||
zh-CN: 私钥
|
||||
en: Private key
|
||||
cli.field.passphrase:
|
||||
zh-CN: 口令
|
||||
en: Passphrase
|
||||
cli.field.hosts:
|
||||
zh-CN: 主机
|
||||
en: Hosts
|
||||
cli.show.saved:
|
||||
zh-CN: "%{label}(已保存)"
|
||||
en: "%{label} (saved)"
|
||||
cli.show.not_saved:
|
||||
zh-CN: "%{label}(未保存)"
|
||||
en: "%{label} (not saved)"
|
||||
cli.auth.password:
|
||||
zh-CN: 密码
|
||||
en: Password
|
||||
cli.auth.credential:
|
||||
zh-CN: 凭据 %{id}
|
||||
en: Credential %{id}
|
||||
cli.auth.no_password:
|
||||
zh-CN: 无密码
|
||||
en: No password
|
||||
cli.route.direct:
|
||||
zh-CN: 直接连接
|
||||
en: Direct
|
||||
cli.route.jump:
|
||||
zh-CN: SSH 跳板 %{hosts}
|
||||
en: Jump hosts %{hosts}
|
||||
cli.route.deleted_host:
|
||||
zh-CN: (已删除的主机)
|
||||
en: (deleted host)
|
||||
cli.route.http:
|
||||
zh-CN: HTTP 代理 %{address}
|
||||
en: HTTP proxy %{address}
|
||||
cli.route.socks5:
|
||||
zh-CN: SOCKS5 代理 %{address}
|
||||
en: SOCKS5 proxy %{address}
|
||||
cli.route.password_saved:
|
||||
zh-CN: "%{proxy}(密码已保存)"
|
||||
en: "%{proxy} (password saved)"
|
||||
cli.route.password_not_saved:
|
||||
zh-CN: "%{proxy}(密码未保存)"
|
||||
en: "%{proxy} (password not saved)"
|
||||
cli.kind.password:
|
||||
zh-CN: 密码
|
||||
en: Password
|
||||
cli.kind.key:
|
||||
zh-CN: 密钥
|
||||
en: Key
|
||||
cli.kind.agent:
|
||||
zh-CN: SSH Agent
|
||||
en: SSH Agent
|
||||
cli.credential.kept_key:
|
||||
zh-CN: 由 ShellRS 保存
|
||||
en: Kept by ShellRS
|
||||
cli.credential.passphrase_saved:
|
||||
zh-CN: 已保存
|
||||
en: Saved
|
||||
cli.credential.passphrase_not_saved:
|
||||
zh-CN: 未保存
|
||||
en: Not saved
|
||||
|
||||
# What `create`, `update` and `delete` did.
|
||||
cli.host.created:
|
||||
zh-CN: 已创建主机「%{name}」,ID %{id}
|
||||
en: 'Created host "%{name}", ID %{id}'
|
||||
cli.host.saved:
|
||||
zh-CN: 已保存主机「%{name}」(ID %{id})
|
||||
en: 'Saved host "%{name}" (ID %{id})'
|
||||
cli.host.deleted:
|
||||
zh-CN: 已删除主机「%{name}」
|
||||
en: 'Deleted host "%{name}"'
|
||||
cli.host.deleted_with_forwards.one:
|
||||
en: 'Deleted host "%{name}" and its port forwarding rule'
|
||||
cli.host.deleted_with_forwards.other:
|
||||
zh-CN: 已删除主机「%{name}」,连同它的 %{count} 条端口转发规则
|
||||
en: 'Deleted host "%{name}" and its %{count} port forwarding rules'
|
||||
cli.host.deleted_jump_users.one:
|
||||
en: "%{deleted}; 1 host jumped through it and now has a deleted jump host"
|
||||
cli.host.deleted_jump_users.other:
|
||||
zh-CN: "%{deleted};%{count} 台主机经它跳转,现在那一跳是「已删除的主机」"
|
||||
en: "%{deleted}; %{count} hosts jumped through it and now have a deleted jump host"
|
||||
cli.credential.created:
|
||||
zh-CN: 已创建凭据「%{name}」,ID %{id}
|
||||
en: 'Created credential "%{name}", ID %{id}'
|
||||
cli.credential.saved:
|
||||
zh-CN: 已保存凭据「%{name}」(ID %{id})
|
||||
en: 'Saved credential "%{name}" (ID %{id})'
|
||||
cli.credential.deleted:
|
||||
zh-CN: 已删除凭据「%{name}」
|
||||
en: 'Deleted credential "%{name}"'
|
||||
cli.credential.deleted_released.one:
|
||||
en: "%{deleted}; 1 host now logs in on its own: %{hosts}"
|
||||
cli.credential.deleted_released.other:
|
||||
zh-CN: "%{deleted};%{count} 台主机改为自己登录:%{hosts}"
|
||||
en: "%{deleted}; %{count} hosts now log in on their own: %{hosts}"
|
||||
|
||||
# `upload`, `download` and `sync`: the progress line and the summary.
|
||||
cli.progress:
|
||||
zh-CN: "%{files}/%{total_files} 个文件,%{bytes} / %{total_bytes}"
|
||||
en: "%{files}/%{total_files} files, %{bytes} / %{total_bytes}"
|
||||
cli.summary.transferred.one:
|
||||
en: Transferred 1 file (%{size})
|
||||
cli.summary.transferred.other:
|
||||
zh-CN: 已传输 %{count} 个文件,共 %{size}
|
||||
en: Transferred %{count} files (%{size})
|
||||
cli.summary.unchanged:
|
||||
zh-CN: "%{summary},未变 %{count} 个"
|
||||
en: "%{summary}, %{count} unchanged"
|
||||
cli.summary.skipped:
|
||||
zh-CN: "%{summary},跳过 %{count} 个"
|
||||
en: "%{summary}, %{count} skipped"
|
||||
cli.summary.deleted:
|
||||
zh-CN: "%{summary},删除 %{count} 个"
|
||||
en: "%{summary}, %{count} deleted"
|
||||
cli.summary.failed:
|
||||
zh-CN: "%{summary},失败 %{count} 个"
|
||||
en: "%{summary}, %{count} failed"
|
||||
|
||||
# What the app answers when it will not take the request.
|
||||
cli.server.version_mismatch:
|
||||
zh-CN: shellrs 命令与正在运行的 ShellRS 版本不同:请重新启动 ShellRS,或在 设置 → 外部 CLI 中更新 CLI
|
||||
en: The shellrs command and the running ShellRS are different versions. Restart ShellRS, or update the CLI in Settings → External CLI.
|
||||
cli.server.not_enabled:
|
||||
zh-CN: ShellRS 未启用外部 CLI:请在 ShellRS 的 设置 → 外部 CLI 中打开「启用外部 CLI」
|
||||
en: The external CLI is turned off. Turn on Enable external CLI in ShellRS under Settings → External CLI.
|
||||
cli.server.closed_during_change:
|
||||
zh-CN: ShellRS 在处理改动时关闭了:请重新打开 ShellRS,用 shellrs hosts list 查看改动是否已完成
|
||||
en: ShellRS closed while making the change. Open ShellRS again and check with shellrs hosts list whether it was made.
|
||||
cli.server.change_timed_out:
|
||||
zh-CN: ShellRS 没有及时处理这项改动,改动没有做:请稍后再试
|
||||
en: ShellRS didn't get to the change in time, so it wasn't made. Try again later.
|
||||
cli.server.not_absolute:
|
||||
zh-CN: 本地路径必须是绝对路径:%{path}
|
||||
en: "The local path must be absolute: %{path}"
|
||||
# The app's notification when a second ShellRS starts.
|
||||
cli.server.another_app:
|
||||
zh-CN: 另一个 ShellRS 已在提供外部 CLI
|
||||
en: Another ShellRS is already serving the external CLI.
|
||||
cli.error.host_not_found:
|
||||
zh-CN: 没有 ID 为 %{id} 的主机:请用 shellrs hosts list 查看
|
||||
en: No host has the ID %{id}. List the hosts with shellrs hosts list.
|
||||
cli.error.credential_not_found:
|
||||
zh-CN: 没有 ID 为 %{id} 的凭据:请用 shellrs credentials list 查看
|
||||
en: No credential has the ID %{id}. List the credentials with shellrs credentials list.
|
||||
|
||||
# Checking what `hosts create` / `update` ask for.
|
||||
cli.host.temporary:
|
||||
zh-CN: 「%{name}」是没有保存的连接(临时连接或外部连接),不能修改或删除:关闭它的标签即可
|
||||
en: '"%{name}" isn''t saved (it came from Quick connect or an external connection), so it can''t be changed or deleted. Closing its tab is enough.'
|
||||
cli.host.credential_with_own_auth:
|
||||
zh-CN: auth 为 password 或 no_password 时不能同时指定 credential
|
||||
en: With auth password or no_password, credential can't be given.
|
||||
cli.host.no_credential:
|
||||
zh-CN: 请选择凭据
|
||||
en: Choose a credential.
|
||||
cli.host.credential_user:
|
||||
zh-CN: 使用凭据的主机以凭据的用户名 %{user} 登录:去掉 user,或改凭据的用户名
|
||||
en: A host using a credential logs in as the credential's user, %{user}. Leave out user, or change the credential's username.
|
||||
cli.host.password_without_auth:
|
||||
zh-CN: 只有 auth 为 password 的主机才保存自己的密码
|
||||
en: Only a host with auth password keeps a password of its own.
|
||||
cli.host.proxy_password_without_user:
|
||||
zh-CN: 填写代理密码时请同时填写用户名
|
||||
en: Enter a username along with the proxy password.
|
||||
cli.host.jump_temporary:
|
||||
zh-CN: 跳板只能是保存的主机:%{id} 是没有保存的连接
|
||||
en: "Jump hosts must be saved hosts: %{id} isn't saved."
|
||||
cli.host.jump_self:
|
||||
zh-CN: 跳板主机不能是这台主机自己
|
||||
en: A host can't be its own jump host.
|
||||
cli.host.jump_repeated:
|
||||
zh-CN: 跳板主机 %{id} 重复了
|
||||
en: Jump host %{id} is listed twice.
|
||||
cli.group.empty_level:
|
||||
zh-CN: 分组路径「%{path}」里有空的一级
|
||||
en: 'The group path "%{path}" has an empty level.'
|
||||
cli.group.ambiguous:
|
||||
zh-CN: 有 %{count} 个分组的路径都是「%{path}」:请先在 ShellRS 里给它们改名
|
||||
en: '%{count} groups have the path "%{path}". Rename them in ShellRS first.'
|
||||
|
||||
# Checking what `credentials create` / `update` ask for.
|
||||
cli.credential.key_without_kind:
|
||||
zh-CN: 只有密钥凭据(kind 为 key)才有私钥
|
||||
en: Only a key credential (kind key) has a private key.
|
||||
cli.credential.password_without_kind:
|
||||
zh-CN: 只有密码凭据(kind 为 password)才保存密码
|
||||
en: Only a password credential (kind password) keeps a password.
|
||||
cli.credential.passphrase_without_kind:
|
||||
zh-CN: 只有密钥凭据(kind 为 key)才有口令
|
||||
en: Only a key credential (kind key) has a passphrase.
|
||||
cli.credential.two_keys:
|
||||
zh-CN: key_path 和 private_key 只能给一个
|
||||
en: Give key_path or private_key, not both.
|
||||
cli.credential.key_not_absolute:
|
||||
zh-CN: 私钥文件必须是绝对路径:%{path}
|
||||
en: "The private key file must be an absolute path: %{path}"
|
||||
cli.credential.key_missing:
|
||||
zh-CN: 私钥文件不存在:%{path}
|
||||
en: "The private key file doesn't exist: %{path}"
|
||||
cli.credential.no_key_dir:
|
||||
zh-CN: 没有可以保存私钥的目录
|
||||
en: There's no folder to keep the private key in.
|
||||
|
||||
# Making the change in the app.
|
||||
cli.change.host_gone:
|
||||
zh-CN: 主机保存后不见了
|
||||
en: The host was gone after saving.
|
||||
cli.change.host_missing:
|
||||
zh-CN: 主机已不存在
|
||||
en: The host no longer exists.
|
||||
cli.change.host_in_use:
|
||||
zh-CN: 主机「%{name}」有打开的标签:先关闭它们,或加 --force 连同标签一起关闭
|
||||
en: 'Host "%{name}" has tabs open. Close them first, or add --force to close them along with it.'
|
||||
cli.change.credential_gone:
|
||||
zh-CN: 凭据保存后不见了
|
||||
en: The credential was gone after saving.
|
||||
cli.change.key_not_saved:
|
||||
zh-CN: 私钥未能保存:%{error}
|
||||
en: "Couldn't save the private key: %{error}"
|
||||
# One failure after another.
|
||||
cli.change.failures:
|
||||
zh-CN: "%{before};%{next}"
|
||||
en: "%{before}; %{next}"
|
||||
|
||||
# Running commands and moving files.
|
||||
cli.exec.try_terminal:
|
||||
zh-CN: "%{message}(堡垒机可能只允许登录一次:改用 shellrs exec --terminal,在 ShellRS 已打开的终端里执行)"
|
||||
en: "%{message} (The bastion host may allow one login only: use shellrs exec --terminal to run the command in the terminal open in ShellRS.)"
|
||||
|
||||
# `exec --terminal`: why the command could not be typed into the terminal,
|
||||
# or did not finish there.
|
||||
cli.terminal.timed_out:
|
||||
zh-CN: ShellRS 没有及时处理这条命令,命令没有执行:请稍后再试
|
||||
en: ShellRS didn't get to the command in time, so it didn't run. Try again later.
|
||||
cli.terminal.no_terminal:
|
||||
zh-CN: 这台主机在 ShellRS 里没有打开的终端:请先在 ShellRS 里打开它的终端
|
||||
en: This host has no terminal open in ShellRS. Open its terminal in ShellRS first.
|
||||
cli.terminal.not_connected:
|
||||
zh-CN: 这台主机在 ShellRS 里的终端没有连接:请先在 ShellRS 里重新连接
|
||||
en: This host's terminal in ShellRS isn't connected. Reconnect it in ShellRS first.
|
||||
cli.terminal.unknown_shell:
|
||||
zh-CN: 认不出终端里的 shell:只支持 Linux 上的 bash、dash 和 busybox ash
|
||||
en: Couldn't tell which shell the terminal runs. Only bash, dash and busybox ash on Linux are supported.
|
||||
cli.terminal.unsupported_shell:
|
||||
zh-CN: 终端里的 shell 是 %{shell}:只支持 bash、dash 和 busybox ash
|
||||
en: The terminal runs %{shell}. Only bash, dash and busybox ash are supported.
|
||||
cli.terminal.too_long:
|
||||
zh-CN: 命令太长:这个终端里,编码后的命令行最多 %{limit} 字节。请拆成几条
|
||||
en: The command is too long. In this terminal the encoded command line takes %{limit} bytes at most; split it up.
|
||||
cli.terminal.full_screen:
|
||||
zh-CN: 终端里正在运行全屏程序(如 vim、less、top、tmux):请先在 ShellRS 里退出它
|
||||
en: A full-screen program (such as vim, less, top or tmux) has the terminal. Quit it in ShellRS first.
|
||||
cli.terminal.another_run:
|
||||
zh-CN: 这个终端里上一条 shellrs exec --terminal 命令还在执行
|
||||
en: The previous shellrs exec --terminal command is still running in this terminal.
|
||||
cli.terminal.no_answer:
|
||||
zh-CN: 终端里的 shell 没有回应:前台程序可能不响应 Ctrl-C(如 python、mysql),或者终端里不是 Linux 上的 bash、dash 或 busybox ash
|
||||
en: The shell in the terminal didn't answer. The program in the foreground may ignore Ctrl-C (such as python or mysql), or the terminal doesn't run bash, dash or busybox ash on Linux.
|
||||
cli.terminal.not_started:
|
||||
zh-CN: shell 没有开始执行这条命令,可能被堡垒机的命令过滤或命令复核拦下了;已发送 Ctrl-C
|
||||
en: The shell didn't start the command; the bastion host's command filter or review may have held it back. Ctrl-C was sent.
|
||||
cli.terminal.disconnected:
|
||||
zh-CN: 命令执行过程中终端断开了,命令可能已执行了一部分
|
||||
en: The terminal disconnected while the command ran; it may have partly run.
|
||||
|
||||
cli.exec.signal:
|
||||
zh-CN: 远程命令被信号 %{signal} 终止
|
||||
en: The remote command was killed by signal %{signal}.
|
||||
cli.transfer.sftp_ended:
|
||||
zh-CN: SFTP 连接意外结束
|
||||
en: The SFTP connection ended unexpectedly.
|
||||
cli.transfer.no_local_folder:
|
||||
zh-CN: 本地目录不存在:%{path}
|
||||
en: "The local folder doesn't exist: %{path}"
|
||||
cli.transfer.host_key_unknown:
|
||||
zh-CN: 尚未信任这台主机的密钥:请先在 ShellRS 中连接一次这台主机
|
||||
en: This host's key isn't trusted yet. Connect to the host once in ShellRS first.
|
||||
cli.transfer.host_key_changed:
|
||||
zh-CN: 主机密钥与已保存的不一致,已拒绝连接。请先核实服务器身份
|
||||
en: The host key doesn't match the saved one, so the connection was refused. Check the server's identity first.
|
||||
cli.transfer.missing_credential:
|
||||
zh-CN: 这台主机没有保存可用的密码或口令:请先在 ShellRS 中连接一次这台主机并保存密码
|
||||
en: No usable password or passphrase is saved for this host. Connect to it once in ShellRS and save the password.
|
||||
cli.transfer.unfinished:
|
||||
zh-CN: 传输未完成
|
||||
en: The transfer didn't finish.
|
||||
cli.transfer.empty_remote_path:
|
||||
zh-CN: 远程路径不能为空
|
||||
en: The remote path is empty.
|
||||
|
||||
# 设置 › 外部 CLI: the agents the skill installs for, and installing the
|
||||
# command.
|
||||
cli.agent.generic:
|
||||
zh-CN: 通用 Agent
|
||||
en: Any agent
|
||||
cli.agent.generic_description:
|
||||
zh-CN: 安装到 Agent Skills 标准目录,供支持该目录的 Agent 使用。
|
||||
en: Installs to the standard Agent Skills folder, for agents that read it.
|
||||
cli.install.unsupported:
|
||||
zh-CN: 此系统暂不支持安装 shellrs 命令
|
||||
en: Installing the shellrs command isn’t supported on this system.
|
||||
cli.install.occupied:
|
||||
zh-CN: "%{path} 已被其他程序占用,ShellRS 不会改动它"
|
||||
en: "%{path} belongs to another program, so ShellRS leaves it alone"
|
||||
cli.install.cancelled:
|
||||
zh-CN: 已取消
|
||||
en: Cancelled
|
||||
cli.install.missing:
|
||||
zh-CN: 找不到 %{path}
|
||||
en: Can’t find %{path}
|
||||
@@ -0,0 +1,28 @@
|
||||
_version: 2
|
||||
|
||||
# 多语言: one key per text, its Chinese and English side by side. Keys
|
||||
# name what the text is for; see `src/i18n.rs`.
|
||||
|
||||
# `join_list`: 「A、B 和 C」, "A, B and C".
|
||||
common.list.separator:
|
||||
zh-CN: 、
|
||||
en: ", "
|
||||
common.list.and:
|
||||
zh-CN: 和
|
||||
en: " and "
|
||||
|
||||
common.cancel:
|
||||
zh-CN: 取消
|
||||
en: Cancel
|
||||
common.close:
|
||||
zh-CN: 关闭
|
||||
en: Close
|
||||
common.save:
|
||||
zh-CN: 保存
|
||||
en: Save
|
||||
common.delete:
|
||||
zh-CN: 删除
|
||||
en: Delete
|
||||
common.delete_title:
|
||||
zh-CN: 删除“%{name}”?
|
||||
en: Delete “%{name}”?
|
||||
@@ -0,0 +1,21 @@
|
||||
_version: 2
|
||||
|
||||
# 连接: the questions a connection asks before it can go on.
|
||||
|
||||
connection.unknown_host.description:
|
||||
zh-CN: "主机:%{host}:%{port}\n算法:%{algorithm}\nSHA-256 指纹:%{fingerprint}\n\n请先确认该指纹来自可信渠道。"
|
||||
en: "Host: %{host}:%{port}\nAlgorithm: %{algorithm}\nSHA-256 fingerprint: %{fingerprint}\n\nMake sure the fingerprint matches one from a source you trust."
|
||||
connection.unknown_host.jump_host_description:
|
||||
zh-CN: "跳板主机:%{jump_host}\n主机:%{host}:%{port}\n算法:%{algorithm}\nSHA-256 指纹:%{fingerprint}\n\n请先确认该指纹来自可信渠道。"
|
||||
en: "Jump host: %{jump_host}\nHost: %{host}:%{port}\nAlgorithm: %{algorithm}\nSHA-256 fingerprint: %{fingerprint}\n\nMake sure the fingerprint matches one from a source you trust."
|
||||
|
||||
connection.jump_host.instructions:
|
||||
zh-CN: 跳板主机「%{name}」
|
||||
en: Jump host “%{name}”
|
||||
connection.jump_host.with_instructions:
|
||||
zh-CN: 跳板主机「%{name}」:%{instructions}
|
||||
en: "Jump host “%{name}”: %{instructions}"
|
||||
|
||||
connection.latency.timed_out:
|
||||
zh-CN: 超时
|
||||
en: Timed out
|
||||
@@ -0,0 +1,156 @@
|
||||
_version: 2
|
||||
|
||||
# 凭据: the credential list and the dialog that edits a credential.
|
||||
|
||||
credential.key_source.file:
|
||||
zh-CN: 本机文件
|
||||
en: Local file
|
||||
credential.key_source.paste:
|
||||
zh-CN: 粘贴
|
||||
en: Paste
|
||||
credential.key_source.generate:
|
||||
zh-CN: 生成新密钥
|
||||
en: Generate
|
||||
|
||||
credential.dialog.title_new:
|
||||
zh-CN: 新建凭据
|
||||
en: New credential
|
||||
credential.dialog.title_edit:
|
||||
zh-CN: 编辑凭据
|
||||
en: Edit credential
|
||||
credential.dialog.create:
|
||||
zh-CN: 创建
|
||||
en: Create
|
||||
credential.dialog.kind:
|
||||
zh-CN: 类型
|
||||
en: Type
|
||||
credential.dialog.name:
|
||||
zh-CN: 名称
|
||||
en: Name
|
||||
credential.dialog.name_placeholder:
|
||||
zh-CN: 例如 生产环境 root
|
||||
en: e.g. Production root
|
||||
credential.dialog.user:
|
||||
zh-CN: 用户名
|
||||
en: Username
|
||||
credential.dialog.password:
|
||||
zh-CN: 密码
|
||||
en: Password
|
||||
credential.dialog.private_key:
|
||||
zh-CN: 私钥
|
||||
en: Private key
|
||||
credential.dialog.key_text_placeholder:
|
||||
zh-CN: 粘贴私钥,以 -----BEGIN 开头
|
||||
en: Paste the private key, starting with -----BEGIN
|
||||
credential.dialog.no_key_dir:
|
||||
zh-CN: 本地数据库没有打开,这次运行无法保存私钥
|
||||
en: The local database isn’t open, so ShellRS can’t keep private keys until it restarts
|
||||
credential.dialog.kept_key:
|
||||
zh-CN: 这把私钥由 ShellRS 保存,删除凭据时会一起删除。
|
||||
en: ShellRS keeps this private key and deletes it along with the credential.
|
||||
credential.dialog.algorithm:
|
||||
zh-CN: 算法
|
||||
en: Algorithm
|
||||
credential.dialog.rsa_note:
|
||||
zh-CN: 只在服务器不支持 Ed25519 时选它(OpenSSH 6.5 以前)。
|
||||
en: Choose it only for servers without Ed25519 support (OpenSSH before 6.5).
|
||||
credential.dialog.public_key:
|
||||
zh-CN: 公钥
|
||||
en: Public key
|
||||
credential.dialog.public_key_note:
|
||||
zh-CN: 把公钥加到服务器的 ~/.ssh/authorized_keys,就能用这把密钥登录。
|
||||
en: Add the public key to ~/.ssh/authorized_keys on a server to log in to it with this key.
|
||||
credential.dialog.copy_public_key:
|
||||
zh-CN: 复制公钥
|
||||
en: Copy public key
|
||||
credential.dialog.generating:
|
||||
zh-CN: 正在生成密钥…
|
||||
en: Generating the key…
|
||||
credential.dialog.passphrase:
|
||||
zh-CN: 私钥口令
|
||||
en: Passphrase
|
||||
credential.dialog.ask_for_passphrase:
|
||||
zh-CN: 留空则每次连接都询问
|
||||
en: Leave empty to be asked at each connection
|
||||
credential.dialog.no_passphrase:
|
||||
zh-CN: 留空则不加密
|
||||
en: Leave empty to leave the key unencrypted
|
||||
credential.dialog.agent_note:
|
||||
zh-CN: 登录时使用 SSH Agent(SSH_AUTH_SOCK 所指)中的密钥。
|
||||
en: Logs in with the keys in the SSH agent that SSH_AUTH_SOCK points to.
|
||||
credential.dialog.agent_note_windows:
|
||||
zh-CN: 登录时使用 Windows 的 OpenSSH Authentication Agent 服务中的密钥。
|
||||
en: Logs in with the keys in the Windows OpenSSH Authentication Agent service.
|
||||
credential.dialog.keychain_note:
|
||||
zh-CN: 密码和口令保存在系统钥匙串,不会写入 ShellRS 的数据库。
|
||||
en: Passwords and passphrases are kept in the system keychain, never in ShellRS’s database.
|
||||
credential.dialog.no_keychain_note:
|
||||
zh-CN: 系统钥匙串不可用,这台机器上无法保存密码,每次连接都会询问。
|
||||
en: The system keychain isn’t available, so passwords can’t be saved on this computer and are asked for at each connection.
|
||||
credential.dialog.drops_kept_key:
|
||||
zh-CN: 保存后,ShellRS 保存的原私钥会被删除。
|
||||
en: Saving deletes the private key ShellRS kept for this credential.
|
||||
credential.dialog.usage.one:
|
||||
en: "%{count} host uses this credential."
|
||||
credential.dialog.usage.other:
|
||||
zh-CN: 有 %{count} 台主机使用此凭据。
|
||||
en: "%{count} hosts use this credential."
|
||||
credential.dialog.usage_new_key:
|
||||
zh-CN: 换成新私钥后,要把它的公钥加到这些主机上,它们才能再登录。
|
||||
en: After switching to a new private key, add its public key to these hosts so they can log in again.
|
||||
credential.dialog.usage_reconnects:
|
||||
zh-CN: 修改用户名、类型或私钥文件后,其中已连接的主机会重新连接。
|
||||
en: Changing the username, type or private key file reconnects the ones that are connected.
|
||||
credential.dialog.encrypting:
|
||||
zh-CN: 正在用口令加密私钥…
|
||||
en: Encrypting the private key with the passphrase…
|
||||
|
||||
credential.dialog.generate_failed:
|
||||
zh-CN: 无法生成密钥:%{error}
|
||||
en: "Couldn’t generate the key: %{error}"
|
||||
credential.dialog.still_generating:
|
||||
zh-CN: 密钥还在生成,请稍候
|
||||
en: The key is still being generated. Try again in a moment.
|
||||
credential.dialog.encode_failed:
|
||||
zh-CN: 无法保存私钥:%{error}
|
||||
en: "Couldn’t save the private key: %{error}"
|
||||
credential.dialog.encrypt_failed:
|
||||
zh-CN: 无法加密私钥:%{error}
|
||||
en: "Couldn’t encrypt the private key: %{error}"
|
||||
credential.dialog.key_not_saved:
|
||||
zh-CN: 私钥未能保存:%{error}
|
||||
en: "The private key wasn’t saved: %{error}"
|
||||
|
||||
credential.panel.title:
|
||||
zh-CN: 凭据
|
||||
en: Credentials
|
||||
credential.panel.search:
|
||||
zh-CN: 搜索凭据
|
||||
en: Search credentials
|
||||
credential.panel.detail.one:
|
||||
en: "%{summary} · %{count} host"
|
||||
credential.panel.detail.other:
|
||||
zh-CN: "%{summary} · %{count} 台主机"
|
||||
en: "%{summary} · %{count} hosts"
|
||||
credential.panel.no_matches:
|
||||
zh-CN: 没有匹配的凭据
|
||||
en: No matching credentials
|
||||
credential.panel.empty:
|
||||
zh-CN: 还没有凭据
|
||||
en: No credentials yet
|
||||
credential.panel.empty_hint:
|
||||
zh-CN: 保存一套用户名和密码、私钥或 SSH Agent,让多台主机共用。
|
||||
en: Save a username with a password, a private key or the SSH agent for several hosts to share.
|
||||
|
||||
credential.menu.new:
|
||||
zh-CN: 新建凭据…
|
||||
en: New credential…
|
||||
credential.menu.generate:
|
||||
zh-CN: 生成密钥…
|
||||
en: Generate key…
|
||||
credential.menu.edit:
|
||||
zh-CN: 编辑凭据…
|
||||
en: Edit credential…
|
||||
credential.menu.copy_public_key:
|
||||
zh-CN: 复制公钥
|
||||
en: Copy public key
|
||||
@@ -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}
|
||||
@@ -0,0 +1,71 @@
|
||||
_version: 2
|
||||
|
||||
# The editor tab: its toolbar, status line text, and the questions about
|
||||
# saving, reloading and closing.
|
||||
|
||||
editor.status.cursor:
|
||||
zh-CN: 行 %{line},列 %{column}
|
||||
en: Ln %{line}, Col %{column}
|
||||
|
||||
editor.state.saving:
|
||||
zh-CN: 正在保存…
|
||||
en: Saving…
|
||||
editor.state.reloading:
|
||||
zh-CN: 正在重新加载…
|
||||
en: Reloading…
|
||||
editor.state.modified:
|
||||
zh-CN: 已修改
|
||||
en: Modified
|
||||
|
||||
editor.command.reload:
|
||||
zh-CN: 重新加载
|
||||
en: Reload
|
||||
editor.command.copy_path:
|
||||
zh-CN: 复制路径
|
||||
en: Copy path
|
||||
editor.text_label:
|
||||
zh-CN: 编辑 %{name}
|
||||
en: Edit %{name}
|
||||
|
||||
editor.reload.title:
|
||||
zh-CN: 放弃对“%{name}”的修改并重新加载?
|
||||
en: Discard changes to “%{name}” and reload?
|
||||
editor.reload.description:
|
||||
zh-CN: 编辑器里没有保存的修改会丢失。
|
||||
en: Changes not saved in the editor will be lost.
|
||||
editor.reload.failed:
|
||||
zh-CN: 无法重新加载“%{name}”
|
||||
en: Couldn’t reload “%{name}”
|
||||
|
||||
editor.changed.title:
|
||||
zh-CN: “%{name}”在打开后已被修改
|
||||
en: “%{name}” changed after it was opened
|
||||
editor.changed.remote:
|
||||
zh-CN: 服务器上的这个文件在你打开或上次保存之后被改过或删除了。覆盖会用编辑器里的内容替换它。
|
||||
en: This file on the server was changed or deleted after you opened or last saved it. Overwriting replaces it with what’s in the editor.
|
||||
editor.changed.local:
|
||||
zh-CN: 本机上的这个文件在你打开或上次保存之后被改过或删除了。覆盖会用编辑器里的内容替换它。
|
||||
en: This file on this computer was changed or deleted after you opened or last saved it. Overwriting replaces it with what’s in the editor.
|
||||
editor.changed.overwrite:
|
||||
zh-CN: 覆盖
|
||||
en: Overwrite
|
||||
|
||||
editor.save.interrupted:
|
||||
zh-CN: "%{message}。文件可能只写了一部分,请重新连接后再保存一次。"
|
||||
en: "%{message}. The file may be only partly written. Reconnect and save it again."
|
||||
editor.save.interrupted_title:
|
||||
zh-CN: 没有保存完“%{name}”
|
||||
en: Couldn’t finish saving “%{name}”
|
||||
editor.save.failed:
|
||||
zh-CN: 无法保存“%{name}”
|
||||
en: Couldn’t save “%{name}”
|
||||
|
||||
editor.close.title:
|
||||
zh-CN: 保存对“%{name}”的修改?
|
||||
en: Save changes to “%{name}”?
|
||||
editor.close.description:
|
||||
zh-CN: 不保存的话,这些修改会丢失。
|
||||
en: Your changes will be lost if you don’t save them.
|
||||
editor.close.discard:
|
||||
zh-CN: 放弃修改
|
||||
en: Discard changes
|
||||
@@ -0,0 +1,706 @@
|
||||
_version: 2
|
||||
|
||||
# The SFTP tab: the two file lists, their toolbars and menus, the dialogs
|
||||
# they open, the transfer queue and the preview.
|
||||
|
||||
explorer.new.folder_title:
|
||||
zh-CN: 新建文件夹
|
||||
en: New folder
|
||||
explorer.new.file_title:
|
||||
zh-CN: 新建文件
|
||||
en: New file
|
||||
explorer.new.folder_name:
|
||||
zh-CN: 新建文件夹
|
||||
en: New folder
|
||||
explorer.new.file_name:
|
||||
zh-CN: 新建文件.txt
|
||||
en: New file.txt
|
||||
|
||||
explorer.kind.folder:
|
||||
zh-CN: 文件夹
|
||||
en: Folder
|
||||
explorer.kind.file:
|
||||
zh-CN: 文件
|
||||
en: File
|
||||
explorer.kind.link:
|
||||
zh-CN: 链接
|
||||
en: Link
|
||||
explorer.kind.parent:
|
||||
zh-CN: 上级目录
|
||||
en: Parent folder
|
||||
explorer.kind.extension:
|
||||
zh-CN: "%{ext} 文件"
|
||||
en: "%{ext} file"
|
||||
|
||||
explorer.size_format.bytes:
|
||||
zh-CN: 字节
|
||||
en: Bytes
|
||||
explorer.size_format.kilobytes:
|
||||
zh-CN: 千字节(KB)
|
||||
en: Kilobytes (KB)
|
||||
explorer.size_format.short:
|
||||
zh-CN: 简短格式(B、KB、MB、GB)
|
||||
en: Short (B, KB, MB, GB)
|
||||
|
||||
explorer.column.name:
|
||||
zh-CN: 名称
|
||||
en: Name
|
||||
explorer.column.size:
|
||||
zh-CN: 大小
|
||||
en: Size
|
||||
explorer.column.type:
|
||||
zh-CN: 类型
|
||||
en: Type
|
||||
explorer.column.modified:
|
||||
zh-CN: 修改时间
|
||||
en: Modified
|
||||
explorer.column.rights:
|
||||
zh-CN: 权限
|
||||
en: Permissions
|
||||
explorer.column.owner:
|
||||
zh-CN: 所有者
|
||||
en: Owner
|
||||
|
||||
explorer.list.connecting:
|
||||
zh-CN: 正在连接 SFTP…
|
||||
en: Connecting to SFTP…
|
||||
explorer.list.reading:
|
||||
zh-CN: 正在读取目录…
|
||||
en: Reading folder…
|
||||
explorer.list.only_hidden:
|
||||
zh-CN: 只有隐藏文件
|
||||
en: Only hidden files
|
||||
explorer.list.empty:
|
||||
zh-CN: 空目录
|
||||
en: Empty folder
|
||||
explorer.list.not_connected:
|
||||
zh-CN: 未连接
|
||||
en: Not connected
|
||||
explorer.list.unreadable:
|
||||
zh-CN: 无法读取目录
|
||||
en: Couldn’t read the folder
|
||||
explorer.list.read_failed:
|
||||
zh-CN: 无法读取目录:%{error}
|
||||
en: "Couldn’t read the folder: %{error}"
|
||||
|
||||
explorer.drag.upload.one:
|
||||
en: Upload %{count} item
|
||||
explorer.drag.upload.other:
|
||||
zh-CN: 上传 %{count} 个项目
|
||||
en: Upload %{count} items
|
||||
explorer.drag.download.one:
|
||||
en: Download %{count} item
|
||||
explorer.drag.download.other:
|
||||
zh-CN: 下载 %{count} 个项目
|
||||
en: Download %{count} items
|
||||
|
||||
explorer.status.opening:
|
||||
zh-CN: 正在打开 %{name}…
|
||||
en: Opening %{name}…
|
||||
explorer.status.items.one:
|
||||
en: "%{count} item"
|
||||
explorer.status.items.other:
|
||||
zh-CN: "%{count} 个项目"
|
||||
en: "%{count} items"
|
||||
explorer.status.hidden.other:
|
||||
zh-CN: 隐藏 %{count} 项
|
||||
en: "%{count} hidden"
|
||||
explorer.status.selected.other:
|
||||
zh-CN: 已选择 %{count} 项
|
||||
en: "%{count} selected"
|
||||
|
||||
explorer.path.local:
|
||||
zh-CN: 本地路径
|
||||
en: Local path
|
||||
explorer.path.remote:
|
||||
zh-CN: 远程路径
|
||||
en: Remote path
|
||||
explorer.directory_select.local:
|
||||
zh-CN: 本地目录
|
||||
en: Local folder
|
||||
explorer.directory_select.remote:
|
||||
zh-CN: 远程目录
|
||||
en: Remote folder
|
||||
explorer.directory_select.current:
|
||||
zh-CN: 当前路径
|
||||
en: Current path
|
||||
explorer.directory_select.places:
|
||||
zh-CN: 位置
|
||||
en: Places
|
||||
|
||||
explorer.command.open_directory:
|
||||
zh-CN: 打开目录/书签…
|
||||
en: Open folder or bookmark…
|
||||
explorer.command.up:
|
||||
zh-CN: 上级目录
|
||||
en: Parent folder
|
||||
explorer.command.root:
|
||||
zh-CN: 根目录
|
||||
en: Root folder
|
||||
explorer.command.home:
|
||||
zh-CN: 主目录
|
||||
en: Home folder
|
||||
explorer.command.refresh:
|
||||
zh-CN: 刷新
|
||||
en: Refresh
|
||||
explorer.command.back:
|
||||
zh-CN: 后退
|
||||
en: Back
|
||||
explorer.command.forward:
|
||||
zh-CN: 前进
|
||||
en: Forward
|
||||
explorer.command.show_hidden_files:
|
||||
zh-CN: 显示隐藏文件
|
||||
en: Show hidden files
|
||||
explorer.toolbar.hide_hidden_files:
|
||||
zh-CN: 不显示隐藏文件
|
||||
en: Hide hidden files
|
||||
explorer.command.upload:
|
||||
zh-CN: 上传…
|
||||
en: Upload…
|
||||
explorer.toolbar.upload_tip:
|
||||
zh-CN: 上传所选项目
|
||||
en: Upload the selected items
|
||||
explorer.command.download:
|
||||
zh-CN: 下载…
|
||||
en: Download…
|
||||
explorer.toolbar.download_tip:
|
||||
zh-CN: 下载所选项目
|
||||
en: Download the selected items
|
||||
explorer.toolbar.choose_files:
|
||||
zh-CN: 选择文件上传…
|
||||
en: Choose files to upload…
|
||||
explorer.command.rename:
|
||||
zh-CN: 重命名…
|
||||
en: Rename…
|
||||
explorer.command.properties:
|
||||
zh-CN: 属性…
|
||||
en: Properties…
|
||||
explorer.command.new:
|
||||
zh-CN: 新建
|
||||
en: New
|
||||
|
||||
explorer.transfer.upload_title:
|
||||
zh-CN: 上传文件
|
||||
en: Upload
|
||||
explorer.transfer.download_title:
|
||||
zh-CN: 下载
|
||||
en: Download
|
||||
explorer.transfer.upload:
|
||||
zh-CN: 上传
|
||||
en: Upload
|
||||
explorer.transfer.download:
|
||||
zh-CN: 下载
|
||||
en: Download
|
||||
explorer.transfer.upload_summary:
|
||||
zh-CN: 上传 %{items}到 %{endpoint}
|
||||
en: Upload %{items} to %{endpoint}
|
||||
explorer.transfer.download_summary:
|
||||
zh-CN: 从 %{endpoint} 下载 %{items}
|
||||
en: Download %{items} from %{endpoint}
|
||||
explorer.transfer.files.one:
|
||||
en: "%{count} file"
|
||||
explorer.transfer.files.other:
|
||||
zh-CN: "%{count} 个文件"
|
||||
en: "%{count} files"
|
||||
explorer.transfer.folders.one:
|
||||
en: "%{count} folder"
|
||||
explorer.transfer.folders.other:
|
||||
zh-CN: "%{count} 个文件夹"
|
||||
en: "%{count} folders"
|
||||
explorer.transfer.items.other:
|
||||
zh-CN: "%{count} 个项目(%{folders}、%{files})"
|
||||
en: "%{count} items (%{folders}, %{files})"
|
||||
explorer.transfer.in_folder:
|
||||
zh-CN: 位于 %{folder}
|
||||
en: In %{folder}
|
||||
explorer.transfer.queued_note:
|
||||
zh-CN: 当前有传输在进行,这一批会排在传输队列里,轮到时自动开始。
|
||||
en: Another transfer is running. This one waits in Transfers and starts when its turn comes.
|
||||
explorer.transfer.target:
|
||||
zh-CN: 目标目录
|
||||
en: Destination folder
|
||||
explorer.transfer.download_to:
|
||||
zh-CN: 下载到
|
||||
en: Download to
|
||||
explorer.transfer.choose_download_folder:
|
||||
zh-CN: 选择下载到的目录
|
||||
en: Choose where to download to
|
||||
explorer.transfer.target_missing:
|
||||
zh-CN: 请输入目标目录
|
||||
en: Enter a destination folder
|
||||
explorer.transfer.local_target_relative:
|
||||
zh-CN: 请输入本机目录的完整路径
|
||||
en: Enter the full path of a folder on this computer
|
||||
|
||||
explorer.question.conflict_title:
|
||||
zh-CN: 同名文件已存在
|
||||
en: A file with this name already exists
|
||||
explorer.question.overwrite:
|
||||
zh-CN: 覆盖
|
||||
en: Overwrite
|
||||
explorer.question.apply_to_all:
|
||||
zh-CN: 应用于本批剩余冲突
|
||||
en: Do the same for the rest of this batch
|
||||
explorer.question.resume_upload_title:
|
||||
zh-CN: 发现未完成的上传
|
||||
en: Unfinished upload found
|
||||
explorer.question.resume_download_title:
|
||||
zh-CN: 发现未完成的下载
|
||||
en: Unfinished download found
|
||||
explorer.question.resume_upload:
|
||||
zh-CN: 继续上传
|
||||
en: Resume upload
|
||||
explorer.question.resume_download:
|
||||
zh-CN: 继续下载
|
||||
en: Resume download
|
||||
explorer.question.restart_upload:
|
||||
zh-CN: 重新上传
|
||||
en: Upload again
|
||||
explorer.question.restart_download:
|
||||
zh-CN: 重新下载
|
||||
en: Download again
|
||||
explorer.question.invalid_resume_title:
|
||||
zh-CN: 无法继续此文件
|
||||
en: Can’t resume this file
|
||||
explorer.question.upload_failed_title:
|
||||
zh-CN: 无法上传此项目
|
||||
en: Couldn’t upload this item
|
||||
explorer.question.download_failed_title:
|
||||
zh-CN: 无法下载此项目
|
||||
en: Couldn’t download this item
|
||||
explorer.question.retry:
|
||||
zh-CN: 重试
|
||||
en: Retry
|
||||
explorer.question.skip:
|
||||
zh-CN: 跳过
|
||||
en: Skip
|
||||
explorer.question.cancel_upload:
|
||||
zh-CN: 取消上传
|
||||
en: Cancel upload
|
||||
explorer.question.cancel_download:
|
||||
zh-CN: 取消下载
|
||||
en: Cancel download
|
||||
|
||||
explorer.close_transfer.upload_title:
|
||||
zh-CN: 停止上传并关闭?
|
||||
en: Stop the upload and close?
|
||||
explorer.close_transfer.download_title:
|
||||
zh-CN: 停止下载并关闭?
|
||||
en: Stop the download and close?
|
||||
explorer.close_transfer.upload_kept:
|
||||
zh-CN: 上传进度会保留,下次选择相同来源和目标目录时可以继续上传。
|
||||
en: "Progress is kept: choose the same source and destination folder later to resume the upload."
|
||||
explorer.close_transfer.download_kept:
|
||||
zh-CN: 下载进度会保留,下次选择相同来源和目标目录时可以继续下载。
|
||||
en: "Progress is kept: choose the same source and destination folder later to resume the download."
|
||||
explorer.close_transfer.two_sentences:
|
||||
zh-CN: "%{first}%{second}"
|
||||
en: "%{first} %{second}"
|
||||
explorer.close_transfer.stop_upload:
|
||||
zh-CN: 停止上传并关闭
|
||||
en: Stop and close
|
||||
explorer.close_transfer.stop_download:
|
||||
zh-CN: 停止下载并关闭
|
||||
en: Stop and close
|
||||
explorer.close_transfer.keep_uploading:
|
||||
zh-CN: 继续上传
|
||||
en: Keep uploading
|
||||
explorer.close_transfer.keep_downloading:
|
||||
zh-CN: 继续下载
|
||||
en: Keep downloading
|
||||
|
||||
explorer.queue.title:
|
||||
zh-CN: 传输队列
|
||||
en: Transfers
|
||||
explorer.queue.title_count:
|
||||
zh-CN: 传输队列 (%{unfinished})
|
||||
en: Transfers (%{unfinished})
|
||||
explorer.queue.resume:
|
||||
zh-CN: 继续
|
||||
en: Resume
|
||||
explorer.queue.stop:
|
||||
zh-CN: 停止
|
||||
en: Stop
|
||||
explorer.queue.remove:
|
||||
zh-CN: 移出队列
|
||||
en: Remove from queue
|
||||
explorer.queue.discard:
|
||||
zh-CN: 丢弃续传进度
|
||||
en: Discard partial transfer
|
||||
explorer.queue.clear_finished:
|
||||
zh-CN: 清除已完成
|
||||
en: Clear finished
|
||||
explorer.queue.column.operation:
|
||||
zh-CN: 操作
|
||||
en: Direction
|
||||
explorer.queue.column.source:
|
||||
zh-CN: 来源
|
||||
en: Source
|
||||
explorer.queue.column.target:
|
||||
zh-CN: 目标
|
||||
en: Destination
|
||||
explorer.queue.column.transferred:
|
||||
zh-CN: 已传输
|
||||
en: Transferred
|
||||
explorer.queue.column.time:
|
||||
zh-CN: 时间
|
||||
en: Time
|
||||
explorer.queue.column.speed:
|
||||
zh-CN: 速度
|
||||
en: Speed
|
||||
explorer.queue.column.progress:
|
||||
zh-CN: 进度
|
||||
en: Progress
|
||||
explorer.queue.hide_results:
|
||||
zh-CN: 收起逐项结果
|
||||
en: Hide item results
|
||||
explorer.queue.show_results:
|
||||
zh-CN: 展开逐项结果
|
||||
en: Show item results
|
||||
explorer.queue.not_started:
|
||||
zh-CN: 还没有开始
|
||||
en: Not started yet
|
||||
explorer.queue.nothing_uploaded:
|
||||
zh-CN: 还没有上传完的项目,每完成一项会列在这里
|
||||
en: No items uploaded yet. Each one is listed here when it’s done.
|
||||
explorer.queue.nothing_downloaded:
|
||||
zh-CN: 还没有下载完的项目,每完成一项会列在这里
|
||||
en: No items downloaded yet. Each one is listed here when it’s done.
|
||||
explorer.queue.outcome.done:
|
||||
zh-CN: 完成
|
||||
en: Done
|
||||
explorer.queue.outcome.skipped:
|
||||
zh-CN: 已跳过
|
||||
en: Skipped
|
||||
explorer.queue.outcome.failed:
|
||||
zh-CN: 失败
|
||||
en: Failed
|
||||
explorer.queue.several_places:
|
||||
zh-CN: 多个位置
|
||||
en: Several places
|
||||
explorer.queue.status.pending:
|
||||
zh-CN: 等待中
|
||||
en: Waiting
|
||||
explorer.queue.status.scanning:
|
||||
zh-CN: 正在扫描
|
||||
en: Scanning
|
||||
explorer.queue.status.waiting:
|
||||
zh-CN: 等待回答
|
||||
en: Waiting for an answer
|
||||
explorer.queue.status.reconnecting:
|
||||
zh-CN: 正在重连
|
||||
en: Reconnecting
|
||||
explorer.queue.status.stopped:
|
||||
zh-CN: 已停止
|
||||
en: Stopped
|
||||
explorer.queue.status.done:
|
||||
zh-CN: 已完成
|
||||
en: Done
|
||||
explorer.queue.status.partly_failed.one:
|
||||
en: "%{count} item failed"
|
||||
explorer.queue.status.partly_failed.other:
|
||||
zh-CN: 部分失败(%{count} 项)
|
||||
en: "%{count} items failed"
|
||||
explorer.queue.status.discarded:
|
||||
zh-CN: 已丢弃续传进度
|
||||
en: Partial transfer discarded
|
||||
explorer.queue.status.not_started:
|
||||
zh-CN: 无法开始:%{reason}
|
||||
en: "Couldn’t start: %{reason}"
|
||||
explorer.queue.status.removed:
|
||||
zh-CN: 已移出
|
||||
en: Removed
|
||||
|
||||
explorer.connection.start_failed:
|
||||
zh-CN: 无法启动 SFTP:%{error}
|
||||
en: "Couldn’t start SFTP: %{error}"
|
||||
explorer.connection.server:
|
||||
zh-CN: 服务器
|
||||
en: the server
|
||||
explorer.connection.lost:
|
||||
zh-CN: SFTP 已断开
|
||||
en: SFTP disconnected
|
||||
explorer.connection.lost_title:
|
||||
zh-CN: 与 %{name} 的 SFTP 连接已断开
|
||||
en: Lost the SFTP connection to %{name}
|
||||
explorer.connection.lost_reconnect:
|
||||
zh-CN: "%{reason}。重新连接后再操作远程文件。"
|
||||
en: "%{reason}. Reconnect to work with remote files."
|
||||
explorer.connection.lost_stopped_batch:
|
||||
zh-CN: "%{reason}。传输队列里有停止的批次,点它的「继续」会重新连接并接着传输。"
|
||||
en: "%{reason}. A stopped batch is waiting in Transfers; its Resume reconnects and carries on."
|
||||
explorer.connection.lost_reconnecting:
|
||||
zh-CN: "%{reason}。正在自动重新连接,请稍候。"
|
||||
en: "%{reason}. Reconnecting automatically."
|
||||
explorer.connection.reconnect:
|
||||
zh-CN: 重新连接
|
||||
en: Reconnect
|
||||
|
||||
explorer.ok:
|
||||
zh-CN: 知道了
|
||||
en: OK
|
||||
explorer.browse:
|
||||
zh-CN: 浏览…
|
||||
en: Browse…
|
||||
explorer.choose_upload:
|
||||
zh-CN: 选择要上传的文件或目录
|
||||
en: Choose files or folders to upload
|
||||
|
||||
explorer.tab.open_sftp_tip:
|
||||
zh-CN: 打开 SFTP 文件浏览
|
||||
en: Browse files over SFTP
|
||||
explorer.tab.rename:
|
||||
zh-CN: 重命名标签…
|
||||
en: Rename tab…
|
||||
explorer.tab.open_sftp:
|
||||
zh-CN: 打开 SFTP
|
||||
en: Open SFTP
|
||||
explorer.tab.copy_ip:
|
||||
zh-CN: 复制 IP 地址
|
||||
en: Copy IP address
|
||||
explorer.tab.copy_host_name:
|
||||
zh-CN: 复制主机名
|
||||
en: Copy hostname
|
||||
|
||||
explorer.name.empty:
|
||||
zh-CN: 名称不能为空
|
||||
en: The name can’t be empty
|
||||
explorer.name.dots:
|
||||
zh-CN: 名称不能是「.」或「..」
|
||||
en: The name can’t be “.” or “..”
|
||||
explorer.name.slash:
|
||||
zh-CN: 名称不能包含「/」
|
||||
en: The name can’t contain “/”
|
||||
explorer.name.unchanged:
|
||||
zh-CN: 名称未改变
|
||||
en: The name hasn’t changed
|
||||
explorer.name.exists:
|
||||
zh-CN: 已有名为「%{name}」的项目
|
||||
en: An item named “%{name}” already exists
|
||||
|
||||
explorer.delete.title_one:
|
||||
zh-CN: 删除「%{name}」?
|
||||
en: Delete “%{name}”?
|
||||
explorer.delete.title_several.other:
|
||||
zh-CN: 删除 %{count} 个项目?
|
||||
en: Delete %{count} items?
|
||||
explorer.delete.remote:
|
||||
zh-CN: 远程文件无法恢复,目录会连同其中全部内容一起删除。
|
||||
en: Remote files can’t be recovered. A folder is deleted with everything in it.
|
||||
explorer.delete.trash:
|
||||
zh-CN: 项目会移到废纸篓。
|
||||
en: The items move to the Trash.
|
||||
explorer.delete.recycle_bin:
|
||||
zh-CN: 项目会移到废纸篓。
|
||||
en: The items move to the Recycle Bin.
|
||||
|
||||
explorer.rename.title:
|
||||
zh-CN: 重命名「%{name}」
|
||||
en: Rename “%{name}”
|
||||
explorer.rename.commit:
|
||||
zh-CN: 重命名
|
||||
en: Rename
|
||||
explorer.rename.label:
|
||||
zh-CN: 新名称
|
||||
en: New name
|
||||
explorer.new.commit:
|
||||
zh-CN: 创建
|
||||
en: Create
|
||||
explorer.new.label:
|
||||
zh-CN: 名称
|
||||
en: Name
|
||||
|
||||
explorer.file.connection_lost:
|
||||
zh-CN: SFTP 连接已断开
|
||||
en: The SFTP connection was lost
|
||||
explorer.file.connecting:
|
||||
zh-CN: 正在连接 SFTP,请稍候再试。
|
||||
en: Connecting to SFTP. Try again in a moment.
|
||||
explorer.file.edit_refused:
|
||||
zh-CN: 无法在编辑器中打开“%{name}”
|
||||
en: Can’t open “%{name}” in the editor
|
||||
explorer.file.preview_refused:
|
||||
zh-CN: 无法预览“%{name}”
|
||||
en: Can’t preview “%{name}”
|
||||
explorer.file.too_large_for_editor:
|
||||
zh-CN: 文件有 %{size},编辑器只打开 %{limit} 以内的文件。
|
||||
en: The file is %{size}. The editor opens files up to %{limit}.
|
||||
explorer.file.too_large_for_preview:
|
||||
zh-CN: 文件有 %{size},预览只打开 %{limit} 以内的文件。
|
||||
en: The file is %{size}. Preview opens files up to %{limit}.
|
||||
explorer.file.not_text:
|
||||
zh-CN: 它不是 UTF-8 编码的文本文件:可能是二进制文件,或用了 GBK 等其他编码。
|
||||
en: "It isn’t a UTF-8 text file: it may be binary, or in another encoding such as GBK."
|
||||
explorer.file.not_file:
|
||||
zh-CN: 它不是普通文件。
|
||||
en: It isn’t a regular file.
|
||||
explorer.file.open_failed:
|
||||
zh-CN: 无法打开“%{name}”
|
||||
en: Couldn’t open “%{name}”
|
||||
explorer.file.download_instead:
|
||||
zh-CN: "%{reason}可以下载到本机,用别的程序打开。"
|
||||
en: "%{reason} You can download it and open it in another app."
|
||||
explorer.file.download:
|
||||
zh-CN: 下载…
|
||||
en: Download…
|
||||
|
||||
explorer.preview.image_info:
|
||||
zh-CN: "%{width} × %{height} 像素 · %{size}"
|
||||
en: "%{width} × %{height} pixels · %{size}"
|
||||
explorer.preview.zoom_out:
|
||||
zh-CN: 缩小
|
||||
en: Zoom out
|
||||
explorer.preview.zoom_in:
|
||||
zh-CN: 放大
|
||||
en: Zoom in
|
||||
explorer.preview.fit:
|
||||
zh-CN: 适合窗口
|
||||
en: Fit to window
|
||||
explorer.preview.actual_size:
|
||||
zh-CN: 原图
|
||||
en: Actual size
|
||||
explorer.preview.actual_size_tip:
|
||||
zh-CN: 原图(100%)
|
||||
en: Actual size (100%)
|
||||
explorer.preview.undecodable:
|
||||
zh-CN: 无法显示这张图片
|
||||
en: Can’t show this image
|
||||
explorer.preview.decoding:
|
||||
zh-CN: 正在显示图片…
|
||||
en: Loading image…
|
||||
|
||||
explorer.open_directory.title:
|
||||
zh-CN: 打开目录
|
||||
en: Open folder
|
||||
explorer.open_directory.folder:
|
||||
zh-CN: 目录
|
||||
en: Folder
|
||||
explorer.open_directory.bookmarks:
|
||||
zh-CN: 书签
|
||||
en: Bookmarks
|
||||
explorer.open_directory.add:
|
||||
zh-CN: 添加
|
||||
en: Add
|
||||
explorer.open_directory.remove:
|
||||
zh-CN: 删除
|
||||
en: Remove
|
||||
explorer.open_directory.up:
|
||||
zh-CN: 上移
|
||||
en: Up
|
||||
explorer.open_directory.down:
|
||||
zh-CN: 下移
|
||||
en: Down
|
||||
explorer.open_directory.open:
|
||||
zh-CN: 打开
|
||||
en: Open
|
||||
explorer.open_directory.missing:
|
||||
zh-CN: 请输入要打开的目录
|
||||
en: Enter a folder to open
|
||||
explorer.open_directory.choose:
|
||||
zh-CN: 选择要打开的目录
|
||||
en: Choose a folder to open
|
||||
explorer.bookmarks.none:
|
||||
zh-CN: 暂无书签
|
||||
en: No bookmarks
|
||||
|
||||
explorer.command.edit:
|
||||
zh-CN: 编辑
|
||||
en: Edit
|
||||
explorer.command.preview:
|
||||
zh-CN: 预览
|
||||
en: Preview
|
||||
explorer.command.copy_path:
|
||||
zh-CN: 复制路径
|
||||
en: Copy path
|
||||
explorer.command.go:
|
||||
zh-CN: 前往
|
||||
en: Go to
|
||||
explorer.command.add_bookmark:
|
||||
zh-CN: 添加路径到书签
|
||||
en: Add path to bookmarks
|
||||
explorer.command.remove_bookmark:
|
||||
zh-CN: 从书签中移除路径
|
||||
en: Remove path from bookmarks
|
||||
explorer.command.size_format:
|
||||
zh-CN: 文件大小显示为
|
||||
en: Show file sizes as
|
||||
explorer.command.new_folder:
|
||||
zh-CN: 文件夹…
|
||||
en: Folder…
|
||||
explorer.command.new_file:
|
||||
zh-CN: 文件…
|
||||
en: File…
|
||||
|
||||
explorer.operation.delete_failed:
|
||||
zh-CN: 删除失败
|
||||
en: Couldn’t delete
|
||||
explorer.operation.rename_failed:
|
||||
zh-CN: 重命名失败
|
||||
en: Couldn’t rename
|
||||
explorer.operation.create_failed:
|
||||
zh-CN: 新建失败
|
||||
en: Couldn’t create
|
||||
explorer.operation.permissions_failed:
|
||||
zh-CN: 修改权限失败
|
||||
en: Couldn’t change permissions
|
||||
|
||||
explorer.properties.title_one:
|
||||
zh-CN: 「%{name}」的属性
|
||||
en: Properties of “%{name}”
|
||||
explorer.properties.title_several.other:
|
||||
zh-CN: "%{count} 个项目的属性"
|
||||
en: Properties of %{count} items
|
||||
explorer.properties.apply:
|
||||
zh-CN: 应用
|
||||
en: Apply
|
||||
explorer.properties.location:
|
||||
zh-CN: 位置
|
||||
en: Location
|
||||
explorer.properties.group:
|
||||
zh-CN: 组
|
||||
en: Group
|
||||
explorer.properties.size.one:
|
||||
en: "%{size} (%{count} byte)"
|
||||
explorer.properties.size.other:
|
||||
zh-CN: "%{size}(%{count} 字节)"
|
||||
en: "%{size} (%{count} bytes)"
|
||||
explorer.properties.mixed:
|
||||
zh-CN: (不一致)
|
||||
en: (mixed)
|
||||
explorer.properties.class_owner:
|
||||
zh-CN: 所有者
|
||||
en: Owner
|
||||
explorer.properties.class_group:
|
||||
zh-CN: 组
|
||||
en: Group
|
||||
explorer.properties.class_others:
|
||||
zh-CN: 其他
|
||||
en: Others
|
||||
explorer.properties.read:
|
||||
zh-CN: 读
|
||||
en: Read
|
||||
explorer.properties.write:
|
||||
zh-CN: 写
|
||||
en: Write
|
||||
explorer.properties.execute:
|
||||
zh-CN: 执行
|
||||
en: Execute
|
||||
explorer.properties.octal:
|
||||
zh-CN: 八进制
|
||||
en: Octal
|
||||
explorer.properties.mixed_note:
|
||||
zh-CN: 部分项目权限不同,未改动的位保持原样。
|
||||
en: The items’ permissions differ. Bits you leave alone stay as they are.
|
||||
explorer.properties.links:
|
||||
zh-CN: 符号链接没有独立的权限。
|
||||
en: Symbolic links have no permissions of their own.
|
||||
explorer.properties.unsupported:
|
||||
zh-CN: 此系统不支持修改权限。
|
||||
en: This system doesn’t support changing permissions.
|
||||
explorer.properties.recursive:
|
||||
zh-CN: 同时应用到其中的文件和子目录
|
||||
en: Also apply to the files and subfolders inside
|
||||
explorer.properties.dir_x:
|
||||
zh-CN: 为目录添加执行权限(X)
|
||||
en: Add execute permission to folders (X)
|
||||
@@ -0,0 +1,286 @@
|
||||
_version: 2
|
||||
|
||||
# 端口转发: the forward list, its dialog and the forwards running.
|
||||
|
||||
forward.diagram.local:
|
||||
zh-CN: 本机
|
||||
en: This computer
|
||||
forward.diagram.server:
|
||||
zh-CN: SSH 服务器
|
||||
en: SSH server
|
||||
forward.diagram.target_service:
|
||||
zh-CN: 目标服务
|
||||
en: Service
|
||||
forward.diagram.anywhere:
|
||||
zh-CN: 任意地址
|
||||
en: Any address
|
||||
forward.diagram.entrance:
|
||||
zh-CN: 隧道入口
|
||||
en: Tunnel entrance
|
||||
forward.diagram.exit:
|
||||
zh-CN: 隧道出口
|
||||
en: Tunnel exit
|
||||
forward.diagram.target:
|
||||
zh-CN: 目标
|
||||
en: Destination
|
||||
forward.diagram.listens:
|
||||
zh-CN: 监听 %{bind}
|
||||
en: Listens on %{bind}
|
||||
forward.diagram.connects_onward:
|
||||
zh-CN: 代为连接目标
|
||||
en: Connects onward
|
||||
forward.diagram.app_chooses:
|
||||
zh-CN: 由应用指定
|
||||
en: Chosen by the app
|
||||
forward.diagram.tunnel:
|
||||
zh-CN: SSH 隧道
|
||||
en: SSH tunnel
|
||||
forward.diagram.plain:
|
||||
zh-CN: 普通连接
|
||||
en: Plain TCP
|
||||
|
||||
forward.explain.local:
|
||||
zh-CN: 在本机连接 %{bind},就等于从 SSH 服务器连接 %{target}。
|
||||
en: Connecting to %{bind} on this computer is the same as connecting to %{target} from the SSH server.
|
||||
forward.explain.remote:
|
||||
zh-CN: 在 SSH 服务器上连接 %{bind},就等于从本机连接 %{target}。
|
||||
en: Connecting to %{bind} on the SSH server is the same as connecting to %{target} from this computer.
|
||||
forward.explain.dynamic:
|
||||
zh-CN: 把应用的 SOCKS5 代理设为 %{bind},它的连接都从 SSH 服务器发出。
|
||||
en: Set an app’s SOCKS5 proxy to %{bind}, and its connections go out from the SSH server.
|
||||
|
||||
forward.purpose.local:
|
||||
zh-CN: 在本机访问服务器那边的服务
|
||||
en: Reach a service on the server’s side
|
||||
forward.purpose.remote:
|
||||
zh-CN: 让服务器那边访问本机的服务
|
||||
en: Let the server’s side reach a service here
|
||||
forward.purpose.dynamic:
|
||||
zh-CN: 把服务器当作 SOCKS 代理
|
||||
en: Use the server as a SOCKS proxy
|
||||
|
||||
forward.typical_use.local:
|
||||
zh-CN: 常见用途:用本机的客户端连接只对服务器开放的数据库或内网后台。
|
||||
en: "Typical use: connect a client here to a database or internal admin site that only the server can reach."
|
||||
forward.typical_use.remote:
|
||||
zh-CN: 常见用途:把本机正在开发的服务给服务器那边访问,或接收 Webhook。
|
||||
en: "Typical use: let the server’s side reach a service you’re developing here, or receive webhooks."
|
||||
forward.typical_use.dynamic:
|
||||
zh-CN: 常见用途:让浏览器经由服务器上网,不必为每个地址单独建转发。
|
||||
en: "Typical use: browse through the server without a forward for each address."
|
||||
|
||||
forward.dialog.title_new:
|
||||
zh-CN: 新建端口转发
|
||||
en: New port forward
|
||||
forward.dialog.title_edit:
|
||||
zh-CN: 编辑端口转发
|
||||
en: Edit port forward
|
||||
forward.dialog.create:
|
||||
zh-CN: 创建
|
||||
en: Create
|
||||
forward.dialog.kind_card:
|
||||
zh-CN: "%{kind}(%{flag})"
|
||||
en: "%{kind} (%{flag})"
|
||||
forward.dialog.name:
|
||||
zh-CN: 名称
|
||||
en: Name
|
||||
forward.dialog.name_placeholder:
|
||||
zh-CN: 可选,留空显示转发摘要
|
||||
en: Optional; shows the forward’s summary if left empty
|
||||
forward.dialog.host:
|
||||
zh-CN: 经由主机
|
||||
en: Via host
|
||||
forward.dialog.choose_host:
|
||||
zh-CN: 请选择主机
|
||||
en: Choose a host
|
||||
forward.dialog.host_option:
|
||||
zh-CN: "%{name}(%{endpoint})"
|
||||
en: "%{name} (%{endpoint})"
|
||||
forward.dialog.bind_local:
|
||||
zh-CN: 本机监听地址
|
||||
en: Listen on this computer
|
||||
forward.dialog.bind_server:
|
||||
zh-CN: 服务器监听地址
|
||||
en: Listen on the server
|
||||
forward.dialog.bind_socks:
|
||||
zh-CN: 本机监听地址(SOCKS 代理)
|
||||
en: Listen on this computer (SOCKS proxy)
|
||||
forward.dialog.target_from_server:
|
||||
zh-CN: 目标地址(从服务器访问)
|
||||
en: Destination (as the server reaches it)
|
||||
forward.dialog.target_from_local:
|
||||
zh-CN: 目标地址(从本机访问)
|
||||
en: Destination (as this computer reaches it)
|
||||
forward.dialog.port:
|
||||
zh-CN: 端口
|
||||
en: Port
|
||||
forward.dialog.target_placeholder:
|
||||
zh-CN: 例如 db.internal 或 127.0.0.1
|
||||
en: e.g. db.internal or 127.0.0.1
|
||||
forward.dialog.target_placeholder_remote:
|
||||
zh-CN: 例如 localhost
|
||||
en: e.g. localhost
|
||||
forward.dialog.auto_start:
|
||||
zh-CN: 启动 ShellRS 时自动开启
|
||||
en: Start when ShellRS opens
|
||||
forward.dialog.gateway_ports_note:
|
||||
zh-CN: 服务器的 sshd_config 需设置 GatewayPorts clientspecified 或 yes,其他机器才连得上;默认是 no,这时转发照常运行,但只监听服务器自己的 127.0.0.1。
|
||||
en: Other machines can connect only if the server’s sshd_config sets GatewayPorts to clientspecified or yes. With the default, no, the forward still runs but listens only on the server’s own 127.0.0.1.
|
||||
forward.dialog.not_loopback_note:
|
||||
zh-CN: 监听地址不是本机回环地址,同一网络中的其他设备也能使用这个转发。
|
||||
en: The listening address isn’t a loopback address, so other devices on the same network can use this forward too.
|
||||
forward.dialog.privileged_port_remote:
|
||||
zh-CN: 服务器上 1024 以下的端口需要以 root 登录才能监听。
|
||||
en: Listening on a port below 1024 on the server takes logging in as root.
|
||||
forward.dialog.privileged_port:
|
||||
zh-CN: 1024 以下的端口通常需要管理员权限才能监听。
|
||||
en: Listening on a port below 1024 usually takes administrator rights.
|
||||
forward.dialog.restart_note:
|
||||
zh-CN: 这条转发正在运行,改动监听或目标后会按新设置重新启动。
|
||||
en: This forward is running. Changing where it listens or connects restarts it with the new settings.
|
||||
forward.dialog.choose_host_error:
|
||||
zh-CN: 请选择端口转发经由的主机
|
||||
en: Choose the host to forward through
|
||||
forward.dialog.host_gone:
|
||||
zh-CN: 所选主机已不存在,请重新选择
|
||||
en: The chosen host no longer exists. Choose another.
|
||||
|
||||
forward.panel.title:
|
||||
zh-CN: 端口转发
|
||||
en: Port forwarding
|
||||
forward.panel.search:
|
||||
zh-CN: 搜索端口转发
|
||||
en: Search port forwards
|
||||
forward.panel.no_matches:
|
||||
zh-CN: 没有匹配的端口转发
|
||||
en: No matching port forwards
|
||||
forward.panel.no_hosts:
|
||||
zh-CN: 还没有主机
|
||||
en: No hosts yet
|
||||
forward.panel.no_hosts_hint:
|
||||
zh-CN: 端口转发经由一台主机的 SSH 连接,先新建主机。
|
||||
en: A port forward goes through a host’s SSH connection. Create a host first.
|
||||
forward.panel.new_host:
|
||||
zh-CN: 新建主机…
|
||||
en: New host…
|
||||
forward.panel.empty:
|
||||
zh-CN: 还没有端口转发
|
||||
en: No port forwards yet
|
||||
forward.panel.empty_hint:
|
||||
zh-CN: 把本机的端口和服务器那一侧的网络接起来。
|
||||
en: Connect a port on this computer with the network on the server’s side.
|
||||
|
||||
forward.menu.new:
|
||||
zh-CN: 新建端口转发…
|
||||
en: New port forward…
|
||||
forward.menu.edit:
|
||||
zh-CN: 编辑端口转发…
|
||||
en: Edit port forward…
|
||||
forward.menu.start:
|
||||
zh-CN: 启动
|
||||
en: Start
|
||||
forward.menu.stop:
|
||||
zh-CN: 停止
|
||||
en: Stop
|
||||
|
||||
forward.status.stopped:
|
||||
zh-CN: 已停止
|
||||
en: Stopped
|
||||
forward.status.connecting:
|
||||
zh-CN: 正在连接
|
||||
en: Connecting
|
||||
forward.status.running:
|
||||
zh-CN: 运行中
|
||||
en: Running
|
||||
forward.status.running_with.one:
|
||||
en: Running, %{count} connection
|
||||
forward.status.running_with.other:
|
||||
zh-CN: 运行中,%{count} 个连接
|
||||
en: Running, %{count} connections
|
||||
forward.status.reconnecting:
|
||||
zh-CN: 连接中断,正在重连(%{attempt}/%{of})
|
||||
en: Connection lost, reconnecting (%{attempt}/%{of})
|
||||
forward.status.failed:
|
||||
zh-CN: 已停止:%{reason}
|
||||
en: "Stopped: %{reason}"
|
||||
|
||||
forward.error.start_failed:
|
||||
zh-CN: 无法启动端口转发:%{error}
|
||||
en: "Couldn’t start the port forward: %{error}"
|
||||
forward.error.ended:
|
||||
zh-CN: 端口转发意外中止
|
||||
en: The port forward stopped unexpectedly
|
||||
forward.error.reconnect_failed:
|
||||
zh-CN: 连接中断,自动重连失败:%{reason}
|
||||
en: "The connection was lost and couldn’t be restored: %{reason}"
|
||||
forward.error.connection_lost:
|
||||
zh-CN: 与服务器的连接已中断
|
||||
en: The connection to the server was lost
|
||||
forward.error.reconnects_used.one:
|
||||
en: The connection was lost, and %{count} attempt to reconnect failed
|
||||
forward.error.reconnects_used.other:
|
||||
zh-CN: 连接中断,重连 %{count} 次均未成功
|
||||
en: The connection was lost, and all %{count} attempts to reconnect failed
|
||||
forward.error.remote_listen:
|
||||
zh-CN: 服务器拒绝在 %{bind} 上监听:端口可能已被占用、1024 以下的端口需要 root,或服务器禁用了端口转发(AllowTcpForwarding)
|
||||
en: "The server refused to listen on %{bind}: the port may be in use, a port below 1024 needs root, or the server has port forwarding turned off (AllowTcpForwarding)"
|
||||
forward.error.key_changed:
|
||||
zh-CN: 主机密钥与已保存的不一致,已阻止连接。请先核实服务器身份
|
||||
en: The host key doesn’t match the saved one, so the connection was blocked. Verify the server’s identity first.
|
||||
forward.error.unknown_key:
|
||||
zh-CN: 尚未信任该主机的密钥,请手动启动以确认
|
||||
en: The host’s key isn’t trusted yet. Start the forward yourself to confirm it.
|
||||
forward.error.needs_password:
|
||||
zh-CN: 需要输入密码,请手动启动
|
||||
en: A password is needed. Start the forward yourself to enter it.
|
||||
forward.error.needs_passphrase:
|
||||
zh-CN: 需要输入私钥口令,请手动启动
|
||||
en: A passphrase is needed. Start the forward yourself to enter it.
|
||||
forward.error.needs_interactive:
|
||||
zh-CN: 服务器要求交互式认证,请手动启动
|
||||
en: The server asks for interactive authentication. Start the forward yourself to answer it.
|
||||
forward.error.unresolved_bind:
|
||||
zh-CN: 无法解析监听地址 %{host}
|
||||
en: Couldn’t resolve the listening address %{host}
|
||||
forward.error.port_in_use:
|
||||
zh-CN: 本机端口 %{port} 已被占用
|
||||
en: Port %{port} on this computer is already in use
|
||||
forward.error.port_denied:
|
||||
zh-CN: 没有权限监听端口 %{port}(1024 以下的端口需要管理员权限)
|
||||
en: No permission to listen on port %{port} (ports below 1024 need administrator rights)
|
||||
forward.error.no_such_address:
|
||||
zh-CN: 本机没有地址 %{host},无法监听
|
||||
en: This computer has no address %{host} to listen on
|
||||
forward.error.bind_failed:
|
||||
zh-CN: 无法监听 %{bind}:%{error}
|
||||
en: "Couldn’t listen on %{bind}: %{error}"
|
||||
forward.error.not_socks:
|
||||
zh-CN: 收到的不是 SOCKS 请求,请把应用的代理类型设为 SOCKS5
|
||||
en: This isn’t a SOCKS request. Set the app’s proxy type to SOCKS5.
|
||||
forward.error.local_refused:
|
||||
zh-CN: 连接被拒绝,该端口上没有服务在监听
|
||||
en: connection refused, as nothing is listening on that port
|
||||
forward.error.local_timed_out:
|
||||
zh-CN: 连接超时
|
||||
en: the connection timed out
|
||||
forward.error.local_target_failed:
|
||||
zh-CN: 无法连接本机目标 %{target}:%{reason}
|
||||
en: "Couldn’t connect to %{target} on this computer: %{reason}"
|
||||
forward.error.server_timed_out:
|
||||
zh-CN: 服务器连接 %{target} 超时
|
||||
en: The server timed out connecting to %{target}
|
||||
forward.error.prohibited:
|
||||
zh-CN: 服务器禁止转发到 %{target}(AllowTcpForwarding 或 PermitOpen 限制)
|
||||
en: The server doesn’t allow forwarding to %{target} (AllowTcpForwarding or PermitOpen)
|
||||
forward.error.server_connect_failed:
|
||||
zh-CN: 服务器无法连接 %{target}
|
||||
en: The server couldn’t connect to %{target}
|
||||
forward.error.unsupported:
|
||||
zh-CN: 服务器不支持端口转发
|
||||
en: The server doesn’t support port forwarding
|
||||
forward.error.resource_shortage:
|
||||
zh-CN: 服务器资源不足,无法建立转发连接
|
||||
en: The server is short of resources and couldn’t open the connection
|
||||
forward.error.refused:
|
||||
zh-CN: 服务器拒绝了到 %{target} 的连接
|
||||
en: The server refused the connection to %{target}
|
||||
@@ -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"
|
||||
@@ -0,0 +1,613 @@
|
||||
_version: 2
|
||||
|
||||
# 主机: the saved hosts, their groups and the store that keeps them.
|
||||
|
||||
host.state.disconnected:
|
||||
zh-CN: 未连接
|
||||
en: Not connected
|
||||
host.state.connecting:
|
||||
zh-CN: 连接中
|
||||
en: Connecting
|
||||
host.state.connected:
|
||||
zh-CN: 已连接
|
||||
en: Connected
|
||||
|
||||
host.proxy_kind.http:
|
||||
zh-CN: HTTP 代理
|
||||
en: HTTP proxy
|
||||
host.proxy_kind.socks5:
|
||||
zh-CN: SOCKS5 代理
|
||||
en: SOCKS5 proxy
|
||||
|
||||
host.draft.name:
|
||||
zh-CN: 请输入名称
|
||||
en: Enter a name
|
||||
host.draft.address:
|
||||
zh-CN: 请输入地址
|
||||
en: Enter an address
|
||||
host.draft.port:
|
||||
zh-CN: 端口必须是 1 到 65535 之间的数字
|
||||
en: The port must be a number from 1 to 65535
|
||||
host.draft.no_jump_hosts:
|
||||
zh-CN: 请添加跳板主机
|
||||
en: Add a jump host
|
||||
host.draft.deleted_jump_host:
|
||||
zh-CN: 请移除已删除的跳板主机
|
||||
en: Remove the deleted jump host
|
||||
host.draft.proxy_address:
|
||||
zh-CN: 请输入代理地址
|
||||
en: Enter the proxy address
|
||||
host.draft.proxy_port:
|
||||
zh-CN: 代理端口必须是 1 到 65535 之间的数字
|
||||
en: The proxy port must be a number from 1 to 65535
|
||||
|
||||
host.credential_kind.password:
|
||||
zh-CN: 密码
|
||||
en: Password
|
||||
host.credential_kind.key:
|
||||
zh-CN: 密钥
|
||||
en: Key
|
||||
|
||||
host.credential_draft.name:
|
||||
zh-CN: 请输入名称
|
||||
en: Enter a name
|
||||
host.credential_draft.key_path:
|
||||
zh-CN: 密钥凭据需要选择私钥文件
|
||||
en: Choose a private key file for a key credential
|
||||
|
||||
host.database.newer:
|
||||
zh-CN: 数据库来自更新版本的 ShellRS(结构版本 %{version},本版本支持到 %{supported}),请安装最新版本
|
||||
en: The database is from a newer version of ShellRS (schema version %{version}; this version supports up to %{supported}). Install the latest version.
|
||||
host.database.too_old:
|
||||
zh-CN: 数据库来自 ShellRS 的早期开发版本(结构版本 %{version}),无法升级
|
||||
en: The database is from an early development version of ShellRS (schema version %{version}) and can’t be upgraded.
|
||||
|
||||
host.forward_kind.local:
|
||||
zh-CN: 本地转发
|
||||
en: Local
|
||||
host.forward_kind.remote:
|
||||
zh-CN: 远程转发
|
||||
en: Remote
|
||||
host.forward_kind.dynamic:
|
||||
zh-CN: 动态转发
|
||||
en: Dynamic
|
||||
|
||||
host.forward_draft.bind_host:
|
||||
zh-CN: 请输入监听地址,地址中不能有空格
|
||||
en: Enter a listening address without spaces
|
||||
host.forward_draft.bind_port:
|
||||
zh-CN: 监听端口应为 1–65535 的数字
|
||||
en: The listening port must be a number from 1 to 65535
|
||||
host.forward_draft.target_host:
|
||||
zh-CN: 请输入目标地址,地址中不能有空格
|
||||
en: Enter a destination address without spaces
|
||||
host.forward_draft.target_port:
|
||||
zh-CN: 目标端口应为 1–65535 的数字
|
||||
en: The destination port must be a number from 1 to 65535
|
||||
|
||||
host.auth.password:
|
||||
zh-CN: 密码
|
||||
en: Password
|
||||
host.auth.credential:
|
||||
zh-CN: 使用凭据
|
||||
en: Credential
|
||||
host.auth.no_password:
|
||||
zh-CN: 无密码
|
||||
en: No password
|
||||
|
||||
host.route.direct:
|
||||
zh-CN: 直接连接
|
||||
en: Direct
|
||||
host.route.jump:
|
||||
zh-CN: SSH 跳板
|
||||
en: Jump hosts
|
||||
host.route.proxy:
|
||||
zh-CN: 代理连接
|
||||
en: Proxy
|
||||
|
||||
host.dialog.title_new:
|
||||
zh-CN: 新建主机
|
||||
en: New host
|
||||
host.dialog.title_edit:
|
||||
zh-CN: 编辑主机
|
||||
en: Edit host
|
||||
host.dialog.title_temporary:
|
||||
zh-CN: 临时连接
|
||||
en: Quick connect
|
||||
host.dialog.create:
|
||||
zh-CN: 创建
|
||||
en: Create
|
||||
host.dialog.connect:
|
||||
zh-CN: 连接
|
||||
en: Connect
|
||||
host.dialog.test_connection:
|
||||
zh-CN: 测试连接
|
||||
en: Test connection
|
||||
host.dialog.name:
|
||||
zh-CN: 名称
|
||||
en: Name
|
||||
host.dialog.name_placeholder:
|
||||
zh-CN: 例如 web-01
|
||||
en: e.g. web-01
|
||||
host.dialog.temporary_name_placeholder:
|
||||
zh-CN: 可选,默认用地址
|
||||
en: Optional; the address if left empty
|
||||
host.dialog.address:
|
||||
zh-CN: 地址
|
||||
en: Address
|
||||
host.dialog.address_placeholder:
|
||||
zh-CN: 主机名或 IP 地址
|
||||
en: Hostname or IP address
|
||||
host.dialog.port:
|
||||
zh-CN: 端口
|
||||
en: Port
|
||||
host.dialog.auth:
|
||||
zh-CN: 认证方式
|
||||
en: Authentication
|
||||
host.dialog.no_password_note:
|
||||
zh-CN: 依次尝试服务器免认证、SSH Agent 和 ~/.ssh 中的默认私钥。
|
||||
en: Tries logging in without authentication, then the SSH agent, then the default private keys in ~/.ssh.
|
||||
host.dialog.user:
|
||||
zh-CN: 用户名
|
||||
en: Username
|
||||
host.dialog.password:
|
||||
zh-CN: 密码
|
||||
en: Password
|
||||
host.dialog.credential:
|
||||
zh-CN: 凭据
|
||||
en: Credential
|
||||
host.dialog.choose_credential:
|
||||
zh-CN: 请选择凭据
|
||||
en: Choose a credential
|
||||
host.dialog.search_credentials:
|
||||
zh-CN: 搜索凭据
|
||||
en: Search credentials
|
||||
host.dialog.no_credentials:
|
||||
zh-CN: 还没有凭据
|
||||
en: No credentials yet
|
||||
host.dialog.no_credentials_hint:
|
||||
zh-CN: 还没有凭据,可在侧栏的「凭据」中新建
|
||||
en: No credentials yet. Create one in the sidebar’s Credentials list.
|
||||
host.dialog.credential_option:
|
||||
zh-CN: "%{name}(%{summary})"
|
||||
en: "%{name} (%{summary})"
|
||||
host.dialog.logs_in_with.password:
|
||||
zh-CN: 以 %{user} 登录,使用凭据保存的密码
|
||||
en: Logs in as %{user} with the password saved in the credential
|
||||
host.dialog.logs_in_with.kept_key:
|
||||
zh-CN: 以 %{user} 登录,使用 ShellRS 保存的私钥
|
||||
en: Logs in as %{user} with the private key ShellRS keeps
|
||||
host.dialog.logs_in_with.key:
|
||||
zh-CN: 以 %{user} 登录,使用私钥 %{path}
|
||||
en: Logs in as %{user} with the private key %{path}
|
||||
host.dialog.logs_in_with.agent:
|
||||
zh-CN: 以 %{user} 登录,使用 SSH Agent 中的密钥
|
||||
en: Logs in as %{user} with the keys in the SSH agent
|
||||
host.dialog.enter_user:
|
||||
zh-CN: 请输入用户名
|
||||
en: Enter a username
|
||||
host.dialog.group:
|
||||
zh-CN: 分组
|
||||
en: Group
|
||||
host.dialog.no_group:
|
||||
zh-CN: (无分组)
|
||||
en: (No group)
|
||||
host.dialog.notes:
|
||||
zh-CN: 备注
|
||||
en: Notes
|
||||
host.dialog.optional:
|
||||
zh-CN: 可选
|
||||
en: Optional
|
||||
host.dialog.keychain_note:
|
||||
zh-CN: 密码保存在系统钥匙串,不会写入 ShellRS 的数据库。
|
||||
en: Passwords are kept in the system keychain, never in ShellRS’s database.
|
||||
host.dialog.no_keychain_note:
|
||||
zh-CN: 系统钥匙串不可用,这台机器上无法保存密码,每次连接都会询问。
|
||||
en: The system keychain isn’t available, so passwords can’t be saved on this computer and are asked for at each connection.
|
||||
host.dialog.reconnect_note:
|
||||
zh-CN: 保存后连接设置将立即生效并重连。
|
||||
en: Saving applies the connection settings at once and reconnects.
|
||||
host.dialog.temporary_note:
|
||||
zh-CN: 仅当前使用的临时会话,不会保存到主机列表。
|
||||
en: For this connection only. It won’t be added to your hosts.
|
||||
|
||||
host.dialog.route:
|
||||
zh-CN: 连接方式
|
||||
en: Connection
|
||||
host.dialog.jump_note:
|
||||
zh-CN: 依次经过跳板主机连接到当前主机,可添加多台。
|
||||
en: Connects to this host through each jump host in turn. You can add several.
|
||||
host.dialog.this_computer:
|
||||
zh-CN: 本机
|
||||
en: This computer
|
||||
host.dialog.this_host:
|
||||
zh-CN: 当前主机
|
||||
en: This host
|
||||
host.dialog.deleted_host:
|
||||
zh-CN: 已删除的主机
|
||||
en: Deleted host
|
||||
host.dialog.jump_host_title:
|
||||
zh-CN: "%{name}(%{address})"
|
||||
en: "%{name} (%{address})"
|
||||
host.dialog.remove_hop:
|
||||
zh-CN: 移除
|
||||
en: Remove
|
||||
host.dialog.add_jump_host:
|
||||
zh-CN: 添加跳板主机
|
||||
en: Add jump host
|
||||
host.dialog.search_hosts:
|
||||
zh-CN: 搜索主机
|
||||
en: Search hosts
|
||||
host.dialog.no_matching_hosts:
|
||||
zh-CN: 没有匹配的主机
|
||||
en: No matching hosts
|
||||
host.dialog.no_other_hosts:
|
||||
zh-CN: 还没有其他主机可做跳板
|
||||
en: No other hosts to jump through
|
||||
host.dialog.all_hosts_added:
|
||||
zh-CN: 其他主机都已添加
|
||||
en: All other hosts are added
|
||||
host.dialog.proxy_kind:
|
||||
zh-CN: 代理类型
|
||||
en: Proxy type
|
||||
host.dialog.proxy_address:
|
||||
zh-CN: 代理地址
|
||||
en: Proxy address
|
||||
host.dialog.proxy_host_placeholder:
|
||||
zh-CN: 例如 127.0.0.1
|
||||
en: e.g. 127.0.0.1
|
||||
host.dialog.proxy_note:
|
||||
zh-CN: 目标地址由代理服务器解析;代理不需要认证时,用户名和密码留空。
|
||||
en: The proxy server resolves the host’s address. Leave the username and password empty if the proxy doesn’t need them.
|
||||
host.dialog.proxy_password_needs_user:
|
||||
zh-CN: 填写代理密码时请同时填写用户名
|
||||
en: Enter a username to go with the proxy password
|
||||
|
||||
host.dialog.test_start_failed:
|
||||
zh-CN: 无法启动连接测试:%{error}
|
||||
en: "Couldn’t start the connection test: %{error}"
|
||||
host.dialog.test_aborted:
|
||||
zh-CN: 连接测试意外中止
|
||||
en: The connection test stopped unexpectedly
|
||||
host.test.connected:
|
||||
zh-CN: 连接成功
|
||||
en: Connection succeeded
|
||||
host.test.failed:
|
||||
zh-CN: 连接失败
|
||||
en: Couldn’t connect
|
||||
|
||||
host.trust.title:
|
||||
zh-CN: 首次连接此主机
|
||||
en: First connection to this host
|
||||
host.trust.trust:
|
||||
zh-CN: 信任并继续
|
||||
en: Trust and continue
|
||||
|
||||
host.delete.closes_tabs:
|
||||
zh-CN: 会一并关闭该主机已打开的终端和 SFTP 标签。
|
||||
en: Its open terminal and SFTP tabs will close.
|
||||
host.delete.transfers.one:
|
||||
en: "%{count} transfer batch will stop, with its progress kept for resuming."
|
||||
host.delete.transfers.other:
|
||||
zh-CN: 将停止 %{count} 个传输批次并保留续传进度。
|
||||
en: "%{count} transfer batches will stop, with their progress kept for resuming."
|
||||
host.delete.forwards.one:
|
||||
en: "%{count} port forward through this host will also be deleted."
|
||||
host.delete.forwards.other:
|
||||
zh-CN: 将同时删除经由该主机的 %{count} 条端口转发。
|
||||
en: "%{count} port forwards through this host will also be deleted."
|
||||
host.delete.jump_users.one:
|
||||
en: "%{count} host uses it as a jump host and will need another one to connect."
|
||||
host.delete.jump_users.other:
|
||||
zh-CN: 有 %{count} 台主机把它用作跳板主机,删除后要重新选择跳板主机才能连接。
|
||||
en: "%{count} hosts use it as a jump host and will need another one to connect."
|
||||
|
||||
host.count.hosts.one:
|
||||
en: "%{count} host"
|
||||
host.count.hosts.other:
|
||||
zh-CN: "%{count} 台主机"
|
||||
en: "%{count} hosts"
|
||||
host.count.subgroups.one:
|
||||
en: "%{count} subgroup"
|
||||
host.count.subgroups.other:
|
||||
zh-CN: "%{count} 个子分组"
|
||||
en: "%{count} subgroups"
|
||||
|
||||
host.group_dialog.title_new:
|
||||
zh-CN: 新建分组
|
||||
en: New group
|
||||
host.group_dialog.title_rename:
|
||||
zh-CN: 重命名分组
|
||||
en: Rename group
|
||||
host.group_dialog.create:
|
||||
zh-CN: 创建
|
||||
en: Create
|
||||
host.group_dialog.name:
|
||||
zh-CN: 名称
|
||||
en: Name
|
||||
host.group_dialog.name_placeholder:
|
||||
zh-CN: 例如 生产
|
||||
en: e.g. Production
|
||||
host.group_dialog.parent:
|
||||
zh-CN: 上级分组
|
||||
en: Parent group
|
||||
host.group_dialog.top_level:
|
||||
zh-CN: (顶层)
|
||||
en: (Top level)
|
||||
host.group_dialog.enter_name:
|
||||
zh-CN: 请输入分组名称
|
||||
en: Enter a group name
|
||||
host.group_dialog.duplicate:
|
||||
zh-CN: 同一层级下已有同名分组
|
||||
en: A group with this name is already at this level
|
||||
host.group_dialog.into_itself:
|
||||
zh-CN: 无法把分组移动到它自己的下级
|
||||
en: A group can’t move into one of its own subgroups
|
||||
|
||||
host.delete_group.contents:
|
||||
zh-CN: 将同时删除其中的 %{items}。
|
||||
en: This also deletes the group’s %{items}.
|
||||
host.delete_group.closes_tabs:
|
||||
zh-CN: 已打开的终端和 SFTP 标签会一并关闭。
|
||||
en: Open terminal and SFTP tabs will close.
|
||||
host.delete_group.forwards.one:
|
||||
en: "%{count} port forward through these hosts will also be deleted."
|
||||
host.delete_group.forwards.other:
|
||||
zh-CN: 这些主机的 %{count} 条端口转发会一并删除。
|
||||
en: "%{count} port forwards through these hosts will also be deleted."
|
||||
host.delete_group.jump_users.one:
|
||||
en: "%{count} host outside the group jumps through these hosts and will need another jump host to connect."
|
||||
host.delete_group.jump_users.other:
|
||||
zh-CN: 分组外有 %{count} 台主机经由这些主机跳转,删除后要重新选择跳板主机才能连接。
|
||||
en: "%{count} hosts outside the group jump through these hosts and will need another jump host to connect."
|
||||
|
||||
host.panel.title:
|
||||
zh-CN: 主机
|
||||
en: Hosts
|
||||
host.panel.search:
|
||||
zh-CN: 搜索主机或分组
|
||||
en: Search hosts or groups
|
||||
|
||||
host.menu.connect:
|
||||
zh-CN: 连接
|
||||
en: Connect
|
||||
host.menu.open_sftp:
|
||||
zh-CN: 打开 SFTP
|
||||
en: Open SFTP
|
||||
host.menu.edit_host:
|
||||
zh-CN: 编辑主机…
|
||||
en: Edit host…
|
||||
host.menu.duplicate:
|
||||
zh-CN: 复制
|
||||
en: Duplicate
|
||||
host.menu.copy_ip:
|
||||
zh-CN: 复制 IP 地址
|
||||
en: Copy IP address
|
||||
host.menu.copy_host_name:
|
||||
zh-CN: 复制主机名
|
||||
en: Copy hostname
|
||||
host.menu.copy_id:
|
||||
zh-CN: 复制 ID
|
||||
en: Copy ID
|
||||
host.menu.new_host:
|
||||
zh-CN: 新建主机…
|
||||
en: New host…
|
||||
host.menu.new_group:
|
||||
zh-CN: 新建分组…
|
||||
en: New group…
|
||||
host.menu.connect_group:
|
||||
zh-CN: 连接组内主机
|
||||
en: Connect all in group
|
||||
host.menu.new_subgroup:
|
||||
zh-CN: 新建子分组…
|
||||
en: New subgroup…
|
||||
host.menu.expand_all:
|
||||
zh-CN: 展开全部分组
|
||||
en: Expand all groups
|
||||
host.menu.collapse_all:
|
||||
zh-CN: 折叠全部分组
|
||||
en: Collapse all groups
|
||||
host.menu.rename_group:
|
||||
zh-CN: 重命名分组…
|
||||
en: Rename group…
|
||||
host.menu.delete_group:
|
||||
zh-CN: 删除分组
|
||||
en: Delete group
|
||||
|
||||
host.pasted_key.empty:
|
||||
zh-CN: 请粘贴私钥
|
||||
en: Paste a private key
|
||||
host.pasted_key.public_key:
|
||||
zh-CN: 这是公钥,请粘贴私钥(以 -----BEGIN 开头的那一段)
|
||||
en: This is a public key. Paste the private key, the part that starts with -----BEGIN.
|
||||
host.pasted_key.unreadable:
|
||||
zh-CN: 无法识别这段私钥,支持 OpenSSH、PEM 和 PuTTY 格式
|
||||
en: This private key can’t be read. Use the OpenSSH, PEM or PuTTY format.
|
||||
|
||||
host.quick_connect.title:
|
||||
zh-CN: 快速连接
|
||||
en: Find a host
|
||||
host.quick_connect.search:
|
||||
zh-CN: 搜索主机名称、地址、用户名、备注或分组
|
||||
en: Search by name, address, username, notes or group
|
||||
host.quick_connect.no_hosts:
|
||||
zh-CN: 还没有保存的主机
|
||||
en: No saved hosts yet
|
||||
host.quick_connect.no_matches:
|
||||
zh-CN: 没有匹配的主机
|
||||
en: No matching hosts
|
||||
host.quick_connect.choose:
|
||||
zh-CN: 选中
|
||||
en: Select
|
||||
host.quick_connect.choose_all:
|
||||
zh-CN: 全选
|
||||
en: Select all
|
||||
host.quick_connect.connect_chosen:
|
||||
zh-CN: 批量连接
|
||||
en: Connect selected
|
||||
|
||||
host.secret_fields.ask_each_time:
|
||||
zh-CN: 留空则每次连接都询问
|
||||
en: Leave empty to be asked at each connection
|
||||
host.secret_fields.key_path_placeholder:
|
||||
zh-CN: 选择 OpenSSH 私钥文件
|
||||
en: Choose an OpenSSH private key file
|
||||
host.secret_fields.choose_key_prompt:
|
||||
zh-CN: 选择 SSH 私钥
|
||||
en: Choose an SSH private key
|
||||
host.secret_fields.choose:
|
||||
zh-CN: 选择…
|
||||
en: Choose…
|
||||
|
||||
host.snippet_draft.name:
|
||||
zh-CN: 请输入名称
|
||||
en: Enter a name
|
||||
host.snippet_draft.command:
|
||||
zh-CN: 请输入命令
|
||||
en: Enter a command
|
||||
|
||||
host.link.missing:
|
||||
zh-CN: 启动参数里没有要连接的地址,应为 ssh://用户@地址[:端口](打开 SFTP 用 sftp://)。
|
||||
en: The launch arguments have no address to connect to. Use ssh://user@address[:port], or sftp:// to open SFTP.
|
||||
host.link.malformed:
|
||||
zh-CN: 链接的格式不对,应为 ssh://用户[:密码]@地址[:端口](打开 SFTP 用 sftp://)。
|
||||
en: The link isn’t in the right format. Use ssh://user[:password]@address[:port], or sftp:// to open SFTP.
|
||||
host.link.invalid_port:
|
||||
zh-CN: 链接里的端口无效。
|
||||
en: The port in the link isn’t valid.
|
||||
host.link.no_address:
|
||||
zh-CN: 链接里没有地址。
|
||||
en: The link has no address.
|
||||
host.link.unsupported_scheme:
|
||||
zh-CN: 只支持 ssh:// 和 sftp:// 链接,不支持 %{scheme}://。
|
||||
en: Only ssh:// and sftp:// links are supported, not %{scheme}://.
|
||||
host.link.no_user:
|
||||
zh-CN: 链接里没有用户名,应为 %{scheme}://用户@地址[:端口]。
|
||||
en: The link has no username. Use %{scheme}://user@address[:port].
|
||||
host.link.unreadable:
|
||||
zh-CN: 链接里有无法识别的字符。
|
||||
en: The link has characters that can’t be read.
|
||||
|
||||
# 本地数据库: a change that stands in memory but is not on disk.
|
||||
host.store.not_saved.new_host:
|
||||
zh-CN: 新建主机未能保存到本地数据库:%{error}
|
||||
en: "Couldn’t save the new host to the local database: %{error}"
|
||||
host.store.not_saved.save_host:
|
||||
zh-CN: 保存主机未能保存到本地数据库:%{error}
|
||||
en: "Couldn’t save the host’s changes to the local database: %{error}"
|
||||
host.store.not_saved.delete_host:
|
||||
zh-CN: 删除主机未能保存到本地数据库:%{error}
|
||||
en: "Couldn’t delete the host from the local database: %{error}"
|
||||
host.store.not_saved.duplicate_host:
|
||||
zh-CN: 复制主机未能保存到本地数据库:%{error}
|
||||
en: "Couldn’t save the host’s copy to the local database: %{error}"
|
||||
host.store.not_saved.host_order:
|
||||
zh-CN: 保存主机顺序未能保存到本地数据库:%{error}
|
||||
en: "Couldn’t save the order of the hosts to the local database: %{error}"
|
||||
host.store.not_saved.new_group:
|
||||
zh-CN: 新建分组未能保存到本地数据库:%{error}
|
||||
en: "Couldn’t save the new group to the local database: %{error}"
|
||||
host.store.not_saved.save_group:
|
||||
zh-CN: 保存分组未能保存到本地数据库:%{error}
|
||||
en: "Couldn’t save the group’s changes to the local database: %{error}"
|
||||
host.store.not_saved.add_bookmark:
|
||||
zh-CN: 添加书签未能保存到本地数据库:%{error}
|
||||
en: "Couldn’t save the new bookmark to the local database: %{error}"
|
||||
host.store.not_saved.delete_bookmark:
|
||||
zh-CN: 删除书签未能保存到本地数据库:%{error}
|
||||
en: "Couldn’t delete the bookmark from the local database: %{error}"
|
||||
host.store.not_saved.bookmark_order:
|
||||
zh-CN: 调整书签顺序未能保存到本地数据库:%{error}
|
||||
en: "Couldn’t save the order of the bookmarks to the local database: %{error}"
|
||||
host.store.not_saved.new_forward:
|
||||
zh-CN: 新建端口转发未能保存到本地数据库:%{error}
|
||||
en: "Couldn’t save the new port forward to the local database: %{error}"
|
||||
host.store.not_saved.save_forward:
|
||||
zh-CN: 保存端口转发未能保存到本地数据库:%{error}
|
||||
en: "Couldn’t save the port forward’s changes to the local database: %{error}"
|
||||
host.store.not_saved.delete_forward:
|
||||
zh-CN: 删除端口转发未能保存到本地数据库:%{error}
|
||||
en: "Couldn’t delete the port forward from the local database: %{error}"
|
||||
host.store.not_saved.new_credential:
|
||||
zh-CN: 新建凭据未能保存到本地数据库:%{error}
|
||||
en: "Couldn’t save the new credential to the local database: %{error}"
|
||||
host.store.not_saved.save_credential:
|
||||
zh-CN: 保存凭据未能保存到本地数据库:%{error}
|
||||
en: "Couldn’t save the credential’s changes to the local database: %{error}"
|
||||
host.store.not_saved.delete_credential:
|
||||
zh-CN: 删除凭据未能保存到本地数据库:%{error}
|
||||
en: "Couldn’t delete the credential from the local database: %{error}"
|
||||
host.store.not_saved.group_expanded:
|
||||
zh-CN: 保存分组展开状态未能保存到本地数据库:%{error}
|
||||
en: "Couldn’t save whether the group is expanded to the local database: %{error}"
|
||||
host.store.not_saved.all_groups_expanded:
|
||||
zh-CN: 保存所有分组展开状态未能保存到本地数据库:%{error}
|
||||
en: "Couldn’t save whether the groups are expanded to the local database: %{error}"
|
||||
host.store.not_saved.move_node:
|
||||
zh-CN: 调整主机顺序未能保存到本地数据库:%{error}
|
||||
en: "Couldn’t save the rearranged host list to the local database: %{error}"
|
||||
host.store.not_saved.delete_group:
|
||||
zh-CN: 删除分组未能保存到本地数据库:%{error}
|
||||
en: "Couldn’t delete the group from the local database: %{error}"
|
||||
host.store.not_saved.recent:
|
||||
zh-CN: 记录最近连接未能保存到本地数据库:%{error}
|
||||
en: "Couldn’t save the recent connections to the local database: %{error}"
|
||||
host.store.not_saved.host_os:
|
||||
zh-CN: 记录主机系统未能保存到本地数据库:%{error}
|
||||
en: "Couldn’t save the host’s operating system to the local database: %{error}"
|
||||
host.store.not_saved.new_snippet:
|
||||
zh-CN: 新建命令片段未能保存到本地数据库:%{error}
|
||||
en: "Couldn’t save the new snippet to the local database: %{error}"
|
||||
host.store.not_saved.save_snippet:
|
||||
zh-CN: 保存命令片段未能保存到本地数据库:%{error}
|
||||
en: "Couldn’t save the snippet’s changes to the local database: %{error}"
|
||||
host.store.not_saved.delete_snippet:
|
||||
zh-CN: 删除命令片段未能保存到本地数据库:%{error}
|
||||
en: "Couldn’t delete the snippet from the local database: %{error}"
|
||||
host.store.not_saved.new_snippet_category:
|
||||
zh-CN: 新建片段分类未能保存到本地数据库:%{error}
|
||||
en: "Couldn’t save the new snippet category to the local database: %{error}"
|
||||
host.store.not_saved.rename_snippet_category:
|
||||
zh-CN: 重命名片段分类未能保存到本地数据库:%{error}
|
||||
en: "Couldn’t save the renamed snippet category to the local database: %{error}"
|
||||
host.store.not_saved.delete_snippet_category:
|
||||
zh-CN: 删除片段分类未能保存到本地数据库:%{error}
|
||||
en: "Couldn’t delete the snippet category from the local database: %{error}"
|
||||
|
||||
host.store.copy_name:
|
||||
zh-CN: "%{name} 副本"
|
||||
en: "%{name} copy"
|
||||
host.store.no_key_dir:
|
||||
zh-CN: 没有可以保存私钥的目录
|
||||
en: There’s no folder to keep private keys in
|
||||
host.store.key_not_deleted:
|
||||
zh-CN: ShellRS 保存的私钥未能删除:%{error}
|
||||
en: "Couldn’t delete a private key ShellRS keeps: %{error}"
|
||||
|
||||
host.store.secret.password_write:
|
||||
zh-CN: 密码未能写入系统钥匙串:%{error}
|
||||
en: "Couldn’t save the password to the system keychain: %{error}"
|
||||
host.store.secret.password_delete:
|
||||
zh-CN: 密码未能从系统钥匙串删除:%{error}
|
||||
en: "Couldn’t delete the password from the system keychain: %{error}"
|
||||
host.store.secret.passphrase_write:
|
||||
zh-CN: 私钥口令未能写入系统钥匙串:%{error}
|
||||
en: "Couldn’t save the private key’s passphrase to the system keychain: %{error}"
|
||||
host.store.secret.passphrase_delete:
|
||||
zh-CN: 私钥口令未能从系统钥匙串删除:%{error}
|
||||
en: "Couldn’t delete the private key’s passphrase from the system keychain: %{error}"
|
||||
host.store.secret.credential_write:
|
||||
zh-CN: 凭据的密码未能写入系统钥匙串:%{error}
|
||||
en: "Couldn’t save the credential’s password to the system keychain: %{error}"
|
||||
host.store.secret.credential_delete:
|
||||
zh-CN: 凭据的密码未能从系统钥匙串删除:%{error}
|
||||
en: "Couldn’t delete the credential’s password from the system keychain: %{error}"
|
||||
host.store.secret.proxy_write:
|
||||
zh-CN: 代理的密码未能写入系统钥匙串:%{error}
|
||||
en: "Couldn’t save the proxy’s password to the system keychain: %{error}"
|
||||
host.store.secret.proxy_delete:
|
||||
zh-CN: 代理的密码未能从系统钥匙串删除:%{error}
|
||||
en: "Couldn’t delete the proxy’s password from the system keychain: %{error}"
|
||||
host.store.secret.temporary:
|
||||
zh-CN: 这次连接的密码未能记下:%{error}
|
||||
en: "Couldn’t keep the password for this connection: %{error}"
|
||||
@@ -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}"
|
||||
@@ -0,0 +1,100 @@
|
||||
_version: 2
|
||||
|
||||
# 网络连接: a Linux host's sockets, as `ss` lists them.
|
||||
|
||||
netstat.panel.search:
|
||||
zh-CN: 搜索地址、端口、状态、PID 或进程
|
||||
en: Address, port, state, PID or process
|
||||
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)"
|
||||
@@ -0,0 +1,166 @@
|
||||
_version: 2
|
||||
|
||||
# 进程管理: a Linux host's processes.
|
||||
|
||||
processes.panel.search:
|
||||
zh-CN: 搜索进程名称、PID 或用户
|
||||
en: Search names, PIDs and users
|
||||
processes.panel.summary.one:
|
||||
en: "%{count} process · refreshed every %{seconds} s"
|
||||
processes.panel.summary.other:
|
||||
zh-CN: 共 %{count} 个进程 · 每 %{seconds} 秒刷新
|
||||
en: "%{count} processes · refreshed every %{seconds} s"
|
||||
processes.panel.truncated:
|
||||
zh-CN: 进程太多,只列出了前 %{limit} 个。
|
||||
en: There are too many processes, so only the first %{limit} are listed.
|
||||
processes.panel.empty:
|
||||
zh-CN: 没有进程
|
||||
en: No processes
|
||||
processes.panel.no_match:
|
||||
zh-CN: 没有符合条件的进程
|
||||
en: No matching processes
|
||||
processes.panel.not_connected:
|
||||
zh-CN: 终端没有连接。连接后这里显示主机的进程。
|
||||
en: The terminal isn’t connected. Once it is, the host’s processes show here.
|
||||
processes.panel.unsupported_system:
|
||||
zh-CN: 暂不支持管理 %{system} 的进程,目前只支持 Linux 主机。
|
||||
en: Processes on %{system} can’t be managed yet. Only Linux hosts are supported.
|
||||
processes.panel.unsupported:
|
||||
zh-CN: 暂不支持管理这台主机的进程,目前只支持 Linux 主机。
|
||||
en: This host’s processes can’t be managed yet. Only Linux hosts are supported.
|
||||
processes.memory:
|
||||
zh-CN: 内存
|
||||
en: Memory
|
||||
processes.card.label:
|
||||
zh-CN: "%{name} · PID %{pid} · %{state} · %{user} · 内存 %{memory} · CPU %{cpu}"
|
||||
en: "%{name} · PID %{pid} · %{state} · %{user} · Memory %{memory} · CPU %{cpu}"
|
||||
|
||||
processes.sort.memory_descending:
|
||||
zh-CN: 按内存从多到少排列
|
||||
en: Sort by memory, most first
|
||||
processes.sort.memory_ascending:
|
||||
zh-CN: 改为按内存从少到多排列
|
||||
en: Sort by memory, least first
|
||||
processes.sort.cpu_descending:
|
||||
zh-CN: 按CPU从多到少排列
|
||||
en: Sort by CPU, most first
|
||||
processes.sort.cpu_ascending:
|
||||
zh-CN: 改为按CPU从少到多排列
|
||||
en: Sort by CPU, least first
|
||||
|
||||
processes.menu.end:
|
||||
zh-CN: 结束进程…
|
||||
en: End process…
|
||||
processes.menu.kill:
|
||||
zh-CN: 强制结束进程…
|
||||
en: Kill process…
|
||||
|
||||
# What a process is doing: the letter in /proc/<pid>/stat.
|
||||
processes.state.running:
|
||||
zh-CN: 运行中
|
||||
en: Running
|
||||
processes.state.sleeping:
|
||||
zh-CN: 休眠
|
||||
en: Sleeping
|
||||
processes.state.uninterruptible:
|
||||
zh-CN: 等待 I/O
|
||||
en: Waiting for I/O
|
||||
processes.state.stopped:
|
||||
zh-CN: 已暂停
|
||||
en: Stopped
|
||||
processes.state.zombie:
|
||||
zh-CN: 僵尸进程
|
||||
en: Zombie
|
||||
processes.state.idle:
|
||||
zh-CN: 空闲
|
||||
en: Idle
|
||||
processes.state.misc:
|
||||
zh-CN: 其他
|
||||
en: Other
|
||||
|
||||
processes.error.not_permitted:
|
||||
zh-CN: 没有权限:只能结束自己的进程,root 才能结束所有进程
|
||||
en: "Permission denied: only root can end another user’s process"
|
||||
processes.error.gone:
|
||||
zh-CN: 进程已经退出
|
||||
en: The process has already exited
|
||||
processes.error.status:
|
||||
zh-CN: kill 返回 %{status}
|
||||
en: kill exited with status %{status}
|
||||
|
||||
# A process's details.
|
||||
processes.details.info:
|
||||
zh-CN: 进程信息
|
||||
en: Overview
|
||||
processes.details.command:
|
||||
zh-CN: 完整命令
|
||||
en: Command line
|
||||
processes.details.copy_pid:
|
||||
zh-CN: 复制 PID
|
||||
en: Copy PID
|
||||
processes.details.copy_command:
|
||||
zh-CN: 复制命令
|
||||
en: Copy command
|
||||
processes.details.copied:
|
||||
zh-CN: 已复制
|
||||
en: Copied
|
||||
processes.details.end:
|
||||
zh-CN: 结束进程
|
||||
en: End process
|
||||
processes.details.kill:
|
||||
zh-CN: 强制结束
|
||||
en: Kill
|
||||
processes.details.none:
|
||||
zh-CN: 无
|
||||
en: None
|
||||
processes.details.no_command:
|
||||
zh-CN: 没有命令行:进程已经退出,或是僵尸进程
|
||||
en: No command line. The process has exited, or it’s a zombie.
|
||||
processes.field.pid:
|
||||
zh-CN: PID
|
||||
en: PID
|
||||
processes.field.parent:
|
||||
zh-CN: 父进程
|
||||
en: Parent
|
||||
processes.field.user:
|
||||
zh-CN: 用户
|
||||
en: User
|
||||
processes.field.state:
|
||||
zh-CN: 状态
|
||||
en: State
|
||||
processes.field.started:
|
||||
zh-CN: 启动时间
|
||||
en: Started
|
||||
processes.field.running:
|
||||
zh-CN: 运行时长
|
||||
en: Elapsed
|
||||
processes.field.terminal:
|
||||
zh-CN: 终端
|
||||
en: TTY
|
||||
processes.field.priority:
|
||||
zh-CN: 优先级
|
||||
en: Priority
|
||||
processes.field.nice:
|
||||
zh-CN: Nice
|
||||
en: Nice
|
||||
processes.field.cpu:
|
||||
zh-CN: CPU 使用率
|
||||
en: CPU usage
|
||||
processes.field.cpu_time:
|
||||
zh-CN: 累计 CPU 时间
|
||||
en: CPU time
|
||||
processes.field.memory:
|
||||
zh-CN: 常驻内存
|
||||
en: Memory (RSS)
|
||||
processes.field.virtual_memory:
|
||||
zh-CN: 虚拟内存
|
||||
en: Virtual memory
|
||||
processes.field.threads:
|
||||
zh-CN: 线程
|
||||
en: Threads
|
||||
processes.field.children:
|
||||
zh-CN: 直接子进程
|
||||
en: Children
|
||||
processes.field.descendants:
|
||||
zh-CN: 全部后代进程
|
||||
en: Descendants
|
||||
@@ -0,0 +1,38 @@
|
||||
_version: 2
|
||||
|
||||
# 钥匙串: what the system keychain says when it fails.
|
||||
|
||||
secrets.unavailable:
|
||||
zh-CN: 系统钥匙串不可用,密码无法保存
|
||||
en: The system keychain isn’t available, so the password can’t be saved
|
||||
|
||||
secrets.keychain.open_failed:
|
||||
zh-CN: 打开系统钥匙串条目失败:%{reason}
|
||||
en: "Couldn’t open the system keychain entry: %{reason}"
|
||||
secrets.keychain.read_failed:
|
||||
zh-CN: 读取系统钥匙串条目失败:%{reason}
|
||||
en: "Couldn’t read the system keychain entry: %{reason}"
|
||||
secrets.keychain.write_failed:
|
||||
zh-CN: 写入系统钥匙串条目失败:%{reason}
|
||||
en: "Couldn’t write the system keychain entry: %{reason}"
|
||||
secrets.keychain.delete_failed:
|
||||
zh-CN: 删除系统钥匙串条目失败:%{reason}
|
||||
en: "Couldn’t delete the system keychain entry: %{reason}"
|
||||
|
||||
secrets.keychain.no_store:
|
||||
zh-CN: 系统钥匙串不可用
|
||||
en: the system keychain isn’t available
|
||||
secrets.keychain.no_access:
|
||||
zh-CN: 系统钥匙串被锁定或拒绝访问
|
||||
en: the system keychain is locked or denied access
|
||||
secrets.keychain.bad_encoding:
|
||||
zh-CN: 已保存的内容不是有效的 UTF-8 文本
|
||||
en: what’s saved isn’t valid UTF-8 text
|
||||
secrets.keychain.ambiguous.one:
|
||||
en: the keychain has %{count} entry with this name
|
||||
secrets.keychain.ambiguous.other:
|
||||
zh-CN: 钥匙串里有 %{count} 条同名条目
|
||||
en: the keychain has %{count} entries with this name
|
||||
secrets.keychain.too_long:
|
||||
zh-CN: 超过系统钥匙串 %{limit} 个字符的长度上限
|
||||
en: it’s longer than the system keychain’s limit of %{limit} characters
|
||||
@@ -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
|
||||
@@ -0,0 +1,362 @@
|
||||
_version: 2
|
||||
|
||||
settings.choice.system:
|
||||
zh-CN: 跟随系统
|
||||
en: System
|
||||
settings.appearance.dark:
|
||||
zh-CN: 深色
|
||||
en: Dark
|
||||
settings.appearance.light:
|
||||
zh-CN: 浅色
|
||||
en: Light
|
||||
settings.channel.stable:
|
||||
zh-CN: 稳定版
|
||||
en: Stable
|
||||
|
||||
settings.store.unreadable:
|
||||
zh-CN: 无法读取设置,本次使用默认设置:%{error}
|
||||
en: "Couldn’t read the settings, so this session uses the defaults: %{error}"
|
||||
settings.store.unsaved:
|
||||
zh-CN: 无法保存设置,本次运行的改动不会被保存:%{error}
|
||||
en: "Settings can’t be saved, so changes last only until ShellRS quits: %{error}"
|
||||
settings.store.save_failed:
|
||||
zh-CN: 无法保存设置:%{error}
|
||||
en: "Couldn’t save the settings: %{error}"
|
||||
|
||||
settings.tab.title:
|
||||
zh-CN: 设置
|
||||
en: Settings
|
||||
settings.preview:
|
||||
zh-CN: 预览
|
||||
en: Preview
|
||||
# A choice that is what ShellRS uses unless told otherwise: 「13(默认)」.
|
||||
settings.default_option:
|
||||
zh-CN: "%{value}(默认)"
|
||||
en: "%{value} (default)"
|
||||
|
||||
settings.category.appearance:
|
||||
zh-CN: 外观
|
||||
en: Appearance
|
||||
settings.category.terminal:
|
||||
zh-CN: 终端
|
||||
en: Terminal
|
||||
settings.category.highlight:
|
||||
zh-CN: 关键字高亮
|
||||
en: Highlighting
|
||||
settings.category.shortcuts:
|
||||
zh-CN: 键盘快捷键
|
||||
en: Keyboard shortcuts
|
||||
settings.category.external_cli:
|
||||
zh-CN: 外部 CLI
|
||||
en: External CLI
|
||||
settings.category.application:
|
||||
zh-CN: 应用
|
||||
en: General
|
||||
settings.category.about:
|
||||
zh-CN: 关于
|
||||
en: About
|
||||
|
||||
settings.appearance.interface:
|
||||
zh-CN: 常规
|
||||
en: Interface
|
||||
settings.appearance.language:
|
||||
zh-CN: 界面语言
|
||||
en: Language
|
||||
settings.appearance.mode:
|
||||
zh-CN: 应用外观
|
||||
en: Appearance
|
||||
settings.appearance.theme:
|
||||
zh-CN: 主题
|
||||
en: Theme
|
||||
settings.appearance.theme_description:
|
||||
zh-CN: 浅色外观时用左栏选中的主题,深色外观时用右栏的。
|
||||
en: The light appearance uses the theme chosen on the left, the dark appearance the one on the right.
|
||||
|
||||
settings.terminal.font:
|
||||
zh-CN: 字体配置
|
||||
en: Font
|
||||
settings.terminal.family:
|
||||
zh-CN: 字体
|
||||
en: Font family
|
||||
settings.terminal.family_description:
|
||||
zh-CN: 只列出等宽字体。
|
||||
en: Only monospaced fonts are listed.
|
||||
settings.terminal.size:
|
||||
zh-CN: 字号
|
||||
en: Font size
|
||||
settings.terminal.size_description:
|
||||
zh-CN: 以像素计。
|
||||
en: In pixels.
|
||||
settings.terminal.line_height:
|
||||
zh-CN: 行高
|
||||
en: Line height
|
||||
settings.terminal.line_height_description:
|
||||
zh-CN: 字号的倍数。
|
||||
en: A multiple of the font size.
|
||||
settings.terminal.preview_keywords:
|
||||
zh-CN: 预览,字体,字号,行高
|
||||
en: preview,font,size,line height
|
||||
|
||||
settings.notifications.title:
|
||||
zh-CN: 通知
|
||||
en: Notifications
|
||||
settings.notifications.description:
|
||||
zh-CN: ShellRS 不在前台时发系统通知;在前台但终端在别的标签时,在窗口右上角提示,点一下切过去。
|
||||
en: When ShellRS is in the background, you get a system notification. When it’s in front but the terminal is in another tab, a notice appears at the top right of the window; click it to go there.
|
||||
settings.notifications.programs:
|
||||
zh-CN: 程序发送的通知
|
||||
en: Notifications from programs
|
||||
settings.notifications.programs_description:
|
||||
zh-CN: 远程或本地程序用 OSC 9 / 777 请求的通知,例如 printf '\e]9;完成\a'。
|
||||
en: Notifications that remote or local programs ask for with OSC 9 / 777, for example printf '\e]9;Done\a'.
|
||||
settings.notifications.bell:
|
||||
zh-CN: 响铃时通知
|
||||
en: Notify on bell
|
||||
settings.notifications.bell_description:
|
||||
zh-CN: 终端就在当前标签时不提示。
|
||||
en: Not when the terminal is the current tab.
|
||||
|
||||
settings.highlight.options:
|
||||
zh-CN: 常规
|
||||
en: Options
|
||||
settings.highlight.enabled:
|
||||
zh-CN: 启用关键字高亮
|
||||
en: Highlight keywords
|
||||
settings.highlight.enabled_description:
|
||||
zh-CN: 全屏程序(vim、less、tmux 等)里不高亮。
|
||||
en: Not in full-screen programs such as vim, less and tmux.
|
||||
settings.highlight.preview_description:
|
||||
zh-CN: 使用当前终端主题
|
||||
en: In the current terminal theme
|
||||
settings.highlight.preview_keywords:
|
||||
zh-CN: 预览,高亮
|
||||
en: preview,highlight
|
||||
settings.highlight.rules:
|
||||
zh-CN: 规则(%{count})
|
||||
en: Rules (%{count})
|
||||
settings.highlight.rules_description:
|
||||
zh-CN: 规则按从上到下应用,勾选「通知」的规则在看不到终端时弹出提醒。
|
||||
en: Rules apply from top to bottom. A rule with Notify checked alerts you when its terminal isn’t in view.
|
||||
settings.highlight.rules_keywords:
|
||||
zh-CN: 规则,正则,关键字,高亮,颜色,备注,通知,排序
|
||||
en: rule,regex,keyword,highlight,color,note,notify,order
|
||||
|
||||
settings.window.title:
|
||||
zh-CN: 窗口
|
||||
en: Window
|
||||
settings.window.remember_size:
|
||||
zh-CN: 记住窗口大小
|
||||
en: Remember window size
|
||||
settings.window.remember_size_description:
|
||||
zh-CN: 下次启动时恢复上次关闭时的窗口大小和最大化状态。
|
||||
en: The next launch restores the window’s size, maximized or not, as it was when it closed.
|
||||
settings.window.remember_position:
|
||||
zh-CN: 记住窗口位置
|
||||
en: Remember window position
|
||||
settings.window.remember_position_description:
|
||||
zh-CN: 下次启动时把窗口放回上次所在的屏幕和位置;那块屏幕不在时放在主屏幕中间。
|
||||
en: The next launch puts the window back on the same screen and in the same place. If that screen is gone, it opens in the middle of the main screen.
|
||||
|
||||
settings.cli.access:
|
||||
zh-CN: 访问控制
|
||||
en: Access
|
||||
settings.cli.enabled:
|
||||
zh-CN: 启用外部 CLI
|
||||
en: Enable external CLI
|
||||
settings.cli.enabled_description:
|
||||
zh-CN: 开启后,本机当前用户下的程序(如 AI Agent)可以通过 shellrs 命令,在已保存的主机上执行命令、传输和同步文件,并查看、新建、修改和删除主机与凭据,无需知道密码。
|
||||
en: When on, programs running as you on this computer, such as AI agents, can use the shellrs command to run commands and transfer and sync files on saved hosts, and to view, create, change and delete hosts and credentials, without knowing any password.
|
||||
settings.cli.binary:
|
||||
zh-CN: CLI 二进制
|
||||
en: Command
|
||||
settings.cli.binary_description:
|
||||
zh-CN: 把 shellrs 命令加入 PATH,方便在终端和 Agent 中直接调用。
|
||||
en: Puts the shellrs command on the PATH so terminals and agents can call it directly.
|
||||
settings.cli.binary_description_windows:
|
||||
zh-CN: 把 shellrs 命令加入当前用户的 PATH,方便在终端和 Agent 中直接调用。重新打开的终端和 Agent 才能找到它。
|
||||
en: Puts the shellrs command on your user PATH so terminals and agents can call it directly. Terminals and agents find it once reopened.
|
||||
settings.cli.skills_description:
|
||||
zh-CN: 安装 shellrs skill,让外部 Agent 在连接服务器、执行远程命令和传输文件时使用 shellrs CLI。可安装到通用目录或指定 Agent 的专用目录。
|
||||
en: Install the shellrs skill so agents use the shellrs CLI to connect to servers, run remote commands and transfer files. Install it to the shared folder or to a particular agent’s own folder.
|
||||
settings.cli.status:
|
||||
zh-CN: 状态:
|
||||
en: "Status:"
|
||||
settings.cli.checking:
|
||||
zh-CN: 正在检查…
|
||||
en: Checking…
|
||||
settings.cli.binary_unsupported:
|
||||
zh-CN: 此系统暂不支持安装 shellrs 命令
|
||||
en: The shellrs command can’t be installed on this system yet
|
||||
settings.cli.skill_unsupported:
|
||||
zh-CN: 此系统暂不支持安装
|
||||
en: Can’t be installed on this system yet
|
||||
settings.cli.missing:
|
||||
zh-CN: 未安装(%{path})
|
||||
en: Not installed (%{path})
|
||||
settings.cli.outdated:
|
||||
zh-CN: 已安装旧版本(%{path})
|
||||
en: An older version is installed (%{path})
|
||||
settings.cli.binary_stale:
|
||||
zh-CN: "%{path} 指向 %{target},已失效或不是这个 ShellRS"
|
||||
en: "%{path} points to %{target}, which is gone or isn’t this ShellRS"
|
||||
settings.cli.binary_occupied_by:
|
||||
zh-CN: "%{path} 已被其他程序占用(指向 %{target})"
|
||||
en: "%{path} is taken by another program (it points to %{target})"
|
||||
settings.cli.binary_occupied:
|
||||
zh-CN: "%{path} 已被其他程序占用"
|
||||
en: "%{path} is taken by another program"
|
||||
settings.cli.binary_not_on_path:
|
||||
zh-CN: "%{path} 所在的文件夹不在 PATH 中"
|
||||
en: The folder of %{path} isn’t on the PATH
|
||||
settings.cli.binary_unavailable:
|
||||
zh-CN: 找不到 shellrs-cli.exe:它应在 ShellRS 程序旁边(开发时请先 cargo build)
|
||||
en: Can’t find shellrs-cli.exe. It belongs next to the ShellRS program (in development, run cargo build first).
|
||||
settings.cli.installed_at:
|
||||
zh-CN: 已安装于
|
||||
en: Installed at
|
||||
settings.cli.installed_at_path:
|
||||
zh-CN: 已安装于 %{path}
|
||||
en: Installed at %{path}
|
||||
settings.cli.install:
|
||||
zh-CN: 安装
|
||||
en: Install
|
||||
settings.cli.reinstall:
|
||||
zh-CN: 重新安装
|
||||
en: Reinstall
|
||||
settings.cli.update:
|
||||
zh-CN: 更新
|
||||
en: Update
|
||||
settings.cli.remove:
|
||||
zh-CN: 移除
|
||||
en: Remove
|
||||
settings.cli.binary_keywords:
|
||||
zh-CN: CLI,PATH,shellrs,命令,安装
|
||||
en: CLI,PATH,shellrs,command,install
|
||||
settings.cli.check:
|
||||
zh-CN: 检查状态
|
||||
en: Check status
|
||||
settings.cli.copy_skills:
|
||||
zh-CN: 复制 skills
|
||||
en: Copy skills
|
||||
settings.cli.skills_keywords:
|
||||
zh-CN: skill,Agent,检查,复制
|
||||
en: skill,agent,check,copy
|
||||
|
||||
settings.about.updates:
|
||||
zh-CN: 应用更新
|
||||
en: Updates
|
||||
settings.about.version:
|
||||
zh-CN: 当前版本
|
||||
en: Current version
|
||||
settings.about.version_keywords:
|
||||
zh-CN: 当前版本,检查更新,更新,升级,版本,下载
|
||||
en: current version,check for updates,update,upgrade,version,download
|
||||
settings.about.check:
|
||||
zh-CN: 检查更新
|
||||
en: Check for updates
|
||||
settings.about.restart:
|
||||
zh-CN: 重启并安装…
|
||||
en: Restart to update…
|
||||
settings.about.download_page:
|
||||
zh-CN: 前往下载页
|
||||
en: Open download page
|
||||
settings.about.retry_download:
|
||||
zh-CN: 重试下载
|
||||
en: Retry download
|
||||
settings.about.download:
|
||||
zh-CN: 下载更新
|
||||
en: Download update
|
||||
settings.about.local_build:
|
||||
zh-CN: 本机构建
|
||||
en: Local build
|
||||
# The version's tooltip: the build, then the system it is for.
|
||||
settings.about.build:
|
||||
zh-CN: "%{build}(%{platform})"
|
||||
en: "%{build} (%{platform})"
|
||||
settings.about.channel:
|
||||
zh-CN: 更新渠道
|
||||
en: Update channel
|
||||
settings.about.channel_description:
|
||||
zh-CN: 稳定版适合日常使用,Beta 可提前接收测试版本。
|
||||
en: Stable is for everyday use. Beta gets test versions early.
|
||||
settings.about.automatic:
|
||||
zh-CN: 自动升级
|
||||
en: Automatic updates
|
||||
settings.about.automatic_description:
|
||||
zh-CN: 检测到新版本后自动下载,下载完成后点击「重启并安装」完成更新。
|
||||
en: A new version downloads by itself. Once it’s ready, click Restart to update.
|
||||
settings.about.privacy:
|
||||
zh-CN: 隐私
|
||||
en: Privacy
|
||||
settings.about.analytics:
|
||||
zh-CN: 发送匿名使用统计
|
||||
en: Send anonymous usage statistics
|
||||
settings.about.analytics_description:
|
||||
zh-CN: 发送应用版本、操作系统/架构,用于评估安装量、版本分布,用于改进 ShellRS。不会发送任何主机、凭据、命令或会话内容。
|
||||
en: Sends the app version, operating system and architecture, to count installs and versions and to improve ShellRS. No host, credential, command or session content is ever sent.
|
||||
settings.about.website:
|
||||
zh-CN: 官网
|
||||
en: Website
|
||||
settings.about.website_description:
|
||||
zh-CN: 功能介绍、下载和更新内容。
|
||||
en: Features, downloads and what’s new.
|
||||
|
||||
settings.highlight.pattern_placeholder:
|
||||
zh-CN: 例如:ERROR|WARN 或 failed\s+\d+
|
||||
en: e.g. ERROR|WARN or failed\s+\d+
|
||||
settings.highlight.note_placeholder:
|
||||
zh-CN: 例如:错误告警
|
||||
en: e.g. Error alert
|
||||
settings.highlight.add_rule:
|
||||
zh-CN: 添加规则
|
||||
en: Add rule
|
||||
settings.highlight.column_enabled:
|
||||
zh-CN: 启用
|
||||
en: "On"
|
||||
settings.highlight.column_pattern:
|
||||
zh-CN: 正则表达式
|
||||
en: Regular expression
|
||||
settings.highlight.column_note:
|
||||
zh-CN: 备注
|
||||
en: Note
|
||||
settings.highlight.column_color:
|
||||
zh-CN: 颜色
|
||||
en: Color
|
||||
settings.highlight.column_notify:
|
||||
zh-CN: 通知
|
||||
en: Notify
|
||||
settings.highlight.drag:
|
||||
zh-CN: 拖动调整顺序
|
||||
en: Drag to reorder
|
||||
settings.highlight.empty:
|
||||
zh-CN: 还没有规则
|
||||
en: No rules yet
|
||||
|
||||
settings.shortcuts.recording:
|
||||
zh-CN: 按下组合键
|
||||
en: Press a key combination
|
||||
settings.shortcuts.none:
|
||||
zh-CN: 无
|
||||
en: None
|
||||
settings.shortcuts.change:
|
||||
zh-CN: 修改快捷键
|
||||
en: Change shortcut
|
||||
settings.shortcuts.enable:
|
||||
zh-CN: 启用
|
||||
en: Enable
|
||||
settings.shortcuts.disable:
|
||||
zh-CN: 禁用
|
||||
en: Disable
|
||||
settings.shortcuts.reset:
|
||||
zh-CN: 恢复默认
|
||||
en: Restore default
|
||||
|
||||
settings.theme.keywords:
|
||||
zh-CN: 主题,终端主题,配色,颜色,浅色,深色
|
||||
en: theme,terminal theme,color scheme,color,light,dark
|
||||
settings.theme.app_follows:
|
||||
zh-CN: 界面跟随主题
|
||||
en: Match interface to theme
|
||||
settings.theme.app_follows_description:
|
||||
zh-CN: 关闭时界面用中性的黑白灰,只有终端按所选主题配色。
|
||||
en: When off, the interface stays neutral black, white and gray, and only terminals take the theme’s colors.
|
||||
@@ -0,0 +1,317 @@
|
||||
_version: 2
|
||||
|
||||
# SFTP connections, transfers and local file operations: mostly errors,
|
||||
# which the SFTP tab, the editor and the external CLI show.
|
||||
|
||||
sftp.place.home:
|
||||
zh-CN: 主目录
|
||||
en: Home
|
||||
sftp.place.desktop:
|
||||
zh-CN: 桌面
|
||||
en: Desktop
|
||||
sftp.place.documents:
|
||||
zh-CN: 文稿
|
||||
en: Documents
|
||||
sftp.place.downloads:
|
||||
zh-CN: 下载
|
||||
en: Downloads
|
||||
|
||||
sftp.error.remote_path_invalid:
|
||||
zh-CN: 远程路径不能为空或包含空字符
|
||||
en: A remote path can’t be empty or contain a null character
|
||||
sftp.error.file_name_invalid:
|
||||
zh-CN: 无效的文件名:%{name}
|
||||
en: "Invalid file name: %{name}"
|
||||
sftp.error.nothing_to_upload:
|
||||
zh-CN: 请选择要上传的文件或目录
|
||||
en: Choose files or folders to upload
|
||||
sftp.error.nothing_to_download:
|
||||
zh-CN: 请选择要下载的文件或目录
|
||||
en: Choose files or folders to download
|
||||
sftp.error.download_target_relative:
|
||||
zh-CN: 下载目标必须是绝对路径
|
||||
en: The download destination must be an absolute path
|
||||
sftp.error.file_name_not_utf8:
|
||||
zh-CN: 目录包含无法用 UTF-8 表示的文件名
|
||||
en: The folder has a file name that isn’t valid UTF-8
|
||||
sftp.error.path_not_utf8:
|
||||
zh-CN: 路径不是有效的 UTF-8
|
||||
en: The path isn’t valid UTF-8
|
||||
sftp.error.trash_failed:
|
||||
zh-CN: 无法移到废纸篓:%{error}
|
||||
en: "Couldn’t move to the Trash: %{error}"
|
||||
sftp.error.recycle_bin_failed:
|
||||
zh-CN: 无法移到废纸篓:%{error}
|
||||
en: "Couldn’t move to the Recycle Bin: %{error}"
|
||||
sftp.error.exists:
|
||||
zh-CN: 已有名为「%{name}」的项目
|
||||
en: An item named “%{name}” already exists
|
||||
sftp.error.permission_denied:
|
||||
zh-CN: 没有权限操作「%{name}」
|
||||
en: Permission denied for “%{name}”
|
||||
sftp.error.not_found:
|
||||
zh-CN: 「%{name}」不存在
|
||||
en: “%{name}” doesn’t exist
|
||||
sftp.error.item:
|
||||
zh-CN: 「%{name}」:%{error}
|
||||
en: "“%{name}”: %{error}"
|
||||
sftp.error.permissions_unsupported:
|
||||
zh-CN: 此系统不支持修改权限
|
||||
en: This system doesn’t support changing permissions
|
||||
|
||||
sftp.transfer.host_key_changed:
|
||||
zh-CN: 服务器主机指纹已改变,已停止续传
|
||||
en: The server’s host key changed, so resuming stopped
|
||||
sftp.transfer.cancelled:
|
||||
zh-CN: 传输已停止,进度已保留
|
||||
en: Transfer stopped. Its progress is kept
|
||||
|
||||
sftp.upload.source_name_not_utf8:
|
||||
zh-CN: 来源文件名不是有效的 UTF-8
|
||||
en: The source file name isn’t valid UTF-8
|
||||
sftp.upload.target_busy:
|
||||
zh-CN: 另一个传输正在上传同一目标,请稍后继续上传
|
||||
en: Another transfer is uploading to the same destination. Resume this upload later
|
||||
sftp.upload.parent_missing:
|
||||
zh-CN: 父目录未创建,无法上传
|
||||
en: Can’t upload because the parent folder wasn’t created
|
||||
sftp.upload.failed:
|
||||
zh-CN: 无法上传:%{error}
|
||||
en: "Couldn’t upload: %{error}"
|
||||
sftp.upload.folder_in_the_way:
|
||||
zh-CN: 目录与文件类型冲突,不会删除远程目录
|
||||
en: A remote folder has this name, and it won’t be deleted to make way for a file
|
||||
sftp.upload.exists.one:
|
||||
en: A remote item with this name exists (%{count} byte, modified %{modified}). Overwriting replaces this file or link.
|
||||
sftp.upload.exists.other:
|
||||
zh-CN: 远程项目已存在(%{count} 字节,修改时间 %{modified})。覆盖会替换这个文件或链接。
|
||||
en: A remote item with this name exists (%{count} bytes, modified %{modified}). Overwriting replaces this file or link.
|
||||
sftp.upload.exists_changed.one:
|
||||
en: The destination changed during the upload. A remote item with this name exists (%{count} byte, modified %{modified}). Overwriting replaces this file or link.
|
||||
sftp.upload.exists_changed.other:
|
||||
zh-CN: 目标在上传期间发生变化。远程项目已存在(%{count} 字节,修改时间 %{modified})。覆盖会替换这个文件或链接。
|
||||
en: The destination changed during the upload. A remote item with this name exists (%{count} bytes, modified %{modified}). Overwriting replaces this file or link.
|
||||
sftp.upload.target_gone:
|
||||
zh-CN: 原目标在上传期间已被删除或移走。是否继续发布此文件?
|
||||
en: The original destination was deleted or moved during the upload. Put this file in its place anyway?
|
||||
sftp.upload.source_now_folder:
|
||||
zh-CN: 来源已变为目录,请丢弃旧文件进度后重新上传,或跳过。
|
||||
en: The source is now a folder. Discard the old file’s progress and upload again, or skip it.
|
||||
sftp.upload.target_not_folder:
|
||||
zh-CN: 目标已存在且不是目录,不会跟随或替换链接
|
||||
en: The destination exists and isn’t a folder. Links aren’t followed or replaced
|
||||
sftp.upload.special_file:
|
||||
zh-CN: 不支持上传设备、套接字或其他特殊文件
|
||||
en: Devices, sockets and other special files can’t be uploaded
|
||||
sftp.upload.resume_found:
|
||||
zh-CN: 发现未完成的 .filepart 文件。续传会按它的现有大小跳过本地文件前缀,请确认本地文件仍是同一版本。
|
||||
en: Found an unfinished .filepart file. Resuming skips as much of the local file as it already holds, so make sure the local file is the same version.
|
||||
sftp.upload.link_target_not_utf8:
|
||||
zh-CN: 链接目标不是有效的 UTF-8
|
||||
en: The link target isn’t valid UTF-8
|
||||
sftp.upload.record_without_link:
|
||||
zh-CN: 续传记录中缺少链接目标
|
||||
en: The resume record has no link target
|
||||
sftp.upload.temporary_link_changed:
|
||||
zh-CN: 临时链接已变化,无法继续上传
|
||||
en: The temporary link changed, so the upload can’t resume
|
||||
sftp.upload.record_without_source:
|
||||
zh-CN: 续传记录中缺少来源文件信息
|
||||
en: The resume record has no details of the source file
|
||||
sftp.upload.temporary_kind_changed:
|
||||
zh-CN: 远端临时文件类型已变化
|
||||
en: The remote temporary file is no longer a file
|
||||
sftp.upload.partial_too_large:
|
||||
zh-CN: 远端续传偏移大于本地文件
|
||||
en: The remote partial file is larger than the local file
|
||||
sftp.upload.pipeline_ended:
|
||||
zh-CN: 上传写入流水线意外结束
|
||||
en: The upload stopped writing unexpectedly
|
||||
sftp.upload.publish_unconfirmed:
|
||||
zh-CN: 无法确认文件发布结果,已保留恢复文件;请检查远端目录
|
||||
en: Couldn’t confirm the file was put in place. The recovery files are kept; check the remote folder
|
||||
sftp.upload.temporary_changed:
|
||||
zh-CN: 临时文件大小或类型已变化,未替换目标
|
||||
en: The temporary file’s size or type changed, so the destination wasn’t replaced
|
||||
sftp.upload.target_created:
|
||||
zh-CN: 替换期间目标被其他操作创建,已保留目标、备份和临时文件
|
||||
en: Something else created the destination during the replace. The destination, backup and temporary file are kept
|
||||
sftp.upload.backup_changed:
|
||||
zh-CN: 恢复备份已变化,停止替换
|
||||
en: The recovery backup changed, so the replace stopped
|
||||
sftp.upload.backup_changed_after_publish:
|
||||
zh-CN: 备份已变化,未清理;上传的目标文件已发布
|
||||
en: The backup changed, so it wasn’t cleaned up. The uploaded file is in place
|
||||
sftp.upload.backup_and_target:
|
||||
zh-CN: 存在恢复备份和目标文件,请先检查远端目录;未删除任何文件
|
||||
en: Both a recovery backup and the destination file exist. Check the remote folder first; nothing was deleted
|
||||
|
||||
sftp.download.name_invalid:
|
||||
zh-CN: 名称「%{name}」在本机无效
|
||||
en: The name “%{name}” isn’t valid on this computer
|
||||
sftp.download.source_missing:
|
||||
zh-CN: 远程项目不存在
|
||||
en: The remote item doesn’t exist
|
||||
sftp.download.target_busy:
|
||||
zh-CN: 另一个传输正在下载到同一位置,请稍后继续下载
|
||||
en: Another transfer is downloading to the same place. Resume this download later
|
||||
sftp.download.parent_missing:
|
||||
zh-CN: 父目录未创建,无法下载
|
||||
en: Can’t download because the parent folder wasn’t created
|
||||
sftp.download.failed:
|
||||
zh-CN: 无法下载:%{error}
|
||||
en: "Couldn’t download: %{error}"
|
||||
sftp.download.target_invalid:
|
||||
zh-CN: 无效的下载目标
|
||||
en: Invalid download destination
|
||||
sftp.download.folder_in_the_way:
|
||||
zh-CN: 本地已有同名目录,不会删除它
|
||||
en: A local folder has this name, and it won’t be deleted
|
||||
sftp.download.exists.one:
|
||||
en: A local item with this name exists (%{count} byte, modified %{modified}). Overwriting replaces it.
|
||||
sftp.download.exists.other:
|
||||
zh-CN: 本地已有同名项目(%{count} 字节,修改时间 %{modified})。覆盖会替换它。
|
||||
en: A local item with this name exists (%{count} bytes, modified %{modified}). Overwriting replaces it.
|
||||
sftp.download.exists_changed.one:
|
||||
en: The destination changed during the download. A local item with this name exists (%{count} byte, modified %{modified}). Overwriting replaces it.
|
||||
sftp.download.exists_changed.other:
|
||||
zh-CN: 目标在下载期间发生变化。本地已有同名项目(%{count} 字节,修改时间 %{modified})。覆盖会替换它。
|
||||
en: The destination changed during the download. A local item with this name exists (%{count} bytes, modified %{modified}). Overwriting replaces it.
|
||||
sftp.download.file_in_the_way:
|
||||
zh-CN: 本地已有同名文件,不会替换为目录
|
||||
en: A local file has this name, and it won’t be replaced by a folder
|
||||
sftp.download.special_file:
|
||||
zh-CN: 不支持下载设备、套接字或其他特殊文件
|
||||
en: Devices, sockets and other special files can’t be downloaded
|
||||
sftp.download.partial_not_file:
|
||||
zh-CN: 临时文件 %{path} 不是普通文件
|
||||
en: The temporary file %{path} isn’t a regular file
|
||||
sftp.download.partial_too_large:
|
||||
zh-CN: 本地 .filepart 文件比远程文件大,无法续传。
|
||||
en: The local .filepart file is larger than the remote file, so the download can’t resume.
|
||||
sftp.download.source_changed:
|
||||
zh-CN: 远程文件在上次下载后已变化,无法续传。
|
||||
en: The remote file changed after the last download, so the download can’t resume.
|
||||
sftp.download.resume_found:
|
||||
zh-CN: 发现未完成的 .filepart 文件。续传会按它的现有大小跳过远程文件前缀,请确认远程文件仍是同一版本。
|
||||
en: Found an unfinished .filepart file. Resuming skips as much of the remote file as it already holds, so make sure the remote file is the same version.
|
||||
sftp.download.pipeline_ended:
|
||||
zh-CN: 下载读取流水线意外结束
|
||||
en: The download stopped reading unexpectedly
|
||||
sftp.download.source_shrank:
|
||||
zh-CN: 远程文件在下载期间变短,已停止
|
||||
en: The remote file got shorter during the download, so it stopped
|
||||
sftp.download.links_unsupported:
|
||||
zh-CN: 此系统不支持创建符号链接
|
||||
en: This system can’t create symbolic links
|
||||
|
||||
sftp.journal.source_not_file:
|
||||
zh-CN: 本地来源已不再是普通文件
|
||||
en: The local source is no longer a regular file
|
||||
sftp.journal.mismatch:
|
||||
zh-CN: 续传记录与当前来源或服务器不匹配
|
||||
en: The resume record doesn’t match this source or server
|
||||
sftp.journal.temporary_invalid:
|
||||
zh-CN: 无效的续传临时路径
|
||||
en: Invalid temporary path in the resume record
|
||||
sftp.journal.replaces_folder:
|
||||
zh-CN: 续传记录不能替换目录
|
||||
en: A resume record can’t replace a folder
|
||||
sftp.journal.upload_damaged:
|
||||
zh-CN: 续传记录损坏,原文件和临时文件均已保留
|
||||
en: The resume record is damaged. The original and temporary files are both kept
|
||||
sftp.journal.download_damaged:
|
||||
zh-CN: 续传记录损坏,临时文件已保留
|
||||
en: The resume record is damaged. The temporary file is kept
|
||||
sftp.journal.unreadable:
|
||||
zh-CN: 无法读取续传记录
|
||||
en: Couldn’t read the resume record
|
||||
sftp.journal.unsaved:
|
||||
zh-CN: 无法保存续传进度
|
||||
en: Couldn’t save the resume progress
|
||||
|
||||
sftp.worker.not_connected:
|
||||
zh-CN: SFTP 未连接,请重新连接
|
||||
en: SFTP isn’t connected. Reconnect to continue
|
||||
sftp.worker.not_connected_short:
|
||||
zh-CN: SFTP 未连接
|
||||
en: SFTP isn’t connected
|
||||
sftp.worker.disconnected:
|
||||
zh-CN: SFTP 已断开
|
||||
en: SFTP disconnected
|
||||
sftp.worker.list_failed:
|
||||
zh-CN: 无法读取目录:%{error}
|
||||
en: "Couldn’t read the folder: %{error}"
|
||||
sftp.worker.busy:
|
||||
zh-CN: 已有传输批次正在处理
|
||||
en: A transfer batch is already running
|
||||
sftp.worker.local_folder_missing:
|
||||
zh-CN: 本地目录 %{path} 不存在
|
||||
en: The local folder %{path} doesn’t exist
|
||||
sftp.worker.remote_folder_missing:
|
||||
zh-CN: 远程目录 %{path} 不存在
|
||||
en: The remote folder %{path} doesn’t exist
|
||||
sftp.worker.dropped_kept:
|
||||
zh-CN: 连接中断,传输进度已保留
|
||||
en: The connection dropped. Transfer progress is kept
|
||||
sftp.worker.reconnect_in.one:
|
||||
en: Reconnecting in %{count} second (%{attempt}/3)
|
||||
sftp.worker.reconnect_in.other:
|
||||
zh-CN: 将在 %{count} 秒后重连(%{attempt}/3)
|
||||
en: Reconnecting in %{count} seconds (%{attempt}/3)
|
||||
sftp.worker.not_a_folder:
|
||||
zh-CN: 远程路径 %{path} 不是目录
|
||||
en: The remote path %{path} isn’t a folder
|
||||
sftp.worker.sync_to_root:
|
||||
zh-CN: 不能同步到根目录 /
|
||||
en: Can’t sync to the root folder /
|
||||
sftp.worker.dropped:
|
||||
zh-CN: SFTP 连接中断,请重新连接
|
||||
en: The SFTP connection dropped. Reconnect to continue
|
||||
|
||||
sftp.edit.not_file:
|
||||
zh-CN: 不是普通文件
|
||||
en: Not a regular file
|
||||
sftp.edit.too_large.one:
|
||||
en: File too large (%{count} byte)
|
||||
sftp.edit.too_large.other:
|
||||
zh-CN: 文件太大(%{count} 字节)
|
||||
en: File too large (%{count} bytes)
|
||||
sftp.edit.not_text:
|
||||
zh-CN: 不是 UTF-8 文本文件
|
||||
en: Not a UTF-8 text file
|
||||
sftp.edit.changed:
|
||||
zh-CN: 文件在打开后已被修改
|
||||
en: The file changed after it was opened
|
||||
sftp.edit.missing:
|
||||
zh-CN: 文件不存在
|
||||
en: The file doesn’t exist
|
||||
sftp.edit.shrank:
|
||||
zh-CN: 文件在读取时变短了,请重新打开
|
||||
en: The file got shorter while it was read. Open it again
|
||||
sftp.edit.gone_after_save:
|
||||
zh-CN: 保存后找不到文件
|
||||
en: The file is gone after saving
|
||||
|
||||
sftp.client.start_timeout:
|
||||
zh-CN: 启动 SFTP 超时
|
||||
en: SFTP took too long to start
|
||||
sftp.client.no_v3:
|
||||
zh-CN: 服务器未提供 SFTP v3
|
||||
en: The server doesn’t offer SFTP v3
|
||||
sftp.client.no_path:
|
||||
zh-CN: 服务器未返回目录路径
|
||||
en: The server didn’t return the folder’s path
|
||||
sftp.client.no_link_target:
|
||||
zh-CN: 无法读取链接目标
|
||||
en: Couldn’t read the link target
|
||||
sftp.client.bad_rename_reply:
|
||||
zh-CN: 服务器返回了无效的重命名响应
|
||||
en: The server sent an invalid reply to the rename
|
||||
sftp.client.ambiguous_name:
|
||||
zh-CN: 远端名称包含无法可靠表示的字符,已停止操作
|
||||
en: A remote name has characters that can’t be shown reliably, so the operation stopped
|
||||
|
||||
sftp.sync.delete_failed:
|
||||
zh-CN: 无法删除:%{error}
|
||||
en: "Couldn’t delete: %{error}"
|
||||
@@ -0,0 +1,36 @@
|
||||
_version: 2
|
||||
|
||||
shared.tab.unsaved:
|
||||
zh-CN: 未保存
|
||||
en: Unsaved changes
|
||||
shared.host_mark.unknown_os:
|
||||
zh-CN: 未探测到系统
|
||||
en: System not detected
|
||||
shared.duration.days.one:
|
||||
en: "%{count} day %{clock}"
|
||||
shared.duration.days.other:
|
||||
zh-CN: "%{count} 天 %{clock}"
|
||||
en: "%{count} days %{clock}"
|
||||
|
||||
shared.rename_tab.title:
|
||||
zh-CN: 重命名标签
|
||||
en: Rename tab
|
||||
shared.rename_tab.name:
|
||||
zh-CN: 标签名称
|
||||
en: Tab name
|
||||
shared.rename_tab.default:
|
||||
zh-CN: 留空则使用「%{title}」。
|
||||
en: Leave empty to use “%{title}”.
|
||||
|
||||
shared.tab_menu.close_left:
|
||||
zh-CN: 关闭左侧
|
||||
en: Close to the left
|
||||
shared.tab_menu.close_right:
|
||||
zh-CN: 关闭右侧
|
||||
en: Close to the right
|
||||
shared.tab_menu.close_others:
|
||||
zh-CN: 关闭其他
|
||||
en: Close others
|
||||
shared.tab_menu.close_all:
|
||||
zh-CN: 关闭全部
|
||||
en: Close all
|
||||
@@ -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
|
||||
@@ -0,0 +1,369 @@
|
||||
_version: 2
|
||||
|
||||
# Connecting
|
||||
ssh.connect.failed:
|
||||
zh-CN: 无法建立 SSH 连接,请检查地址、端口和主机密钥
|
||||
en: Couldn’t establish an SSH connection (check the address, port and host key)
|
||||
ssh.connect.cancelled:
|
||||
zh-CN: 连接已取消
|
||||
en: Connection cancelled
|
||||
|
||||
# Jump hosts
|
||||
ssh.jump.deleted:
|
||||
zh-CN: 第 %{number} 台跳板主机已被删除,请编辑这台主机的连接方式
|
||||
en: Jump host %{number} was deleted. Edit how this host connects
|
||||
ssh.jump.failed:
|
||||
zh-CN: 跳板主机「%{name}」:%{reason}
|
||||
en: "Jump host “%{name}”: %{reason}"
|
||||
ssh.jump.none:
|
||||
zh-CN: 没有可用的跳板主机
|
||||
en: No jump hosts to connect through
|
||||
ssh.jump.forward.connect_failed:
|
||||
zh-CN: 跳板主机「%{name}」无法连接到 %{host}:%{port}:连接失败
|
||||
en: "Jump host “%{name}” couldn’t connect to %{host}:%{port}: connection failed"
|
||||
ssh.jump.forward.prohibited:
|
||||
zh-CN: 跳板主机「%{name}」无法连接到 %{host}:%{port}:服务器不允许端口转发
|
||||
en: "Jump host “%{name}” couldn’t connect to %{host}:%{port}: the server doesn’t allow port forwarding"
|
||||
ssh.jump.forward.channel_failed:
|
||||
zh-CN: 跳板主机「%{name}」无法连接到 %{host}:%{port}:无法打开转发通道
|
||||
en: "Jump host “%{name}” couldn’t connect to %{host}:%{port}: couldn’t open a forwarding channel"
|
||||
|
||||
# Proxy
|
||||
ssh.proxy.connect_failed:
|
||||
zh-CN: 无法连接代理服务器 %{host}:%{port}:%{reason}
|
||||
en: "Couldn’t connect to the proxy at %{host}:%{port}: %{reason}"
|
||||
ssh.proxy.bad_credentials:
|
||||
zh-CN: 代理服务器拒绝了用户名或密码
|
||||
en: The proxy rejected the username or password
|
||||
ssh.proxy.auth_required:
|
||||
zh-CN: 代理服务器要求认证,请填写代理的用户名和密码
|
||||
en: The proxy requires authentication. Enter a username and password for it
|
||||
ssh.proxy.forbidden:
|
||||
zh-CN: 代理服务器不允许连接到 %{target}
|
||||
en: The proxy doesn’t allow connections to %{target}
|
||||
ssh.proxy.reply_too_long:
|
||||
zh-CN: 代理服务器的回复过长
|
||||
en: The proxy’s reply is too long
|
||||
ssh.proxy.closed:
|
||||
zh-CN: 代理服务器没有回复就关闭了连接
|
||||
en: The proxy closed the connection without replying
|
||||
ssh.proxy.http.bad_gateway:
|
||||
zh-CN: 代理服务器无法连接到 %{target}:连接失败(HTTP 502)
|
||||
en: "The proxy couldn’t connect to %{target}: connection failed (HTTP 502)"
|
||||
ssh.proxy.http.gateway_timeout:
|
||||
zh-CN: 代理服务器无法连接到 %{target}:连接超时(HTTP 504)
|
||||
en: "The proxy couldn’t connect to %{target}: timed out (HTTP 504)"
|
||||
ssh.proxy.http.refused:
|
||||
zh-CN: 代理服务器拒绝了连接:%{status}
|
||||
en: "The proxy refused the connection: %{status}"
|
||||
ssh.proxy.http.unrecognized:
|
||||
zh-CN: 代理服务器的回复无法识别,请确认它是 HTTP 代理
|
||||
en: The proxy’s reply wasn’t understood. Check that it’s an HTTP proxy
|
||||
ssh.proxy.socks.unrecognized:
|
||||
zh-CN: 代理服务器的回复无法识别,请确认它是 SOCKS5 代理
|
||||
en: The proxy’s reply wasn’t understood. Check that it’s a SOCKS5 proxy
|
||||
ssh.proxy.socks.credentials_too_long:
|
||||
zh-CN: 代理的用户名或密码太长,SOCKS5 最多 255 个字节
|
||||
en: The proxy username or password is too long. SOCKS5 allows at most 255 bytes
|
||||
ssh.proxy.socks.no_password_auth:
|
||||
zh-CN: 代理服务器不接受用户名和密码认证
|
||||
en: The proxy doesn’t accept username and password authentication
|
||||
ssh.proxy.socks.host_too_long:
|
||||
zh-CN: 主机名太长,SOCKS5 最多 255 个字节
|
||||
en: The hostname is too long. SOCKS5 allows at most 255 bytes
|
||||
ssh.proxy.socks.rules_forbid:
|
||||
zh-CN: 代理服务器的规则不允许连接到 %{target}
|
||||
en: The proxy’s rules don’t allow connections to %{target}
|
||||
ssh.proxy.socks.network_unreachable:
|
||||
zh-CN: 代理服务器无法连接到 %{target}:网络不可达
|
||||
en: "The proxy couldn’t connect to %{target}: network unreachable"
|
||||
ssh.proxy.socks.host_unreachable:
|
||||
zh-CN: 代理服务器无法连接到 %{target}:主机不可达
|
||||
en: "The proxy couldn’t connect to %{target}: host unreachable"
|
||||
ssh.proxy.socks.refused:
|
||||
zh-CN: 代理服务器无法连接到 %{target}:连接被拒绝
|
||||
en: "The proxy couldn’t connect to %{target}: connection refused"
|
||||
ssh.proxy.socks.timeout:
|
||||
zh-CN: 代理服务器无法连接到 %{target}:连接超时
|
||||
en: "The proxy couldn’t connect to %{target}: timed out"
|
||||
ssh.proxy.socks.internal:
|
||||
zh-CN: 代理服务器无法连接到 %{target}:代理服务器内部错误
|
||||
en: "The proxy couldn’t connect to %{target}: internal proxy error"
|
||||
ssh.proxy.socks.no_connect:
|
||||
zh-CN: 代理服务器不支持 CONNECT 命令
|
||||
en: The proxy doesn’t support the CONNECT command
|
||||
ssh.proxy.socks.address_type:
|
||||
zh-CN: 代理服务器不支持这种地址类型
|
||||
en: The proxy doesn’t support this type of address
|
||||
|
||||
# A credential a connection without anyone to ask needed
|
||||
ssh.missing.password_rejected:
|
||||
zh-CN: 用户名或密码错误
|
||||
en: Incorrect username or password
|
||||
ssh.missing.password:
|
||||
zh-CN: 未填写密码
|
||||
en: No password entered
|
||||
ssh.missing.passphrase_rejected:
|
||||
zh-CN: 私钥口令错误
|
||||
en: Incorrect private key passphrase
|
||||
ssh.missing.passphrase:
|
||||
zh-CN: 私钥已加密,未填写口令
|
||||
en: The private key is encrypted and no passphrase was entered
|
||||
ssh.missing.keyboard_interactive:
|
||||
zh-CN: 服务器要求键盘交互式认证,无法在测试中完成
|
||||
en: The server asks for keyboard-interactive authentication, which a test can’t complete
|
||||
ssh.password_wanted:
|
||||
zh-CN: 服务器要求密码,「无密码」不会询问;请改用「密码」或「使用凭据」
|
||||
en: The server wants a password, and “No password” doesn’t ask for one. Choose “Password” or “Credential” instead
|
||||
|
||||
# Questions
|
||||
ssh.prompt.view_closed:
|
||||
zh-CN: 连接视图已关闭
|
||||
en: The tab for this connection was closed
|
||||
ssh.prompt.request_cancelled:
|
||||
zh-CN: 认证请求已取消
|
||||
en: Authentication request cancelled
|
||||
|
||||
# Known hosts
|
||||
ssh.known_hosts.changed_while_saving:
|
||||
zh-CN: 保存主机密钥时发现信任文件已发生变化
|
||||
en: The known hosts file changed while the host key was being saved
|
||||
ssh.known_hosts.write_failed:
|
||||
zh-CN: 无法写入主机信任文件:%{path}
|
||||
en: "Couldn’t write to the known hosts file: %{path}"
|
||||
ssh.known_hosts.read_failed:
|
||||
zh-CN: 无法读取主机信任文件:%{path}
|
||||
en: "Couldn’t read the known hosts file: %{path}"
|
||||
ssh.known_hosts.corrupt:
|
||||
zh-CN: 主机信任文件已损坏:%{path}
|
||||
en: "The known hosts file is damaged: %{path}"
|
||||
ssh.known_hosts.unreadable:
|
||||
zh-CN: 主机信任文件已损坏或无法读取:%{path}
|
||||
en: "The known hosts file is damaged or unreadable: %{path}"
|
||||
|
||||
# Authentication
|
||||
ssh.auth.methods_unknown:
|
||||
zh-CN: 无法查询服务器支持的认证方式
|
||||
en: Couldn’t ask the server which authentication methods it supports
|
||||
ssh.auth.agent_rejected:
|
||||
zh-CN: 服务器未接受 SSH Agent 中的密钥
|
||||
en: The server didn’t accept the keys in the SSH agent
|
||||
ssh.auth.agent_no_publickey:
|
||||
zh-CN: 服务器不接受公钥登录,无法使用 SSH Agent
|
||||
en: The server doesn’t accept public key logins, so the SSH agent can’t be used
|
||||
ssh.auth.rsa_hash:
|
||||
zh-CN: 无法协商 RSA 签名算法
|
||||
en: Couldn’t agree on an RSA signature algorithm with the server
|
||||
ssh.auth.key_failed:
|
||||
zh-CN: 私钥认证失败
|
||||
en: Private key authentication failed
|
||||
ssh.auth.key_rejected:
|
||||
zh-CN: 服务器未接受指定的私钥
|
||||
en: The server didn’t accept the private key
|
||||
ssh.auth.no_key_accepted:
|
||||
zh-CN: 服务器未接受 SSH Agent 和 ~/.ssh 中的任何私钥
|
||||
en: The server accepted none of the keys in the SSH agent or ~/.ssh
|
||||
ssh.auth.password_failed:
|
||||
zh-CN: 密码认证失败
|
||||
en: Password authentication failed
|
||||
ssh.auth.saved_password_rejected:
|
||||
zh-CN: 已保存的密码被服务器拒绝,请重新输入
|
||||
en: The server rejected the saved password. Enter it again
|
||||
ssh.auth.enter_password:
|
||||
zh-CN: 请输入登录密码
|
||||
en: Enter the password to log in
|
||||
ssh.auth.prompt_title:
|
||||
zh-CN: SSH 登录
|
||||
en: SSH login
|
||||
ssh.auth.password_field:
|
||||
zh-CN: 密码
|
||||
en: Password
|
||||
ssh.auth.failed:
|
||||
zh-CN: 认证失败:服务器未接受可用的认证方式
|
||||
en: "Authentication failed: the server accepted none of the available methods"
|
||||
ssh.auth.cancelled:
|
||||
zh-CN: 认证已取消
|
||||
en: Authentication cancelled
|
||||
ssh.auth.invalid_reply:
|
||||
zh-CN: 认证回复无效
|
||||
en: Invalid authentication reply
|
||||
ssh.auth.keyboard_start_failed:
|
||||
zh-CN: 无法开始交互式认证
|
||||
en: Couldn’t start keyboard-interactive authentication
|
||||
ssh.auth.keyboard_cancelled:
|
||||
zh-CN: 交互式认证已取消
|
||||
en: Keyboard-interactive authentication cancelled
|
||||
ssh.auth.keyboard_failed:
|
||||
zh-CN: 交互式认证失败
|
||||
en: Keyboard-interactive authentication failed
|
||||
|
||||
# SSH agent
|
||||
ssh.agent.unreachable:
|
||||
zh-CN: 无法连接 SSH Agent:%{reason}
|
||||
en: "Couldn’t connect to the SSH agent: %{reason}"
|
||||
ssh.agent.empty:
|
||||
zh-CN: SSH Agent 中没有密钥,请先用 ssh-add 添加
|
||||
en: The SSH agent has no keys. Add one with ssh-add first
|
||||
ssh.agent.unsigned:
|
||||
zh-CN: SSH Agent 拒绝了签名请求
|
||||
en: The SSH agent refused to sign
|
||||
ssh.agent.hash:
|
||||
zh-CN: 无法协商 SSH Agent 签名算法
|
||||
en: Couldn’t agree on a signature algorithm for the SSH agent
|
||||
ssh.agent.no_socket:
|
||||
zh-CN: 没有设置 SSH_AUTH_SOCK
|
||||
en: SSH_AUTH_SOCK isn’t set
|
||||
ssh.agent.missing_socket:
|
||||
zh-CN: "%{path} 不存在"
|
||||
en: "%{path} doesn’t exist"
|
||||
ssh.agent.no_response:
|
||||
zh-CN: 没有响应
|
||||
en: no response
|
||||
ssh.agent.service_not_running:
|
||||
zh-CN: OpenSSH Authentication Agent 服务没有运行
|
||||
en: the OpenSSH Authentication Agent service isn’t running
|
||||
ssh.agent.unsupported:
|
||||
zh-CN: 这个平台不支持
|
||||
en: not supported on this platform
|
||||
|
||||
# Private keys
|
||||
ssh.key.not_chosen:
|
||||
zh-CN: 私钥认证需要选择私钥文件
|
||||
en: Choose a private key file to log in with a key
|
||||
ssh.key.unreadable:
|
||||
zh-CN: 无法读取私钥文件:%{path}
|
||||
en: "Couldn’t read the private key file: %{path}"
|
||||
ssh.key.saved_passphrase_rejected:
|
||||
zh-CN: 已保存的口令无法解开 %{path},请重新输入
|
||||
en: The saved passphrase doesn’t unlock %{path}. Enter it again
|
||||
ssh.key.enter_passphrase:
|
||||
zh-CN: 请输入 %{path} 的口令
|
||||
en: Enter the passphrase for %{path}
|
||||
ssh.key.prompt_title:
|
||||
zh-CN: 私钥口令
|
||||
en: Private key passphrase
|
||||
ssh.key.passphrase_field:
|
||||
zh-CN: 口令
|
||||
en: Passphrase
|
||||
ssh.key.too_many_attempts:
|
||||
zh-CN: 私钥口令错误次数过多
|
||||
en: Too many incorrect passphrases
|
||||
|
||||
# Why a login failed
|
||||
ssh.error.timeout:
|
||||
zh-CN: 连接超时
|
||||
en: Connection timed out
|
||||
ssh.error.refused:
|
||||
zh-CN: 连接被拒绝,该端口上没有服务在监听
|
||||
en: "Connection refused: nothing is listening on that port"
|
||||
ssh.error.unreachable:
|
||||
zh-CN: 主机不可达
|
||||
en: Host unreachable
|
||||
ssh.error.connect_failed:
|
||||
zh-CN: 无法连接:%{error}
|
||||
en: "Couldn’t connect: %{error}"
|
||||
ssh.error.no_common.kex:
|
||||
zh-CN: 无法建立 SSH 连接:和服务器没有共同的密钥交换算法,服务器支持的是 %{theirs}
|
||||
en: "Couldn’t establish an SSH connection: no key exchange algorithm in common with the server, which supports %{theirs}"
|
||||
ssh.error.no_common.host_key:
|
||||
zh-CN: 无法建立 SSH 连接:和服务器没有共同的主机密钥算法,服务器支持的是 %{theirs}
|
||||
en: "Couldn’t establish an SSH connection: no host key algorithm in common with the server, which supports %{theirs}"
|
||||
ssh.error.no_common.cipher:
|
||||
zh-CN: 无法建立 SSH 连接:和服务器没有共同的加密算法,服务器支持的是 %{theirs}
|
||||
en: "Couldn’t establish an SSH connection: no cipher in common with the server, which supports %{theirs}"
|
||||
ssh.error.no_common.mac:
|
||||
zh-CN: 无法建立 SSH 连接:和服务器没有共同的消息校验(MAC)算法,服务器支持的是 %{theirs}
|
||||
en: "Couldn’t establish an SSH connection: no MAC algorithm in common with the server, which supports %{theirs}"
|
||||
ssh.error.no_common.compression:
|
||||
zh-CN: 无法建立 SSH 连接:和服务器没有共同的压缩算法,服务器支持的是 %{theirs}
|
||||
en: "Couldn’t establish an SSH connection: no compression algorithm in common with the server, which supports %{theirs}"
|
||||
|
||||
# Connection test
|
||||
ssh.test.runtime_failed:
|
||||
zh-CN: 无法启动连接测试:%{error}
|
||||
en: "Couldn’t start the connection test: %{error}"
|
||||
ssh.test.unresolved:
|
||||
zh-CN: 无法解析主机 %{host}
|
||||
en: Couldn’t resolve %{host}
|
||||
ssh.test.host_key_changed:
|
||||
zh-CN: 主机密钥与已保存的不一致,已阻止连接。请先核实服务器身份
|
||||
en: The host key doesn’t match the saved one, so the connection was blocked. Verify the server’s identity first
|
||||
ssh.test.host_key_declined:
|
||||
zh-CN: 未信任该主机的密钥
|
||||
en: The host key wasn’t trusted
|
||||
|
||||
# Remote terminal
|
||||
ssh.terminal.shell_only:
|
||||
zh-CN: 这个终端经由堡垒机打开,只能使用终端本身,不能另外执行命令
|
||||
en: This terminal was opened by a bastion host and can’t run commands beside the shell
|
||||
ssh.terminal.runtime_failed:
|
||||
zh-CN: 无法启动 SSH 运行时
|
||||
en: Couldn’t start the SSH runtime
|
||||
ssh.terminal.bridge_failed:
|
||||
zh-CN: 无法启动 SSH 命令桥接线程
|
||||
en: Couldn’t start the SSH command thread
|
||||
ssh.terminal.session_failed:
|
||||
zh-CN: 无法创建 SSH 会话通道
|
||||
en: Couldn’t open an SSH session channel
|
||||
ssh.terminal.pty_refused:
|
||||
zh-CN: 服务器拒绝创建终端
|
||||
en: The server refused to create a terminal
|
||||
ssh.terminal.shell_refused:
|
||||
zh-CN: 服务器拒绝启动 Shell
|
||||
en: The server refused to start a shell
|
||||
ssh.terminal.tab_closed:
|
||||
zh-CN: 终端标签页已关闭
|
||||
en: The terminal tab was closed
|
||||
ssh.terminal.write_failed:
|
||||
zh-CN: 向远程终端写入失败
|
||||
en: Couldn’t write to the remote terminal
|
||||
ssh.terminal.resize_failed:
|
||||
zh-CN: 调整远程终端尺寸失败
|
||||
en: Couldn’t resize the remote terminal
|
||||
|
||||
# Commands: beside the shell, and the external CLI’s exec
|
||||
ssh.exec.channel_failed:
|
||||
zh-CN: 无法打开 SSH 通道
|
||||
en: Couldn’t open an SSH channel
|
||||
ssh.exec.send_failed:
|
||||
zh-CN: 无法发送命令
|
||||
en: Couldn’t send the command
|
||||
ssh.exec.refused:
|
||||
zh-CN: 服务器拒绝执行命令
|
||||
en: The server refused to run the command
|
||||
ssh.exec.timeout:
|
||||
zh-CN: 命令超时
|
||||
en: The command timed out
|
||||
ssh.exec.runtime_failed:
|
||||
zh-CN: 无法启动连接:%{error}
|
||||
en: "Couldn’t start the connection: %{error}"
|
||||
ssh.exec.command_failed:
|
||||
zh-CN: 无法执行命令:%{error}
|
||||
en: "Couldn’t run the command: %{error}"
|
||||
ssh.exec.aborted:
|
||||
zh-CN: 输出已无人接收,命令已放弃
|
||||
en: Nothing was reading the output, so the command was abandoned
|
||||
ssh.exec.disconnected:
|
||||
zh-CN: 连接在命令结束前断开
|
||||
en: The connection closed before the command finished
|
||||
ssh.exec.host_key_changed:
|
||||
zh-CN: "%{endpoint} 的主机密钥与已保存的不一致,已拒绝连接。请先核实服务器身份"
|
||||
en: The host key of %{endpoint} doesn’t match the saved one, so the connection was refused. Verify the server’s identity first
|
||||
ssh.exec.host_key_unknown:
|
||||
zh-CN: 尚未信任 %{endpoint} 的主机密钥:请先在 ShellRS 中连接一次这台主机
|
||||
en: The host key of %{endpoint} isn’t trusted yet. Connect to this host in ShellRS once first
|
||||
ssh.exec.no_saved_password:
|
||||
zh-CN: 这台主机没有保存密码:请先在 ShellRS 中连接一次这台主机并保存密码
|
||||
en: This host has no saved password. Connect to it in ShellRS once and save the password
|
||||
ssh.exec.saved_password_rejected:
|
||||
zh-CN: 保存的密码被服务器拒绝:请先在 ShellRS 中连接一次这台主机并保存密码
|
||||
en: The server rejected the saved password. Connect to this host in ShellRS once and save the right one
|
||||
ssh.exec.no_saved_passphrase:
|
||||
zh-CN: 私钥已加密,但没有保存口令:请先在 ShellRS 中连接一次这台主机并保存密码
|
||||
en: The private key is encrypted and has no saved passphrase. Connect to this host in ShellRS once and save it
|
||||
ssh.exec.saved_passphrase_rejected:
|
||||
zh-CN: 保存的私钥口令不正确:请先在 ShellRS 中连接一次这台主机并保存密码
|
||||
en: The saved private key passphrase is incorrect. Connect to this host in ShellRS once and save the right one
|
||||
ssh.exec.keyboard_interactive:
|
||||
zh-CN: 服务器要求键盘交互式认证:请先在 ShellRS 中连接一次这台主机并保存密码
|
||||
en: The server asks for keyboard-interactive authentication. Connect to this host in ShellRS once and save the password
|
||||
@@ -0,0 +1,193 @@
|
||||
_version: 2
|
||||
|
||||
# Remote terminal tab
|
||||
terminal.remote.label:
|
||||
zh-CN: "%{host} 的终端"
|
||||
en: "Terminal on %{host}"
|
||||
terminal.remote.fallback_title:
|
||||
zh-CN: 终端
|
||||
en: Terminal
|
||||
terminal.remote.sftp_tooltip:
|
||||
zh-CN: 打开 SFTP 文件浏览
|
||||
en: Browse files over SFTP
|
||||
terminal.remote.open_sftp:
|
||||
zh-CN: 打开 SFTP
|
||||
en: Open SFTP
|
||||
terminal.remote.reconnect:
|
||||
zh-CN: 重新连接
|
||||
en: Reconnect
|
||||
terminal.remote.disconnect:
|
||||
zh-CN: 断开连接
|
||||
en: Disconnect
|
||||
terminal.remote.copy_ip:
|
||||
zh-CN: 复制 IP 地址
|
||||
en: Copy IP address
|
||||
terminal.remote.copy_host_name:
|
||||
zh-CN: 复制主机名
|
||||
en: Copy hostname
|
||||
terminal.remote.rename:
|
||||
zh-CN: 重命名标签…
|
||||
en: Rename tab…
|
||||
terminal.remote.connect_in_new_tab:
|
||||
zh-CN: 在新标签页中连接
|
||||
en: Connect in new tab
|
||||
terminal.remote.edit_host:
|
||||
zh-CN: 编辑主机…
|
||||
en: Edit host…
|
||||
|
||||
# Local terminal tab
|
||||
terminal.local.title:
|
||||
zh-CN: 本地终端 %{number}
|
||||
en: Local terminal %{number}
|
||||
terminal.local.restart:
|
||||
zh-CN: 重新启动
|
||||
en: Restart
|
||||
|
||||
# Notes written into the terminal itself
|
||||
terminal.notice.disconnected:
|
||||
zh-CN: 已断开连接
|
||||
en: Disconnected
|
||||
terminal.notice.cancelled:
|
||||
zh-CN: 连接已取消
|
||||
en: Connection cancelled
|
||||
terminal.notice.exited_signal:
|
||||
zh-CN: 进程已退出:%{signal},退出码 %{code}
|
||||
en: "Process exited on signal %{signal}, exit code %{code}"
|
||||
terminal.notice.exited:
|
||||
zh-CN: 进程已退出,退出码 %{code}
|
||||
en: Process exited with code %{code}
|
||||
terminal.notice.error:
|
||||
zh-CN: 终端错误:%{error}
|
||||
en: "Terminal error: %{error}"
|
||||
|
||||
# A command put on the input line (history, snippets)
|
||||
terminal.command.not_connected:
|
||||
zh-CN: 终端没有连接
|
||||
en: The terminal isn’t connected
|
||||
terminal.command.full_screen:
|
||||
zh-CN: 终端里正在运行全屏程序,先退出它
|
||||
en: A full-screen program is running in the terminal. Quit it first
|
||||
terminal.command.multiline:
|
||||
zh-CN: 这是一条多行命令,在这个终端里只能直接执行
|
||||
en: This is a multi-line command, which this terminal can only run directly
|
||||
|
||||
# State of a terminal
|
||||
terminal.lifecycle.starting:
|
||||
zh-CN: 正在启动
|
||||
en: Starting
|
||||
terminal.lifecycle.running:
|
||||
zh-CN: 运行中
|
||||
en: Running
|
||||
terminal.lifecycle.exited:
|
||||
zh-CN: 已退出
|
||||
en: Exited
|
||||
terminal.lifecycle.failed:
|
||||
zh-CN: 启动失败
|
||||
en: Couldn’t start
|
||||
terminal.lifecycle.closing:
|
||||
zh-CN: 正在关闭
|
||||
en: Closing
|
||||
terminal.lifecycle.exited_signal:
|
||||
zh-CN: 已退出(%{signal},退出码 %{code})
|
||||
en: Exited (%{signal}, exit code %{code})
|
||||
terminal.lifecycle.exited_code:
|
||||
zh-CN: 已退出(退出码 %{code})
|
||||
en: Exited (exit code %{code})
|
||||
terminal.lifecycle.failed_with:
|
||||
zh-CN: 启动失败:%{error}
|
||||
en: "Couldn’t start: %{error}"
|
||||
|
||||
# Context menu
|
||||
terminal.menu.copy:
|
||||
zh-CN: 复制
|
||||
en: Copy
|
||||
terminal.menu.paste:
|
||||
zh-CN: 粘贴
|
||||
en: Paste
|
||||
terminal.menu.find:
|
||||
zh-CN: 查找…
|
||||
en: Find…
|
||||
terminal.menu.clear:
|
||||
zh-CN: 清屏
|
||||
en: Clear screen
|
||||
|
||||
# Links
|
||||
terminal.link.open_hint:
|
||||
zh-CN: "%{key} 单击打开"
|
||||
en: "%{key}-click to open"
|
||||
|
||||
# Find bar
|
||||
terminal.find.placeholder:
|
||||
zh-CN: 查找
|
||||
en: Find
|
||||
terminal.find.previous:
|
||||
zh-CN: 上一项(⇧↩)
|
||||
en: Previous (⇧↩)
|
||||
terminal.find.next:
|
||||
zh-CN: 下一项(↩)
|
||||
en: Next (↩)
|
||||
terminal.find.close:
|
||||
zh-CN: 关闭(Esc)
|
||||
en: Close (Esc)
|
||||
terminal.find.no_results:
|
||||
zh-CN: 无结果
|
||||
en: No results
|
||||
terminal.find.count.one:
|
||||
en: "%{count} match"
|
||||
terminal.find.count.other:
|
||||
zh-CN: "%{count} 项"
|
||||
en: "%{count} matches"
|
||||
|
||||
# Font preview in settings, for screen readers
|
||||
terminal.font.preview:
|
||||
zh-CN: "%{family} %{size} px,行高 %{line_height}"
|
||||
en: "%{family} %{size} px, line height %{line_height}"
|
||||
|
||||
# Keyword highlighting
|
||||
terminal.highlight.example.error:
|
||||
zh-CN: 错误
|
||||
en: Error
|
||||
terminal.highlight.example.warning:
|
||||
zh-CN: 警告
|
||||
en: Warning
|
||||
terminal.highlight.example.ipv4:
|
||||
zh-CN: IPv4 地址
|
||||
en: IPv4 address
|
||||
terminal.highlight.too_long:
|
||||
zh-CN: 不能超过 %{limit} 个字符
|
||||
en: Can’t be longer than %{limit} characters
|
||||
terminal.highlight.too_complex:
|
||||
zh-CN: 正则表达式过于复杂
|
||||
en: This regular expression is too complex
|
||||
terminal.highlight.invalid:
|
||||
zh-CN: 正则表达式写法有误
|
||||
en: Not a valid regular expression
|
||||
|
||||
# Local shell
|
||||
terminal.pty.open_failed:
|
||||
zh-CN: 无法创建本地 PTY
|
||||
en: Couldn’t create a local PTY
|
||||
terminal.pty.shell_failed:
|
||||
zh-CN: 无法启动系统默认 shell
|
||||
en: Couldn’t start the default shell
|
||||
terminal.pty.reader_failed:
|
||||
zh-CN: 无法读取本地 PTY
|
||||
en: Couldn’t read from the local PTY
|
||||
terminal.pty.writer_failed:
|
||||
zh-CN: 无法写入本地 PTY
|
||||
en: Couldn’t write to the local PTY
|
||||
terminal.pty.reader_thread:
|
||||
zh-CN: 无法启动 PTY 读取线程
|
||||
en: Couldn’t start the PTY reader thread
|
||||
terminal.pty.read_error:
|
||||
zh-CN: 读取 PTY 失败:%{error}
|
||||
en: "Couldn’t read from the PTY: %{error}"
|
||||
terminal.pty.write_error:
|
||||
zh-CN: 写入 PTY 失败:%{error}
|
||||
en: "Couldn’t write to the PTY: %{error}"
|
||||
terminal.pty.resize_error:
|
||||
zh-CN: 调整 PTY 尺寸失败:%{error}
|
||||
en: "Couldn’t resize the PTY: %{error}"
|
||||
terminal.pty.exit_status:
|
||||
zh-CN: 无法读取 shell 退出状态
|
||||
en: Couldn’t read the shell’s exit status
|
||||
@@ -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}"
|
||||
@@ -0,0 +1,257 @@
|
||||
_version: 2
|
||||
|
||||
# 在线升级: the status line on 设置 › 关于, the title bar button, the update
|
||||
# dialog and what goes wrong. Reasons that follow a colon start in lower
|
||||
# case in English, like the system's own errors they stand beside.
|
||||
|
||||
# `UpdateError`, always shown after 「检查更新失败:」 and the like.
|
||||
update.error.network:
|
||||
zh-CN: 无法连接更新服务器:%{reason}
|
||||
en: "couldn’t reach the update server: %{reason}"
|
||||
update.error.http:
|
||||
zh-CN: 更新服务器返回 HTTP %{status}
|
||||
en: the update server answered HTTP %{status}
|
||||
update.error.stalled:
|
||||
zh-CN: 下载长时间没有进展,已中断
|
||||
en: the download stalled and was stopped
|
||||
update.error.bad_signature:
|
||||
zh-CN: 更新清单的签名无效
|
||||
en: the update manifest’s signature isn’t valid
|
||||
update.error.bad_manifest:
|
||||
zh-CN: 无法读取更新清单:%{reason}
|
||||
en: "couldn’t read the update manifest: %{reason}"
|
||||
update.error.corrupt:
|
||||
zh-CN: 下载的文件与更新清单不符
|
||||
en: the downloaded file doesn’t match the update manifest
|
||||
update.error.no_space:
|
||||
zh-CN: 磁盘空间不足
|
||||
en: not enough disk space
|
||||
update.error.disk:
|
||||
zh-CN: 无法写入更新文件:%{reason}
|
||||
en: "couldn’t write the update file: %{reason}"
|
||||
update.error.install:
|
||||
zh-CN: 无法安装新版本:%{reason}
|
||||
en: "couldn’t install the new version: %{reason}"
|
||||
update.error.cancelled:
|
||||
zh-CN: 已取消
|
||||
en: cancelled
|
||||
|
||||
# The reasons inside those errors.
|
||||
update.feed.wrong_range:
|
||||
zh-CN: 服务器返回的续传位置不对
|
||||
en: the server resumed at the wrong position
|
||||
update.feed.cut_off:
|
||||
zh-CN: 连接在下载完成前断开
|
||||
en: the connection closed before the download finished
|
||||
update.feed.too_large:
|
||||
zh-CN: 文件过大
|
||||
en: the file is too large
|
||||
update.feed.no_urls:
|
||||
zh-CN: 没有下载地址
|
||||
en: there’s no download address
|
||||
update.feed.timed_out:
|
||||
zh-CN: 连接超时
|
||||
en: the connection timed out
|
||||
update.updater.restart_failed:
|
||||
zh-CN: 无法自动重新启动,请手动重新打开 ShellRS
|
||||
en: ShellRS couldn’t restart itself, so open it again
|
||||
update.updater.check_stopped:
|
||||
zh-CN: 检查意外中止
|
||||
en: the check stopped unexpectedly
|
||||
update.install.not_possible:
|
||||
zh-CN: 这份 ShellRS 无法自动更新
|
||||
en: this copy of ShellRS can’t update itself
|
||||
update.install.setup_missing:
|
||||
zh-CN: 找不到下载的安装程序
|
||||
en: the downloaded setup program is missing
|
||||
update.install.tool_failed:
|
||||
zh-CN: "%{program}:%{error}"
|
||||
en: "%{program}: %{error}"
|
||||
update.install.bad_signature:
|
||||
zh-CN: 新版本的签名无效或不是 ShellRS 的开发者
|
||||
en: the new version’s signature isn’t valid or isn’t from ShellRS’s developer
|
||||
update.install.no_app:
|
||||
zh-CN: 更新包里没有应用程序
|
||||
en: the update package has no app in it
|
||||
update.install.wrong_version:
|
||||
zh-CN: 更新包的版本与更新清单不符
|
||||
en: the update package’s version doesn’t match the update manifest
|
||||
|
||||
# Why this copy cannot replace itself: on 设置 › 关于 (with a full stop
|
||||
# added) and in the update dialog.
|
||||
update.unsupported.development_build:
|
||||
zh-CN: 开发构建,不检查更新
|
||||
en: Development builds don’t check for updates
|
||||
update.unsupported.translocated:
|
||||
zh-CN: ShellRS 不在“应用程序”文件夹中,无法自动更新。请把它移到“应用程序”文件夹后重新打开
|
||||
en: ShellRS isn’t in the Applications folder, so it can’t update itself. Move it to Applications, then open it again
|
||||
update.unsupported.disk_image:
|
||||
zh-CN: ShellRS 正在从磁盘映像运行,无法自动更新。请先把它拖到“应用程序”文件夹
|
||||
en: ShellRS is running from its disk image, so it can’t update itself. Drag it to the Applications folder first
|
||||
update.unsupported.read_only:
|
||||
zh-CN: 没有权限写入 %{folder},无法自动更新
|
||||
en: ShellRS can’t write to %{folder}, so it can’t update itself
|
||||
update.unsupported.not_bundled:
|
||||
zh-CN: ShellRS 不是从应用程序包运行的,无法自动更新
|
||||
en: ShellRS isn’t running from an app bundle, so it can’t update itself
|
||||
update.unsupported.not_installed:
|
||||
zh-CN: 这份 ShellRS 不是用安装程序安装的,无法自动更新
|
||||
en: This copy of ShellRS wasn’t installed by the setup program, so it can’t update itself
|
||||
update.unsupported.not_appimage:
|
||||
zh-CN: 这份 ShellRS 不是 AppImage,请用安装它的方式更新
|
||||
en: This copy of ShellRS isn’t an AppImage. Update it the way you installed it
|
||||
update.unsupported.platform:
|
||||
zh-CN: ShellRS 没有为这个系统提供更新
|
||||
en: ShellRS has no updates for this system
|
||||
|
||||
# The line under 当前版本 on 设置 › 关于.
|
||||
update.status.unsupported:
|
||||
zh-CN: "%{reason}。"
|
||||
en: "%{reason}."
|
||||
update.status.off:
|
||||
zh-CN: 不检查更新。
|
||||
en: Not checking for updates.
|
||||
update.status.idle:
|
||||
zh-CN: 尚未检查更新。
|
||||
en: Not checked for updates yet.
|
||||
update.status.checking:
|
||||
zh-CN: 正在检查更新…
|
||||
en: Checking for updates…
|
||||
update.status.up_to_date:
|
||||
zh-CN: 当前已是最新版本。
|
||||
en: ShellRS is up to date.
|
||||
update.status.available_manual:
|
||||
zh-CN: 发现新版本 %{version},需要从官网下载安装。
|
||||
en: ShellRS %{version} is available. Download it from the website to install it.
|
||||
update.status.available_unsupported:
|
||||
zh-CN: 发现新版本 %{version}。%{reason}。
|
||||
en: ShellRS %{version} is available. %{reason}.
|
||||
update.status.available:
|
||||
zh-CN: 发现新版本 %{version}。
|
||||
en: ShellRS %{version} is available.
|
||||
update.status.downloading:
|
||||
zh-CN: 正在下载新版本 %{version}… %{percent}%
|
||||
en: Downloading ShellRS %{version}… %{percent}%
|
||||
update.status.verifying:
|
||||
zh-CN: 正在校验新版本 %{version}…
|
||||
en: Verifying ShellRS %{version}…
|
||||
update.status.ready:
|
||||
zh-CN: 新版本 %{version} 已下载,重启 ShellRS 即可完成更新。
|
||||
en: ShellRS %{version} is downloaded. Restart ShellRS to finish updating.
|
||||
update.status.installing:
|
||||
zh-CN: 正在安装新版本 %{version}…
|
||||
en: Installing ShellRS %{version}…
|
||||
update.status.check_failed:
|
||||
zh-CN: 检查更新失败:%{error}
|
||||
en: "Couldn’t check for updates: %{error}"
|
||||
update.status.download_failed:
|
||||
zh-CN: 下载新版本 %{version} 失败:%{error}
|
||||
en: "Couldn’t download ShellRS %{version}: %{error}"
|
||||
update.status.install_failed:
|
||||
zh-CN: 安装新版本 %{version} 失败:%{error}
|
||||
en: "Couldn’t install ShellRS %{version}: %{error}"
|
||||
|
||||
# The title bar button's tooltip.
|
||||
update.badge.ready:
|
||||
zh-CN: 新版本 %{version} 已就绪
|
||||
en: ShellRS %{version} is ready to install
|
||||
update.badge.available:
|
||||
zh-CN: 新版本 %{version} 可用
|
||||
en: ShellRS %{version} is available
|
||||
update.badge.download_failed:
|
||||
zh-CN: 新版本 %{version} 下载失败
|
||||
en: Couldn’t download ShellRS %{version}
|
||||
update.badge.install_failed:
|
||||
zh-CN: 新版本 %{version} 安装失败
|
||||
en: Couldn’t install ShellRS %{version}
|
||||
|
||||
# The update dialog.
|
||||
update.dialog.title:
|
||||
zh-CN: ShellRS 更新
|
||||
en: ShellRS update
|
||||
update.dialog.versions_released:
|
||||
zh-CN: 当前版本 %{current} · 新版本发布于 %{day}
|
||||
en: Current version %{current} · New version released %{day}
|
||||
update.dialog.current_version:
|
||||
zh-CN: 当前版本 %{current}
|
||||
en: Current version %{current}
|
||||
update.dialog.ready:
|
||||
zh-CN: 新版本已下载,重启 ShellRS 即可完成更新。
|
||||
en: The new version is downloaded. Restart ShellRS to finish updating.
|
||||
update.dialog.downloading:
|
||||
zh-CN: 正在下载 · %{percent}%
|
||||
en: Downloading · %{percent}%
|
||||
update.dialog.verifying:
|
||||
zh-CN: 正在校验…
|
||||
en: Verifying…
|
||||
update.dialog.check_failed:
|
||||
zh-CN: 检查失败:%{error}
|
||||
en: "Couldn’t check for updates: %{error}"
|
||||
update.dialog.download_failed:
|
||||
zh-CN: 下载失败:%{error}
|
||||
en: "Couldn’t download the update: %{error}"
|
||||
update.dialog.install_failed:
|
||||
zh-CN: 安装失败:%{error}
|
||||
en: "Couldn’t install the update: %{error}"
|
||||
update.dialog.manual:
|
||||
zh-CN: 这个版本需要从官网下载安装,无法在 ShellRS 中直接更新。
|
||||
en: This version can’t be installed from within ShellRS. Download it from the website.
|
||||
update.dialog.restart:
|
||||
zh-CN: 重启并安装
|
||||
en: Restart to update
|
||||
update.dialog.download_page:
|
||||
zh-CN: 前往下载页
|
||||
en: Open download page
|
||||
update.dialog.retry:
|
||||
zh-CN: 重试
|
||||
en: Retry
|
||||
update.dialog.download:
|
||||
zh-CN: 下载
|
||||
en: Download
|
||||
update.dialog.whats_new:
|
||||
zh-CN: 查看更新内容
|
||||
en: What’s new
|
||||
|
||||
# What a restart interrupts, in the update dialog above 重启并安装.
|
||||
update.restart.remote_terminals.one:
|
||||
en: 1 remote terminal
|
||||
update.restart.remote_terminals.other:
|
||||
zh-CN: "%{count} 个远程终端"
|
||||
en: "%{count} remote terminals"
|
||||
update.restart.sftp_tabs.one:
|
||||
en: 1 SFTP tab
|
||||
update.restart.sftp_tabs.other:
|
||||
zh-CN: "%{count} 个 SFTP 标签"
|
||||
en: "%{count} SFTP tabs"
|
||||
update.restart.local_terminals.one:
|
||||
en: 1 local terminal
|
||||
update.restart.local_terminals.other:
|
||||
zh-CN: "%{count} 个本地终端"
|
||||
en: "%{count} local terminals"
|
||||
update.restart.transfers.one:
|
||||
en: 1 transfer (its progress is kept)
|
||||
update.restart.transfers.other:
|
||||
zh-CN: "%{count} 个传输(保留续传进度)"
|
||||
en: "%{count} transfers (their progress is kept)"
|
||||
update.restart.forwards.one:
|
||||
en: 1 port forward
|
||||
update.restart.forwards.other:
|
||||
zh-CN: "%{count} 条端口转发"
|
||||
en: "%{count} port forwards"
|
||||
update.restart.closes:
|
||||
zh-CN: 重启会关闭 %{closes}。
|
||||
en: Restarting closes %{closes}.
|
||||
update.restart.stops:
|
||||
zh-CN: 重启会停止 %{stops}。
|
||||
en: Restarting stops %{stops}.
|
||||
update.restart.closes_and_stops:
|
||||
zh-CN: 重启会关闭 %{closes},停止 %{stops}。
|
||||
en: Restarting closes %{closes} and stops %{stops}.
|
||||
update.restart.unsaved_files.one:
|
||||
en: Unsaved changes in 1 file will be lost.
|
||||
update.restart.unsaved_files.other:
|
||||
zh-CN: "%{count} 个文件有未保存的修改,重启后会丢失。"
|
||||
en: Unsaved changes in %{count} files will be lost.
|
||||
update.restart.and_unsaved_files:
|
||||
zh-CN: "%{note}%{unsaved}"
|
||||
en: "%{note} %{unsaved}"
|
||||
@@ -0,0 +1,257 @@
|
||||
_version: 2
|
||||
|
||||
# The title bar.
|
||||
workspace.title_bar.hosts:
|
||||
zh-CN: 主机
|
||||
en: Hosts
|
||||
workspace.title_bar.forwards:
|
||||
zh-CN: 端口转发
|
||||
en: Port forwarding
|
||||
workspace.title_bar.forwards_running:
|
||||
zh-CN: 端口转发,%{count} 条运行中
|
||||
en: Port forwarding, %{count} running
|
||||
workspace.title_bar.credentials:
|
||||
zh-CN: 凭据
|
||||
en: Credentials
|
||||
workspace.title_bar.hide_sidebar:
|
||||
zh-CN: 隐藏侧栏
|
||||
en: Hide sidebar
|
||||
workspace.title_bar.show_sidebar:
|
||||
zh-CN: 显示侧栏
|
||||
en: Show sidebar
|
||||
workspace.title_bar.quick_connect:
|
||||
zh-CN: 临时连接…
|
||||
en: Quick connect…
|
||||
workspace.title_bar.quick_connect_tooltip:
|
||||
zh-CN: 连接一台主机,不保存
|
||||
en: Connect to a host without saving it
|
||||
workspace.title_bar.local_terminal:
|
||||
zh-CN: 本地终端
|
||||
en: Local terminal
|
||||
workspace.title_bar.new_local_terminal:
|
||||
zh-CN: 新建本地终端
|
||||
en: New local terminal
|
||||
workspace.title_bar.to_light:
|
||||
zh-CN: 切换为浅色
|
||||
en: Switch to light
|
||||
workspace.title_bar.to_dark:
|
||||
zh-CN: 切换为深色
|
||||
en: Switch to dark
|
||||
|
||||
workspace.sidebar.settings:
|
||||
zh-CN: 设置
|
||||
en: Settings
|
||||
workspace.sidebar.open_settings:
|
||||
zh-CN: 打开设置
|
||||
en: Open settings
|
||||
|
||||
# The status bar.
|
||||
workspace.status.connected_to:
|
||||
zh-CN: 已连接 %{name}
|
||||
en: Connected to %{name}
|
||||
workspace.status.connecting_to:
|
||||
zh-CN: 连接中 %{name}
|
||||
en: Connecting to %{name}
|
||||
workspace.status.disconnected_from:
|
||||
zh-CN: 未连接 %{name}
|
||||
en: Not connected to %{name}
|
||||
# How the connection stands, then what went wrong.
|
||||
workspace.status.problem:
|
||||
zh-CN: "%{state}:%{problem}"
|
||||
en: "%{state}: %{problem}"
|
||||
workspace.status.local_file:
|
||||
zh-CN: 本机文件
|
||||
en: Local file
|
||||
workspace.status.local_starting:
|
||||
zh-CN: 正在启动 本地终端
|
||||
en: Local terminal starting
|
||||
workspace.status.local_running:
|
||||
zh-CN: 运行中 本地终端
|
||||
en: Local terminal running
|
||||
workspace.status.local_exited:
|
||||
zh-CN: 已退出 本地终端
|
||||
en: Local terminal exited
|
||||
workspace.status.local_failed:
|
||||
zh-CN: 启动失败 本地终端
|
||||
en: Local terminal failed to start
|
||||
workspace.status.local_closing:
|
||||
zh-CN: 正在关闭 本地终端
|
||||
en: Local terminal closing
|
||||
workspace.status.encoding:
|
||||
zh-CN: 编码 UTF-8
|
||||
en: Encoding UTF-8
|
||||
workspace.status.terminal_type:
|
||||
zh-CN: 终端 xterm-256color
|
||||
en: Terminal xterm-256color
|
||||
workspace.status.terminal_size:
|
||||
zh-CN: 终端尺寸:%{columns} 列,%{rows} 行
|
||||
en: "Terminal size: %{columns} columns, %{rows} rows"
|
||||
|
||||
# 最近连接, the start page.
|
||||
workspace.recent.title:
|
||||
zh-CN: 最近连接
|
||||
en: Recent
|
||||
workspace.recent.hint:
|
||||
zh-CN: 双击主机连接,或新建一台主机
|
||||
en: Double-click a host to connect, or create a new one
|
||||
workspace.recent.find_host:
|
||||
zh-CN: 快速连接
|
||||
en: Find a host
|
||||
workspace.recent.new_host:
|
||||
zh-CN: 新建主机…
|
||||
en: New host…
|
||||
workspace.recent.empty:
|
||||
zh-CN: 还没有连接过的主机
|
||||
en: No hosts connected yet
|
||||
workspace.recent.connected:
|
||||
zh-CN: 已连接
|
||||
en: Connected
|
||||
|
||||
workspace.copied:
|
||||
zh-CN: 已复制 %{text}
|
||||
en: Copied %{text}
|
||||
workspace.copied_id:
|
||||
zh-CN: 已复制 ID %{id}
|
||||
en: Copied ID %{id}
|
||||
workspace.link.open_failed:
|
||||
zh-CN: 无法打开链接
|
||||
en: Couldn’t open the link
|
||||
|
||||
# Questions while connecting.
|
||||
workspace.host_key.unknown_title:
|
||||
zh-CN: 首次连接此主机
|
||||
en: First connection to this host
|
||||
workspace.host_key.trust:
|
||||
zh-CN: 信任并连接
|
||||
en: Trust and connect
|
||||
workspace.host_key.changed_title:
|
||||
zh-CN: 服务器主机密钥已变更
|
||||
en: Server host key changed
|
||||
workspace.host_key.changed:
|
||||
zh-CN: "主机:%{host}:%{port}\n算法:%{algorithm}\n已保存指纹:%{old}\n服务器当前指纹:%{fingerprint}\n\n连接已阻断。请核验服务器身份后手动处理:%{path}"
|
||||
en: "Host: %{host}:%{port}\nAlgorithm: %{algorithm}\nSaved fingerprint: %{old}\nServer’s fingerprint now: %{fingerprint}\n\nThe connection was blocked. Verify the server’s identity, then sort it out by hand in %{path}"
|
||||
workspace.host_key.changed_via_jump_host:
|
||||
zh-CN: "跳板主机:%{jump_host}\n主机:%{host}:%{port}\n算法:%{algorithm}\n已保存指纹:%{old}\n服务器当前指纹:%{fingerprint}\n\n连接已阻断。请核验服务器身份后手动处理:%{path}"
|
||||
en: "Jump host: %{jump_host}\nHost: %{host}:%{port}\nAlgorithm: %{algorithm}\nSaved fingerprint: %{old}\nServer’s fingerprint now: %{fingerprint}\n\nThe connection was blocked. Verify the server’s identity, then sort it out by hand in %{path}"
|
||||
workspace.auth.title:
|
||||
zh-CN: SSH 认证
|
||||
en: SSH authentication
|
||||
workspace.auth.continue:
|
||||
zh-CN: 继续
|
||||
en: Continue
|
||||
|
||||
# 外部 CLI.
|
||||
workspace.cli.start_failed:
|
||||
zh-CN: 外部 CLI 无法启动
|
||||
en: The external CLI couldn’t start
|
||||
workspace.cli.install_command_failed:
|
||||
zh-CN: 无法安装 shellrs 命令
|
||||
en: Couldn’t install the shellrs command
|
||||
workspace.cli.remove_command_failed:
|
||||
zh-CN: 无法移除 shellrs 命令
|
||||
en: Couldn’t remove the shellrs command
|
||||
workspace.cli.install_skill_failed:
|
||||
zh-CN: 无法安装 skill
|
||||
en: Couldn’t install the skill
|
||||
workspace.cli.remove_skill_failed:
|
||||
zh-CN: 无法移除 skill
|
||||
en: Couldn’t remove the skill
|
||||
workspace.cli.skill_copied:
|
||||
zh-CN: 已复制 shellrs skill
|
||||
en: Copied the shellrs skill
|
||||
|
||||
# Changes not saved yet, which a close or a quit would lose. `files` is
|
||||
# one of the workspace.files phrases.
|
||||
workspace.files.single:
|
||||
zh-CN: “%{name}”
|
||||
en: “%{name}”
|
||||
workspace.files.pair:
|
||||
zh-CN: “%{first}”和“%{second}”
|
||||
en: “%{first}” and “%{second}”
|
||||
workspace.files.several:
|
||||
zh-CN: “%{first}”等 %{count} 个文件
|
||||
en: "%{count} files including “%{first}”"
|
||||
workspace.unsaved.quit_title:
|
||||
zh-CN: 退出 ShellRS?
|
||||
en: Quit ShellRS?
|
||||
workspace.unsaved.quit:
|
||||
zh-CN: "%{files}有未保存的修改,退出后会丢失。"
|
||||
en: Unsaved changes to %{files} will be lost.
|
||||
workspace.unsaved.discard_and_quit:
|
||||
zh-CN: 放弃修改并退出
|
||||
en: Discard changes and quit
|
||||
workspace.unsaved.close_tabs_title.one:
|
||||
en: Close 1 tab?
|
||||
workspace.unsaved.close_tabs_title.other:
|
||||
zh-CN: 关闭 %{count} 个标签?
|
||||
en: Close %{count} tabs?
|
||||
workspace.unsaved.close_tabs:
|
||||
zh-CN: "%{files}有未保存的修改,关闭后会丢失。"
|
||||
en: Unsaved changes to %{files} will be lost.
|
||||
workspace.unsaved.close_sftp_title:
|
||||
zh-CN: 关闭 SFTP 标签?
|
||||
en: Close the SFTP tab?
|
||||
workspace.unsaved.close_sftp:
|
||||
zh-CN: "%{files}有未保存的修改,关闭 SFTP 标签会一起关掉它们,修改会丢失。"
|
||||
en: Closing the SFTP tab also closes %{files}. Unsaved changes will be lost.
|
||||
workspace.unsaved.discard_and_close:
|
||||
zh-CN: 放弃修改并关闭
|
||||
en: Discard changes and close
|
||||
|
||||
# Terminal notices: the tab first, so a notice says which terminal it is
|
||||
# from.
|
||||
workspace.notice.titled:
|
||||
zh-CN: "%{tab}:%{title}"
|
||||
en: "%{tab}: %{title}"
|
||||
workspace.notice.bell:
|
||||
zh-CN: "%{tab}:响铃"
|
||||
en: "%{tab}: Bell"
|
||||
|
||||
workspace.credential.public_key_copied:
|
||||
zh-CN: 已复制公钥
|
||||
en: Copied the public key
|
||||
workspace.credential.public_key_unreadable:
|
||||
zh-CN: 无法从 %{path} 读出公钥
|
||||
en: Couldn’t read a public key from %{path}
|
||||
workspace.credential.in_use_then_password.one:
|
||||
en: 1 host uses this credential. Once it’s deleted, that host switches to Password, asks for the password when connecting, and keeps its username.
|
||||
workspace.credential.in_use_then_password.other:
|
||||
zh-CN: 有 %{count} 台主机正在使用此凭据。删除后它们改为「密码」,连接时询问密码,用户名不变。
|
||||
en: "%{count} hosts use this credential. Once it’s deleted, they switch to Password, ask for the password when connecting, and keep their usernames."
|
||||
workspace.credential.in_use_then_no_password.one:
|
||||
en: 1 host uses this credential. Once it’s deleted, that host switches to No password, signs in with the SSH agent and the private keys in ~/.ssh, and keeps its username.
|
||||
workspace.credential.in_use_then_no_password.other:
|
||||
zh-CN: 有 %{count} 台主机正在使用此凭据。删除后它们改为「无密码」,用 SSH Agent 和 ~/.ssh 中的私钥登录,用户名不变。
|
||||
en: "%{count} hosts use this credential. Once it’s deleted, they switch to No password, sign in with the SSH agent and the private keys in ~/.ssh, and keep their usernames."
|
||||
|
||||
workspace.forward.needs_host:
|
||||
zh-CN: 端口转发经由一台主机的 SSH 连接,请先新建主机。
|
||||
en: A port forward runs over a host’s SSH connection. Create a host first.
|
||||
workspace.forward.delete_running:
|
||||
zh-CN: 这条转发正在运行,会先停止。
|
||||
en: This forward is running and will be stopped first.
|
||||
workspace.forward.stopped:
|
||||
zh-CN: 端口转发“%{title}”已停止
|
||||
en: Port forward “%{title}” stopped
|
||||
workspace.forward.prompt_origin:
|
||||
zh-CN: 端口转发“%{title}”正在通过 %{host} 连接。
|
||||
en: Port forward “%{title}” is connecting through %{host}.
|
||||
|
||||
workspace.snippets.delete_category.one:
|
||||
en: The 1 snippet in the category is deleted with it.
|
||||
workspace.snippets.delete_category.other:
|
||||
zh-CN: 分类里的 %{count} 个片段会一起删除。
|
||||
en: The %{count} snippets in the category are deleted with it.
|
||||
|
||||
workspace.update.done_title:
|
||||
zh-CN: 更新完成
|
||||
en: Update complete
|
||||
workspace.update.done:
|
||||
zh-CN: ShellRS 已更新到 %{version}。
|
||||
en: ShellRS is now %{version}.
|
||||
workspace.update.failed_title:
|
||||
zh-CN: 更新没有完成
|
||||
en: Update not completed
|
||||
workspace.update.failed:
|
||||
zh-CN: 没有装上 %{to},ShellRS 仍是 %{from}。可以到官网下载安装。
|
||||
en: "%{to} wasn’t installed, so ShellRS is still %{from}. You can download it from the website."
|
||||
@@ -1,7 +1,8 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=ShellRS
|
||||
Comment=SSH 主机管理:终端、SFTP 与端口转发
|
||||
Comment=SSH host manager: terminal, SFTP and port forwarding
|
||||
Comment[zh_CN]=SSH 主机管理:终端、SFTP 与端口转发
|
||||
Exec=shellrs
|
||||
Icon=shellrs
|
||||
Terminal=false
|
||||
|
||||
@@ -5,11 +5,20 @@
|
||||
change: the keychain grants each saved password to them, so a new
|
||||
identifier would have macOS ask about every password again.
|
||||
bundle.sh fills in @VERSION@ and @BUILD@ (a number that only goes up).
|
||||
The languages are the interface's: the system's own panels (the file
|
||||
picker, the menu bar's standard items) follow whichever of them the user
|
||||
prefers, and the *.lproj folders beside this file translate the
|
||||
descriptions below.
|
||||
-->
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>zh_CN</string>
|
||||
<string>en</string>
|
||||
<key>CFBundleLocalizations</key>
|
||||
<array>
|
||||
<string>en</string>
|
||||
<string>zh-Hans</string>
|
||||
</array>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>ShellRS</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
@@ -35,7 +44,7 @@
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>ShellRS 需要访问本地网络,才能连接局域网中的主机。</string>
|
||||
<string>ShellRS needs the local network to connect to hosts on your LAN.</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>© ShellRS</string>
|
||||
</dict>
|
||||
|
||||
@@ -24,6 +24,11 @@ chmod 755 "$app/Contents/MacOS/shellrs"
|
||||
sed -e "s/@VERSION@/$version/g" -e "s/@BUILD@/$build/g" \
|
||||
"$here/Info.plist.in" > "$app/Contents/Info.plist"
|
||||
plutil -lint "$app/Contents/Info.plist" >/dev/null
|
||||
# The Info.plist descriptions in each language of the interface.
|
||||
for lproj in "$here"/*.lproj; do
|
||||
cp -R "$lproj" "$app/Contents/Resources/"
|
||||
plutil -lint "$app/Contents/Resources/$(basename "$lproj")/InfoPlist.strings" >/dev/null
|
||||
done
|
||||
|
||||
icns="$root/assets/logo/shellrs.icns"
|
||||
icon="$root/assets/logo/shellrs.png"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/* What macOS shows when ShellRS first reaches for the local network. */
|
||||
NSLocalNetworkUsageDescription = "ShellRS needs the local network to connect to hosts on your LAN.";
|
||||
@@ -0,0 +1,2 @@
|
||||
/* ShellRS 第一次访问本地网络时,macOS 显示的说明。 */
|
||||
NSLocalNetworkUsageDescription = "ShellRS 需要访问本地网络,才能连接局域网中的主机。";
|
||||
@@ -0,0 +1,455 @@
|
||||
//! The analytics thread, the handle the workspace signals it through, and
|
||||
//! the request to Aptabase.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{self, Receiver, Sender};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
use reqwest::blocking::Client;
|
||||
use reqwest::header::{CONTENT_TYPE, USER_AGENT};
|
||||
|
||||
use super::APP_KEY;
|
||||
use super::model::{
|
||||
Counter, Delivery, Pace, Reporter, Signal, Snapshot, State, SystemProps, body, endpoint,
|
||||
};
|
||||
use super::system::SystemInfo;
|
||||
use crate::update::{Channel, build_info};
|
||||
|
||||
/// Lets a development build report, to its debug view, to try it out.
|
||||
const DEBUG_ENV: &str = "SHELLRS_ANALYTICS_DEBUG";
|
||||
|
||||
/// Where the events go.
|
||||
pub trait Transport: Send + Sync {
|
||||
/// Post `body`, a JSON array of events, and say how it went. Blocks: it
|
||||
/// runs on the analytics thread.
|
||||
fn send(&self, body: &[u8], user_agent: &str) -> Delivery;
|
||||
}
|
||||
|
||||
/// Aptabase, over HTTPS.
|
||||
pub struct HttpTransport {
|
||||
endpoint: String,
|
||||
app_key: String,
|
||||
/// How long connecting may take, and the whole request.
|
||||
connect_timeout: Duration,
|
||||
timeout: Duration,
|
||||
/// Whether to go through the system's proxy, as the update check does.
|
||||
/// Only the loopback tests turn it off: a developer's proxy would
|
||||
/// capture 127.0.0.1.
|
||||
system_proxy: bool,
|
||||
}
|
||||
|
||||
impl HttpTransport {
|
||||
pub fn new(endpoint: impl Into<String>, app_key: impl Into<String>) -> Self {
|
||||
Self {
|
||||
endpoint: endpoint.into(),
|
||||
app_key: app_key.into(),
|
||||
connect_timeout: Duration::from_secs(5),
|
||||
timeout: Duration::from_secs(15),
|
||||
system_proxy: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Transport for HttpTransport {
|
||||
fn send(&self, body: &[u8], user_agent: &str) -> Delivery {
|
||||
// Built for each request, on the analytics thread: the blocking
|
||||
// client runs a runtime of its own, and requests are a few a day.
|
||||
let builder = Client::builder()
|
||||
.connect_timeout(self.connect_timeout)
|
||||
.timeout(self.timeout);
|
||||
let builder = if self.system_proxy {
|
||||
builder
|
||||
} else {
|
||||
builder.no_proxy()
|
||||
};
|
||||
let Ok(client) = builder.build() else {
|
||||
return Delivery::Retry;
|
||||
};
|
||||
let response = client
|
||||
.post(&self.endpoint)
|
||||
.header("App-Key", &self.app_key)
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.header(USER_AGENT, user_agent)
|
||||
.body(body.to_vec())
|
||||
.send();
|
||||
match response {
|
||||
Ok(response) => delivery(response.status().as_u16()),
|
||||
Err(_) => Delivery::Retry,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What an answer means for the events: kept for a later try only when
|
||||
/// trying again can help.
|
||||
fn delivery(status: u16) -> Delivery {
|
||||
match status {
|
||||
200..=299 => Delivery::Sent,
|
||||
408 | 429 | 500..=599 => Delivery::Retry,
|
||||
_ => Delivery::Rejected,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the analytics thread works with: production's from `system`, the
|
||||
/// tests' with a fake transport.
|
||||
pub struct AnalyticsServices {
|
||||
pub transport: Arc<dyn Transport>,
|
||||
/// `analytics.json`.
|
||||
pub state_path: PathBuf,
|
||||
/// A development build's events go to the dashboard's debug view, and
|
||||
/// at its quicker pace.
|
||||
pub is_debug: bool,
|
||||
/// Reads the system's name and version, on the thread.
|
||||
pub detect: fn() -> SystemInfo,
|
||||
}
|
||||
|
||||
impl AnalyticsServices {
|
||||
/// Production's, or `None` for a build that does not report: only
|
||||
/// release builds do, and a development build with
|
||||
/// `SHELLRS_ANALYTICS_DEBUG` set.
|
||||
pub fn system() -> Option<Self> {
|
||||
let is_debug = Channel::of_this_build().is_none();
|
||||
if is_debug && std::env::var_os(DEBUG_ENV).is_none() {
|
||||
return None;
|
||||
}
|
||||
let endpoint = endpoint(APP_KEY)?;
|
||||
Some(Self {
|
||||
transport: Arc::new(HttpTransport::new(endpoint, APP_KEY)),
|
||||
state_path: crate::app::analytics_path(),
|
||||
is_debug,
|
||||
detect: SystemInfo::detect,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The workspace's end. Signals go to the analytics thread and nothing
|
||||
/// comes back, so the window never waits on the network; with 发送匿名使用统计
|
||||
/// off, none are sent.
|
||||
pub struct Analytics {
|
||||
sender: Sender<Signal>,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl Analytics {
|
||||
/// Signals go to `sender`. The tests read them off its receiver.
|
||||
pub fn new(sender: Sender<Signal>, enabled: bool) -> Self {
|
||||
Self { sender, enabled }
|
||||
}
|
||||
|
||||
/// Start the analytics thread. Off, it only forgets what an earlier
|
||||
/// run left counted.
|
||||
pub fn start(services: AnalyticsServices, enabled: bool) -> Self {
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
// A thread that cannot start leaves the signals unheard, which is
|
||||
// all it costs.
|
||||
let _ = thread::Builder::new()
|
||||
.name("shellrs-analytics".into())
|
||||
.spawn(move || run(services, receiver));
|
||||
let analytics = Self::new(sender, enabled);
|
||||
if !enabled {
|
||||
let _ = analytics.sender.send(Signal::Enabled(false));
|
||||
}
|
||||
analytics
|
||||
}
|
||||
|
||||
pub fn started(&self, snapshot: Snapshot) {
|
||||
self.send(Signal::Started(snapshot));
|
||||
}
|
||||
|
||||
pub fn window_active(&self, snapshot: Snapshot) {
|
||||
self.send(Signal::Active(snapshot));
|
||||
}
|
||||
|
||||
pub fn count(&self, counter: Counter) {
|
||||
self.send(Signal::Count(counter));
|
||||
}
|
||||
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
|
||||
pub fn set_enabled(&mut self, enabled: bool) {
|
||||
if enabled != self.enabled {
|
||||
self.enabled = enabled;
|
||||
let _ = self.sender.send(Signal::Enabled(enabled));
|
||||
}
|
||||
}
|
||||
|
||||
/// A thread that is gone is not the window's problem.
|
||||
fn send(&self, signal: Signal) {
|
||||
if self.enabled {
|
||||
let _ = self.sender.send(signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The analytics thread: wait for a signal, take it in with whatever else
|
||||
/// arrived meanwhile, send what is due, keep the state. Nothing happens
|
||||
/// between signals; the thread ends with the workspace's handle.
|
||||
fn run(services: AnalyticsServices, signals: Receiver<Signal>) {
|
||||
let info = (services.detect)();
|
||||
let user_agent = info.user_agent(build_info::VERSION);
|
||||
let system = SystemProps {
|
||||
is_debug: services.is_debug,
|
||||
os_name: info.os_name,
|
||||
os_version: info.os_version,
|
||||
app_version: build_info::VERSION.to_string(),
|
||||
};
|
||||
let pace = if services.is_debug {
|
||||
Pace::Debug
|
||||
} else {
|
||||
Pace::Release
|
||||
};
|
||||
let mut reporter = Reporter::new(State::load(&services.state_path), pace);
|
||||
while let Ok(signal) = signals.recv() {
|
||||
let now = Utc::now();
|
||||
reporter.signal(signal, now);
|
||||
for signal in signals.try_iter() {
|
||||
reporter.signal(signal, now);
|
||||
}
|
||||
if let Some(batch) = reporter.take_batch(now) {
|
||||
let delivery = services
|
||||
.transport
|
||||
.send(&body(&batch.events, &system), &user_agent);
|
||||
reporter.settle(batch, delivery, Utc::now());
|
||||
}
|
||||
if reporter.take_changed() {
|
||||
// Unwritable, the state lives on in memory until the next try.
|
||||
let _ = reporter.state().save(&services.state_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Instant;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A request as the loopback server saw it.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct Seen {
|
||||
request_line: String,
|
||||
headers: Vec<(String, String)>,
|
||||
body: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Seen {
|
||||
fn header(&self, name: &str) -> Option<&str> {
|
||||
self.headers
|
||||
.iter()
|
||||
.find(|(key, _)| key.eq_ignore_ascii_case(name))
|
||||
.map(|(_, value)| value.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Answers every request with `status`, or with nothing at all for
|
||||
/// `None`, and remembers what it was sent.
|
||||
struct Server {
|
||||
port: u16,
|
||||
seen: Arc<Mutex<Vec<Seen>>>,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
fn start(status: Option<u16>) -> Self {
|
||||
let listener = {
|
||||
let _forks = crate::testing::no_forks();
|
||||
TcpListener::bind("127.0.0.1:0").unwrap()
|
||||
};
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let shared = seen.clone();
|
||||
thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let seen = shared.clone();
|
||||
thread::spawn(move || serve(stream, status, &seen));
|
||||
}
|
||||
});
|
||||
Self { port, seen }
|
||||
}
|
||||
|
||||
fn transport(&self) -> HttpTransport {
|
||||
HttpTransport {
|
||||
endpoint: format!("http://127.0.0.1:{}/api/v0/events", self.port),
|
||||
app_key: "A-US-1".into(),
|
||||
connect_timeout: Duration::from_millis(500),
|
||||
timeout: Duration::from_millis(500),
|
||||
system_proxy: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn seen(&self) -> Vec<Seen> {
|
||||
self.seen.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, status: Option<u16>, seen: &Mutex<Vec<Seen>>) {
|
||||
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
||||
let mut request = Seen::default();
|
||||
if reader.read_line(&mut request.request_line).is_err() {
|
||||
return;
|
||||
}
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if reader.read_line(&mut line).unwrap_or(0) == 0 || line.trim().is_empty() {
|
||||
break;
|
||||
}
|
||||
if let Some((name, value)) = line.split_once(':') {
|
||||
request
|
||||
.headers
|
||||
.push((name.trim().to_string(), value.trim().to_string()));
|
||||
}
|
||||
}
|
||||
let length = request
|
||||
.header("content-length")
|
||||
.and_then(|length| length.parse().ok())
|
||||
.unwrap_or(0);
|
||||
request.body = vec![0; length];
|
||||
let _ = reader.read_exact(&mut request.body);
|
||||
seen.lock().unwrap().push(request);
|
||||
let Some(status) = status else {
|
||||
// Keep the connection open and say nothing.
|
||||
thread::sleep(Duration::from_secs(2));
|
||||
return;
|
||||
};
|
||||
let mut stream = stream;
|
||||
let _ = stream.write_all(
|
||||
format!(
|
||||
"HTTP/1.1 {status} Whatever\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{{}}"
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_request_is_what_aptabase_reads() {
|
||||
let server = Server::start(Some(200));
|
||||
let delivery = server
|
||||
.transport()
|
||||
.send(b"[]", "ShellRS/1 (macOS 15; aarch64)");
|
||||
assert_eq!(delivery, Delivery::Sent);
|
||||
let seen = server.seen();
|
||||
let request = &seen[0];
|
||||
assert!(request.request_line.starts_with("POST /api/v0/events "));
|
||||
assert_eq!(request.header("app-key"), Some("A-US-1"));
|
||||
assert_eq!(request.header("content-type"), Some("application/json"));
|
||||
assert_eq!(
|
||||
request.header("user-agent"),
|
||||
Some("ShellRS/1 (macOS 15; aarch64)")
|
||||
);
|
||||
assert_eq!(request.body, b"[]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_what_may_pass_is_tried_again() {
|
||||
for (status, expected) in [
|
||||
(202, Delivery::Sent),
|
||||
(500, Delivery::Retry),
|
||||
(503, Delivery::Retry),
|
||||
(429, Delivery::Retry),
|
||||
(400, Delivery::Rejected),
|
||||
(404, Delivery::Rejected),
|
||||
] {
|
||||
let server = Server::start(Some(status));
|
||||
assert_eq!(server.transport().send(b"[]", "ua"), expected, "{status}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_network_is_tried_again_later() {
|
||||
// A port nothing listens on.
|
||||
let port = {
|
||||
let _forks = crate::testing::no_forks();
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.local_addr().unwrap().port()
|
||||
};
|
||||
let mut transport = Server::start(Some(200)).transport();
|
||||
transport.endpoint = format!("http://127.0.0.1:{port}/api/v0/events");
|
||||
assert_eq!(transport.send(b"[]", "ua"), Delivery::Retry);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_that_never_answers_is_given_up_on() {
|
||||
let server = Server::start(None);
|
||||
let started = Instant::now();
|
||||
assert_eq!(server.transport().send(b"[]", "ua"), Delivery::Retry);
|
||||
assert!(started.elapsed() < Duration::from_secs(2));
|
||||
}
|
||||
|
||||
/// Hands each body to the test.
|
||||
struct FakeTransport(Mutex<Sender<Vec<u8>>>);
|
||||
|
||||
impl Transport for FakeTransport {
|
||||
fn send(&self, body: &[u8], _: &str) -> Delivery {
|
||||
let _ = self.0.lock().unwrap().send(body.to_vec());
|
||||
Delivery::Sent
|
||||
}
|
||||
}
|
||||
|
||||
fn a_mac() -> SystemInfo {
|
||||
SystemInfo {
|
||||
os_name: "macOS".into(),
|
||||
os_version: "15.1.0".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_thread_reports_the_start_and_keeps_the_state() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("analytics.json");
|
||||
let (bodies, received) = mpsc::channel();
|
||||
let services = AnalyticsServices {
|
||||
transport: Arc::new(FakeTransport(Mutex::new(bodies))),
|
||||
state_path: path.clone(),
|
||||
is_debug: true,
|
||||
detect: a_mac,
|
||||
};
|
||||
let analytics = Analytics::start(services, true);
|
||||
analytics.started(Snapshot::default());
|
||||
let body = received.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
let events: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(events[0]["eventName"], "app_installed");
|
||||
assert_eq!(events[1]["eventName"], "app_started");
|
||||
assert_eq!(events[0]["systemProps"]["osName"], "macOS");
|
||||
assert_eq!(events[0]["systemProps"]["isDebug"], true);
|
||||
analytics.count(Counter::Ssh);
|
||||
// The count is on disk once the thread has taken it in.
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let state = loop {
|
||||
let state = State::load(&path);
|
||||
if state.counting.is_some() || Instant::now() > deadline {
|
||||
break state;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
assert!(state.installed);
|
||||
assert_eq!(state.counting.unwrap().counts["ssh"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switched_off_nothing_is_signalled() {
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let mut analytics = Analytics::new(sender, true);
|
||||
analytics.count(Counter::Find);
|
||||
analytics.set_enabled(false);
|
||||
analytics.count(Counter::Find);
|
||||
analytics.window_active(Snapshot::default());
|
||||
analytics.set_enabled(true);
|
||||
let signals: Vec<Signal> = receiver.try_iter().collect();
|
||||
assert_eq!(
|
||||
signals,
|
||||
[
|
||||
Signal::Count(Counter::Find),
|
||||
Signal::Enabled(false),
|
||||
Signal::Enabled(true)
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! 匿名使用统计: how many installations, how many devices a day, which
|
||||
//! versions, and which features get used, reported to Aptabase.
|
||||
//!
|
||||
//! What goes out: the version, the operating system and its version, the
|
||||
//! CPU architecture, a few settings (each from a fixed set of values, the
|
||||
//! saved hosts and such only counted, in bands), and how many times each
|
||||
//! feature was used. Never a host, credential, command, file name, path or
|
||||
//! anything that identifies the machine: Aptabase tells devices apart from
|
||||
//! the address and the User-Agent, hashed with a salt it replaces daily.
|
||||
//!
|
||||
//! Aptabase has no Rust SDK beyond its Tauri plugin, so this posts to its
|
||||
//! REST API itself, as its other SDKs do: batches of up to 25 events, the
|
||||
//! network and its 5xx retried, any other refusal dropped. Unlike them,
|
||||
//! nothing runs on a timer: the workspace sends signals (started, the
|
||||
//! window came forward, a feature was used, the switch flipped) to one
|
||||
//! thread, which counts, works out what is due and sends it then. Feature
|
||||
//! uses are summed for four hours into one `usage` event and kept on disk
|
||||
//! until it is sent, so quitting loses nothing and never waits.
|
||||
//!
|
||||
//! Only release builds report; a development build does with
|
||||
//! `SHELLRS_ANALYTICS_DEBUG` set, as debug events, summed by the minute and
|
||||
//! retried after ten seconds. The external CLI, a second copy passing its
|
||||
//! links on, and the UI tests never start it.
|
||||
|
||||
mod client;
|
||||
mod model;
|
||||
mod system;
|
||||
|
||||
pub use client::{Analytics, AnalyticsServices, HttpTransport, Transport};
|
||||
pub use model::{Counter, Delivery, Signal, Snapshot};
|
||||
pub use system::SystemInfo;
|
||||
|
||||
/// The Aptabase app ShellRS reports to. Not a secret: every build carries
|
||||
/// it, and all it allows is sending events.
|
||||
pub const APP_KEY: &str = "A-US-1582708162";
|
||||
@@ -0,0 +1,97 @@
|
||||
//! The machine as Aptabase hears of it: the system's name and version.
|
||||
|
||||
use os_info::{Type, Version};
|
||||
|
||||
/// The operating system, named as Aptabase's dashboard knows them:
|
||||
/// `macOS`, `Windows`, or a Linux distribution such as `Ubuntu`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SystemInfo {
|
||||
pub os_name: String,
|
||||
/// Empty when the system does not say.
|
||||
pub os_version: String,
|
||||
}
|
||||
|
||||
impl SystemInfo {
|
||||
/// Ask the system. It may start a process or two (`uname`,
|
||||
/// `lsb_release`, `sw_vers`), so only the analytics thread asks.
|
||||
pub fn detect() -> Self {
|
||||
let info = os_info::get();
|
||||
Self::from_os(info.os_type(), info.version())
|
||||
}
|
||||
|
||||
fn from_os(kind: Type, version: &Version) -> Self {
|
||||
let os_name = match kind {
|
||||
// os_info's own name for it is "Mac OS".
|
||||
Type::Macos => "macOS".to_string(),
|
||||
Type::Windows => "Windows".to_string(),
|
||||
Type::Unknown => std::env::consts::OS.to_string(),
|
||||
kind => kind.to_string(),
|
||||
};
|
||||
let os_version = match version {
|
||||
Version::Unknown => String::new(),
|
||||
version => version.to_string(),
|
||||
};
|
||||
Self {
|
||||
os_name,
|
||||
os_version,
|
||||
}
|
||||
}
|
||||
|
||||
/// `ShellRS/0.1.5 (macOS 15.1.0; aarch64)`. Aptabase tells the devices
|
||||
/// behind one address apart by it, so it says what differs between
|
||||
/// machines and nothing that names one.
|
||||
pub fn user_agent(&self, version: &str) -> String {
|
||||
let system = format!("{} {}", self.os_name, self.os_version);
|
||||
format!(
|
||||
"ShellRS/{version} ({}; {})",
|
||||
system.trim(),
|
||||
std::env::consts::ARCH
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn systems_are_named_as_aptabase_knows_them() {
|
||||
let macos = SystemInfo::from_os(Type::Macos, &Version::Semantic(15, 1, 0));
|
||||
assert_eq!(macos.os_name, "macOS");
|
||||
assert_eq!(macos.os_version, "15.1.0");
|
||||
let windows = SystemInfo::from_os(Type::Windows, &Version::Semantic(10, 0, 22631));
|
||||
assert_eq!(windows.os_name, "Windows");
|
||||
assert_eq!(windows.os_version, "10.0.22631");
|
||||
let ubuntu = SystemInfo::from_os(Type::Ubuntu, &Version::Custom("24.04".into()));
|
||||
assert_eq!(ubuntu.os_name, "Ubuntu");
|
||||
let unknown = SystemInfo::from_os(Type::Unknown, &Version::Unknown);
|
||||
assert_eq!(unknown.os_name, std::env::consts::OS);
|
||||
assert_eq!(unknown.os_version, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_user_agent_names_the_system_and_architecture() {
|
||||
let macos = SystemInfo::from_os(Type::Macos, &Version::Semantic(15, 1, 0));
|
||||
assert_eq!(
|
||||
macos.user_agent("0.1.5"),
|
||||
format!("ShellRS/0.1.5 (macOS 15.1.0; {})", std::env::consts::ARCH)
|
||||
);
|
||||
let bare = SystemInfo::from_os(Type::Unknown, &Version::Unknown);
|
||||
assert_eq!(
|
||||
bare.user_agent("0.1.5"),
|
||||
format!(
|
||||
"ShellRS/0.1.5 ({}; {})",
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_machine_has_a_name() {
|
||||
// os_info starts `uname` and the like.
|
||||
let _forks = crate::testing::no_forks();
|
||||
let info = SystemInfo::detect();
|
||||
assert!(!info.os_name.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use gpui_kit::*;
|
||||
|
||||
use crate::{
|
||||
cli::AgentKind,
|
||||
docker::{ContainerCommand, DockerObject},
|
||||
docker::{ContainerCommand, ContainerSubject, DockerObject},
|
||||
editor::EditorId,
|
||||
explorer::{ExplorerId, FileSizeFormat, PaneSide},
|
||||
host::{
|
||||
@@ -327,7 +327,7 @@ pub struct ControlService {
|
||||
pub struct ControlContainers {
|
||||
/// What they are, for the question and the notification: 「容器“web”」,
|
||||
/// 「项目“php-56”」.
|
||||
pub subject: String,
|
||||
pub subject: ContainerSubject,
|
||||
pub ids: Vec<String>,
|
||||
pub command: ContainerCommand,
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ pub use actions::*;
|
||||
pub use app_icon::show_logo_when_unbundled;
|
||||
pub use assets::{AppAssets, CatalogIcon, DOCKER_ICON};
|
||||
pub use paths::{
|
||||
cli_endpoint, cli_socket_path, data_dir, database_path, keys_dir, settings_path, updates_dir,
|
||||
window_state_path,
|
||||
analytics_path, cli_endpoint, cli_socket_path, data_dir, database_path, keys_dir,
|
||||
settings_path, updates_dir, window_state_path,
|
||||
};
|
||||
pub use quit::{quit_held_back, set_quit_guard};
|
||||
pub use shortcuts::{
|
||||
@@ -25,6 +25,8 @@ pub use window_hiding::{bring_forward, hide_when_closed};
|
||||
use gpui_kit::component::Theme;
|
||||
use gpui_kit::*;
|
||||
|
||||
use crate::i18n::t;
|
||||
|
||||
/// Key context of the host panel, for bindings that only apply there.
|
||||
pub const HOST_PANEL_CONTEXT: &str = "HostPanel";
|
||||
pub const RECENT_HOSTS_CONTEXT: &str = "RecentHosts";
|
||||
@@ -40,35 +42,102 @@ pub const IMAGE_PREVIEW_CONTEXT: &str = "ImagePreview";
|
||||
pub const LOCAL_FILE_LIST_CONTEXT: &str = "LocalFileList";
|
||||
pub const REMOTE_FILE_LIST_CONTEXT: &str = "RemoteFileList";
|
||||
|
||||
/// What the window says when the local database cannot be opened: the run
|
||||
/// goes on, keeping its changes in memory only.
|
||||
pub fn database_unavailable(error: &anyhow::Error) -> SharedString {
|
||||
t!("app.database_unavailable", error = error)
|
||||
}
|
||||
|
||||
/// Initialize GPUI Kit and everything global to the application.
|
||||
pub fn init(cx: &mut App) {
|
||||
gpui_kit::init(cx);
|
||||
// Before ours: rebuilding the keymap starts from gpui-kit's own.
|
||||
shortcuts::keep_kit_bindings(cx);
|
||||
deepen_list_hover(cx);
|
||||
gpui_kit::component::set_locale("zh-CN");
|
||||
// gpui-kit's own text in the language ShellRS's is in: the saved one,
|
||||
// which `main` set before anything was built.
|
||||
crate::i18n::set_locale(crate::i18n::locale());
|
||||
// The defaults; the settings bring the user's changes once read.
|
||||
apply_shortcuts(&ShortcutOverrides::default(), cx);
|
||||
cx.on_action(|_: &Quit, cx: &mut App| quit::quit(cx));
|
||||
}
|
||||
|
||||
/// How much of a selected row's color hover gets in the dark appearance.
|
||||
/// Selected rows there have the deepened hover color itself; hover with all
|
||||
/// of it would look the same.
|
||||
const DARK_HOVER_SHARE: f32 = 0.5;
|
||||
|
||||
/// Use a stronger version of the theme's neutral list hover color throughout
|
||||
/// the app.
|
||||
/// the app. Dark, the stronger one is for selected rows
|
||||
/// (`selected_row_color`) and hover, in lists and tables, gets half of it.
|
||||
pub(crate) fn deepen_list_hover(cx: &mut App) {
|
||||
let theme = Theme::global_mut(cx);
|
||||
let hover = theme.list_hover;
|
||||
let deeper = hover.alpha(((hover.a + 0.2) * 1.2).min(1.0));
|
||||
theme.list_hover = deeper;
|
||||
theme.tokens.list_hover = deeper.into();
|
||||
if theme.is_dark() {
|
||||
let faint = deeper.alpha(deeper.a * DARK_HOVER_SHARE);
|
||||
theme.list_hover = faint;
|
||||
theme.tokens.list_hover = faint.into();
|
||||
theme.table_hover = faint;
|
||||
theme.tokens.table_hover = faint.into();
|
||||
} else {
|
||||
theme.list_hover = deeper;
|
||||
theme.tokens.list_hover = deeper.into();
|
||||
}
|
||||
Theme::sync_base(cx);
|
||||
}
|
||||
|
||||
/// Give the host tree three times the list hover's contrast against its sidebar.
|
||||
/// The recent-host list keeps the shared hover color unchanged.
|
||||
pub(crate) fn host_tree_selection_color(theme: &Theme) -> Hsla {
|
||||
/// The deepened list hover: light the hover itself, dark the full color
|
||||
/// hover has only a share of.
|
||||
fn deepened_hover(theme: &Theme) -> Hsla {
|
||||
let hover = theme.list_hover;
|
||||
if theme.is_dark() {
|
||||
hover.alpha((hover.a / DARK_HOVER_SHARE).min(1.0))
|
||||
} else {
|
||||
hover
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected row of the sidebar's lists (hosts, port forwards,
|
||||
/// credentials) and of the recent hosts, so that it stands out from hover.
|
||||
/// Light, three times the list hover's contrast against the sidebar; dark,
|
||||
/// where that glares, the deepened hover.
|
||||
pub(crate) fn selected_row_color(theme: &Theme) -> Hsla {
|
||||
let sidebar = theme.sidebar;
|
||||
let hover = sidebar.blend(theme.list_hover);
|
||||
let hover = sidebar.blend(deepened_hover(theme));
|
||||
let mut selected = hover.alpha(1.0);
|
||||
if theme.is_dark() {
|
||||
return selected;
|
||||
}
|
||||
selected.l = (sidebar.l + (hover.l - sidebar.l) * 3.0).clamp(0.0, 1.0);
|
||||
selected
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gpui_kit::TestAppContext;
|
||||
use gpui_kit::component::{ActiveTheme as _, Theme, ThemeMode};
|
||||
|
||||
use super::{deepen_list_hover, deepened_hover};
|
||||
|
||||
#[gpui_kit::test]
|
||||
fn dark_hover_is_fainter_than_a_selected_row(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
gpui_kit::component::init(cx);
|
||||
for mode in [ThemeMode::Light, ThemeMode::Dark] {
|
||||
Theme::change(mode, None, cx);
|
||||
deepen_list_hover(cx);
|
||||
let theme = cx.theme();
|
||||
let selected = deepened_hover(theme);
|
||||
assert!(selected.a > 0.9, "{mode:?}: {selected:?}");
|
||||
match mode {
|
||||
ThemeMode::Light => assert_eq!(theme.list_hover, selected),
|
||||
ThemeMode::Dark => {
|
||||
assert!((theme.list_hover.a * 2. - selected.a).abs() < 0.01);
|
||||
assert_eq!(theme.table_hover, theme.list_hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,13 @@ pub fn window_state_path() -> PathBuf {
|
||||
data_dir().join("window.json")
|
||||
}
|
||||
|
||||
/// What 匿名使用统计 keeps between runs: whether this installation was
|
||||
/// reported, and the uses not sent yet. Beside the database, so a fresh
|
||||
/// data directory is a fresh installation.
|
||||
pub fn analytics_path() -> PathBuf {
|
||||
data_dir().join("analytics.json")
|
||||
}
|
||||
|
||||
/// Where the running app listens for the `shellrs` command. The command
|
||||
/// and the app both ask here, so both follow `SHELLRS_DATA_DIR`.
|
||||
pub fn cli_socket_path() -> PathBuf {
|
||||
|
||||
@@ -14,6 +14,7 @@ use gpui_kit::*;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use super::*;
|
||||
use crate::i18n::t;
|
||||
use crate::terminal::{TERMINAL_FIND_KEY_CONTEXT, TERMINAL_KEY_CONTEXT, terminal_key_bindings};
|
||||
|
||||
/// The groups of 设置 › 键盘快捷键, in the order the page shows them.
|
||||
@@ -28,12 +29,12 @@ pub enum ShortcutGroup {
|
||||
impl ShortcutGroup {
|
||||
pub const ALL: [Self; 4] = [Self::General, Self::Tabs, Self::Terminal, Self::Editor];
|
||||
|
||||
pub fn title(self) -> &'static str {
|
||||
pub fn title(self) -> SharedString {
|
||||
match self {
|
||||
Self::General => "通用",
|
||||
Self::Tabs => "标签",
|
||||
Self::Terminal => "终端",
|
||||
Self::Editor => "编辑器",
|
||||
Self::General => t!("app.shortcut_group.general"),
|
||||
Self::Tabs => t!("app.shortcut_group.tabs"),
|
||||
Self::Terminal => t!("app.shortcut_group.terminal"),
|
||||
Self::Editor => t!("app.shortcut_group.editor"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +43,7 @@ impl ShortcutGroup {
|
||||
pub struct Shortcut {
|
||||
/// What the settings file stores. Never changes once released.
|
||||
id: &'static str,
|
||||
/// The key of what it is called.
|
||||
label: &'static str,
|
||||
group: ShortcutGroup,
|
||||
/// Where it works: anywhere, or in these key contexts.
|
||||
@@ -82,7 +84,7 @@ macro_rules! shortcut {
|
||||
pub static SHORTCUTS: [Shortcut; 25] = [
|
||||
shortcut!(
|
||||
"new-host",
|
||||
"新建主机",
|
||||
"app.shortcut.new_host",
|
||||
General,
|
||||
&[],
|
||||
"cmd-n",
|
||||
@@ -91,7 +93,7 @@ pub static SHORTCUTS: [Shortcut; 25] = [
|
||||
),
|
||||
shortcut!(
|
||||
"new-group",
|
||||
"新建分组",
|
||||
"app.shortcut.new_group",
|
||||
General,
|
||||
&[],
|
||||
"cmd-shift-n",
|
||||
@@ -100,7 +102,7 @@ pub static SHORTCUTS: [Shortcut; 25] = [
|
||||
),
|
||||
shortcut!(
|
||||
"open-settings",
|
||||
"打开设置",
|
||||
"app.shortcut.open_settings",
|
||||
General,
|
||||
&[],
|
||||
"cmd-,",
|
||||
@@ -109,7 +111,7 @@ pub static SHORTCUTS: [Shortcut; 25] = [
|
||||
),
|
||||
shortcut!(
|
||||
"toggle-sidebar",
|
||||
"显示或隐藏侧栏",
|
||||
"app.shortcut.toggle_sidebar",
|
||||
General,
|
||||
&[],
|
||||
"cmd-b",
|
||||
@@ -118,7 +120,7 @@ pub static SHORTCUTS: [Shortcut; 25] = [
|
||||
),
|
||||
shortcut!(
|
||||
"toggle-tool-sidebar",
|
||||
"显示或隐藏右侧栏",
|
||||
"app.shortcut.toggle_tool_sidebar",
|
||||
General,
|
||||
&[],
|
||||
"cmd-alt-b",
|
||||
@@ -128,7 +130,7 @@ pub static SHORTCUTS: [Shortcut; 25] = [
|
||||
// In a terminal ⌘K clears instead, as in other macOS terminals.
|
||||
shortcut!(
|
||||
"focus-search",
|
||||
"聚焦搜索",
|
||||
"app.shortcut.focus_search",
|
||||
General,
|
||||
&[],
|
||||
"cmd-k",
|
||||
@@ -138,7 +140,7 @@ pub static SHORTCUTS: [Shortcut; 25] = [
|
||||
// In a terminal the same keys size its text instead, as terminals do.
|
||||
shortcut!(
|
||||
"zoom-in",
|
||||
"放大界面",
|
||||
"app.shortcut.zoom_in",
|
||||
General,
|
||||
&[],
|
||||
"cmd-=",
|
||||
@@ -147,7 +149,7 @@ pub static SHORTCUTS: [Shortcut; 25] = [
|
||||
),
|
||||
shortcut!(
|
||||
"zoom-out",
|
||||
"缩小界面",
|
||||
"app.shortcut.zoom_out",
|
||||
General,
|
||||
&[],
|
||||
"cmd--",
|
||||
@@ -156,17 +158,25 @@ pub static SHORTCUTS: [Shortcut; 25] = [
|
||||
),
|
||||
shortcut!(
|
||||
"zoom-reset",
|
||||
"界面实际大小",
|
||||
"app.shortcut.zoom_reset",
|
||||
General,
|
||||
&[],
|
||||
"cmd-0",
|
||||
"ctrl-0",
|
||||
ZoomReset
|
||||
),
|
||||
shortcut!("quit", "退出", General, &[], "cmd-q", "ctrl-q", Quit),
|
||||
shortcut!(
|
||||
"quit",
|
||||
"app.shortcut.quit",
|
||||
General,
|
||||
&[],
|
||||
"cmd-q",
|
||||
"ctrl-q",
|
||||
Quit
|
||||
),
|
||||
shortcut!(
|
||||
"new-local-terminal",
|
||||
"新建本地终端",
|
||||
"app.shortcut.new_local_terminal",
|
||||
Tabs,
|
||||
&[],
|
||||
"cmd-t",
|
||||
@@ -175,7 +185,7 @@ pub static SHORTCUTS: [Shortcut; 25] = [
|
||||
),
|
||||
shortcut!(
|
||||
"close-tab",
|
||||
"关闭标签",
|
||||
"app.shortcut.close_tab",
|
||||
Tabs,
|
||||
&[],
|
||||
"cmd-w",
|
||||
@@ -185,7 +195,7 @@ pub static SHORTCUTS: [Shortcut; 25] = [
|
||||
// macOS reports ⌘⇧] as ⌘}, as Safari and Terminal take it.
|
||||
shortcut!(
|
||||
"next-tab",
|
||||
"下一个标签",
|
||||
"app.shortcut.next_tab",
|
||||
Tabs,
|
||||
&[],
|
||||
"cmd-}",
|
||||
@@ -194,7 +204,7 @@ pub static SHORTCUTS: [Shortcut; 25] = [
|
||||
),
|
||||
shortcut!(
|
||||
"previous-tab",
|
||||
"上一个标签",
|
||||
"app.shortcut.previous_tab",
|
||||
Tabs,
|
||||
&[],
|
||||
"cmd-{",
|
||||
@@ -203,7 +213,7 @@ pub static SHORTCUTS: [Shortcut; 25] = [
|
||||
),
|
||||
Shortcut {
|
||||
id: "switch-to-tab",
|
||||
label: "切换到标签 1…9",
|
||||
label: "app.shortcut.switch_to_tab",
|
||||
group: ShortcutGroup::Tabs,
|
||||
contexts: &[],
|
||||
mac: "cmd-1",
|
||||
@@ -213,7 +223,7 @@ pub static SHORTCUTS: [Shortcut; 25] = [
|
||||
},
|
||||
shortcut!(
|
||||
"terminal-copy",
|
||||
"复制",
|
||||
"app.shortcut.terminal_copy",
|
||||
Terminal,
|
||||
TERMINAL,
|
||||
"cmd-c",
|
||||
@@ -222,7 +232,7 @@ pub static SHORTCUTS: [Shortcut; 25] = [
|
||||
),
|
||||
shortcut!(
|
||||
"terminal-paste",
|
||||
"粘贴",
|
||||
"app.shortcut.terminal_paste",
|
||||
Terminal,
|
||||
TERMINAL,
|
||||
"cmd-v",
|
||||
@@ -231,7 +241,7 @@ pub static SHORTCUTS: [Shortcut; 25] = [
|
||||
),
|
||||
shortcut!(
|
||||
"terminal-find",
|
||||
"查找",
|
||||
"app.shortcut.terminal_find",
|
||||
Terminal,
|
||||
TERMINAL_AND_FIND,
|
||||
"cmd-f",
|
||||
@@ -240,7 +250,7 @@ pub static SHORTCUTS: [Shortcut; 25] = [
|
||||
),
|
||||
shortcut!(
|
||||
"terminal-find-next",
|
||||
"查找下一个",
|
||||
"app.shortcut.terminal_find_next",
|
||||
Terminal,
|
||||
TERMINAL_AND_FIND,
|
||||
"cmd-g",
|
||||
@@ -249,7 +259,7 @@ pub static SHORTCUTS: [Shortcut; 25] = [
|
||||
),
|
||||
shortcut!(
|
||||
"terminal-find-previous",
|
||||
"查找上一个",
|
||||
"app.shortcut.terminal_find_previous",
|
||||
Terminal,
|
||||
TERMINAL_AND_FIND,
|
||||
"cmd-shift-g",
|
||||
@@ -258,7 +268,7 @@ pub static SHORTCUTS: [Shortcut; 25] = [
|
||||
),
|
||||
shortcut!(
|
||||
"terminal-clear",
|
||||
"清屏",
|
||||
"app.shortcut.terminal_clear",
|
||||
Terminal,
|
||||
TERMINAL,
|
||||
"cmd-k",
|
||||
@@ -269,7 +279,7 @@ pub static SHORTCUTS: [Shortcut; 25] = [
|
||||
// iTerm2 and Windows Terminal.
|
||||
shortcut!(
|
||||
"terminal-zoom-in",
|
||||
"放大字号",
|
||||
"app.shortcut.terminal_zoom_in",
|
||||
Terminal,
|
||||
TERMINAL_AND_FIND,
|
||||
"cmd-=",
|
||||
@@ -278,7 +288,7 @@ pub static SHORTCUTS: [Shortcut; 25] = [
|
||||
),
|
||||
shortcut!(
|
||||
"terminal-zoom-out",
|
||||
"缩小字号",
|
||||
"app.shortcut.terminal_zoom_out",
|
||||
Terminal,
|
||||
TERMINAL_AND_FIND,
|
||||
"cmd--",
|
||||
@@ -287,7 +297,7 @@ pub static SHORTCUTS: [Shortcut; 25] = [
|
||||
),
|
||||
shortcut!(
|
||||
"terminal-zoom-reset",
|
||||
"默认字号",
|
||||
"app.shortcut.terminal_zoom_reset",
|
||||
Terminal,
|
||||
TERMINAL_AND_FIND,
|
||||
"cmd-0",
|
||||
@@ -296,7 +306,7 @@ pub static SHORTCUTS: [Shortcut; 25] = [
|
||||
),
|
||||
shortcut!(
|
||||
"editor-save",
|
||||
"保存",
|
||||
"app.shortcut.editor_save",
|
||||
Editor,
|
||||
EDITOR,
|
||||
"cmd-s",
|
||||
@@ -320,8 +330,8 @@ impl Shortcut {
|
||||
self.id
|
||||
}
|
||||
|
||||
pub fn label(&self) -> &'static str {
|
||||
self.label
|
||||
pub fn label(&self) -> SharedString {
|
||||
t!(self.label)
|
||||
}
|
||||
|
||||
pub fn is_numbered(&self) -> bool {
|
||||
@@ -481,7 +491,7 @@ pub enum Refusal {
|
||||
NeedsModifier,
|
||||
/// 切换到标签 1…9 takes a modifier with a digit.
|
||||
NeedsDigit,
|
||||
/// Another shortcut has it where this one works.
|
||||
/// Another shortcut, this id's, has it where this one works.
|
||||
UsedBy(&'static str),
|
||||
/// A text field, list or dialog has it.
|
||||
Reserved,
|
||||
@@ -492,15 +502,20 @@ impl Refusal {
|
||||
pub fn message(&self, keys: &str) -> String {
|
||||
match self {
|
||||
Self::NeedsModifier if cfg!(target_os = "macos") => {
|
||||
"快捷键要带 ⌘、⌥ 或 ⌃,或者使用 F1–F12。".into()
|
||||
t!("app.shortcut_refusal.needs_modifier_mac").to_string()
|
||||
}
|
||||
Self::NeedsModifier => "快捷键要带 Ctrl 或 Alt,或者使用 F1–F12。".into(),
|
||||
Self::NeedsModifier => t!("app.shortcut_refusal.needs_modifier").to_string(),
|
||||
Self::NeedsDigit if cfg!(target_os = "macos") => {
|
||||
"这一项要按带修饰键的数字 1,例如 ⌘1。".into()
|
||||
t!("app.shortcut_refusal.needs_digit_mac").to_string()
|
||||
}
|
||||
Self::NeedsDigit => "这一项要按带修饰键的数字 1,例如 Alt+1。".into(),
|
||||
Self::UsedBy(label) => format!("{keys} 已用于「{label}」。"),
|
||||
Self::Reserved => format!("{keys} 已被输入框等内置功能使用。"),
|
||||
Self::NeedsDigit => t!("app.shortcut_refusal.needs_digit").to_string(),
|
||||
Self::UsedBy(id) => t!(
|
||||
"app.shortcut_refusal.used_by",
|
||||
keys = keys,
|
||||
label = Shortcut::find(id).map(Shortcut::label).unwrap_or_default()
|
||||
)
|
||||
.to_string(),
|
||||
Self::Reserved => t!("app.shortcut_refusal.reserved", keys = keys).to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -547,7 +562,7 @@ pub fn check(
|
||||
.iter()
|
||||
.any(|(taken, _)| same_keystroke(pressed, taken))
|
||||
}) {
|
||||
return Err(Refusal::UsedBy(other.label));
|
||||
return Err(Refusal::UsedBy(other.id));
|
||||
}
|
||||
}
|
||||
// One that works anywhere would win over a text field's or a list's
|
||||
@@ -1032,7 +1047,7 @@ mod tests {
|
||||
let terminal = shortcut("new-local-terminal");
|
||||
assert_eq!(
|
||||
check(new_host, terminal.default_keys()),
|
||||
Err(Refusal::UsedBy("新建本地终端"))
|
||||
Err(Refusal::UsedBy("new-local-terminal"))
|
||||
);
|
||||
// A text field's key.
|
||||
assert_eq!(check(new_host, &primary("z")), Err(Refusal::Reserved));
|
||||
@@ -1047,7 +1062,7 @@ mod tests {
|
||||
let clear = shortcut("terminal-clear");
|
||||
assert_eq!(
|
||||
check(shortcut("terminal-copy"), clear.default_keys()),
|
||||
Err(Refusal::UsedBy("清屏"))
|
||||
Err(Refusal::UsedBy("terminal-clear"))
|
||||
);
|
||||
|
||||
// 切换到标签 1…9 takes any digit for all nine, and holds them all.
|
||||
@@ -1057,7 +1072,7 @@ mod tests {
|
||||
assert_eq!(check(tabs, "ctrl-alt-5"), Ok("ctrl-alt-1".into()));
|
||||
assert_eq!(
|
||||
check(new_host, &tabs.default_keys().replace('1', "4")),
|
||||
Err(Refusal::UsedBy("切换到标签 1…9"))
|
||||
Err(Refusal::UsedBy("switch-to-tab"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ shellrs download <host-id> <remote-path> <local-path>
|
||||
shellrs sync <host-id> <local-dir> <remote-dir> [--delete]
|
||||
```
|
||||
|
||||
`<host-id>` is the 16-character `id` from `shellrs hosts list` (the same ID that ShellRS copies with 复制 ID).
|
||||
`<host-id>` is the 16-character `id` from `shellrs hosts list`, the same ID that ShellRS copies with Copy ID (复制 ID).
|
||||
|
||||
For a simple command, pass one complete remote shell command string as `<command>`.
|
||||
Pass a command through stdin when it contains quotes, pipes, redirects, JSON, `$`, backticks, or nested shell code, and quote the heredoc delimiter so the local shell leaves it alone:
|
||||
@@ -32,6 +32,23 @@ shellrs exec <host-id> --stdin <<'EOF'
|
||||
EOF
|
||||
```
|
||||
|
||||
## Hosts Opened From a Bastion Host
|
||||
|
||||
A host listed with `"temporary": true` may have been opened by a bastion host's link that allows one login. Then `exec`, `upload` and `download`, which log in again, fail with `missing_credential` or `connect_failed`, and the message says so. Run the command in the host's terminal open in ShellRS instead:
|
||||
|
||||
```bash
|
||||
shellrs exec <host-id> --terminal "<command>"
|
||||
shellrs exec <host-id> --terminal --stdin <<'EOF'
|
||||
<command>
|
||||
EOF
|
||||
```
|
||||
|
||||
- It types into the user's own terminal tab, in sight of the user. It sends Ctrl-C first, which interrupts whatever runs there: use it only on a tab the user handed to you, not on one they are working in.
|
||||
- The command runs where that terminal is now: its user, folder and machine, after any `su`, `sudo -i` or nested `ssh`. With `--json`, the result's `context` says where: `{"user", "host", "cwd", "shell", "interp"}`.
|
||||
- stderr comes as stdout. One command at a time per terminal. Only bash, dash and busybox ash.
|
||||
- Programs that ask for a password wait in the user's tab: use `sudo -n` and other non-interactive flags. Give your own tool a timeout; when the command is given up, it gets Ctrl-C.
|
||||
- Files cannot be transferred this way; write a small text file with a heredoc in the command.
|
||||
|
||||
## File Transfer
|
||||
|
||||
```bash
|
||||
@@ -94,6 +111,12 @@ $OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new()
|
||||
'@ | shellrs exec <host-id> --stdin
|
||||
```
|
||||
|
||||
For `--terminal`, add `terminal = $true`:
|
||||
|
||||
```powershell
|
||||
@{ host = '<host-id>'; command = 'df -h'; terminal = $true } | ConvertTo-Json -Compress | shellrs exec --json | ConvertFrom-Json
|
||||
```
|
||||
|
||||
The `@'` must end its line and the `'@` must start one. Set the same encodings before the other commands, or non-ASCII text (such as the Chinese names of saved hosts) turns into `?` or mojibake.
|
||||
|
||||
To create or update a host or credential from Windows PowerShell 5.1, pipe the JSON in the same way, writing non-ASCII characters in it as `\uXXXX` escapes (`"group": "\u751f\u4ea7"` for 生产):
|
||||
@@ -119,12 +142,16 @@ To create or update a host or credential from Windows PowerShell 5.1, pipe the J
|
||||
Errors are printed to stderr with a code in brackets, such as `[not_enabled]`. Exit code 255 means `shellrs` could not run the command at all.
|
||||
|
||||
- `not_running`: ShellRS is not open. Ask the user to open ShellRS.
|
||||
- `not_enabled`: ask the user to turn on 启用外部 CLI in ShellRS under 设置 → 外部 CLI.
|
||||
- `not_enabled`: ask the user to turn on Enable external CLI (启用外部 CLI) in ShellRS under Settings → External CLI (设置 → 外部 CLI).
|
||||
- `host_not_found`: run `shellrs hosts list` again and use an `id` from its output. `credential_not_found`: the same with `shellrs credentials list`.
|
||||
- `bad_request` from `create` or `update`: the message says which field is wrong, in the words the ShellRS forms use.
|
||||
- `host_in_use`: the host's tabs are open in ShellRS. Ask the user whether to close them (`--force`).
|
||||
- `save_failed`: the change was made, but not all of it could be written to disk or the keychain. Tell the user the message.
|
||||
- `version_mismatch`: the `shellrs` command and the running ShellRS are different versions. Ask the user to restart or update ShellRS.
|
||||
- `host_key_unknown`, `missing_credential`: ShellRS has not connected to this host yet, or has no saved password. Ask the user to connect to the host once in ShellRS and save the password.
|
||||
- `host_key_unknown`, `missing_credential`: ShellRS has not connected to this host yet, or has no saved password. Ask the user to connect to the host once in ShellRS and save the password. For a host opened from a bastion host, use `--terminal` (see above).
|
||||
- `host_key_changed`: the server's host key changed. Tell the user; do not try to work around it.
|
||||
- `no_terminal` (`--terminal`): the host has no connected terminal in ShellRS. Ask the user to open or reconnect it.
|
||||
- `terminal_busy` (`--terminal`): a full-screen program has the terminal, another `--terminal` command still runs there, the shell did not answer, or it did not start the command (a bastion host's command filter may hold it back). Tell the user the message.
|
||||
- `unsupported_shell` (`--terminal`): the terminal runs a shell other than bash, dash or busybox ash. Tell the user.
|
||||
- `too_long` (`--terminal`): split the command into shorter ones.
|
||||
- `connect_failed` saying there is no permission to connect to ShellRS: the agent's sandbox blocks local inter-process communication. Ask the user to allow it, or run the command outside the sandbox.
|
||||
@@ -10,6 +10,7 @@ use std::{
|
||||
use super::protocol::{CliError, ErrorCode, TransferCounters, TransferSummary};
|
||||
use super::server::{CliBackend, CliTarget};
|
||||
use crate::connection::{ConnectionPromptKind, ConnectionPromptReply};
|
||||
use crate::i18n::t;
|
||||
use crate::secrets::{SecretRef, SharedSecretStore};
|
||||
use crate::sftp::{
|
||||
DownloadRequest, RemotePath, SftpCommand, SftpEvent, SharedSftpTransportProvider,
|
||||
@@ -72,7 +73,7 @@ impl SshCliBackend {
|
||||
Ok(SftpEvent::Prompt(prompt)) => run.refuse(&commands, &prompt),
|
||||
Ok(SftpEvent::Disconnected(reason)) => return Err(run.failure(reason)),
|
||||
Ok(_) => {}
|
||||
Err(_) => return Err(run.failure("SFTP 连接意外结束".into())),
|
||||
Err(_) => return Err(run.failure(t!("cli.transfer.sftp_ended").into())),
|
||||
}
|
||||
}
|
||||
let _ = commands.send_blocking(command);
|
||||
@@ -140,19 +141,31 @@ impl CliBackend for SshCliBackend {
|
||||
Ok(ExecExit::Signal(signal)) => {
|
||||
let _ = output(
|
||||
ExecStream::Stderr,
|
||||
format!("shellrs: 远程命令被信号 {signal} 终止\n").as_bytes(),
|
||||
format!("shellrs: {}\n", t!("cli.exec.signal", signal = signal)).as_bytes(),
|
||||
);
|
||||
Ok(signal_exit_code(&signal))
|
||||
}
|
||||
Err(error) => Err(CliError::new(
|
||||
match error.kind {
|
||||
ExecErrorKind::HostKeyUnknown => ErrorCode::HostKeyUnknown,
|
||||
ExecErrorKind::HostKeyChanged => ErrorCode::HostKeyChanged,
|
||||
ExecErrorKind::MissingCredential => ErrorCode::MissingCredential,
|
||||
ExecErrorKind::Connect | ExecErrorKind::Aborted => ErrorCode::ConnectFailed,
|
||||
},
|
||||
error.message,
|
||||
)),
|
||||
Err(error) => {
|
||||
// A bastion host's link logs in once; its terminal is open.
|
||||
let message = if target.login().shell_only
|
||||
&& matches!(
|
||||
error.kind,
|
||||
ExecErrorKind::MissingCredential | ExecErrorKind::Connect
|
||||
) {
|
||||
t!("cli.exec.try_terminal", message = error.message).to_string()
|
||||
} else {
|
||||
error.message
|
||||
};
|
||||
Err(CliError::new(
|
||||
match error.kind {
|
||||
ExecErrorKind::HostKeyUnknown => ErrorCode::HostKeyUnknown,
|
||||
ExecErrorKind::HostKeyChanged => ErrorCode::HostKeyChanged,
|
||||
ExecErrorKind::MissingCredential => ErrorCode::MissingCredential,
|
||||
ExecErrorKind::Connect | ExecErrorKind::Aborted => ErrorCode::ConnectFailed,
|
||||
},
|
||||
message,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +203,7 @@ impl CliBackend for SshCliBackend {
|
||||
if !source.is_dir() {
|
||||
return Err(CliError::new(
|
||||
ErrorCode::BadRequest,
|
||||
format!("本地目录不存在:{}", source.display()),
|
||||
t!("cli.transfer.no_local_folder", path = source.display()),
|
||||
));
|
||||
}
|
||||
let request = UploadRequest::sync(source.to_path_buf(), remote_path(destination)?, delete);
|
||||
@@ -225,15 +238,15 @@ impl Transfer {
|
||||
let refused = match prompt.kind() {
|
||||
ConnectionPromptKind::UnknownHost(_) => CliError::new(
|
||||
ErrorCode::HostKeyUnknown,
|
||||
"尚未信任这台主机的密钥:请先在 ShellRS 中连接一次这台主机",
|
||||
t!("cli.transfer.host_key_unknown"),
|
||||
),
|
||||
ConnectionPromptKind::HostKeyChanged(_) => CliError::new(
|
||||
ErrorCode::HostKeyChanged,
|
||||
"主机密钥与已保存的不一致,已拒绝连接。请先核实服务器身份",
|
||||
t!("cli.transfer.host_key_changed"),
|
||||
),
|
||||
ConnectionPromptKind::Authentication(_) => CliError::new(
|
||||
ErrorCode::MissingCredential,
|
||||
"这台主机没有保存可用的密码或口令:请先在 ShellRS 中连接一次这台主机并保存密码",
|
||||
t!("cli.transfer.missing_credential"),
|
||||
),
|
||||
};
|
||||
self.refused.get_or_insert(refused);
|
||||
@@ -264,7 +277,7 @@ impl Transfer {
|
||||
ErrorCode::TransferFailed,
|
||||
self.notice
|
||||
.take()
|
||||
.unwrap_or_else(|| "传输未完成".to_string()),
|
||||
.unwrap_or_else(|| t!("cli.transfer.unfinished").to_string()),
|
||||
)
|
||||
})),
|
||||
}
|
||||
@@ -287,7 +300,10 @@ fn remote_path(path: &str) -> Result<RemotePath, CliError> {
|
||||
let path = path.trim();
|
||||
let path = match path {
|
||||
"" => {
|
||||
return Err(CliError::new(ErrorCode::BadRequest, "远程路径不能为空"));
|
||||
return Err(CliError::new(
|
||||
ErrorCode::BadRequest,
|
||||
t!("cli.transfer.empty_remote_path"),
|
||||
));
|
||||
}
|
||||
"~" => ".".to_string(),
|
||||
_ if path.starts_with("~/") => format!(".{}", &path[1..]),
|
||||
|
||||
@@ -8,15 +8,18 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use gpui_kit::SharedString;
|
||||
use unicode_width::UnicodeWidthStr as _;
|
||||
|
||||
use super::link::OpenLink;
|
||||
use super::protocol::{
|
||||
AuthChoice, CredentialDeleted, CredentialDetails, CredentialKindChoice, Envelope, ErrorCode,
|
||||
FrameKind, HostDeleted, HostDetails, HostInfo, PROTOCOL_VERSION, ProxyChoice, Reply, Request,
|
||||
RouteDetails, TransferCounters, TransferSummary, parse_json, read_frame, write_json,
|
||||
RouteDetails, TerminalContext, TransferCounters, TransferSummary, parse_json, read_frame,
|
||||
write_json,
|
||||
};
|
||||
use crate::host::HostOs;
|
||||
use crate::i18n::{t, tn};
|
||||
|
||||
/// Exit code when the command could not do what was asked at all, as ssh
|
||||
/// uses it.
|
||||
@@ -58,6 +61,9 @@ struct ExecResult<'a> {
|
||||
exit_code: i32,
|
||||
stdout: std::borrow::Cow<'a, str>,
|
||||
stderr: std::borrow::Cow<'a, str>,
|
||||
/// Where an `exec --terminal` command ran.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
context: Option<TerminalContext>,
|
||||
}
|
||||
|
||||
/// `value` as JSON in ASCII alone: every other character escaped, which
|
||||
@@ -100,25 +106,19 @@ impl Asked {
|
||||
}
|
||||
}
|
||||
|
||||
/// What an app from before a request was added says about it: it cannot
|
||||
/// read the request, and serde names what it did not know.
|
||||
const OLDER_APP: &str =
|
||||
"正在运行的 ShellRS 不认识这个命令:请把 ShellRS 升级到最新版本,然后重新启动它";
|
||||
|
||||
/// Send `request` to the app listening at `endpoint` and print the answer;
|
||||
/// the exit code.
|
||||
pub fn run(endpoint: &Path, request: Request, console: &mut Console) -> i32 {
|
||||
let result = connect(endpoint).and_then(|stream| talk(&stream, request, console));
|
||||
match result {
|
||||
Ok(code) => code,
|
||||
Err(Failure::NotRunning) => console.error(
|
||||
ErrorCode::NotRunning,
|
||||
"ShellRS 未运行:请先打开 ShellRS,并在 设置 → 外部 CLI 中打开「启用外部 CLI」",
|
||||
),
|
||||
Err(Failure::Refused(message)) => console.error(ErrorCode::ConnectFailed, message),
|
||||
Err(Failure::NotRunning) => {
|
||||
console.error(ErrorCode::NotRunning, &t!("cli.client.not_running"))
|
||||
}
|
||||
Err(Failure::Refused(message)) => console.error(ErrorCode::ConnectFailed, &message),
|
||||
Err(Failure::Broken(error)) => console.error(
|
||||
ErrorCode::ConnectFailed,
|
||||
&format!("与 ShellRS 的连接意外中断:{error}"),
|
||||
&t!("cli.client.broken", error = error),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -172,7 +172,7 @@ where
|
||||
enum Failure {
|
||||
NotRunning,
|
||||
/// Not allowed to talk to the app, or not willing to.
|
||||
Refused(&'static str),
|
||||
Refused(SharedString),
|
||||
Broken(io::Error),
|
||||
}
|
||||
|
||||
@@ -182,19 +182,20 @@ impl From<io::Error> for Failure {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a sandbox that forbids local connections looks like.
|
||||
const NO_PERMISSION: &str =
|
||||
"没有权限连接 ShellRS:Agent 可能运行在沙箱中,需要允许它访问本机的进程间通信";
|
||||
|
||||
#[cfg(unix)]
|
||||
fn connect(socket: &Path) -> Result<std::os::unix::net::UnixStream, Failure> {
|
||||
std::os::unix::net::UnixStream::connect(socket).map_err(|error| match error.kind() {
|
||||
io::ErrorKind::NotFound | io::ErrorKind::ConnectionRefused => Failure::NotRunning,
|
||||
io::ErrorKind::PermissionDenied => Failure::Refused(NO_PERMISSION),
|
||||
io::ErrorKind::PermissionDenied => Failure::Refused(no_permission()),
|
||||
_ => error.into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// What a sandbox that forbids local connections looks like.
|
||||
fn no_permission() -> SharedString {
|
||||
t!("cli.client.no_permission")
|
||||
}
|
||||
|
||||
/// Open the app's pipe, and make sure it is the app's before saying
|
||||
/// anything: the name is the machine's, so someone else could hold it.
|
||||
#[cfg(windows)]
|
||||
@@ -203,20 +204,18 @@ fn connect(pipe: &Path) -> Result<std::fs::File, Failure> {
|
||||
|
||||
let pipe = pipe_windows::open(pipe).map_err(|error| match error.kind() {
|
||||
io::ErrorKind::NotFound => Failure::NotRunning,
|
||||
io::ErrorKind::PermissionDenied => Failure::Refused(NO_PERMISSION),
|
||||
io::ErrorKind::PermissionDenied => Failure::Refused(no_permission()),
|
||||
_ => error.into(),
|
||||
})?;
|
||||
if !pipe_windows::owned_by_current_user(&pipe)? {
|
||||
return Err(Failure::Refused(
|
||||
"外部 CLI 的管道不属于当前用户,已拒绝连接",
|
||||
));
|
||||
return Err(Failure::Refused(t!("cli.client.foreign_pipe")));
|
||||
}
|
||||
Ok(pipe)
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn connect(_: &Path) -> Result<std::fs::File, Failure> {
|
||||
Err(Failure::Refused("此系统暂不支持外部 CLI"))
|
||||
Err(Failure::Refused(t!("cli.unsupported")))
|
||||
}
|
||||
|
||||
/// Send the request and print the replies until the last one.
|
||||
@@ -238,6 +237,7 @@ where
|
||||
let mut progress = ProgressLine::default();
|
||||
// What `exec --json` holds until the command ends.
|
||||
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
|
||||
let mut context = None;
|
||||
loop {
|
||||
let Some((kind, payload)) = read_frame(&mut reader)? else {
|
||||
return Err(io::Error::from(io::ErrorKind::UnexpectedEof).into());
|
||||
@@ -291,11 +291,14 @@ where
|
||||
print_credential_deleted(&deleted, console)?;
|
||||
return Ok(0);
|
||||
}
|
||||
// Only `--json` prints where the command ran.
|
||||
Reply::Context(found) => context = Some(found),
|
||||
Reply::Exit { code } if console.exec_json => {
|
||||
let result = ExecResult {
|
||||
exit_code: code,
|
||||
stdout: String::from_utf8_lossy(&stdout),
|
||||
stderr: String::from_utf8_lossy(&stderr),
|
||||
context: context.take(),
|
||||
};
|
||||
writeln!(console.stdout, "{}", ascii_json(&result))?;
|
||||
return Ok(code);
|
||||
@@ -306,7 +309,11 @@ where
|
||||
code: ErrorCode::BadRequest,
|
||||
message,
|
||||
} if message.contains("unknown variant") => {
|
||||
return Ok(console.error(ErrorCode::VersionMismatch, OLDER_APP));
|
||||
// What an app from before the request was added
|
||||
// says: serde names what it did not know.
|
||||
return Ok(
|
||||
console.error(ErrorCode::VersionMismatch, &t!("cli.client.older_app"))
|
||||
);
|
||||
}
|
||||
Reply::Error { code, message } => return Ok(console.error(code, &message)),
|
||||
}
|
||||
@@ -353,7 +360,7 @@ fn print_json(value: &impl serde::Serialize, console: &mut Console) -> io::Resul
|
||||
|
||||
/// Rows lined up in columns, wide characters counted as two.
|
||||
fn print_table<const N: usize>(
|
||||
header: [&str; N],
|
||||
header: [SharedString; N],
|
||||
rows: &[[String; N]],
|
||||
console: &mut Console,
|
||||
) -> io::Result<()> {
|
||||
@@ -379,7 +386,7 @@ fn print_table<const N: usize>(
|
||||
}
|
||||
|
||||
/// Labelled lines, the labels lined up.
|
||||
fn print_fields(fields: &[(&str, String)], console: &mut Console) -> io::Result<()> {
|
||||
fn print_fields(fields: &[(SharedString, String)], console: &mut Console) -> io::Result<()> {
|
||||
let width = fields
|
||||
.iter()
|
||||
.map(|(label, _)| label.width())
|
||||
@@ -392,12 +399,14 @@ fn print_fields(fields: &[(&str, String)], console: &mut Console) -> io::Result<
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn saved_word(saved: Option<bool>) -> &'static str {
|
||||
match saved {
|
||||
Some(true) => "(已保存)",
|
||||
Some(false) => "(未保存)",
|
||||
None => "",
|
||||
}
|
||||
/// `label` saying whether its password is saved, when that is known.
|
||||
fn with_saved(label: SharedString, saved: Option<bool>) -> String {
|
||||
let text = match saved {
|
||||
Some(true) => t!("cli.show.saved", label = label),
|
||||
Some(false) => t!("cli.show.not_saved", label = label),
|
||||
None => label,
|
||||
};
|
||||
text.to_string()
|
||||
}
|
||||
|
||||
fn print_host(host: &HostDetails, asked: Asked, console: &mut Console) -> io::Result<()> {
|
||||
@@ -408,80 +417,86 @@ fn print_host(host: &HostDetails, asked: Asked, console: &mut Console) -> io::Re
|
||||
Asked::Create => {
|
||||
return writeln!(
|
||||
console.stdout,
|
||||
"已创建主机「{}」,ID {}",
|
||||
host.name, host.id
|
||||
"{}",
|
||||
t!("cli.host.created", name = host.name, id = host.id)
|
||||
);
|
||||
}
|
||||
Asked::Update => {
|
||||
return writeln!(
|
||||
console.stdout,
|
||||
"已保存主机「{}」(ID {})",
|
||||
host.name, host.id
|
||||
"{}",
|
||||
t!("cli.host.saved", name = host.name, id = host.id)
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let auth = match host.auth {
|
||||
AuthChoice::Password => format!("密码{}", saved_word(host.password_saved)),
|
||||
AuthChoice::Credential => {
|
||||
format!("凭据 {}", host.credential.as_deref().unwrap_or_default())
|
||||
}
|
||||
AuthChoice::NoPassword => "无密码".to_string(),
|
||||
AuthChoice::Password => with_saved(t!("cli.auth.password"), host.password_saved),
|
||||
AuthChoice::Credential => t!(
|
||||
"cli.auth.credential",
|
||||
id = host.credential.as_deref().unwrap_or_default()
|
||||
)
|
||||
.to_string(),
|
||||
AuthChoice::NoPassword => t!("cli.auth.no_password").to_string(),
|
||||
};
|
||||
let route = match &host.route {
|
||||
RouteDetails::Direct => "直接连接".to_string(),
|
||||
RouteDetails::Jump { hosts } => format!(
|
||||
"SSH 跳板 {}",
|
||||
hosts
|
||||
RouteDetails::Direct => t!("cli.route.direct").to_string(),
|
||||
RouteDetails::Jump { hosts } => {
|
||||
let deleted = t!("cli.route.deleted_host");
|
||||
let hosts = hosts
|
||||
.iter()
|
||||
.map(|hop| hop.as_deref().unwrap_or("(已删除的主机)"))
|
||||
.map(|hop| hop.as_deref().unwrap_or(deleted.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" → ")
|
||||
),
|
||||
.join(" → ");
|
||||
t!("cli.route.jump", hosts = hosts).to_string()
|
||||
}
|
||||
RouteDetails::Proxy {
|
||||
kind,
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
password_saved,
|
||||
} => format!(
|
||||
"{} {}{host}:{port}{}",
|
||||
match kind {
|
||||
ProxyChoice::Http => "HTTP 代理",
|
||||
ProxyChoice::Socks5 => "SOCKS5 代理",
|
||||
},
|
||||
user.as_deref()
|
||||
} => {
|
||||
let user = user
|
||||
.as_deref()
|
||||
.map(|user| format!("{user}@"))
|
||||
.unwrap_or_default(),
|
||||
password_saved
|
||||
.map(|saved| if saved {
|
||||
"(密码已保存)"
|
||||
} else {
|
||||
"(密码未保存)"
|
||||
})
|
||||
.unwrap_or_default()
|
||||
),
|
||||
.unwrap_or_default();
|
||||
let address = format!("{user}{host}:{port}");
|
||||
let proxy = match kind {
|
||||
ProxyChoice::Http => t!("cli.route.http", address = address),
|
||||
ProxyChoice::Socks5 => t!("cli.route.socks5", address = address),
|
||||
};
|
||||
let proxy = match password_saved {
|
||||
Some(true) => t!("cli.route.password_saved", proxy = proxy),
|
||||
Some(false) => t!("cli.route.password_not_saved", proxy = proxy),
|
||||
None => proxy,
|
||||
};
|
||||
proxy.to_string()
|
||||
}
|
||||
};
|
||||
let mut fields = vec![
|
||||
("ID", host.id.clone()),
|
||||
("名称", host.name.clone()),
|
||||
(t!("cli.field.id"), host.id.clone()),
|
||||
(t!("cli.field.name"), host.name.clone()),
|
||||
(
|
||||
"分组",
|
||||
t!("cli.field.group"),
|
||||
if host.temporary {
|
||||
"(未保存)".to_string()
|
||||
t!("cli.group.not_saved").to_string()
|
||||
} else {
|
||||
host.group.clone().unwrap_or_default()
|
||||
},
|
||||
),
|
||||
("地址", format!("{}@{}:{}", host.user, host.host, host.port)),
|
||||
("认证", auth),
|
||||
("连接方式", route),
|
||||
(
|
||||
t!("cli.field.address"),
|
||||
format!("{}@{}:{}", host.user, host.host, host.port),
|
||||
),
|
||||
(t!("cli.field.auth"), auth),
|
||||
(t!("cli.field.route"), route),
|
||||
];
|
||||
if let Some(os) = host.os.as_deref().and_then(HostOs::from_stored) {
|
||||
fields.push(("系统", os.label().to_string()));
|
||||
fields.push((t!("cli.field.os"), os.label().to_string()));
|
||||
}
|
||||
if !host.notes.is_empty() {
|
||||
fields.push(("备注", host.notes.clone()));
|
||||
fields.push((t!("cli.field.notes"), host.notes.clone()));
|
||||
}
|
||||
print_fields(&fields, console)
|
||||
}
|
||||
@@ -490,24 +505,31 @@ fn print_host_deleted(deleted: &HostDeleted, console: &mut Console) -> io::Resul
|
||||
if console.json {
|
||||
return print_json(deleted, console);
|
||||
}
|
||||
let mut line = format!("已删除主机「{}」", deleted.host.name);
|
||||
if deleted.forwards > 0 {
|
||||
line.push_str(&format!(",连同它的 {} 条端口转发规则", deleted.forwards));
|
||||
}
|
||||
let name = &deleted.host.name;
|
||||
let mut line = if deleted.forwards > 0 {
|
||||
tn!(
|
||||
"cli.host.deleted_with_forwards",
|
||||
deleted.forwards,
|
||||
name = name
|
||||
)
|
||||
} else {
|
||||
t!("cli.host.deleted", name = name)
|
||||
};
|
||||
if deleted.jump_users > 0 {
|
||||
line.push_str(&format!(
|
||||
";{} 台主机经它跳转,现在那一跳是「已删除的主机」",
|
||||
deleted.jump_users
|
||||
));
|
||||
line = tn!(
|
||||
"cli.host.deleted_jump_users",
|
||||
deleted.jump_users,
|
||||
deleted = line
|
||||
);
|
||||
}
|
||||
writeln!(console.stdout, "{line}")
|
||||
}
|
||||
|
||||
fn kind_label(kind: CredentialKindChoice) -> &'static str {
|
||||
fn kind_label(kind: CredentialKindChoice) -> SharedString {
|
||||
match kind {
|
||||
CredentialKindChoice::Password => "密码",
|
||||
CredentialKindChoice::Key => "密钥",
|
||||
CredentialKindChoice::Agent => "SSH Agent",
|
||||
CredentialKindChoice::Password => t!("cli.kind.password"),
|
||||
CredentialKindChoice::Key => t!("cli.kind.key"),
|
||||
CredentialKindChoice::Agent => t!("cli.kind.agent"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,7 +538,7 @@ fn print_credentials(credentials: &[CredentialDetails], console: &mut Console) -
|
||||
return print_json(&credentials, console);
|
||||
}
|
||||
if credentials.is_empty() {
|
||||
return writeln!(console.stderr, "没有匹配的凭据");
|
||||
return writeln!(console.stderr, "{}", t!("cli.credentials.none"));
|
||||
}
|
||||
let rows: Vec<[String; 5]> = credentials
|
||||
.iter()
|
||||
@@ -530,7 +552,17 @@ fn print_credentials(credentials: &[CredentialDetails], console: &mut Console) -
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
print_table(["ID", "名称", "类型", "用户名", "主机数"], &rows, console)
|
||||
print_table(
|
||||
[
|
||||
t!("cli.column.id"),
|
||||
t!("cli.column.name"),
|
||||
t!("cli.column.kind"),
|
||||
t!("cli.column.user"),
|
||||
t!("cli.column.hosts"),
|
||||
],
|
||||
&rows,
|
||||
console,
|
||||
)
|
||||
}
|
||||
|
||||
fn print_credential(
|
||||
@@ -545,43 +577,52 @@ fn print_credential(
|
||||
Asked::Create => {
|
||||
return writeln!(
|
||||
console.stdout,
|
||||
"已创建凭据「{}」,ID {}",
|
||||
credential.name, credential.id
|
||||
"{}",
|
||||
t!(
|
||||
"cli.credential.created",
|
||||
name = credential.name,
|
||||
id = credential.id
|
||||
)
|
||||
);
|
||||
}
|
||||
Asked::Update => {
|
||||
return writeln!(
|
||||
console.stdout,
|
||||
"已保存凭据「{}」(ID {})",
|
||||
credential.name, credential.id
|
||||
"{}",
|
||||
t!(
|
||||
"cli.credential.saved",
|
||||
name = credential.name,
|
||||
id = credential.id
|
||||
)
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let mut fields = vec![
|
||||
("ID", credential.id.clone()),
|
||||
("名称", credential.name.clone()),
|
||||
(t!("cli.field.id"), credential.id.clone()),
|
||||
(t!("cli.field.name"), credential.name.clone()),
|
||||
(
|
||||
"类型",
|
||||
format!(
|
||||
"{}{}",
|
||||
kind_label(credential.kind),
|
||||
saved_word(credential.password_saved)
|
||||
),
|
||||
t!("cli.field.kind"),
|
||||
with_saved(kind_label(credential.kind), credential.password_saved),
|
||||
),
|
||||
("用户名", credential.user.clone()),
|
||||
(t!("cli.field.user"), credential.user.clone()),
|
||||
];
|
||||
if credential.kind == CredentialKindChoice::Key {
|
||||
let key = match (&credential.key_path, credential.kept) {
|
||||
(Some(path), _) => path.clone(),
|
||||
(None, _) => "由 ShellRS 保存".to_string(),
|
||||
(None, _) => t!("cli.credential.kept_key").to_string(),
|
||||
};
|
||||
fields.push(("私钥", key));
|
||||
fields.push((t!("cli.field.private_key"), key));
|
||||
if let Some(saved) = credential.passphrase_saved {
|
||||
fields.push(("口令", if saved { "已保存" } else { "未保存" }.to_string()));
|
||||
let saved = if saved {
|
||||
t!("cli.credential.passphrase_saved")
|
||||
} else {
|
||||
t!("cli.credential.passphrase_not_saved")
|
||||
};
|
||||
fields.push((t!("cli.field.passphrase"), saved.to_string()));
|
||||
}
|
||||
}
|
||||
fields.push(("主机", credential.hosts.join(", ")));
|
||||
fields.push((t!("cli.field.hosts"), credential.hosts.join(", ")));
|
||||
print_fields(&fields, console)
|
||||
}
|
||||
|
||||
@@ -589,18 +630,20 @@ fn print_credential_deleted(deleted: &CredentialDeleted, console: &mut Console)
|
||||
if console.json {
|
||||
return print_json(deleted, console);
|
||||
}
|
||||
let mut line = format!("已删除凭据「{}」", deleted.credential.name);
|
||||
let mut line = t!("cli.credential.deleted", name = deleted.credential.name);
|
||||
if !deleted.released.is_empty() {
|
||||
let names: Vec<&str> = deleted
|
||||
.released
|
||||
.iter()
|
||||
.map(|host| host.name.as_str())
|
||||
.collect();
|
||||
line.push_str(&format!(
|
||||
";{} 台主机改为自己登录:{}",
|
||||
let separator = t!("common.list.separator");
|
||||
line = tn!(
|
||||
"cli.credential.deleted_released",
|
||||
names.len(),
|
||||
names.join("、")
|
||||
));
|
||||
deleted = line,
|
||||
hosts = names.join(separator.as_str())
|
||||
);
|
||||
}
|
||||
writeln!(console.stdout, "{line}")
|
||||
}
|
||||
@@ -610,7 +653,7 @@ fn print_hosts(hosts: &[HostInfo], console: &mut Console) -> io::Result<()> {
|
||||
return print_json(&hosts, console);
|
||||
}
|
||||
if hosts.is_empty() {
|
||||
return writeln!(console.stderr, "没有匹配的主机");
|
||||
return writeln!(console.stderr, "{}", t!("cli.hosts.none"));
|
||||
}
|
||||
let rows: Vec<[String; 5]> = hosts
|
||||
.iter()
|
||||
@@ -621,7 +664,7 @@ fn print_hosts(hosts: &[HostInfo], console: &mut Console) -> io::Result<()> {
|
||||
// 临时连接 and 外部连接 alike: an agent needs no telling
|
||||
// them apart, only that the host is not saved.
|
||||
if host.temporary {
|
||||
"(未保存)".to_string()
|
||||
t!("cli.group.not_saved").to_string()
|
||||
} else {
|
||||
host.group.clone().unwrap_or_default()
|
||||
},
|
||||
@@ -634,7 +677,17 @@ fn print_hosts(hosts: &[HostInfo], console: &mut Console) -> io::Result<()> {
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
print_table(["ID", "名称", "分组", "地址", "系统"], &rows, console)
|
||||
print_table(
|
||||
[
|
||||
t!("cli.column.id"),
|
||||
t!("cli.column.name"),
|
||||
t!("cli.column.group"),
|
||||
t!("cli.column.address"),
|
||||
t!("cli.column.os"),
|
||||
],
|
||||
&rows,
|
||||
console,
|
||||
)
|
||||
}
|
||||
|
||||
/// A sync's skipped items are the ones that had not changed.
|
||||
@@ -642,20 +695,35 @@ fn print_summary(summary: &TransferSummary, sync: bool, console: &mut Console) -
|
||||
if console.json {
|
||||
print_json(summary, console)?;
|
||||
} else {
|
||||
let mut line = format!(
|
||||
"已传输 {} 个文件,共 {}",
|
||||
let mut line = tn!(
|
||||
"cli.summary.transferred",
|
||||
summary.files,
|
||||
format_bytes(summary.bytes)
|
||||
size = format_bytes(summary.bytes)
|
||||
);
|
||||
if summary.skipped > 0 {
|
||||
let word = if sync { "未变" } else { "跳过" };
|
||||
line.push_str(&format!(",{word} {} 个", summary.skipped));
|
||||
line = if sync {
|
||||
t!(
|
||||
"cli.summary.unchanged",
|
||||
summary = line,
|
||||
count = summary.skipped
|
||||
)
|
||||
} else {
|
||||
t!(
|
||||
"cli.summary.skipped",
|
||||
summary = line,
|
||||
count = summary.skipped
|
||||
)
|
||||
};
|
||||
}
|
||||
if summary.deleted > 0 {
|
||||
line.push_str(&format!(",删除 {} 个", summary.deleted));
|
||||
line = t!(
|
||||
"cli.summary.deleted",
|
||||
summary = line,
|
||||
count = summary.deleted
|
||||
);
|
||||
}
|
||||
if summary.failed > 0 {
|
||||
line.push_str(&format!(",失败 {} 个", summary.failed));
|
||||
line = t!("cli.summary.failed", summary = line, count = summary.failed);
|
||||
}
|
||||
writeln!(console.stdout, "{line}")?;
|
||||
for failure in &summary.failures {
|
||||
@@ -666,13 +734,14 @@ fn print_summary(summary: &TransferSummary, sync: bool, console: &mut Console) -
|
||||
}
|
||||
|
||||
fn progress_line(counters: TransferCounters) -> String {
|
||||
format!(
|
||||
"{}/{} 个文件,{} / {}",
|
||||
counters.files,
|
||||
counters.total_files,
|
||||
format_bytes(counters.bytes),
|
||||
format_bytes(counters.total_bytes)
|
||||
t!(
|
||||
"cli.progress",
|
||||
files = counters.files,
|
||||
total_files = counters.total_files,
|
||||
bytes = format_bytes(counters.bytes),
|
||||
total_bytes = format_bytes(counters.total_bytes)
|
||||
)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn format_bytes(bytes: u64) -> String {
|
||||
@@ -688,3 +757,143 @@ fn format_bytes(bytes: u64) -> String {
|
||||
}
|
||||
format!("{value:.1} {}", UNITS[unit])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// What `print` writes to stdout, as on a terminal.
|
||||
fn printed(print: impl FnOnce(&mut Console) -> io::Result<()>) -> String {
|
||||
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
|
||||
print(&mut Console {
|
||||
stdout: &mut stdout,
|
||||
stderr: &mut stderr,
|
||||
json: false,
|
||||
exec_json: false,
|
||||
stderr_is_terminal: false,
|
||||
})
|
||||
.unwrap();
|
||||
String::from_utf8(stdout).unwrap()
|
||||
}
|
||||
|
||||
fn host(name: &str) -> HostDetails {
|
||||
HostDetails {
|
||||
id: "Jwg5rHvXCxw89paM".into(),
|
||||
name: name.into(),
|
||||
group: None,
|
||||
host: "10.0.0.9".into(),
|
||||
port: 22,
|
||||
user: "root".into(),
|
||||
auth: AuthChoice::Password,
|
||||
credential: None,
|
||||
password_saved: None,
|
||||
route: RouteDetails::Direct,
|
||||
notes: String::new(),
|
||||
os: None,
|
||||
temporary: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn deleted_host(forwards: u64, jump_users: u64) -> String {
|
||||
let deleted = HostDeleted {
|
||||
host: host("web"),
|
||||
forwards,
|
||||
jump_users,
|
||||
};
|
||||
printed(|console| print_host_deleted(&deleted, console))
|
||||
}
|
||||
|
||||
fn deleted_credential(released: &[&str]) -> String {
|
||||
let info = |name: &&str| HostInfo {
|
||||
id: String::new(),
|
||||
name: name.to_string(),
|
||||
group: None,
|
||||
user: "root".into(),
|
||||
host: "10.0.0.9".into(),
|
||||
port: 22,
|
||||
os: None,
|
||||
temporary: false,
|
||||
};
|
||||
let deleted = CredentialDeleted {
|
||||
credential: CredentialDetails {
|
||||
id: String::new(),
|
||||
name: "deploy".into(),
|
||||
kind: CredentialKindChoice::Password,
|
||||
user: "deploy".into(),
|
||||
key_path: None,
|
||||
kept: false,
|
||||
hosts: Vec::new(),
|
||||
password_saved: None,
|
||||
passphrase_saved: None,
|
||||
},
|
||||
released: released.iter().map(info).collect(),
|
||||
};
|
||||
printed(|console| print_credential_deleted(&deleted, console))
|
||||
}
|
||||
|
||||
fn summary(files: u64, skipped: u64, sync: bool) -> String {
|
||||
let summary = TransferSummary {
|
||||
files,
|
||||
bytes: 2048,
|
||||
skipped,
|
||||
failed: 1,
|
||||
deleted: 0,
|
||||
failures: Vec::new(),
|
||||
};
|
||||
printed(|console| print_summary(&summary, sync, console).map(drop))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_was_done_reads_as_one_line_in_either_language() {
|
||||
crate::i18n::isolate_thread();
|
||||
crate::i18n::set_locale("zh-CN");
|
||||
assert_eq!(deleted_host(0, 0), "已删除主机「web」\n");
|
||||
assert_eq!(
|
||||
deleted_host(2, 3),
|
||||
"已删除主机「web」,连同它的 2 条端口转发规则;\
|
||||
3 台主机经它跳转,现在那一跳是「已删除的主机」\n"
|
||||
);
|
||||
assert_eq!(
|
||||
deleted_credential(&["web", "db"]),
|
||||
"已删除凭据「deploy」;2 台主机改为自己登录:web、db\n"
|
||||
);
|
||||
assert_eq!(
|
||||
summary(2, 40, true),
|
||||
"已传输 2 个文件,共 2.0 KB,未变 40 个,失败 1 个\n"
|
||||
);
|
||||
|
||||
crate::i18n::set_locale("en");
|
||||
assert_eq!(deleted_host(0, 0), "Deleted host \"web\"\n");
|
||||
assert_eq!(
|
||||
deleted_host(1, 1),
|
||||
"Deleted host \"web\" and its port forwarding rule; \
|
||||
1 host jumped through it and now has a deleted jump host\n"
|
||||
);
|
||||
assert_eq!(
|
||||
deleted_credential(&["web", "db"]),
|
||||
"Deleted credential \"deploy\"; 2 hosts now log in on their own: web, db\n"
|
||||
);
|
||||
assert_eq!(
|
||||
summary(1, 4, false),
|
||||
"Transferred 1 file (2.0 KB), 4 skipped, 1 failed\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_english_show_lines_up_its_labels() {
|
||||
crate::i18n::isolate_thread();
|
||||
crate::i18n::set_locale("en");
|
||||
let mut shown = host("web");
|
||||
shown.password_saved = Some(true);
|
||||
let shown = printed(|console| print_host(&shown, Asked::Show, console));
|
||||
assert!(
|
||||
shown.contains("\nAuthentication Password (saved)\n"),
|
||||
"{shown}"
|
||||
);
|
||||
assert!(shown.contains("\nConnection Direct\n"), "{shown}");
|
||||
assert!(
|
||||
shown.starts_with("ID Jwg5rHvXCxw89paM\n"),
|
||||
"{shown}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,10 @@
|
||||
use std::path::Path;
|
||||
use std::{fs, io, path::PathBuf};
|
||||
|
||||
use gpui_kit::SharedString;
|
||||
|
||||
use crate::i18n::t;
|
||||
|
||||
/// The skill that teaches an agent to use `shellrs`.
|
||||
pub const SKILL: &str = include_str!("SKILL.md");
|
||||
|
||||
@@ -36,19 +40,19 @@ impl AgentKind {
|
||||
AgentKind::WorkBuddy,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
pub fn label(self) -> SharedString {
|
||||
match self {
|
||||
AgentKind::Generic => "通用 Agent",
|
||||
AgentKind::Codex => "Codex",
|
||||
AgentKind::ClaudeCode => "Claude Code",
|
||||
AgentKind::OpenCode => "OpenCode",
|
||||
AgentKind::WorkBuddy => "WorkBuddy",
|
||||
AgentKind::Generic => t!("cli.agent.generic"),
|
||||
AgentKind::Codex => "Codex".into(),
|
||||
AgentKind::ClaudeCode => "Claude Code".into(),
|
||||
AgentKind::OpenCode => "OpenCode".into(),
|
||||
AgentKind::WorkBuddy => "WorkBuddy".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn description(self) -> Option<&'static str> {
|
||||
pub fn description(self) -> Option<SharedString> {
|
||||
match self {
|
||||
AgentKind::Generic => Some("安装到 Agent Skills 标准目录,供支持该目录的 Agent 使用。"),
|
||||
AgentKind::Generic => Some(t!("cli.agent.generic_description")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -277,7 +281,7 @@ fn link_binary(paths: &IntegrationPaths) -> io::Result<()> {
|
||||
#[cfg(not(unix))]
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"此系统暂不支持安装 shellrs 命令",
|
||||
t!("cli.install.unsupported").to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -300,7 +304,7 @@ fn remove_link(paths: &IntegrationPaths) -> io::Result<()> {
|
||||
fn occupied(link: &Path) -> io::Error {
|
||||
io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
format!("{} 已被其他程序占用,ShellRS 不会改动它", link.display()),
|
||||
t!("cli.install.occupied", path = link.display()).to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -323,7 +327,10 @@ fn run_as_administrator(command: &str) -> io::Result<()> {
|
||||
let message = String::from_utf8_lossy(&output.stderr);
|
||||
// AppleScript's "User canceled." is error -128.
|
||||
if message.contains("-128") {
|
||||
return Err(io::Error::new(io::ErrorKind::Interrupted, "已取消"));
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Interrupted,
|
||||
t!("cli.install.cancelled").to_string(),
|
||||
));
|
||||
}
|
||||
Err(io::Error::other(message.trim().to_string()))
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use std::{
|
||||
};
|
||||
|
||||
use super::install::{BinaryStatus, IntegrationPaths, UserPath};
|
||||
use crate::i18n::t;
|
||||
|
||||
pub(super) fn binary_status(paths: &IntegrationPaths) -> BinaryStatus {
|
||||
let source = fs::metadata(&paths.exe).ok();
|
||||
@@ -56,7 +57,7 @@ pub(super) fn install_binary(paths: &IntegrationPaths) -> io::Result<()> {
|
||||
if !paths.exe.exists() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
format!("找不到 {}", paths.exe.display()),
|
||||
t!("cli.install.missing", path = paths.exe.display()).to_string(),
|
||||
));
|
||||
}
|
||||
let folder = folder(paths);
|
||||
|
||||
@@ -46,7 +46,7 @@ fn without_password(url: &str) -> String {
|
||||
}
|
||||
parsed.to_string()
|
||||
}
|
||||
Err(_) => "<无法解析的链接>".into(),
|
||||
Err(_) => "<unreadable link>".into(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::host::{
|
||||
AuthKind, Credential, CredentialDraft, CredentialId, CredentialKind, GroupId, Host, HostDraft,
|
||||
HostId, HostStore, PastedKey, ProxyKind, ProxySettings, Route,
|
||||
};
|
||||
use crate::i18n::t;
|
||||
use crate::secrets::SecretRef;
|
||||
|
||||
use super::protocol::{
|
||||
@@ -202,9 +203,10 @@ pub fn with_saved_credential_secrets(
|
||||
/// The credential search: its name, user, kind or ID.
|
||||
pub fn credential_matches(details: &CredentialDetails, query: &str) -> bool {
|
||||
let needle = query.trim().to_lowercase();
|
||||
// Both languages' words, whichever the agent or its user types.
|
||||
let kind = match details.kind {
|
||||
CredentialKindChoice::Password => "password 密码",
|
||||
CredentialKindChoice::Key => "key 密钥",
|
||||
CredentialKindChoice::Password => "password 密码", // i18n: keep
|
||||
CredentialKindChoice::Key => "key 密钥", // i18n: keep
|
||||
CredentialKindChoice::Agent => "agent ssh agent",
|
||||
};
|
||||
needle.is_empty()
|
||||
@@ -218,10 +220,17 @@ fn bad(message: impl Into<String>) -> CliError {
|
||||
CliError::new(ErrorCode::BadRequest, message)
|
||||
}
|
||||
|
||||
fn host_not_found(id: &str) -> CliError {
|
||||
pub(super) fn host_not_found(id: &str) -> CliError {
|
||||
CliError::new(
|
||||
ErrorCode::HostNotFound,
|
||||
format!("没有 ID 为 {id} 的主机:请用 shellrs hosts list 查看"),
|
||||
t!("cli.error.host_not_found", id = id),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn credential_not_found(id: &str) -> CliError {
|
||||
CliError::new(
|
||||
ErrorCode::CredentialNotFound,
|
||||
t!("cli.error.credential_not_found", id = id),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -239,10 +248,7 @@ pub fn find_host<'a>(store: &'a HostStore, id: &str) -> Result<&'a Host, CliErro
|
||||
pub fn find_saved_host(store: &HostStore, id: &str) -> Result<HostId, CliError> {
|
||||
let host = find_host(store, id)?;
|
||||
if store.is_temporary(host.id) {
|
||||
return Err(bad(format!(
|
||||
"「{}」是没有保存的连接(临时连接或外部连接),不能修改或删除:关闭它的标签即可",
|
||||
host.name
|
||||
)));
|
||||
return Err(bad(t!("cli.host.temporary", name = host.name)));
|
||||
}
|
||||
Ok(host.id)
|
||||
}
|
||||
@@ -252,12 +258,7 @@ pub fn find_credential<'a>(store: &'a HostStore, id: &str) -> Result<&'a Credent
|
||||
.credentials()
|
||||
.iter()
|
||||
.find(|credential| credential.keychain_id.as_str() == id.trim())
|
||||
.ok_or_else(|| {
|
||||
CliError::new(
|
||||
ErrorCode::CredentialNotFound,
|
||||
format!("没有 ID 为 {id} 的凭据:请用 shellrs credentials list 查看"),
|
||||
)
|
||||
})
|
||||
.ok_or_else(|| credential_not_found(id))
|
||||
}
|
||||
|
||||
/// Where a host goes in the tree.
|
||||
@@ -359,9 +360,7 @@ pub fn plan_host(
|
||||
};
|
||||
let credential = match (fields.auth, named) {
|
||||
(Some(AuthChoice::Password | AuthChoice::NoPassword), Some(_)) => {
|
||||
return Err(bad(
|
||||
"auth 为 password 或 no_password 时不能同时指定 credential",
|
||||
));
|
||||
return Err(bad(t!("cli.host.credential_with_own_auth")));
|
||||
}
|
||||
(Some(AuthChoice::Password | AuthChoice::NoPassword), None) => None,
|
||||
(_, Some(named)) => Some(named),
|
||||
@@ -369,15 +368,12 @@ pub fn plan_host(
|
||||
(_, None) => draft.credential.and_then(|id| store.credential(id)),
|
||||
};
|
||||
match (fields.auth, credential) {
|
||||
(Some(AuthChoice::Credential), None) => return Err(bad("请选择凭据")),
|
||||
(Some(AuthChoice::Credential), None) => return Err(bad(t!("cli.host.no_credential"))),
|
||||
(_, Some(credential)) => {
|
||||
if let Some(user) = &fields.user
|
||||
&& user.trim() != credential.user.as_ref()
|
||||
{
|
||||
return Err(bad(format!(
|
||||
"使用凭据的主机以凭据的用户名 {} 登录:去掉 user,或改凭据的用户名",
|
||||
credential.user
|
||||
)));
|
||||
return Err(bad(t!("cli.host.credential_user", user = credential.user)));
|
||||
}
|
||||
draft.credential = Some(credential.id);
|
||||
draft.user = credential.user.clone();
|
||||
@@ -401,7 +397,7 @@ pub fn plan_host(
|
||||
}
|
||||
let own_password = draft.credential.is_none() && draft.auth == AuthKind::Password;
|
||||
if !own_password && matches!(fields.password, Some(Some(_))) {
|
||||
return Err(bad("只有 auth 为 password 的主机才保存自己的密码"));
|
||||
return Err(bad(t!("cli.host.password_without_auth")));
|
||||
}
|
||||
|
||||
// The proxy password field, when the route was given at all.
|
||||
@@ -419,7 +415,7 @@ pub fn plan_host(
|
||||
} => {
|
||||
let user = user.unwrap_or_default();
|
||||
if user.trim().is_empty() && matches!(password, Some(Some(_))) {
|
||||
return Err(bad("填写代理密码时请同时填写用户名"));
|
||||
return Err(bad(t!("cli.host.proxy_password_without_user")));
|
||||
}
|
||||
proxy_field = Some(password);
|
||||
let kind = match kind {
|
||||
@@ -475,13 +471,13 @@ fn jump_hosts(
|
||||
};
|
||||
let hop = find_host(store, id)?;
|
||||
if store.is_temporary(hop.id) {
|
||||
return Err(bad(format!("跳板只能是保存的主机:{id} 是没有保存的连接")));
|
||||
return Err(bad(t!("cli.host.jump_temporary", id = id)));
|
||||
}
|
||||
if Some(hop.id) == own {
|
||||
return Err(bad("跳板主机不能是这台主机自己"));
|
||||
return Err(bad(t!("cli.host.jump_self")));
|
||||
}
|
||||
if hops.contains(&Some(hop.id)) {
|
||||
return Err(bad(format!("跳板主机 {id} 重复了")));
|
||||
return Err(bad(t!("cli.host.jump_repeated", id = id)));
|
||||
}
|
||||
hops.push(Some(hop.id));
|
||||
}
|
||||
@@ -498,14 +494,11 @@ fn resolve_group(store: &HostStore, path: &str) -> Result<GroupPlan, CliError> {
|
||||
}
|
||||
let names: Vec<&str> = path.split('/').map(str::trim).collect();
|
||||
if names.iter().any(|name| name.is_empty()) {
|
||||
return Err(bad(format!("分组路径「{path}」里有空的一级")));
|
||||
return Err(bad(t!("cli.group.empty_level", path = path)));
|
||||
}
|
||||
let wanted = names.join("/");
|
||||
let ambiguous = |count: usize, path: &str| {
|
||||
bad(format!(
|
||||
"有 {count} 个分组的路径都是「{path}」:请先在 ShellRS 里给它们改名"
|
||||
))
|
||||
};
|
||||
let ambiguous =
|
||||
|count: usize, path: &str| bad(t!("cli.group.ambiguous", count = count, path = path));
|
||||
let whole: Vec<GroupId> = store
|
||||
.groups()
|
||||
.iter()
|
||||
@@ -587,22 +580,25 @@ pub fn plan_credential(
|
||||
draft.user = user.into();
|
||||
}
|
||||
if gives_key && kind != CredentialKind::Key {
|
||||
return Err(bad("只有密钥凭据(kind 为 key)才有私钥"));
|
||||
return Err(bad(t!("cli.credential.key_without_kind")));
|
||||
}
|
||||
if kind != CredentialKind::Password && matches!(fields.password, Some(Some(_))) {
|
||||
return Err(bad("只有密码凭据(kind 为 password)才保存密码"));
|
||||
return Err(bad(t!("cli.credential.password_without_kind")));
|
||||
}
|
||||
if kind != CredentialKind::Key && matches!(fields.passphrase, Some(Some(_))) {
|
||||
return Err(bad("只有密钥凭据(kind 为 key)才有口令"));
|
||||
return Err(bad(t!("cli.credential.passphrase_without_kind")));
|
||||
}
|
||||
let private_key = match (fields.key_path, fields.private_key) {
|
||||
(Some(_), Some(_)) => return Err(bad("key_path 和 private_key 只能给一个")),
|
||||
(Some(_), Some(_)) => return Err(bad(t!("cli.credential.two_keys"))),
|
||||
(Some(path), None) => {
|
||||
if !path.is_absolute() {
|
||||
return Err(bad(format!("私钥文件必须是绝对路径:{}", path.display())));
|
||||
return Err(bad(t!(
|
||||
"cli.credential.key_not_absolute",
|
||||
path = path.display()
|
||||
)));
|
||||
}
|
||||
if !Path::new(&path).is_file() {
|
||||
return Err(bad(format!("私钥文件不存在:{}", path.display())));
|
||||
return Err(bad(t!("cli.credential.key_missing", path = path.display())));
|
||||
}
|
||||
draft = draft.with_key_path(path.display().to_string());
|
||||
None
|
||||
@@ -612,11 +608,12 @@ pub fn plan_credential(
|
||||
if store.key_dir().is_none() {
|
||||
return Err(CliError::new(
|
||||
ErrorCode::SaveFailed,
|
||||
"没有可以保存私钥的目录",
|
||||
t!("cli.credential.no_key_dir"),
|
||||
));
|
||||
}
|
||||
// Stands in until the key file is written, so the draft passes.
|
||||
draft = draft.with_key_path("(新私钥)");
|
||||
// Stands in until the key file is written, so the draft passes;
|
||||
// never saved or shown.
|
||||
draft = draft.with_key_path("(pasted key)");
|
||||
Some(pasted)
|
||||
}
|
||||
(None, None) => None,
|
||||
|
||||
@@ -26,6 +26,8 @@ use std::{
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
use crate::i18n::t;
|
||||
|
||||
pub use backend::SshCliBackend;
|
||||
pub use client::{Console, FAILURE_EXIT, PARTIAL_EXIT, activate_running_app};
|
||||
pub use install::{
|
||||
@@ -38,22 +40,28 @@ pub use link::{OpenLink, link_arguments};
|
||||
pub use manage::CliChange;
|
||||
pub(crate) use manage::{
|
||||
CredentialPlan, GroupPlan, SecretChange, credential_details, credential_secrets,
|
||||
find_credential, find_saved_host, host_details, host_info, host_secrets, plan_credential,
|
||||
plan_host, with_saved_credential_secrets, with_saved_passwords,
|
||||
find_credential, find_host, find_saved_host, host_details, host_info, host_secrets,
|
||||
plan_credential, plan_host, with_saved_credential_secrets, with_saved_passwords,
|
||||
};
|
||||
pub use protocol::{
|
||||
AuthChoice, CliError, CredentialDeleted, CredentialDetails, CredentialFields,
|
||||
CredentialKindChoice, ErrorCode, HostDeleted, HostDetails, HostFields, HostInfo, ProxyChoice,
|
||||
Reply, Request, RouteDetails, RouteFields, Secret, TransferCounters, TransferSummary,
|
||||
Reply, Request, RouteDetails, RouteFields, Secret, TerminalContext, TransferCounters,
|
||||
TransferSummary,
|
||||
};
|
||||
pub use server::{ChangeReply, CliBackend, CliServer, CliTarget};
|
||||
pub use server::{ChangeReply, CliBackend, CliServer, CliTarget, CliUse};
|
||||
|
||||
const AFTER_HELP: &str = "\
|
||||
Hosts are named by the 16-character ID that `shellrs hosts list` prints (the one ShellRS copies with 复制 ID).
|
||||
ShellRS must be running, with 设置 → 外部 CLI → 启用外部 CLI turned on.
|
||||
|
||||
Exit codes: exec exits with the remote command's code; 1 means a transfer finished with failures;
|
||||
255 means shellrs could not do what was asked (the reason is printed as `shellrs: [code] message`).";
|
||||
// `--help` is English whatever the interface language, so it names the
|
||||
// settings it points to in both.
|
||||
const AFTER_HELP: &str = concat!(
|
||||
"Hosts are named by the 16-character ID that `shellrs hosts list` prints, ",
|
||||
"the one ShellRS copies with Copy ID (复制 ID).\n", // i18n: keep
|
||||
"ShellRS must be running, with Settings → External CLI → Enable external CLI ",
|
||||
"(设置 → 外部 CLI → 启用外部 CLI) turned on.\n", // i18n: keep
|
||||
"\n",
|
||||
"Exit codes: exec exits with the remote command's code; 1 means a transfer finished with failures;\n",
|
||||
"255 means shellrs could not do what was asked (the reason is printed as `shellrs: [code] message`).",
|
||||
);
|
||||
|
||||
const HOST_FIELDS: &str = "\
|
||||
Reads one JSON object from stdin. Every field may be left out; `update` changes only the fields given.
|
||||
@@ -61,7 +69,7 @@ Reads one JSON object from stdin. Every field may be left out; `update` changes
|
||||
host The address (IP or host name). Required to create. Also read as `address`.
|
||||
port Default 22.
|
||||
user Default root. A host using a credential logs in as the credential's user.
|
||||
group A group path such as \"生产/数据库\", made when it is not there; null for the top level.
|
||||
group A group path such as \"Production/Databases\", made when it is not there; null for the top level.
|
||||
auth \"password\", \"credential\" or \"no_password\".
|
||||
credential A credential ID from `shellrs credentials list`, for auth \"credential\" (implied).
|
||||
password The host's own password, saved to the keychain and never printed; null deletes it.
|
||||
@@ -128,9 +136,17 @@ enum Command {
|
||||
/// {"exit_code", "stdout", "stderr"} once the command ends, errors as
|
||||
/// {"error": {"code", "message"}}. The output is ASCII, everything
|
||||
/// else escaped: no shell quoting, and no console code page can
|
||||
/// garble it.
|
||||
/// garble it. {"terminal": true} is the same as --terminal.
|
||||
#[arg(long, conflicts_with_all = ["id", "command", "stdin"])]
|
||||
json: bool,
|
||||
/// Type the command into the host's terminal open in ShellRS
|
||||
/// instead of logging in again, for a bastion host that allows one
|
||||
/// login. It sends Ctrl-C first, which interrupts whatever runs
|
||||
/// there. Its stderr comes as stdout; with --json, the result also
|
||||
/// has "context": the user, host, folder and shell it ran in. Bash,
|
||||
/// dash and busybox ash only.
|
||||
#[arg(long)]
|
||||
terminal: bool,
|
||||
},
|
||||
/// Copy a local file or folder to a saved host.
|
||||
///
|
||||
@@ -277,6 +293,11 @@ enum CredentialsCommand {
|
||||
/// The command line entry: `args` without the program name. Returns the
|
||||
/// process exit code.
|
||||
pub fn main(args: Vec<OsString>) -> i32 {
|
||||
// In the language the app's window is in, as what the app sends back.
|
||||
if let Ok(path) = crate::app::settings_path() {
|
||||
let (settings, _) = crate::settings::SettingsStore::load(path);
|
||||
crate::i18n::set_locale(settings.settings().language.resolved());
|
||||
}
|
||||
let cli = match Cli::try_parse_from(std::iter::once(OsString::from("shellrs")).chain(args)) {
|
||||
Ok(cli) => cli,
|
||||
Err(error) => {
|
||||
@@ -314,22 +335,34 @@ fn request(command: Command, console: &mut Console) -> Result<Request, i32> {
|
||||
command,
|
||||
stdin,
|
||||
json,
|
||||
terminal,
|
||||
} => {
|
||||
// From here on, errors are JSON too.
|
||||
console.exec_json = json;
|
||||
let (host, command) = if json {
|
||||
let request = if json {
|
||||
exec_request(&read_stdin(console)?)
|
||||
.map_err(|message| console.error(ErrorCode::BadRequest, &message))?
|
||||
} else if stdin {
|
||||
(id.unwrap_or_default(), read_stdin(console)?)
|
||||
} else {
|
||||
(id.unwrap_or_default(), command.unwrap_or_default())
|
||||
ExecJson {
|
||||
host: id.unwrap_or_default(),
|
||||
command: if stdin {
|
||||
read_stdin(console)?
|
||||
} else {
|
||||
command.unwrap_or_default()
|
||||
},
|
||||
terminal: false,
|
||||
}
|
||||
};
|
||||
let command = normalize_command(&command);
|
||||
let command = normalize_command(&request.command);
|
||||
if command.trim().is_empty() {
|
||||
return Err(console.error(ErrorCode::BadRequest, "命令不能为空"));
|
||||
return Err(console.error(ErrorCode::BadRequest, &t!("cli.exec.empty")));
|
||||
}
|
||||
let host = request.host;
|
||||
if terminal || request.terminal {
|
||||
Request::ExecInTerminal { host, command }
|
||||
} else {
|
||||
Request::Exec { host, command }
|
||||
}
|
||||
Request::Exec { host, command }
|
||||
}
|
||||
Command::Upload {
|
||||
id,
|
||||
@@ -445,7 +478,7 @@ fn read_json(console: &mut Console, example: &str) -> Result<serde_json::Value,
|
||||
if io::stdin().is_terminal() {
|
||||
return Err(console.error(
|
||||
ErrorCode::BadRequest,
|
||||
&format!("请从标准输入传入 JSON,例如 {example}"),
|
||||
&t!("cli.json.from_terminal", example = example),
|
||||
));
|
||||
}
|
||||
let text = read_stdin(console)?;
|
||||
@@ -453,23 +486,31 @@ fn read_json(console: &mut Console, example: &str) -> Result<serde_json::Value,
|
||||
serde_json::from_str(text).map_err(|error| {
|
||||
console.error(
|
||||
ErrorCode::BadRequest,
|
||||
&format!("标准输入不是 JSON:{error}"),
|
||||
&t!("cli.json.not_json", error = error),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn host_fields(console: &mut Console, example: &str) -> Result<HostFields, i32> {
|
||||
let value = read_json(console, example)?;
|
||||
manage::host_fields(value)
|
||||
.map_err(|error| console.error(ErrorCode::BadRequest, &format!("JSON 有误:{error}")))
|
||||
manage::host_fields(value).map_err(|error| {
|
||||
console.error(
|
||||
ErrorCode::BadRequest,
|
||||
&t!("cli.json.invalid", error = error),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// A key file named relative to here, or to the home directory, is sent
|
||||
/// as the absolute path the app can find.
|
||||
fn credential_fields(console: &mut Console, example: &str) -> Result<CredentialFields, i32> {
|
||||
let value = read_json(console, example)?;
|
||||
let mut fields = manage::credential_fields(value)
|
||||
.map_err(|error| console.error(ErrorCode::BadRequest, &format!("JSON 有误:{error}")))?;
|
||||
let mut fields = manage::credential_fields(value).map_err(|error| {
|
||||
console.error(
|
||||
ErrorCode::BadRequest,
|
||||
&t!("cli.json.invalid", error = error),
|
||||
)
|
||||
})?;
|
||||
if let Some(path) = fields.key_path.take() {
|
||||
let path = match path.strip_prefix("~") {
|
||||
Ok(rest) => dirs::home_dir().map(|home| home.join(rest)).unwrap_or(path),
|
||||
@@ -485,25 +526,35 @@ fn read_stdin(console: &mut Console) -> Result<String, i32> {
|
||||
std::io::stdin()
|
||||
.read_to_string(&mut text)
|
||||
.map_err(|error| {
|
||||
console.error(ErrorCode::BadRequest, &format!("无法读取标准输入:{error}"))
|
||||
console.error(
|
||||
ErrorCode::BadRequest,
|
||||
&t!("cli.stdin.unreadable", error = error),
|
||||
)
|
||||
})?;
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// What `exec --json` reads.
|
||||
#[derive(serde::Deserialize)]
|
||||
#[derive(Debug, PartialEq, serde::Deserialize)]
|
||||
struct ExecJson {
|
||||
host: String,
|
||||
command: String,
|
||||
/// `--terminal`.
|
||||
#[serde(default)]
|
||||
terminal: bool,
|
||||
}
|
||||
|
||||
/// The host and command of an `exec --json` request.
|
||||
fn exec_request(text: &str) -> Result<(String, String), String> {
|
||||
/// What an `exec --json` request asks for.
|
||||
fn exec_request(text: &str) -> Result<ExecJson, String> {
|
||||
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
|
||||
let request: ExecJson = serde_json::from_str(text).map_err(|error| {
|
||||
format!("标准输入不是 {{\"host\": ..., \"command\": ...}} 形式的 JSON:{error}")
|
||||
})?;
|
||||
Ok((request.host, request.command))
|
||||
serde_json::from_str(text).map_err(|error| {
|
||||
t!(
|
||||
"cli.exec.bad_json",
|
||||
form = r#"{"host": ..., "command": ...}"#,
|
||||
error = error
|
||||
)
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
/// A command as the remote shell should see it. PowerShell ends every line
|
||||
@@ -521,7 +572,11 @@ fn absolute(path: PathBuf, console: &mut Console) -> Result<PathBuf, i32> {
|
||||
std::path::absolute(&path).map_err(|error| {
|
||||
console.error(
|
||||
ErrorCode::BadRequest,
|
||||
&format!("无法解析路径 {}:{error}", path.display()),
|
||||
&t!(
|
||||
"cli.path.unresolvable",
|
||||
path = path.display(),
|
||||
error = error
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -127,6 +127,14 @@ pub enum Request {
|
||||
host: String,
|
||||
command: String,
|
||||
},
|
||||
/// `exec --terminal`: type `command` into the host's open terminal
|
||||
/// instead of logging in again, for a bastion host that allows one
|
||||
/// login. Its output comes as stdout, then [`Reply::Context`], then
|
||||
/// [`Reply::Exit`].
|
||||
ExecInTerminal {
|
||||
host: String,
|
||||
command: String,
|
||||
},
|
||||
/// `source` is absolute: the app's working directory is not the
|
||||
/// caller's.
|
||||
Upload {
|
||||
@@ -207,6 +215,8 @@ pub enum Reply {
|
||||
Exit {
|
||||
code: i32,
|
||||
},
|
||||
/// Where an `exec --terminal` command runs, once it started.
|
||||
Context(TerminalContext),
|
||||
/// A host as it is now: shown, created or changed.
|
||||
Host(HostDetails),
|
||||
HostDeleted(HostDeleted),
|
||||
@@ -250,6 +260,19 @@ impl HostInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an `exec --terminal` command ran: the terminal may have gone to
|
||||
/// another user, folder or machine since it was opened.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TerminalContext {
|
||||
pub user: String,
|
||||
pub host: String,
|
||||
pub cwd: String,
|
||||
/// The interactive shell the command was typed into.
|
||||
pub shell: String,
|
||||
/// What ran the command: `bash`, or `sh` where there is none.
|
||||
pub interp: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TransferCounters {
|
||||
pub files: u64,
|
||||
@@ -296,6 +319,14 @@ pub enum ErrorCode {
|
||||
/// A change was made, but could not all be written to disk or the
|
||||
/// keychain.
|
||||
SaveFailed,
|
||||
/// `exec --terminal`: the host has no connected terminal.
|
||||
NoTerminal,
|
||||
/// `exec --terminal`: the terminal could not take the command.
|
||||
TerminalBusy,
|
||||
/// `exec --terminal`: the shell is not bash, dash or busybox ash.
|
||||
UnsupportedShell,
|
||||
/// `exec --terminal`: the command line is longer than the shell takes.
|
||||
TooLong,
|
||||
}
|
||||
|
||||
impl ErrorCode {
|
||||
@@ -314,6 +345,10 @@ impl ErrorCode {
|
||||
ErrorCode::CredentialNotFound => "credential_not_found",
|
||||
ErrorCode::HostInUse => "host_in_use",
|
||||
ErrorCode::SaveFailed => "save_failed",
|
||||
ErrorCode::NoTerminal => "no_terminal",
|
||||
ErrorCode::TerminalBusy => "terminal_busy",
|
||||
ErrorCode::UnsupportedShell => "unsupported_shell",
|
||||
ErrorCode::TooLong => "too_long",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -641,7 +676,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn error_codes_are_spelled_as_documented() {
|
||||
for code in [ErrorCode::NotEnabled, ErrorCode::HostKeyUnknown] {
|
||||
for code in [
|
||||
ErrorCode::NotEnabled,
|
||||
ErrorCode::HostKeyUnknown,
|
||||
ErrorCode::NoTerminal,
|
||||
ErrorCode::TerminalBusy,
|
||||
ErrorCode::UnsupportedShell,
|
||||
ErrorCode::TooLong,
|
||||
] {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&code).unwrap(),
|
||||
format!("\"{}\"", code.as_str())
|
||||
|
||||
@@ -21,17 +21,21 @@ use tokio::sync::watch;
|
||||
use super::link::OpenLink;
|
||||
use super::manage::{
|
||||
CliChange, CredentialSecrets, HostSecrets, credential_details, credential_matches,
|
||||
credential_secrets, host_details, host_info, host_secrets, with_saved_credential_secrets,
|
||||
with_saved_passwords,
|
||||
credential_not_found, credential_secrets, host_details, host_info, host_not_found,
|
||||
host_secrets, with_saved_credential_secrets, with_saved_passwords,
|
||||
};
|
||||
use super::protocol::{
|
||||
CliError, CredentialDetails, Envelope, ErrorCode, FrameKind, HostDetails, HostInfo,
|
||||
PROTOCOL_VERSION, Reply, Request, TransferCounters, TransferSummary, VersionOnly, parse_json,
|
||||
read_frame, write_frame, write_json,
|
||||
PROTOCOL_VERSION, Reply, Request, TerminalContext, TransferCounters, TransferSummary,
|
||||
VersionOnly, parse_json, read_frame, write_frame, write_json,
|
||||
};
|
||||
use crate::host::{Host, HostLogin, HostStore, matches_query};
|
||||
use crate::i18n::t;
|
||||
use crate::secrets::SecretRef;
|
||||
use crate::ssh::ExecStream;
|
||||
use crate::terminal::{
|
||||
BusyReason, RunEvent, RunFailure, RunHandle, RunProgress, RunTiming, TerminalRun,
|
||||
};
|
||||
|
||||
/// Does what a CLI request asks. Every method blocks: each request has a
|
||||
/// thread of its own.
|
||||
@@ -158,6 +162,26 @@ const QUEUE_TIMEOUT: Duration = if cfg!(test) {
|
||||
Duration::from_secs(10)
|
||||
};
|
||||
|
||||
/// How an `exec --terminal` run is paced; quick in the tests.
|
||||
const RUN_TIMING: RunTiming = if cfg!(test) {
|
||||
RunTiming {
|
||||
settle: Duration::ZERO,
|
||||
probe: Duration::from_millis(300),
|
||||
start: Duration::from_millis(300),
|
||||
tick: Duration::from_millis(20),
|
||||
}
|
||||
} else {
|
||||
RunTiming::STANDARD
|
||||
};
|
||||
|
||||
/// An `exec --terminal` run waiting for the app to find the host's
|
||||
/// terminal.
|
||||
struct PendingRun {
|
||||
id: u64,
|
||||
host: String,
|
||||
run: RunHandle,
|
||||
}
|
||||
|
||||
/// A change waiting for the app, with where its outcome goes.
|
||||
struct PendingChange {
|
||||
id: u64,
|
||||
@@ -188,7 +212,49 @@ struct Shared {
|
||||
/// Changes to hosts and credentials, oldest first, until the app takes
|
||||
/// them up: only the UI thread can change the store.
|
||||
changes: Mutex<VecDeque<PendingChange>>,
|
||||
/// `exec --terminal` runs, oldest first, until the app takes them up:
|
||||
/// only the UI thread can reach a terminal.
|
||||
runs: Mutex<VecDeque<PendingRun>>,
|
||||
next_change: AtomicU64,
|
||||
/// What kinds of command were served, for 匿名使用统计, until the app
|
||||
/// takes them.
|
||||
usage: Mutex<Vec<CliUse>>,
|
||||
}
|
||||
|
||||
/// The kind of a `shellrs` command, as 匿名使用统计 counts them: nothing
|
||||
/// of what it asked for.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CliUse {
|
||||
Exec,
|
||||
ExecTerminal,
|
||||
Upload,
|
||||
Download,
|
||||
Sync,
|
||||
Hosts,
|
||||
Credentials,
|
||||
}
|
||||
|
||||
impl CliUse {
|
||||
fn of(request: &Request) -> Option<Self> {
|
||||
match request {
|
||||
Request::Exec { .. } => Some(Self::Exec),
|
||||
Request::ExecInTerminal { .. } => Some(Self::ExecTerminal),
|
||||
Request::Upload { .. } => Some(Self::Upload),
|
||||
Request::Download { .. } => Some(Self::Download),
|
||||
Request::Sync { .. } => Some(Self::Sync),
|
||||
Request::List { .. }
|
||||
| Request::ShowHost { .. }
|
||||
| Request::CreateHost { .. }
|
||||
| Request::UpdateHost { .. }
|
||||
| Request::DeleteHost { .. } => Some(Self::Hosts),
|
||||
Request::ListCredentials { .. }
|
||||
| Request::ShowCredential { .. }
|
||||
| Request::CreateCredential { .. }
|
||||
| Request::UpdateCredential { .. }
|
||||
| Request::DeleteCredential { .. } => Some(Self::Credentials),
|
||||
Request::Activate { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Listens on the CLI socket until dropped. Always listening, even with
|
||||
@@ -211,7 +277,9 @@ impl CliServer {
|
||||
backend,
|
||||
activation: Mutex::new(None),
|
||||
changes: Mutex::new(VecDeque::new()),
|
||||
runs: Mutex::new(VecDeque::new()),
|
||||
next_change: AtomicU64::new(0),
|
||||
usage: Mutex::new(Vec::new()),
|
||||
});
|
||||
let listener = listen(endpoint, shared.clone())?;
|
||||
Ok(Self {
|
||||
@@ -258,6 +326,17 @@ impl CliServer {
|
||||
.map(|pending| (pending.change, pending.reply))
|
||||
}
|
||||
|
||||
/// The oldest `exec --terminal` run waiting, with the host it is for,
|
||||
/// for the app to hand to that host's terminal, or say why it cannot.
|
||||
pub fn take_run(&self) -> Option<(String, RunHandle)> {
|
||||
self.shared
|
||||
.runs
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.pop_front()
|
||||
.map(|pending| (pending.host, pending.run))
|
||||
}
|
||||
|
||||
/// Whether ShellRS was opened again since this was last asked, and the
|
||||
/// links it was opened with, oldest first: `Some` and empty for a plain
|
||||
/// re-open. The request threads cannot reach the window, so the app asks
|
||||
@@ -269,17 +348,38 @@ impl CliServer {
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.take()
|
||||
}
|
||||
|
||||
/// The kinds of command served since this was last asked, oldest first.
|
||||
/// Asked on the same timer as `take_activation`.
|
||||
pub fn take_usage(&self) -> Vec<CliUse> {
|
||||
std::mem::take(
|
||||
&mut *self
|
||||
.shared
|
||||
.usage
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CliServer {
|
||||
/// The app is going: whatever waits for it to take up a change hears
|
||||
/// that it never will.
|
||||
/// The app is going: whatever waits for it to take up a change or a
|
||||
/// run hears that it never will.
|
||||
fn drop(&mut self) {
|
||||
self.shared
|
||||
.changes
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.clear();
|
||||
for pending in self
|
||||
.shared
|
||||
.runs
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.drain(..)
|
||||
{
|
||||
pending.run.fail(RunFailure::NotConnected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,12 +588,15 @@ struct Listener;
|
||||
fn listen(_: PathBuf, _: Arc<Shared>) -> io::Result<Listener> {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"此系统暂不支持外部 CLI",
|
||||
t!("cli.unsupported").to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn another_app() -> io::Error {
|
||||
io::Error::new(io::ErrorKind::AddrInUse, "另一个 ShellRS 已在提供外部 CLI")
|
||||
io::Error::new(
|
||||
io::ErrorKind::AddrInUse,
|
||||
t!("cli.server.another_app").to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Read one request and answer it.
|
||||
@@ -543,17 +646,23 @@ fn respond(
|
||||
if version.version != PROTOCOL_VERSION {
|
||||
return Err(CliError::new(
|
||||
ErrorCode::VersionMismatch,
|
||||
"shellrs 命令与正在运行的 ShellRS 版本不同:\
|
||||
请重新启动 ShellRS,或在 设置 → 外部 CLI 中更新 CLI",
|
||||
t!("cli.server.version_mismatch"),
|
||||
));
|
||||
}
|
||||
let envelope = envelope.map_err(bad_request)?;
|
||||
if !shared.enabled.load(Ordering::Acquire) {
|
||||
return Err(CliError::new(
|
||||
ErrorCode::NotEnabled,
|
||||
"ShellRS 未启用外部 CLI:请在 ShellRS 的 设置 → 外部 CLI 中打开「启用外部 CLI」",
|
||||
t!("cli.server.not_enabled"),
|
||||
));
|
||||
}
|
||||
if let Some(usage) = CliUse::of(&envelope.request) {
|
||||
shared
|
||||
.usage
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.push(usage);
|
||||
}
|
||||
match envelope.request {
|
||||
Request::List { query } => {
|
||||
let targets = shared
|
||||
@@ -580,6 +689,10 @@ fn respond(
|
||||
})?;
|
||||
Ok(Reply::Exit { code })
|
||||
}
|
||||
Request::ExecInTerminal { host, command } => {
|
||||
let target = find(shared, &host)?;
|
||||
run_in_terminal(shared, writer, &target.info.id, command)
|
||||
}
|
||||
Request::Upload {
|
||||
host,
|
||||
source,
|
||||
@@ -661,14 +774,7 @@ fn respond(
|
||||
.iter()
|
||||
.find(|known| known.details.id == credential.trim())
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
CliError::new(
|
||||
ErrorCode::CredentialNotFound,
|
||||
format!(
|
||||
"没有 ID 为 {credential} 的凭据:请用 shellrs credentials list 查看"
|
||||
),
|
||||
)
|
||||
})?;
|
||||
.ok_or_else(|| credential_not_found(&credential))?;
|
||||
let saved = |secret: &SecretRef| shared.backend.is_saved(secret);
|
||||
Ok(Reply::Credential(with_saved_credential_secrets(
|
||||
found.details,
|
||||
@@ -701,7 +807,7 @@ fn change(shared: &Shared, change: CliChange) -> Result<Reply, CliError> {
|
||||
let gone = || {
|
||||
CliError::new(
|
||||
ErrorCode::ConnectFailed,
|
||||
"ShellRS 在处理改动时关闭了:请重新打开 ShellRS,用 shellrs hosts list 查看改动是否已完成",
|
||||
t!("cli.server.closed_during_change"),
|
||||
)
|
||||
};
|
||||
let (sender, outcome) = mpsc::channel();
|
||||
@@ -729,7 +835,7 @@ fn change(shared: &Shared, change: CliChange) -> Result<Reply, CliError> {
|
||||
changes.remove(index);
|
||||
return Err(CliError::new(
|
||||
ErrorCode::ConnectFailed,
|
||||
"ShellRS 没有及时处理这项改动,改动没有做:请稍后再试",
|
||||
t!("cli.server.change_timed_out"),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -737,6 +843,105 @@ fn change(shared: &Shared, change: CliChange) -> Result<Reply, CliError> {
|
||||
outcome.recv().unwrap_or_else(|_| Err(gone()))
|
||||
}
|
||||
|
||||
/// Hand a run to the app, which finds the host's terminal, and see it
|
||||
/// through: the command's output as stdout, its context, its exit code.
|
||||
/// Taken back, so certainly not typed, when the app does not take it up
|
||||
/// in time. While the command prints nothing, an empty frame now and then
|
||||
/// finds out whether the caller is still there; once it is not, the
|
||||
/// command gets a ^C.
|
||||
fn run_in_terminal(
|
||||
shared: &Shared,
|
||||
writer: &mut impl io::Write,
|
||||
host: &str,
|
||||
command: String,
|
||||
) -> Result<Reply, CliError> {
|
||||
let (run, events) = TerminalRun::new(command);
|
||||
let id = shared.next_change.fetch_add(1, Ordering::Relaxed);
|
||||
shared
|
||||
.runs
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.push_back(PendingRun {
|
||||
id,
|
||||
host: host.to_owned(),
|
||||
run: run.handle(),
|
||||
});
|
||||
let first = match events.recv_timeout(QUEUE_TIMEOUT) {
|
||||
Ok(event) => event,
|
||||
Err(_) => {
|
||||
let mut runs = shared
|
||||
.runs
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
if let Some(index) = runs.iter().position(|pending| pending.id == id) {
|
||||
runs.remove(index);
|
||||
return Err(CliError::new(
|
||||
ErrorCode::ConnectFailed,
|
||||
t!("cli.terminal.timed_out"),
|
||||
));
|
||||
}
|
||||
drop(runs);
|
||||
// Taken up just now: its answer is on the way.
|
||||
events
|
||||
.recv()
|
||||
.map_err(|_| run_error(RunFailure::NotConnected, host))?
|
||||
}
|
||||
};
|
||||
if let RunEvent::Failed(failure) = first {
|
||||
return Err(run_error(failure, host));
|
||||
}
|
||||
let code = run
|
||||
.drive(&events, RUN_TIMING, &mut |progress| match progress {
|
||||
RunProgress::Context(context) => write_json(
|
||||
writer,
|
||||
&Reply::Context(TerminalContext {
|
||||
user: context.user,
|
||||
host: context.host,
|
||||
cwd: context.cwd,
|
||||
shell: context.shell,
|
||||
interp: context.interp,
|
||||
}),
|
||||
),
|
||||
RunProgress::Output(bytes) => write_frame(writer, FrameKind::Stdout, &bytes),
|
||||
RunProgress::Idle => write_frame(writer, FrameKind::Stdout, &[]),
|
||||
})
|
||||
.map_err(|failure| run_error(failure, host))?;
|
||||
Ok(Reply::Exit { code })
|
||||
}
|
||||
|
||||
/// Why a run did not run, in the CLI's words.
|
||||
fn run_error(failure: RunFailure, host: &str) -> CliError {
|
||||
let busy = |message| CliError::new(ErrorCode::TerminalBusy, message);
|
||||
match failure {
|
||||
RunFailure::HostNotFound => host_not_found(host),
|
||||
RunFailure::NoTerminal => {
|
||||
CliError::new(ErrorCode::NoTerminal, t!("cli.terminal.no_terminal"))
|
||||
}
|
||||
RunFailure::NotConnected => {
|
||||
CliError::new(ErrorCode::NoTerminal, t!("cli.terminal.not_connected"))
|
||||
}
|
||||
RunFailure::UnsupportedShell(shell) if shell.is_empty() => CliError::new(
|
||||
ErrorCode::UnsupportedShell,
|
||||
t!("cli.terminal.unknown_shell"),
|
||||
),
|
||||
RunFailure::UnsupportedShell(shell) => CliError::new(
|
||||
ErrorCode::UnsupportedShell,
|
||||
t!("cli.terminal.unsupported_shell", shell = shell),
|
||||
),
|
||||
RunFailure::TooLong { limit } => CliError::new(
|
||||
ErrorCode::TooLong,
|
||||
t!("cli.terminal.too_long", limit = limit),
|
||||
),
|
||||
RunFailure::Busy(BusyReason::FullScreen) => busy(t!("cli.terminal.full_screen")),
|
||||
RunFailure::Busy(BusyReason::AnotherRun) => busy(t!("cli.terminal.another_run")),
|
||||
RunFailure::Busy(BusyReason::NoAnswer) => busy(t!("cli.terminal.no_answer")),
|
||||
RunFailure::Busy(BusyReason::NotStarted) => busy(t!("cli.terminal.not_started")),
|
||||
RunFailure::Disconnected => {
|
||||
CliError::new(ErrorCode::ConnectFailed, t!("cli.terminal.disconnected"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find(shared: &Shared, id: &str) -> Result<CliTarget, CliError> {
|
||||
shared
|
||||
.targets
|
||||
@@ -745,17 +950,12 @@ fn find(shared: &Shared, id: &str) -> Result<CliTarget, CliError> {
|
||||
.iter()
|
||||
.find(|target| target.info.id == id.trim())
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
CliError::new(
|
||||
ErrorCode::HostNotFound,
|
||||
format!("没有 ID 为 {id} 的主机:请用 shellrs hosts list 查看"),
|
||||
)
|
||||
})
|
||||
.ok_or_else(|| host_not_found(id))
|
||||
}
|
||||
|
||||
fn not_absolute(path: &Path) -> CliError {
|
||||
CliError::new(
|
||||
ErrorCode::BadRequest,
|
||||
format!("本地路径必须是绝对路径:{}", path.display()),
|
||||
t!("cli.server.not_absolute", path = path.display()),
|
||||
)
|
||||
}
|
||||
@@ -17,9 +17,9 @@ use super::protocol::{
|
||||
AuthChoice, CliError, CredentialDetails, ErrorCode, HostDetails, HostFields, HostInfo, Reply,
|
||||
Request, RouteDetails, Secret, TransferCounters, TransferSummary,
|
||||
};
|
||||
use super::server::{CliBackend, CliServer, CliTarget};
|
||||
use super::server::{CliBackend, CliServer, CliTarget, CliUse};
|
||||
use super::{Cli, Command, CredentialsCommand, HostsCommand};
|
||||
use super::{ConsoleText, exec_request, normalize_command};
|
||||
use super::{ConsoleText, ExecJson, exec_request, normalize_command};
|
||||
use crate::app::cli_endpoint;
|
||||
use crate::host::{
|
||||
AuthKind, CredentialDraft, CredentialKind, GroupDraft, Host, HostDraft, HostId, HostStore,
|
||||
@@ -27,6 +27,7 @@ use crate::host::{
|
||||
};
|
||||
use crate::secrets::SecretRef;
|
||||
use crate::ssh::ExecStream;
|
||||
use crate::terminal::{RunFailure, RunHandle, TerminalTransportCommand};
|
||||
|
||||
/// Records what it was asked and answers from a script.
|
||||
#[derive(Default)]
|
||||
@@ -366,6 +367,27 @@ fn without_the_app_the_command_says_it_is_not_running() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_kinds_of_command_served_are_counted_and_nothing_else() {
|
||||
let fixture = fixture();
|
||||
run(&fixture.socket, Request::List { query: None }, true);
|
||||
run(
|
||||
&fixture.socket,
|
||||
Request::Exec {
|
||||
host: id(&fixture.web),
|
||||
command: "uname -a".into(),
|
||||
},
|
||||
false,
|
||||
);
|
||||
assert_eq!(fixture.server.take_usage(), [CliUse::Hosts, CliUse::Exec]);
|
||||
assert_eq!(fixture.server.take_usage(), []);
|
||||
// Refused, or ShellRS opened again: not a command served.
|
||||
fixture.server.set_enabled(false);
|
||||
run(&fixture.socket, Request::List { query: None }, true);
|
||||
assert!(client::activate_running_app(&fixture.socket, None));
|
||||
assert_eq!(fixture.server.take_usage(), []);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_shellrs_again_brings_the_running_one_forward() {
|
||||
let fixture = fixture();
|
||||
@@ -611,6 +633,19 @@ fn exec_takes_its_command_as_one_argument_or_from_stdin() {
|
||||
assert!(parse(&["shellrs", "exec", "--json", "--stdin"]).is_err());
|
||||
// Split words are a mistake, not a longer command.
|
||||
assert!(parse(&["shellrs", "exec", "ID", "ls", "-la"]).is_err());
|
||||
// Typed into the open terminal, in any of the forms.
|
||||
assert!(matches!(
|
||||
parse(&["shellrs", "exec", "ID", "--terminal", "ls"]),
|
||||
Ok(Command::Exec { terminal: true, command: Some(command), .. }) if command == "ls"
|
||||
));
|
||||
assert!(matches!(
|
||||
parse(&["shellrs", "exec", "--json", "--terminal"]),
|
||||
Ok(Command::Exec {
|
||||
terminal: true,
|
||||
json: true,
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -659,14 +694,23 @@ fn a_windows_console_gets_text_even_from_bytes_that_are_not_utf8() {
|
||||
|
||||
#[test]
|
||||
fn exec_json_reads_the_host_and_the_command() {
|
||||
let asked = |host: &str, command: &str, terminal| ExecJson {
|
||||
host: host.into(),
|
||||
command: command.into(),
|
||||
terminal,
|
||||
};
|
||||
assert_eq!(
|
||||
exec_request("\u{feff}{\"host\": \"ID\", \"command\": \"echo \\\"hi\\\" | wc -c\"}"),
|
||||
Ok(("ID".to_string(), "echo \"hi\" | wc -c".to_string()))
|
||||
Ok(asked("ID", "echo \"hi\" | wc -c", false))
|
||||
);
|
||||
// Escaped as an agent may write it in PowerShell 5.1, which pipes ASCII.
|
||||
assert_eq!(
|
||||
exec_request(r#"{"host":"ID","command":"grep \u751f\u4ea7 a.log"}"#),
|
||||
Ok(("ID".to_string(), "grep 生产 a.log".to_string()))
|
||||
Ok(asked("ID", "grep 生产 a.log", false))
|
||||
);
|
||||
assert_eq!(
|
||||
exec_request(r#"{"host":"ID","command":"ls","terminal":true}"#),
|
||||
Ok(asked("ID", "ls", true))
|
||||
);
|
||||
assert!(exec_request(r#"{"command":"ls"}"#).is_err());
|
||||
assert!(exec_request("ls -la").is_err());
|
||||
@@ -1089,3 +1133,165 @@ fn an_app_that_does_not_know_the_command_is_said_to_be_older() {
|
||||
.starts_with("shellrs: [version_mismatch] 正在运行的 ShellRS 不认识这个命令"),
|
||||
);
|
||||
}
|
||||
|
||||
/// The app's end of an `exec --terminal` run: wait for it to come in.
|
||||
fn take_run(server: &CliServer) -> (String, RunHandle) {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
loop {
|
||||
if let Some(taken) = server.take_run() {
|
||||
return taken;
|
||||
}
|
||||
assert!(std::time::Instant::now() < deadline, "no run came in");
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
/// A terminal taking `run` up, with a shell whose program is `shell`: it
|
||||
/// answers the probe, then the command line with `output` and `code`,
|
||||
/// slowly enough for the server to check on the caller meanwhile.
|
||||
/// Everything typed, once nothing more is.
|
||||
fn answer_run(
|
||||
run: RunHandle,
|
||||
shell: &'static str,
|
||||
output: &'static str,
|
||||
code: i32,
|
||||
) -> std::thread::JoinHandle<String> {
|
||||
let (input, typed) = std::sync::mpsc::channel();
|
||||
assert!(run.accept(input));
|
||||
std::thread::spawn(move || {
|
||||
let mut all = String::new();
|
||||
while let Ok(TerminalTransportCommand::Write(bytes)) =
|
||||
typed.recv_timeout(std::time::Duration::from_millis(500))
|
||||
{
|
||||
let line = String::from_utf8(bytes).unwrap();
|
||||
all.push_str(&line);
|
||||
// ` sh -c '<script>' sh NONCE …`
|
||||
let Some(at) = line.find("' sh ") else {
|
||||
continue;
|
||||
};
|
||||
let nonce = &line[at + 5..at + 17];
|
||||
if line.contains("6973;P;") {
|
||||
run.feed(format!("\x1b]6973;P;{nonce};{shell}\x07").as_bytes());
|
||||
} else if line.contains("6973;B;") {
|
||||
let context: String = "deploy\nasset\n/home/deploy\nbash"
|
||||
.bytes()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect();
|
||||
run.feed(format!("\x1b]6973;B;{nonce};{context}\x07").as_bytes());
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
run.feed(format!("{output}\x1b]6973;E;{nonce};{code}\x07").as_bytes());
|
||||
}
|
||||
}
|
||||
all
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_typed_into_the_terminal_passes_on_its_output_and_where_it_ran() {
|
||||
let fixture = fixture();
|
||||
let exec = |json| {
|
||||
let socket = fixture.socket.clone();
|
||||
let request = Request::ExecInTerminal {
|
||||
host: id(&fixture.web),
|
||||
command: "ls /srv".into(),
|
||||
};
|
||||
std::thread::spawn(move || {
|
||||
if json {
|
||||
let (code, result, _) = run_exec_json(&socket, request);
|
||||
(code, result.to_string())
|
||||
} else {
|
||||
let (code, stdout, _) = run(&socket, request, false);
|
||||
(code, stdout)
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let command = exec(false);
|
||||
let (host, run) = take_run(&fixture.server);
|
||||
assert_eq!(host, id(&fixture.web));
|
||||
let terminal = answer_run(run, "/usr/bin/bash", "a\r\nb\r\n", 2);
|
||||
assert_eq!(command.join().unwrap(), (2, "a\nb\n".to_string()));
|
||||
assert!(terminal.join().unwrap().contains(" 'ls /srv'\r"));
|
||||
|
||||
let command = exec(true);
|
||||
let (_, run) = take_run(&fixture.server);
|
||||
answer_run(run, "/bin/busybox", "a\r\n", 0);
|
||||
let (code, result) = command.join().unwrap();
|
||||
assert_eq!(code, 0);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&result).unwrap(),
|
||||
serde_json::json!({
|
||||
"exit_code": 0,
|
||||
"stdout": "a\n",
|
||||
"stderr": "",
|
||||
"context": {
|
||||
"user": "deploy",
|
||||
"host": "asset",
|
||||
"cwd": "/home/deploy",
|
||||
"shell": "busybox",
|
||||
"interp": "bash",
|
||||
},
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
fixture.server.take_usage(),
|
||||
[CliUse::ExecTerminal, CliUse::ExecTerminal]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_that_cannot_be_typed_says_why() {
|
||||
let fixture = fixture();
|
||||
let exec = || {
|
||||
let socket = fixture.socket.clone();
|
||||
let host = id(&fixture.web);
|
||||
std::thread::spawn(move || {
|
||||
let request = Request::ExecInTerminal {
|
||||
host,
|
||||
command: "true".into(),
|
||||
};
|
||||
let (code, _, stderr) = run(&socket, request, false);
|
||||
(code, String::from_utf8(stderr).unwrap())
|
||||
})
|
||||
};
|
||||
let refused = |command: std::thread::JoinHandle<(i32, String)>, code: &str| {
|
||||
let (exit, stderr) = command.join().unwrap();
|
||||
assert_eq!(exit, 255);
|
||||
assert!(
|
||||
stderr.starts_with(&format!("shellrs: [{code}] ")),
|
||||
"{stderr}"
|
||||
);
|
||||
stderr
|
||||
};
|
||||
|
||||
// No terminal for it.
|
||||
let command = exec();
|
||||
take_run(&fixture.server).1.fail(RunFailure::NoTerminal);
|
||||
refused(command, "no_terminal");
|
||||
|
||||
// A shell it cannot type into: nothing but the probe was typed.
|
||||
let command = exec();
|
||||
let terminal = answer_run(take_run(&fixture.server).1, "/usr/bin/zsh", "", 0);
|
||||
assert!(refused(command, "unsupported_shell").contains("zsh"));
|
||||
assert!(!terminal.join().unwrap().contains("6973;B;"));
|
||||
|
||||
// Nothing answers the probe.
|
||||
let command = exec();
|
||||
let (input, _typed) = std::sync::mpsc::channel();
|
||||
assert!(take_run(&fixture.server).1.accept(input));
|
||||
refused(command, "terminal_busy");
|
||||
|
||||
// Nobody takes it up: taken back, never typed.
|
||||
let command = exec();
|
||||
refused(command, "connect_failed");
|
||||
assert!(fixture.server.take_run().is_none());
|
||||
|
||||
// An unknown host never gets to the app.
|
||||
let socket = fixture.socket.clone();
|
||||
let request = Request::ExecInTerminal {
|
||||
host: "nope".into(),
|
||||
command: "true".into(),
|
||||
};
|
||||
assert_eq!(run(&socket, request, false).0, 255);
|
||||
assert!(fixture.server.take_run().is_none());
|
||||
}
|
||||
@@ -4,6 +4,8 @@ use std::time::Duration;
|
||||
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::i18n::t;
|
||||
|
||||
/// One field requested by an SSH keyboard-interactive challenge.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ConnectionPromptField {
|
||||
@@ -72,24 +74,30 @@ impl UnknownHostPrompt {
|
||||
|
||||
/// What the trust dialog tells the user about the key.
|
||||
pub fn description(&self) -> String {
|
||||
format!(
|
||||
"{}主机:{}:{}\n算法:{}\nSHA-256 指纹:{}\n\n请先确认该指纹来自可信渠道。",
|
||||
jump_host_line(self.jump_host.as_deref()),
|
||||
self.host,
|
||||
self.port,
|
||||
self.algorithm,
|
||||
self.fingerprint,
|
||||
)
|
||||
let (host, port, algorithm, fingerprint) =
|
||||
(&self.host, self.port, &self.algorithm, &self.fingerprint);
|
||||
match &self.jump_host {
|
||||
// A line above the rest names the jump host.
|
||||
Some(jump_host) => t!(
|
||||
"connection.unknown_host.jump_host_description",
|
||||
jump_host = jump_host,
|
||||
host = host,
|
||||
port = port,
|
||||
algorithm = algorithm,
|
||||
fingerprint = fingerprint
|
||||
),
|
||||
None => t!(
|
||||
"connection.unknown_host.description",
|
||||
host = host,
|
||||
port = port,
|
||||
algorithm = algorithm,
|
||||
fingerprint = fingerprint
|
||||
),
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// The line that opens a question about a jump host, naming it.
|
||||
fn jump_host_line(jump_host: Option<&str>) -> String {
|
||||
jump_host
|
||||
.map(|name| format!("跳板主机:{name}\n"))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct HostKeyChangedPrompt {
|
||||
host: String,
|
||||
@@ -203,10 +211,15 @@ impl ConnectionPromptKind {
|
||||
Self::HostKeyChanged(prompt) => prompt.jump_host = Some(name.to_string()),
|
||||
Self::Authentication(prompt) => {
|
||||
prompt.instructions = if prompt.instructions.trim().is_empty() {
|
||||
format!("跳板主机「{name}」")
|
||||
t!("connection.jump_host.instructions", name = name)
|
||||
} else {
|
||||
format!("跳板主机「{name}」:{}", prompt.instructions)
|
||||
};
|
||||
t!(
|
||||
"connection.jump_host.with_instructions",
|
||||
name = name,
|
||||
instructions = prompt.instructions
|
||||
)
|
||||
}
|
||||
.to_string();
|
||||
}
|
||||
}
|
||||
self
|
||||
@@ -230,7 +243,7 @@ impl ConnectionSecret {
|
||||
|
||||
impl std::fmt::Debug for ConnectionSecret {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str("ConnectionSecret([已隐藏])")
|
||||
formatter.write_str("ConnectionSecret(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,7 +261,7 @@ impl std::fmt::Debug for ConnectionPromptReply {
|
||||
Self::TrustAndSave => formatter.write_str("TrustAndSave"),
|
||||
Self::Answers(answers) => formatter
|
||||
.debug_tuple("Answers")
|
||||
.field(&format_args!("{} 个已隐藏答案", answers.len()))
|
||||
.field(&format_args!("{} redacted", answers.len()))
|
||||
.finish(),
|
||||
Self::Cancel => formatter.write_str("Cancel"),
|
||||
}
|
||||
@@ -418,7 +431,7 @@ impl Latency {
|
||||
pub fn label(self) -> String {
|
||||
match self {
|
||||
Latency::Measured(rtt) => format!("{} ms", rtt.as_millis()),
|
||||
Latency::TimedOut => "超时".into(),
|
||||
Latency::TimedOut => t!("connection.latency.timed_out").to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -451,3 +464,33 @@ mod latency_tests {
|
||||
assert_eq!(Latency::TimedOut.label(), "超时");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod prompt_tests {
|
||||
use super::ConnectionPromptKind;
|
||||
|
||||
fn description(jump_host: Option<&str>) -> String {
|
||||
match ConnectionPromptKind::unknown_host("10.0.0.5", 22, "ssh-ed25519", "SHA256:abc")
|
||||
.at_jump_host(jump_host)
|
||||
{
|
||||
ConnectionPromptKind::UnknownHost(prompt) => prompt.description(),
|
||||
_ => unreachable!("an unknown host stays one"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_host_key_is_described_line_by_line() {
|
||||
assert_eq!(
|
||||
description(Some("堡垒机")),
|
||||
"跳板主机:堡垒机\n主机:10.0.0.5:22\n算法:ssh-ed25519\n\
|
||||
SHA-256 指纹:SHA256:abc\n\n请先确认该指纹来自可信渠道。"
|
||||
);
|
||||
crate::i18n::isolate_thread();
|
||||
crate::i18n::set_locale("en");
|
||||
assert_eq!(
|
||||
description(None),
|
||||
"Host: 10.0.0.5:22\nAlgorithm: ssh-ed25519\nSHA-256 fingerprint: SHA256:abc\n\n\
|
||||
Make sure the fingerprint matches one from a source you trust."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,23 +15,31 @@ use zeroize::Zeroizing;
|
||||
|
||||
use crate::host::{
|
||||
CredentialDraft, CredentialId, CredentialKind, DEFAULT_USER, GeneratedKey, HostStore,
|
||||
KeyAlgorithm, PastedKey, PastedKeyError, SecretFields, read_public_key,
|
||||
KeyAlgorithm, PastedKey, PastedKeyError, SecretFields, join_sentences, read_public_key,
|
||||
};
|
||||
use crate::i18n::{t, tn};
|
||||
use crate::secrets::SecretRef;
|
||||
use crate::shared::{
|
||||
Segment, SegmentedControl, commit_footer, dismiss_form_error, form_error_notification,
|
||||
};
|
||||
|
||||
/// Where the SSH agent is found, as the form explains it.
|
||||
#[cfg(windows)]
|
||||
const AGENT_NOTE: &str = "登录时使用 Windows 的 OpenSSH Authentication Agent 服务中的密钥。";
|
||||
#[cfg(not(windows))]
|
||||
const AGENT_NOTE: &str = "登录时使用 SSH Agent(SSH_AUTH_SOCK 所指)中的密钥。";
|
||||
fn agent_note() -> SharedString {
|
||||
if cfg!(windows) {
|
||||
t!("credential.dialog.agent_note_windows")
|
||||
} else {
|
||||
t!("credential.dialog.agent_note")
|
||||
}
|
||||
}
|
||||
|
||||
/// What an empty passphrase field means for a key that already exists, and
|
||||
/// for one about to be generated.
|
||||
const ASK_FOR_PASSPHRASE: &str = "留空则每次连接都询问";
|
||||
const NO_PASSPHRASE: &str = "留空则不加密";
|
||||
fn passphrase_placeholder(source: KeySource) -> SharedString {
|
||||
match source {
|
||||
KeySource::Generate => t!("credential.dialog.no_passphrase"),
|
||||
KeySource::File | KeySource::Paste => t!("credential.dialog.ask_for_passphrase"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Which credential dialog to open.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -57,11 +65,11 @@ enum KeySource {
|
||||
impl KeySource {
|
||||
const ALL: [KeySource; 3] = [KeySource::File, KeySource::Paste, KeySource::Generate];
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
fn label(self) -> SharedString {
|
||||
match self {
|
||||
KeySource::File => "本机文件",
|
||||
KeySource::Paste => "粘贴",
|
||||
KeySource::Generate => "生成新密钥",
|
||||
KeySource::File => t!("credential.key_source.file"),
|
||||
KeySource::Paste => t!("credential.key_source.paste"),
|
||||
KeySource::Generate => t!("credential.key_source.generate"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,7 +179,7 @@ impl CredentialForm {
|
||||
|
||||
let name = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("例如 生产环境 root")
|
||||
.placeholder(t!("credential.dialog.name_placeholder"))
|
||||
.default_value(draft.name.clone())
|
||||
});
|
||||
let user = cx.new(|cx| {
|
||||
@@ -192,13 +200,13 @@ impl CredentialForm {
|
||||
}
|
||||
if source == KeySource::Generate {
|
||||
fields.update(cx, |fields, cx| {
|
||||
fields.set_passphrase_placeholder(NO_PASSPHRASE, window, cx)
|
||||
fields.set_passphrase_placeholder(passphrase_placeholder(source), window, cx)
|
||||
});
|
||||
}
|
||||
let key_text = cx.new(|cx| {
|
||||
TextareaState::new(window, cx)
|
||||
.auto_grow(4, 8)
|
||||
.placeholder("粘贴私钥,以 -----BEGIN 开头")
|
||||
.placeholder(t!("credential.dialog.key_text_placeholder"))
|
||||
});
|
||||
let subscriptions = vec![
|
||||
cx.observe(&fields, |this, _, cx| {
|
||||
@@ -266,13 +274,9 @@ impl CredentialForm {
|
||||
self.source = source;
|
||||
// A passphrase belongs to one key; whatever the field held was for
|
||||
// the key the form showed before.
|
||||
let placeholder = match source {
|
||||
KeySource::Generate => NO_PASSPHRASE,
|
||||
KeySource::File | KeySource::Paste => ASK_FOR_PASSPHRASE,
|
||||
};
|
||||
self.fields.update(cx, |fields, cx| {
|
||||
fields.clear_passphrase(window, cx);
|
||||
fields.set_passphrase_placeholder(placeholder, window, cx);
|
||||
fields.set_passphrase_placeholder(passphrase_placeholder(source), window, cx);
|
||||
});
|
||||
if source == KeySource::Generate {
|
||||
self.generate(cx);
|
||||
@@ -315,7 +319,9 @@ impl CredentialForm {
|
||||
}
|
||||
let generated = match made {
|
||||
Ok(key) => Generated::Ready(Arc::new(key)),
|
||||
Err(error) => Generated::Failed(format!("无法生成密钥:{error}").into()),
|
||||
Err(error) => {
|
||||
Generated::Failed(t!("credential.dialog.generate_failed", error = error))
|
||||
}
|
||||
};
|
||||
this.generated = Some((algorithm, generated));
|
||||
cx.notify();
|
||||
@@ -379,7 +385,8 @@ impl CredentialForm {
|
||||
// A pasted or generated key's file is only written once everything
|
||||
// else checks out; until then the draft stands in for it.
|
||||
let key_path = if key_from_text {
|
||||
"(新私钥)".to_string()
|
||||
// A stand-in, never shown or kept.
|
||||
"(pasted key)".to_string()
|
||||
} else {
|
||||
key_file
|
||||
};
|
||||
@@ -417,13 +424,17 @@ impl CredentialForm {
|
||||
let key = match &self.generated {
|
||||
Some((algorithm, Generated::Ready(key))) if *algorithm == self.algorithm => key.clone(),
|
||||
Some((_, Generated::Failed(error))) => return self.fail(error.clone(), window, cx),
|
||||
_ => return self.fail("密钥还在生成,请稍候", window, cx),
|
||||
_ => return self.fail(t!("credential.dialog.still_generating"), window, cx),
|
||||
};
|
||||
let comment = draft.name.clone();
|
||||
if passphrase.is_empty() {
|
||||
return match key.encode(&comment, "") {
|
||||
Ok(text) => self.save(draft, Some(text), None, Some(None), window, cx),
|
||||
Err(error) => self.fail(format!("无法保存私钥:{error}"), window, cx),
|
||||
Err(error) => self.fail(
|
||||
t!("credential.dialog.encode_failed", error = error),
|
||||
window,
|
||||
cx,
|
||||
),
|
||||
};
|
||||
}
|
||||
// Deriving the encryption key from a passphrase is slow on purpose,
|
||||
@@ -443,7 +454,11 @@ impl CredentialForm {
|
||||
let passphrase = Some(Some(passphrase.to_string()));
|
||||
this.save(draft, Some(text), None, passphrase, window, cx)
|
||||
}
|
||||
Err(error) => this.fail(format!("无法加密私钥:{error}"), window, cx),
|
||||
Err(error) => this.fail(
|
||||
t!("credential.dialog.encrypt_failed", error = error),
|
||||
window,
|
||||
cx,
|
||||
),
|
||||
};
|
||||
if saved {
|
||||
// Closing it from here skips the dialog's `on_close`.
|
||||
@@ -479,7 +494,13 @@ impl CredentialForm {
|
||||
if let Some(text) = key_text {
|
||||
match self.store.read(cx).save_private_key(editing, &text) {
|
||||
Ok(path) => draft.key_path = Some(path),
|
||||
Err(error) => return self.fail(format!("私钥未能保存:{error}"), window, cx),
|
||||
Err(error) => {
|
||||
return self.fail(
|
||||
t!("credential.dialog.key_not_saved", error = error),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let passphrase_change = passphrase_change
|
||||
@@ -513,19 +534,19 @@ impl CredentialForm {
|
||||
}
|
||||
|
||||
/// What editing the credential does to the hosts using it.
|
||||
fn usage_note(&self) -> Option<String> {
|
||||
fn usage_note(&self) -> Option<SharedString> {
|
||||
if self.hosts == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut note = format!("有 {} 台主机使用此凭据。", self.hosts);
|
||||
let mut note = vec![tn!("credential.dialog.usage", self.hosts)];
|
||||
// A new key is no use to them until their servers know it.
|
||||
if self.kind == CredentialKind::Key && self.source != KeySource::File {
|
||||
note.push_str("换成新私钥后,要把它的公钥加到这些主机上,它们才能再登录。");
|
||||
note.push(t!("credential.dialog.usage_new_key"));
|
||||
}
|
||||
if self.connected > 0 {
|
||||
note.push_str("修改用户名、类型或私钥文件后,其中已连接的主机会重新连接。");
|
||||
note.push(t!("credential.dialog.usage_reconnects"));
|
||||
}
|
||||
Some(note)
|
||||
join_sentences(note)
|
||||
}
|
||||
|
||||
/// Whether saving now would delete the key ShellRS keeps for the
|
||||
@@ -600,7 +621,7 @@ impl CredentialForm {
|
||||
Segment::new(each.label()).when(each != KeySource::File && !can_keep, |segment| {
|
||||
segment
|
||||
.disabled(true)
|
||||
.tooltip("本地数据库没有打开,这次运行无法保存私钥")
|
||||
.tooltip(t!("credential.dialog.no_key_dir"))
|
||||
})
|
||||
}));
|
||||
// A generated key has nothing to fill in under the choice.
|
||||
@@ -630,17 +651,17 @@ impl CredentialForm {
|
||||
};
|
||||
let mut form = form.child(
|
||||
Field::new()
|
||||
.label("私钥")
|
||||
.label(t!("credential.dialog.private_key"))
|
||||
.required(true)
|
||||
.child(v_flex().w_full().gap_2().child(sources).children(key))
|
||||
.when(kept_here, |field| {
|
||||
field.description("这把私钥由 ShellRS 保存,删除凭据时会一起删除。")
|
||||
field.description(t!("credential.dialog.kept_key"))
|
||||
}),
|
||||
);
|
||||
if source == KeySource::Generate {
|
||||
form = form.child(
|
||||
Field::new()
|
||||
.label("算法")
|
||||
.label(t!("credential.dialog.algorithm"))
|
||||
.child(
|
||||
SegmentedControl::new("credential-key-algorithm")
|
||||
.selected_index(
|
||||
@@ -656,41 +677,43 @@ impl CredentialForm {
|
||||
.segments(KeyAlgorithm::ALL.map(|each| Segment::new(each.label()))),
|
||||
)
|
||||
.when(self.algorithm == KeyAlgorithm::Rsa, |field| {
|
||||
field.description("只在服务器不支持 Ed25519 时选它(OpenSSH 6.5 以前)。")
|
||||
field.description(t!("credential.dialog.rsa_note"))
|
||||
}),
|
||||
);
|
||||
}
|
||||
form = match self.public_key(cx) {
|
||||
PublicKeyView::Line(line) => form.child(
|
||||
Field::new()
|
||||
.label("公钥")
|
||||
.label(t!("credential.dialog.public_key"))
|
||||
.child(public_key_box(line, cx))
|
||||
.when(source == KeySource::Generate, |field| {
|
||||
field.description(
|
||||
"把公钥加到服务器的 ~/.ssh/authorized_keys,就能用这把密钥登录。",
|
||||
)
|
||||
field.description(t!("credential.dialog.public_key_note"))
|
||||
}),
|
||||
),
|
||||
PublicKeyView::Generating => form.child(
|
||||
Field::new().label("公钥").child(
|
||||
div()
|
||||
.id("credential-public-key-pending")
|
||||
.test_support()
|
||||
.text_sm()
|
||||
.text_color(muted)
|
||||
.child("正在生成密钥…"),
|
||||
),
|
||||
Field::new()
|
||||
.label(t!("credential.dialog.public_key"))
|
||||
.child(
|
||||
div()
|
||||
.id("credential-public-key-pending")
|
||||
.test_support()
|
||||
.text_sm()
|
||||
.text_color(muted)
|
||||
.child(t!("credential.dialog.generating")),
|
||||
),
|
||||
),
|
||||
PublicKeyView::Failed(error) => form.child(
|
||||
Field::new().label("公钥").child(
|
||||
div()
|
||||
.id("credential-generate-error")
|
||||
.test_support()
|
||||
.aria_label(error.clone())
|
||||
.text_sm()
|
||||
.text_color(theme.danger)
|
||||
.child(error),
|
||||
),
|
||||
Field::new()
|
||||
.label(t!("credential.dialog.public_key"))
|
||||
.child(
|
||||
div()
|
||||
.id("credential-generate-error")
|
||||
.test_support()
|
||||
.aria_label(error.clone())
|
||||
.text_sm()
|
||||
.text_color(theme.danger)
|
||||
.child(error),
|
||||
),
|
||||
),
|
||||
PublicKeyView::Unknown => form,
|
||||
};
|
||||
@@ -699,7 +722,7 @@ impl CredentialForm {
|
||||
if source != KeySource::Paste || encrypted_paste {
|
||||
form = form.child(
|
||||
Field::new()
|
||||
.label("私钥口令")
|
||||
.label(t!("credential.dialog.passphrase"))
|
||||
.child(fields.passphrase_input("credential-passphrase")),
|
||||
);
|
||||
}
|
||||
@@ -736,7 +759,7 @@ fn public_key_box(line: SharedString, cx: &App) -> impl IntoElement + use<> {
|
||||
.child(
|
||||
Clipboard::new("copy-public-key")
|
||||
.value(line)
|
||||
.tooltip("复制公钥"),
|
||||
.tooltip(t!("credential.dialog.copy_public_key")),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -747,13 +770,13 @@ impl Render for CredentialForm {
|
||||
let keychain = self.fields.read(cx).keychain_available();
|
||||
let secret_note = match kind {
|
||||
CredentialKind::Agent => None,
|
||||
_ if keychain => Some("密码和口令保存在系统钥匙串,不会写入 ShellRS 的数据库。"),
|
||||
_ => Some("系统钥匙串不可用,这台机器上无法保存密码,每次连接都会询问。"),
|
||||
_ if keychain => Some(t!("credential.dialog.keychain_note")),
|
||||
_ => Some(t!("credential.dialog.no_keychain_note")),
|
||||
};
|
||||
let drops_kept_key = self.drops_kept_key(cx);
|
||||
let form = Form::new()
|
||||
.child(
|
||||
Field::new().label("类型").child(
|
||||
Field::new().label(t!("credential.dialog.kind")).child(
|
||||
SegmentedControl::new("credential-kind")
|
||||
.selected_index(CredentialKind::ALL.iter().position(|each| *each == kind))
|
||||
.on_change(cx.listener(|this, ix: &usize, _, cx| {
|
||||
@@ -766,19 +789,19 @@ impl Render for CredentialForm {
|
||||
)
|
||||
.child(
|
||||
Field::new()
|
||||
.label("名称")
|
||||
.label(t!("credential.dialog.name"))
|
||||
.required(true)
|
||||
.child(Input::new(&self.name).id("credential-name").small()),
|
||||
)
|
||||
.child(
|
||||
Field::new()
|
||||
.label("用户名")
|
||||
.label(t!("credential.dialog.user"))
|
||||
.child(Input::new(&self.user).id("credential-user").small()),
|
||||
);
|
||||
let form = match kind {
|
||||
CredentialKind::Password => form.child(
|
||||
Field::new()
|
||||
.label("密码")
|
||||
.label(t!("credential.dialog.password"))
|
||||
.child(self.fields.read(cx).password_input("credential-password")),
|
||||
),
|
||||
CredentialKind::Key => self.key_fields(form, cx),
|
||||
@@ -793,22 +816,22 @@ impl Render for CredentialForm {
|
||||
div()
|
||||
.id("credential-agent-note")
|
||||
.test_support()
|
||||
.aria_label(AGENT_NOTE)
|
||||
.aria_label(agent_note())
|
||||
.text_sm()
|
||||
.text_color(muted)
|
||||
.child(AGENT_NOTE),
|
||||
.child(agent_note()),
|
||||
)
|
||||
})
|
||||
.when_some(secret_note, |view, note| {
|
||||
view.child(div().text_sm().text_color(muted).child(note))
|
||||
})
|
||||
.when(drops_kept_key, |view| {
|
||||
let note = "保存后,ShellRS 保存的原私钥会被删除。";
|
||||
let note = t!("credential.dialog.drops_kept_key");
|
||||
view.child(
|
||||
div()
|
||||
.id("credential-kept-key-note")
|
||||
.test_support()
|
||||
.aria_label(note)
|
||||
.aria_label(note.clone())
|
||||
.text_sm()
|
||||
.text_color(cx.theme().warning)
|
||||
.child(note),
|
||||
@@ -832,7 +855,7 @@ impl Render for CredentialForm {
|
||||
.test_support()
|
||||
.text_sm()
|
||||
.text_color(muted)
|
||||
.child("正在用口令加密私钥…"),
|
||||
.child(t!("credential.dialog.encrypting")),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -848,13 +871,16 @@ pub fn open_credential_dialog(
|
||||
) {
|
||||
let form = cx.new(|cx| CredentialForm::new(dialog, store, window, cx));
|
||||
let editing = matches!(dialog, CredentialDialog::Edit(_));
|
||||
let title: SharedString = if editing {
|
||||
"编辑凭据"
|
||||
let title = if editing {
|
||||
t!("credential.dialog.title_edit")
|
||||
} else {
|
||||
"新建凭据"
|
||||
}
|
||||
.into();
|
||||
let commit_label: SharedString = if editing { "保存" } else { "创建" }.into();
|
||||
t!("credential.dialog.title_new")
|
||||
};
|
||||
let commit_label = if editing {
|
||||
t!("common.save")
|
||||
} else {
|
||||
t!("credential.dialog.create")
|
||||
};
|
||||
window.open_dialog(cx, {
|
||||
let form = form.clone();
|
||||
move |dialog, _, _| {
|
||||
|
||||
@@ -19,6 +19,7 @@ use crate::app::{
|
||||
SelectNextCredential, SelectPreviousCredential,
|
||||
};
|
||||
use crate::host::{CredentialId, CredentialKind, HostStore, matches_credential_query};
|
||||
use crate::i18n::{UiLocale, t, tn};
|
||||
use crate::shared::{RowTooltip, RowTooltips};
|
||||
|
||||
/// The credential list the left dock shows in place of the hosts: every
|
||||
@@ -69,7 +70,7 @@ impl CredentialPanel {
|
||||
) -> Self {
|
||||
let search = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("搜索凭据")
|
||||
.placeholder(t!("credential.panel.search"))
|
||||
.clean_on_escape()
|
||||
});
|
||||
let known = store
|
||||
@@ -80,6 +81,11 @@ impl CredentialPanel {
|
||||
.collect();
|
||||
let subscriptions = vec![
|
||||
cx.observe(&store, |this, _, cx| this.on_store_changed(cx)),
|
||||
cx.observe_global_in::<UiLocale>(window, |this, window, cx| {
|
||||
this.search.update(cx, |search, cx| {
|
||||
search.set_placeholder(t!("credential.panel.search"), window, cx)
|
||||
});
|
||||
}),
|
||||
cx.subscribe(&search, |this, state, event: &InputEvent, cx| {
|
||||
if matches!(event, InputEvent::Change) {
|
||||
this.query = state.read(cx).value().to_string();
|
||||
@@ -136,7 +142,7 @@ impl CredentialPanel {
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(Icon::new(CatalogIcon::KeyRound).small())
|
||||
.child("凭据")
|
||||
.child(t!("credential.panel.title"))
|
||||
}
|
||||
|
||||
/// The dock's toolbar while this list is up.
|
||||
@@ -145,8 +151,8 @@ impl CredentialPanel {
|
||||
vec![
|
||||
Button::new("new-credential")
|
||||
.icon(IconName::Plus)
|
||||
.tooltip("新建凭据…")
|
||||
.accessibility_label("新建凭据…")
|
||||
.tooltip(t!("credential.menu.new"))
|
||||
.accessibility_label(t!("credential.menu.new"))
|
||||
.on_click(move |_, window, cx| target.dispatch_action(&NewCredential, window, cx)),
|
||||
]
|
||||
}
|
||||
@@ -162,14 +168,14 @@ impl CredentialPanel {
|
||||
let hosts = store.hosts_using(credential.id).count();
|
||||
let summary = credential.summary(store.key_dir());
|
||||
let detail = match hosts {
|
||||
0 => summary,
|
||||
hosts => format!("{summary} · {hosts} 台主机"),
|
||||
0 => summary.into(),
|
||||
hosts => tn!("credential.panel.detail", hosts, summary = summary),
|
||||
};
|
||||
CredentialRow {
|
||||
id: credential.id,
|
||||
kind: credential.kind,
|
||||
name: credential.name.clone(),
|
||||
detail: detail.into(),
|
||||
detail,
|
||||
key_path: credential.key_path.clone(),
|
||||
}
|
||||
})
|
||||
@@ -226,12 +232,12 @@ impl CredentialPanel {
|
||||
let muted = cx.theme().muted_foreground;
|
||||
let target = self.target.clone();
|
||||
let (title, hint, buttons) = if !self.store.read(cx).credentials().is_empty() {
|
||||
("没有匹配的凭据", None, None)
|
||||
(t!("credential.panel.no_matches"), None, None)
|
||||
} else {
|
||||
let generate = target.clone();
|
||||
(
|
||||
"还没有凭据",
|
||||
Some("保存一套用户名和密码、私钥或 SSH Agent,让多台主机共用。"),
|
||||
t!("credential.panel.empty"),
|
||||
Some(t!("credential.panel.empty_hint")),
|
||||
Some(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
@@ -241,7 +247,7 @@ impl CredentialPanel {
|
||||
Button::new("credential-empty-new")
|
||||
.small()
|
||||
.icon(IconName::Plus)
|
||||
.label("新建凭据…")
|
||||
.label(t!("credential.menu.new"))
|
||||
.on_click(move |_, window, cx| {
|
||||
target.dispatch_action(&NewCredential, window, cx)
|
||||
}),
|
||||
@@ -250,7 +256,7 @@ impl CredentialPanel {
|
||||
Button::new("credential-empty-generate")
|
||||
.small()
|
||||
.icon(CatalogIcon::KeyRound)
|
||||
.label("生成密钥…")
|
||||
.label(t!("credential.menu.generate"))
|
||||
.on_click(move |_, window, cx| {
|
||||
generate.dispatch_action(&GenerateCredentialKey, window, cx)
|
||||
}),
|
||||
@@ -261,7 +267,7 @@ impl CredentialPanel {
|
||||
v_flex()
|
||||
.id("credential-empty")
|
||||
.test_support()
|
||||
.aria_label(title)
|
||||
.aria_label(title.clone())
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_4()
|
||||
@@ -295,7 +301,7 @@ impl CredentialPanel {
|
||||
.confirmed(selected)
|
||||
.aria_selected(selected)
|
||||
.when(selected, |item| {
|
||||
item.bg(crate::app::host_tree_selection_color(theme))
|
||||
item.bg(crate::app::selected_row_color(theme))
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
@@ -374,31 +380,31 @@ fn build_context_menu(hit: Option<(CredentialId, CredentialKind)>, menu: PopupMe
|
||||
let Some((id, kind)) = hit else {
|
||||
return menu
|
||||
.menu_with_icon(
|
||||
"新建凭据…",
|
||||
t!("credential.menu.new"),
|
||||
Icon::new(IconName::Plus),
|
||||
Box::new(NewCredential),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"生成密钥…",
|
||||
t!("credential.menu.generate"),
|
||||
Icon::new(CatalogIcon::KeyRound),
|
||||
Box::new(GenerateCredentialKey),
|
||||
);
|
||||
};
|
||||
menu.menu_with_icon(
|
||||
"编辑凭据…",
|
||||
t!("credential.menu.edit"),
|
||||
Icon::new(CatalogIcon::Pencil),
|
||||
Box::new(EditCredential(id)),
|
||||
)
|
||||
.when(kind == CredentialKind::Key, |menu| {
|
||||
menu.menu_with_icon(
|
||||
"复制公钥",
|
||||
t!("credential.menu.copy_public_key"),
|
||||
Icon::new(CatalogIcon::ClipboardCopy),
|
||||
Box::new(CopyCredentialPublicKey(id)),
|
||||
)
|
||||
})
|
||||
.separator()
|
||||
.menu_with_icon(
|
||||
"删除",
|
||||
t!("common.delete"),
|
||||
Icon::new(CatalogIcon::Trash),
|
||||
Box::new(DeleteCredential(id)),
|
||||
)
|
||||
|
||||
@@ -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")),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ use gpui_kit::prelude::FluentBuilder as _;
|
||||
use gpui_kit::*;
|
||||
|
||||
use super::model::{DetailSection, SectionBody};
|
||||
use crate::i18n::t;
|
||||
use crate::terminal::{ExecResult, TerminalView, exec_answer};
|
||||
|
||||
/// Something read off the host for a dialog.
|
||||
@@ -29,10 +30,10 @@ pub(super) fn run<V: 'static>(
|
||||
let reply = command.and_then(|command| terminal.upgrade()?.read(cx).exec(command, cx));
|
||||
cx.spawn(async move |this, cx| {
|
||||
let answer: ExecResult = match reply {
|
||||
None => Err("终端没有连接".into()),
|
||||
None => Err(t!("tools.not_connected").into()),
|
||||
Some(reply) => match exec_answer(reply, cx).await {
|
||||
None => Err("终端没有连接".into()),
|
||||
Some(Err(error)) => Err(format!("读取失败:{error}")),
|
||||
None => Err(t!("tools.not_connected").into()),
|
||||
Some(Err(error)) => Err(t!("tools.read_failed", error = error).into()),
|
||||
Some(Ok(output)) => Ok(output),
|
||||
},
|
||||
};
|
||||
@@ -46,7 +47,7 @@ pub(super) fn run<V: 'static>(
|
||||
|
||||
/// The sections one under another, each under its heading; 「—」 for one
|
||||
/// with nothing in it. Their lines are found as 「{prefix}-{section}:{label}」
|
||||
/// (「container-basics:名称」), a text section as 「{prefix}-{section}」.
|
||||
/// (「container-basics:name」), a text section as 「{prefix}-{section}」.
|
||||
pub(super) fn render_sections(
|
||||
prefix: &'static str,
|
||||
sections: &[DetailSection],
|
||||
@@ -59,7 +60,7 @@ pub(super) fn render_sections(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(section.title),
|
||||
.child(t!(section.title)),
|
||||
)
|
||||
.child(render_body(prefix, section, cx))
|
||||
}))
|
||||
@@ -98,7 +99,7 @@ fn render_body(prefix: &'static str, section: &DetailSection, cx: &App) -> AnyEl
|
||||
rows.iter()
|
||||
.map(|(label, value)| {
|
||||
h_flex()
|
||||
.id(line_id(label))
|
||||
.id(line_id(label.id()))
|
||||
.test_support()
|
||||
.aria_label(value.clone())
|
||||
.items_start()
|
||||
@@ -108,10 +109,10 @@ fn render_body(prefix: &'static str, section: &DetailSection, cx: &App) -> AnyEl
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.w(rems(7.))
|
||||
.w(rems(9.))
|
||||
.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()
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use gpui_kit::component::{
|
||||
h_flex,
|
||||
scroll::ScrollableElement as _,
|
||||
spinner::Spinner,
|
||||
tab::{Tab, TabBar},
|
||||
tag::Tag,
|
||||
v_flex,
|
||||
};
|
||||
@@ -16,15 +17,16 @@ 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::shared::{count_tabs, soft_tag, tinted};
|
||||
use crate::i18n::t;
|
||||
use crate::shared::{soft_tag, tinted};
|
||||
use crate::terminal::{ExecResult, ExecTarget, RemoteTerminalId, exec_answer};
|
||||
|
||||
/// How long a terminal not connected is left before it is asked again.
|
||||
@@ -248,61 +250,86 @@ impl DockerPanel {
|
||||
self.sync_list(&rows);
|
||||
let summary = table.summary();
|
||||
let refresh = self.dispatch.clone();
|
||||
let header = v_flex()
|
||||
.flex_shrink_0()
|
||||
.px_3()
|
||||
.pt_3()
|
||||
.pb_2()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.id("docker-summary")
|
||||
.test_support()
|
||||
.aria_label(summary.clone())
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(summary),
|
||||
)
|
||||
.child(
|
||||
Button::new("docker-refresh")
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.icon(Icon::new(CatalogIcon::RefreshCw))
|
||||
.loading(self.loading)
|
||||
.tooltip("刷新")
|
||||
.accessibility_label("刷新")
|
||||
.on_click(move |_, window, cx| {
|
||||
refresh.dispatch_action(&RefreshDocker, window, cx)
|
||||
}),
|
||||
let header =
|
||||
v_flex()
|
||||
.flex_shrink_0()
|
||||
.px_3()
|
||||
.pt_3()
|
||||
.pb_2()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.id("docker-summary")
|
||||
.test_support()
|
||||
.aria_label(summary.clone())
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(summary),
|
||||
)
|
||||
.child(
|
||||
Button::new("docker-refresh")
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.icon(Icon::new(CatalogIcon::RefreshCw))
|
||||
.loading(self.loading)
|
||||
.tooltip(t!("tools.refresh"))
|
||||
.accessibility_label(t!("tools.refresh"))
|
||||
.on_click(move |_, window, cx| {
|
||||
refresh.dispatch_action(&RefreshDocker, window, cx)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.when_some(problem, |header, problem| {
|
||||
header.child(render_problem(&problem, cx))
|
||||
})
|
||||
.child(
|
||||
// gpui-kit's underlined tabs, as 系统服务's: each as wide as
|
||||
// its label and count, and where they do not all fit the bar
|
||||
// scrolls sideways rather than cut a label short, whatever
|
||||
// the language ("Containers 12" has no room in a quarter of
|
||||
// the panel).
|
||||
div().id("docker-tabs").test_support().child(
|
||||
TabBar::new("docker-tab-bar")
|
||||
.underline()
|
||||
.small()
|
||||
.selected_index(
|
||||
DockerTab::ALL
|
||||
.iter()
|
||||
.position(|tab| *tab == self.tab)
|
||||
.unwrap_or(0),
|
||||
)
|
||||
.on_click(cx.listener(|this, index: &usize, _, cx| {
|
||||
this.show(DockerTab::ALL[*index], cx)
|
||||
}))
|
||||
.children(DockerTab::ALL.iter().zip(table.counts()).map(
|
||||
|(tab, count)| {
|
||||
Tab::new()
|
||||
.label(tab.label())
|
||||
.aria_label(format!("{} {count}", tab.label()))
|
||||
// Beside the label: the suffix would sit
|
||||
// outside the label's box.
|
||||
.child(
|
||||
div()
|
||||
.ml_1()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(count.to_string()),
|
||||
)
|
||||
},
|
||||
)),
|
||||
),
|
||||
)
|
||||
.when_some(problem, |header, problem| {
|
||||
header.child(render_problem(&problem, cx))
|
||||
})
|
||||
.child(count_tabs(
|
||||
"docker-tabs",
|
||||
DockerTab::ALL
|
||||
.iter()
|
||||
.map(|tab| tab.label())
|
||||
.zip(table.counts()),
|
||||
DockerTab::ALL
|
||||
.iter()
|
||||
.position(|tab| *tab == self.tab)
|
||||
.unwrap_or(0),
|
||||
cx.listener(|this, index: &usize, _, cx| this.show(DockerTab::ALL[*index], cx)),
|
||||
));
|
||||
);
|
||||
|
||||
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 +347,7 @@ impl DockerPanel {
|
||||
div()
|
||||
.id("docker-empty")
|
||||
.test_support()
|
||||
.aria_label(empty)
|
||||
.aria_label(empty.clone())
|
||||
.py_8()
|
||||
.text_sm()
|
||||
.text_center()
|
||||
@@ -347,7 +374,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 +457,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 +467,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 +508,7 @@ fn dot(color: Hsla) -> Div {
|
||||
/// stop while something in it is up.
|
||||
fn command_buttons(
|
||||
id: &str,
|
||||
subject: String,
|
||||
subject: ContainerSubject,
|
||||
ids: Vec<String>,
|
||||
up: bool,
|
||||
dispatch: &FocusHandle,
|
||||
@@ -563,8 +587,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 +657,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 +749,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 +763,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 +860,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.
|
||||
|
||||
@@ -7,8 +7,9 @@ use serde_json::Value;
|
||||
|
||||
use super::model::{
|
||||
Container, ContainerCommand, ContainerDetails, ContainerState, DetailSection, DockerObject,
|
||||
DockerTable, Image, Network, SectionBody, Volume, rows,
|
||||
DockerTable, Image, Network, RowLabel, SectionBody, Volume, rows,
|
||||
};
|
||||
use crate::i18n::t;
|
||||
use crate::shared::format_bytes;
|
||||
|
||||
/// Where `docker` may be besides the login's `PATH` (Docker Desktop on a
|
||||
@@ -107,15 +108,15 @@ pub fn logs_command(id: &str) -> Option<String> {
|
||||
))
|
||||
}
|
||||
|
||||
/// Docker's complaint, in Chinese where it is one a user can do something
|
||||
/// about.
|
||||
/// Docker's complaint, in ShellRS's words where it is one a user can do
|
||||
/// something about.
|
||||
fn complaint(said: &str) -> String {
|
||||
if said.contains("password is required")
|
||||
|| (said.contains("permission denied") && said.contains("docker"))
|
||||
{
|
||||
"没有权限:以 root 登录、把这个用户加入 docker 组,或给它配置免密码的 sudo".into()
|
||||
t!("docker.error.permission").into()
|
||||
} else if said.contains("Cannot connect to the Docker daemon") {
|
||||
"Docker 没有运行".into()
|
||||
t!("docker.error.not_running").into()
|
||||
} else {
|
||||
let line = said.lines().next().unwrap_or(said).trim();
|
||||
line.trim_start_matches("Error response from daemon: ")
|
||||
@@ -128,10 +129,10 @@ fn complaint(said: &str) -> String {
|
||||
pub fn done(output: &str) -> Result<(), String> {
|
||||
let (said, status) = output
|
||||
.rsplit_once("@@status")
|
||||
.ok_or_else(|| "主机没有回答".to_string())?;
|
||||
.ok_or_else(|| t!("tools.no_answer").to_string())?;
|
||||
match status.trim() {
|
||||
"0" => Ok(()),
|
||||
status if said.trim().is_empty() => Err(format!("docker 返回 {status}")),
|
||||
status if said.trim().is_empty() => Err(t!("docker.error.status", status = status).into()),
|
||||
_ => Err(complaint(said.trim())),
|
||||
}
|
||||
}
|
||||
@@ -374,13 +375,14 @@ pub fn parse_details(output: &str) -> Option<ContainerDetails> {
|
||||
_ => text(mount, "Source"),
|
||||
};
|
||||
let read_only = mount.get("RW").and_then(Value::as_bool) == Some(false);
|
||||
let destination = text(mount, "Destination");
|
||||
(
|
||||
source.to_owned(),
|
||||
format!(
|
||||
"{}{}",
|
||||
text(mount, "Destination"),
|
||||
if read_only { "(只读)" } else { "" }
|
||||
),
|
||||
if read_only {
|
||||
t!("docker.mount.read_only", path = destination).into()
|
||||
} else {
|
||||
destination.to_owned()
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -462,8 +464,8 @@ fn pairs_at(value: &Value, keys: &[&str]) -> Vec<(String, String)> {
|
||||
|
||||
fn yes_no(value: &Value, keys: &[&str]) -> String {
|
||||
match at(value, keys).and_then(Value::as_bool) {
|
||||
Some(true) => "是".into(),
|
||||
Some(false) => "否".into(),
|
||||
Some(true) => t!("docker.value.yes").into(),
|
||||
Some(false) => t!("docker.value.no").into(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
@@ -473,7 +475,7 @@ fn yes_no(value: &Value, keys: &[&str]) -> String {
|
||||
fn used_by(used_by: &[String]) -> DetailSection {
|
||||
DetailSection {
|
||||
id: "users",
|
||||
title: "使用它的容器",
|
||||
title: "docker.section.users",
|
||||
body: SectionBody::List(used_by.to_vec()),
|
||||
}
|
||||
}
|
||||
@@ -485,24 +487,39 @@ pub fn volume_details(output: &str, users: &[String]) -> Option<Vec<DetailSectio
|
||||
Some(vec![
|
||||
DetailSection {
|
||||
id: "basics",
|
||||
title: "基础信息",
|
||||
title: "docker.section.basics",
|
||||
body: rows([
|
||||
("名称", string_at(&volume, &["Name"])),
|
||||
("驱动", string_at(&volume, &["Driver"])),
|
||||
("挂载点", string_at(&volume, &["Mountpoint"])),
|
||||
("范围", string_at(&volume, &["Scope"])),
|
||||
("创建时间", string_at(&volume, &["CreatedAt"])),
|
||||
(
|
||||
RowLabel::Field("docker.field.name"),
|
||||
string_at(&volume, &["Name"]),
|
||||
),
|
||||
(
|
||||
RowLabel::Field("docker.field.driver"),
|
||||
string_at(&volume, &["Driver"]),
|
||||
),
|
||||
(
|
||||
RowLabel::Field("docker.field.mountpoint"),
|
||||
string_at(&volume, &["Mountpoint"]),
|
||||
),
|
||||
(
|
||||
RowLabel::Field("docker.field.scope"),
|
||||
string_at(&volume, &["Scope"]),
|
||||
),
|
||||
(
|
||||
RowLabel::Field("docker.field.created"),
|
||||
string_at(&volume, &["CreatedAt"]),
|
||||
),
|
||||
]),
|
||||
},
|
||||
used_by(users),
|
||||
DetailSection {
|
||||
id: "options",
|
||||
title: "选项",
|
||||
title: "docker.section.options",
|
||||
body: rows(pairs_at(&volume, &["Options"])),
|
||||
},
|
||||
DetailSection {
|
||||
id: "labels",
|
||||
title: "标签",
|
||||
title: "docker.section.labels",
|
||||
body: rows(pairs_at(&volume, &["Labels"])),
|
||||
},
|
||||
])
|
||||
@@ -531,51 +548,75 @@ pub fn image_details(output: &str, users: &[String]) -> Option<Vec<DetailSection
|
||||
Some(vec![
|
||||
DetailSection {
|
||||
id: "basics",
|
||||
title: "基础信息",
|
||||
title: "docker.section.basics",
|
||||
body: rows([
|
||||
(
|
||||
"ID",
|
||||
RowLabel::Raw("ID".into()),
|
||||
string_at(&image, &["Id"])
|
||||
.trim_start_matches("sha256:")
|
||||
.to_owned(),
|
||||
),
|
||||
("标签", strings_at(&image, &["RepoTags"]).join("\n")),
|
||||
("摘要", strings_at(&image, &["RepoDigests"]).join("\n")),
|
||||
("创建时间", string_at(&image, &["Created"])),
|
||||
(
|
||||
"大小",
|
||||
RowLabel::Field("docker.field.tags"),
|
||||
strings_at(&image, &["RepoTags"]).join("\n"),
|
||||
),
|
||||
(
|
||||
RowLabel::Field("docker.field.digests"),
|
||||
strings_at(&image, &["RepoDigests"]).join("\n"),
|
||||
),
|
||||
(
|
||||
RowLabel::Field("docker.field.created"),
|
||||
string_at(&image, &["Created"]),
|
||||
),
|
||||
(
|
||||
RowLabel::Field("docker.field.size"),
|
||||
at(&image, &["Size"])
|
||||
.and_then(Value::as_u64)
|
||||
.map_or(String::new(), format_bytes),
|
||||
),
|
||||
("平台", platform),
|
||||
("层数", layers),
|
||||
("作者", string_at(&image, &["Author"])),
|
||||
(RowLabel::Field("docker.field.platform"), platform),
|
||||
(RowLabel::Field("docker.field.layers"), layers),
|
||||
(
|
||||
RowLabel::Field("docker.field.author"),
|
||||
string_at(&image, &["Author"]),
|
||||
),
|
||||
]),
|
||||
},
|
||||
used_by(users),
|
||||
DetailSection {
|
||||
id: "config",
|
||||
title: "运行配置",
|
||||
title: "docker.section.config",
|
||||
body: rows([
|
||||
(
|
||||
"入口",
|
||||
RowLabel::Field("docker.field.entrypoint"),
|
||||
strings_at(&image, &["Config", "Entrypoint"]).join(" "),
|
||||
),
|
||||
("命令", strings_at(&image, &["Config", "Cmd"]).join(" ")),
|
||||
("工作目录", string_at(&image, &["Config", "WorkingDir"])),
|
||||
("用户", string_at(&image, &["Config", "User"])),
|
||||
("暴露端口", ports.join(", ")),
|
||||
(
|
||||
RowLabel::Field("docker.field.command"),
|
||||
strings_at(&image, &["Config", "Cmd"]).join(" "),
|
||||
),
|
||||
(
|
||||
RowLabel::Field("docker.field.working_dir"),
|
||||
string_at(&image, &["Config", "WorkingDir"]),
|
||||
),
|
||||
(
|
||||
RowLabel::Field("docker.field.user"),
|
||||
string_at(&image, &["Config", "User"]),
|
||||
),
|
||||
(
|
||||
RowLabel::Field("docker.field.exposed_ports"),
|
||||
ports.join(", "),
|
||||
),
|
||||
]),
|
||||
},
|
||||
DetailSection {
|
||||
id: "environment",
|
||||
title: "环境变量",
|
||||
title: "docker.section.environment",
|
||||
body: SectionBody::Text(strings_at(&image, &["Config", "Env"])),
|
||||
},
|
||||
DetailSection {
|
||||
id: "labels",
|
||||
title: "标签",
|
||||
title: "docker.section.labels",
|
||||
body: rows(pairs_at(&image, &["Config", "Labels"])),
|
||||
},
|
||||
])
|
||||
@@ -594,7 +635,7 @@ pub fn network_details(output: &str) -> Option<Vec<DetailSection>> {
|
||||
text(config, "Subnet").to_owned(),
|
||||
match text(config, "Gateway") {
|
||||
"" => String::new(),
|
||||
gateway => format!("网关 {gateway}"),
|
||||
gateway => t!("docker.network.gateway", address = gateway).into(),
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -621,35 +662,53 @@ pub fn network_details(output: &str) -> Option<Vec<DetailSection>> {
|
||||
Some(vec![
|
||||
DetailSection {
|
||||
id: "basics",
|
||||
title: "基础信息",
|
||||
title: "docker.section.basics",
|
||||
body: rows([
|
||||
("名称", string_at(&network, &["Name"])),
|
||||
("ID", string_at(&network, &["Id"])),
|
||||
("驱动", string_at(&network, &["Driver"])),
|
||||
("范围", string_at(&network, &["Scope"])),
|
||||
("创建时间", string_at(&network, &["Created"])),
|
||||
("IPv6", yes_no(&network, &["EnableIPv6"])),
|
||||
("内部网络", yes_no(&network, &["Internal"])),
|
||||
(
|
||||
RowLabel::Field("docker.field.name"),
|
||||
string_at(&network, &["Name"]),
|
||||
),
|
||||
(RowLabel::Raw("ID".into()), string_at(&network, &["Id"])),
|
||||
(
|
||||
RowLabel::Field("docker.field.driver"),
|
||||
string_at(&network, &["Driver"]),
|
||||
),
|
||||
(
|
||||
RowLabel::Field("docker.field.scope"),
|
||||
string_at(&network, &["Scope"]),
|
||||
),
|
||||
(
|
||||
RowLabel::Field("docker.field.created"),
|
||||
string_at(&network, &["Created"]),
|
||||
),
|
||||
(
|
||||
RowLabel::Raw("IPv6".into()),
|
||||
yes_no(&network, &["EnableIPv6"]),
|
||||
),
|
||||
(
|
||||
RowLabel::Field("docker.field.internal"),
|
||||
yes_no(&network, &["Internal"]),
|
||||
),
|
||||
]),
|
||||
},
|
||||
DetailSection {
|
||||
id: "subnets",
|
||||
title: "子网",
|
||||
title: "docker.section.subnets",
|
||||
body: rows(subnets),
|
||||
},
|
||||
DetailSection {
|
||||
id: "containers",
|
||||
title: "容器",
|
||||
title: "docker.section.containers",
|
||||
body: rows(containers),
|
||||
},
|
||||
DetailSection {
|
||||
id: "options",
|
||||
title: "选项",
|
||||
title: "docker.section.options",
|
||||
body: rows(pairs_at(&network, &["Options"])),
|
||||
},
|
||||
DetailSection {
|
||||
id: "labels",
|
||||
title: "标签",
|
||||
title: "docker.section.labels",
|
||||
body: rows(pairs_at(&network, &["Labels"])),
|
||||
},
|
||||
])
|
||||
@@ -846,7 +905,7 @@ v2.25.0
|
||||
panic!("{id} has no rows");
|
||||
};
|
||||
rows.into_iter()
|
||||
.find(|(key, _)| key == label)
|
||||
.find(|(key, _)| key.id() == label)
|
||||
.unwrap_or_else(|| panic!("no {label} in {id}"))
|
||||
.1
|
||||
}
|
||||
@@ -860,7 +919,7 @@ v2.25.0
|
||||
)
|
||||
.expect("volume");
|
||||
assert_eq!(
|
||||
row(&volume, "basics", "挂载点"),
|
||||
row(&volume, "basics", "mountpoint"),
|
||||
"/var/lib/docker/volumes/vw-data/_data"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -882,13 +941,13 @@ v2.25.0
|
||||
)
|
||||
.expect("image");
|
||||
assert_eq!(row(&image, "basics", "ID"), "4a3b5c6d7e8f9a");
|
||||
assert_eq!(row(&image, "basics", "大小"), "9.84 KB");
|
||||
assert_eq!(row(&image, "basics", "平台"), "linux/amd64");
|
||||
assert_eq!(row(&image, "basics", "层数"), "2");
|
||||
assert_eq!(row(&image, "basics", "size"), "9.84 KB");
|
||||
assert_eq!(row(&image, "basics", "platform"), "linux/amd64");
|
||||
assert_eq!(row(&image, "basics", "layers"), "2");
|
||||
// Nothing to say is said with a dash.
|
||||
assert_eq!(row(&image, "basics", "作者"), "—");
|
||||
assert_eq!(row(&image, "config", "命令"), "/hello");
|
||||
assert_eq!(row(&image, "config", "暴露端口"), "80/tcp");
|
||||
assert_eq!(row(&image, "basics", "author"), "—");
|
||||
assert_eq!(row(&image, "config", "command"), "/hello");
|
||||
assert_eq!(row(&image, "config", "exposed_ports"), "80/tcp");
|
||||
assert_eq!(
|
||||
section(&image, "environment"),
|
||||
SectionBody::Text(vec!["PATH=/usr/bin".into()])
|
||||
@@ -907,6 +966,24 @@ v2.25.0
|
||||
assert_eq!(volume_details("Error: no such volume", &[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_heading_and_label_has_its_text() {
|
||||
let mut sections = ContainerDetails::default().sections();
|
||||
sections.extend(volume_details("[{}]", &[]).expect("volume"));
|
||||
sections.extend(image_details("[{}]", &[]).expect("image"));
|
||||
sections.extend(network_details("[{}]").expect("network"));
|
||||
for section in sections {
|
||||
assert!(crate::i18n::has(section.title), "{}", section.title);
|
||||
if let SectionBody::Rows(rows) = section.body {
|
||||
for (label, _) in rows {
|
||||
if let RowLabel::Field(key) = label {
|
||||
assert!(crate::i18n::has(key), "{key}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_an_id_goes_into_a_command() {
|
||||
assert!(valid_id("f8664a4a9b8d"));
|
||||
|
||||
@@ -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;
|
||||
@@ -3,6 +3,10 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use gpui_kit::SharedString;
|
||||
|
||||
use crate::i18n::{t, tn};
|
||||
|
||||
/// What a container is doing, as `docker ps` says.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ContainerState {
|
||||
@@ -37,15 +41,15 @@ impl ContainerState {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
pub fn label(self) -> SharedString {
|
||||
match self {
|
||||
ContainerState::Running => "运行中",
|
||||
ContainerState::Paused => "已暂停",
|
||||
ContainerState::Restarting => "正在重启",
|
||||
ContainerState::Created => "已创建",
|
||||
ContainerState::Exited => "已停止",
|
||||
ContainerState::Dead => "异常",
|
||||
ContainerState::Removing => "正在删除",
|
||||
ContainerState::Running => t!("docker.state.running"),
|
||||
ContainerState::Paused => t!("docker.state.paused"),
|
||||
ContainerState::Restarting => t!("docker.state.restarting"),
|
||||
ContainerState::Created => t!("docker.state.created"),
|
||||
ContainerState::Exited => t!("docker.state.exited"),
|
||||
ContainerState::Dead => t!("docker.state.dead"),
|
||||
ContainerState::Removing => t!("docker.state.removing"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,22 +163,23 @@ impl ContainerCommand {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
pub fn label(self) -> SharedString {
|
||||
match self {
|
||||
ContainerCommand::Start => "启动",
|
||||
ContainerCommand::Stop => "停止",
|
||||
ContainerCommand::Restart => "重启",
|
||||
ContainerCommand::Start => t!("docker.command.start"),
|
||||
ContainerCommand::Stop => t!("docker.command.stop"),
|
||||
ContainerCommand::Restart => t!("docker.command.restart"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What it did, after the name: 「已停止」.
|
||||
pub fn done(self) -> &'static str {
|
||||
match self {
|
||||
ContainerCommand::Start => "已启动",
|
||||
ContainerCommand::Stop => "已停止",
|
||||
ContainerCommand::Restart => "已重启",
|
||||
}
|
||||
}
|
||||
/// What a container command is run on, for the question and the
|
||||
/// notification: 「容器“web”」, 「项目“php-56”」.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ContainerSubject {
|
||||
/// One container, by name.
|
||||
Container(String),
|
||||
/// A compose project's containers, by the project's name.
|
||||
Project(String),
|
||||
}
|
||||
|
||||
/// What kind of thing a removal removes.
|
||||
@@ -187,12 +192,12 @@ pub enum DockerObject {
|
||||
}
|
||||
|
||||
impl DockerObject {
|
||||
pub fn label(self) -> &'static str {
|
||||
pub fn label(self) -> SharedString {
|
||||
match self {
|
||||
DockerObject::Container => "容器",
|
||||
DockerObject::Image => "镜像",
|
||||
DockerObject::Volume => "卷",
|
||||
DockerObject::Network => "网络",
|
||||
DockerObject::Container => t!("docker.object.container"),
|
||||
DockerObject::Image => t!("docker.object.image"),
|
||||
DockerObject::Volume => t!("docker.object.volume"),
|
||||
DockerObject::Network => t!("docker.object.network"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,12 +220,12 @@ impl DockerTab {
|
||||
DockerTab::Networks,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
pub fn label(self) -> SharedString {
|
||||
match self {
|
||||
DockerTab::Containers => "容器",
|
||||
DockerTab::Volumes => "卷",
|
||||
DockerTab::Images => "镜像",
|
||||
DockerTab::Networks => "网络",
|
||||
DockerTab::Containers => t!("docker.tab.containers"),
|
||||
DockerTab::Volumes => t!("docker.tab.volumes"),
|
||||
DockerTab::Images => t!("docker.tab.images"),
|
||||
DockerTab::Networks => t!("docker.tab.networks"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -468,7 +473,7 @@ pub struct ObjectSummary {
|
||||
pub detail: String,
|
||||
pub tag: Option<(String, Tone)>,
|
||||
/// Whether it can be removed, and why not.
|
||||
pub removable: Result<(), &'static str>,
|
||||
pub removable: Result<(), SharedString>,
|
||||
/// The containers using it, by name.
|
||||
pub used_by: Vec<String>,
|
||||
}
|
||||
@@ -491,12 +496,12 @@ impl Volume {
|
||||
name: self.name.clone(),
|
||||
detail: joined(&[&self.driver, &self.mountpoint]),
|
||||
tag: Some(if in_use {
|
||||
("使用中".into(), Tone::Good)
|
||||
(t!("docker.object.in_use").into(), Tone::Good)
|
||||
} else {
|
||||
("未使用".into(), Tone::Quiet)
|
||||
(t!("docker.object.unused").into(), Tone::Quiet)
|
||||
}),
|
||||
removable: if in_use {
|
||||
Err("有容器在用,不能删除")
|
||||
Err(t!("docker.object.in_use_by_containers"))
|
||||
} else {
|
||||
Ok(())
|
||||
},
|
||||
@@ -514,11 +519,11 @@ impl Image {
|
||||
name: self
|
||||
.reference
|
||||
.clone()
|
||||
.unwrap_or_else(|| "未命名镜像".into()),
|
||||
.unwrap_or_else(|| t!("docker.image.unnamed").into()),
|
||||
detail: joined(&[&self.id, &self.size, &self.created]),
|
||||
tag: in_use.then(|| ("使用中".into(), Tone::Good)),
|
||||
tag: in_use.then(|| (t!("docker.object.in_use").into(), Tone::Good)),
|
||||
removable: if in_use {
|
||||
Err("有容器在用,不能删除")
|
||||
Err(t!("docker.object.in_use_by_containers"))
|
||||
} else {
|
||||
Ok(())
|
||||
},
|
||||
@@ -531,16 +536,19 @@ impl Network {
|
||||
pub fn summary(&self) -> ObjectSummary {
|
||||
let (tag, removable) = if self.builtin() {
|
||||
(
|
||||
("内置".to_owned(), Tone::Quiet),
|
||||
Err("Docker 自带的网络,不能删除"),
|
||||
(t!("docker.network.builtin").into(), Tone::Quiet),
|
||||
Err(t!("docker.network.builtin_kept")),
|
||||
)
|
||||
} else if !self.used_by.is_empty() {
|
||||
(
|
||||
(format!("{} 个容器", self.used_by.len()), Tone::Good),
|
||||
Err("有容器连着,不能删除"),
|
||||
(
|
||||
tn!("docker.network.containers", self.used_by.len()).into(),
|
||||
Tone::Good,
|
||||
),
|
||||
Err(t!("docker.network.in_use")),
|
||||
)
|
||||
} else {
|
||||
(("未使用".to_owned(), Tone::Quiet), Ok(()))
|
||||
((t!("docker.object.unused").into(), Tone::Quiet), Ok(()))
|
||||
};
|
||||
ObjectSummary {
|
||||
object: DockerObject::Network,
|
||||
@@ -559,14 +567,48 @@ impl Network {
|
||||
pub struct DetailSection {
|
||||
/// For the test ids of its lines: 「basics」.
|
||||
pub id: &'static str,
|
||||
/// The heading's text, by its key: translated where it is drawn.
|
||||
pub title: &'static str,
|
||||
pub body: SectionBody,
|
||||
}
|
||||
|
||||
/// What a line of details is labelled with.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum RowLabel {
|
||||
/// One of ShellRS's labels, by its text's key: 「docker.field.mountpoint」.
|
||||
Field(&'static str),
|
||||
/// One of the host's, as it is: a port, a path, a label's key.
|
||||
Raw(String),
|
||||
}
|
||||
|
||||
impl RowLabel {
|
||||
/// What the line is found by: a field by the last part of its key
|
||||
/// (「mountpoint」), the host's own as it is.
|
||||
pub fn id(&self) -> &str {
|
||||
match self {
|
||||
RowLabel::Field(key) => key.rsplit('.').next().unwrap_or(key),
|
||||
RowLabel::Raw(label) => label,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn text(&self) -> SharedString {
|
||||
match self {
|
||||
RowLabel::Field(key) => t!(*key),
|
||||
RowLabel::Raw(label) => label.clone().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for RowLabel {
|
||||
fn from(label: String) -> Self {
|
||||
RowLabel::Raw(label)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum SectionBody {
|
||||
/// Labels and their values: 「挂载点」 「/var/lib/docker/volumes/…」.
|
||||
Rows(Vec<(String, String)>),
|
||||
Rows(Vec<(RowLabel, String)>),
|
||||
/// Names, one a line: the containers using it.
|
||||
List(Vec<String>),
|
||||
/// Lines of text as they are, in a fixed-width face: the environment.
|
||||
@@ -583,7 +625,9 @@ impl SectionBody {
|
||||
}
|
||||
|
||||
/// Labels and values, with 「—」 for the empty values.
|
||||
pub fn rows(rows: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>) -> SectionBody {
|
||||
pub fn rows(
|
||||
rows: impl IntoIterator<Item = (impl Into<RowLabel>, impl Into<String>)>,
|
||||
) -> SectionBody {
|
||||
SectionBody::Rows(
|
||||
rows.into_iter()
|
||||
.map(|(label, value)| {
|
||||
@@ -607,34 +651,43 @@ impl ContainerDetails {
|
||||
vec![
|
||||
DetailSection {
|
||||
id: "basics",
|
||||
title: "基础信息",
|
||||
title: "docker.section.basics",
|
||||
body: rows([
|
||||
("名称", self.name.clone()),
|
||||
("ID", self.id.clone()),
|
||||
("镜像", self.image.clone()),
|
||||
("创建时间", self.created.clone()),
|
||||
("入口", self.entrypoint.clone()),
|
||||
("命令", self.command.clone()),
|
||||
(RowLabel::Field("docker.field.name"), self.name.clone()),
|
||||
(RowLabel::Raw("ID".into()), self.id.clone()),
|
||||
(RowLabel::Field("docker.field.image"), self.image.clone()),
|
||||
(
|
||||
RowLabel::Field("docker.field.created"),
|
||||
self.created.clone(),
|
||||
),
|
||||
(
|
||||
RowLabel::Field("docker.field.entrypoint"),
|
||||
self.entrypoint.clone(),
|
||||
),
|
||||
(
|
||||
RowLabel::Field("docker.field.command"),
|
||||
self.command.clone(),
|
||||
),
|
||||
]),
|
||||
},
|
||||
DetailSection {
|
||||
id: "ports",
|
||||
title: "端口映射",
|
||||
title: "docker.section.ports",
|
||||
body: rows(self.ports.clone()),
|
||||
},
|
||||
DetailSection {
|
||||
id: "mounts",
|
||||
title: "挂载",
|
||||
title: "docker.section.mounts",
|
||||
body: rows(self.mounts.clone()),
|
||||
},
|
||||
DetailSection {
|
||||
id: "environment",
|
||||
title: "环境变量",
|
||||
title: "docker.section.environment",
|
||||
body: SectionBody::Text(self.environment.clone()),
|
||||
},
|
||||
DetailSection {
|
||||
id: "labels",
|
||||
title: "标签",
|
||||
title: "docker.section.labels",
|
||||
body: rows(self.labels.clone()),
|
||||
},
|
||||
]
|
||||
@@ -832,7 +885,7 @@ mod tests {
|
||||
let summary = |object, id: &str| table.summary_of(object, id).expect(id);
|
||||
let data = summary(DockerObject::Volume, "web-data");
|
||||
assert_eq!(data.tag, Some(("使用中".into(), Tone::Good)));
|
||||
assert_eq!(data.removable, Err("有容器在用,不能删除"));
|
||||
assert_eq!(data.removable, Err("有容器在用,不能删除".into()));
|
||||
assert_eq!(data.used_by, ["web"]);
|
||||
assert_eq!(summary(DockerObject::Volume, "old-data").removable, Ok(()));
|
||||
let dangling = summary(DockerObject::Image, "aaa");
|
||||
@@ -840,7 +893,7 @@ mod tests {
|
||||
assert_eq!(dangling.tag, None);
|
||||
assert_eq!(
|
||||
summary(DockerObject::Network, "bridge").removable,
|
||||
Err("Docker 自带的网络,不能删除")
|
||||
Err("Docker 自带的网络,不能删除".into())
|
||||
);
|
||||
assert_eq!(
|
||||
summary(DockerObject::Network, "web_default").tag,
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ use crate::app::{
|
||||
};
|
||||
use crate::explorer::{ExplorerId, ExplorerPanel, FileLocation};
|
||||
use crate::host::{HostId, HostStore};
|
||||
use crate::i18n::t;
|
||||
use crate::sftp::{
|
||||
FileStamp, ReadFailure, SaveFailure, SharedLocalDirectoryProvider, TextFile, TextFormat,
|
||||
read_local_text, write_local_text,
|
||||
@@ -221,9 +222,9 @@ impl EditorPanel {
|
||||
let action = EditorAction::new(self.id, EditorCommand::ReloadConfirmed);
|
||||
let dispatch = self.dispatch.clone();
|
||||
confirm_danger(
|
||||
format!("放弃对“{}”的修改并重新加载?", self.name()).into(),
|
||||
Some("编辑器里没有保存的修改会丢失。".into()),
|
||||
"重新加载",
|
||||
t!("editor.reload.title", name = self.name()),
|
||||
Some(t!("editor.reload.description")),
|
||||
t!("editor.command.reload"),
|
||||
std::rc::Rc::new(move |window, cx| send(&dispatch, &action, window, cx)),
|
||||
window,
|
||||
cx,
|
||||
@@ -326,22 +327,17 @@ impl EditorPanel {
|
||||
}
|
||||
}
|
||||
Err(SaveFailure::Changed) => {
|
||||
let place = if self.location.is_remote() {
|
||||
"服务器上的"
|
||||
let description = if self.location.is_remote() {
|
||||
t!("editor.changed.remote")
|
||||
} else {
|
||||
"本机上的"
|
||||
t!("editor.changed.local")
|
||||
};
|
||||
let action = EditorAction::new(self.id, EditorCommand::Overwrite { close });
|
||||
let dispatch = self.dispatch.clone();
|
||||
confirm_danger(
|
||||
format!("“{name}”在打开后已被修改").into(),
|
||||
Some(
|
||||
format!(
|
||||
"{place}这个文件在你打开或上次保存之后被改过或删除了。覆盖会用编辑器里的内容替换它。"
|
||||
)
|
||||
.into(),
|
||||
),
|
||||
"覆盖",
|
||||
t!("editor.changed.title", name = name),
|
||||
Some(description),
|
||||
t!("editor.changed.overwrite"),
|
||||
std::rc::Rc::new(move |window, cx| send(&dispatch, &action, window, cx)),
|
||||
window,
|
||||
cx,
|
||||
@@ -351,15 +347,13 @@ impl EditorPanel {
|
||||
// What is on disk is neither what was read nor what was meant.
|
||||
self.stamp = None;
|
||||
window.push_notification(
|
||||
Notification::error(format!(
|
||||
"{message}。文件可能只写了一部分,请重新连接后再保存一次。"
|
||||
))
|
||||
.title(format!("没有保存完“{name}”")),
|
||||
Notification::error(t!("editor.save.interrupted", message = message))
|
||||
.title(t!("editor.save.interrupted_title", name = name)),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
Err(SaveFailure::Failed(message)) => window.push_notification(
|
||||
Notification::error(message).title(format!("无法保存“{name}”")),
|
||||
Notification::error(message).title(t!("editor.save.failed", name = name)),
|
||||
cx,
|
||||
),
|
||||
}
|
||||
@@ -424,7 +418,7 @@ impl EditorPanel {
|
||||
}
|
||||
Err(failure) => window.push_notification(
|
||||
Notification::error(failure.to_string())
|
||||
.title(format!("无法重新加载“{}”", self.name())),
|
||||
.title(t!("editor.reload.failed", name = self.name())),
|
||||
cx,
|
||||
),
|
||||
}
|
||||
@@ -442,20 +436,20 @@ impl EditorPanel {
|
||||
let focus = self.state.read(cx).focus_handle(cx);
|
||||
window.open_dialog(cx, move |dialog, _, _| {
|
||||
dialog
|
||||
.title(format!("保存对“{name}”的修改?"))
|
||||
.title(t!("editor.close.title", name = name))
|
||||
.overlay_closable(false)
|
||||
.child(div().text_sm().child("不保存的话,这些修改会丢失。"))
|
||||
.child(div().text_sm().child(t!("editor.close.description")))
|
||||
.footer(
|
||||
DialogFooter::new()
|
||||
.child(
|
||||
Button::new("editor-close-cancel")
|
||||
.label("取消")
|
||||
.label(t!("common.cancel"))
|
||||
.on_click(|_, window, cx| window.close_dialog(cx)),
|
||||
)
|
||||
.child(
|
||||
Button::new("editor-close-discard")
|
||||
.danger()
|
||||
.label("放弃修改")
|
||||
.label(t!("editor.close.discard"))
|
||||
.on_click({
|
||||
let (dispatch, discard) = (dispatch.clone(), discard.clone());
|
||||
move |_, window, cx| {
|
||||
@@ -465,8 +459,11 @@ impl EditorPanel {
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
DialogAction::new()
|
||||
.child(Button::new("editor-close-save").primary().label("保存")),
|
||||
DialogAction::new().child(
|
||||
Button::new("editor-close-save")
|
||||
.primary()
|
||||
.label(t!("common.save")),
|
||||
),
|
||||
),
|
||||
)
|
||||
.on_ok({
|
||||
@@ -518,9 +515,9 @@ impl EditorPanel {
|
||||
.into_any_element(),
|
||||
};
|
||||
let state = match self.phase {
|
||||
Phase::Saving => Some(("正在保存…", cx.theme().muted_foreground)),
|
||||
Phase::Reloading => Some(("正在重新加载…", cx.theme().muted_foreground)),
|
||||
Phase::Idle if self.dirty => Some(("已修改", cx.theme().warning)),
|
||||
Phase::Saving => Some((t!("editor.state.saving"), cx.theme().muted_foreground)),
|
||||
Phase::Reloading => Some((t!("editor.state.reloading"), cx.theme().muted_foreground)),
|
||||
Phase::Idle if self.dirty => Some((t!("editor.state.modified"), cx.theme().warning)),
|
||||
Phase::Idle => None,
|
||||
};
|
||||
let command = |command: EditorCommand| {
|
||||
@@ -565,7 +562,7 @@ impl EditorPanel {
|
||||
div()
|
||||
.id(("editor-state", id.0))
|
||||
.test_support()
|
||||
.aria_label(text)
|
||||
.aria_label(text.clone())
|
||||
.flex_shrink_0()
|
||||
.child(soft_tag(text, color)),
|
||||
)
|
||||
@@ -575,7 +572,7 @@ impl EditorPanel {
|
||||
.ghost()
|
||||
.small()
|
||||
.icon(Icon::new(CatalogIcon::RefreshCw))
|
||||
.label("重新加载")
|
||||
.label(t!("editor.command.reload"))
|
||||
.disabled(self.phase != Phase::Idle)
|
||||
.on_click(command(EditorCommand::Reload)),
|
||||
)
|
||||
@@ -583,10 +580,10 @@ impl EditorPanel {
|
||||
Button::new(("editor-save", id.0))
|
||||
.small()
|
||||
.icon(Icon::new(CatalogIcon::Save))
|
||||
.label("保存")
|
||||
.label(t!("common.save"))
|
||||
.disabled(!self.dirty || self.phase != Phase::Idle)
|
||||
.tooltip_with_action(
|
||||
"保存",
|
||||
t!("common.save"),
|
||||
&EditorShortcut(EditorCommand::Save),
|
||||
Some(EDITOR_CONTEXT),
|
||||
)
|
||||
@@ -682,16 +679,19 @@ fn tab_menu(
|
||||
let action = |command| Box::new(EditorAction::new(id, command));
|
||||
let menu = menu
|
||||
.menu_with_icon(
|
||||
"保存",
|
||||
t!("common.save"),
|
||||
Icon::new(CatalogIcon::Save),
|
||||
action(EditorCommand::Save),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"重新加载",
|
||||
t!("editor.command.reload"),
|
||||
Icon::new(CatalogIcon::RefreshCw),
|
||||
action(EditorCommand::Reload),
|
||||
)
|
||||
.menu("复制路径", action(EditorCommand::CopyPath))
|
||||
.menu(
|
||||
t!("editor.command.copy_path"),
|
||||
action(EditorCommand::CopyPath),
|
||||
)
|
||||
.separator();
|
||||
close_tab_items(menu, CenterTab::Editor(id), group, panel, cx)
|
||||
}
|
||||
@@ -717,7 +717,7 @@ impl Render for EditorPanel {
|
||||
.rounded_none()
|
||||
.h_full()
|
||||
.readonly(self.phase == Phase::Reloading)
|
||||
.aria_label(format!("编辑 {}", self.name())),
|
||||
.aria_label(t!("editor.text_label", name = self.name())),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! how it indents, and what the status line says.
|
||||
|
||||
use crate::host::HostId;
|
||||
use crate::i18n::t;
|
||||
use crate::sftp::{RemotePath, TextFormat};
|
||||
use gpui_kit::component::input::TabSize;
|
||||
use std::path::PathBuf;
|
||||
@@ -78,7 +79,12 @@ pub fn indentation(language: &str, text: &str) -> TabSize {
|
||||
|
||||
/// 「行 12,列 5」, from the editor's zero-based position.
|
||||
pub fn cursor_label(line: u32, character: u32) -> String {
|
||||
format!("行 {},列 {}", line + 1, character + 1)
|
||||
t!(
|
||||
"editor.status.cursor",
|
||||
line = line + 1,
|
||||
column = character + 1
|
||||
)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// 「UTF-8 · LF」, 「UTF-8 BOM · CRLF」.
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::{
|
||||
},
|
||||
connection::{ConnectionPrompt, ConnectionPromptReply, Latency},
|
||||
host::{BookmarkSide, ConnectionState, HostId, HostStore},
|
||||
i18n::t,
|
||||
sftp::{
|
||||
DownloadRequest, RemotePath, SftpCommand, SftpEvent, SharedLocalDirectoryProvider,
|
||||
SharedSftpTransportProvider, TextFile, TransferDirection, TransferPhase, TransferQuestion,
|
||||
@@ -119,7 +120,7 @@ impl ExplorerPanel {
|
||||
let places = local_provider
|
||||
.places()
|
||||
.into_iter()
|
||||
.map(|(title, path)| (title.into(), path.to_string_lossy().into_owned()))
|
||||
.map(|(place, path)| (place, path.to_string_lossy().into_owned()))
|
||||
.collect();
|
||||
let local = cx.new(|cx| {
|
||||
FilePane::new(
|
||||
@@ -162,7 +163,9 @@ impl ExplorerPanel {
|
||||
}
|
||||
})
|
||||
{
|
||||
let _ = failed.try_send(SftpEvent::Disconnected(format!("无法启动 SFTP:{error}")));
|
||||
let _ = failed.try_send(SftpEvent::Disconnected(
|
||||
t!("explorer.connection.start_failed", error = error).to_string(),
|
||||
));
|
||||
}
|
||||
// Poll snapshots as the terminal engine does. Worker threads never wake
|
||||
// a foreground-only GPUI task directly.
|
||||
@@ -296,24 +299,23 @@ impl ExplorerPanel {
|
||||
if window.has_active_dialog(cx) {
|
||||
return;
|
||||
}
|
||||
let name = self
|
||||
.store
|
||||
.read(cx)
|
||||
.host(self.host_id)
|
||||
.map_or_else(|| "服务器".into(), |host| host.name.clone());
|
||||
let name = self.store.read(cx).host(self.host_id).map_or_else(
|
||||
|| t!("explorer.connection.server"),
|
||||
|host| host.name.clone(),
|
||||
);
|
||||
let reason = self
|
||||
.remote
|
||||
.read(cx)
|
||||
.problem()
|
||||
.unwrap_or("SFTP 已断开")
|
||||
.to_string();
|
||||
.map(SharedString::from)
|
||||
.unwrap_or_else(|| t!("explorer.connection.lost"));
|
||||
let can_reconnect = self.can_reconnect();
|
||||
let description = if can_reconnect {
|
||||
format!("{reason}。重新连接后再操作远程文件。")
|
||||
t!("explorer.connection.lost_reconnect", reason = reason)
|
||||
} else if self.head_stopped() {
|
||||
format!("{reason}。传输队列里有停止的批次,点它的「继续」会重新连接并接着传输。")
|
||||
t!("explorer.connection.lost_stopped_batch", reason = reason)
|
||||
} else {
|
||||
format!("{reason}。正在自动重新连接,请稍候。")
|
||||
t!("explorer.connection.lost_reconnecting", reason = reason)
|
||||
};
|
||||
let sender = self.sender();
|
||||
let focus = window.focused(cx);
|
||||
@@ -328,14 +330,14 @@ impl ExplorerPanel {
|
||||
}
|
||||
};
|
||||
let dialog = dialog
|
||||
.title(format!("与 {name} 的 SFTP 连接已断开"))
|
||||
.title(t!("explorer.connection.lost_title", name = name))
|
||||
.description(description.clone());
|
||||
if can_reconnect {
|
||||
dialog
|
||||
.button_props(
|
||||
DialogButtonProps::default()
|
||||
.ok_text("重新连接")
|
||||
.cancel_text("取消"),
|
||||
.ok_text(t!("explorer.connection.reconnect"))
|
||||
.cancel_text(t!("common.cancel")),
|
||||
)
|
||||
.show_cancel(true)
|
||||
.on_cancel(restore.clone())
|
||||
@@ -348,7 +350,7 @@ impl ExplorerPanel {
|
||||
})
|
||||
} else {
|
||||
dialog
|
||||
.button_props(DialogButtonProps::default().ok_text("知道了"))
|
||||
.button_props(DialogButtonProps::default().ok_text(t!("explorer.ok")))
|
||||
.on_ok(restore)
|
||||
}
|
||||
});
|
||||
@@ -417,8 +419,9 @@ impl ExplorerPanel {
|
||||
pub fn disconnect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.send(SftpCommand::Disconnect);
|
||||
self.state = ConnectionState::Disconnected;
|
||||
self.remote
|
||||
.update(cx, |pane, cx| pane.disconnected("SFTP 已断开".into(), cx));
|
||||
self.remote.update(cx, |pane, cx| {
|
||||
pane.disconnected(t!("explorer.connection.lost").to_string(), cx)
|
||||
});
|
||||
self.close_transfer_dialog(window, cx);
|
||||
self.sync_available(cx);
|
||||
cx.emit(ExplorerPanelEvent::StateChanged(self.id, self.host_id));
|
||||
@@ -775,7 +778,7 @@ impl ExplorerPanel {
|
||||
files: true,
|
||||
directories: true,
|
||||
multiple: true,
|
||||
prompt: Some("选择要上传的文件或目录".into()),
|
||||
prompt: Some(t!("explorer.choose_upload")),
|
||||
});
|
||||
let target = self.remote.read(cx).path();
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
@@ -935,13 +938,13 @@ impl Panel for ExplorerPanel {
|
||||
Button::new(("open-sftp", id.0))
|
||||
.icon(Icon::new(CatalogIcon::FolderTree))
|
||||
.label("SFTP")
|
||||
.tooltip("打开 SFTP 文件浏览")
|
||||
.tooltip(t!("explorer.tab.open_sftp_tip"))
|
||||
.on_click(move |_, window, cx| {
|
||||
window.dispatch_action(Box::new(OpenExplorer(host_id)), cx)
|
||||
}),
|
||||
Button::new(("reconnect-sftp", id.0))
|
||||
.icon(Icon::new(CatalogIcon::RefreshCw))
|
||||
.tooltip("重新连接")
|
||||
.tooltip(t!("explorer.connection.reconnect"))
|
||||
.disabled(!self.can_reconnect())
|
||||
.on_click(move |_, window, cx| {
|
||||
window.dispatch_action(
|
||||
@@ -1068,18 +1071,18 @@ impl TabMenu {
|
||||
fn build(&self, menu: PopupMenu, cx: &App) -> PopupMenu {
|
||||
let (id, host_id) = (self.id, self.host_id);
|
||||
let copy_host = if self.host_is_ip {
|
||||
"复制 IP 地址"
|
||||
t!("explorer.tab.copy_ip")
|
||||
} else {
|
||||
"复制主机名"
|
||||
t!("explorer.tab.copy_host_name")
|
||||
};
|
||||
let menu = menu
|
||||
.menu_with_icon(
|
||||
"重命名标签…",
|
||||
t!("explorer.tab.rename"),
|
||||
Icon::new(CatalogIcon::Pencil),
|
||||
Box::new(RenameExplorer(id)),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"打开 SFTP",
|
||||
t!("explorer.tab.open_sftp"),
|
||||
Icon::new(CatalogIcon::FolderTree),
|
||||
Box::new(OpenExplorer(host_id)),
|
||||
)
|
||||
@@ -1089,7 +1092,7 @@ impl TabMenu {
|
||||
Box::new(CopyHostAddress(host_id)),
|
||||
)
|
||||
.menu_with_icon_and_disabled(
|
||||
"重新连接",
|
||||
t!("explorer.connection.reconnect"),
|
||||
Icon::new(CatalogIcon::RefreshCw),
|
||||
Box::new(ExplorerAction::new(id, ExplorerCommand::Reconnect)),
|
||||
!self.can_reconnect,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use super::{ExplorerPanel, NewEntryKind, PaneOperation};
|
||||
use crate::app::ExplorerCommand;
|
||||
use crate::i18n::{t, tn};
|
||||
use crate::shared::{commit_footer, dismiss_form_error, form_error_notification};
|
||||
use gpui_kit::component::{
|
||||
Sizable as _, WindowExt as _,
|
||||
@@ -21,19 +22,19 @@ pub fn validate_entry_name(
|
||||
existing: &[String],
|
||||
) -> Result<(), String> {
|
||||
if name.trim().is_empty() {
|
||||
return Err("名称不能为空".into());
|
||||
return Err(t!("explorer.name.empty").into());
|
||||
}
|
||||
if matches!(name, "." | "..") {
|
||||
return Err("名称不能是「.」或「..」".into());
|
||||
return Err(t!("explorer.name.dots").into());
|
||||
}
|
||||
if name.contains(['/', '\0']) {
|
||||
return Err("名称不能包含「/」".into());
|
||||
return Err(t!("explorer.name.slash").into());
|
||||
}
|
||||
if original == Some(name) {
|
||||
return Err("名称未改变".into());
|
||||
return Err(t!("explorer.name.unchanged").into());
|
||||
}
|
||||
if existing.iter().any(|existing| existing == name) {
|
||||
return Err(format!("已有名为「{name}」的项目"));
|
||||
return Err(t!("explorer.name.exists", name = name).into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -47,14 +48,14 @@ enum NameIntent {
|
||||
|
||||
struct NameForm {
|
||||
input: Entity<InputState>,
|
||||
label: &'static str,
|
||||
label: SharedString,
|
||||
}
|
||||
impl Render for NameForm {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex().gap_2().child(
|
||||
Form::new().child(
|
||||
Field::new()
|
||||
.label(self.label)
|
||||
.label(self.label.clone())
|
||||
.required(true)
|
||||
.child(Input::new(&self.input).id("entry-name").small()),
|
||||
),
|
||||
@@ -76,26 +77,28 @@ impl ExplorerPanel {
|
||||
return;
|
||||
}
|
||||
let title = if let [name] = names.as_slice() {
|
||||
format!("删除「{name}」?")
|
||||
t!("explorer.delete.title_one", name = name)
|
||||
} else {
|
||||
format!("删除 {} 个项目?", names.len())
|
||||
tn!("explorer.delete.title_several", names.len())
|
||||
};
|
||||
let description = if remote {
|
||||
"远程文件无法恢复,目录会连同其中全部内容一起删除。"
|
||||
t!("explorer.delete.remote")
|
||||
} else if cfg!(windows) {
|
||||
t!("explorer.delete.recycle_bin")
|
||||
} else {
|
||||
"项目会移到废纸篓。"
|
||||
t!("explorer.delete.trash")
|
||||
};
|
||||
let sender = self.sender();
|
||||
let focus = window.focused(cx);
|
||||
window.open_alert_dialog(cx, move |dialog, _, _| {
|
||||
dialog
|
||||
.title(title.clone())
|
||||
.description(description)
|
||||
.description(description.clone())
|
||||
.button_props(
|
||||
DialogButtonProps::default()
|
||||
.ok_text("删除")
|
||||
.ok_text(t!("common.delete"))
|
||||
.ok_variant(ButtonVariant::Danger)
|
||||
.cancel_text("取消"),
|
||||
.cancel_text(t!("common.cancel")),
|
||||
)
|
||||
.show_cancel(true)
|
||||
.on_cancel({
|
||||
@@ -163,16 +166,16 @@ impl ExplorerPanel {
|
||||
.collect();
|
||||
let (title, initial, commit, label) = match &intent {
|
||||
NameIntent::Rename(name) => (
|
||||
format!("重命名「{name}」"),
|
||||
t!("explorer.rename.title", name = name),
|
||||
name.clone(),
|
||||
"重命名",
|
||||
"新名称",
|
||||
t!("explorer.rename.commit"),
|
||||
t!("explorer.rename.label"),
|
||||
),
|
||||
NameIntent::New(kind) => (
|
||||
kind.title().to_string(),
|
||||
kind.title(),
|
||||
kind.default_name().to_string(),
|
||||
"创建",
|
||||
"名称",
|
||||
t!("explorer.new.commit"),
|
||||
t!("explorer.new.label"),
|
||||
),
|
||||
};
|
||||
let input = cx.new(|cx| InputState::new(window, cx).default_value(initial));
|
||||
@@ -185,7 +188,7 @@ impl ExplorerPanel {
|
||||
// Closed by its buttons or Escape, not by a click beside it.
|
||||
.overlay_closable(false)
|
||||
.child(form.clone())
|
||||
.footer(commit_footer("commit", commit))
|
||||
.footer(commit_footer("commit", commit.clone()))
|
||||
.on_ok({
|
||||
let (form, sender, intent, existing) = (
|
||||
form.clone(),
|
||||
|
||||
@@ -10,6 +10,7 @@ use super::preview::{IMAGE_LIMIT, Preview, PreviewContent, PreviewKind, open_pre
|
||||
use super::{ExplorerPanel, ExplorerPanelEvent, format_size};
|
||||
use crate::app::{ExplorerAction, ExplorerCommand, ExplorerDispatch as _};
|
||||
use crate::host::ConnectionState;
|
||||
use crate::i18n::t;
|
||||
use crate::sftp::{
|
||||
EDIT_LIMIT, FileBytes, FileStamp, ReadFailure, RemotePath, SaveFailure, SftpCommand, TextFile,
|
||||
read_local_bytes, read_local_text,
|
||||
@@ -191,9 +192,11 @@ impl ExplorerPanel {
|
||||
path: path.clone(),
|
||||
});
|
||||
Some(cx.spawn(async move |_, _| {
|
||||
receiver
|
||||
.await
|
||||
.unwrap_or_else(|_| Err(ReadFailure::Failed("SFTP 连接已断开".into())))
|
||||
receiver.await.unwrap_or_else(|_| {
|
||||
Err(ReadFailure::Failed(
|
||||
t!("explorer.file.connection_lost").into(),
|
||||
))
|
||||
})
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -227,9 +230,11 @@ impl ExplorerPanel {
|
||||
limit,
|
||||
});
|
||||
Some(cx.spawn(async move |_, _| {
|
||||
receiver
|
||||
.await
|
||||
.unwrap_or_else(|_| Err(ReadFailure::Failed("SFTP 连接已断开".into())))
|
||||
receiver.await.unwrap_or_else(|_| {
|
||||
Err(ReadFailure::Failed(
|
||||
t!("explorer.file.connection_lost").into(),
|
||||
))
|
||||
})
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -259,9 +264,11 @@ impl ExplorerPanel {
|
||||
Some(cx.spawn(async move |_, _| {
|
||||
// The connection dropped before the answer: the write may have
|
||||
// stopped halfway.
|
||||
receiver
|
||||
.await
|
||||
.unwrap_or_else(|_| Err(SaveFailure::Interrupted("SFTP 连接已断开".into())))
|
||||
receiver.await.unwrap_or_else(|_| {
|
||||
Err(SaveFailure::Interrupted(
|
||||
t!("explorer.file.connection_lost").into(),
|
||||
))
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -310,7 +317,7 @@ impl ExplorerPanel {
|
||||
false
|
||||
}
|
||||
ConnectionState::Connecting => {
|
||||
window.push_notification(Notification::warning("正在连接 SFTP,请稍候再试。"), cx);
|
||||
window.push_notification(Notification::warning(t!("explorer.file.connecting")), cx);
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -393,28 +400,40 @@ impl ExplorerPanel {
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let name = location.name();
|
||||
let (title, what, limit) = match opening {
|
||||
Opening::Edit => (format!("无法在编辑器中打开“{name}”"), "编辑器", EDIT_LIMIT),
|
||||
Opening::Preview(PreviewKind::Image(_)) => {
|
||||
(format!("无法预览“{name}”"), "预览", IMAGE_LIMIT)
|
||||
}
|
||||
let editing = opening == Opening::Edit;
|
||||
let (title, limit) = match opening {
|
||||
Opening::Edit => (t!("explorer.file.edit_refused", name = name), EDIT_LIMIT),
|
||||
Opening::Preview(PreviewKind::Image(_)) => (
|
||||
t!("explorer.file.preview_refused", name = name),
|
||||
IMAGE_LIMIT,
|
||||
),
|
||||
Opening::Preview(PreviewKind::Markdown) => {
|
||||
(format!("无法预览“{name}”"), "预览", EDIT_LIMIT)
|
||||
(t!("explorer.file.preview_refused", name = name), EDIT_LIMIT)
|
||||
}
|
||||
};
|
||||
let reason = match &failure {
|
||||
ReadFailure::TooLarge(size) => format!(
|
||||
"文件有 {},{what}只打开 {} 以内的文件。",
|
||||
format_size(*size),
|
||||
format_size(limit)
|
||||
),
|
||||
ReadFailure::NotText => {
|
||||
"它不是 UTF-8 编码的文本文件:可能是二进制文件,或用了 GBK 等其他编码。".into()
|
||||
ReadFailure::TooLarge(size) => {
|
||||
let (size, limit) = (format_size(*size), format_size(limit));
|
||||
if editing {
|
||||
t!(
|
||||
"explorer.file.too_large_for_editor",
|
||||
size = size,
|
||||
limit = limit
|
||||
)
|
||||
} else {
|
||||
t!(
|
||||
"explorer.file.too_large_for_preview",
|
||||
size = size,
|
||||
limit = limit
|
||||
)
|
||||
}
|
||||
}
|
||||
ReadFailure::NotFile => "它不是普通文件。".into(),
|
||||
ReadFailure::NotText => t!("explorer.file.not_text"),
|
||||
ReadFailure::NotFile => t!("explorer.file.not_file"),
|
||||
ReadFailure::Failed(message) => {
|
||||
window.push_notification(
|
||||
Notification::error(message.clone()).title(format!("无法打开“{name}”")),
|
||||
Notification::error(message.clone())
|
||||
.title(t!("explorer.file.open_failed", name = name)),
|
||||
cx,
|
||||
);
|
||||
return;
|
||||
@@ -422,7 +441,7 @@ impl ExplorerPanel {
|
||||
};
|
||||
let download = self.download_action(location, cx);
|
||||
let description = if download.is_some() {
|
||||
format!("{reason}可以下载到本机,用别的程序打开。")
|
||||
t!("explorer.file.download_instead", reason = reason)
|
||||
} else {
|
||||
reason
|
||||
};
|
||||
@@ -433,8 +452,8 @@ impl ExplorerPanel {
|
||||
Some(download) => alert
|
||||
.button_props(
|
||||
DialogButtonProps::default()
|
||||
.ok_text("下载…")
|
||||
.cancel_text("取消"),
|
||||
.ok_text(t!("explorer.file.download"))
|
||||
.cancel_text(t!("common.cancel")),
|
||||
)
|
||||
.show_cancel(true)
|
||||
.on_ok({
|
||||
@@ -448,7 +467,7 @@ impl ExplorerPanel {
|
||||
true
|
||||
}
|
||||
}),
|
||||
None => alert.button_props(DialogButtonProps::default().ok_text("知道了")),
|
||||
None => alert.button_props(DialogButtonProps::default().ok_text(t!("explorer.ok"))),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use crate::app::ExplorerDispatch as _;
|
||||
use crate::app::{CatalogIcon, ExplorerAction, ExplorerCommand};
|
||||
use crate::i18n::{t, tn};
|
||||
use crate::sftp::TransferDirection;
|
||||
use gpui_kit::component::{
|
||||
ActiveTheme as _, Icon, IconName, Sizable as _, h_flex,
|
||||
table::{Column, ColumnSort, TableDelegate, TableState},
|
||||
@@ -33,7 +35,32 @@ pub(super) struct ListingContext {
|
||||
/// Where the rows are, for the selection rectangle.
|
||||
pub geometry: Rc<ListGeometry>,
|
||||
/// What the list says while it has no rows.
|
||||
pub placeholder: SharedString,
|
||||
pub placeholder: ListPlaceholder,
|
||||
}
|
||||
|
||||
/// What the list says while it has no rows, put in words as it is drawn.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(super) enum ListPlaceholder {
|
||||
Connecting,
|
||||
#[default]
|
||||
Reading,
|
||||
OnlyHidden,
|
||||
Empty,
|
||||
NotConnected,
|
||||
Unreadable,
|
||||
}
|
||||
|
||||
impl ListPlaceholder {
|
||||
fn text(self) -> SharedString {
|
||||
match self {
|
||||
Self::Connecting => t!("explorer.list.connecting"),
|
||||
Self::Reading => t!("explorer.list.reading"),
|
||||
Self::OnlyHidden => t!("explorer.list.only_hidden"),
|
||||
Self::Empty => t!("explorer.list.empty"),
|
||||
Self::NotConnected => t!("explorer.list.not_connected"),
|
||||
Self::Unreadable => t!("explorer.list.unreadable"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the list and its rows are on screen, recorded as they paint, so a
|
||||
@@ -112,27 +139,41 @@ pub struct FileListing {
|
||||
/// WinSCP's columns for each side. Column widths are an API boundary that
|
||||
/// takes `Pixels`.
|
||||
fn columns(side: PaneSide) -> Vec<Column> {
|
||||
let column = |key: &'static str, width: f32| {
|
||||
Column::new(key, column_title(key))
|
||||
.width(px(width))
|
||||
.sortable()
|
||||
};
|
||||
match side {
|
||||
PaneSide::Local => vec![
|
||||
name_column(px(260.)),
|
||||
Column::new("size", "大小").width(px(100.)).sortable(),
|
||||
Column::new("type", "类型").width(px(100.)).sortable(),
|
||||
Column::new("modified", "修改时间")
|
||||
.width(px(150.))
|
||||
.sortable(),
|
||||
column("size", 100.),
|
||||
column("type", 100.),
|
||||
column("modified", 150.),
|
||||
],
|
||||
PaneSide::Remote => vec![
|
||||
name_column(px(240.)),
|
||||
Column::new("size", "大小").width(px(100.)).sortable(),
|
||||
Column::new("modified", "修改时间")
|
||||
.width(px(150.))
|
||||
.sortable(),
|
||||
Column::new("rights", "权限").width(px(96.)).sortable(),
|
||||
Column::new("owner", "所有者").width(px(80.)).sortable(),
|
||||
column("size", 100.),
|
||||
column("modified", 150.),
|
||||
column("rights", 96.),
|
||||
column("owner", 80.),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// A column's title, by its key, in the interface language.
|
||||
fn column_title(key: &str) -> SharedString {
|
||||
match key {
|
||||
"name" => t!("explorer.column.name"),
|
||||
"size" => t!("explorer.column.size"),
|
||||
"type" => t!("explorer.column.type"),
|
||||
"modified" => t!("explorer.column.modified"),
|
||||
"rights" => t!("explorer.column.rights"),
|
||||
"owner" => t!("explorer.column.owner"),
|
||||
_ => SharedString::default(),
|
||||
}
|
||||
}
|
||||
|
||||
impl FileListing {
|
||||
pub fn new(side: PaneSide) -> Self {
|
||||
Self {
|
||||
@@ -150,6 +191,14 @@ impl FileListing {
|
||||
self.context = context;
|
||||
}
|
||||
|
||||
/// Title the columns in the interface language again, widths as they
|
||||
/// are; the table takes the titles on its next `refresh`.
|
||||
pub(super) fn retitle_columns(&mut self) {
|
||||
for column in &mut self.columns {
|
||||
column.name = column_title(&column.key);
|
||||
}
|
||||
}
|
||||
|
||||
/// The rows shown, in display order.
|
||||
pub fn rows(&self) -> &[FileEntry] {
|
||||
&self.rows
|
||||
@@ -209,8 +258,16 @@ impl FileListing {
|
||||
};
|
||||
let paths = names.iter().map(|name| self.child_path(name));
|
||||
match self.side {
|
||||
PaneSide::Local => drag_files(cell, paths.map(PathBuf::from).collect(), "上传"),
|
||||
PaneSide::Remote => drag_files(cell, paths.collect::<Vec<String>>(), "下载"),
|
||||
PaneSide::Local => drag_files(
|
||||
cell,
|
||||
paths.map(PathBuf::from).collect(),
|
||||
TransferDirection::Upload,
|
||||
),
|
||||
PaneSide::Remote => drag_files(
|
||||
cell,
|
||||
paths.collect::<Vec<String>>(),
|
||||
TransferDirection::Download,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,7 +606,7 @@ impl TableDelegate for FileListing {
|
||||
_: &mut Window,
|
||||
cx: &mut Context<TableState<Self>>,
|
||||
) -> impl IntoElement {
|
||||
let text = self.context.placeholder.clone();
|
||||
let text = self.context.placeholder.text();
|
||||
div()
|
||||
.id("list-placeholder")
|
||||
.size_full()
|
||||
@@ -568,7 +625,7 @@ impl TableDelegate for FileListing {
|
||||
/// included, so it has none of the table's: the cell and the title pad
|
||||
/// themselves as the table would (`px_1p5`, the small table's 6 px).
|
||||
fn name_column(width: Pixels) -> Column {
|
||||
Column::new("name", "名称")
|
||||
Column::new("name", column_title("name"))
|
||||
.width(width)
|
||||
.paddings(Edges::all(px(0.)))
|
||||
.sortable()
|
||||
@@ -583,16 +640,20 @@ pub(super) struct FilesDrag<P> {
|
||||
pub(super) type LocalFilesDrag = FilesDrag<PathBuf>;
|
||||
pub(super) type RemoteFilesDrag = FilesDrag<String>;
|
||||
|
||||
/// Let `cell` be dragged to the other pane, carrying `paths`. `verb` is what
|
||||
/// dropping them there does.
|
||||
fn drag_files<P: 'static>(cell: Stateful<Div>, paths: Vec<P>, verb: &'static str) -> Stateful<Div> {
|
||||
/// Let `cell` be dragged to the other pane, carrying `paths`, which dropping
|
||||
/// there moves in `direction`.
|
||||
fn drag_files<P: 'static>(
|
||||
cell: Stateful<Div>,
|
||||
paths: Vec<P>,
|
||||
direction: TransferDirection,
|
||||
) -> Stateful<Div> {
|
||||
let drag = FilesDrag {
|
||||
paths,
|
||||
spot: DropSpot::default(),
|
||||
};
|
||||
cell.on_drag(drag, move |drag, _, _, cx| {
|
||||
cx.new(|_| FileDragPreview {
|
||||
verb,
|
||||
direction,
|
||||
count: drag.paths.len(),
|
||||
spot: drag.spot.clone(),
|
||||
})
|
||||
@@ -675,7 +736,7 @@ impl DropSpot {
|
||||
}
|
||||
|
||||
struct FileDragPreview {
|
||||
verb: &'static str,
|
||||
direction: TransferDirection,
|
||||
count: usize,
|
||||
spot: DropSpot,
|
||||
}
|
||||
@@ -698,7 +759,10 @@ impl Render for FileDragPreview {
|
||||
if !droppable {
|
||||
return div().child(pointer).into_any_element();
|
||||
}
|
||||
let text = format!("{} {} 个项目", self.verb, self.count);
|
||||
let text = match self.direction {
|
||||
TransferDirection::Upload => tn!("explorer.drag.upload", self.count),
|
||||
TransferDirection::Download => tn!("explorer.drag.download", self.count),
|
||||
};
|
||||
div()
|
||||
.id("file-drag-preview")
|
||||
.px_3()
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use super::{
|
||||
ClickMode, CursorMotion, ExplorerId, FileEntry, FileListing, FileSizeFormat, LoadIntent,
|
||||
NavigationHistory, PreviewKind, Selection, ShowHiddenFiles, child_path,
|
||||
file_listing::{ListGeometry, ListingContext, MenuHit, Pressed, accept_drops, offer_drop},
|
||||
file_listing::{
|
||||
ListGeometry, ListPlaceholder, ListingContext, MenuHit, Pressed, accept_drops, offer_drop,
|
||||
},
|
||||
pane_menu::{
|
||||
PaneMenuState, bookmark_menu, directory_menu, item_menu, new_menu, size_format_menu,
|
||||
},
|
||||
@@ -15,7 +17,8 @@ use crate::{
|
||||
REMOTE_FILE_LIST_CONTEXT, ToggleHiddenFiles,
|
||||
},
|
||||
host::{BookmarkSide, ConnectionState, HostId, HostStore},
|
||||
sftp::{DirectoryListing, SharedLocalDirectoryProvider},
|
||||
i18n::{UiLocale, t, tn},
|
||||
sftp::{DirectoryListing, Place, SharedLocalDirectoryProvider},
|
||||
};
|
||||
use gpui_kit::component::{
|
||||
ActiveTheme as _, Disableable as _, Icon, IconName, Sizable as _,
|
||||
@@ -80,11 +83,20 @@ impl PaneSide {
|
||||
pub fn from_remote(remote: bool) -> Self {
|
||||
if remote { Self::Remote } else { Self::Local }
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
/// What assistive technology calls this side's path label.
|
||||
pub(super) fn path_label_name(self) -> SharedString {
|
||||
if self == Self::Local {
|
||||
"本地"
|
||||
t!("explorer.path.local")
|
||||
} else {
|
||||
"远程"
|
||||
t!("explorer.path.remote")
|
||||
}
|
||||
}
|
||||
/// What assistive technology calls this side's 目录列表 select.
|
||||
fn directory_select_name(self) -> SharedString {
|
||||
if self == Self::Local {
|
||||
t!("explorer.directory_select.local")
|
||||
} else {
|
||||
t!("explorer.directory_select.remote")
|
||||
}
|
||||
}
|
||||
fn pane_id(self) -> &'static str {
|
||||
@@ -171,7 +183,7 @@ pub struct FilePane {
|
||||
pub(super) path: String,
|
||||
home: String,
|
||||
/// Well-known local places for the 目录列表 select; empty for remote.
|
||||
places: Vec<(SharedString, String)>,
|
||||
places: Vec<(Place, String)>,
|
||||
table: Entity<TableState<FileListing>>,
|
||||
/// The path label part under the pointer, by the directory it opens.
|
||||
pub(super) hovered_part: Option<String>,
|
||||
@@ -224,7 +236,7 @@ impl FilePane {
|
||||
explorer: ExplorerId,
|
||||
host_id: HostId,
|
||||
home: String,
|
||||
places: Vec<(SharedString, String)>,
|
||||
places: Vec<(Place, String)>,
|
||||
store: Entity<HostStore>,
|
||||
dispatch: FocusHandle,
|
||||
window: &mut Window,
|
||||
@@ -262,6 +274,15 @@ impl FilePane {
|
||||
),
|
||||
cx.observe(&store, |_, _, cx| cx.notify()),
|
||||
cx.observe_global::<ShowHiddenFiles>(|pane, cx| pane.sync_hidden_files(cx)),
|
||||
// The column titles and the select's groups are kept by
|
||||
// gpui-kit, so they are given again in the new language.
|
||||
cx.observe_global_in::<UiLocale>(window, |pane, window, cx| {
|
||||
pane.table.update(cx, |table, cx| {
|
||||
table.delegate_mut().retitle_columns();
|
||||
table.refresh(cx);
|
||||
});
|
||||
pane.sync_path_select(window, cx);
|
||||
}),
|
||||
];
|
||||
let mut pane = Self {
|
||||
side,
|
||||
@@ -489,9 +510,7 @@ impl FilePane {
|
||||
pane: Some(cx.entity().downgrade()),
|
||||
menu_hit: self.menu_hit.clone(),
|
||||
geometry: self.geometry.clone(),
|
||||
placeholder: self
|
||||
.placeholder(self.table.read(cx).delegate().hidden_count())
|
||||
.into(),
|
||||
placeholder: self.placeholder(self.table.read(cx).delegate().hidden_count()),
|
||||
};
|
||||
self.table.update(cx, |table, cx| {
|
||||
table.delegate_mut().configure(context);
|
||||
@@ -504,22 +523,22 @@ impl FilePane {
|
||||
/// the read is slow, like the status line, so a quick refresh does not
|
||||
/// flicker. A directory of nothing but hidden files, not shown, is not
|
||||
/// empty.
|
||||
fn placeholder(&self, hidden: usize) -> &'static str {
|
||||
fn placeholder(&self, hidden: usize) -> ListPlaceholder {
|
||||
if self.connection == ConnectionState::Connecting {
|
||||
"正在连接 SFTP…"
|
||||
ListPlaceholder::Connecting
|
||||
} else if self.loading && (!self.listed || self.slow_load) {
|
||||
"正在读取目录…"
|
||||
ListPlaceholder::Reading
|
||||
} else if self.listed && hidden > 0 {
|
||||
"只有隐藏文件"
|
||||
ListPlaceholder::OnlyHidden
|
||||
} else if self.listed {
|
||||
"空目录"
|
||||
ListPlaceholder::Empty
|
||||
} else if self.connection == ConnectionState::Disconnected {
|
||||
"未连接"
|
||||
ListPlaceholder::NotConnected
|
||||
} else if self.error.is_some() {
|
||||
"无法读取目录"
|
||||
ListPlaceholder::Unreadable
|
||||
} else {
|
||||
// Before the first read is asked for.
|
||||
"正在读取目录…"
|
||||
ListPlaceholder::Reading
|
||||
}
|
||||
}
|
||||
fn update_selection(
|
||||
@@ -686,21 +705,22 @@ impl FilePane {
|
||||
/// wrong goes to the window's status line instead, see `problem`.
|
||||
fn render_status(&self, file_count: usize, selected: usize, cx: &App) -> impl IntoElement {
|
||||
let hidden = self.table.read(cx).delegate().hidden_count();
|
||||
let status = if self.connection == ConnectionState::Connecting {
|
||||
"正在连接 SFTP…".to_string()
|
||||
let status: SharedString = if self.connection == ConnectionState::Connecting {
|
||||
t!("explorer.list.connecting")
|
||||
} else if let Some(name) = &self.opening {
|
||||
format!("正在打开 {name}…")
|
||||
t!("explorer.status.opening", name = name)
|
||||
} else if self.slow_load {
|
||||
"正在读取目录…".to_string()
|
||||
t!("explorer.list.reading")
|
||||
} else {
|
||||
let mut status = format!("{file_count} 个项目");
|
||||
// Counts, each a phrase of its own, joined by a dot.
|
||||
let mut parts = vec![tn!("explorer.status.items", file_count)];
|
||||
if hidden > 0 {
|
||||
status.push_str(&format!(" · 隐藏 {hidden} 项"));
|
||||
parts.push(tn!("explorer.status.hidden", hidden));
|
||||
}
|
||||
if selected > 0 {
|
||||
status.push_str(&format!(" · 已选择 {selected} 项"));
|
||||
parts.push(tn!("explorer.status.selected", selected));
|
||||
}
|
||||
status
|
||||
parts.join(" · ").into()
|
||||
};
|
||||
h_flex()
|
||||
.id("pane-status")
|
||||
@@ -907,27 +927,30 @@ impl FilePane {
|
||||
let side = self.side;
|
||||
let chain = path_ancestors(&self.path, side == PaneSide::Remote);
|
||||
let mut groups = vec![
|
||||
SearchableGroup::new("当前路径").items(chain.into_iter().enumerate().map(
|
||||
|(depth, (title, path))| PathChoice {
|
||||
title: title.into(),
|
||||
path: path.into(),
|
||||
depth,
|
||||
home: false,
|
||||
side,
|
||||
},
|
||||
)),
|
||||
SearchableGroup::new(t!("explorer.directory_select.current")).items(
|
||||
chain
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(depth, (title, path))| PathChoice {
|
||||
title: title.into(),
|
||||
path: path.into(),
|
||||
depth,
|
||||
home: false,
|
||||
side,
|
||||
}),
|
||||
),
|
||||
];
|
||||
if !self.places.is_empty() {
|
||||
groups.push(
|
||||
SearchableGroup::new("位置").items(self.places.iter().enumerate().map(
|
||||
|(ix, (title, path))| PathChoice {
|
||||
title: title.clone(),
|
||||
SearchableGroup::new(t!("explorer.directory_select.places")).items(
|
||||
self.places.iter().map(|(place, path)| PathChoice {
|
||||
title: place.title(),
|
||||
path: path.clone().into(),
|
||||
depth: 0,
|
||||
home: ix == 0,
|
||||
home: *place == Place::Home,
|
||||
side,
|
||||
},
|
||||
)),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
let current: SharedString = self.path.clone().into();
|
||||
@@ -950,7 +973,7 @@ impl FilePane {
|
||||
.background_spawn(async move {
|
||||
provider
|
||||
.list(&PathBuf::from(path))
|
||||
.map_err(|e| format!("无法读取目录:{e}"))
|
||||
.map_err(|e| t!("explorer.list.read_failed", error = e).to_string())
|
||||
})
|
||||
.await;
|
||||
let _ = this.update_in(cx, |this, window, cx| {
|
||||
@@ -1000,14 +1023,14 @@ impl Render for FilePane {
|
||||
let navigable = self.takes_commands();
|
||||
// A toolbar button dispatches the same command as its key binding,
|
||||
// and its tooltip shows that binding.
|
||||
let tool = |id: &'static str, icon: Icon, tip: &'static str, command: ExplorerCommand| {
|
||||
let tool = |id: &'static str, icon: Icon, tip: SharedString, command: ExplorerCommand| {
|
||||
let dispatch = self.dispatch.clone();
|
||||
let shortcut = ExplorerShortcut(command.clone());
|
||||
Button::new(id)
|
||||
.ghost()
|
||||
.small()
|
||||
.icon(icon)
|
||||
.accessibility_label(tip)
|
||||
.accessibility_label(tip.clone())
|
||||
.tooltip_with_action(tip, &shortcut, Some(context))
|
||||
.on_click(move |_, window, cx| {
|
||||
dispatch.dispatch_explorer_action(
|
||||
@@ -1039,9 +1062,9 @@ impl Render for FilePane {
|
||||
.child(row)
|
||||
};
|
||||
let hidden_files_tip = if state.show_hidden {
|
||||
"不显示隐藏文件"
|
||||
t!("explorer.toolbar.hide_hidden_files")
|
||||
} else {
|
||||
"显示隐藏文件"
|
||||
t!("explorer.command.show_hidden_files")
|
||||
};
|
||||
let navigation = toolbar()
|
||||
// `Select` fills its parent (`size_full`), so it needs a sized
|
||||
@@ -1052,7 +1075,7 @@ impl Render for FilePane {
|
||||
.id("path-select")
|
||||
.small()
|
||||
.menu_width(rems(18.))
|
||||
.accessibility_label(format!("{}目录", self.side.label()))
|
||||
.accessibility_label(self.side.directory_select_name())
|
||||
.disabled(!navigable),
|
||||
),
|
||||
)
|
||||
@@ -1067,7 +1090,7 @@ impl Render for FilePane {
|
||||
tool(
|
||||
"bookmarks",
|
||||
Icon::new(CatalogIcon::Bookmark),
|
||||
"打开目录/书签…",
|
||||
t!("explorer.command.open_directory"),
|
||||
ExplorerCommand::OpenDirectory { remote },
|
||||
)
|
||||
.disabled(!navigable),
|
||||
@@ -1080,7 +1103,7 @@ impl Render for FilePane {
|
||||
tool(
|
||||
"up",
|
||||
Icon::new(CatalogIcon::FolderUp),
|
||||
"上级目录",
|
||||
t!("explorer.command.up"),
|
||||
ExplorerCommand::Up { remote },
|
||||
)
|
||||
.disabled(!navigable || !state.can_go_up),
|
||||
@@ -1089,7 +1112,7 @@ impl Render for FilePane {
|
||||
tool(
|
||||
"root",
|
||||
Icon::new(CatalogIcon::FolderRoot),
|
||||
"根目录",
|
||||
t!("explorer.command.root"),
|
||||
ExplorerCommand::Root { remote },
|
||||
)
|
||||
.disabled(!navigable || !state.can_go_up),
|
||||
@@ -1098,7 +1121,7 @@ impl Render for FilePane {
|
||||
tool(
|
||||
"home",
|
||||
Icon::new(CatalogIcon::House),
|
||||
"主目录",
|
||||
t!("explorer.command.home"),
|
||||
ExplorerCommand::Home { remote },
|
||||
)
|
||||
.disabled(!navigable || !state.can_go_home),
|
||||
@@ -1107,7 +1130,7 @@ impl Render for FilePane {
|
||||
tool(
|
||||
"refresh",
|
||||
Icon::new(CatalogIcon::RefreshCw),
|
||||
"刷新",
|
||||
t!("explorer.command.refresh"),
|
||||
ExplorerCommand::Refresh { remote },
|
||||
)
|
||||
.disabled(!navigable),
|
||||
@@ -1117,7 +1140,7 @@ impl Render for FilePane {
|
||||
tool(
|
||||
"back",
|
||||
Icon::new(IconName::ArrowLeft),
|
||||
"后退",
|
||||
t!("explorer.command.back"),
|
||||
ExplorerCommand::Back { remote },
|
||||
)
|
||||
.disabled(!navigable || !state.can_go_back),
|
||||
@@ -1126,7 +1149,7 @@ impl Render for FilePane {
|
||||
tool(
|
||||
"forward",
|
||||
Icon::new(IconName::ArrowRight),
|
||||
"前进",
|
||||
t!("explorer.command.forward"),
|
||||
ExplorerCommand::Forward { remote },
|
||||
)
|
||||
.disabled(!navigable || !state.can_go_forward),
|
||||
@@ -1144,7 +1167,7 @@ impl Render for FilePane {
|
||||
} else {
|
||||
CatalogIcon::EyeOff
|
||||
}))
|
||||
.accessibility_label(hidden_files_tip)
|
||||
.accessibility_label(hidden_files_tip.clone())
|
||||
.tooltip_with_action(
|
||||
hidden_files_tip,
|
||||
&ToggleHiddenFiles(self.side),
|
||||
@@ -1163,9 +1186,19 @@ impl Render for FilePane {
|
||||
);
|
||||
let transfer_shortcut = ExplorerShortcut(ExplorerCommand::Transfer { remote });
|
||||
let (id, icon, label, tip) = if remote {
|
||||
("download", CatalogIcon::Download, "下载…", "下载所选项目")
|
||||
(
|
||||
"download",
|
||||
CatalogIcon::Download,
|
||||
t!("explorer.command.download"),
|
||||
t!("explorer.toolbar.download_tip"),
|
||||
)
|
||||
} else {
|
||||
("upload", CatalogIcon::Upload, "上传…", "上传所选项目")
|
||||
(
|
||||
"upload",
|
||||
CatalogIcon::Upload,
|
||||
t!("explorer.command.upload"),
|
||||
t!("explorer.toolbar.upload_tip"),
|
||||
)
|
||||
};
|
||||
let transfer = Button::new(id)
|
||||
.icon(Icon::new(icon))
|
||||
@@ -1192,7 +1225,7 @@ impl Render for FilePane {
|
||||
.disabled(!state.can_transfer)
|
||||
.dropdown_menu(move |menu, _, _| {
|
||||
menu.menu(
|
||||
"选择文件上传…",
|
||||
t!("explorer.toolbar.choose_files"),
|
||||
Box::new(ExplorerAction::new(sid, ExplorerCommand::ChooseFiles)),
|
||||
)
|
||||
})
|
||||
@@ -1205,7 +1238,7 @@ impl Render for FilePane {
|
||||
tool(
|
||||
"delete",
|
||||
Icon::new(CatalogIcon::Trash),
|
||||
"删除",
|
||||
t!("common.delete"),
|
||||
ExplorerCommand::Delete { remote },
|
||||
)
|
||||
.disabled(selected == 0 || !state.can_modify),
|
||||
@@ -1214,7 +1247,7 @@ impl Render for FilePane {
|
||||
tool(
|
||||
"rename",
|
||||
Icon::new(CatalogIcon::SquarePen),
|
||||
"重命名…",
|
||||
t!("explorer.command.rename"),
|
||||
ExplorerCommand::Rename { remote },
|
||||
)
|
||||
.disabled(selected != 1 || !state.can_modify),
|
||||
@@ -1223,7 +1256,7 @@ impl Render for FilePane {
|
||||
tool(
|
||||
"properties",
|
||||
Icon::new(IconName::Info),
|
||||
"属性…",
|
||||
t!("explorer.command.properties"),
|
||||
ExplorerCommand::Properties { remote },
|
||||
)
|
||||
.disabled(selected == 0 || !state.can_modify),
|
||||
@@ -1235,7 +1268,7 @@ impl Render for FilePane {
|
||||
.ghost()
|
||||
.small()
|
||||
.icon(Icon::new(CatalogIcon::FolderPlus))
|
||||
.label("新建")
|
||||
.label(t!("explorer.command.new"))
|
||||
.dropdown_caret(true)
|
||||
.disabled(!state.can_modify)
|
||||
.dropdown_menu(move |popup, _, _| new_menu(popup, &menu))
|
||||
|
||||
@@ -22,6 +22,7 @@ use crate::app::{
|
||||
ActualSizePreview, CatalogIcon, FitPreview, IMAGE_PREVIEW_CONTEXT, ZoomPreviewIn,
|
||||
ZoomPreviewOut,
|
||||
};
|
||||
use crate::i18n::t;
|
||||
|
||||
/// The zoom levels the buttons and keys step through, as a share of the
|
||||
/// image's own size (one image pixel to one point at 100%).
|
||||
@@ -234,15 +235,14 @@ impl Render for ImagePreview {
|
||||
.unwrap_or_else(|| size(viewport.width * 0.8 - px(48.), height));
|
||||
let scale = pixels.map(|image| self.scale(frame, image));
|
||||
let info: SharedString = match pixels {
|
||||
Some(image) => format!(
|
||||
"{} × {} 像素 · {}",
|
||||
f32::from(image.width) as u32,
|
||||
f32::from(image.height) as u32,
|
||||
format_size(self.bytes)
|
||||
Some(image) => t!(
|
||||
"explorer.preview.image_info",
|
||||
width = f32::from(image.width) as u32,
|
||||
height = f32::from(image.height) as u32,
|
||||
size = format_size(self.bytes)
|
||||
),
|
||||
None => format_size(self.bytes),
|
||||
}
|
||||
.into();
|
||||
None => format_size(self.bytes).into(),
|
||||
};
|
||||
// Only the scale: whether it fits is the 适合窗口 button's state.
|
||||
let zoom_label: SharedString = scale.map(percent).unwrap_or_default().into();
|
||||
let focus = self.focus_handle.clone();
|
||||
@@ -273,8 +273,12 @@ impl Render for ImagePreview {
|
||||
.ghost()
|
||||
.small()
|
||||
.icon(Icon::new(CatalogIcon::ZoomOut))
|
||||
.accessibility_label("缩小")
|
||||
.tooltip_with_action("缩小", &ZoomPreviewOut, Some(IMAGE_PREVIEW_CONTEXT))
|
||||
.accessibility_label(t!("explorer.preview.zoom_out"))
|
||||
.tooltip_with_action(
|
||||
t!("explorer.preview.zoom_out"),
|
||||
&ZoomPreviewOut,
|
||||
Some(IMAGE_PREVIEW_CONTEXT),
|
||||
)
|
||||
.on_click(command(Box::new(ZoomPreviewOut))),
|
||||
)
|
||||
.child(
|
||||
@@ -293,27 +297,35 @@ impl Render for ImagePreview {
|
||||
.ghost()
|
||||
.small()
|
||||
.icon(Icon::new(CatalogIcon::ZoomIn))
|
||||
.accessibility_label("放大")
|
||||
.tooltip_with_action("放大", &ZoomPreviewIn, Some(IMAGE_PREVIEW_CONTEXT))
|
||||
.accessibility_label(t!("explorer.preview.zoom_in"))
|
||||
.tooltip_with_action(
|
||||
t!("explorer.preview.zoom_in"),
|
||||
&ZoomPreviewIn,
|
||||
Some(IMAGE_PREVIEW_CONTEXT),
|
||||
)
|
||||
.on_click(command(Box::new(ZoomPreviewIn))),
|
||||
)
|
||||
.child(
|
||||
Button::new("preview-fit")
|
||||
.ghost()
|
||||
.small()
|
||||
.label("适合窗口")
|
||||
.label(t!("explorer.preview.fit"))
|
||||
.selected(self.zoom == Zoom::Fit)
|
||||
.tooltip_with_action("适合窗口", &FitPreview, Some(IMAGE_PREVIEW_CONTEXT))
|
||||
.tooltip_with_action(
|
||||
t!("explorer.preview.fit"),
|
||||
&FitPreview,
|
||||
Some(IMAGE_PREVIEW_CONTEXT),
|
||||
)
|
||||
.on_click(command(Box::new(FitPreview))),
|
||||
)
|
||||
.child(
|
||||
Button::new("preview-actual-size")
|
||||
.ghost()
|
||||
.small()
|
||||
.label("原图")
|
||||
.label(t!("explorer.preview.actual_size"))
|
||||
.selected(self.zoom == Zoom::Scale(1.0))
|
||||
.tooltip_with_action(
|
||||
"原图(100%)",
|
||||
t!("explorer.preview.actual_size_tip"),
|
||||
&ActualSizePreview,
|
||||
Some(IMAGE_PREVIEW_CONTEXT),
|
||||
)
|
||||
@@ -412,14 +424,14 @@ impl Render for ImagePreview {
|
||||
.children(picture)
|
||||
.when(pixels.is_none(), |scroll| {
|
||||
let note = match &self.decoded {
|
||||
Some(Err(_)) => "无法显示这张图片",
|
||||
_ => "正在显示图片…",
|
||||
Some(Err(_)) => t!("explorer.preview.undecodable"),
|
||||
_ => t!("explorer.preview.decoding"),
|
||||
};
|
||||
scroll.child(
|
||||
div()
|
||||
.id("preview-note")
|
||||
.test_support()
|
||||
.aria_label(note)
|
||||
.aria_label(note.clone())
|
||||
.size_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Directory row snapshots and display formatting.
|
||||
|
||||
use super::PaneSide;
|
||||
use crate::i18n::t;
|
||||
use crate::sftp::{DirectoryEntry, EntryKind};
|
||||
use gpui_kit::{App, Global, SharedString};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -18,17 +19,17 @@ pub enum NewEntryKind {
|
||||
}
|
||||
|
||||
impl NewEntryKind {
|
||||
pub fn title(self) -> &'static str {
|
||||
pub fn title(self) -> SharedString {
|
||||
match self {
|
||||
NewEntryKind::Folder => "新建文件夹",
|
||||
NewEntryKind::File => "新建文件",
|
||||
NewEntryKind::Folder => t!("explorer.new.folder_title"),
|
||||
NewEntryKind::File => t!("explorer.new.file_title"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_name(self) -> &'static str {
|
||||
pub fn default_name(self) -> SharedString {
|
||||
match self {
|
||||
NewEntryKind::Folder => "新建文件夹",
|
||||
NewEntryKind::File => "新建文件.txt",
|
||||
NewEntryKind::Folder => t!("explorer.new.folder_name"),
|
||||
NewEntryKind::File => t!("explorer.new.file_name"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,11 +42,11 @@ pub enum FileKind {
|
||||
}
|
||||
|
||||
impl FileKind {
|
||||
pub fn label(self) -> &'static str {
|
||||
pub fn label(self) -> SharedString {
|
||||
match self {
|
||||
FileKind::Dir => "文件夹",
|
||||
FileKind::File => "文件",
|
||||
FileKind::Symlink => "链接",
|
||||
FileKind::Dir => t!("explorer.kind.folder"),
|
||||
FileKind::File => t!("explorer.kind.file"),
|
||||
FileKind::Symlink => t!("explorer.kind.link"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,12 +150,12 @@ impl FileEntry {
|
||||
/// The type shown in the 类型 column: the kind, or the extension for files.
|
||||
pub fn type_label(&self) -> String {
|
||||
if self.is_parent() {
|
||||
return "上级目录".into();
|
||||
return t!("explorer.kind.parent").into();
|
||||
}
|
||||
match self.kind {
|
||||
FileKind::File => match self.name.rsplit_once('.') {
|
||||
Some((stem, ext)) if !stem.is_empty() && !ext.is_empty() => {
|
||||
format!("{} 文件", ext.to_uppercase())
|
||||
t!("explorer.kind.extension", ext = ext.to_uppercase()).into()
|
||||
}
|
||||
_ => self.kind.label().to_string(),
|
||||
},
|
||||
@@ -317,11 +318,11 @@ impl FileSizeFormat {
|
||||
FileSizeFormat::Short,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
pub fn label(self) -> SharedString {
|
||||
match self {
|
||||
FileSizeFormat::Bytes => "字节",
|
||||
FileSizeFormat::Kilobytes => "千字节(KB)",
|
||||
FileSizeFormat::Short => "简短格式(B、KB、MB、GB)",
|
||||
FileSizeFormat::Bytes => t!("explorer.size_format.bytes"),
|
||||
FileSizeFormat::Kilobytes => t!("explorer.size_format.kilobytes"),
|
||||
FileSizeFormat::Short => t!("explorer.size_format.short"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::app::ExplorerDispatch as _;
|
||||
use crate::{
|
||||
app::{CatalogIcon, ExplorerAction, ExplorerCommand},
|
||||
host::{BookmarkSide, HostId, HostStore},
|
||||
i18n::t,
|
||||
shared::{commit_footer, dismiss_form_error, form_error_notification},
|
||||
};
|
||||
use gpui_kit::component::{
|
||||
@@ -75,7 +76,10 @@ impl OpenDirectoryForm {
|
||||
fn open(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
|
||||
let path = self.directory(cx);
|
||||
if path.is_empty() {
|
||||
window.push_notification(form_error_notification("请输入要打开的目录"), cx);
|
||||
window.push_notification(
|
||||
form_error_notification(t!("explorer.open_directory.missing")),
|
||||
cx,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
let remote = self.remote;
|
||||
@@ -151,7 +155,7 @@ impl OpenDirectoryForm {
|
||||
files: false,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
prompt: Some("选择要打开的目录".into()),
|
||||
prompt: Some(t!("explorer.open_directory.choose")),
|
||||
});
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(Ok(Some(paths))) = choice.await
|
||||
@@ -177,7 +181,7 @@ impl OpenDirectoryForm {
|
||||
.id("bookmark-list")
|
||||
.test_support()
|
||||
.role(Role::ListBox)
|
||||
.aria_label("书签")
|
||||
.aria_label(t!("explorer.open_directory.bookmarks"))
|
||||
.track_focus(&self.list_focus)
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
@@ -203,7 +207,7 @@ impl OpenDirectoryForm {
|
||||
.px_2()
|
||||
.py_1()
|
||||
.text_color(theme.muted_foreground)
|
||||
.child("暂无书签"),
|
||||
.child(t!("explorer.bookmarks.none")),
|
||||
)
|
||||
})
|
||||
.children(bookmarks.iter().enumerate().map(|(ix, path)| {
|
||||
@@ -247,74 +251,96 @@ impl Render for OpenDirectoryForm {
|
||||
let bookmarks = self.bookmarks(cx);
|
||||
let selected = self.selected(cx);
|
||||
let typed = !self.directory(cx).is_empty();
|
||||
let button = |id: &'static str, label: &'static str| Button::new(id).small().label(label);
|
||||
let button = |id: &'static str, label: SharedString| Button::new(id).small().label(label);
|
||||
v_flex().gap_3().child(
|
||||
Form::new()
|
||||
.child(
|
||||
Field::new().label("目录").child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Input::new(&self.input)
|
||||
.id("open-directory-path")
|
||||
.small()
|
||||
.flex_1(),
|
||||
)
|
||||
.when(!self.remote, |this| {
|
||||
this.child(button("open-directory-browse", "浏览…").on_click(
|
||||
cx.listener(|this, _, window, cx| this.browse(window, cx)),
|
||||
))
|
||||
}),
|
||||
),
|
||||
Field::new()
|
||||
.label(t!("explorer.open_directory.folder"))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Input::new(&self.input)
|
||||
.id("open-directory-path")
|
||||
.small()
|
||||
.flex_1(),
|
||||
)
|
||||
.when(!self.remote, |this| {
|
||||
this.child(
|
||||
button("open-directory-browse", t!("explorer.browse"))
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.browse(window, cx)
|
||||
})),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Field::new().label("书签").child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_start()
|
||||
.child(self.render_list(&bookmarks, selected, window, cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.w(rems(5.))
|
||||
.flex_shrink_0()
|
||||
.child(
|
||||
button("bookmark-add", "添加")
|
||||
Field::new()
|
||||
.label(t!("explorer.open_directory.bookmarks"))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_start()
|
||||
.child(self.render_list(&bookmarks, selected, window, cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.w(rems(5.))
|
||||
.flex_shrink_0()
|
||||
.child(
|
||||
button(
|
||||
"bookmark-add",
|
||||
t!("explorer.open_directory.add"),
|
||||
)
|
||||
.w_full()
|
||||
.disabled(!typed || selected.is_some())
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.add(window, cx)
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
button("bookmark-remove", "删除")
|
||||
.on_click(
|
||||
cx.listener(|this, _, window, cx| {
|
||||
this.add(window, cx)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
button(
|
||||
"bookmark-remove",
|
||||
t!("explorer.open_directory.remove"),
|
||||
)
|
||||
.w_full()
|
||||
.disabled(selected.is_none())
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.remove(window, cx)
|
||||
})),
|
||||
)
|
||||
.child(div().h_4())
|
||||
.child(
|
||||
button("bookmark-up", "上移")
|
||||
.w_full()
|
||||
.disabled(selected.is_none_or(|ix| ix == 0))
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.shift(-1, window, cx)
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
button("bookmark-down", "下移")
|
||||
.on_click(
|
||||
cx.listener(|this, _, window, cx| {
|
||||
this.remove(window, cx)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(div().h_4())
|
||||
.child(
|
||||
button("bookmark-up", t!("explorer.open_directory.up"))
|
||||
.w_full()
|
||||
.disabled(selected.is_none_or(|ix| ix == 0))
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.shift(-1, window, cx)
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
button(
|
||||
"bookmark-down",
|
||||
t!("explorer.open_directory.down"),
|
||||
)
|
||||
.w_full()
|
||||
.disabled(
|
||||
selected.is_none_or(|ix| ix + 1 >= bookmarks.len()),
|
||||
)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.shift(1, window, cx)
|
||||
})),
|
||||
),
|
||||
),
|
||||
),
|
||||
.on_click(
|
||||
cx.listener(|this, _, window, cx| {
|
||||
this.shift(1, window, cx)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -351,11 +377,14 @@ impl ExplorerPanel {
|
||||
});
|
||||
window.open_dialog(cx, move |dialog, _, _| {
|
||||
dialog
|
||||
.title("打开目录")
|
||||
.title(t!("explorer.open_directory.title"))
|
||||
// Closed by its buttons or Escape, not by a click beside it.
|
||||
.overlay_closable(false)
|
||||
.child(form.clone())
|
||||
.footer(commit_footer("open-directory-confirm", "打开"))
|
||||
.footer(commit_footer(
|
||||
"open-directory-confirm",
|
||||
t!("explorer.open_directory.open"),
|
||||
))
|
||||
.on_ok({
|
||||
let form = form.clone();
|
||||
move |_, window, cx| form.update(cx, |form, cx| form.open(window, cx))
|
||||
|
||||
@@ -6,6 +6,7 @@ use super::{ExplorerId, FileSizeFormat, NewEntryKind, PaneSide, PreviewKind};
|
||||
use crate::app::{
|
||||
CatalogIcon, ExplorerAction, ExplorerCommand, SetFileSizeFormat, ToggleHiddenFiles,
|
||||
};
|
||||
use crate::i18n::t;
|
||||
use gpui_kit::component::{Icon, IconName, menu::PopupMenu};
|
||||
use gpui_kit::*;
|
||||
|
||||
@@ -54,7 +55,7 @@ pub(super) fn item_menu(menu: PopupMenu, state: &PaneMenuState) -> PopupMenu {
|
||||
menu
|
||||
} else {
|
||||
menu.menu_with_icon(
|
||||
"编辑",
|
||||
t!("explorer.command.edit"),
|
||||
Icon::new(CatalogIcon::FilePenLine),
|
||||
state.action(ExplorerCommand::Edit {
|
||||
remote,
|
||||
@@ -64,7 +65,7 @@ pub(super) fn item_menu(menu: PopupMenu, state: &PaneMenuState) -> PopupMenu {
|
||||
};
|
||||
let menu = if kind.is_some() {
|
||||
menu.menu_with_icon(
|
||||
"预览",
|
||||
t!("explorer.command.preview"),
|
||||
Icon::new(CatalogIcon::Eye),
|
||||
state.action(ExplorerCommand::Preview {
|
||||
remote,
|
||||
@@ -79,9 +80,9 @@ pub(super) fn item_menu(menu: PopupMenu, state: &PaneMenuState) -> PopupMenu {
|
||||
menu
|
||||
};
|
||||
let (verb, icon) = if remote {
|
||||
("下载…", CatalogIcon::Download)
|
||||
(t!("explorer.command.download"), CatalogIcon::Download)
|
||||
} else {
|
||||
("上传…", CatalogIcon::Upload)
|
||||
(t!("explorer.command.upload"), CatalogIcon::Upload)
|
||||
};
|
||||
menu.menu_with_icon_and_disabled(
|
||||
verb,
|
||||
@@ -90,27 +91,27 @@ pub(super) fn item_menu(menu: PopupMenu, state: &PaneMenuState) -> PopupMenu {
|
||||
state.targets.is_empty() || !state.can_transfer,
|
||||
)
|
||||
.menu_with_icon_and_disabled(
|
||||
"复制路径",
|
||||
t!("explorer.command.copy_path"),
|
||||
Icon::new(IconName::Copy),
|
||||
state.action(ExplorerCommand::CopySelectedPaths { remote }),
|
||||
state.targets.is_empty(),
|
||||
)
|
||||
.separator()
|
||||
.menu_with_icon_and_disabled(
|
||||
"删除",
|
||||
t!("common.delete"),
|
||||
Icon::new(CatalogIcon::Trash),
|
||||
state.action(ExplorerCommand::Delete { remote }),
|
||||
none,
|
||||
)
|
||||
.menu_with_icon_and_disabled(
|
||||
"重命名…",
|
||||
t!("explorer.command.rename"),
|
||||
Icon::new(CatalogIcon::SquarePen),
|
||||
state.action(ExplorerCommand::Rename { remote }),
|
||||
none || state.targets.len() != 1,
|
||||
)
|
||||
.separator()
|
||||
.menu_with_icon_and_disabled(
|
||||
"属性…",
|
||||
t!("explorer.command.properties"),
|
||||
Icon::new(IconName::Info),
|
||||
state.action(ExplorerCommand::Properties { remote }),
|
||||
none,
|
||||
@@ -127,16 +128,17 @@ pub(super) fn directory_menu(
|
||||
let create = state.clone();
|
||||
let menu = refresh_items(menu, state, window, cx)
|
||||
.menu_with_check(
|
||||
"显示隐藏文件",
|
||||
t!("explorer.command.show_hidden_files"),
|
||||
state.show_hidden,
|
||||
Box::new(ToggleHiddenFiles(PaneSide::from_remote(state.remote))),
|
||||
)
|
||||
.separator();
|
||||
add_bookmark_item(menu, state)
|
||||
.separator()
|
||||
.submenu("新建", window, cx, move |menu, _, _| {
|
||||
new_menu(menu, &create)
|
||||
})
|
||||
add_bookmark_item(menu, state).separator().submenu(
|
||||
t!("explorer.command.new"),
|
||||
window,
|
||||
cx,
|
||||
move |menu, _, _| new_menu(menu, &create),
|
||||
)
|
||||
}
|
||||
|
||||
/// Right-click on the path label: WinSCP's panel menu.
|
||||
@@ -149,13 +151,13 @@ pub(super) fn path_menu(
|
||||
let remote = state.remote;
|
||||
add_bookmark_item(refresh_items(menu, state, window, cx).separator(), state)
|
||||
.menu_with_icon(
|
||||
"复制路径",
|
||||
t!("explorer.command.copy_path"),
|
||||
Icon::new(IconName::Copy),
|
||||
state.action(ExplorerCommand::CopyPath { remote }),
|
||||
)
|
||||
.separator()
|
||||
.menu_with_icon(
|
||||
"打开目录/书签…",
|
||||
t!("explorer.command.open_directory"),
|
||||
Icon::new(IconName::FolderOpen),
|
||||
state.action(ExplorerCommand::OpenDirectory { remote }),
|
||||
)
|
||||
@@ -170,41 +172,41 @@ fn refresh_items(
|
||||
) -> PopupMenu {
|
||||
let remote = state.remote;
|
||||
let go = state.clone();
|
||||
menu.submenu("前往", window, cx, move |menu, _, _| {
|
||||
menu.submenu(t!("explorer.command.go"), window, cx, move |menu, _, _| {
|
||||
menu.menu_with_icon_and_disabled(
|
||||
"上级目录",
|
||||
t!("explorer.command.up"),
|
||||
Icon::new(CatalogIcon::FolderUp),
|
||||
go.action(ExplorerCommand::Up { remote }),
|
||||
!go.can_go_up,
|
||||
)
|
||||
.menu_with_icon_and_disabled(
|
||||
"根目录",
|
||||
t!("explorer.command.root"),
|
||||
Icon::new(CatalogIcon::FolderRoot),
|
||||
go.action(ExplorerCommand::Root { remote }),
|
||||
!go.can_go_up,
|
||||
)
|
||||
.menu_with_icon_and_disabled(
|
||||
"主目录",
|
||||
t!("explorer.command.home"),
|
||||
Icon::new(CatalogIcon::House),
|
||||
go.action(ExplorerCommand::Home { remote }),
|
||||
!go.can_go_home,
|
||||
)
|
||||
.separator()
|
||||
.menu_with_icon_and_disabled(
|
||||
"后退",
|
||||
t!("explorer.command.back"),
|
||||
Icon::new(IconName::ArrowLeft),
|
||||
go.action(ExplorerCommand::Back { remote }),
|
||||
!go.can_go_back,
|
||||
)
|
||||
.menu_with_icon_and_disabled(
|
||||
"前进",
|
||||
t!("explorer.command.forward"),
|
||||
Icon::new(IconName::ArrowRight),
|
||||
go.action(ExplorerCommand::Forward { remote }),
|
||||
!go.can_go_forward,
|
||||
)
|
||||
})
|
||||
.menu_with_icon(
|
||||
"刷新",
|
||||
t!("explorer.command.refresh"),
|
||||
Icon::new(CatalogIcon::RefreshCw),
|
||||
state.action(ExplorerCommand::Refresh { remote }),
|
||||
)
|
||||
@@ -212,7 +214,7 @@ fn refresh_items(
|
||||
|
||||
fn add_bookmark_item(menu: PopupMenu, state: &PaneMenuState) -> PopupMenu {
|
||||
menu.menu_with_icon_and_disabled(
|
||||
"添加路径到书签",
|
||||
t!("explorer.command.add_bookmark"),
|
||||
Icon::new(CatalogIcon::Bookmark),
|
||||
state.action(ExplorerCommand::AddBookmark {
|
||||
remote: state.remote,
|
||||
@@ -225,22 +227,23 @@ fn add_bookmark_item(menu: PopupMenu, state: &PaneMenuState) -> PopupMenu {
|
||||
/// The 大小 column title's menu: WinSCP's 文件大小显示为, with the format in
|
||||
/// use checked. The choice holds for every SFTP tab and is saved.
|
||||
pub(super) fn size_format_menu(menu: PopupMenu, current: FileSizeFormat) -> PopupMenu {
|
||||
FileSizeFormat::ALL
|
||||
.into_iter()
|
||||
.fold(menu.label("文件大小显示为"), |menu, format| {
|
||||
FileSizeFormat::ALL.into_iter().fold(
|
||||
menu.label(t!("explorer.command.size_format")),
|
||||
|menu, format| {
|
||||
menu.menu_with_check(
|
||||
format.label(),
|
||||
format == current,
|
||||
Box::new(SetFileSizeFormat(format)),
|
||||
)
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// The toolbar's 新建 menu, also the context menu's 新建 submenu.
|
||||
pub(super) fn new_menu(menu: PopupMenu, state: &PaneMenuState) -> PopupMenu {
|
||||
let remote = state.remote;
|
||||
menu.menu_with_icon_and_disabled(
|
||||
"文件夹…",
|
||||
t!("explorer.command.new_folder"),
|
||||
Icon::new(CatalogIcon::FolderPlus),
|
||||
state.action(ExplorerCommand::New {
|
||||
remote,
|
||||
@@ -249,7 +252,7 @@ pub(super) fn new_menu(menu: PopupMenu, state: &PaneMenuState) -> PopupMenu {
|
||||
!state.can_modify,
|
||||
)
|
||||
.menu_with_icon_and_disabled(
|
||||
"文件…",
|
||||
t!("explorer.command.new_file"),
|
||||
Icon::new(CatalogIcon::FilePlus),
|
||||
state.action(ExplorerCommand::New {
|
||||
remote,
|
||||
@@ -264,7 +267,7 @@ pub(super) fn new_menu(menu: PopupMenu, state: &PaneMenuState) -> PopupMenu {
|
||||
pub(super) fn bookmark_menu(menu: PopupMenu, state: &PaneMenuState) -> PopupMenu {
|
||||
let remote = state.remote;
|
||||
let menu = if state.bookmarks.is_empty() {
|
||||
menu.label("暂无书签")
|
||||
menu.label(t!("explorer.bookmarks.none"))
|
||||
} else {
|
||||
state.bookmarks.iter().fold(menu, |menu, path| {
|
||||
menu.menu_with_check(
|
||||
@@ -280,7 +283,7 @@ pub(super) fn bookmark_menu(menu: PopupMenu, state: &PaneMenuState) -> PopupMenu
|
||||
let menu = menu.separator();
|
||||
if state.bookmarks.contains(&state.path) {
|
||||
menu.menu(
|
||||
"从书签中移除路径",
|
||||
t!("explorer.command.remove_bookmark"),
|
||||
state.action(ExplorerCommand::RemoveBookmark {
|
||||
remote,
|
||||
path: state.path.clone(),
|
||||
@@ -288,7 +291,7 @@ pub(super) fn bookmark_menu(menu: PopupMenu, state: &PaneMenuState) -> PopupMenu
|
||||
)
|
||||
} else {
|
||||
menu.menu_with_disabled(
|
||||
"添加路径到书签",
|
||||
t!("explorer.command.add_bookmark"),
|
||||
state.action(ExplorerCommand::AddBookmark { remote, path: None }),
|
||||
state.path.is_empty(),
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use super::{ExplorerPanel, NewEntryKind};
|
||||
use crate::app::{ExplorerAction, ExplorerCommand, ExplorerDispatch as _};
|
||||
use crate::host::ConnectionState;
|
||||
use crate::i18n::t;
|
||||
use crate::sftp::{
|
||||
LocalDirectoryProvider, PermissionEdit, RemoteOperation, RemotePath, SftpCommand,
|
||||
};
|
||||
@@ -37,19 +38,20 @@ pub enum PaneOperation {
|
||||
/// What to do when an operation answers.
|
||||
pub(super) struct PendingOperation {
|
||||
remote: bool,
|
||||
failure: &'static str,
|
||||
/// The title of the notification that says it failed.
|
||||
failure: fn() -> SharedString,
|
||||
select: Option<String>,
|
||||
/// A new file, to open in the editor once it exists (WinSCP does).
|
||||
edit: Option<String>,
|
||||
}
|
||||
|
||||
impl PaneOperation {
|
||||
fn failure_title(&self) -> &'static str {
|
||||
fn failure_title(&self) -> fn() -> SharedString {
|
||||
match self {
|
||||
PaneOperation::Delete(_) => "删除失败",
|
||||
PaneOperation::Rename { .. } => "重命名失败",
|
||||
PaneOperation::Create { .. } => "新建失败",
|
||||
PaneOperation::Permissions { .. } => "修改权限失败",
|
||||
PaneOperation::Delete(_) => || t!("explorer.operation.delete_failed"),
|
||||
PaneOperation::Rename { .. } => || t!("explorer.operation.rename_failed"),
|
||||
PaneOperation::Create { .. } => || t!("explorer.operation.create_failed"),
|
||||
PaneOperation::Permissions { .. } => || t!("explorer.operation.permissions_failed"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,9 +214,8 @@ impl ExplorerPanel {
|
||||
}
|
||||
});
|
||||
match result {
|
||||
Err(message) => {
|
||||
window.push_notification(Notification::error(message).title(pending.failure), cx)
|
||||
}
|
||||
Err(message) => window
|
||||
.push_notification(Notification::error(message).title((pending.failure)()), cx),
|
||||
Ok(()) => {
|
||||
if let Some(path) = pending.edit {
|
||||
self.dispatch.dispatch_explorer_action(
|
||||
|
||||
@@ -75,7 +75,7 @@ impl FilePane {
|
||||
let bar = h_flex()
|
||||
.id(self.side.path_id())
|
||||
.test_support()
|
||||
.aria_label(format!("{}路径", self.side.label()))
|
||||
.aria_label(self.side.path_label_name())
|
||||
// WinSCP's current pane, which takes focus back when the tab
|
||||
// is shown again; nothing marks it on screen.
|
||||
.aria_selected(self.current)
|
||||
|
||||
@@ -11,6 +11,7 @@ use gpui_kit::*;
|
||||
use super::format_size;
|
||||
use super::image_preview::ImagePreview;
|
||||
use crate::app::{ExplorerAction, ExplorerDispatch as _};
|
||||
use crate::i18n::t;
|
||||
|
||||
/// The largest image a preview reads.
|
||||
pub const IMAGE_LIMIT: u64 = 20 * 1024 * 1024;
|
||||
@@ -126,7 +127,7 @@ pub(super) fn open_preview_dialog(preview: Preview, window: &mut Window, cx: &mu
|
||||
};
|
||||
// A command at the foot closes the preview first: what it opens
|
||||
// (the download question, the editor) goes in front.
|
||||
let command = |id: &'static str, label: &'static str, action: ExplorerAction| {
|
||||
let command = |id: &'static str, label: SharedString, action: ExplorerAction| {
|
||||
let dispatch = dispatch.clone();
|
||||
Button::new(id).label(label).on_click(move |_, window, cx| {
|
||||
window.close_dialog(cx);
|
||||
@@ -148,14 +149,18 @@ pub(super) fn open_preview_dialog(preview: Preview, window: &mut Window, cx: &mu
|
||||
.footer(
|
||||
DialogFooter::new()
|
||||
.when_some(download.clone(), |footer, action| {
|
||||
footer.child(command("preview-download", "下载…", action))
|
||||
footer.child(command(
|
||||
"preview-download",
|
||||
t!("explorer.file.download"),
|
||||
action,
|
||||
))
|
||||
})
|
||||
.when_some(edit.clone(), |footer, action| {
|
||||
footer.child(command("preview-edit", "编辑", action))
|
||||
footer.child(command("preview-edit", t!("explorer.command.edit"), action))
|
||||
})
|
||||
.child(
|
||||
Button::new("preview-close")
|
||||
.label("关闭")
|
||||
.label(t!("common.close"))
|
||||
.on_click(|_, window, cx| window.close_dialog(cx)),
|
||||
),
|
||||
)
|
||||
|
||||