commit d32882443f1e6827e858237f11625eab31ec3f3d Author: Hsdi Date: Fri Jun 19 03:09:53 2026 +0800 20260619 diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..f869ed0 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,500 @@ +name: Release + +# Gitea Actions workflow: +# - pushes to main build the Windows GNU package and publish a build-date Release. +# - tag pushes (v*) build the Windows GNU package and publish the matching Release. +# - manual runs build the same package and publish a build-date Release. + +on: + push: + branches: ["main"] + tags: ["v*"] + workflow_dispatch: + +env: + HTTP_PROXY: "http://10.31.88.88:7891" + HTTPS_PROXY: "http://10.31.88.88:7891" + NO_PROXY: "localhost,127.0.0.1,.local,*.local" + +permissions: + contents: write + +jobs: + build-windows-gnu: + name: windows-x86_64-gnu + runs-on: Ubuntu_docker_act + + env: + TARGET: x86_64-pc-windows-gnu + BIN: meatshell.exe + CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER: x86_64-w64-mingw32-gcc + CARGO_TARGET_X86_64_PC_WINDOWS_GNU_AR: x86_64-w64-mingw32-ar + CC_x86_64_pc_windows_gnu: x86_64-w64-mingw32-gcc + CXX_x86_64_pc_windows_gnu: x86_64-w64-mingw32-g++ + AR_x86_64_pc_windows_gnu: x86_64-w64-mingw32-ar + WINDRES: x86_64-w64-mingw32-windres + PKG_CONFIG_ALLOW_CROSS: "1" + + steps: + - name: 显示代理配置 + shell: bash + run: | + echo "=== Proxy Configuration ===" + echo "HTTP_PROXY: ${HTTP_PROXY}" + echo "HTTPS_PROXY: ${HTTPS_PROXY}" + echo "NO_PROXY: ${NO_PROXY}" + echo "==========================" + + - name: 测试代理连通性 + shell: bash + run: | + echo "Testing proxy connectivity..." + if curl -x "${HTTP_PROXY}" -I --connect-timeout 10 https://google.com 2>&1 | grep -q "HTTP"; then + echo "✅ Proxy is working!" + else + echo "⚠️ Proxy test failed, but continuing..." + fi + + - name: 配置 Git 全局代理 + shell: bash + run: | + git config --global http.proxy ${HTTP_PROXY} + git config --global https.proxy ${HTTPS_PROXY} + git config --global http.sslVerify false + git config --global https.sslVerify false + echo "✅ Git proxy configured" + + - name: 配置 Cargo 代理 + shell: bash + run: | + mkdir -p ~/.cargo + printf '[http]\n' > ~/.cargo/config.toml + printf 'proxy = "%s"\n' "${HTTP_PROXY}" >> ~/.cargo/config.toml + printf 'check-revoke = false\n\n' >> ~/.cargo/config.toml + printf '[https]\n' >> ~/.cargo/config.toml + printf 'proxy = "%s"\n' "${HTTPS_PROXY}" >> ~/.cargo/config.toml + printf 'check-revoke = false\n\n' >> ~/.cargo/config.toml + printf '[net]\n' >> ~/.cargo/config.toml + printf 'git-fetch-with-cli = true\n' >> ~/.cargo/config.toml + printf 'retry = 10\n' >> ~/.cargo/config.toml + echo "✅ Cargo proxy configured" + cat ~/.cargo/config.toml + + - name: 配置 Cargo 环境变量 + shell: bash + run: | + echo "CARGO_HTTP_PROXY=${HTTP_PROXY}" >> $GITHUB_ENV + echo "CARGO_HTTPS_PROXY=${HTTPS_PROXY}" >> $GITHUB_ENV + echo "CARGO_HTTP_SSL_VERIFY=false" >> $GITHUB_ENV + echo "CARGO_HTTPS_SSL_VERIFY=false" >> $GITHUB_ENV + + - name: 配置 curl 默认代理 + shell: bash + run: | + mkdir -p ~/.curl + printf 'proxy = %s\n' "${HTTP_PROXY}" > ~/.curlrc + printf 'insecure\n' >> ~/.curlrc + echo "✅ curl proxy configured" + + - uses: actions/checkout@v4 + with: + clean: false + + - name: Cache Cargo registry and target + uses: actions/cache@v3 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-${{ env.TARGET }}-cargo-${{ hashFiles('Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-${{ env.TARGET }}-cargo- + ${{ runner.os }}-cargo- + + - name: Configure local Cargo cache + shell: bash + run: | + set -eu + CACHE_ROOT="${MEATSHELL_CI_CACHE:-$PWD/.cache/ci}" + case "$CACHE_ROOT" in + /*) ;; + *) CACHE_ROOT="$PWD/$CACHE_ROOT" ;; + esac + mkdir -p "$CACHE_ROOT/cargo" "$CACHE_ROOT/target" + echo "CARGO_HOME=$CACHE_ROOT/cargo" >> "$GITHUB_ENV" + echo "CARGO_TARGET_DIR=$CACHE_ROOT/target" >> "$GITHUB_ENV" + echo "CARGO_NET_RETRY=10" >> "$GITHUB_ENV" + echo "Using Cargo cache root: $CACHE_ROOT" + + if [ -d "$CACHE_ROOT/target/$TARGET/release" ]; then + echo "✅ Cache hit! Previous build artifacts found." + echo "Release binary size: $(du -sh "$CACHE_ROOT/target/$TARGET/release/$BIN" 2>/dev/null | cut -f1 || echo 'not built yet')" + else + echo "❌ Cache miss or first run. Will perform full build." + fi + + - name: 清理可能损坏的缓存 + shell: bash + run: | + if [ -d ~/.cargo/registry/index/github.com-* ]; then + echo "Cleaning corrupted cargo registry cache..." + rm -rf ~/.cargo/registry/index/github.com-* + fi + + - name: Show toolchain + shell: bash + run: | + set -eu + rustc --version + cargo --version + x86_64-w64-mingw32-gcc --version | head -n 1 + x86_64-w64-mingw32-windres --version | head -n 1 + + - name: Ensure Rust target + shell: bash + run: rustup target add "$TARGET" + + - name: Update crates.io index with retry + shell: bash + run: | + for i in 1 2 3 4 5; do + echo "Attempt $i to fetch dependencies..." + if cargo fetch 2>&1; then + echo "✅ Cargo fetch succeeded on attempt $i" + break + fi + if [ $i -eq 5 ]; then + echo "❌ Cargo fetch failed after 5 attempts" + exit 1 + fi + echo "Waiting 10 seconds before retry..." + sleep 10 + done + + - name: UI terminal smoke test + shell: bash + run: | + set -eu + BUILD_DIR="${CARGO_TARGET_DIR:-target}" + cargo build + rm -f "$BUILD_DIR/debug/meatshell-debug.log" meatshell-debug.log + SMOKE_OUTPUT="$BUILD_DIR/debug/meatshell-smoke.out" + set +e + MEATSHELL_SMOKE_TERMINAL=1 timeout 8s xvfb-run -a "$BUILD_DIR/debug/meatshell" >"$SMOKE_OUTPUT" 2>&1 + code=$? + set -e + if [ "$code" -ne 0 ]; then + cat "$SMOKE_OUTPUT" + if [ -f "$BUILD_DIR/debug/meatshell-debug.log" ]; then + cat "$BUILD_DIR/debug/meatshell-debug.log" + fi + exit "$code" + fi + if [ -f "$BUILD_DIR/debug/meatshell-debug.log" ]; then + if grep -E "ERROR|panic|Recursion detected" "$BUILD_DIR/debug/meatshell-debug.log"; then + cat "$SMOKE_OUTPUT" + cat "$BUILD_DIR/debug/meatshell-debug.log" + exit 1 + fi + fi + + - name: Build Windows GNU release + shell: bash + run: | + set -eu + if command -v build-win-gnu >/dev/null 2>&1; then + build-win-gnu + else + cargo build --release --target "$TARGET" + fi + + - name: Package Windows zip + shell: bash + env: + EVENT_NAME: ${{ gitea.event_name }} + REF: ${{ gitea.ref }} + REF_NAME: ${{ gitea.ref_name }} + run: | + set -eu + BUILD_DIR="${CARGO_TARGET_DIR:-target}" + event_name="${EVENT_NAME:-${GITHUB_EVENT_NAME:-}}" + ref="${REF:-${GITHUB_REF:-}}" + ref_name="${REF_NAME:-${GITHUB_REF_NAME:-dev}}" + short_sha="$(git rev-parse --short=12 HEAD)" + if [ "$event_name" = "push" ] && [ "$ref" = "refs/tags/${ref_name}" ]; then + VERSION="${ref_name}-${short_sha}" + RELEASE_TAG="${ref_name}" + elif [ "$event_name" = "push" ] && [ "$ref" != "refs/heads/main" ]; then + VERSION="${ref_name}-${short_sha}" + RELEASE_TAG="${ref_name}" + else + BUILD_STAMP="$(date -u +%Y%m%d-%H%M%S)" + VERSION="build-${BUILD_STAMP}-${short_sha}" + RELEASE_TAG="$VERSION" + fi + RELEASE_NAME="$RELEASE_TAG" + STAGE="meatshell-${VERSION}-windows-x86_64-gnu" + mkdir -p "$STAGE" + cp "${BUILD_DIR}/${TARGET}/release/${BIN}" "$STAGE/" + cp README.md README.en.md CHANGELOG.md "$STAGE/" 2>/dev/null || true + if command -v 7z >/dev/null 2>&1; then + 7z a "${STAGE}.zip" "$STAGE" >/dev/null + else + zip -r "${STAGE}.zip" "$STAGE" + fi + echo "ASSET=${STAGE}.zip" >> "$GITHUB_ENV" + echo "RELEASE_TAG=${RELEASE_TAG}" >> "$GITHUB_ENV" + echo "RELEASE_NAME=${RELEASE_NAME}" >> "$GITHUB_ENV" + + - name: Select release token + shell: bash + env: + RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + WORKFLOW_TOKEN: ${{ gitea.token }} + run: | + set -eu + token="${RELEASE_TOKEN:-${GITEA_TOKEN:-${WORKFLOW_TOKEN:-}}}" + if [ -z "$token" ]; then + echo "No release token available; set RELEASE_TOKEN or GITEA_TOKEN." >&2 + exit 1 + fi + echo "::add-mask::$token" + echo "RELEASE_API_KEY=$token" >> "$GITHUB_ENV" + + - name: Publish Gitea release + shell: bash + env: + API_TOKEN: ${{ env.RELEASE_API_KEY }} + RELEASE_TAG_ENV: ${{ env.RELEASE_TAG }} + RELEASE_NAME_ENV: ${{ env.RELEASE_NAME }} + EVENT_NAME: ${{ gitea.event_name }} + REF: ${{ gitea.ref }} + REF_NAME: ${{ gitea.ref_name }} + RUN_ID: ${{ gitea.run_id }} + RUN_NUMBER: ${{ gitea.run_number }} + GITEA_SERVER_URL_CTX: ${{ gitea.server_url }} + GITEA_REPOSITORY_CTX: ${{ gitea.repository }} + GITHUB_SERVER_URL_CTX: ${{ github.server_url }} + GITHUB_REPOSITORY_CTX: ${{ github.repository }} + run: | + set -euo pipefail + asset="${ASSET:?ASSET is not set}" + event_name="${EVENT_NAME:-${GITHUB_EVENT_NAME:-}}" + ref="${REF:-${GITHUB_REF:-}}" + if [ -n "${RELEASE_TAG_ENV:-}" ]; then + tag="$RELEASE_TAG_ENV" + release_name="${RELEASE_NAME_ENV:-$tag}" + release_body="Automated Windows GNU build for ${tag}." + target_commitish="$(git rev-parse HEAD)" + elif [ "$event_name" = "push" ] && [ "$ref" = "refs/heads/main" ]; then + short_sha="$(git rev-parse --short=12 HEAD)" + stamp="$(date -u +%Y%m%d-%H%M%S)" + tag="build-${stamp}-${short_sha}" + release_name="$tag" + release_body="Automated Windows GNU build for main commit ${short_sha}." + target_commitish="$(git rev-parse HEAD)" + elif [ "$event_name" = "workflow_dispatch" ]; then + short_sha="$(git rev-parse --short=12 HEAD)" + run_id="${RUN_ID:-${GITHUB_RUN_ID:-${RUN_NUMBER:-${GITHUB_RUN_NUMBER:-$(date +%Y%m%d%H%M%S)}}}}" + tag="manual-${run_id}" + release_name="$tag" + release_body="Manual Windows GNU build for commit ${short_sha}." + target_commitish="$(git rev-parse HEAD)" + else + tag="${REF_NAME:-${GITHUB_REF_NAME:-}}" + release_name="$tag" + release_body="Automated Windows GNU build for ${tag}." + target_commitish="$(git rev-parse HEAD)" + fi + token="${API_TOKEN:-${RELEASE_API_KEY:-}}" + server="${GITEA_SERVER_URL_CTX:-${GITHUB_SERVER_URL_CTX:-${GITHUB_SERVER_URL:-}}}" + repo="${GITEA_REPOSITORY_CTX:-${GITHUB_REPOSITORY_CTX:-${GITHUB_REPOSITORY:-}}}" + + if [ -z "$token" ]; then + echo "RELEASE_API_KEY is not set." >&2 + exit 1 + fi + if [ -z "$tag" ]; then + echo "Tag name is empty." >&2 + exit 1 + fi + if [ ! -f "$asset" ]; then + echo "Release asset not found: $asset" >&2 + exit 1 + fi + + if [ -z "$server" ] || [ -z "$repo" ]; then + read -r server repo < <(python3 - <<'PY' + import re + import subprocess + from urllib.parse import urlparse + + remote = subprocess.check_output( + ["git", "remote", "get-url", "origin"], text=True + ).strip() + if remote.startswith(("http://", "https://")): + parsed = urlparse(remote) + path = parsed.path.strip("/") + if path.endswith(".git"): + path = path[:-4] + print(f"{parsed.scheme}://{parsed.netloc} {path}") + else: + match = re.match(r"[^@]+@([^:]+):(.+)$", remote) + if not match: + raise SystemExit(f"cannot parse git remote: {remote}") + path = match.group(2) + if path.endswith(".git"): + path = path[:-4] + print(f"https://{match.group(1)} {path}") + PY + ) + fi + + server="${server%/}" + api="$server/api/v1/repos/$repo" + auth_header="Authorization: token $token" + + curl_api() { + attempt=1 + while true; do + if curl --proxy "${HTTP_PROXY:-}" \ + --insecure \ + --fail --silent --show-error --location \ + --connect-timeout 20 --max-time 180 \ + -H "$auth_header" "$@"; then + return 0 + fi + if [ "$attempt" -ge 8 ]; then + return 1 + fi + sleep $((attempt * 3)) + attempt=$((attempt + 1)) + done + } + + curl_status() { + out="$1" + shift + attempt=1 + while true; do + status="$( + curl --proxy "${HTTP_PROXY:-}" \ + --insecure \ + --silent --show-error --location \ + --connect-timeout 20 --max-time 180 \ + -H "$auth_header" \ + -o "$out" -w "%{http_code}" \ + "$@" || true + )" + if [ -n "$status" ] && [ "$status" != "000" ]; then + printf '%s' "$status" + return 0 + fi + if [ "$attempt" -ge 8 ]; then + printf '%s' "${status:-000}" + return 1 + fi + sleep $((attempt * 3)) + attempt=$((attempt + 1)) + done + } + + echo "Checking Gitea API: $server/api/v1/version" + curl_api "$server/api/v1/version" >/dev/null + + release_json="$(mktemp)" + status="$(curl_status "$release_json" "$api/releases/tags/$tag" || true)" + if [ "$status" = "404" ]; then + payload="$( + TAG="$tag" RELEASE_NAME="$release_name" RELEASE_BODY="$release_body" TARGET_COMMITISH="$target_commitish" python3 - <<'PY' + import json + import os + + tag = os.environ["TAG"] + print(json.dumps({ + "tag_name": tag, + "target_commitish": os.environ["TARGET_COMMITISH"], + "name": os.environ["RELEASE_NAME"], + "body": os.environ["RELEASE_BODY"], + "draft": False, + "prerelease": False, + })) + PY + )" + status="$( + curl_status "$release_json" \ + -H "Content-Type: application/json" \ + -X POST -d "$payload" \ + "$api/releases" || true + )" + fi + + if [ "$status" != "200" ] && [ "$status" != "201" ]; then + echo "Release lookup/create failed with HTTP $status" >&2 + cat "$release_json" >&2 || true + exit 1 + fi + + release_id="$(python3 - "$release_json" <<'PY' + import json + import sys + + with open(sys.argv[1], encoding="utf-8") as fh: + print(json.load(fh)["id"]) + PY + )" + + update_payload="$( + RELEASE_NAME="$release_name" RELEASE_BODY="$release_body" python3 - <<'PY' + import json + import os + + print(json.dumps({ + "name": os.environ["RELEASE_NAME"], + "body": os.environ["RELEASE_BODY"], + "draft": False, + "prerelease": False, + })) + PY + )" + curl_api -H "Content-Type: application/json" \ + -X PATCH -d "$update_payload" \ + "$api/releases/$release_id" >/dev/null + + asset_name="$(basename "$asset")" + asset_id="$( + ASSET_NAME="$asset_name" python3 - "$release_json" <<'PY' + import json + import os + import sys + + with open(sys.argv[1], encoding="utf-8") as fh: + release = json.load(fh) + assets = release.get("assets") or release.get("attachments") or [] + for item in assets: + if item.get("name") == os.environ["ASSET_NAME"]: + print(item["id"]) + break + PY + )" + if [ -n "$asset_id" ]; then + echo "Deleting existing release asset: $asset_name" + curl_api -X DELETE "$api/releases/$release_id/assets/$asset_id" >/dev/null + fi + + asset_query="$( + ASSET_NAME="$asset_name" python3 - <<'PY' + import os + import urllib.parse + + print(urllib.parse.quote(os.environ["ASSET_NAME"])) + PY + )" + echo "Uploading release asset: $asset_name" + curl_api -X POST \ + -F "attachment=@${asset}" \ + "$api/releases/$release_id/assets?name=$asset_query" >/dev/null + + echo "✅ Release published successfully through proxy" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..95e50b4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +/target +Cargo.lock +*.rs.bk +meatshell-debug.log +release-*.log +.DS_Store +/.vscode +/.idea +/.claude +/.cache diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..16cb98c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,192 @@ +# Changelog / 更新日志 + +All notable changes are documented here. 本文件记录所有重要变更。 +中英对照(English first, 中文在后). + +## [Unreleased] + +### Added / 新增 + +- **Confirmation prompt before deleting a remote file (#28).** SFTP delete is + irreversible (there is no trash), so the context-menu *Delete* now asks for + confirmation — showing the full path — before removing anything; a misclick + no longer silently destroys a file. + **删除远程文件前先确认 (#28)。** SFTP 删除不可撤销(没有回收站),右键菜单的 + 「删除」现在会先弹出确认框(显示完整路径)再执行,误点不会再悄悄删掉文件。 + +- **Serial port sessions (#14, #17).** New session type for connecting to + switches, routers and embedded devices over a serial console. Pick + **Serial** in the session dialog and set the port (`COM3`, `/dev/ttyUSB0`), + baud rate, data/stop bits, parity and flow control. The serial line reuses + the full terminal pipeline (output, input, scrollback, copy/paste); SFTP and + the resource monitor are not applicable and are hidden. + **串口会话 (#14, #17)。** 新增串口会话类型,用于通过串口控制台连接交换机、 + 路由器和嵌入式设备。在会话对话框选择 **串口**,填写串口号(`COM3`、 + `/dev/ttyUSB0`)、波特率、数据/停止位、校验位和流控。串口复用完整的终端管线 + (输出、输入、回滚、复制粘贴);SFTP 和资源监控不适用,已隐藏。 + +- **Telnet sessions (#17).** New session type for legacy gear that only speaks + Telnet. Handles RFC 854 option negotiation (suppress-go-ahead / echo / + window-size), strips IAC sequences from the stream, and tunnels through the + same SOCKS5 / HTTP proxy as SSH when configured. + **Telnet 会话 (#17)。** 新增 Telnet 会话类型,用于只支持 Telnet 的老旧设备。 + 处理 RFC 854 选项协商(抑制 Go-Ahead / 回显 / 窗口大小),从数据流中剥离 IAC + 序列,并可经与 SSH 相同的 SOCKS5 / HTTP 代理隧道连接。 + +### Performance / 性能 + +- **Pipelined SFTP upload (#16).** Uploads now keep ~32 WRITE requests in flight + on a dedicated SFTP channel instead of writing one chunk and waiting for each + ack, hiding the round-trip latency that made transfers ~15x slower than `scp`. + Out-of-order completion is safe (every chunk carries its absolute offset). + **SFTP 上传流水线化 (#16)。** 上传改为在专用 SFTP 通道上保持约 32 个 WRITE 请求 + 并发在途,而不是写一块等一块的 ack,消除了让传输比 `scp` 慢约 15 倍的往返延迟。 + 乱序完成也安全(每块都带绝对偏移)。 + +### Fixed / 修复 + +- **Dragging the SFTP panel up no longer clears terminal output (#18).** vt100's + shrink truncated the grid from the bottom, dropping the most recent output; + before shrinking we now save the top rows to scrollback and scroll so the + bottom (recent) rows stay visible. Two follow-ups: (1) the shrink now only + scrolls off as many rows as needed to keep the cursor visible, so rapid + up/down dragging on a not-yet-full screen no longer pushes the prompt into + scrollback and strands the cursor at the top (also reported as #24); (2) drag-selection is now stored + in absolute scrollback coordinates, so selecting from the top of the history + down through several screens copies every line instead of losing everything + above the final window when the view auto-scrolls. + **上拉 SFTP 面板不再清空终端输出 (#18)。** vt100 缩小时从底部截断,丢掉最近输出; + 现在缩小前把顶部行存入回滚区并滚动,使底部(最近)行保持可见。两处后续修复: + (1) 缩小时只滚走"保持光标可见所需"的行数,疯狂上下拖动未填满的屏幕时不再把 + 提示符推进回滚区、光标卡在顶部;(2) 拖选改用绝对回滚坐标存储,从历史顶部往下 + 跨多屏选择时能复制到每一行,而不是在视图自动滚动后丢掉最后一屏以上的内容。 + +### Security / 安全 + +- **Harden the remote resource monitor against a hostile server (#27).** The + monitor runs a small loop over an SSH exec channel. It now (1) resets `PATH` + to the standard system dirs so a server with a hijacked `PATH`/`BASH_ENV` + can't shadow `awk`/`cat`/`df`/`sleep`; (2) caps the reassembly buffer at 1 MiB + so a server that streams data without the sync marker can't exhaust memory; + and (3) parses `/proc` and `df` output with saturating arithmetic and a + 64-row cap per sample, so crafted huge values or a flood of fake interfaces + can't overflow-panic or swamp the sidebar. + **加固远程资源监控以防恶意服务器 (#27)。** 监控通过 SSH exec 通道跑一个小循环。 + 现在:(1) 重置 `PATH` 为标准系统目录,使被劫持 `PATH`/`BASH_ENV` 的服务器无法 + 替换 `awk`/`cat`/`df`/`sleep`;(2) 重组缓冲上限 1 MiB,防止只发数据不发同步标记 + 的服务器耗尽内存;(3) 解析 `/proc` 与 `df` 输出改用饱和运算并对每次采样限 64 行, + 使构造的超大数值或伪造网卡洪流无法触发溢出 panic 或拖垮侧栏。 + +- **Sanitize remote file names before saving downloads (#26).** SFTP downloads + built the local path straight from the server-supplied name, so a malicious + server could use path separators, shell-special characters or a Windows + reserved device name (`CON`, `NUL`, `COM1`…) to write outside the chosen + folder or hit a device. Downloads now run the name through `sanitize_filename` + (already used by the open/edit flow), which also gained reserved-device-name + and leading-whitespace handling. + **保存下载前清洗远程文件名 (#26)。** SFTP 下载直接用服务器给的文件名拼本地路径, + 恶意服务器可借路径分隔符、shell 特殊字符或 Windows 保留设备名(`CON`、`NUL`、 + `COM1`…)写到目标目录之外或命中设备。现在下载会先经 `sanitize_filename` + (查看/编辑流程已在用)清洗,并新增了保留设备名与前导空白的处理。 + +- **Stop logging raw keystroke bytes (#15).** Debug logs recorded the hex of SSH + input, which could include passwords; now they record only the byte length. + A follow-up found two more leak sites in the key handler: `send_key` logged + the raw key string (`key={:?}`) at debug level, and the `[KEY_DIAG]` IME + diagnostic logged each Shift-typed key's code point at **info** level (no + `RUST_LOG` needed) — both could expose password characters. They now go + through a `redact_key` helper that reveals only C0/C1 control codes (what the + IME diagnostics actually need) and masks every printable character. + **不再记录原始按键字节 (#15)。** debug 日志原本记录 SSH 输入的十六进制(可能含 + 密码),现在只记录字节长度。后续又发现按键处理里还有两处泄露:`send_key` 以 + debug 级打印按键原文(`key={:?}`),`[KEY_DIAG]` IME 诊断更是以 **info 级** + (无需 `RUST_LOG`)打印每个带 Shift 按键的码位——都可能暴露密码字符。现在统一 + 经 `redact_key` 处理,只保留 C0/C1 控制码(IME 诊断真正需要的),可打印字符一律掩码。 + +## [0.2.3] - 2026-06-05 + +### Added / 新增 + +- **Proxy support for SSH / SFTP (#7).** Connections can tunnel through a + **SOCKS5** (`socks5://`) or **HTTP CONNECT** (`http://`) proxy, with optional + `user:pass@` credentials. Set it per session in the dialog, or leave it blank + to use the `$ALL_PROXY` environment variable; empty = direct. + **SSH / SFTP 代理支持 (#7)。** 连接可经 **SOCKS5**(`socks5://`)或 + **HTTP CONNECT**(`http://`)代理(支持 `user:pass@` 认证)。会话对话框里按需 + 填写,留空则用 `$ALL_PROXY` 环境变量,再空则直连。 + +- **Import hosts from `~/.ssh/config` (#1).** The "Import ~/.ssh/config" action + (in the settings menu) parses the standard SSH config (`Host` / `HostName` / + `User` / `Port` / `IdentityFile`, wildcard `Host *` blocks skipped) and adds + each host as a session, skipping duplicates. Hosts with an `IdentityFile` + default to key auth. + **从 `~/.ssh/config` 导入主机 (#1)。** 设置菜单里的「导入 ~/.ssh/config」解析 + 标准 SSH 配置(`Host` / `HostName` / `User` / `Port` / `IdentityFile`,跳过 + `Host *` 通配块),将每个主机加为会话并跳过重复;带 `IdentityFile` 的默认用密钥。 + +- **GitHub Actions release workflow** building native binaries for Windows / + Linux / macOS (arm64 + x86_64) on each `v*` tag. + **GitHub Actions 发布工作流**,每个 `v*` 标签自动构建 Windows / Linux / + macOS(arm64 + x86_64)三平台二进制。 + +### Fixed / 修复 + +- The full-width `+` before "New session" rendered as a tofu box in English; + switched to an ASCII `+`. + 英文下「New session」前的全角 `+` 显示为豆腐块,改用 ASCII `+`。 + +- `install-linux.sh` now auto-detects the `meatshell` binary sitting next to it + in a release package, so it works with no arguments (it previously defaulted to + the source-tree `./target/release` path and failed for end users). + `install-linux.sh` 现在自动识别发布包里同目录的 `meatshell`,无需传参即可使用 + (之前默认指向源码树的 `./target/release`,普通用户直接跑会报错)。 + +## [0.2.2] - 2026-06-05 + +### Security / 安全 + +- **Fix Windows command injection (#12)** — `open_with_os` no longer shells out + via `cmd /C start`; it calls `ShellExecuteW` directly so a malicious remote + file name (e.g. `foo&calc.exe`) can't inject commands. Added `sanitize_filename` + as defence-in-depth. + **修复 Windows 命令注入 (#12)** —— 打开文件不再经 `cmd /C start`,改用 + `ShellExecuteW` 直接打开,恶意远程文件名(如 `foo&calc.exe`)无法注入命令; + 并新增 `sanitize_filename` 清洗作为纵深防御。 + +- **Stop echoing the saved password when editing a session (#10)** — the field + is left blank with a "leave blank to keep" hint; an empty field on save keeps + the existing password. + **编辑会话时不再回显已保存密码 (#10)** —— 密码框留空并提示「留空则不修改」, + 保存时为空则保留原密码。 + +- **Zero passwords in memory on drop (#8)** — passwords now use a `Secret` type + (`zeroize`) that wipes its heap buffer on drop and redacts itself in logs; the + on-disk JSON format is unchanged. + **密码内存清零 (#8)** —— 密码改用 `Secret` 类型(`zeroize`),Drop 时清零堆 + 内存、日志中脱敏;磁盘 JSON 格式不变。 + +### Added / 新增 + +- **Internationalization — Chinese / English with runtime switching (#9).** + Static UI uses Slint `@tr` + bundled `.po`; dynamic Rust strings use a `t()` + helper. Switch via the gear menu; the choice is persisted and the default + follows the system locale. + **国际化 —— 中 / 英双语,运行时实时切换 (#9)。** 静态界面用 Slint `@tr` + + bundled `.po`;Rust 动态文本用 `t()`。设置菜单里切换,选择会持久化,首次启动 + 跟随系统语言。 + +- **Private-key file picker** in the session dialog, plus `.pub` fallback (auto + strips the suffix to load the matching private key) and uniform `/` path + separators across platforms. + **会话弹窗的私钥文件选择器**,并支持 `.pub` 容错(自动去后缀加载对应私钥)、 + 路径分隔符统一为 `/`。 + +- **Linux desktop integration** — `assets/meatshell.desktop` + `install-linux.sh` + and an `xdg_app_id` so the GNOME/Ubuntu dock shows the app icon on Wayland. + **Linux 桌面集成** —— `assets/meatshell.desktop` + `install-linux.sh`,并设置 + `xdg_app_id`,使 Wayland 下 GNOME/Ubuntu 任务栏显示应用图标。 + +- **Screenshots in the README** (`docs/screenshots/`, sensitive info redacted). + **README 增加截图**(`docs/screenshots/`,敏感信息已打码)。 + +[0.2.2]: https://github.com/jeff141/meatshell/releases/tag/v0.2.2 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e1a002c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,66 @@ +# 参与 meatshell / Contributing to meatshell + +> [English version below](#english) + +## 中文 + +感谢你对 meatshell 的关注!🙏 + +meatshell 目前是一个**个人实验性项目**。它的设计、取舍和代码风格都由我一个人把控,目的是保持产品方向的一致性,做一个**轻量、专注的 FinalShell 风格 SSH 客户端**——不做成大而全的工具。 + +### 关于 Pull Request + +为了保持这份一致性,**本项目暂不接受外部 Pull Request**。 + +这不是因为你的代码不够好,而是: + +- 我希望每一行代码都经过自己的理解和取舍,避免项目逐渐偏离最初的设计意图; +- 维护一个能完全掌控的小项目,对我个人而言比快速扩张更重要。 + +如果你已经提交了 PR,我可能会在阅读后礼貌关闭,但**你的想法我都会认真看**——很多改进我会用自己的方式重新实现。 + +### 欢迎你这样参与 + +- 🐛 **报告 Bug**:在 [Issues](https://github.com/jeff141/meatshell/issues) 里描述复现步骤、系统环境和期望行为。 +- 💡 **提出建议**:功能想法、交互改进、设计参考都欢迎在 Issue 区讨论。 +- ⭐ **Star / Fork**:如果你喜欢它,欢迎 fork 出自己的版本自由折腾。 + +清晰的 Issue 对我的帮助,往往不亚于一个 PR。再次感谢! + +--- + + +## English + +Thanks for your interest in meatshell! 🙏 + +meatshell is currently a **personal, experimental project**. Its design, +trade-offs, and code style are all maintained by a single person, on purpose — +the goal is a **lightweight, focused, FinalShell-style SSH client**, not an +all-in-one tool. + +### About Pull Requests + +To keep that consistency, **this project does not accept external Pull +Requests at this time.** + +It's not that your code isn't good enough — rather: + +- I want to personally understand and own every line of code, so the project + doesn't gradually drift away from its original intent; +- For me, keeping a small project I fully control matters more than growing fast. + +If you've already opened a PR, I may close it politely after reading, but +**I do read every idea** — and I'll often re-implement worthwhile improvements +in my own way. + +### How you're very welcome to help + +- 🐛 **Report bugs**: open an [Issue](https://github.com/jeff141/meatshell/issues) + with repro steps, your OS/environment, and expected behavior. +- 💡 **Suggest ideas**: feature requests, UX improvements, and design references + are all welcome in the issue tracker. +- ⭐ **Star / Fork**: if you like it, feel free to fork your own version and + hack away. + +A clear issue often helps me as much as a PR would. Thank you! diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..6dbb690 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,77 @@ +[package] +name = "meatshell" +version = "0.2.7" +edition = "2021" +authors = ["meatshell contributors"] +description = "A lightweight FinalShell-style SSH/terminal client written in Rust + Slint" +license = "MIT OR Apache-2.0" +repository = "https://github.com/your/meatshell" +readme = "README.md" +rust-version = "1.75" + +[dependencies] +slint = { version = "1.8", features = ["compat-1-2"] } +# Direct access to the winit window/events (OS file-drop for drag-and-drop upload). +i-slint-backend-winit = "1.16" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1" +thiserror = "1" +directories = "5" +chrono = { version = "0.4", features = ["serde"] } +uuid = { version = "1", features = ["v4", "serde"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "io-util", "net", "time", "fs"] } +russh = "0.49" +russh-keys = "0.49" +ssh-key = "0.6" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +rand = "0.8" +async-trait = "0.1" +futures = "0.3" +vt100 = "0.15" +unicode-width = "0.2" +russh-sftp = "2" +rfd = "0.15" +arboard = "3" +# Zeroes password buffers on drop so plaintext credentials don't linger in +# freed heap memory (core dumps / debuggers / /proc/pid/mem). +zeroize = "1" +# SOCKS5 proxy tunnelling for SSH/SFTP in network-restricted environments. +# (HTTP CONNECT proxies are handled in src/proxy.rs without an extra crate.) +tokio-socks = "0.5" +base64 = "0.22" +# Cross-platform serial port access for the Serial session type (#14, #17). +# Opens COM3 / /dev/ttyUSB0 etc.; on Linux the default `libudev` feature is +# used for port enumeration (CI installs libudev-dev). +serialport = "4" +# ChaCha20-Poly1305 AEAD for encrypting passwords at rest in sessions.json. +# rand (OsRng) and base64 are already pulled in by other deps above. +chacha20poly1305 = "0.10" +# Detect whether the OS is running in dark or light mode. +# Used on startup to honour the system preference before the user overrides it. +dark-light = "1" +# Runtime background image decoding/blur. Used for png/jpg/jpeg/webp user +# backgrounds before handing pixels to Slint. +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] } + +# Decode the embedded PNG icon to set the window icon at runtime (Linux only). +# On Windows the icon comes from the .ico embedded by winresource at link time; +# on macOS it comes from the app bundle — neither needs runtime decoding. + +[build-dependencies] +slint-build = "1.8" +# Embeds assets/meatshell.ico into the .exe so Explorer / taskbar / shortcuts +# show the app icon. Kept as an unconditional build-dependency because build.rs +# runs on the host; Linux -> Windows cross builds still need this crate. +winresource = "0.1" + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 1 +strip = "symbols" +panic = "unwind" + +[profile.dev] +opt-level = 1 diff --git a/Dockerfile.Miu_v1 b/Dockerfile.Miu_v1 new file mode 100644 index 0000000..bf03d62 --- /dev/null +++ b/Dockerfile.Miu_v1 @@ -0,0 +1,111 @@ +# Gitea act runner image for meatshell. +# +# This is still a Linux container. It can build the Windows GNU target +# (`x86_64-pc-windows-gnu`) with MinGW, and can run the resulting .exe through +# Wine for simple smoke tests. It is not a replacement for a real Windows/MSVC +# runner (`x86_64-pc-windows-msvc`). + +FROM ghcr.io/catthehacker/ubuntu:full-latest + +USER root +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +ENV DEBIAN_FRONTEND=noninteractive + +# Use Tsinghua Ubuntu mirrors, matching the base image codename instead of +# hard-coding noble. +RUN . /etc/os-release \ + && { \ + echo "deb https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ ${VERSION_CODENAME} main restricted universe multiverse"; \ + echo "deb https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ ${VERSION_CODENAME}-updates main restricted universe multiverse"; \ + echo "deb https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ ${VERSION_CODENAME}-backports main restricted universe multiverse"; \ + echo "deb https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ ${VERSION_CODENAME}-security main restricted universe multiverse"; \ + } > /etc/apt/sources.list + +# Build tools: +# - Linux GUI/dev libs match the official release workflow's Linux build deps. +# - MinGW provides the Windows GNU linker, archiver and windres. +# - Wine + Xvfb let cargo run/test Windows binaries via a virtual display. +RUN dpkg --add-architecture i386 \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + git \ + build-essential \ + pkg-config \ + cmake \ + clang \ + lld \ + libfontconfig1-dev \ + libfreetype6-dev \ + libxcb1-dev \ + libxcb-render0-dev \ + libxcb-shape0-dev \ + libxcb-xfixes0-dev \ + libxkbcommon-dev \ + libxkbcommon-x11-dev \ + libwayland-dev \ + libgl1-mesa-dev \ + libegl1-mesa-dev \ + libgtk-3-dev \ + libudev-dev \ + mingw-w64 \ + gcc-mingw-w64-x86-64 \ + g++-mingw-w64-x86-64 \ + binutils-mingw-w64-x86-64 \ + xvfb \ + x11-utils \ + x11-xserver-utils \ + wine64 \ + wine32 \ + wine \ + wine32:i386 \ + fonts-wine \ + p7zip-full \ + zip \ + unzip \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Rust toolchain. Use rustup so the Windows target can be installed cleanly. +ENV RUSTUP_DIST_SERVER=https://mirrors.tuna.tsinghua.edu.cn/rustup +ENV RUSTUP_UPDATE_ROOT=https://mirrors.tuna.tsinghua.edu.cn/rustup/rustup +ENV RUSTUP_HOME=/root/.rustup +ENV CARGO_HOME=/root/.cargo +ENV PATH=/root/.cargo/bin:${PATH} + +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --profile minimal --default-toolchain stable \ + && rustup target add x86_64-pc-windows-gnu x86_64-unknown-linux-gnu \ + && rustc --version \ + && cargo --version + +# Cross-compilation configuration for cargo and build scripts. +ENV CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc +ENV CARGO_TARGET_X86_64_PC_WINDOWS_GNU_AR=x86_64-w64-mingw32-ar +ENV CC_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc +ENV CXX_x86_64_pc_windows_gnu=x86_64-w64-mingw32-g++ +ENV AR_x86_64_pc_windows_gnu=x86_64-w64-mingw32-ar +ENV WINDRES=x86_64-w64-mingw32-windres +ENV PKG_CONFIG_ALLOW_CROSS=1 + +# Wine runtime setup for optional smoke tests. +ENV DISPLAY=:99 +ENV WINEDLLOVERRIDES=winemenubuilder.exe=d +ENV WINEDEBUG=-all +ENV CARGO_TARGET_X86_64_PC_WINDOWS_GNU_RUNNER=/usr/local/bin/wine-xvfb + +RUN wine --version \ + && xvfb-run -a wineboot -u \ + && printf '%s\n' '#!/usr/bin/env bash' 'exec xvfb-run -a wine "$@"' \ + > /usr/local/bin/wine-xvfb \ + && chmod +x /usr/local/bin/wine-xvfb \ + && printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'set -euo pipefail' \ + 'cargo build --release --target x86_64-pc-windows-gnu "$@"' \ + > /usr/local/bin/build-win-gnu \ + && chmod +x /usr/local/bin/build-win-gnu + +WORKDIR /workspace diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..d8300c2 --- /dev/null +++ b/README.en.md @@ -0,0 +1,147 @@ +# meatshell + +[简体中文](./README.md) | **English** + +A lightweight, low-memory SSH / terminal client inspired by FinalShell, but +written entirely in **Rust + [Slint](https://slint.dev)**. The goal is to keep +FinalShell's core experience (resource-monitor sidebar, session management, +tabbed terminals) while cutting memory use from the 400 MB+ of a JVM app down to +the tens-of-MB range of a native binary. + +## Screenshots + +

+ Welcome / session management
+ Welcome page: session management + local resource monitor sidebar +

+ +

+ Terminal + SFTP
+ Tabbed terminal (full-screen btop) + SFTP file browser + remote resource monitoring +

+ +## Download & install + +Every pushed `v*` tag triggers a Gitea Actions build that produces the Windows +GNU package and publishes it on the +[Releases](https://git.miuoo.qzz.io/Miu/meatshell/releases) page. + +### Windows + +Download `meatshell-*-windows-x86_64.zip`, unzip, and run `meatshell.exe`. +At runtime, `meatshell-debug.log` is written next to `meatshell.exe`; check it first for SSH connection crashes or unexpected exits. + +### Linux + +```bash +tar -xzf meatshell-*-linux-x86_64.tar.gz +cd meatshell-*-linux-x86_64 +./meatshell # run it directly +# Optional: install the app icon + launcher entry (shows the icon in the dock / +# app list — no argument needed, it finds the binary next to the script) +chmod +x install-linux.sh && ./install-linux.sh +``` + +> Requires glibc ≥ 2.35 (Ubuntu 22.04+ / Debian 12+). On Wayland you may need to +> log out/in once after installing the icon. + +### macOS + +```bash +tar -xzf meatshell-*-macos-*.tar.gz # aarch64 = Apple Silicon, x86_64 = Intel +xattr -dr com.apple.quarantine meatshell # clear the "unsigned app" Gatekeeper flag +./meatshell +``` + +> To build from source, see [Running](#running) below. + +## Roadmap + +### v0.1 + +- [x] FinalShell-style dark theme UI +- [x] Local system monitor sidebar (CPU / memory / swap / network throughput, 1 Hz) +- [x] Tabs (welcome page + multiple terminal sessions) +- [x] Session management: create / edit / delete, persisted to local JSON + - Config location: `%APPDATA%/meatshell/sessions.json` (Windows) + / `~/.config/meatshell/sessions.json` (Linux) + / `~/Library/Application Support/meatshell/sessions.json` (macOS) +- [x] SSH connection scaffold (`russh`, pure Rust, password + private key) +- [x] Raw PTY input + VT/ANSI terminal view + +### v0.2 + +- [x] Full VT/ANSI terminal emulation (`vt100`/xterm parser, full-screen TUI, scrollback, find, select-copy) +- [x] Remote host resource monitoring (run a remote collector script, like FinalShell) +- [x] SFTP file browser + drag-and-drop upload/download +- [x] Known-hosts (`known_hosts`) verification (TOFU into the app known_hosts, reject changed keys) +- [x] Store session passwords in the OS keychain (encrypted config fallback when unavailable) + +### v0.3+ + +- [x] Split panes for tabbed terminals +- [x] Session groups / folders +- [x] Theme switching (light / follow system) +- [x] Command history & snippet management + +## Tech stack + +| Module | Choice | +| ------------- | ----------------------------------------------------------------- | +| UI | [Slint](https://slint.dev) (compiled pure Rust, no GC) | +| Async runtime | [`tokio`](https://tokio.rs) | +| SSH protocol | [`russh`](https://crates.io/crates/russh) (no libssh dependency) | +| System metrics| [`sysinfo`](https://crates.io/crates/sysinfo) | +| Serialization | `serde` + `serde_json` | +| Logging | `tracing` + `tracing-subscriber` | + +## Running + +```bash +cargo run --release +``` + +On first launch an empty session store is created at +`%APPDATA%/meatshell/sessions.json`. Click **"+ New Session"** in the top-right +to add your first server. + +## Project layout + +``` +meatshell/ +├── Cargo.toml +├── build.rs # Slint compiler entry point +├── ui/ +│ ├── app.slint # top-level window +│ ├── theme.slint # design tokens +│ ├── widgets.slint # reusable buttons / inputs / sparkline +│ ├── sidebar.slint # left-hand system monitor panel +│ ├── tabs.slint # top tab bar +│ ├── welcome.slint # welcome page / quick connect +│ ├── session_dialog.slint # new / edit session dialog +│ └── terminal_view.slint # VT/ANSI terminal view + command bar / SFTP panel +└── src/ + ├── main.rs + ├── app.rs # UI ↔ backend bridge + ├── config.rs # session JSON persistence + ├── keychain.rs # OS keychain password storage + encrypted fallback + ├── known_hosts.rs # OpenSSH known_hosts verification + ├── system.rs # CPU / memory / network sampling + ├── ssh.rs # SSH session worker + └── sftp.rs # SFTP browser / transfer worker +``` + +## Development notes + +- Slint widgets use a strict layout DSL; after editing a `.slint` file, + `cargo check` is the fastest feedback loop. +- The application event loop is single-threaded (required by Slint); all + cross-thread UI updates go through `slint::invoke_from_event_loop` callbacks. +- `known_hosts` uses trust-on-first-use (TOFU): first connections are written + to meatshell's own `known_hosts`; later server-key changes are rejected. +- Session passwords prefer the OS keychain; when no keychain backend is + available, meatshell falls back to local ChaCha20-Poly1305 encrypted config. + +## License + +Dual-licensed under MIT OR Apache-2.0. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6f0c6f4 --- /dev/null +++ b/README.md @@ -0,0 +1,142 @@ +# meatshell + +**简体中文** | [English](./README.en.md) + +一个轻量级、低内存占用的 SSH / 终端客户端,灵感来自 FinalShell,但完全由 +**Rust + [Slint](https://slint.dev)** 实现。目标是保留 FinalShell 的核心体验 +(资源监控侧栏、会话管理、多标签页终端)的同时,把内存占用从 400 MB+ 的 +JVM 压到几十 MB 原生级别。 + +## 截图 + +

+ 欢迎页 / 会话管理
+ 欢迎页:会话管理 + 左侧本机资源监控 +

+ +

+ 终端 + SFTP
+ 多标签页终端(btop 全屏渲染)+ 底部 SFTP 文件浏览 + 远端资源监控 +

+ +## 下载与安装 + +每次推送 `v*` 标签,Gitea Actions 会自动构建 Windows GNU 版本并发布到 +[Releases](https://git.miuoo.qzz.io/Miu/meatshell/releases) 页面。 + +### Windows + +下载 `meatshell-*-windows-x86_64.zip`,解压后双击 `meatshell.exe`。 +运行时会在 `meatshell.exe` 同目录生成 `meatshell-debug.log`,SSH 连接闪退或异常时请优先查看这个文件。 + +### Linux + +```bash +tar -xzf meatshell-*-linux-x86_64.tar.gz +cd meatshell-*-linux-x86_64 +./meatshell # 直接运行 +# 可选:装应用图标 + 启动器入口(Dock / 应用列表里显示图标,无需传参) +chmod +x install-linux.sh && ./install-linux.sh +``` + +> 需要 glibc ≥ 2.35(Ubuntu 22.04+ / Debian 12+)。Wayland 下首次装完图标可能要注销重登一次。 + +### macOS + +```bash +tar -xzf meatshell-*-macos-*.tar.gz # aarch64 = Apple 芯片,x86_64 = Intel +xattr -dr com.apple.quarantine meatshell # 去掉「未签名应用」的 Gatekeeper 拦截 +./meatshell +``` + +> 从源码构建见下方 [运行](#运行)。 + +## 路线图 + +### v0.1 + +- [x] FinalShell 风格深色主题 UI +- [x] 左侧本机系统监控(CPU / 内存 / 交换 / 网络吞吐,1 Hz) +- [x] 多标签页(欢迎页 + 多个终端会话) +- [x] 会话管理:新建 / 编辑 / 删除,本地 JSON 持久化 + - 配置位置:`%APPDATA%/meatshell/sessions.json`(Windows) + / `~/.config/meatshell/sessions.json`(Linux) + / `~/Library/Application Support/meatshell/sessions.json`(macOS) +- [x] SSH 连接骨架(`russh`,纯 Rust 实现,支持密码 + 私钥) +- [x] 原始 PTY 输入 + VT/ANSI 终端视图 + +### v0.2 + +- [x] 完整 VT/ANSI 终端模拟(`vt100`/xterm 解析,支持全屏 TUI、滚动缓冲、查找、选择复制) +- [x] 远端主机资源监控(与 FinalShell 一样执行远端脚本收集) +- [x] SFTP 文件浏览 + 拖拽上传/下载 +- [x] 已知主机 (known_hosts) 校验(首次信任写入本应用 known_hosts,密钥变化拒绝连接) +- [x] 会话密码使用 OS 钥匙串存储(不可用时使用加密配置回退) + +### v0.3+ + +- [x] 多标签页终端分屏 +- [x] 会话分组 / 文件夹 +- [x] 主题切换(浅色 / 跟随系统) +- [x] 命令历史与片段管理 + +## 技术栈 + +| 模块 | 选型 | +| ------------- | ----------------------------------------------------------------- | +| UI | [Slint](https://slint.dev)(纯 Rust 编译,无 GC) | +| 异步运行时 | [`tokio`](https://tokio.rs) | +| SSH 协议 | [`russh`](https://crates.io/crates/russh)(无 libssh 依赖) | +| 系统指标 | [`sysinfo`](https://crates.io/crates/sysinfo) | +| 序列化 | `serde` + `serde_json` | +| 日志 | `tracing` + `tracing-subscriber` | + +## 运行 + +```bash +cargo run --release +``` + +首次启动会在 `%APPDATA%/meatshell/sessions.json` 建立空的会话库。点击右上 +角 **“+ 新建会话”** 添加第一台服务器。 + +## 项目布局 + +``` +meatshell/ +├── Cargo.toml +├── build.rs # Slint 编译器入口 +├── ui/ +│ ├── app.slint # 顶层窗口 +│ ├── theme.slint # 设计 tokens +│ ├── widgets.slint # 可复用按钮 / 输入框 / sparkline +│ ├── sidebar.slint # 左侧系统监控面板 +│ ├── tabs.slint # 顶部标签栏 +│ ├── welcome.slint # 欢迎页 / 快速连接 +│ ├── session_dialog.slint # 新建 / 编辑会话弹框 +│ └── terminal_view.slint # VT/ANSI 终端视图 + 命令条 / SFTP 面板 +└── src/ + ├── main.rs + ├── app.rs # UI ↔ 后端桥接 + ├── config.rs # 会话 JSON 持久化 + ├── keychain.rs # 系统钥匙串密码存储 + 加密回退 + ├── known_hosts.rs # OpenSSH known_hosts 校验 + ├── system.rs # CPU / 内存 / 网络采样 + ├── ssh.rs # SSH 会话 worker + └── sftp.rs # SFTP 文件浏览 / 传输 worker +``` + +## 开发提示 + +- Slint 控件有非常严格的布局 DSL,改 `.slint` 后 `cargo check` 是最快的 + 反馈方式。 +- 应用事件循环是单线程(Slint 要求),所有跨线程 UI 更新通过 + `slint::invoke_from_event_loop` 回调。 +- `known_hosts` 采用首次信任(TOFU):第一次连接会写入 meatshell 自己的 + `known_hosts`,之后服务端密钥变化会拒绝连接。 +- 会话密码优先存入系统钥匙串;若平台钥匙串不可用,会回退到本地 + ChaCha20-Poly1305 加密配置。 + +## License + +MIT OR Apache-2.0(双许可)。 diff --git a/assets/Info.plist b/assets/Info.plist new file mode 100644 index 0000000..a24a21c --- /dev/null +++ b/assets/Info.plist @@ -0,0 +1,44 @@ + + + + + + CFBundleExecutable + meatshell + + + CFBundleIconFile + meatshell + + CFBundleIdentifier + dev.meatshell.meatshell + + CFBundleName + meatshell + + CFBundleDisplayName + meatshell + + CFBundlePackageType + APPL + + + CFBundleShortVersionString + __VERSION__ + + CFBundleVersion + __VERSION__ + + + NSHighResolutionCapable + + + + LSMinimumSystemVersion + 11.0 + + NSHumanReadableCopyright + Copyright © 2024 meatshell contributors. MIT OR Apache-2.0. + + diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..8abc411 Binary files /dev/null and b/assets/icon.png differ diff --git a/assets/icon@512.png b/assets/icon@512.png new file mode 100644 index 0000000..31a54f9 Binary files /dev/null and b/assets/icon@512.png differ diff --git a/assets/install-linux.sh b/assets/install-linux.sh new file mode 100644 index 0000000..b75605a --- /dev/null +++ b/assets/install-linux.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# +# Install meatshell's icon + desktop entry on Linux so the GNOME/Ubuntu dock and +# the app launcher show the app icon. +# +# Why this is needed: the Windows build embeds the icon in the .exe, but on Linux +# the icon comes from a freedesktop ".desktop" entry plus an icon installed into +# the hicolor icon theme. On Wayland (Ubuntu's default) the shell matches a +# running window to its .desktop file via the window's app_id — meatshell sets +# that to "meatshell" (slint::set_xdg_app_id), and this script's StartupWMClass +# matches it. +# +# Usage: +# ./install-linux.sh [/path/to/meatshell-binary] +# You normally don't need an argument: when run from inside a release package +# (the `meatshell` binary sits next to this script) it is picked up automatically. +# In the source tree it falls back to ./target/release/meatshell. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Resolve the binary: explicit arg > sibling (release package) > source-tree build. +if [ -n "${1:-}" ]; then + BIN="$1" +elif [ -x "$SCRIPT_DIR/meatshell" ]; then + BIN="$SCRIPT_DIR/meatshell" +else + BIN="$SCRIPT_DIR/../target/release/meatshell" +fi +BIN="$(readlink -f "$BIN" 2>/dev/null || echo "$BIN")" + +# Make sure the binary is executable (a downloaded tarball may have lost +x). +[ -f "$BIN" ] && chmod +x "$BIN" 2>/dev/null || true + +if [ ! -x "$BIN" ]; then + echo "error: meatshell binary not found: $BIN" >&2 + echo "Run this script from the extracted release folder (it sits next to the" >&2 + echo "'meatshell' binary), or pass the binary path as an argument." >&2 + exit 1 +fi + +ICON_SRC="$SCRIPT_DIR/icon@512.png" +ICON_DIR="$HOME/.local/share/icons/hicolor/512x512/apps" +APP_DIR="$HOME/.local/share/applications" + +mkdir -p "$ICON_DIR" "$APP_DIR" +if [ -f "$ICON_SRC" ]; then + install -m644 "$ICON_SRC" "$ICON_DIR/meatshell.png" +else + echo "warning: icon not found ($ICON_SRC); the desktop entry will use a generic icon" >&2 +fi + +cat > "$APP_DIR/meatshell.desktop" </dev/null || true +gtk-update-icon-cache -f -t "$HOME/.local/share/icons/hicolor" 2>/dev/null || true + +echo "Installed:" +echo " icon -> $ICON_DIR/meatshell.png" +echo " desktop -> $APP_DIR/meatshell.desktop" +echo " exec -> $BIN" +echo +echo "If the dock still shows the generic icon, log out/in (Wayland) or run" +echo "'killall -3 gnome-shell' (X11) to refresh the shell." diff --git a/assets/make_icon.py b/assets/make_icon.py new file mode 100644 index 0000000..9d1ec6c --- /dev/null +++ b/assets/make_icon.py @@ -0,0 +1,169 @@ +# -*- coding: utf-8 -*- +"""Generate the meatshell app icon (braised pork-belly + terminal prompt). + +Pure-Pillow, 4x supersampled for crisp anti-aliasing. +Outputs: icon.png (256), icon@512.png, meatshell.ico (multi-size) +""" +import math +from PIL import Image, ImageDraw, ImageFilter, ImageFont, ImageChops + +SS = 4 +BASE = 256 +S = BASE * SS # working canvas 1024 + +OUT_DIR = "." # script is run from assets/ + + +def lerp(a, b, t): + return int(round(a + (b - a) * t)) + + +def vgradient(size, top, bot): + """Vertical gradient RGB image.""" + w, h = size + col = Image.new("RGB", (1, h)) + px = col.load() + for y in range(h): + t = y / (h - 1) + px[0, y] = (lerp(top[0], bot[0], t), + lerp(top[1], bot[1], t), + lerp(top[2], bot[2], t)) + return col.resize((w, h)) + + +def wavy_band(x0, x1, y_top, y_bot, amp, phase, n=60): + """Polygon points for a horizontal band with wavy top & bottom edges.""" + pts = [] + for i in range(n + 1): + x = x0 + (x1 - x0) * i / n + y = y_top + amp * math.sin(i / n * math.pi * 2.5 + phase) + pts.append((x, y)) + for i in range(n, -1, -1): + x = x0 + (x1 - x0) * i / n + y = y_bot + amp * math.sin(i / n * math.pi * 2.5 + phase + 0.6) + pts.append((x, y)) + return pts + + +img = Image.new("RGBA", (S, S), (0, 0, 0, 0)) + +# ---------------------------------------------------------------- tile (dark terminal) +tile_mask = Image.new("L", (S, S), 0) +ImageDraw.Draw(tile_mask).rounded_rectangle( + [0, 0, S - 1, S - 1], radius=int(S * 0.22), fill=255) + +grad = vgradient((S, S), (37, 36, 46), (22, 21, 28)).convert("RGBA") +img.paste(grad, (0, 0), tile_mask) + +# subtle top sheen on the tile +sheen = Image.new("RGBA", (S, S), (0, 0, 0, 0)) +ImageDraw.Draw(sheen).ellipse( + [int(-S * 0.3), int(-S * 0.55), int(S * 1.3), int(S * 0.35)], + fill=(255, 255, 255, 22)) +sheen.putalpha(ImageChops.multiply(sheen.getchannel("A"), tile_mask)) +img = Image.alpha_composite(img, sheen) + +# ---------------------------------------------------------------- meat block geometry +mx0, my0 = int(S * 0.165), int(S * 0.175) +mx1, my1 = int(S * 0.835), int(S * 0.825) +mw, mh = mx1 - mx0, my1 - my0 +block_radius = int(mw * 0.17) + +# drop shadow under the block +shadow = Image.new("RGBA", (S, S), (0, 0, 0, 0)) +ImageDraw.Draw(shadow).rounded_rectangle( + [mx0, my0 + int(S * 0.02), mx1, my1 + int(S * 0.03)], + radius=block_radius, fill=(0, 0, 0, 150)) +shadow = shadow.filter(ImageFilter.GaussianBlur(S * 0.025)) +img = Image.alpha_composite(img, shadow) + +# meat layers on their own layer, then clip to rounded rect +meat = Image.new("RGBA", (S, S), (0, 0, 0, 0)) +md = ImageDraw.Draw(meat) +# base fill so wavy edges never leave gaps +md.rounded_rectangle([mx0, my0, mx1, my1], radius=block_radius, + fill=(178, 47, 38, 255)) + +amp = mh * 0.025 +# (start_frac, end_frac, color) top -> bottom, 五花肉 cross-section +layers = [ + (-0.02, 0.15, (94, 52, 32)), # skin / caramel glaze + (0.13, 0.31, (255, 224, 209)), # fat + (0.29, 0.52, (199, 60, 45)), # lean + (0.50, 0.66, (255, 216, 201)), # fat + (0.64, 1.04, (181, 48, 39)), # lean +] +for idx, (a, b, col) in enumerate(layers): + yt = my0 + mh * a + yb = my0 + mh * b + md.polygon(wavy_band(mx0 - 4, mx1 + 4, yt, yb, amp, idx * 1.7), + fill=col + (255,)) + +# marbling: faint warm streaks of fat running through the lean layers +for (ly, ph) in [(0.42, 0.0), (0.74, 1.1), (0.92, 2.0)]: + yc = my0 + mh * ly + pts = [] + n = 60 + for i in range(n + 1): + x = mx0 + mw * i / n + y = yc + mh * 0.010 * math.sin(i / n * math.pi * 3 + ph) + pts.append((x, y)) + md.line(pts, fill=(255, 228, 214, 70), width=int(S * 0.007), joint="curve") + +# glossy highlight on the glaze (top skin) — warm, not gray +md.ellipse([mx0 + mw * 0.12, my0 + mh * 0.012, + mx0 + mw * 0.60, my0 + mh * 0.085], + fill=(255, 240, 224, 90)) + +# clip meat to rounded-rect +meat_mask = Image.new("L", (S, S), 0) +ImageDraw.Draw(meat_mask).rounded_rectangle( + [mx0, my0, mx1, my1], radius=block_radius, fill=255) +meat.putalpha(ImageChops.multiply(meat.getchannel("A"), meat_mask)) +img = Image.alpha_composite(img, meat) + +# thin inner rim to define the block edge +rim = Image.new("RGBA", (S, S), (0, 0, 0, 0)) +ImageDraw.Draw(rim).rounded_rectangle( + [mx0, my0, mx1, my1], radius=block_radius, outline=(40, 18, 12, 160), + width=int(S * 0.006)) +img = Image.alpha_composite(img, rim) + +# ---------------------------------------------------------------- terminal prompt >_ +def load_font(size): + for path in (r"C:\Windows\Fonts\consolab.ttf", + r"C:\Windows\Fonts\consola.ttf", + r"C:\Windows\Fonts\lucon.ttf"): + try: + return ImageFont.truetype(path, size) + except OSError: + continue + return ImageFont.load_default() + + +prompt = Image.new("RGBA", (S, S), (0, 0, 0, 0)) +pd = ImageDraw.Draw(prompt) +font = load_font(int(S * 0.30)) +text = ">_" +bbox = pd.textbbox((0, 0), text, font=font) +tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1] +tx = (S - tw) / 2 - bbox[0] +ty = (S - th) / 2 - bbox[1] + +# dark shadow for legibility on the meat +pd.text((tx + S * 0.012, ty + S * 0.012), text, font=font, fill=(20, 8, 4, 180)) +# bright terminal green +pd.text((tx, ty), text, font=font, fill=(126, 255, 122, 255)) +prompt = prompt.filter(ImageFilter.GaussianBlur(1.2)) +img = Image.alpha_composite(img, prompt) + +# ---------------------------------------------------------------- export +img256 = img.resize((BASE, BASE), Image.LANCZOS) +img512 = img.resize((512, 512), Image.LANCZOS) +img256.save("icon.png") +img512.save("icon@512.png") +img.resize((512, 512), Image.LANCZOS).save("icon@1024_preview.png") # for review +img256.save("meatshell.ico", format="ICO", + sizes=[(256, 256), (128, 128), (64, 64), + (48, 48), (32, 32), (16, 16)]) +print("OK: icon.png, icon@512.png, meatshell.ico written") diff --git a/assets/meatshell.desktop b/assets/meatshell.desktop new file mode 100644 index 0000000..fbb21ae --- /dev/null +++ b/assets/meatshell.desktop @@ -0,0 +1,13 @@ +[Desktop Entry] +Type=Application +Name=meatshell +GenericName=SSH Client +Comment=Lightweight Rust + Slint SSH/SFTP client +Comment[zh_CN]=轻量级 Rust + Slint SSH/SFTP 客户端 +Exec=meatshell +Icon=meatshell +Terminal=false +Categories=Network;System;TerminalEmulator;Utility; +Keywords=ssh;sftp;terminal;shell; +StartupNotify=true +StartupWMClass=meatshell diff --git a/assets/meatshell.ico b/assets/meatshell.ico new file mode 100644 index 0000000..69815fb Binary files /dev/null and b/assets/meatshell.ico differ diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..15cd4a3 --- /dev/null +++ b/build.rs @@ -0,0 +1,30 @@ +fn main() { + // Bundle the gettext `.po` translations under `lang/` so the UI's `@tr(...)` + // strings can switch language at runtime via slint::select_bundled_translation. + // Source language is Chinese (the msgids); `lang//LC_MESSAGES/meatshell.po` + // provides other locales. No per-component context, so msgids are the raw + // Chinese strings. + println!("cargo:rerun-if-changed=lang"); + slint_build::compile_with_config( + "ui/app.slint", + slint_build::CompilerConfiguration::new() + .with_style("fluent".into()) + .with_bundled_translations("lang") + .with_default_translation_context(slint_build::DefaultTranslationContext::None), + ) + .expect("Slint build failed"); + + // Embed the application icon into the Windows executable so it shows up in + // Explorer, the taskbar and shortcuts. Build scripts run on the host, so a + // Linux -> Windows cross build must check the target OS via Cargo env vars + // instead of `#[cfg(windows)]`. + println!("cargo:rerun-if-env-changed=CARGO_CFG_TARGET_OS"); + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { + println!("cargo:rerun-if-changed=assets/meatshell.ico"); + let mut res = winresource::WindowsResource::new(); + res.set_icon("assets/meatshell.ico"); + if let Err(e) = res.compile() { + println!("cargo:warning=failed to embed Windows icon: {e}"); + } + } +} diff --git a/docs/screenshots/01-welcome-en.png b/docs/screenshots/01-welcome-en.png new file mode 100644 index 0000000..b03022c Binary files /dev/null and b/docs/screenshots/01-welcome-en.png differ diff --git a/docs/screenshots/01-welcome.png b/docs/screenshots/01-welcome.png new file mode 100644 index 0000000..cd7e8e9 Binary files /dev/null and b/docs/screenshots/01-welcome.png differ diff --git a/docs/screenshots/02-terminal-btop.png b/docs/screenshots/02-terminal-btop.png new file mode 100644 index 0000000..20ee5c6 Binary files /dev/null and b/docs/screenshots/02-terminal-btop.png differ diff --git a/docs/ui-layout.md b/docs/ui-layout.md new file mode 100644 index 0000000..68cd9f2 --- /dev/null +++ b/docs/ui-layout.md @@ -0,0 +1,120 @@ +# UI layout boundaries + +The UI should follow a FinalShell-style work layout: navigation on the left, +tabs and terminal workspace in the center, and operational tools as separate +panels. Keep one terminal per tab. Terminal split panes are intentionally not +part of the product surface because they couple session lifecycle, resize, +SFTP, resource sampling, and focus handling too tightly. + +## Independent Components + +### AppShell + +Owns the top-level layout only: + +- left navigation width +- main workspace area +- top-right global actions +- modal/popup mounting + +It should not own terminal rendering, SFTP state, or session form details. + +### ConnectionNavigator + +Left-side session tree/list, similar to FinalShell's connection manager: + +- session groups +- saved sessions +- quick connect entry points +- edit/delete/import actions + +This should replace putting the full session list on the welcome page as the +primary workflow. The welcome page can remain a light empty state. + +### WorkspaceTabs + +Owns tab metadata and switching: + +- welcome tab +- terminal tabs +- close/select/new-tab commands + +It should not know about SFTP rows, terminal spans, or resource graphs. + +### TerminalWorkspace + +Owns the active terminal tab content area: + +- one `TerminalView` per active terminal tab +- keyboard focus routing +- terminal resize forwarding +- terminal status line + +No split panes. If multiple terminals are needed, open multiple tabs. + +### TerminalToolbar + +Commands around the terminal should be separate from terminal rendering: + +- command input +- run/save snippet buttons +- snippet popup +- find toggle +- copy/paste/clear actions + +This keeps terminal grid rendering focused on VT output and selection. + +### FilePanel + +SFTP should be an independent panel, not terminal internals: + +- remote path +- remote tree +- file list +- upload/download/view/edit/delete +- loading/status text + +It can be docked below or to the right of the terminal, but its data model +should remain separate from terminal spans and cursor state. + +### ResourcePanel + +Local/remote machine stats should be independent: + +- connection status +- CPU/memory/swap +- network graphs +- disk usage + +It may live in the left sidebar initially, but it should not be mixed with +session navigation logic. + +### TransferManager + +Global transfer history/progress: + +- active transfers +- completed transfers +- failed transfers +- clear/open download folder actions + +This remains global because downloads can outlive tab focus. + +### SessionEditor + +Session creation and editing should stay modal and isolated: + +- SSH/Telnet/Serial fields +- auth fields +- key picker +- validation and save/cancel + +It should not manipulate tab/terminal models directly. + +## Current Refactor Priority + +1. Remove terminal split panes and keep one terminal per tab. +2. Move session navigation out of the welcome screen into an independent left + component. +3. Split `TerminalView` into terminal grid, command toolbar, and SFTP/file panel. +4. Keep resource stats and transfer manager as independent global panels. diff --git a/lang/en/LC_MESSAGES/meatshell.po b/lang/en/LC_MESSAGES/meatshell.po new file mode 100644 index 0000000..cdc0161 --- /dev/null +++ b/lang/en/LC_MESSAGES/meatshell.po @@ -0,0 +1,355 @@ +msgid "" +msgstr "" +"Project-Id-Version: meatshell\n" +"Language: en\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid "Not connected" +msgstr "Not connected" + +msgid "Local resources" +msgstr "Local resources" + +msgid "Server resources" +msgstr "Server resources" + +msgid "Download settings" +msgstr "Download settings" + +msgid "Save downloads to:" +msgstr "Save downloads to:" + +msgid "(ask every time)" +msgstr "(ask every time)" + +msgid "Choose folder" +msgstr "Choose folder" + +msgid "Open folder" +msgstr "Open folder" + +msgid "Transfers" +msgstr "Transfers" + +msgid "Clear" +msgstr "Clear" + +msgid "No transfers yet" +msgstr "No transfers yet" + +msgid "About" +msgstr "About" + +msgid "About meatshell" +msgstr "About meatshell" + +msgid "A lightweight Rust + Slint SSH terminal client by 'lil meatball'." +msgstr "A lightweight Rust + Slint SSH terminal client by 'lil meatball'." + +msgid "Open source · MIT / Apache-2.0" +msgstr "Open source · MIT / Apache-2.0" + +msgid "Open-source libraries used" +msgstr "Open-source libraries used" + +msgid "Edit session" +msgstr "Edit session" + +msgid "New session" +msgstr "New session" + +msgid "Name" +msgstr "Name" + +msgid "e.g. production web-01" +msgstr "e.g. production web-01" + +msgid "Host / IP" +msgstr "Host / IP" + +msgid "Port" +msgstr "Port" + +msgid "Username" +msgstr "Username" + +msgid "Authentication" +msgstr "Authentication" + +msgid "Password" +msgstr "Password" + +msgid "Private key" +msgstr "Private key" + +msgid "Leave blank to keep the current password" +msgstr "Leave blank to keep the current password" + +msgid "Private key file" +msgstr "Private key file" + +msgid "Browse…" +msgstr "Browse…" + +msgid "Pick the private key itself (not the .pub public key)" +msgstr "Pick the private key itself (not the .pub public key)" + +msgid "Cancel" +msgstr "Cancel" + +msgid "Save" +msgstr "Save" + +msgid "Create" +msgstr "Create" + +msgid "SFTP not connected" +msgstr "SFTP not connected" + +msgid "Upload" +msgstr "Upload" + +msgid "Directory tree" +msgstr "Directory tree" + +msgid "Size" +msgstr "Size" + +msgid "Modified" +msgstr "Modified" + +msgid "Loading..." +msgstr "Loading..." + +msgid "Empty directory" +msgstr "Empty directory" + +msgid "Download" +msgstr "Download" + +msgid "View" +msgstr "View" + +msgid "Edit" +msgstr "Edit" + +msgid "Delete" +msgstr "Delete" + +msgid "Status" +msgstr "Status" + +msgid "Memory" +msgstr "Memory" + +msgid "Swap" +msgstr "Swap" + +msgid "Local" +msgstr "Local" + +msgid "Path" +msgstr "Path" + +msgid "Free/Total" +msgstr "Free/Total" + +msgid "Copy" +msgstr "Copy" + +msgid "Paste" +msgstr "Paste" + +msgid "Clear buffer" +msgstr "Clear buffer" + +msgid "Find" +msgstr "Find" + +msgid "meatshell — a lightweight Rust + Slint SSH client by 'lil meatball'" +msgstr "meatshell — a lightweight Rust + Slint SSH client by 'lil meatball'" + +msgid "Quick connect" +msgstr "Quick connect" + +msgid "+ New session" +msgstr "+ New session" + +msgid "No sessions yet" +msgstr "No sessions yet" + +msgid "Use 'New session' (top-right) to add your first server" +msgstr "Use 'New session' (top-right) to add your first server" + +msgid "Import ~/.ssh/config" +msgstr "Import ~/.ssh/config" + +msgid "Proxy (optional)" +msgstr "Proxy (optional)" + +msgid "Leave blank to use $ALL_PROXY or connect directly" +msgstr "Leave blank to use $ALL_PROXY or connect directly" + +msgid "Connection type" +msgstr "Connection type" + +msgid "Serial" +msgstr "Serial" + +msgid "Serial port" +msgstr "Serial port" + +msgid "Baud rate" +msgstr "Baud rate" + +msgid "Data bits / Stop bits / Parity" +msgstr "Data bits / Stop bits / Parity" + +msgid "Flow control" +msgstr "Flow control" + +msgid "None" +msgstr "None" + +msgid "Hardware" +msgstr "Hardware" + +msgid "Software" +msgstr "Software" + +msgid "Confirm delete" +msgstr "Confirm delete" + +msgid "Delete this item? This cannot be undone." +msgstr "Delete this item? This cannot be undone." + +msgid "Please confirm" +msgstr "Please confirm" + +msgid "Confirm" +msgstr "Confirm" + +msgid "Connection manager" +msgstr "Connection manager" + +msgid "Use the plus button above to add SSH, Serial, or Telnet" +msgstr "Use the plus button above to add SSH, Serial, or Telnet" + +msgid "File management" +msgstr "File management" + +msgid "Command management" +msgstr "Command management" + +msgid "Categories" +msgstr "Categories" + +msgid "Custom commands" +msgstr "Custom commands" + +msgid "No custom commands" +msgstr "No custom commands" + +msgid "New" +msgstr "New" + +msgid "New command" +msgstr "New command" + +msgid "Command detail" +msgstr "Command detail" + +msgid "New category" +msgstr "New category" + +msgid "Rename category" +msgstr "Rename category" + +msgid "Rename" +msgstr "Rename" + +msgid "Run" +msgstr "Run" + +msgid "Append CR" +msgstr "Append CR" + +msgid "Parameters" +msgstr "Parameters" + +msgid "Missing parameters" +msgstr "Missing parameters" + +msgid "Move to category" +msgstr "Move to category" + +msgid "Configuration" +msgstr "Configuration" + +msgid "Maximum terminal windows" +msgstr "Maximum terminal windows" + +msgid "Terminal font size" +msgstr "Terminal font size" + +msgid "Folder" +msgstr "Folder" + +msgid "New folder" +msgstr "New folder" + +msgid "Folder name" +msgstr "Folder name" + +msgid "Folder name is required" +msgstr "Folder name is required" + +msgid "Category name" +msgstr "Category name" + +msgid "Category name is required" +msgstr "Category name is required" + +msgid "Auto" +msgstr "Auto" + +msgid "File manager font size" +msgstr "File manager font size" + +msgid "Background image" +msgstr "Background image" + +msgid "No background image" +msgstr "No background image" + +msgid "Background scope" +msgstr "Background scope" + +msgid "Terminal" +msgstr "Terminal" + +msgid "Fullscreen" +msgstr "Fullscreen" + +msgid "Blur" +msgstr "Blur" + +msgid "Configuration directory" +msgstr "Configuration directory" + +msgid "Choose" +msgstr "Choose" + +msgid "Debug identifiers" +msgstr "Debug identifiers" + +msgid "On" +msgstr "On" + +msgid "Off" +msgstr "Off" + +msgid "Shows stable ids on UI parts for bug reports" +msgstr "Shows stable ids on UI parts for bug reports" diff --git a/lang/zh/LC_MESSAGES/meatshell.po b/lang/zh/LC_MESSAGES/meatshell.po new file mode 100644 index 0000000..6ac5972 --- /dev/null +++ b/lang/zh/LC_MESSAGES/meatshell.po @@ -0,0 +1,355 @@ +msgid "" +msgstr "" +"Project-Id-Version: meatshell\n" +"Language: zh\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid "Not connected" +msgstr "未连接" + +msgid "Local resources" +msgstr "本机资源" + +msgid "Server resources" +msgstr "服务器资源" + +msgid "Download settings" +msgstr "下载设置" + +msgid "Save downloads to:" +msgstr "下载保存到:" + +msgid "(ask every time)" +msgstr "(每次下载时询问)" + +msgid "Choose folder" +msgstr "选择文件夹" + +msgid "Open folder" +msgstr "打开目录" + +msgid "Transfers" +msgstr "传输记录" + +msgid "Clear" +msgstr "清空" + +msgid "No transfers yet" +msgstr "暂无传输记录" + +msgid "About" +msgstr "关于" + +msgid "About meatshell" +msgstr "关于 meatshell" + +msgid "A lightweight Rust + Slint SSH terminal client by 'lil meatball'." +msgstr "一个由「一坨肉」开发的轻量 Rust + Slint SSH 终端客户端。" + +msgid "Open source · MIT / Apache-2.0" +msgstr "开源项目 · 协议 MIT / Apache-2.0" + +msgid "Open-source libraries used" +msgstr "使用的开源库" + +msgid "Edit session" +msgstr "编辑会话" + +msgid "New session" +msgstr "新建会话" + +msgid "Name" +msgstr "名称" + +msgid "e.g. production web-01" +msgstr "例如:生产环境 web-01" + +msgid "Host / IP" +msgstr "主机 / IP" + +msgid "Port" +msgstr "端口" + +msgid "Username" +msgstr "用户名" + +msgid "Authentication" +msgstr "认证方式" + +msgid "Password" +msgstr "密码" + +msgid "Private key" +msgstr "私钥" + +msgid "Leave blank to keep the current password" +msgstr "留空则不修改密码" + +msgid "Private key file" +msgstr "私钥文件" + +msgid "Browse…" +msgstr "浏览…" + +msgid "Pick the private key itself (not the .pub public key)" +msgstr "选择私钥本身(不是 .pub 公钥文件)" + +msgid "Cancel" +msgstr "取消" + +msgid "Save" +msgstr "保存" + +msgid "Create" +msgstr "创建" + +msgid "SFTP not connected" +msgstr "SFTP 未连接" + +msgid "Upload" +msgstr "上传" + +msgid "Directory tree" +msgstr "目录树" + +msgid "Size" +msgstr "大小" + +msgid "Modified" +msgstr "修改时间" + +msgid "Loading..." +msgstr "加载中..." + +msgid "Empty directory" +msgstr "目录为空" + +msgid "Download" +msgstr "下载" + +msgid "View" +msgstr "查看" + +msgid "Edit" +msgstr "编辑" + +msgid "Delete" +msgstr "删除" + +msgid "Status" +msgstr "运行状态" + +msgid "Memory" +msgstr "内存" + +msgid "Swap" +msgstr "交换" + +msgid "Local" +msgstr "本机" + +msgid "Path" +msgstr "路径" + +msgid "Free/Total" +msgstr "可用/大小" + +msgid "Copy" +msgstr "复制" + +msgid "Paste" +msgstr "粘贴" + +msgid "Clear buffer" +msgstr "清空缓存" + +msgid "Find" +msgstr "查找" + +msgid "meatshell — a lightweight Rust + Slint SSH client by 'lil meatball'" +msgstr "meatshell 一个由「一坨肉」开发的轻量 Rust + Slint SSH 客户端" + +msgid "Quick connect" +msgstr "快速连接" + +msgid "+ New session" +msgstr "+ 新建会话" + +msgid "No sessions yet" +msgstr "尚无会话" + +msgid "Use 'New session' (top-right) to add your first server" +msgstr "点击右上角 “新建会话” 添加你的第一台服务器" + +msgid "Import ~/.ssh/config" +msgstr "导入 ~/.ssh/config" + +msgid "Proxy (optional)" +msgstr "代理(可选)" + +msgid "Leave blank to use $ALL_PROXY or connect directly" +msgstr "留空则用 $ALL_PROXY 环境变量,或直接连接" + +msgid "Connection type" +msgstr "连接类型" + +msgid "Serial" +msgstr "串口" + +msgid "Serial port" +msgstr "串口号" + +msgid "Baud rate" +msgstr "波特率" + +msgid "Data bits / Stop bits / Parity" +msgstr "数据位 / 停止位 / 校验位" + +msgid "Flow control" +msgstr "流控" + +msgid "None" +msgstr "无" + +msgid "Hardware" +msgstr "硬件" + +msgid "Software" +msgstr "软件" + +msgid "Confirm delete" +msgstr "确认删除" + +msgid "Delete this item? This cannot be undone." +msgstr "确定删除此项?此操作不可撤销。" + +msgid "Please confirm" +msgstr "请确认" + +msgid "Confirm" +msgstr "确认" + +msgid "Connection manager" +msgstr "连接管理" + +msgid "Use the plus button above to add SSH, Serial, or Telnet" +msgstr "点击上方加号添加 SSH、串口或 Telnet" + +msgid "File management" +msgstr "文件管理" + +msgid "Command management" +msgstr "命令管理" + +msgid "Categories" +msgstr "分类" + +msgid "Custom commands" +msgstr "自定义命令" + +msgid "No custom commands" +msgstr "暂无自定义命令" + +msgid "New" +msgstr "新建" + +msgid "New command" +msgstr "新建命令" + +msgid "Command detail" +msgstr "命令详情" + +msgid "New category" +msgstr "新建分类" + +msgid "Rename category" +msgstr "重命名分类" + +msgid "Rename" +msgstr "重命名" + +msgid "Run" +msgstr "运行" + +msgid "Append CR" +msgstr "末尾添加 CR" + +msgid "Parameters" +msgstr "参数" + +msgid "Missing parameters" +msgstr "参数未填写" + +msgid "Move to category" +msgstr "移动到分类" + +msgid "Configuration" +msgstr "配置" + +msgid "Maximum terminal windows" +msgstr "最大终端窗口数" + +msgid "Terminal font size" +msgstr "终端字体大小" + +msgid "Folder" +msgstr "文件夹" + +msgid "New folder" +msgstr "新建文件夹" + +msgid "Folder name" +msgstr "文件夹名称" + +msgid "Folder name is required" +msgstr "文件夹名称不能为空" + +msgid "Category name" +msgstr "分类名称" + +msgid "Category name is required" +msgstr "分类名称不能为空" + +msgid "Auto" +msgstr "自动" + +msgid "File manager font size" +msgstr "文件管理字体大小" + +msgid "Background image" +msgstr "背景图" + +msgid "No background image" +msgstr "未设置背景图" + +msgid "Background scope" +msgstr "背景范围" + +msgid "Terminal" +msgstr "终端" + +msgid "Fullscreen" +msgstr "全屏" + +msgid "Blur" +msgstr "模糊" + +msgid "Configuration directory" +msgstr "配置目录" + +msgid "Choose" +msgstr "选择" + +msgid "Debug identifiers" +msgstr "调试标识" + +msgid "On" +msgstr "开启" + +msgid "Off" +msgstr "关闭" + +msgid "Shows stable ids on UI parts for bug reports" +msgstr "在界面部件上显示固定 ID,便于反馈问题" diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..aca7aa2 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,5125 @@ +//! Top-level UI state machine. +//! +//! Responsibilities: +//! * Load the config store and expose sessions to Slint. +//! * Manage the tab list + per-tab `SessionHandle` map. +//! * Route Slint callbacks to the right domain module. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::rc::Rc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +/// Per-terminal state: vt100 parser drives all rendering for both normal +/// (bash) and alt-screen (vim/nano/htop) modes. +/// +/// Using vt100 for normal mode too is necessary because readline rewrites the +/// current input line using `\r` + full-line redraw + `\x1b[K` (erase to EOL) +/// whenever the cursor moves. A naive append-only buffer would duplicate the +/// text; vt100 tracks cursor position and overwrites in place correctly. +struct TermBuffer { + parser: vt100::Parser, + /// Active find query for this tab ("" = no search). + find_query: String, + /// Current theme mode — propagated from the global dark-mode toggle. + /// Stored here so the event-pump threads can render new output with the + /// correct palette without needing a window reference. + is_dark: bool, + /// Drag selection in ABSOLUTE scrollback coordinates: each endpoint is a + /// `(combined_row, col)` where `combined_row` indexes the virtual buffer of + /// `history` lines followed by the live screen rows. Absolute (rather than + /// visible-window) coordinates keep the selection pinned to its content + /// while the view auto-scrolls during a drag, so a top-to-bottom selection + /// across more than one screen of scrollback copies every line (#18). + /// `anchor` = where the drag began, `focus` = the moving end. + sel_anchor: Option<(usize, u16)>, + sel_focus: Option<(usize, u16)>, + /// Session scrollback: lines that have scrolled off the top (oldest first). + history: Vec, + /// Previous frame's grid lines, for scroll-off detection. + prev: Vec, + /// Scrollback view offset in lines (0 = live bottom). + view_offset: usize, + /// Plain text of the rows currently displayed (drives find + selection). + displayed_text: Vec, + /// CSI-scanner state for rewriting HVP (`ESC [ … f`) into CUP (`ESC [ … H`). + /// vt100 0.15 only implements the `H` final byte, not the equivalent `f` + /// that btop/htop use for cursor positioning — without this rewrite their + /// absolute-positioned full-screen output collapses into a scrolling mess. + /// Kept here so a sequence split across read chunks is still translated. + csi_state: CsiState, +} + +/// Minimal CSI-final-byte rewriter state (persists across read chunks). +#[derive(Clone, Copy, PartialEq)] +enum CsiState { + /// Normal text. + Normal, + /// Saw ESC (0x1b), waiting to see if it starts a CSI (`[`). + Esc, + /// Inside a CSI sequence (after `ESC [`), scanning params/intermediates. + Csi, +} + +type TermBuffers = Arc>>; + +use anyhow::{Context, Result}; +use i_slint_backend_winit::WinitWindowAccessor; +use slint::{ComponentHandle, Model, ModelRc, Rgba8Pixel, SharedPixelBuffer, SharedString, VecModel}; +use tokio::runtime::Runtime; + +use crate::config::{ + AuthMethod, ConfigStore, Secret, Session, SessionKind, DEFAULT_COMMAND_CATEGORY_ID, +}; +use crate::i18n::t; +use crate::sftp::{spawn_sftp, SftpHandle}; +use crate::ssh::{ + format_mtime, format_size, spawn_session, RemoteEntry, SessionCommand, SessionEvent, + SessionHandle, +}; +use crate::system::{format_bytes_per_sec, format_bytes_short, format_mem_mib}; + +type SftpHandles = Arc>>; +/// Per-tab flag: once the user explicitly navigates via the SFTP tree or +/// toolbar, stop auto-syncing to the terminal's `cd` path. +type SftpManualNav = Arc>>; + +/// Per-tab connection status + latest remote resource sample, used to drive the +/// sidebar for whichever tab is active. `Arc` because the SSH event-pump +/// threads update it before bouncing to the UI thread. +#[derive(Clone, Default)] +struct TabStatus { + host: String, // "root@192.168.100.2" + state: u8, // 0 = connecting, 1 = connected, 2 = disconnected + connected_since: Option, + uptime_secs: u64, + cpu: f32, // 0.0..1.0 + cpu_cores: u32, + mem_used_kib: u64, + mem_total_kib: u64, + swap_used_kib: u64, + swap_total_kib: u64, + /// Latest per-interface rates: (name, rx_bps, tx_bps), busiest first. + net: Vec<(String, u64, u64)>, + /// Which interface drives the top sparkline (empty = auto = busiest). + selected_iface: String, + /// Ring buffer of the selected interface's total (rx+tx) bytes/sec. + net_hist: Vec, + /// Per-filesystem (mount, available_bytes, total_bytes). + disks: Vec<(String, u64, u64)>, + /// Top processes: (resident_memory_bytes, cpu_percent, command). + processes: Vec<(u64, f32, String)>, +} +type TabStatuses = Arc>>; +type TransferSeq = Arc; + +// Slint generates types into this scope. +slint::include_modules!(); + +/// Number of samples kept for the sparkline. +const NET_HISTORY_LEN: usize = 60; + +fn default_max_tabs_for_ui() -> u32 { + 10 +} + +fn default_terminal_font_size_for_ui() -> u32 { + 13 +} + +fn default_sftp_font_size_for_ui() -> u32 { + 16 +} + +fn default_sftp_name_column_width_for_ui() -> u32 { + 180 +} + +fn default_terminal_opacity_for_ui() -> u32 { + 88 +} + +fn default_ui_opacity_for_ui() -> u32 { + 72 +} + +fn load_background_image(path: &str, blur_px: u32) -> slint::Image { + let path = path.trim(); + if path.is_empty() { + return slint::Image::default(); + } + match image::open(path) { + Ok(mut img) => { + let blur_px = blur_px.clamp(0, 20); + if blur_px > 0 { + img = img.fast_blur(blur_px as f32); + } + let rgba = img.to_rgba8(); + let (w, h) = rgba.dimensions(); + let raw = rgba.into_raw(); + let mut pixels = SharedPixelBuffer::::new(w, h); + for (dst, src) in pixels.make_mut_slice().iter_mut().zip(raw.chunks_exact(4)) { + *dst = Rgba8Pixel::new(src[0], src[1], src[2], src[3]); + } + slint::Image::from_rgba8(pixels) + } + Err(err) => { + tracing::warn!("failed to load background image: {err:#}"); + slint::Image::default() + } + } +} + +fn apply_window_theme(window: &AppWindow, is_dark: bool) { + use i_slint_backend_winit::winit::window::Theme as WinitTheme; + window.window().with_winit_window(|ww| { + ww.set_theme(Some(if is_dark { + WinitTheme::Dark + } else { + WinitTheme::Light + })); + }); +} + +/// Embed the app icon PNG into the binary and set it as the X11 window icon. +/// +/// On X11, the taskbar/dock icon for a running window comes from the +/// `_NET_WM_ICON` property, which winit sets via `Window::set_window_icon`. +/// When the app runs as a bare AppImage (or from a plain directory without +/// running install-linux.sh) there is no installed .desktop + icon, so the +/// dock falls back to a generic gear. This call fixes that for X11 sessions. +/// +/// On Wayland the dock icon is resolved by the compositor from the XDG +/// app-id → .desktop file mapping; `set_window_icon` is a no-op there, so +/// Wayland users still need AppImageLauncher or install-linux.sh for the +/// dock icon. The `icon:` property in app.slint handles the in-title-bar +/// icon on both backends without any runtime work. +/// +/// Windows gets its icon from the `.ico` embedded by winresource at link +/// time; macOS from the app bundle — neither path needs runtime decoding. +#[cfg(target_os = "linux")] +fn set_window_icon(window: &AppWindow) { + use i_slint_backend_winit::winit::window::Icon; + const ICON_PNG: &[u8] = include_bytes!("../assets/icon@512.png"); + let Ok(img) = image::load_from_memory(ICON_PNG) else { return }; + let rgba = img.into_rgba8(); + let (w, h) = rgba.dimensions(); + let Ok(icon) = Icon::from_rgba(rgba.into_raw(), w, h) else { return }; + window.window().with_winit_window(|ww| ww.set_window_icon(Some(icon))); +} + +pub fn run() -> Result<()> { + // --- Runtime + store ------------------------------------------------- + let runtime = Arc::new( + Runtime::new().context("failed to start tokio runtime")?, + ); + let store = Rc::new(RefCell::new( + ConfigStore::load().context("failed to load config")?, + )); + + // Per-tab SSH handles (shell only; lives on Slint thread via Rc). + let handles: Rc>> = + Rc::new(RefCell::new(HashMap::new())); + + // Per-tab SFTP handles — Arc so the event-pump OS thread and the + // Slint UI thread can both post SftpCommands. + let sftp_handles: SftpHandles = Arc::new(Mutex::new(HashMap::new())); + // Once the user navigates manually in the SFTP panel, stop auto-following cd. + let sftp_manual_nav: SftpManualNav = Arc::new(Mutex::new(HashMap::new())); + + // Per-tab vt100 parsers + history logs (Arc so they can be cloned + // into the thread that pumps session events into invoke_from_event_loop). + let bufs: TermBuffers = Arc::new(Mutex::new(HashMap::new())); + + // Last-known terminal pixel dimensions, updated by every terminal-resize + // callback. Shared so on_connect_session can pass a sensible initial PTY + // size to spawn_session before the first resize callback fires. + // Default: 80 cols × 24 rows (SSH spec minimum). + let last_term_size: Arc> = Arc::new(Mutex::new((80, 24))); + + // --- Build window + models ------------------------------------------ + // Set the Wayland app_id / X11 WM_CLASS *before* the window is created so + // the Linux desktop shell can match the running window to the installed + // `meatshell.desktop` entry and show our icon in the dock/taskbar. (On + // Windows the icon comes from the embedded .ico, so this is a no-op there.) + let _ = slint::set_xdg_app_id("meatshell"); + let window = AppWindow::new().context("failed to build Slint window")?; + + // Set the window icon from the PNG embedded in the binary so the dock + // shows the correct icon even without a system-installed .desktop entry + // (e.g. AppImage without AppImageLauncher, or plain binary in ~/bin). + #[cfg(target_os = "linux")] + set_window_icon(&window); + + // Apply the saved UI language. The Rust-side flag drives `i18n::t(...)`; + // `apply_to_slint` selects the bundled `.po` for the static `@tr(...)` text + // (must run after the first component exists, which it now does). + crate::i18n::set_language(store.borrow().language()); + crate::i18n::apply_to_slint(); + window.set_lang_en(crate::i18n::is_en()); + + // Apply the saved (or system-detected) theme. + // "dark" / "light" → use that directly; "system" or unset → ask the OS; + // OS unknown → fall back to dark. + { + let is_dark = match store.borrow().theme_pref() { + "light" => false, + "dark" => true, + _ => match dark_light::detect() { + dark_light::Mode::Light => false, + dark_light::Mode::Dark => true, + dark_light::Mode::Default => true, // undetectable → dark + }, + }; + window.set_dark_mode(is_dark); + apply_window_theme(&window, is_dark); + } + + let sessions_model: Rc> = Rc::new(VecModel::default()); + window.set_sessions(ModelRc::from(sessions_model.clone())); + let connection_folders_model: Rc> = + Rc::new(VecModel::default()); + window.set_connection_folders(ModelRc::from(connection_folders_model.clone())); + let connection_folder_names_model: Rc> = + Rc::new(VecModel::default()); + window.set_connection_folder_names(ModelRc::from( + connection_folder_names_model.clone(), + )); + sync_connection_folders_to_model( + &store.borrow(), + &connection_folders_model, + &connection_folder_names_model, + ); + sync_sessions_to_model(&store.borrow(), &sessions_model); + + let tabs_model: Rc> = Rc::new(VecModel::default()); + window.set_tabs(ModelRc::from(tabs_model.clone())); + window.set_active_tab_id("welcome".into()); + + let terminals_model: Rc> = Rc::new(VecModel::default()); + window.set_terminals(ModelRc::from(terminals_model.clone())); + let transfer_seq: TransferSeq = Arc::new(AtomicU64::new(0)); + + // Per-tab connection status + remote resources. Local resource sampling is + // intentionally disabled so idle welcome/disconnected screens stay light. + let tab_statuses: TabStatuses = Arc::new(Mutex::new(HashMap::new())); + + // --- Wire callbacks -------------------------------------------------- + wire_session_callbacks( + &window, + store.clone(), + sessions_model.clone(), + connection_folders_model.clone(), + connection_folder_names_model.clone(), + tabs_model.clone(), + terminals_model.clone(), + handles.clone(), + bufs.clone(), + runtime.clone(), + last_term_size.clone(), + sftp_handles.clone(), + sftp_manual_nav.clone(), + transfer_seq.clone(), + tab_statuses.clone(), + ); + + // Recompute the sidebar whenever the active tab changes (fired from Slint's + // `changed active-tab-id`). + { + let weak = window.as_weak(); + let statuses = tab_statuses.clone(); + window.on_refresh_sidebar(move || { + if let Some(w) = weak.upgrade() { + refresh_sidebar(&w, &statuses); + } + }); + } + + // Switch UI language at runtime. Static `@tr(...)` text updates live via + // select_bundled_translation; we additionally refresh the Rust-driven + // dynamic strings (sidebar status + the welcome tab title). + { + let weak = window.as_weak(); + let store = store.clone(); + window.on_set_language(move |code| { + crate::i18n::set_language(&code.to_string()); + { + let mut s = store.borrow_mut(); + s.set_language(crate::i18n::current_code().to_string()); + let _ = s.save(); + } + if let Some(w) = weak.upgrade() { + w.set_lang_en(crate::i18n::is_en()); + w.invoke_refresh_sidebar(); + } + }); + } + + // Theme toggle: flip dark ↔ light, persist the preference, and re-render + // every open terminal with the new ANSI palette so historical output is + // also recoloured (not just new output). + { + let weak = window.as_weak(); + let store = store.clone(); + let bufs_theme = bufs.clone(); + window.on_toggle_theme(move || { + let Some(w) = weak.upgrade() else { return }; + let next_dark = !w.get_dark_mode(); + w.set_dark_mode(next_dark); + apply_window_theme(&w, next_dark); + // Propagate new palette to all open terminal buffers. + { + let mut map = bufs_theme.lock().unwrap(); + for buf in map.values_mut() { + buf.is_dark = next_dark; + } + } + // Re-render every visible terminal so colours update immediately. + let tab_ids: Vec = { + let map = bufs_theme.lock().unwrap(); + map.keys().cloned().collect() + }; + for tid in tab_ids { + rebuild_tab_display(&w, &bufs_theme, &tid); + } + let pref = if next_dark { "dark" } else { "light" }; + let mut s = store.borrow_mut(); + s.set_theme_pref(pref.to_string()); + let _ = s.save(); + }); + } + + // NIC selector: remember the user's choice for the active tab and refresh. + { + let weak = window.as_weak(); + let statuses = tab_statuses.clone(); + window.on_select_net_iface(move |iface: SharedString| { + let Some(w) = weak.upgrade() else { return }; + let active = w.get_active_tab_id().to_string(); + if let Some(st) = statuses.lock().unwrap().get_mut(&active) { + st.selected_iface = iface.to_string(); + st.net_hist = vec![0.0; NET_HISTORY_LEN]; // reset graph for new NIC + } + refresh_sidebar(&w, &statuses); + }); + } + + // Settings: preset download directory (load + pick + open). + window.set_download_dir(store.borrow().download_dir().to_string().into()); + window.set_max_tabs_text(store.borrow().max_terminal_tabs().to_string().into()); + window.set_terminal_font_size_text(store.borrow().terminal_font_size().to_string().into()); + window.set_terminal_font_size(store.borrow().terminal_font_size() as f32); + window.set_sftp_font_size_text(store.borrow().sftp_file_font_size().to_string().into()); + window.set_sftp_file_font_size(store.borrow().sftp_file_font_size() as f32); + window.set_background_path_text(store.borrow().background_path().to_string().into()); + window.set_background_mode(store.borrow().background_mode() as i32); + window.set_background_blur_text(store.borrow().background_blur_px().to_string().into()); + window.set_terminal_opacity_text( + store + .borrow() + .terminal_opacity_percent() + .to_string() + .into(), + ); + window.set_terminal_background_opacity( + store.borrow().terminal_opacity_percent() as f32 / 100.0, + ); + window.set_ui_opacity_text(store.borrow().ui_opacity_percent().to_string().into()); + window.set_ui_background_opacity(store.borrow().ui_opacity_percent() as f32 / 100.0); + window.set_ui_debug_ids(store.borrow().ui_debug_ids()); + window.set_background_image(load_background_image( + store.borrow().background_path(), + store.borrow().background_blur_px(), + )); + window.set_sftp_name_column_width(store.borrow().sftp_name_column_width() as f32); + window.set_sftp_name_width_manual(store.borrow().sftp_name_column_manual()); + window.set_config_dir_text( + store + .borrow() + .config_dir() + .to_string_lossy() + .to_string() + .into(), + ); + window.on_debug_click(move |parent, button| { + let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f"); + tracing::info!("[{now}]点击[{button}/{parent}]"); + }); + { + let weak = window.as_weak(); + let store = store.clone(); + window.on_pick_download_dir(move || { + if let Some(folder) = rfd::FileDialog::new().pick_folder() { + let dir = folder.to_string_lossy().to_string(); + { + let mut s = store.borrow_mut(); + s.set_download_dir(dir.clone()); + let _ = s.save(); + } + if let Some(w) = weak.upgrade() { + w.set_download_dir(dir.into()); + } + } + }); + } + { + let weak = window.as_weak(); + window.on_open_download_dir(move || { + let Some(w) = weak.upgrade() else { return }; + let dir = w.get_download_dir().to_string(); + if dir.is_empty() { + return; + } + #[cfg(windows)] + { + let _ = std::process::Command::new("explorer").arg(&dir).spawn(); + } + #[cfg(not(windows))] + { + let _ = std::process::Command::new("xdg-open").arg(&dir).spawn(); + } + }); + } + { + let weak = window.as_weak(); + window.on_app_config_pick_dir(move || { + let Some(w) = weak.upgrade() else { return }; + let current = w.get_config_dir_text().to_string(); + let mut dialog = rfd::FileDialog::new() + .set_title(t("选择配置目录", "Choose configuration directory")); + if !current.trim().is_empty() { + dialog = dialog.set_directory(current.trim()); + } + if let Some(folder) = dialog.pick_folder() { + w.set_config_dir_text(folder.to_string_lossy().to_string().into()); + } + }); + } + { + let weak = window.as_weak(); + let store = store.clone(); + window.on_app_background_pick(move || { + let Some(w) = weak.upgrade() else { return }; + let current = w.get_background_path_text().to_string(); + let blur_px = w + .get_background_blur_text() + .trim() + .parse::() + .unwrap_or(3) + .clamp(0, 20); + let mut dialog = rfd::FileDialog::new() + .set_title(t("选择背景图", "Choose background image")) + .add_filter("Images", &["png", "jpg", "jpeg", "bmp", "gif", "webp"]); + if !current.trim().is_empty() { + if let Some(parent) = Path::new(current.trim()).parent() { + dialog = dialog.set_directory(parent); + } + } + if let Some(file) = dialog.pick_file() { + let path = file.to_string_lossy().to_string(); + let image = load_background_image(&path, blur_px); + { + let mut s = store.borrow_mut(); + s.set_background_path(path.clone()); + s.set_background_blur_px(blur_px); + if s.background_mode() == 0 { + s.set_background_mode(1); + w.set_background_mode(1); + } + if let Err(err) = s.save() { + tracing::warn!("failed to save background config: {err:#}"); + } + } + w.set_background_path_text(path.into()); + w.set_background_blur_text(blur_px.to_string().into()); + w.set_background_image(image); + } + }); + } + { + let weak = window.as_weak(); + let store = store.clone(); + window.on_app_background_clear(move || { + { + let mut s = store.borrow_mut(); + s.set_background_path(String::new()); + s.set_background_mode(0); + if let Err(err) = s.save() { + tracing::warn!("failed to save background config: {err:#}"); + } + } + if let Some(w) = weak.upgrade() { + w.set_background_path_text("".into()); + w.set_background_mode(0); + w.set_background_image(slint::Image::default()); + } + }); + } + { + let weak = window.as_weak(); + let store = store.clone(); + window.on_app_config_save( + move |max_tabs: SharedString, + terminal_font: SharedString, + sftp_font: SharedString, + background_mode: i32, + background_blur: SharedString, + terminal_opacity: SharedString, + ui_opacity: SharedString, + ui_debug_ids: bool, + config_dir: SharedString| { + let parsed = max_tabs + .trim() + .parse::() + .unwrap_or(default_max_tabs_for_ui()); + let max_tabs = parsed.clamp(1, 50); + let parsed_terminal_font = terminal_font + .trim() + .parse::() + .unwrap_or(default_terminal_font_size_for_ui()); + let terminal_font_size = parsed_terminal_font.clamp(10, 28); + let parsed_font = sftp_font + .trim() + .parse::() + .unwrap_or(default_sftp_font_size_for_ui()); + let sftp_font_size = parsed_font.clamp(10, 28); + let background_mode = background_mode.clamp(0, 2) as u8; + let parsed_blur = background_blur.trim().parse::().unwrap_or(3); + let background_blur = parsed_blur.clamp(0, 20); + let parsed_terminal_opacity = terminal_opacity + .trim() + .parse::() + .unwrap_or(default_terminal_opacity_for_ui()); + let terminal_opacity = parsed_terminal_opacity.clamp(30, 100); + let parsed_ui_opacity = ui_opacity + .trim() + .parse::() + .unwrap_or(default_ui_opacity_for_ui()); + let ui_opacity = parsed_ui_opacity.clamp(30, 100); + let requested_config_dir = config_dir.trim().to_string(); + let actual_config_dir; + { + let mut s = store.borrow_mut(); + s.set_max_terminal_tabs(max_tabs); + s.set_terminal_font_size(terminal_font_size); + s.set_sftp_file_font_size(sftp_font_size); + s.set_background_mode(background_mode); + s.set_background_blur_px(background_blur); + s.set_terminal_opacity_percent(terminal_opacity); + s.set_ui_opacity_percent(ui_opacity); + s.set_ui_debug_ids(ui_debug_ids); + if !requested_config_dir.is_empty() { + let current = s.config_dir().to_path_buf(); + let requested = PathBuf::from(&requested_config_dir); + if requested != current { + if let Err(err) = s.set_config_dir(requested) { + tracing::warn!("failed to set config directory: {err:#}"); + } + } + } + if let Err(err) = s.save() { + tracing::warn!("failed to save app config: {err:#}"); + } + actual_config_dir = s.config_dir().to_string_lossy().to_string(); + } + if let Some(w) = weak.upgrade() { + w.set_max_tabs_text(max_tabs.to_string().into()); + w.set_terminal_font_size_text(terminal_font_size.to_string().into()); + w.set_terminal_font_size(terminal_font_size as f32); + w.set_sftp_font_size_text(sftp_font_size.to_string().into()); + w.set_sftp_file_font_size(sftp_font_size as f32); + w.set_background_mode(background_mode as i32); + w.set_background_blur_text(background_blur.to_string().into()); + w.set_terminal_opacity_text(terminal_opacity.to_string().into()); + w.set_terminal_background_opacity(terminal_opacity as f32 / 100.0); + w.set_ui_opacity_text(ui_opacity.to_string().into()); + w.set_ui_background_opacity(ui_opacity as f32 / 100.0); + w.set_ui_debug_ids(ui_debug_ids); + w.set_background_image(load_background_image( + &w.get_background_path_text(), + background_blur, + )); + w.set_config_dir_text(actual_config_dir.into()); + w.set_config_open(false); + } + }, + ); + } + { + let store = store.clone(); + window.on_sftp_name_column_width_changed(move |width_px: f32| { + let width = width_px.round().clamp(96.0, 360.0) as u32; + let mut s = store.borrow_mut(); + s.set_sftp_name_column_width(width, true); + if let Err(err) = s.save() { + tracing::warn!("failed to save SFTP name column width: {err:#}"); + } + }); + } + { + let weak = window.as_weak(); + let store = store.clone(); + window.on_sftp_name_column_auto(move || { + let width = default_sftp_name_column_width_for_ui(); + { + let mut s = store.borrow_mut(); + s.set_sftp_name_column_width(width, false); + if let Err(err) = s.save() { + tracing::warn!("failed to save SFTP name column auto mode: {err:#}"); + } + } + if let Some(w) = weak.upgrade() { + w.set_sftp_name_width_manual(false); + w.set_sftp_name_column_width(width as f32); + } + }); + } + + // Transfer records (download/upload progress + history) shown in the popup. + let transfers_model: Rc> = Rc::new(VecModel::default()); + window.set_transfers(ModelRc::from(transfers_model.clone())); + { + let tm = transfers_model.clone(); + window.on_clear_transfers(move || tm.set_vec(Vec::::new())); + } + + // FinalShell-style custom command manager. + let command_categories_model: Rc> = Rc::new(VecModel::default()); + sync_command_categories_to_model(&store.borrow(), &command_categories_model); + window.set_command_categories(ModelRc::from(command_categories_model.clone())); + let command_items_model: Rc> = Rc::new(VecModel::default()); + sync_commands_to_model(&store.borrow(), &command_items_model); + window.set_command_items(ModelRc::from(command_items_model.clone())); + window.set_command_selected_category(DEFAULT_COMMAND_CATEGORY_ID.into()); + window.set_command_category_draft("默认分类".into()); + + // Open-source libraries shown in the About popup. + { + let libs: Vec = [ + t("Slint — 图形界面框架 (GUI)", "Slint — GUI framework"), + t("russh / russh-keys — SSH 协议实现", "russh / russh-keys — SSH protocol"), + t("russh-sftp — SFTP 文件传输", "russh-sftp — SFTP file transfer"), + t("ssh-key — SSH 密钥解析", "ssh-key — SSH key parsing"), + t("tokio — 异步运行时", "tokio — async runtime"), + t("vt100 — 终端 (VT100/xterm) 解析", "vt100 — terminal (VT100/xterm) parser"), + t("serde / serde_json — 配置序列化", "serde / serde_json — config serialization"), + t("arboard — 系统剪贴板", "arboard — system clipboard"), + t("rfd — 原生文件对话框", "rfd — native file dialogs"), + t("directories — 配置目录定位", "directories — config dir lookup"), + t("chrono — 日期时间处理", "chrono — date/time handling"), + t("uuid — 唯一标识符", "uuid — unique identifiers"), + t("anyhow / thiserror — 错误处理", "anyhow / thiserror — error handling"), + t("tracing / tracing-subscriber — 日志", "tracing / tracing-subscriber — logging"), + t("futures / async-trait — 异步辅助", "futures / async-trait — async helpers"), + t("rand — 随机数", "rand — randomness"), + t("winresource — Windows 图标/资源嵌入", "winresource — Windows icon/resource embedding"), + ] + .iter() + .map(|s| (*s).into()) + .collect(); + window.set_about_libs(ModelRc::from(Rc::new(VecModel::from(libs)))); + } + + wire_tab_callbacks( + &window, + tabs_model.clone(), + terminals_model.clone(), + handles.clone(), + bufs.clone(), + sftp_handles.clone(), + sftp_manual_nav.clone(), + ); + wire_sftp_callbacks(&window, sftp_handles.clone(), sftp_manual_nav.clone()); + wire_key_input(&window, handles.clone(), bufs.clone(), last_term_size.clone()); + wire_command_callbacks( + &window, + store.clone(), + command_categories_model.clone(), + command_items_model.clone(), + handles.clone(), + ); + + if env_flag("MEATSHELL_SMOKE_TERMINAL") { + install_terminal_smoke(&window, &tabs_model, &terminals_model, &bufs); + } + + // OS file drag-and-drop → upload to the active session's SFTP directory, + // but only when the file is dropped over the file-list area. + { + use i_slint_backend_winit::winit::event::WindowEvent as WEvent; + use i_slint_backend_winit::EventResult; + let weak = window.as_weak(); + let sh = sftp_handles.clone(); + window.window().on_winit_window_event(move |_w, event| { + if let WEvent::DroppedFile(path) = event { + if let Some(win) = weak.upgrade() { + handle_file_drop(&win, &sh, path.to_string_lossy().to_string()); + } + } + EventResult::Propagate + }); + } + + // Center the window on the primary monitor once it's shown (size is only + // known after the first frame, so defer via a single-shot timer). + { + let weak = window.as_weak(); + slint::Timer::single_shot(std::time::Duration::from_millis(30), move || { + if let Some(w) = weak.upgrade() { + center_window(&w); + } + }); + } + + window.run().context("event loop exited with error")?; + Ok(()) +} + +fn install_terminal_smoke( + window: &AppWindow, + tabs_model: &Rc>, + terminals_model: &Rc>, + bufs: &TermBuffers, +) { + let add_terminal = |tab_id: &str, title: &str, status: &str, detail: &str| { + tabs_model.push(TabInfo { + id: tab_id.into(), + title: title.into(), + kind: "terminal".into(), + connected: true, + }); + + let heading = format!("meatshell UI smoke {}", title); + let spans = vec![ + TermSpan { + cells: heading.chars().count() as i32, + text: heading.into(), + fg: slint::Color::from_rgb_u8(0xd4, 0xd4, 0xd4), + bg: slint::Color::from_argb_u8(0, 0, 0, 0), + bold: true, + use_fallback_font: false, + row: 0, + col: 0, + }, + TermSpan { + cells: detail.chars().count() as i32, + text: detail.into(), + fg: slint::Color::from_rgb_u8(0xa8, 0xb3, 0xc7), + bg: slint::Color::from_argb_u8(0, 0, 0, 0), + bold: false, + use_fallback_font: text_uses_cjk_fallback(detail), + row: 1, + col: 0, + }, + ]; + + terminals_model.push(TerminalState { + id: tab_id.into(), + tab_id: tab_id.into(), + status: status.into(), + spans: ModelRc::from(Rc::new(VecModel::from(spans))), + cursor_row: 2, + cursor_col: 0, + rows_used: 3, + is_alt_screen: false, + mouse_tracking: false, + find_matches: ModelRc::from(Rc::new(VecModel::::default())), + selection: ModelRc::from(Rc::new(VecModel::::default())), + sftp_path: "/".into(), + sftp_entries: ModelRc::from(Rc::new(VecModel::::default())), + sftp_status: "Smoke mode".into(), + sftp_loading: false, + sftp_tree_nodes: ModelRc::from(Rc::new(VecModel::::default())), + }); + + bufs.lock().unwrap().insert( + tab_id.to_string(), + TermBuffer { + parser: vt100::Parser::new(24, 80, 5000), + find_query: String::new(), + is_dark: true, + sel_anchor: None, + sel_focus: None, + history: Vec::new(), + prev: Vec::new(), + view_offset: 0, + displayed_text: Vec::new(), + csi_state: CsiState::Normal, + }, + ); + }; + + add_terminal( + "smoke-terminal-a", + "A", + "UI smoke terminal A", + "first tab layout probe", + ); + add_terminal( + "smoke-terminal-b", + "B", + "UI smoke terminal B", + "second tab layout probe", + ); + + window.set_active_tab_id("smoke-terminal-b".into()); + + // Keep smoke timers alive until they fire; dropping a Timer cancels it. + let theme_timer = Box::leak(Box::new(slint::Timer::default())); + { + let weak = window.as_weak(); + theme_timer.start( + slint::TimerMode::SingleShot, + std::time::Duration::from_millis(300), + move || { + if let Some(w) = weak.upgrade() { + w.invoke_toggle_theme(); + } + }, + ); + } + + let tab_a_timer = Box::leak(Box::new(slint::Timer::default())); + { + let weak = window.as_weak(); + tab_a_timer.start( + slint::TimerMode::SingleShot, + std::time::Duration::from_millis(650), + move || { + if let Some(w) = weak.upgrade() { + w.set_active_tab_id("smoke-terminal-a".into()); + } + }, + ); + } + + let tab_b_timer = Box::leak(Box::new(slint::Timer::default())); + { + let weak = window.as_weak(); + tab_b_timer.start( + slint::TimerMode::SingleShot, + std::time::Duration::from_millis(1000), + move || { + if let Some(w) = weak.upgrade() { + w.set_active_tab_id("smoke-terminal-b".into()); + } + }, + ); + } + + let quit_timer = Box::leak(Box::new(slint::Timer::default())); + quit_timer.start( + slint::TimerMode::SingleShot, + std::time::Duration::from_millis(1500), + || { + if let Err(err) = slint::quit_event_loop() { + tracing::error!(error = %err, "failed to quit terminal smoke test"); + } + }, + ); +} + +fn env_flag(name: &str) -> bool { + matches!( + std::env::var(name).as_deref(), + Ok("1") | Ok("true") | Ok("TRUE") | Ok("yes") | Ok("YES") | Ok("on") | Ok("ON") + ) +} + +/// Center the window on the primary monitor's work area (Windows). +#[cfg(windows)] +fn center_window(win: &AppWindow) { + #[repr(C)] + struct Rect { + left: i32, + top: i32, + right: i32, + bottom: i32, + } + #[link(name = "user32")] + extern "system" { + fn SystemParametersInfoW(action: u32, uiparam: u32, pvparam: *mut Rect, winini: u32) -> i32; + } + const SPI_GETWORKAREA: u32 = 0x0030; + + let size = win.window().size(); // physical pixels + let mut wa = Rect { left: 0, top: 0, right: 0, bottom: 0 }; + let ok = unsafe { SystemParametersInfoW(SPI_GETWORKAREA, 0, &mut wa, 0) }; + if ok == 0 { + return; + } + let area_w = (wa.right - wa.left).max(0) as u32; + let area_h = (wa.bottom - wa.top).max(0) as u32; + let x = wa.left + ((area_w.saturating_sub(size.width)) / 2) as i32; + let y = wa.top + ((area_h.saturating_sub(size.height)) / 2) as i32; + win.window() + .set_position(slint::PhysicalPosition::new(x, y)); +} + +#[cfg(not(windows))] +fn center_window(_win: &AppWindow) {} + +/// The active terminal tab's current SFTP directory ("" if unknown). +#[cfg(windows)] +fn active_sftp_path(win: &AppWindow, tab_id: &str) -> String { + let model = win.get_terminals(); + if let Some(m) = model.as_any().downcast_ref::>() { + for i in 0..m.row_count() { + if let Some(row) = m.row_data(i) { + if row.id.as_str() == tab_id { + return row.sftp_path.to_string(); + } + } + } + } + String::new() +} + +/// Current mouse cursor position in physical screen pixels (Windows). +#[cfg(windows)] +fn cursor_pos() -> Option<(i32, i32)> { + #[repr(C)] + struct Point { + x: i32, + y: i32, + } + extern "system" { + fn GetCursorPos(p: *mut Point) -> i32; + } + let mut p = Point { x: 0, y: 0 }; + if unsafe { GetCursorPos(&mut p) } != 0 { + Some((p.x, p.y)) + } else { + None + } +} + +/// Handle an OS file drop: if it landed over the SFTP file-list area of the +/// active session tab, upload the file to that tab's current remote directory. +#[cfg(windows)] +fn handle_file_drop(win: &AppWindow, sftp_handles: &SftpHandles, path: String) { + let active = win.get_active_tab_id().to_string(); + if active == "welcome" { + return; + } + let w = win.window(); + let scale = w.scale_factor().max(0.01); + let size = w.size(); // physical + let Some(inner) = w + .with_winit_window(|ww| ww.inner_position().ok()) + .flatten() + else { + return; + }; + let Some((cx, cy)) = cursor_pos() else { + return; + }; + // Drop point in logical client coordinates. + let client_x = (cx - inner.x) as f32 / scale; + let client_y = (cy - inner.y) as f32 / scale; + let w_logical = size.width as f32 / scale; + let h_logical = size.height as f32 / scale; + let h_sftp = win.get_sftp_panel_height(); + + // File-list box (logical): right of the sidebar(220)+tree(160)+sep(1), + // below the tools tab bar(30)+SFTP toolbar(30)+header(20)+sep(1), + // above the status bar(18). + let zone_left = 381.0_f32; + let zone_top = h_logical - h_sftp + 81.0; + let zone_bottom = h_logical - 18.0; + if client_x < zone_left + || client_x > w_logical + || client_y < zone_top + || client_y > zone_bottom + { + return; // dropped outside the file list — ignore + } + + let dir = active_sftp_path(win, &active); + if dir.is_empty() { + return; + } + if let Ok(handles) = sftp_handles.lock() { + if let Some(h) = handles.get(&active) { + h.upload(path, dir); + } + } +} + +#[cfg(not(windows))] +fn handle_file_drop(_win: &AppWindow, _sftp_handles: &SftpHandles, _path: String) {} + +// --------------------------------------------------------------------------- +// Model helpers +// --------------------------------------------------------------------------- + +fn sync_sessions_to_model(store: &ConfigStore, model: &VecModel) { + let mut rows: Vec = store + .sessions() + .iter() + .map(|s| { + let host = if s.kind == SessionKind::Serial { + s.serial_port.clone() + } else { + s.host.clone() + }; + SessionInfo { + id: s.id.clone().into(), + name: s.name.clone().into(), + group: session_group(&s.group).into(), + host: host.into(), + port: s.port as i32, + user: s.user.clone().into(), + auth: s.auth.as_str().into(), + kind: s.kind.as_str().into(), + last_used: s + .last_used + .clone() + .unwrap_or_else(|| "never".to_string()) + .into(), + } + }) + .collect(); + rows.sort_by(|a, b| { + a.group + .as_str() + .cmp(b.group.as_str()) + .then_with(|| a.name.as_str().cmp(b.name.as_str())) + }); + model.set_vec(rows); +} + +fn sync_connection_folders_to_model( + store: &ConfigStore, + folders_model: &VecModel, + names_model: &VecModel, +) { + let folders: Vec = store + .connection_folders() + .iter() + .map(|name| ConnectionFolderInfo { + name: name.clone().into(), + expanded: store.connection_folder_expanded(name), + }) + .collect(); + let names: Vec = store + .connection_folders() + .iter() + .map(|name| name.clone().into()) + .collect(); + folders_model.set_vec(folders); + names_model.set_vec(names); +} + +fn session_group(group: &str) -> String { + let g = group.trim(); + if g.is_empty() { + "Default".to_string() + } else { + g.to_string() + } +} + +fn suggested_sftp_name_column_width(entries: &[RemoteEntry], font_size_px: f32) -> f32 { + let longest = entries + .iter() + .map(|entry| { + entry + .name + .chars() + .map(|ch| if ch.is_ascii() { 1.0 } else { 1.7 }) + .sum::() + }) + .fold(4.0, f32::max); + let font_size = font_size_px.clamp(10.0, 28.0); + (longest * font_size * 0.62 + 46.0).clamp(96.0, 360.0) +} + +fn sync_command_categories_to_model(store: &ConfigStore, model: &VecModel) { + let rows: Vec = store + .command_categories() + .iter() + .map(|c| CommandCategory { + id: c.id.clone().into(), + name: c.name.clone().into(), + builtin: c.builtin, + }) + .collect(); + model.set_vec(rows); +} + +fn sync_commands_to_model(store: &ConfigStore, model: &VecModel) { + let rows: Vec = store + .commands() + .iter() + .map(|c| CommandItem { + id: c.id.clone().into(), + category_id: c.category_id.clone().into(), + name: c.name.clone().into(), + command: c.command.clone().into(), + append_cr: c.append_cr, + }) + .collect(); + model.set_vec(rows); +} + +// --------------------------------------------------------------------------- +// Session callbacks (welcome page + dialog) +// --------------------------------------------------------------------------- + +fn wire_session_callbacks( + window: &AppWindow, + store: Rc>, + sessions_model: Rc>, + connection_folders_model: Rc>, + connection_folder_names_model: Rc>, + tabs_model: Rc>, + terminals_model: Rc>, + handles: Rc>>, + bufs: TermBuffers, + runtime: Arc, + last_term_size: Arc>, + sftp_handles: SftpHandles, + sftp_manual_nav: SftpManualNav, + transfer_seq: TransferSeq, + tab_statuses: TabStatuses, +) { + // New session -> open dialog with blank draft. + let weak = window.as_weak(); + let store_new_session = store.clone(); + window.on_new_session_clicked(move || { + if let Some(w) = weak.upgrade() { + let empty = Session::new_empty(); + let folder = store_new_session + .borrow() + .connection_folders() + .first() + .cloned() + .unwrap_or_else(|| "Default".to_string()); + w.set_dialog_id(empty.id.into()); + w.set_dialog_name("".into()); + w.set_dialog_group(folder.into()); + w.set_dialog_host("".into()); + w.set_dialog_port("22".into()); + w.set_dialog_user("root".into()); + w.set_dialog_auth("password".into()); + w.set_dialog_password("".into()); + w.set_dialog_key_path("".into()); + w.set_dialog_proxy("".into()); + w.set_dialog_kind("ssh".into()); + w.set_dialog_serial_port("".into()); + w.set_dialog_baud("115200".into()); + w.set_dialog_data_bits("8".into()); + w.set_dialog_stop_bits("1".into()); + w.set_dialog_parity("none".into()); + w.set_dialog_flow("none".into()); + w.set_dialog_editing(false); + w.set_dialog_open(true); + } + }); + + { + let weak = window.as_weak(); + let store = store.clone(); + let folders_model = connection_folders_model.clone(); + let folder_names_model = connection_folder_names_model.clone(); + window.on_connection_folder_create(move |name: SharedString| { + let name = name.trim(); + if name.is_empty() { + if let Some(w) = weak.upgrade() { + w.set_folder_error( + t("文件夹名称不能为空", "Folder name is required").into(), + ); + } + return; + } + let folder = { + let mut s = store.borrow_mut(); + let folder = s.add_connection_folder(name.to_string()); + if let Err(err) = s.save() { + tracing::warn!("failed to save connection folder: {err:#}"); + } + folder + }; + sync_connection_folders_to_model( + &store.borrow(), + &folders_model, + &folder_names_model, + ); + if let Some(w) = weak.upgrade() { + w.set_dialog_group(folder.into()); + w.set_folder_draft("".into()); + w.set_folder_error("".into()); + w.set_folder_dialog_open(false); + } + }); + } + + { + let weak = window.as_weak(); + let store = store.clone(); + let sessions_model = sessions_model.clone(); + let folders_model = connection_folders_model.clone(); + let folder_names_model = connection_folder_names_model.clone(); + window.on_connection_folder_rename(move |old_name: SharedString, new_name: SharedString| { + let new_name = new_name.trim(); + if new_name.is_empty() { + if let Some(w) = weak.upgrade() { + w.set_folder_error(t("分类名称不能为空", "Category name is required").into()); + } + return; + } + let folder = { + let mut s = store.borrow_mut(); + let folder = s.rename_connection_folder(old_name.as_str(), new_name.to_string()); + if let Err(err) = s.save() { + tracing::warn!("failed to rename connection folder: {err:#}"); + } + folder + }; + sync_connection_folders_to_model( + &store.borrow(), + &folders_model, + &folder_names_model, + ); + sync_sessions_to_model(&store.borrow(), &sessions_model); + if let Some(w) = weak.upgrade() { + w.set_dialog_group(folder.into()); + w.set_folder_draft("".into()); + w.set_folder_error("".into()); + w.set_folder_dialog_open(false); + } + }); + } + + { + let store = store.clone(); + let sessions_model = sessions_model.clone(); + let folders_model = connection_folders_model.clone(); + let folder_names_model = connection_folder_names_model.clone(); + window.on_connection_folder_delete(move |name: SharedString| { + { + let mut s = store.borrow_mut(); + s.remove_connection_folder(name.as_str()); + if let Err(err) = s.save() { + tracing::warn!("failed to delete connection folder: {err:#}"); + } + } + sync_connection_folders_to_model( + &store.borrow(), + &folders_model, + &folder_names_model, + ); + sync_sessions_to_model(&store.borrow(), &sessions_model); + }); + } + + { + let store = store.clone(); + let folders_model = connection_folders_model.clone(); + let folder_names_model = connection_folder_names_model.clone(); + window.on_connection_folder_toggle(move |name: SharedString| { + { + let mut s = store.borrow_mut(); + s.toggle_connection_folder(name.as_str()); + if let Err(err) = s.save() { + tracing::warn!("failed to save connection folder state: {err:#}"); + } + } + sync_connection_folders_to_model( + &store.borrow(), + &folders_model, + &folder_names_model, + ); + }); + } + + { + let store = store.clone(); + let sessions_model = sessions_model.clone(); + window.on_session_move_folder(move |id: SharedString, folder: SharedString| { + { + let mut s = store.borrow_mut(); + if s.move_session_to_folder(id.as_str(), folder.as_str()).is_some() { + if let Err(err) = s.save() { + tracing::warn!("failed to move session to folder: {err:#}"); + } + } + } + sync_sessions_to_model(&store.borrow(), &sessions_model); + }); + } + + // Import hosts from ~/.ssh/config -> add them as sessions (skipping dups). + { + let weak = window.as_weak(); + let store = store.clone(); + let sessions_model = sessions_model.clone(); + let folders_model = connection_folders_model.clone(); + let folder_names_model = connection_folder_names_model.clone(); + window.on_import_ssh_config(move || { + let hosts = crate::ssh_config::parse_default(); + let mut added = 0usize; + if hosts.is_empty() { + if let Some(w) = weak.upgrade() { + w.set_ssh_import_hint(t("未找到 ~/.ssh/config", "no ~/.ssh/config found").into()); + } + return; + } + { + let mut s = store.borrow_mut(); + let import_folder = s.add_connection_folder("SSH Config".to_string()); + for h in hosts { + // Skip if a session already has this alias, or the same + // host + user pair. + let dup = s.sessions().iter().any(|x| { + x.name == h.alias || (x.host == h.hostname && x.user == h.user) + }); + if dup { + continue; + } + let auth = if h.identity_file.is_empty() { + AuthMethod::Password + } else { + AuthMethod::Key + }; + s.upsert(Session { + name: h.alias, + host: h.hostname, + port: h.port, + user: if h.user.is_empty() { "root".into() } else { h.user }, + group: import_folder.clone(), + auth, + private_key_path: h.identity_file, + ..Session::new_empty() + }); + added += 1; + } + if added > 0 { + let _ = s.save(); + } + } + sync_connection_folders_to_model(&store.borrow(), &folders_model, &folder_names_model); + sync_sessions_to_model(&store.borrow(), &sessions_model); + if let Some(w) = weak.upgrade() { + let hint = if added > 0 { + format!("{} {}", t("已导入", "imported"), added) + } else { + t("没有新主机可导入", "no new hosts to import").to_string() + }; + w.set_ssh_import_hint(hint.into()); + } + }); + } + + // Edit -> open dialog prefilled. + { + let weak = window.as_weak(); + let store = store.clone(); + window.on_edit_session(move |id: SharedString| { + let id = id.to_string(); + let store = store.borrow(); + let Some(session) = store.get(&id) else { return; }; + if let Some(w) = weak.upgrade() { + w.set_dialog_id(session.id.clone().into()); + w.set_dialog_name(session.name.clone().into()); + w.set_dialog_group(session_group(&session.group).into()); + w.set_dialog_host(session.host.clone().into()); + w.set_dialog_port(session.port.to_string().into()); + w.set_dialog_user(session.user.clone().into()); + w.set_dialog_auth(session.auth.as_str().into()); + // Never echo the stored password back into the UI (issue #10) — + // leave it blank; a blank field on save keeps the existing one. + w.set_dialog_password("".into()); + w.set_dialog_key_path(session.private_key_path.clone().into()); + w.set_dialog_proxy(session.proxy.clone().into()); + w.set_dialog_kind(session.kind.as_str().into()); + w.set_dialog_serial_port(session.serial_port.clone().into()); + w.set_dialog_baud(session.baud_rate.to_string().into()); + w.set_dialog_data_bits(session.data_bits.to_string().into()); + w.set_dialog_stop_bits(session.stop_bits.to_string().into()); + w.set_dialog_parity(session.parity.clone().into()); + w.set_dialog_flow(session.flow_control.clone().into()); + w.set_dialog_editing(true); + w.set_dialog_open(true); + } + }); + } + + // Remove session. + { + let weak = window.as_weak(); + let store = store.clone(); + let sessions_model = sessions_model.clone(); + window.on_remove_session(move |id: SharedString| { + { + let mut s = store.borrow_mut(); + s.remove(&id.to_string()); + if let Err(err) = s.save() { + tracing::warn!("failed to save config: {err:#}"); + } + } + sync_sessions_to_model(&store.borrow(), &sessions_model); + if let Some(w) = weak.upgrade() { + // Touch a property so the list re-renders reliably. + let _ = w.get_sessions(); + } + }); + } + + // Dialog submit -> persist + (optionally) connect. + { + let weak = window.as_weak(); + let store = store.clone(); + let sessions_model = sessions_model.clone(); + window.on_session_dialog_submit(move |draft: SessionDraft| { + let id = draft.id.to_string(); + let auth = AuthMethod::from_str(&draft.auth.to_string()); + let existing = store.borrow().get(&id).cloned(); + let group = store + .borrow() + .connection_folder_or_default(&draft.group.to_string()); + // The edit dialog never echoes the real password (issue #10): a blank + // field while editing means "keep the existing password" rather than + // "clear it". Only overwrite when the user actually typed something. + let password = if auth == AuthMethod::Password { + if draft.password.is_empty() { + existing + .as_ref() + .map(|s| s.password.clone()) + .unwrap_or_default() + } else { + Secret::new(draft.password.to_string()) + } + } else { + Secret::default() + }; + let password_ref = if auth == AuthMethod::Password { + existing + .as_ref() + .map(|s| s.password_ref.clone()) + .unwrap_or_default() + } else { + String::new() + }; + let kind = crate::config::SessionKind::from_str(&draft.kind.to_string()); + // Auto-name: serial → port label, otherwise user@host. + let auto_name = match kind { + crate::config::SessionKind::Serial => { + format!("{} @{}", draft.serial_port, draft.baud_rate) + } + _ => format!("{}@{}", draft.user, draft.host), + }; + // Telnet defaults to port 23, SSH to 22; serial ignores port. + let default_port = if kind == crate::config::SessionKind::Telnet { + 23 + } else { + 22 + }; + let new_session = Session { + id, + name: if draft.name.is_empty() { + auto_name + } else { + draft.name.to_string() + }, + group, + host: draft.host.to_string(), + port: if draft.port <= 0 { + default_port + } else { + draft.port as u16 + }, + user: draft.user.to_string(), + auth, + password, + password_ref, + // Store the key path with forward slashes uniformly. + private_key_path: draft.private_key_path.to_string().replace('\\', "/"), + proxy: draft.proxy.to_string(), + last_used: None, + kind, + serial_port: draft.serial_port.to_string(), + baud_rate: if draft.baud_rate <= 0 { + 115_200 + } else { + draft.baud_rate as u32 + }, + data_bits: draft.data_bits as u8, + stop_bits: draft.stop_bits as u8, + parity: draft.parity.to_string(), + flow_control: draft.flow_control.to_string(), + }; + { + let mut s = store.borrow_mut(); + s.upsert(new_session); + if let Err(err) = s.save() { + tracing::warn!("failed to save config: {err:#}"); + } + } + sync_sessions_to_model(&store.borrow(), &sessions_model); + if let Some(w) = weak.upgrade() { + w.set_dialog_open(false); + } + }); + } + + // Cancel dialog. + { + let weak = window.as_weak(); + window.on_session_dialog_cancel(move || { + if let Some(w) = weak.upgrade() { + w.set_dialog_open(false); + } + }); + } + + // Private-key file picker: pick the private key and store its path with + // forward-slash separators (uniform across Windows/Linux; russh accepts them). + { + let weak = window.as_weak(); + window.on_session_dialog_pick_key(move || { + let mut dialog = rfd::FileDialog::new().set_title(t("选择私钥文件", "Choose private key file")); + // Start in ~/.ssh if it exists. + if let Some(home) = directories::UserDirs::new().map(|u| u.home_dir().join(".ssh")) { + if home.is_dir() { + dialog = dialog.set_directory(home); + } + } + if let Some(file) = dialog.pick_file() { + let path = file.to_string_lossy().replace('\\', "/"); + if let Some(w) = weak.upgrade() { + w.set_dialog_key_path(path.into()); + } + } + }); + } + + // Connect session -> open a new terminal tab. + { + let weak = window.as_weak(); + let store = store.clone(); + let tabs_model = tabs_model.clone(); + let terminals_model = terminals_model.clone(); + let handles = handles.clone(); + let bufs = bufs.clone(); + let runtime = runtime.clone(); + let last_term_size = last_term_size.clone(); + let sftp_handles = sftp_handles.clone(); + let sftp_manual_nav = sftp_manual_nav.clone(); + let tab_statuses = tab_statuses.clone(); + window.on_connect_session(move |id: SharedString| { + let id = id.to_string(); + let session = match store.borrow().get(&id).cloned() { + Some(s) => s, + None => return, + }; + let max_tabs = store.borrow().max_terminal_tabs() as usize; + if tabs_model.row_count() >= max_tabs { + if let Some(w) = weak.upgrade() { + let msg = format!( + "{} {}", + t("窗口数量已达到上限", "Window limit reached"), + max_tabs + ); + w.set_ssh_import_hint(msg.clone().into()); + w.set_conn_state(2); + w.set_connection_state(msg.into()); + } + return; + } + let tab_id = format!("term-{}", uuid::Uuid::new_v4()); + let tab_title = session.name.clone(); + + // Connection label shown in the sidebar / status line, per transport. + let conn_label = match session.kind { + SessionKind::Ssh => format!("{}@{}", session.user, session.host), + SessionKind::Serial => { + format!("{} @{}", session.serial_port, session.baud_rate) + } + SessionKind::Telnet => format!("telnet {}:{}", session.host, session.port), + }; + // Serial / Telnet have no SFTP side-channel. + let has_sftp = session.kind == SessionKind::Ssh; + let known_hosts_path = store.borrow().config_dir().join("known_hosts"); + + // Seed the per-tab status so the sidebar shows "连接中 host" the + // moment this tab becomes active (the `changed active-tab-id` + // handler fires refresh-sidebar right after set_active_tab_id below). + tab_statuses.lock().unwrap().insert( + tab_id.clone(), + TabStatus { + host: conn_label.clone(), + state: 0, + ..Default::default() + }, + ); + + // Register tab + terminal state (SFTP fields start empty/loading). + tabs_model.push(TabInfo { + id: tab_id.clone().into(), + title: tab_title.into(), + kind: "terminal".into(), + connected: false, + }); + terminals_model.push(TerminalState { + id: tab_id.clone().into(), + tab_id: tab_id.clone().into(), + status: t("连接中...", "Connecting...").into(), + spans: ModelRc::from(std::rc::Rc::new(VecModel::::default())), + cursor_row: 0, + cursor_col: 0, + rows_used: 0, + is_alt_screen: false, + mouse_tracking: false, + find_matches: ModelRc::from(std::rc::Rc::new(VecModel::::default())), + selection: ModelRc::from(std::rc::Rc::new(VecModel::::default())), + sftp_path: "/".into(), + sftp_entries: ModelRc::from( + std::rc::Rc::new(VecModel::::default()), + ), + sftp_status: if has_sftp { + t("SFTP 连接中...", "SFTP connecting...").into() + } else { + t("此会话类型不支持 SFTP", "SFTP not available for this session").into() + }, + sftp_loading: has_sftp, + sftp_tree_nodes: ModelRc::from( + std::rc::Rc::new(VecModel::::default()), + ), + }); + // Create vt100 parser for this tab (default 24×80; resized on first + // terminal-resize callback). 5000-line scrollback is stored for + // future scroll-navigation support. + let is_dark_now = weak.upgrade().map(|w| w.get_dark_mode()).unwrap_or(true); + bufs.lock().unwrap().insert( + tab_id.clone(), + TermBuffer { + parser: vt100::Parser::new(24, 80, 5000), + find_query: String::new(), + is_dark: is_dark_now, + sel_anchor: None, + sel_focus: None, + history: Vec::new(), + prev: Vec::new(), + view_offset: 0, + displayed_text: Vec::new(), + csi_state: CsiState::Normal, + }, + ); + // Start in cd-auto-follow mode (flag = false → follow cd). + sftp_manual_nav.lock().unwrap().insert(tab_id.clone(), false); + if let Some(w) = weak.upgrade() { + w.set_active_tab_id(tab_id.clone().into()); + } + + // Spawn SSH shell worker. + // + // Pass the current best-known terminal dimensions so the remote PTY + // is opened at (approximately) the right size. The resize callback + // will fire again shortly and send an accurate window_change if + // needed. + let (initial_cols, initial_rows) = *last_term_size.lock().unwrap(); + let (handle, rx) = match session.kind { + SessionKind::Ssh => spawn_session( + runtime.handle(), + tab_id.clone(), + session.clone(), + known_hosts_path.clone(), + initial_cols, + initial_rows, + ), + SessionKind::Serial => crate::serial::spawn_serial_session( + runtime.handle(), + tab_id.clone(), + session.clone(), + ), + SessionKind::Telnet => crate::telnet::spawn_telnet_session( + runtime.handle(), + tab_id.clone(), + session.clone(), + initial_cols, + initial_rows, + ), + }; + handles.borrow_mut().insert(tab_id.clone(), handle); + + // Spawn separate SFTP connection for the same session. + // The SFTP worker pushes SessionEvent::SftpEntries / SftpStatus + // back via the same receiver channel (rx) — no second receiver + // needed because spawn_sftp accepts an UnboundedSender clone. + // Only SSH sessions get an SFTP side-channel; Serial / Telnet skip it. + let sftp_evt_tx = if has_sftp { + // spawn_session doesn't expose its event sender, so SFTP gets its + // own channel; the dedicated pump below merges its events in. + let (sftp_tx, sftp_rx) = tokio::sync::mpsc::unbounded_channel::(); + let sftp_handle = spawn_sftp(runtime.handle(), session, known_hosts_path, sftp_tx); + sftp_handles.lock().unwrap().insert(tab_id.clone(), sftp_handle); + Some(sftp_rx) + } else { + None + }; + + // --- Shell event pump (dedicated thread) ---------------------- + // Blocks on shell events; handles CwdChanged with 500 ms debounce. + { + let weak_inner = weak.clone(); + let bufs_thread = bufs.clone(); + let sftp_handles_pump = sftp_handles.clone(); + let sftp_manual_nav_pump = sftp_manual_nav.clone(); + let rt_pump = runtime.clone(); + let tab_id_pump = tab_id.clone(); + let statuses_pump = tab_statuses.clone(); + let transfer_seq_pump = transfer_seq.clone(); + std::thread::spawn(move || { + let mut shell_rx = rx; + let mut cwd_debounce: Option> = None; + loop { + match shell_rx.blocking_recv() { + None => break, + Some(shell_evt) => { + if let SessionEvent::CwdChanged(ref cwd) = shell_evt { + let is_manual = sftp_manual_nav_pump + .lock() + .ok() + .and_then(|m| m.get(&tab_id_pump).copied()) + .unwrap_or(false); + if !is_manual { + if let Some(prev) = cwd_debounce.take() { + prev.abort(); + } + let cwd = cwd.clone(); + let sftp_h = sftp_handles_pump.clone(); + let tid = tab_id_pump.clone(); + cwd_debounce = Some(rt_pump.spawn(async move { + tokio::time::sleep( + std::time::Duration::from_millis(500), + ) + .await; + if let Ok(handles) = sftp_h.lock() { + if let Some(h) = handles.get(&tid) { + h.list_dir(cwd); + } + } + })); + } + } + let weak_evt = weak_inner.clone(); + let tid = tab_id_pump.clone(); + let bufs_evt = bufs_thread.clone(); + let st_evt = statuses_pump.clone(); + let seq_evt = transfer_seq_pump.clone(); + let _ = slint::invoke_from_event_loop(move || { + if let Some(win) = weak_evt.upgrade() { + apply_session_event_to_window( + &win, &tid, shell_evt, &bufs_evt, + &st_evt, &seq_evt, + ); + } + }); + } + } + } + }); + } + + // --- SFTP event pump (separate thread) ------------------------- + // Never blocks on shell; dispatches SFTP events the moment they + // arrive so tree/file-list updates are immediate even when the + // terminal is idle. Only present for SSH sessions. + if let Some(sftp_evt_tx) = sftp_evt_tx { + let weak_sftp = weak.clone(); + let bufs_sftp = bufs.clone(); + let tab_id_sftp = tab_id.clone(); + let statuses_sftp = tab_statuses.clone(); + let transfer_seq_sftp = transfer_seq.clone(); + std::thread::spawn(move || { + let mut sftp_rx = sftp_evt_tx; + loop { + match sftp_rx.blocking_recv() { + None => break, + Some(sftp_evt) => { + let weak_s = weak_sftp.clone(); + let tid = tab_id_sftp.clone(); + let bufs_s = bufs_sftp.clone(); + let st_s = statuses_sftp.clone(); + let seq_s = transfer_seq_sftp.clone(); + let _ = slint::invoke_from_event_loop(move || { + if let Some(win) = weak_s.upgrade() { + apply_session_event_to_window( + &win, &tid, sftp_evt, &bufs_s, + &st_s, &seq_s, + ); + } + }); + } + } + } + }); + } + }); + } + +} + +/// Push a value into a fixed-length ring buffer (newest at the end). +fn push_ring(buf: &mut Vec, val: f32) { + if buf.len() != NET_HISTORY_LEN { + *buf = vec![0.0; NET_HISTORY_LEN]; + } + buf.remove(0); + buf.push(val); +} + +/// Auto-scale a raw bytes/sec history to 0..1 against its own window peak so the +/// sparkline always uses the full height (like FinalShell's relative graph). +fn normalized_model(buf: &[f32]) -> ModelRc { + let max = buf.iter().cloned().fold(1.0_f32, f32::max); + let scaled: Vec = buf.iter().map(|v| (v / max).clamp(0.0, 1.0)).collect(); + ModelRc::from(Rc::new(VecModel::from(scaled))) +} + +/// Build the filesystem-usage model (path, "avail/total", used fraction). +fn disk_model(disks: &[(String, u64, u64)]) -> ModelRc { + let rows: Vec = disks + .iter() + .map(|(mount, avail, total)| { + let used = total.saturating_sub(*avail); + let percent = if *total > 0 { + used as f32 / *total as f32 + } else { + 0.0 + }; + DiskInfo { + path: mount.clone().into(), + detail: format!("{}/{}", format_size(*avail), format_size(*total)).into(), + percent, + } + }) + .collect(); + ModelRc::from(Rc::new(VecModel::from(rows))) +} + +fn process_model(processes: &[(u64, f32, String)]) -> ModelRc { + let rows: Vec = processes + .iter() + .take(5) + .map(|(mem_bytes, cpu_percent, name)| ProcessInfo { + mem: format_bytes_short(*mem_bytes).into(), + cpu: format!("{:.0}%", cpu_percent.clamp(0.0, 999.0)).into(), + name: name.clone().into(), + }) + .collect(); + ModelRc::from(Rc::new(VecModel::from(rows))) +} + +fn format_cpu_cores(cores: u32) -> String { + if cores == 0 { + return String::new(); + } + if crate::i18n::is_en() { + let suffix = if cores == 1 { "core" } else { "cores" }; + format!("{cores} {suffix}") + } else { + format!("{cores}核") + } +} + +fn format_server_running(secs: u64) -> String { + let (value, zh_unit, en_unit) = if secs < 3600 { + ((secs / 60).max(1), "分钟", "minute") + } else if secs < 86_400 { + ((secs / 3600).max(1), "小时", "hour") + } else { + ((secs / 86_400).max(1), "天", "day") + }; + + if crate::i18n::is_en() { + let suffix = if value == 1 { "" } else { "s" }; + format!("Server running for {value} {en_unit}{suffix}") + } else { + format!("服务器已运行{value}{zh_unit}") + } +} + +/// Find every (case-insensitive) occurrence of `query` across the currently +/// displayed rows and return highlight rectangles (char index == grid column). +fn compute_find_matches(rows: &[String], query: &str) -> Vec { + let mut out: Vec = Vec::new(); + if query.is_empty() { + return out; + } + let q: Vec = query.chars().map(|c| c.to_ascii_lowercase()).collect(); + if q.is_empty() { + return out; + } + for (r, line) in rows.iter().enumerate() { + let lower: Vec = line.chars().map(|c| c.to_ascii_lowercase()).collect(); + let mut i = 0usize; + while i + q.len() <= lower.len() { + if lower[i..i + q.len()] == q[..] { + out.push(TermMatch { + row: r as i32, + col: i as i32, + len: q.len() as i32, + }); + i += q.len(); + } else { + i += 1; + } + } + } + out +} + +/// Recompute spans + cursor + find/selection highlights for one tab from its +/// current vt100 screen (respecting scrollback) and push them to the model. +/// Used by scroll + selection callbacks (Output has its own equivalent inline). +fn rebuild_tab_display(win: &AppWindow, bufs: &TermBuffers, tab_id: &str) { + let data = { + let mut map = bufs.lock().unwrap(); + let Some(buf) = map.get_mut(tab_id) else { return }; + let cols = buf.parser.screen().size().1; + let b = buf.render(); // also refreshes buf.displayed_text + let matches = compute_find_matches(&buf.displayed_text, &buf.find_query); + let sel = buf.selection_rects_visible(cols); + let mouse = buf.mouse_tracking_enabled(); + (b, matches, sel, mouse) + }; + let (b, matches, sel, mouse) = data; + let spans = ModelRc::from(Rc::new(VecModel::from(b.spans))); + let fm = ModelRc::from(Rc::new(VecModel::from(matches))); + let sm = ModelRc::from(Rc::new(VecModel::from(sel))); + let (cr, cc, ru, alt) = (b.cursor_row, b.cursor_col, b.rows_used, b.is_alt); + set_terminal_row(win, tab_id, move |row| { + row.spans = spans.clone(); + row.cursor_row = cr; + row.cursor_col = cc; + row.rows_used = ru; + row.is_alt_screen = alt; + row.mouse_tracking = mouse; + row.find_matches = fm.clone(); + row.selection = sm.clone(); + }); +} + +/// Resolve which interface drives the top sparkline: the user's selection if it +/// still exists, otherwise the busiest (the list is sorted busiest-first). +/// Returns (name, rx_bps, tx_bps). +fn selected_iface(st: &TabStatus) -> (String, u64, u64) { + if !st.selected_iface.is_empty() { + if let Some(e) = st.net.iter().find(|e| e.0 == st.selected_iface) { + return e.clone(); + } + } + st.net.first().cloned().unwrap_or_default() +} + +/// Recompute the whole sidebar for whichever tab is active. Only live session +/// tabs show resource data; welcome/connecting/disconnected states stay empty. +/// Must run on the Slint event loop thread. +fn refresh_sidebar( + win: &AppWindow, + statuses: &TabStatuses, +) { + let pct = |used: u64, total: u64| -> f32 { + if total > 0 { + used as f32 / total as f32 + } else { + 0.0 + } + }; + let empty_history = + || ModelRc::from(Rc::new(VecModel::from(vec![0.0_f32; NET_HISTORY_LEN]))); + let clear_remote_data = |win: &AppWindow| { + win.set_net_top_up("".into()); + win.set_net_top_down("".into()); + win.set_net_top_history(empty_history()); + win.set_net_show_selector(false); + win.set_net_selected("".into()); + win.set_net_ifaces(ModelRc::from(Rc::new(VecModel::::default()))); + win.set_disks(disk_model(&[])); + win.set_processes(process_model(&[])); + }; + let clear_stats = |win: &AppWindow| { + win.set_cpu_percent(0.0); + win.set_mem_percent(0.0); + win.set_swap_percent(0.0); + win.set_cpu_detail("".into()); + win.set_mem_detail("".into()); + win.set_swap_detail("".into()); + }; + + let active = win.get_active_tab_id().to_string(); + let status = if active == "welcome" { + None + } else { + statuses.lock().unwrap().get(&active).cloned() + }; + + match status { + // A live session tab → remote resources + remote NIC on top. + Some(st) if st.state == 1 => { + win.set_conn_state(1); + let running_secs = if st.uptime_secs > 0 { + st.uptime_secs + } else { + st.connected_since + .map(|at| at.elapsed().as_secs()) + .unwrap_or(0) + }; + win.set_connection_state(format_server_running(running_secs).into()); + win.set_resource_title(t("服务器资源", "Server resources").into()); + win.set_cpu_percent(st.cpu); + win.set_mem_percent(pct(st.mem_used_kib, st.mem_total_kib)); + win.set_swap_percent(pct(st.swap_used_kib, st.swap_total_kib)); + win.set_cpu_detail(format_cpu_cores(st.cpu_cores).into()); + win.set_mem_detail( + format_mem_mib(st.mem_used_kib / 1024, st.mem_total_kib / 1024).into(), + ); + win.set_swap_detail( + format_mem_mib(st.swap_used_kib / 1024, st.swap_total_kib / 1024).into(), + ); + let (name, rx, tx) = selected_iface(&st); + win.set_net_top_up(format_bytes_per_sec(tx).into()); + win.set_net_top_down(format_bytes_per_sec(rx).into()); + win.set_net_top_history(normalized_model(&st.net_hist)); + win.set_net_show_selector(!st.net.is_empty()); + win.set_net_selected(name.into()); + let ifaces: Vec = + st.net.iter().map(|e| e.0.clone().into()).collect(); + win.set_net_ifaces(ModelRc::from(Rc::new(VecModel::from(ifaces)))); + win.set_disks(disk_model(&st.disks)); + win.set_processes(process_model(&st.processes)); + } + // Disconnected / timed-out session. + Some(st) if st.state == 2 => { + win.set_conn_state(2); + win.set_connection_state(format!("{} {}", st.host, t("已断开", "disconnected")).into()); + win.set_resource_title(t("服务器资源", "Server resources").into()); + clear_stats(win); + clear_remote_data(win); + } + // Still connecting. + Some(st) => { + win.set_conn_state(0); + win.set_connection_state(format!("{} {}", t("连接中", "Connecting"), st.host).into()); + win.set_resource_title(t("服务器资源", "Server resources").into()); + clear_stats(win); + clear_remote_data(win); + } + // Welcome tab (or unknown) → no local sampling or display. + None => { + win.set_conn_state(0); + win.set_connection_state(t("未连接", "Not connected").into()); + win.set_resource_title("".into()); + clear_stats(win); + clear_remote_data(win); + } + } +} + +/// Apply a session event to the live UI models. Must be called on the Slint +/// event loop thread. +fn apply_session_event_to_window( + win: &AppWindow, + tab_id: &str, + event: SessionEvent, + bufs: &TermBuffers, + statuses: &TabStatuses, + transfer_seq: &TransferSeq, +) { + let tabs_rc = win.get_tabs(); + let terminals_rc = win.get_terminals(); + // `ModelRc::as_any` lets us downcast to the concrete `VecModel`. + let tabs = tabs_rc + .as_any() + .downcast_ref::>() + .expect("tabs model must be a VecModel"); + let terminals = terminals_rc + .as_any() + .downcast_ref::>() + .expect("terminals model must be a VecModel"); + let update_terminal = |mutator: &dyn Fn(&mut TerminalState)| { + for i in 0..terminals.row_count() { + if let Some(mut row) = terminals.row_data(i) { + if row.id.as_str() == tab_id { + mutator(&mut row); + terminals.set_row_data(i, row); + break; + } + } + } + }; + let update_tab = |mutator: &dyn Fn(&mut TabInfo)| { + for i in 0..tabs.row_count() { + if let Some(mut row) = tabs.row_data(i) { + if row.id.as_str() == tab_id { + mutator(&mut row); + tabs.set_row_data(i, row); + break; + } + } + } + }; + + match event { + SessionEvent::Status(status) => { + update_terminal(&|t| t.status = status.clone().into()); + } + SessionEvent::Output(chunk) => { + // Feed raw bytes into the vt100 parser. vt100 correctly handles + // cursor movement, \r + line-redraw (readline), \x1b[K (erase to + // EOL), alternate-screen switching, and all VT100/xterm sequences. + // We then split the rendered screen at cursor_position() so Slint + // can insert the blinking "█" at the exact cursor cell. + let built = { + let mut map = bufs.lock().unwrap(); + if let Some(buf) = map.get_mut(tab_id) { + // Capture scrolled-off lines into history, then render the + // current view (live or scrolled-back). + buf.ingest(chunk.as_bytes()); + let cols = buf.parser.screen().size().1; + let b = buf.render(); // refreshes buf.displayed_text + let matches = compute_find_matches(&buf.displayed_text, &buf.find_query); + let sel = buf.selection_rects_visible(cols); + let mouse = buf.mouse_tracking_enabled(); + Some((b, matches, sel, mouse)) + } else { + None + } + }; + if let Some((b, matches, sel, mouse)) = built { + let spans_model: ModelRc = + ModelRc::from(std::rc::Rc::new(VecModel::from(b.spans))); + let matches_model: ModelRc = + ModelRc::from(std::rc::Rc::new(VecModel::from(matches))); + let sel_model: ModelRc = + ModelRc::from(std::rc::Rc::new(VecModel::from(sel))); + let (cur_row, cur_col, rows_used, is_alt) = + (b.cursor_row, b.cursor_col, b.rows_used, b.is_alt); + update_terminal(&|t| { + t.spans = spans_model.clone(); + t.cursor_row = cur_row; + t.cursor_col = cur_col; + t.rows_used = rows_used; + t.is_alt_screen = is_alt; + t.mouse_tracking = mouse; + t.find_matches = matches_model.clone(); + t.selection = sel_model.clone(); + }); + } + } + SessionEvent::Connected => { + update_tab(&|t| t.connected = true); + update_terminal(&|t| t.status = crate::i18n::t("已连接", "Connected").into()); + if let Some(st) = statuses.lock().unwrap().get_mut(tab_id) { + st.state = 1; + if st.connected_since.is_none() { + st.connected_since = Some(Instant::now()); + } + } + if win.get_active_tab_id().as_str() == tab_id { + refresh_sidebar(win, statuses); + } + } + SessionEvent::Closed(reason) => { + update_tab(&|t| t.connected = false); + update_terminal(&|t| t.status = format!("{} — {reason}", crate::i18n::t("已断开", "Disconnected")).into()); + if let Some(st) = statuses.lock().unwrap().get_mut(tab_id) { + st.state = 2; + st.connected_since = None; + } + if win.get_active_tab_id().as_str() == tab_id { + refresh_sidebar(win, statuses); + } + } + SessionEvent::ResourceStats { + cpu_percent, + cpu_cores, + mem_used_kib, + mem_total_kib, + swap_used_kib, + swap_total_kib, + uptime_secs, + net, + disks, + processes, + } => { + if let Some(st) = statuses.lock().unwrap().get_mut(tab_id) { + st.cpu = cpu_percent; + st.cpu_cores = cpu_cores; + st.mem_used_kib = mem_used_kib; + st.mem_total_kib = mem_total_kib; + st.swap_used_kib = swap_used_kib; + st.swap_total_kib = swap_total_kib; + st.uptime_secs = uptime_secs; + st.net = net; + st.disks = disks; + st.processes = processes; + // A sample means the channel is alive → treat as connected. + if st.state != 1 { + st.state = 1; + st.connected_since = Some(Instant::now()); + } else if st.connected_since.is_none() { + st.connected_since = Some(Instant::now()); + } + // Append the selected interface's total rate to its sparkline. + let (_, rx, tx) = selected_iface(st); + push_ring(&mut st.net_hist, (rx + tx) as f32); + } + if win.get_active_tab_id().as_str() == tab_id { + refresh_sidebar(win, statuses); + } + } + + // --- SFTP events --------------------------------------------------- + SessionEvent::CwdChanged(path) => { + // Just update the displayed path; the pump thread already sent + // SftpCommand::ListDir so a SftpEntries event is inbound. + update_terminal(&|t| { + t.sftp_path = path.clone().into(); + t.sftp_loading = true; + }); + } + SessionEvent::SftpEntries { path, entries } => { + if !win.get_sftp_name_width_manual() { + let suggested = + suggested_sftp_name_column_width(&entries, win.get_sftp_file_font_size()); + win.set_sftp_name_column_width(suggested); + } + let slint_entries: Vec = entries + .iter() + .map(|e| SftpEntry { + name: e.name.clone().into(), + full_path: e.full_path.clone().into(), + is_dir: e.is_dir, + size: if e.is_dir { + "".into() + } else { + format_size(e.size).into() + }, + modified: format_mtime(e.modified).into(), + }) + .collect(); + let model = ModelRc::from( + std::rc::Rc::new(VecModel::from(slint_entries)), + ); + update_terminal(&|t| { + t.sftp_path = path.clone().into(); + t.sftp_entries = model.clone(); + t.sftp_loading = false; + }); + } + SessionEvent::SftpStatus(msg) => { + update_terminal(&|t| t.sftp_status = msg.clone().into()); + } + SessionEvent::SftpTreeUpdate(nodes) => { + let slint_nodes: Vec = nodes + .iter() + .map(|n| SftpTreeNode { + path: n.path.clone().into(), + name: n.name.clone().into(), + depth: n.depth as i32, + expanded: n.expanded, + has_children: n.has_children, + }) + .collect(); + let model = ModelRc::from(std::rc::Rc::new(VecModel::from(slint_nodes))); + update_terminal(&|t| t.sftp_tree_nodes = model.clone()); + } + SessionEvent::SftpTransfer { + id, + name, + is_upload, + transferred, + total, + state, + msg, + } => { + win.set_download_open(true); + win.set_settings_open(false); + win.set_config_open(false); + let detail = match state { + 2 => { + if msg.is_empty() { + t("失败", "Failed").to_string() + } else { + msg + } + } + 1 => t("已完成", "Done").to_string(), + _ => { + if total > 0 { + format!("{}/{}", format_size(transferred), format_size(total)) + } else { + format_size(transferred) + } + } + }; + let percent = if state == 1 { + 1.0 + } else if total > 0 { + (transferred as f32 / total as f32).clamp(0.0, 1.0) + } else { + 0.0 + }; + if let Some(model) = win + .get_transfers() + .as_any() + .downcast_ref::>() + { + let mut found = None; + let mut display_name = name; + for i in 0..model.row_count() { + if let Some(row) = model.row_data(i) { + if row.id.as_str() == id.as_str() { + display_name = row.name.to_string(); + found = Some(i); + break; + } + } + } + if found.is_none() { + let seq = transfer_seq.fetch_add(1, Ordering::Relaxed) + 1; + display_name = format!("#{seq} {display_name}"); + } + let rec = TransferInfo { + id: id.clone().into(), + name: display_name.into(), + detail: detail.into(), + percent, + state: state as i32, + is_upload, + }; + match found { + Some(i) => model.set_row_data(i, rec), + None => model.insert(0, rec), // newest at top + } + } + } + } +} + +// --------------------------------------------------------------------------- +// Tab callbacks +// --------------------------------------------------------------------------- + +const TAB_VISIBLE_COUNT: usize = 5; + +fn keep_tab_visible(window: &AppWindow, tabs_model: &VecModel, target_id: &str) { + let row_count = tabs_model.row_count(); + if row_count <= TAB_VISIBLE_COUNT { + window.set_tab_visible_start(0); + return; + } + + let Some(idx) = (0..row_count).find(|i| { + tabs_model + .row_data(*i) + .map(|tab| tab.id.as_str() == target_id) + .unwrap_or(false) + }) else { + return; + }; + + let max_start = row_count.saturating_sub(TAB_VISIBLE_COUNT); + let current_start = window.get_tab_visible_start().max(0) as usize; + let mut start = current_start.min(max_start); + if idx < start { + start = idx; + } else if idx >= start + TAB_VISIBLE_COUNT { + start = idx + 1 - TAB_VISIBLE_COUNT; + } + window.set_tab_visible_start(start.min(max_start) as i32); +} + +fn select_relative_terminal_tab( + window: &AppWindow, + tabs_model: &VecModel, + direction: isize, +) { + let mut terminal_ids = Vec::new(); + for i in 0..tabs_model.row_count() { + if let Some(tab) = tabs_model.row_data(i) { + if tab.kind.as_str() != "welcome" { + terminal_ids.push(tab.id.to_string()); + } + } + } + if terminal_ids.is_empty() { + return; + } + + let active = window.get_active_tab_id().to_string(); + let current = terminal_ids.iter().position(|id| id == &active); + let target = match (current, direction < 0) { + (Some(0), true) => terminal_ids.len() - 1, + (Some(i), true) => i - 1, + (Some(i), false) => (i + 1) % terminal_ids.len(), + (None, true) => terminal_ids.len() - 1, + (None, false) => 0, + }; + + let target_id = &terminal_ids[target]; + window.set_active_tab_id(target_id.clone().into()); + keep_tab_visible(window, tabs_model, target_id); +} + +fn wire_tab_callbacks( + window: &AppWindow, + tabs_model: Rc>, + terminals_model: Rc>, + handles: Rc>>, + bufs: TermBuffers, + sftp_handles: SftpHandles, + sftp_manual_nav: SftpManualNav, +) { + // Selecting a tab is already applied inside the Slint callback; we just + // need to keep the C++/Rust state in sync if needed. + { + let weak = window.as_weak(); + let tabs_model = tabs_model.clone(); + window.on_tab_selected(move |id: SharedString| { + // AppWindow.active-tab-id is updated inline in the .slint. + if let Some(w) = weak.upgrade() { + keep_tab_visible(&w, &tabs_model, id.as_str()); + } + }); + } + + { + let weak = window.as_weak(); + let tabs_model = tabs_model.clone(); + let terminals_model = terminals_model.clone(); + let handles = handles.clone(); + let bufs = bufs.clone(); + let sftp_handles = sftp_handles.clone(); + let sftp_manual_nav = sftp_manual_nav.clone(); + window.on_tab_closed(move |id: SharedString| { + let id = id.to_string(); + if id == "welcome" { + return; + } + if let Some(handle) = handles.borrow_mut().remove(&id) { + handle.close(); + } + if let Some(sftp) = sftp_handles.lock().unwrap().remove(&id) { + sftp.close(); + } + sftp_manual_nav.lock().unwrap().remove(&id); + bufs.lock().unwrap().remove(&id); + + // Remove from tabs + terminals models. + let mut idx = None; + for i in 0..tabs_model.row_count() { + if tabs_model + .row_data(i) + .map(|r| r.id.as_str() == id) + .unwrap_or(false) + { + idx = Some(i); + break; + } + } + if let Some(i) = idx { + tabs_model.remove(i); + } + let mut i = terminals_model.row_count(); + while i > 0 { + i -= 1; + if terminals_model + .row_data(i) + .map(|r| r.id.as_str() == id) + .unwrap_or(false) + { + terminals_model.remove(i); + } + } + + // If we closed the active tab, fall back to the welcome page. + if let Some(w) = weak.upgrade() { + let active = w.get_active_tab_id().to_string(); + if active == id { + w.set_active_tab_id("welcome".into()); + w.set_tab_visible_start(0); + } else { + keep_tab_visible(&w, &tabs_model, &active); + } + } + }); + } + + { + let weak = window.as_weak(); + window.on_new_tab_clicked(move || { + if let Some(w) = weak.upgrade() { + w.set_active_tab_id("welcome".into()); + } + }); + } + + { + let weak = window.as_weak(); + let tabs_model = tabs_model.clone(); + window.on_previous_tab_clicked(move || { + if let Some(w) = weak.upgrade() { + select_relative_terminal_tab(&w, &tabs_model, -1); + } + }); + } + + { + let weak = window.as_weak(); + let tabs_model = tabs_model.clone(); + window.on_next_tab_clicked(move || { + if let Some(w) = weak.upgrade() { + select_relative_terminal_tab(&w, &tabs_model, 1); + } + }); + } +} + +// --------------------------------------------------------------------------- +// SFTP callbacks +// --------------------------------------------------------------------------- + +fn wire_sftp_callbacks( + window: &AppWindow, + sftp_handles: SftpHandles, + sftp_manual_nav: SftpManualNav, +) { + // Navigate to a remote path (or ".." to go up one level). + { + let sftp_handles = sftp_handles.clone(); + let sftp_manual_nav = sftp_manual_nav.clone(); + let weak = window.as_weak(); + window.on_sftp_navigate(move |tab_id: SharedString, path: SharedString| { + let tab_id = tab_id.to_string(); + let resolved = if path.as_str() == ".." { + let current = weak.upgrade().and_then(|w| { + let terminals_rc = w.get_terminals(); + let terminals = terminals_rc + .as_any() + .downcast_ref::>()?; + for i in 0..terminals.row_count() { + if let Some(row) = terminals.row_data(i) { + if row.id.as_str() == tab_id { + return Some(row.sftp_path.to_string()); + } + } + } + None + }); + parent_path(¤t.unwrap_or_else(|| "/".to_string())) + } else { + path.to_string() + }; + // Any manual navigation stops cd auto-follow. + sftp_manual_nav.lock().unwrap().insert(tab_id.clone(), true); + if let Ok(handles) = sftp_handles.lock() { + if let Some(h) = handles.get(&tab_id) { + h.list_dir(resolved); + } + } + }); + } + + // Download a remote file. If a download folder is preset in settings, save + // straight there; otherwise fall back to a native folder picker. + { + let sftp_handles = sftp_handles.clone(); + let weak = window.as_weak(); + window.on_sftp_download(move |tab_id: SharedString, remote_path: SharedString| { + let tab_id = tab_id.to_string(); + let remote_path = remote_path.to_string(); + let preset = weak + .upgrade() + .map(|w| w.get_download_dir().to_string()) + .unwrap_or_default(); + if !preset.is_empty() { + if let Ok(handles) = sftp_handles.lock() { + if let Some(h) = handles.get(&tab_id) { + h.download(remote_path, preset); + } + } + return; + } + let sftp_handles = sftp_handles.clone(); + std::thread::spawn(move || { + if let Some(dir) = rfd::FileDialog::new().pick_folder() { + let local_dir = dir.to_string_lossy().to_string(); + if let Ok(handles) = sftp_handles.lock() { + if let Some(h) = handles.get(&tab_id) { + h.download(remote_path, local_dir); + } + } + } + }); + }); + } + + // Upload a local file into the current remote directory. + { + let sftp_handles = sftp_handles.clone(); + window.on_sftp_upload_clicked( + move |tab_id: SharedString, remote_dir: SharedString| { + let tab_id = tab_id.to_string(); + let remote_dir = remote_dir.to_string(); + let sftp_handles = sftp_handles.clone(); + std::thread::spawn(move || { + if let Some(file) = rfd::FileDialog::new().pick_file() { + let local = file.to_string_lossy().to_string(); + if let Ok(handles) = sftp_handles.lock() { + if let Some(h) = handles.get(&tab_id) { + h.upload(local, remote_dir); + } + } + } + }); + }, + ); + } + + // Refresh the current directory listing. + { + let sftp_handles = sftp_handles.clone(); + window.on_sftp_refresh(move |tab_id: SharedString, path: SharedString| { + let tab_id = tab_id.to_string(); + let path = path.to_string(); + if let Ok(handles) = sftp_handles.lock() { + if let Some(h) = handles.get(&tab_id) { + h.list_dir(path); + } + } + }); + } + + // Toggle tree node expand/collapse and navigate to that directory. + { + let sftp_handles = sftp_handles.clone(); + let sftp_manual_nav = sftp_manual_nav.clone(); + window.on_sftp_tree_expand(move |tab_id: SharedString, path: SharedString| { + let tab_id = tab_id.to_string(); + let path = path.to_string(); + // Manual tree navigation stops cd auto-follow. + sftp_manual_nav.lock().unwrap().insert(tab_id.clone(), true); + if let Ok(handles) = sftp_handles.lock() { + if let Some(h) = handles.get(&tab_id) { + h.toggle_tree_node(path.clone()); + h.list_dir(path); + } + } + }); + } + + // Context menu → 删除 a remote file. The irreversible-delete confirmation + // (#28) is handled by the in-app ConfirmDialog in the UI layer, so by the + // time this fires the user has already confirmed. + { + let sftp_handles = sftp_handles.clone(); + window.on_sftp_delete(move |tab_id: SharedString, path: SharedString| { + if let Ok(handles) = sftp_handles.lock() { + if let Some(h) = handles.get(tab_id.as_str()) { + h.delete(path.to_string()); + } + } + }); + } + + // Context menu → 重命名 / 编辑 (open + auto-reupload). + { + let sftp_handles = sftp_handles.clone(); + window.on_sftp_rename( + move |tab_id: SharedString, path: SharedString, new_name: SharedString| { + let new_name = new_name.trim().to_string(); + if new_name.is_empty() { + return; + } + if let Ok(handles) = sftp_handles.lock() { + if let Some(h) = handles.get(tab_id.as_str()) { + h.rename(path.to_string(), new_name); + } + } + }, + ); + } + { + let sftp_handles = sftp_handles.clone(); + window.on_sftp_edit(move |tab_id: SharedString, path: SharedString| { + if let Ok(handles) = sftp_handles.lock() { + if let Some(h) = handles.get(tab_id.as_str()) { + h.open_temp(path.to_string(), true); + } + } + }); + } +} + +// --------------------------------------------------------------------------- +// Raw keystroke forwarding and PTY resize +// --------------------------------------------------------------------------- + +fn wire_key_input( + window: &AppWindow, + handles: Rc>>, + bufs: TermBuffers, + last_term_size: Arc>, +) { + // Forward each keystroke as raw bytes to the SSH PTY. The server's bash / + // readline handles echo, history (↑↓), Tab completion, Ctrl+C, etc. + { + let handles = handles.clone(); + let bufs = bufs.clone(); + // Shared timestamp: the last time the Shift key alone was pressed + // (key="", shift=true). Used by the time-based Backspace filter below. + let last_shift_time: Arc>> = + Arc::new(Mutex::new(None)); + window.on_send_key(move |tab_id: SharedString, key: SharedString, ctrl: bool, alt: bool, shift: bool| { + // Check whether the remote PTY switched to application cursor mode + // (DECCKM, set by nano/vim via \x1b[?1h). In that mode the terminal + // must send \x1bOA/B/C/D instead of \x1b[A/B/C/D. + let app_cursor = { + let mut map = bufs.lock().unwrap(); + match map.get_mut(tab_id.as_str()) { + Some(b) => { + // Typing snaps the view back to the live bottom so the + // user always sees what they're entering. + b.view_offset = 0; + b.parser.screen().application_cursor() + } + None => false, + } + }; + // Never log the raw key string — it can be a password character + // (#15). redact_key keeps control codes but masks printable text. + tracing::debug!( + "send_key tab={} key={} ctrl={} alt={} shift={} app_cursor={}", + tab_id, redact_key(key.as_str()), ctrl, alt, shift, app_cursor + ); + + // ── Shift / Backspace 诊断日志 (info 级, 无需 RUST_LOG=debug) ───── + // 每个 Shift 相关事件都打印 key 的 Unicode 码位,方便对比 + // 左Shift / 右Shift 是否产生不同的 key 字符串。 + if shift || key.as_str() == "\u{0008}" { + // INFO level (no RUST_LOG needed) — must not leak the key text. + // redact_key reveals only control code points (the IME markers + // this diagnostic cares about), masking any printable char that + // could be part of a Shift-typed password symbol (#15). + let codepoints = redact_key(key.as_str()); + let elapsed_ms = last_shift_time + .lock() + .unwrap() + .map(|t| format!("{}ms ago", t.elapsed().as_millis())) + .unwrap_or_else(|| "never".to_string()); + tracing::info!( + "[KEY_DIAG] key={} shift={} ctrl={} alt={} | last_shift={}", + codepoints, shift, ctrl, alt, elapsed_ms + ); + } + + // ── Track lone-Shift presses for the time-based Backspace filter ── + // Slint sends key="" (empty string) when a bare modifier key (Shift, + // Ctrl, Alt) is pressed. We record the timestamp whenever Shift + // alone fires so the filter below can catch IME-injected Backspace + // events even if they arrive with shift=false. + if key.as_str().is_empty() && shift && !ctrl && !alt { + *last_shift_time.lock().unwrap() = Some(std::time::Instant::now()); + tracing::info!("[KEY_DIAG] lone-Shift recorded → timestamp saved"); + } + + // ── 拦截百度拼音注入的 Shift 标记字符(核心修复)──────────────────── + // 诊断日志证实,百度拼音通过 WH_KEYBOARD_LL 钩子,在 Shift 键按下时 + // 向消息队列注入一个 C0 控制字符,而非空字符串: + // + // 左 Shift → U+0015 (Ctrl+U / NAK), shift=true, ctrl=false + // 右 Shift → U+0010 (Ctrl+P / DLE), shift=true, ctrl=false + // 紧接着注入: U+0008 (Backspace), shift=false + // + // 这些字符绝对不应送入 PTY: + // 0x15 (Ctrl+U) 在 bash/vim 中会清空当前输入行 → "左Shift替换字符" + // 0x10 (Ctrl+P) 在 vim 中翻历史/触发补全 → "右Shift乱跳" + // 0x08 (Backspace) 紧随其后 → "右Shift删除字符" + // + // 合法独立 C0 键(Backspace=0x08, Tab=0x09, LF=0x0A, CR=0x0D, + // ESC=0x1B)不受此过滤影响,由下方代码单独处理。 + // + // 检测到 IME Shift 标记后,记录时间戳,让 Layer 2 在 1500ms 内 + // 拦截随后可能到来的 Backspace(右Shift场景,日志显示间隔约 914ms)。 + if !ctrl && !alt { + if let Some(c) = key.as_str().chars().next() { + let cp = c as u32; + let is_standalone = matches!(cp, 0x08 | 0x09 | 0x0A | 0x0D | 0x1B); + if key.as_str().chars().count() == 1 + && (0x01..=0x1f).contains(&cp) + && !is_standalone + { + *last_shift_time.lock().unwrap() = Some(std::time::Instant::now()); + tracing::info!( + "[KEY_DIAG] DROPPED IME C0 marker U+{:04X} (shift={}) → timestamp saved", + cp, shift + ); + return; + } + } + } + + // ── Windows: filter synthetic Ctrl+char injections ────────────── + // Some keyboards / IME drivers (e.g. Aula F99 + Baidu Pinyin) + // inject a synthetic WM_CHAR 0x11 (Ctrl+Q) when Left Ctrl is + // briefly tapped, WITHOUT sending a WM_KEYDOWN VK_Q beforehand. + // + // FinalShell avoids this because it builds Ctrl+letter from + // WM_KEYDOWN (virtual-key codes). Slint uses WM_CHAR, so it + // sees the injected byte and forwards it straight to us. + // + // Fix: for C0 control chars (Ctrl+A…Ctrl+Z, i.e. 0x01–0x1A), + // use GetKeyState — which returns the key state *as of the last + // processed message*, not the live hardware state — to verify + // the corresponding letter VK was actually queued as a keydown + // before this WM_CHAR arrived. If Q was never keyed down, + // GetKeyState(VK_Q) = 0 → the event is synthetic → drop it. + #[cfg(windows)] + if ctrl { + if let Some(ch) = key.as_str().chars().next() { + let cp = ch as u32; + // Always let Enter / Tab pass through regardless of Ctrl + // state. These C0 codes (0x09 Tab, 0x0a LF, 0x0d CR) are + // "double-duty" keys: pressing Enter while Ctrl is still + // physically held (e.g. just after Ctrl+O in nano) generates + // Ctrl+M (0x0d) with ctrl=true — but GetKeyState(VK_M) is 0 + // because the user never pressed M. Without this exemption + // the filter would silently drop the Enter, making it + // impossible to confirm nano's "File Name to Write:" prompt. + let always_pass = matches!(cp, 0x09 | 0x0a | 0x0d); + if !always_pass + && key.as_str().chars().count() == 1 + && (0x01..=0x1a).contains(&cp) + && !c0_letter_key_down(cp) + { + tracing::debug!( + "send_key: dropped synthetic Ctrl+{} \ + (VK_{:02X} not down per GetKeyState)", + (0x40u8 + cp as u8) as char, + cp + 0x40 + ); + return; + } + } + } + + // ── Filter synthetic Backspace injected by Chinese IME ──────────── + // Baidu Pinyin (and similar Chinese IMEs) hooks the keyboard at the + // driver level via WH_KEYBOARD_LL, below Win32's ImmDisableIME. + // When the user presses Shift to switch from Chinese to English mode + // while a pinyin syllable is in-flight, the IME: + // 1. Cancels the composition (discards the syllable). + // 2. Posts WM_KEYDOWN VK_BACK + WM_CHAR 0x08 to erase whatever + // character it had already forwarded to the app. + // + // Three-layer defence: + // + // Layer 1 – shift=true guard. + // The synthetic Backspace arrives during Shift keydown, so + // GetKeyState(VK_SHIFT) is still "down" → Slint reports shift=true. + // Drop any Backspace (0x08) arriving while Shift is flagged. + // + // Layer 2 – time-based guard. + // Baidu Pinyin posts WM_CHAR 0x08 asynchronously, so by the time + // the message is dequeued Shift may already read as "up" + // → shift=false defeats Layer 1. + // Mitigation: we recorded the timestamp when the Shift key alone + // was pressed (key="", shift=true) a few lines above. Drop any + // Backspace arriving within 200 ms of that moment. + // + // Layer 3 – GetKeyState guard (belt-and-suspenders). + // If VK_BACK is not actually "down" (i.e. no real WM_KEYDOWN + // VK_BACK was ever queued), the Backspace must be synthetic. + if key.as_str() == "\u{0008}" && !ctrl && !alt { + // Layer 1 + if shift { + tracing::info!("[KEY_DIAG] Backspace DROPPED by layer-1 (shift=true)"); + return; + } + // Layer 2 — 时间窗口 1500ms + // 日志显示百度拼音注入 U+0010(右Shift标记) 到 Backspace 之间 + // 间隔约 914ms,因此窗口设为 1500ms 以覆盖该场景。 + let (shift_just_pressed, elapsed_ms) = { + let guard = last_shift_time.lock().unwrap(); + match *guard { + Some(t) => { + let ms = t.elapsed().as_millis(); + (ms < 1500, ms) + } + None => (false, 0), + } + }; + if shift_just_pressed { + tracing::info!( + "[KEY_DIAG] Backspace DROPPED by layer-2 ({}ms after IME Shift marker)", + elapsed_ms + ); + return; + } + // Layer 3 + #[cfg(windows)] + if !is_vk_back_down() { + tracing::info!("[KEY_DIAG] Backspace DROPPED by layer-3 (VK_BACK not down)"); + return; + } + tracing::info!("[KEY_DIAG] Backspace PASSED all filters → sent to PTY"); + } + + let bytes = key_to_pty_bytes(key.as_str(), ctrl, alt, app_cursor); + // Log only the length — never the keystroke bytes, which can be + // password characters (#15). + tracing::debug!( + "send_key len={} handle_exists={}", + bytes.len(), + handles.borrow().contains_key(tab_id.as_str()), + ); + if !bytes.is_empty() { + if let Some(handle) = handles.borrow().get(tab_id.as_str()) { + handle.send_raw(bytes); + } + } + }); + } + + // Terminal mouse reporting for tmux/vim/mc/etc. vt100 tracks whether the + // remote enabled xterm mouse mode; we only emit mouse bytes while that mode + // is active so normal shell clicks keep working as selection/context-menu. + { + let handles = handles.clone(); + let bufs_mouse = bufs.clone(); + window.on_terminal_mouse_event( + move |tab_id: SharedString, kind: SharedString, button: i32, row: i32, col: i32| { + let bytes = { + let map = bufs_mouse.lock().unwrap(); + let Some(buf) = map.get(tab_id.as_str()) else { return }; + let screen = buf.parser.screen(); + let (rows, cols) = screen.size(); + terminal_mouse_event_bytes( + screen.mouse_protocol_mode(), + screen.mouse_protocol_encoding(), + kind.as_str(), + button, + row, + col, + rows, + cols, + ) + }; + let Some(bytes) = bytes else { return }; + if let Some(handle) = handles.borrow().get(tab_id.as_str()) { + handle.send_raw(bytes); + } + }, + ); + } + + // Propagate PTY resize to the SSH worker and vt100 parser. Pixel + // dimensions come from Slint; we approximate col/row counts using + // Consolas 13px metrics. + // + // terminal_view.slint now passes the FocusScope height (not the full + // TerminalView height), so the SFTP panel is already excluded. + // Layout breakdown for the FocusScope: + // 16 px – bottom strip (TouchArea for focus-regain) + // 8 px – y-offset of the output Text element inside the Flickable + // = 24 px total vertical chrome within FocusScope + // + // Consolas 13 px renders at ≈ 8 px wide × 16 px tall per cell. + { + let handles = handles.clone(); + let bufs_resize = bufs.clone(); // keep bufs alive for the copy handler below + // The Slint side now measures the real Consolas cell size (via a hidden + // probe Text) and passes whole column/row counts directly, so there is + // no pixel→cell guesswork here. This keeps full-screen programs like + // nano from over-counting rows and clipping their bottom shortcut bar. + window.on_terminal_resize(move |tab_id: SharedString, cols_f: f32, rows_f: f32| { + let cols = (cols_f as u32).max(10); + let rows = (rows_f as u32).max(5); + tracing::debug!( + "terminal_resize tab={} cols={} rows={}", + tab_id, cols, rows + ); + // Keep the shared size up-to-date so future connections start + // with the correct PTY dimensions. + *last_term_size.lock().unwrap() = (cols, rows); + if let Some(handle) = handles.borrow().get(tab_id.as_str()) { + handle.resize(cols, rows); + } + if let Some(buf) = bufs_resize.lock().unwrap().get_mut(tab_id.as_str()) { + let (old_rows, old_cols) = buf.parser.screen().size(); + let new_rows = rows as u16; + // Shrinking the grid (e.g. dragging the SFTP panel up) makes + // vt100's set_size truncate rows from the BOTTOM — silently + // dropping the most recent output + prompt (#18). To keep the + // bottom (recent) rows we scroll the screen up first, but only + // by as much as is needed to keep the CURSOR on-screen: the rows + // *below* the cursor are unused blank space and can be truncated + // for free. Scrolling by the full delta instead would push real + // content off the top into scrollback whenever the screen wasn't + // full — e.g. a fresh shell with a few prompt lines — leaving a + // blank grid with the cursor stranded at the top, and rapid + // up/down dragging would repeat that until the prompt was gone. + // Skipped on the alternate screen (vim/btop own their buffer). + if new_rows < old_rows && !buf.parser.screen().alternate_screen() { + let (cursor_row, _) = buf.parser.screen().cursor_position(); + // Rows that must scroll off the top to keep the cursor in view. + let scroll = (cursor_row + 1).saturating_sub(new_rows); + if scroll > 0 { + let saved: Vec = { + let s = buf.parser.screen(); + (0..scroll).map(|r| build_row(s, r, old_cols)).collect() + }; + for line in saved { + buf.history.push(line); + } + if buf.history.len() > MAX_HISTORY { + let drop = buf.history.len() - MAX_HISTORY; + buf.history.drain(0..drop); + } + buf.parser.process(format!("\x1b[{scroll}S").as_bytes()); + } + } + buf.parser.set_size(new_rows, cols as u16); + // The pre/post-resize screens differ in size+content; drop the + // scroll-detection snapshot so the next output isn't mis-read as + // a scroll (which would double-capture lines). + buf.prev.clear(); + } + }); + } + + // Ctrl+Shift+C: copy current terminal screen to clipboard. + { + let bufs = bufs.clone(); + window.on_copy_terminal_text(move |tab_id: SharedString| { + let text = { + let map = bufs.lock().unwrap(); + match map.get(tab_id.as_str()) { + Some(buf) => { + // Copy the drag-selection when there is one, else the + // whole displayed screen. + let sel = buf.extract_selection_text(); + if sel.is_empty() { + buf.displayed_text.join("\n") + } else { + sel + } + } + None => String::new(), + } + }; + // Run the clipboard write on a dedicated OS thread. arboard's + // Windows backend opens the clipboard and pumps Win32 messages; + // doing that on the Slint/winit event-loop thread re-enters the + // message loop and dead-locks the whole UI. + std::thread::spawn(move || { + match arboard::Clipboard::new().and_then(|mut cb| cb.set_text(text)) { + Ok(()) => tracing::debug!("copy_terminal: clipboard updated"), + Err(e) => tracing::warn!("copy_terminal: clipboard error: {}", e), + } + }); + }); + } + + // Middle-click / Ctrl+Shift+V: paste clipboard text into PTY. + { + let handles = handles.clone(); + window.on_paste_from_clipboard(move |tab_id: SharedString| { + // Clone the (Send) command sender for this tab so the clipboard read + // can run off the UI thread. Reading arboard on the event-loop + // thread is what froze the app on middle-click / paste — see the + // copy handler above for the deadlock explanation. + let sender = handles + .borrow() + .get(tab_id.as_str()) + .map(|h| h.commands.clone()); + let Some(sender) = sender else { return }; + std::thread::spawn(move || { + match arboard::Clipboard::new().and_then(|mut cb| cb.get_text()) { + Ok(text) => { + let _ = sender.send(SessionCommand::RawInput(text.into_bytes())); + } + Err(e) => tracing::warn!("paste_from_clipboard: clipboard error: {}", e), + } + }); + }); + } + + // Context menu → 清空缓存: reset the local vt100 buffer (drops scrollback), + // wipe the displayed screen, then nudge the remote to redraw a fresh prompt. + { + let bufs_clear = bufs.clone(); + let handles_clear = handles.clone(); + let weak = window.as_weak(); + window.on_clear_terminal(move |tab_id: SharedString| { + let tid = tab_id.to_string(); + if let Some(buf) = bufs_clear.lock().unwrap().get_mut(&tid) { + let (rows, cols) = buf.parser.screen().size(); + buf.parser = vt100::Parser::new(rows, cols, 5000); + buf.find_query.clear(); + buf.history = Vec::new(); // recycle the session scrollback + buf.prev = Vec::new(); + buf.view_offset = 0; + buf.sel_anchor = None; + buf.sel_focus = None; + buf.displayed_text = Vec::new(); + } + if let Some(win) = weak.upgrade() { + set_terminal_row(&win, &tid, |row| { + row.spans = + ModelRc::from(Rc::new(VecModel::::default())); + row.find_matches = + ModelRc::from(Rc::new(VecModel::::default())); + row.selection = + ModelRc::from(Rc::new(VecModel::::default())); + row.cursor_row = 0; + row.cursor_col = 0; + row.rows_used = 0; + row.mouse_tracking = false; + }); + } + if let Some(h) = handles_clear.borrow().get(&tid) { + h.send_raw(vec![0x0c]); // Ctrl+L → shell clears + redraws prompt + } + }); + } + + // Context menu → 查找: store the query and recompute highlight rectangles. + { + let bufs_find = bufs.clone(); + let weak = window.as_weak(); + window.on_find_query_changed(move |tab_id: SharedString, query: SharedString| { + let tid = tab_id.to_string(); + let q = query.to_string(); + let matches = { + let mut map = bufs_find.lock().unwrap(); + if let Some(buf) = map.get_mut(&tid) { + buf.find_query = q.clone(); + compute_find_matches(&buf.displayed_text, &q) + } else { + Vec::new() + } + }; + if let Some(win) = weak.upgrade() { + let model = ModelRc::from(Rc::new(VecModel::from(matches))); + set_terminal_row(&win, &tid, |row| { + row.find_matches = model.clone(); + }); + } + }); + } + + // Mouse-wheel → scroll the scrollback history. + { + let bufs_scroll = bufs.clone(); + let weak = window.as_weak(); + window.on_terminal_scroll(move |tab_id: SharedString, delta: i32| { + let tid = tab_id.to_string(); + { + let mut map = bufs_scroll.lock().unwrap(); + let Some(buf) = map.get_mut(&tid) else { return }; + // Scroll within our own session scrollback (history lines above + // the live screen). Offset 0 = live bottom. + let max_off = buf.history.len() as i64; + let cur = buf.view_offset as i64; + buf.view_offset = (cur + delta as i64).clamp(0, max_off) as usize; + } + if let Some(win) = weak.upgrade() { + rebuild_tab_display(&win, &bufs_scroll, &tid); + } + }); + } + + // Drag-selection lifecycle. + { + let bufs_sel = bufs.clone(); + let weak = window.as_weak(); + window.on_term_select_start(move |tab_id: SharedString, row: i32, col: i32| { + let tid = tab_id.to_string(); + { + let mut map = bufs_sel.lock().unwrap(); + let Some(buf) = map.get_mut(&tid) else { return }; + let (rows, cols) = buf.parser.screen().size(); + let r = row.clamp(0, rows.saturating_sub(1) as i32) as u16; + let c = col.clamp(0, cols.saturating_sub(1) as i32) as u16; + // Anchor + focus in absolute scrollback coordinates. + let abs = buf.vis_to_abs(r); + buf.sel_anchor = Some((abs, c)); + buf.sel_focus = Some((abs, c)); + } + if let Some(win) = weak.upgrade() { + rebuild_tab_display(&win, &bufs_sel, &tid); + } + }); + } + { + let bufs_sel = bufs.clone(); + let weak = window.as_weak(); + window.on_term_select_update(move |tab_id: SharedString, row: i32, col: i32| { + let tid = tab_id.to_string(); + { + let mut map = bufs_sel.lock().unwrap(); + let Some(buf) = map.get_mut(&tid) else { return }; + let (rows, cols) = buf.parser.screen().size(); + let r = row.clamp(0, rows.saturating_sub(1) as i32) as u16; + let c = col.clamp(0, cols.saturating_sub(1) as i32) as u16; + if buf.sel_anchor.is_some() { + let abs = buf.vis_to_abs(r); + buf.sel_focus = Some((abs, c)); + } + } + if let Some(win) = weak.upgrade() { + rebuild_tab_display(&win, &bufs_sel, &tid); + } + }); + } + { + let bufs_sel = bufs.clone(); + let weak = window.as_weak(); + window.on_term_select_end(move |tab_id: SharedString| { + let tid = tab_id.to_string(); + // Extract the selected text; a zero-area selection (a plain click) + // is cleared instead of copied. + let text = { + let mut map = bufs_sel.lock().unwrap(); + let Some(buf) = map.get_mut(&tid) else { return }; + let extracted = buf.extract_selection_text(); + if extracted.is_empty() { + // Zero-area selection (a plain click) → clear it. + buf.sel_anchor = None; + buf.sel_focus = None; + None + } else { + Some(extracted) + } + }; + match text { + Some(t) if !t.is_empty() => { + // Auto-copy on release (select-to-copy, PuTTY style). + std::thread::spawn(move || { + let _ = arboard::Clipboard::new() + .and_then(|mut cb| cb.set_text(t)); + }); + } + _ => {} + } + if let Some(win) = weak.upgrade() { + rebuild_tab_display(&win, &bufs_sel, &tid); + } + }); + } + // Auto-scroll while drag-selecting past the visible top/bottom edge. The + // anchor is in absolute coordinates so it stays pinned no matter how far the + // view moves; we only advance the scrollback view and re-point the focus at + // the absolute row now sitting on the edge the mouse is parked against. + { + let bufs_sel = bufs.clone(); + let weak = window.as_weak(); + window.on_term_select_autoscroll(move |tab_id: SharedString, dir: i32| { + let tid = tab_id.to_string(); + { + let mut map = bufs_sel.lock().unwrap(); + let Some(buf) = map.get_mut(&tid) else { return }; + if buf.sel_anchor.is_none() { + return; + } + let rows = buf.parser.screen().size().0; + let last = rows.saturating_sub(1); + let max_off = buf.history.len(); + let step = 2usize; + // Keep the focus column the user last dragged to. + let focus_col = buf.sel_focus.map(|f| f.1).unwrap_or(0); + let edge_vis = if dir < 0 { + // Mouse above the top → reveal older lines. + let new_off = (buf.view_offset + step).min(max_off); + if new_off == buf.view_offset { + return; // already at the oldest line + } + buf.view_offset = new_off; + 0u16 + } else if dir > 0 { + // Mouse below the bottom → move toward the live tail. + let new_off = buf.view_offset.saturating_sub(step); + if new_off == buf.view_offset { + return; // already at the live bottom + } + buf.view_offset = new_off; + last + } else { + return; + }; + let abs = buf.vis_to_abs(edge_vis); + buf.sel_focus = Some((abs, focus_col)); + } + if let Some(win) = weak.upgrade() { + rebuild_tab_display(&win, &bufs_sel, &tid); + } + }); + } +} + +fn wire_command_callbacks( + window: &AppWindow, + store: Rc>, + command_categories_model: Rc>, + command_items_model: Rc>, + handles: Rc>>, +) { + { + let store = store.clone(); + let weak = window.as_weak(); + window.on_command_category_select(move |id: SharedString| { + let id = id.to_string(); + let name = store + .borrow() + .command_categories() + .iter() + .find(|c| c.id == id) + .map(|c| c.name.clone()) + .unwrap_or_default(); + if let Some(w) = weak.upgrade() { + w.set_command_selected_category(id.into()); + w.set_command_category_draft(name.into()); + clear_command_editor(&w); + } + }); + } + + { + let store = store.clone(); + let categories = command_categories_model.clone(); + let weak = window.as_weak(); + window.on_command_category_add(move |name: SharedString| { + let mut s = store.borrow_mut(); + let id = s.add_command_category(name.to_string()); + if let Err(err) = s.save() { + tracing::warn!("failed to save command category: {err:#}"); + } + sync_command_categories_to_model(&*s, &categories); + let cat_name = s + .command_categories() + .iter() + .find(|c| c.id == id) + .map(|c| c.name.clone()) + .unwrap_or_default(); + drop(s); + if let Some(w) = weak.upgrade() { + w.set_command_selected_category(id.into()); + w.set_command_category_draft(cat_name.into()); + clear_command_editor(&w); + } + }); + } + + { + let store = store.clone(); + let categories = command_categories_model.clone(); + let weak = window.as_weak(); + window.on_command_category_rename(move |id: SharedString, name: SharedString| { + let mut s = store.borrow_mut(); + s.rename_command_category(id.as_str(), name.to_string()); + if let Err(err) = s.save() { + tracing::warn!("failed to rename command category: {err:#}"); + } + sync_command_categories_to_model(&*s, &categories); + if let Some(w) = weak.upgrade() { + w.set_command_category_draft(name.to_string().into()); + } + }); + } + + { + let store = store.clone(); + let categories = command_categories_model.clone(); + let commands = command_items_model.clone(); + let weak = window.as_weak(); + window.on_command_category_delete(move |id: SharedString| { + let mut s = store.borrow_mut(); + s.remove_command_category(id.as_str()); + if let Err(err) = s.save() { + tracing::warn!("failed to delete command category: {err:#}"); + } + sync_command_categories_to_model(&*s, &categories); + sync_commands_to_model(&*s, &commands); + drop(s); + if let Some(w) = weak.upgrade() { + w.set_command_selected_category(DEFAULT_COMMAND_CATEGORY_ID.into()); + w.set_command_category_draft("默认分类".into()); + clear_command_editor(&w); + } + }); + } + + { + let weak = window.as_weak(); + window.on_command_new(move |category: SharedString| { + if let Some(w) = weak.upgrade() { + let category = if category.is_empty() { + DEFAULT_COMMAND_CATEGORY_ID.to_string() + } else { + category.to_string() + }; + w.set_command_selected_category(category.into()); + w.set_command_selected_id("".into()); + w.set_command_edit_id("".into()); + w.set_command_edit_name("".into()); + w.set_command_edit_command("".into()); + w.set_command_edit_append_cr(true); + clear_command_params(&w); + } + }); + } + + { + let store = store.clone(); + let weak = window.as_weak(); + window.on_command_select(move |id: SharedString| { + if let Some(w) = weak.upgrade() { + select_command_in_window(&w, &store.borrow(), id.as_str()); + } + }); + } + + { + let store = store.clone(); + let commands = command_items_model.clone(); + let weak = window.as_weak(); + window.on_command_save( + move |id: SharedString, + category: SharedString, + name: SharedString, + command: SharedString, + append_cr: bool| { + let mut s = store.borrow_mut(); + let category = if category.is_empty() { + DEFAULT_COMMAND_CATEGORY_ID + } else { + category.as_str() + }; + let saved_id = s.upsert_command( + id.as_str(), + category, + name.to_string(), + command.to_string(), + append_cr, + ); + if let Err(err) = s.save() { + tracing::warn!("failed to save managed command: {err:#}"); + } + sync_commands_to_model(&*s, &commands); + if let (Some(w), Some(saved_id)) = (weak.upgrade(), saved_id) { + select_command_in_window(&w, &s, &saved_id); + } + }, + ); + } + + { + let store = store.clone(); + let commands = command_items_model.clone(); + let weak = window.as_weak(); + window.on_command_delete(move |id: SharedString| { + let mut s = store.borrow_mut(); + s.remove_command(id.as_str()); + if let Err(err) = s.save() { + tracing::warn!("failed to delete managed command: {err:#}"); + } + sync_commands_to_model(&*s, &commands); + drop(s); + if let Some(w) = weak.upgrade() { + clear_command_editor(&w); + } + }); + } + + { + let store = store.clone(); + let commands = command_items_model.clone(); + let weak = window.as_weak(); + window.on_command_move(move |id: SharedString, category: SharedString| { + let mut s = store.borrow_mut(); + s.move_command(id.as_str(), category.as_str()); + if let Err(err) = s.save() { + tracing::warn!("failed to move managed command: {err:#}"); + } + sync_commands_to_model(&*s, &commands); + let id = id.to_string(); + if let Some(w) = weak.upgrade() { + select_command_in_window(&w, &s, &id); + } + }); + } + + { + let store = store.clone(); + let handles = handles.clone(); + window.on_command_run( + move |tab_id: SharedString, + command_id: SharedString, + p1: SharedString, + p2: SharedString, + p3: SharedString, + p4: SharedString, + p5: SharedString| { + let rendered = { + let s = store.borrow(); + let Some(cmd) = s.commands().iter().find(|c| c.id == command_id.as_str()) + else { + return; + }; + render_command_with_params( + &cmd.command, + &[ + p1.to_string(), + p2.to_string(), + p3.to_string(), + p4.to_string(), + p5.to_string(), + ], + ) + .map(|command| (command, cmd.append_cr)) + }; + let Some((command, append_cr)) = rendered else { return }; + if send_command_to_handle(&handles, tab_id.as_str(), &command, append_cr) { + let mut s = store.borrow_mut(); + s.remember_command(command); + if let Err(err) = s.save() { + tracing::warn!("failed to remember managed command: {err:#}"); + } + } + }, + ); + } +} + +fn clear_command_editor(win: &AppWindow) { + win.set_command_selected_id("".into()); + win.set_command_edit_id("".into()); + win.set_command_edit_name("".into()); + win.set_command_edit_command("".into()); + win.set_command_edit_append_cr(true); + clear_command_params(win); +} + +fn clear_command_params(win: &AppWindow) { + win.set_command_param1_name("".into()); + win.set_command_param1_value("".into()); + win.set_command_param2_name("".into()); + win.set_command_param2_value("".into()); + win.set_command_param3_name("".into()); + win.set_command_param3_value("".into()); + win.set_command_param4_name("".into()); + win.set_command_param4_value("".into()); + win.set_command_param5_name("".into()); + win.set_command_param5_value("".into()); +} + +fn select_command_in_window(win: &AppWindow, store: &ConfigStore, id: &str) { + let Some(cmd) = store.commands().iter().find(|c| c.id == id) else { + clear_command_editor(win); + return; + }; + win.set_command_selected_id(cmd.id.clone().into()); + win.set_command_selected_category(cmd.category_id.clone().into()); + win.set_command_edit_id(cmd.id.clone().into()); + win.set_command_edit_name(cmd.name.clone().into()); + win.set_command_edit_command(cmd.command.clone().into()); + win.set_command_edit_append_cr(cmd.append_cr); + if let Some(cat) = store + .command_categories() + .iter() + .find(|c| c.id == cmd.category_id) + { + win.set_command_category_draft(cat.name.clone().into()); + } + set_command_param_names(win, &extract_command_params(&cmd.command)); +} + +fn set_command_param_names(win: &AppWindow, params: &[String]) { + clear_command_params(win); + if let Some(p) = params.first() { + win.set_command_param1_name(p.clone().into()); + } + if let Some(p) = params.get(1) { + win.set_command_param2_name(p.clone().into()); + } + if let Some(p) = params.get(2) { + win.set_command_param3_name(p.clone().into()); + } + if let Some(p) = params.get(3) { + win.set_command_param4_name(p.clone().into()); + } + if let Some(p) = params.get(4) { + win.set_command_param5_name(p.clone().into()); + } +} + +fn extract_command_params(command: &str) -> Vec { + let mut params: Vec<(usize, String)> = Vec::new(); + let mut offset = 0usize; + while let Some(rel) = command[offset..].find("[p#") { + let start = offset + rel; + let Some(end_rel) = command[start..].find(']') else { + break; + }; + let end = start + end_rel; + let body = &command[start + 3..end]; + let digits_len = body + .chars() + .take_while(|ch| ch.is_ascii_digit()) + .map(char::len_utf8) + .sum::(); + if digits_len > 0 { + if let Ok(idx) = body[..digits_len].parse::() { + if (1..=5).contains(&idx) && !params.iter().any(|(i, _)| *i == idx) { + let name = body[digits_len..].trim(); + params.push(( + idx, + if name.is_empty() { + format!("参数{idx}") + } else { + name.to_string() + }, + )); + } + } + } + offset = end + 1; + } + params.sort_by_key(|(idx, _)| *idx); + params.into_iter().map(|(_, name)| name).collect() +} + +fn render_command_with_params(command: &str, values: &[String]) -> Option { + let mut out = String::new(); + let mut offset = 0usize; + while let Some(rel) = command[offset..].find("[p#") { + let start = offset + rel; + out.push_str(&command[offset..start]); + let Some(end_rel) = command[start..].find(']') else { + out.push_str(&command[start..]); + return Some(out); + }; + let end = start + end_rel; + let body = &command[start + 3..end]; + let digits_len = body + .chars() + .take_while(|ch| ch.is_ascii_digit()) + .map(char::len_utf8) + .sum::(); + if digits_len == 0 { + out.push_str(&command[start..=end]); + } else if let Ok(idx) = body[..digits_len].parse::() { + if !(1..=5).contains(&idx) { + out.push_str(&command[start..=end]); + offset = end + 1; + continue; + } + let value = values.get(idx.saturating_sub(1))?.trim(); + if value.is_empty() { + return None; + } + out.push_str(&value); + } else { + out.push_str(&command[start..=end]); + } + offset = end + 1; + } + out.push_str(&command[offset..]); + Some(out) +} + +fn send_command_to_handle( + handles: &Rc>>, + tab_id: &str, + command: &str, + append_cr: bool, +) -> bool { + let command = command.trim(); + if command.is_empty() { + return false; + } + let mut bytes = command.as_bytes().to_vec(); + if append_cr { + bytes.push(0x0d); + } + if let Some(handle) = handles.borrow().get(tab_id) { + handle.send_raw(bytes); + true + } else { + false + } +} + +fn terminal_mouse_event_bytes( + mode: vt100::MouseProtocolMode, + encoding: vt100::MouseProtocolEncoding, + kind: &str, + button: i32, + row: i32, + col: i32, + rows: u16, + cols: u16, +) -> Option> { + if matches!(mode, vt100::MouseProtocolMode::None) { + return None; + } + if kind == "up" && matches!(mode, vt100::MouseProtocolMode::Press) { + return None; + } + + let code = match kind { + "down" if (0..=2).contains(&button) => button, + "up" if (0..=2).contains(&button) => { + if matches!(encoding, vt100::MouseProtocolEncoding::Sgr) { + button + } else { + 3 + } + } + "drag" + if (0..=2).contains(&button) + && matches!( + mode, + vt100::MouseProtocolMode::ButtonMotion + | vt100::MouseProtocolMode::AnyMotion + ) => + { + button + 32 + } + "move" if matches!(mode, vt100::MouseProtocolMode::AnyMotion) => 35, + "wheel" if button == 64 || button == 65 => button, + _ => return None, + }; + + let max_row = rows.saturating_sub(1) as i32; + let max_col = cols.saturating_sub(1) as i32; + let y = row.clamp(0, max_row) + 1; + let x = col.clamp(0, max_col) + 1; + + if matches!(encoding, vt100::MouseProtocolEncoding::Sgr) { + let suffix = if kind == "up" { 'm' } else { 'M' }; + return Some(format!("\x1b[<{};{};{}{}", code, x, y, suffix).into_bytes()); + } + + let mut out = b"\x1b[M".to_vec(); + push_x10_mouse_value(&mut out, code as u32, encoding)?; + push_x10_mouse_value(&mut out, x as u32, encoding)?; + push_x10_mouse_value(&mut out, y as u32, encoding)?; + Some(out) +} + +fn push_x10_mouse_value( + out: &mut Vec, + value: u32, + encoding: vt100::MouseProtocolEncoding, +) -> Option<()> { + let codepoint = value + 32; + if matches!(encoding, vt100::MouseProtocolEncoding::Utf8) { + let ch = char::from_u32(codepoint)?; + let mut buf = [0u8; 4]; + out.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes()); + } else { + if codepoint > u8::MAX as u32 { + return None; + } + out.push(codepoint as u8); + } + Some(()) +} + +#[cfg(test)] +mod mouse_event_tests { + use super::terminal_mouse_event_bytes; + + #[test] + fn sgr_click_uses_down_and_release_events() { + assert_eq!( + terminal_mouse_event_bytes( + vt100::MouseProtocolMode::PressRelease, + vt100::MouseProtocolEncoding::Sgr, + "down", + 0, + 4, + 9, + 24, + 80, + ) + .unwrap(), + b"\x1b[<0;10;5M".to_vec() + ); + assert_eq!( + terminal_mouse_event_bytes( + vt100::MouseProtocolMode::PressRelease, + vt100::MouseProtocolEncoding::Sgr, + "up", + 0, + 4, + 9, + 24, + 80, + ) + .unwrap(), + b"\x1b[<0;10;5m".to_vec() + ); + } + + #[test] + fn sgr_drag_requires_motion_mode() { + assert!(terminal_mouse_event_bytes( + vt100::MouseProtocolMode::PressRelease, + vt100::MouseProtocolEncoding::Sgr, + "drag", + 0, + 4, + 9, + 24, + 80, + ) + .is_none()); + assert_eq!( + terminal_mouse_event_bytes( + vt100::MouseProtocolMode::ButtonMotion, + vt100::MouseProtocolEncoding::Sgr, + "drag", + 0, + 4, + 9, + 24, + 80, + ) + .unwrap(), + b"\x1b[<32;10;5M".to_vec() + ); + } +} + +/// Mutate the `TerminalState` whose id matches `tab_id` in the live model. +/// Must run on the Slint event loop thread. +fn set_terminal_row(win: &AppWindow, tab_id: &str, mutator: impl Fn(&mut TerminalState)) { + let terminals = win.get_terminals(); + let Some(model) = terminals.as_any().downcast_ref::>() else { + return; + }; + for i in 0..model.row_count() { + if let Some(mut row) = model.row_data(i) { + if row.id.as_str() == tab_id { + mutator(&mut row); + model.set_row_data(i, row); + break; + } + } + } +} + +/// Convert a Slint `KeyEvent.text` + modifier flags into the byte sequence +/// that the remote PTY expects. +/// +/// Slint uses Unicode Private Use Area (`\u{F700}`…) for special keys. +/// Regular printable characters and C0 control characters are passed as-is. +/// +/// Render a key string for diagnostic logs WITHOUT leaking its content (#15). +/// +/// Any printable character could be a password character, so we never emit it. +/// Only C0/C1 control code points (Backspace, Esc, the IME-injected 0x10/0x15 +/// markers, …) are revealed — those are exactly what the Shift/Backspace IME +/// diagnostics need and are never password material. Printable characters are +/// collapsed to a count, so the logs stay useful without exposing keystrokes. +fn redact_key(key: &str) -> String { + if key.is_empty() { + return "(empty)".to_string(); + } + let mut parts: Vec = Vec::new(); + let mut printable = 0usize; + for c in key.chars() { + let cp = c as u32; + if cp < 0x20 || (0x7f..=0x9f).contains(&cp) { + parts.push(format!("U+{cp:04X}")); + } else { + printable += 1; + } + } + if printable > 0 { + parts.push(format!("<{printable} printable redacted>")); + } + parts.join(",") +} + +/// `app_cursor` mirrors the remote terminal's DECCKM mode (`\x1b[?1h/l`): +/// when true the four arrow keys must use SS3 sequences (`\x1bOA`…) instead +/// of the default CSI sequences (`\x1b[A`…). Full-screen apps like nano and +/// vim set this mode on startup. +fn key_to_pty_bytes(key: &str, ctrl: bool, alt: bool, app_cursor: bool) -> Vec { + // --- Special keys (Slint PUA code points) ------------------------------ + // Arrow keys: respect DECCKM application-cursor mode. + let special: Option<&[u8]> = match key { + "\u{F700}" => Some(if app_cursor { b"\x1bOA" } else { b"\x1b[A" }), // Up + "\u{F701}" => Some(if app_cursor { b"\x1bOB" } else { b"\x1b[B" }), // Down + "\u{F702}" => Some(if app_cursor { b"\x1bOD" } else { b"\x1b[D" }), // Left + "\u{F703}" => Some(if app_cursor { b"\x1bOC" } else { b"\x1b[C" }), // Right + "\u{F729}" => Some(b"\x1b[H"), // Home + "\u{F72B}" => Some(b"\x1b[F"), // End + "\u{F72C}" => Some(b"\x1b[5~"), // PageUp + "\u{F72D}" => Some(b"\x1b[6~"), // PageDown + "\u{F728}" => Some(b"\x1b[3~"), // Delete (forward) + "\u{F704}" => Some(b"\x1bOP"), // F1 + "\u{F705}" => Some(b"\x1bOQ"), // F2 + "\u{F706}" => Some(b"\x1bOR"), // F3 + "\u{F707}" => Some(b"\x1bOS"), // F4 + "\u{F708}" => Some(b"\x1b[15~"), // F5 + "\u{F709}" => Some(b"\x1b[17~"), // F6 + "\u{F70A}" => Some(b"\x1b[18~"), // F7 + "\u{F70B}" => Some(b"\x1b[19~"), // F8 + "\u{F70C}" => Some(b"\x1b[20~"), // F9 + "\u{F70D}" => Some(b"\x1b[21~"), // F10 + "\u{F70E}" => Some(b"\x1b[23~"), // F11 + "\u{F70F}" => Some(b"\x1b[24~"), // F12 + _ => None, + }; + if let Some(seq) = special { + return seq.to_vec(); + } + + // Slint sometimes sends `\u{0008}` for Backspace; terminals expect DEL. + if key == "\u{0008}" { + return vec![0x7f]; + } + + // Slint encodes Key::Return as "\n" (U+000A, LF). Every real terminal + // emulator (xterm, WezTerm, PuTTY …) sends 0x0D (CR) for Enter because + // that is what a physical keyboard generates over a serial line. bash/ + // readline happens to accept LF too, but ncurses apps in raw mode (nano, + // vim command-line, passwd prompts …) strictly require CR to confirm input. + // Ctrl+J (ctrl=true, "\n") intentionally stays 0x0A — it is a distinct + // control character in some applications. + if key == "\n" && !ctrl && !alt { + return vec![0x0d]; + } + + // Empty text (e.g. the Ctrl/Shift/Alt key press itself) — nothing to send. + if key.is_empty() { + return vec![]; + } + + // --- Ctrl + letter: synthesise C0 control character -------------------- + // Two cases: + // A) Platform already encoded the control char in `key` (e.g. "\x18" for + // Ctrl+X on some Linux/macOS builds). Pass through directly. + // B) Platform sends the letter ("x") with modifiers.control=true. + // We synthesise the C0 code ourselves. + if ctrl { + // Case A: key is already a C0 control character (0x01..0x1F, not ESC). + if let Some(c) = key.chars().next() { + let cp = c as u32; + if key.chars().count() == 1 && (0x01..=0x1f).contains(&cp) { + return vec![cp as u8]; + } + } + // Case B: letter + ctrl modifier. + if let Some(c) = key.chars().next() { + if key.chars().count() == 1 { + let upper = c.to_ascii_uppercase() as u8; + let ctrl_char: Option = match upper { + b'A'..=b'Z' => Some(upper - b'A' + 1), // Ctrl+A=\x01 … Ctrl+Z=\x1A + b'[' => Some(0x1b), // Ctrl+[ = ESC + b'\\' => Some(0x1c), + b']' => Some(0x1d), + b'^' => Some(0x1e), + b'_' => Some(0x1f), + b'@' => Some(0x00), + _ => None, + }; + if let Some(byte) = ctrl_char { + return vec![byte]; + } + } + } + } + + // --- Skip unknown Private Use Area code points ------------------------- + if key.chars().any(|c| (0xE000..=0xF8FF).contains(&(c as u32))) { + return vec![]; + } + + // --- Alt + key: prefix with ESC ---------------------------------------- + if alt && !ctrl { + let mut bytes = vec![0x1b]; + bytes.extend_from_slice(key.as_bytes()); + return bytes; + } + + // --- Everything else: send UTF-8 bytes as-is --------------------------- + // This covers printable characters, \r (Enter), \t (Tab), \x1b (Escape), + // and any C0 control chars the platform already encoded in `key`. + key.as_bytes().to_vec() +} + +/// Windows-only: returns `true` when the physical Backspace key (VK_BACK) is +/// currently "down" according to `GetKeyState`. +/// +/// Used to distinguish real Backspace key presses from synthetic WM_CHAR 0x08 +/// events injected by IME drivers (Baidu Pinyin, etc.) when they cancel an +/// in-flight composition. For a real Backspace, WM_KEYDOWN VK_BACK precedes +/// WM_CHAR 0x08, so GetKeyState returns "down". For an IME-synthesised +/// Backspace, no VK_BACK keydown was queued, so GetKeyState returns "up". +#[cfg(windows)] +fn is_vk_back_down() -> bool { + #[allow(non_snake_case)] + extern "system" { + fn GetKeyState(nVirtKey: i32) -> i16; + } + const VK_BACK: i32 = 0x08; + unsafe { (GetKeyState(VK_BACK) as u16) & 0x8000 != 0 } +} + +/// Windows-only: returns `true` when the letter key for a C0 control code +/// is currently "down" according to `GetKeyState`. +/// +/// `GetKeyState` is synchronised with the Windows message queue: its value +/// reflects the state as of the *last message processed by this thread*. +/// When we are called from within a `WM_CHAR` dispatch: +/// +/// * **Real Ctrl+Q**: `WM_KEYDOWN VK_Q` was dequeued and processed just +/// before `WM_CHAR 0x11`, so `GetKeyState(VK_Q)` returns "down". ✓ +/// * **Synthetic injection** (Aula F99 / Baidu Pinyin tap-Left-Ctrl): +/// the driver posts `WM_CHAR 0x11` directly — no `WM_KEYDOWN VK_Q` was +/// ever in the queue — so `GetKeyState(VK_Q)` returns "up". → dropped ✓ +/// +/// `cp` is the C0 code point (0x01 = Ctrl+A … 0x1A = Ctrl+Z). +/// Returns `true` (allow) for code points outside 0x01–0x1A (e.g. ESC). +#[cfg(windows)] +fn c0_letter_key_down(cp: u32) -> bool { + if !(0x01..=0x1a).contains(&cp) { + return true; // Not a Ctrl+letter — don't filter. + } + let vk = (cp + 0x40) as i32; // 0x01→0x41 ('A') … 0x11→0x51 ('Q') … + #[allow(non_snake_case)] + extern "system" { + fn GetKeyState(nVirtKey: i32) -> i16; + } + unsafe { (GetKeyState(vk) as u16) & 0x8000 != 0 } +} + +/// A coloured, cursor-annotated snapshot ready for the Slint terminal grid. +struct BuiltScreen { + spans: Vec, + cursor_row: i32, + cursor_col: i32, + rows_used: i32, + is_alt: bool, +} + +/// One coloured run within a line (its grid row is assigned at render time). +/// Colours are stored as raw vt100::Color so the palette (dark vs. light) +/// can be applied at render time rather than at history-capture time. +/// This lets a theme switch retroactively recolour the entire scrollback. +#[derive(Clone)] +struct HistSpan { + text: String, + fg: vt100::Color, + bg: vt100::Color, + bold: bool, + col: i32, + cells: i32, +} + +/// A rendered line: plain text (one char per cell, for find/selection) + runs. +type Line = (String, Vec); + +/// Per-session scrollback cap (recycled on clear / tab close). +const MAX_HISTORY: usize = 100_000; + +/// Build one screen row into `(plain_text, coloured_runs)`. `plain` carries one +/// char per cell (space for blanks) so a char index equals the grid column. +/// Effective (contents, fg, bg, bold) for one grid cell, applying reverse-video. +/// `contents` is empty for wide-glyph continuation cells, and " " for blanks. +fn cell_attrs( + screen: &vt100::Screen, + r: u16, + c: u16, +) -> (String, vt100::Color, vt100::Color, bool, bool) { + match screen.cell(r, c) { + Some(cell) => { + let (mut fg, mut bg) = (cell.fgcolor(), cell.bgcolor()); + if cell.inverse() { + std::mem::swap(&mut fg, &mut bg); + } + let s = cell.contents(); + if cell.is_wide_continuation() { + (String::new(), fg, bg, cell.bold(), true) + } else { + let s = if s.is_empty() { " ".to_string() } else { s }; + (s, fg, bg, cell.bold(), false) + } + } + None => ( + " ".to_string(), + vt100::Color::Default, + vt100::Color::Default, + false, + false, + ), + } +} + +fn push_plain_cell(plain: &mut String, contents: &str, wide_cont: bool) { + if wide_cont { + plain.push(' '); + return; + } + plain.push(contents.chars().next().unwrap_or(' ')); +} + +fn cell_text_width(contents: &str) -> u16 { + let Some(ch) = contents.chars().next() else { + return 0; + }; + unicode_width::UnicodeWidthChar::width(ch) + .unwrap_or(0) + .min(2) as u16 +} + +fn text_uses_cjk_fallback(text: &str) -> bool { + text.chars().any(|ch| { + matches!( + ch as u32, + 0x3000..=0x303f // CJK symbols and punctuation: 、。 + | 0x3400..=0x4dbf // CJK Extension A + | 0x4e00..=0x9fff // CJK unified ideographs + | 0xf900..=0xfaff // CJK compatibility ideographs + | 0xfe10..=0xfe1f // vertical forms + | 0xfe30..=0xfe4f // CJK compatibility forms + | 0xff00..=0xffef // halfwidth/fullwidth forms: :, + ) + }) +} + +fn starts_wide_cell( + screen: &vt100::Screen, + r: u16, + c: u16, + cols: u16, + contents: &str, +) -> bool { + c + 1 < cols + && (cell_text_width(contents) >= 2 + || screen + .cell(r, c + 1) + .map(|next| next.is_wide_continuation()) + .unwrap_or(false)) +} + +fn cell_display_width(contents: &str, wide_cont: bool, starts_wide: bool) -> u16 { + if wide_cont { + 0 + } else if starts_wide { + 2 + } else { + cell_text_width(contents).max(1) + } +} + +fn visual_col_for_raw_col(screen: &vt100::Screen, r: u16, raw_col: u16, cols: u16) -> i32 { + let mut c = 0u16; + let mut visual = 0u16; + while c < raw_col.min(cols) && visual < cols { + let (s, _, _, _, wide_cont) = cell_attrs(screen, r, c); + let starts_wide = !wide_cont && starts_wide_cell(screen, r, c, cols, &s); + visual = visual + .saturating_add(cell_display_width(&s, wide_cont, starts_wide)) + .min(cols); + c += 1; + } + visual as i32 +} + +fn build_row(screen: &vt100::Screen, r: u16, cols: u16) -> Line { + let mut plain = String::with_capacity(cols as usize); + let mut runs: Vec = Vec::new(); + let mut c = 0u16; + let mut visual_col = 0u16; + while c < cols && visual_col < cols { + let (s, fg, bg, bold, wide_cont) = cell_attrs(screen, r, c); + let starts_wide = !wide_cont && starts_wide_cell(screen, r, c, cols, &s); + // Group consecutive simple cells that share fg + bg + bold into one run. + // Wide glyphs are isolated to their own span so font fallback cannot + // shift following ASCII text away from the vt100 cell grid. + let start_col = visual_col; + let mut text = if wide_cont { String::new() } else { s.clone() }; + push_plain_cell(&mut plain, &s, wide_cont); + visual_col += 1; + if starts_wide && visual_col < cols { + plain.push(' '); + visual_col += 1; + } + c += 1; + while c < cols && visual_col < cols { + let (cs, cfg, cbg, cbold, c_wide_cont) = cell_attrs(screen, r, c); + if starts_wide { + if c_wide_cont { + c += 1; + } + break; + } + if cfg != fg || cbg != bg || cbold != bold { + break; + } + let bg_default = matches!(bg, vt100::Color::Default); + let text_is_blank = text.chars().all(|ch| ch == ' '); + let cell_is_blank = !c_wide_cont && cs == " "; + if bg_default && (text_is_blank != cell_is_blank) { + break; + } + if !c_wide_cont && starts_wide_cell(screen, r, c, cols, &cs) { + break; + } + push_plain_cell(&mut plain, &cs, c_wide_cont); + visual_col += 1; + if !c_wide_cont { + text.push_str(&cs); + } + c += 1; + } + let cells = (visual_col - start_col) as i32; + let is_blank = text.chars().all(|ch| ch == ' '); + let bg_default = matches!(bg, vt100::Color::Default); + // Skip runs that contribute nothing visible: blank text *and* default bg. + if is_blank && bg_default { + continue; + } + runs.push(HistSpan { + text, + fg, // raw vt100::Color — converted at render time with the live palette + bg, + bold, + col: start_col as i32, + cells, + }); + } + (plain, runs) +} + +/// Detect how many lines scrolled off the top between two screen snapshots by +/// finding the vertical shift `k` that best aligns `prev` onto `curr` (longest +/// top-anchored run of equal plain-text lines). `k` lines left the top. +fn detect_scroll(prev: &[Line], curr: &[Line]) -> usize { + let mut best_k = 0usize; + let mut best_len = 0usize; + for k in 0..prev.len() { + let mut p = 0usize; + while k + p < prev.len() && p < curr.len() && prev[k + p].0 == curr[p].0 { + p += 1; + } + if p > best_len { + best_len = p; + best_k = k; + } + } + best_k +} + +impl TermBuffer { + // ---- Absolute-coordinate selection helpers (#18 follow-up) ------------- + // + // The "combined" buffer is `history` (oldest first) followed by the live + // screen rows. A visible window of `rows` rows looks at a slice of it whose + // top index depends on whether we're at the live bottom or scrolled up. + + fn mouse_tracking_enabled(&self) -> bool { + !matches!( + self.parser.screen().mouse_protocol_mode(), + vt100::MouseProtocolMode::None + ) + } + + /// Live screen rows plus the count of non-blank ones at the top. + fn live_rows(&self) -> (Vec, usize) { + let s = self.parser.screen(); + let (rows, cols) = s.size(); + let live: Vec = (0..rows).map(|r| build_row(s, r, cols)).collect(); + let used = live + .iter() + .rposition(|(_, runs)| !runs.is_empty()) + .map(|i| i + 1) + .unwrap_or(0); + (live, used) + } + + /// Absolute combined-row index of the top visible row for the current view. + fn view_top_abs(&self, live_used: usize) -> usize { + let rows = self.parser.screen().size().0 as usize; + let hist_len = self.history.len(); + if self.view_offset == 0 { + // Live view: visible row 0 is live screen row 0 = combined[hist_len]. + hist_len + } else { + let combined_len = hist_len + live_used; + combined_len.saturating_sub(rows + self.view_offset) + } + } + + /// Map a visible row (0..rows) to its absolute combined-row index. + fn vis_to_abs(&self, vis_row: u16) -> usize { + let (_, live_used) = self.live_rows(); + self.view_top_abs(live_used) + vis_row as usize + } + + /// Highlight rectangles for the current selection, clipped to the visible + /// window of the current view. + fn selection_rects_visible(&self, cols: u16) -> Vec { + let (Some((ar, ac)), Some((fr, fc))) = (self.sel_anchor, self.sel_focus) else { + return Vec::new(); + }; + let (lo_r, lo_c, hi_r, hi_c) = if (ar, ac) <= (fr, fc) { + (ar, ac, fr, fc) + } else { + (fr, fc, ar, ac) + }; + if (lo_r, lo_c) == (hi_r, hi_c) { + return Vec::new(); + } + let (_, live_used) = self.live_rows(); + let top = self.view_top_abs(live_used); + let rows = self.parser.screen().size().0; + let mut out = Vec::new(); + for vis in 0..rows { + let abs = top + vis as usize; + if abs < lo_r || abs > hi_r { + continue; + } + let (c0, c1) = if abs == lo_r && abs == hi_r { + (lo_c.min(hi_c), lo_c.max(hi_c)) + } else if abs == lo_r { + (lo_c, cols.saturating_sub(1)) + } else if abs == hi_r { + (0, hi_c) + } else { + (0, cols.saturating_sub(1)) + }; + out.push(TermMatch { + row: vis as i32, + col: c0 as i32, + len: (c1.saturating_sub(c0) + 1) as i32, + }); + } + out + } + + /// Extract the selected text from the combined buffer (whole selection, + /// even the parts currently scrolled out of view). + fn extract_selection_text(&self) -> String { + let (Some((ar, ac)), Some((fr, fc))) = (self.sel_anchor, self.sel_focus) else { + return String::new(); + }; + let (lo_r, lo_c, hi_r, hi_c) = if (ar, ac) <= (fr, fc) { + (ar, ac, fr, fc) + } else { + (fr, fc, ar, ac) + }; + let (live, live_used) = self.live_rows(); + let hist_len = self.history.len(); + let combined_len = hist_len + live_used; + // Clamp into real content so a focus parked on a blank row below the + // prompt doesn't emit trailing empty lines. + let hi_r = hi_r.min(combined_len.saturating_sub(1)); + let mut out = String::new(); + for r in lo_r..=hi_r { + let line: &str = if r < hist_len { + &self.history[r].0 + } else if r - hist_len < live.len() { + &live[r - hist_len].0 + } else { + "" + }; + let chars: Vec = line.chars().collect(); + let (c0, c1) = if r == lo_r && r == hi_r { + (lo_c.min(hi_c), lo_c.max(hi_c)) + } else if r == lo_r { + (lo_c, u16::MAX) + } else if r == hi_r { + (0, hi_c) + } else { + (0, u16::MAX) + }; + let c0 = (c0 as usize).min(chars.len()); + let c1 = ((c1 as usize).saturating_add(1)).min(chars.len()); + let seg: String = if c0 < c1 { + chars[c0..c1].iter().collect() + } else { + String::new() + }; + out.push_str(seg.trim_end()); + if r != hi_r { + out.push('\n'); + } + } + out + } + + /// Feed bytes to vt100 and capture scrolled-off lines into history. + /// + /// We detect scroll by diffing the screen before/after a `process`, which + /// can only recover up to one screen of shift per call. A single large + /// burst can scroll many screens at once, so we split the input at newline + /// boundaries into batches of at most ~half a screen of lines and capture + /// after each — that way no batch ever scrolls more than the diff can see, + /// and nothing is lost. (Splitting only on `\n` is safe: VT escape + /// sequences never contain a newline.) + fn ingest(&mut self, raw: &[u8]) { + // Rewrite HVP (`ESC [ … f`) → CUP (`ESC [ … H`) so vt100 (which only + // implements `H`) honours btop/htop's absolute cursor positioning. + let bytes = self.rewrite_hvp(raw); + let bytes = &bytes[..]; + let rows = self.parser.screen().size().0 as usize; + let batch_lines = (rows / 2).max(1); + let mut start = 0usize; + let mut nl = 0usize; + for i in 0..bytes.len() { + if bytes[i] == b'\n' { + nl += 1; + if nl >= batch_lines { + self.ingest_chunk(&bytes[start..=i]); + start = i + 1; + nl = 0; + } + } + } + if start < bytes.len() { + self.ingest_chunk(&bytes[start..]); + } + } + + /// Translate every CSI sequence terminated by `f` (HVP) into the identical + /// sequence terminated by `H` (CUP). The scanner state persists across + /// calls, so a sequence split across read chunks is still handled. Only the + /// final byte of a CSI sequence is ever touched; text bytes pass through. + fn rewrite_hvp(&mut self, input: &[u8]) -> Vec { + let mut out = Vec::with_capacity(input.len()); + for &b in input { + match self.csi_state { + CsiState::Normal => { + if b == 0x1b { + self.csi_state = CsiState::Esc; + } + out.push(b); + } + CsiState::Esc => { + if b == b'[' { + self.csi_state = CsiState::Csi; + } else { + // Not a CSI (could be another ESC, OSC, etc.). Re-arm on + // a fresh ESC, otherwise fall back to normal text. + self.csi_state = if b == 0x1b { CsiState::Esc } else { CsiState::Normal }; + } + out.push(b); + } + CsiState::Csi => { + // Final bytes are 0x40..=0x7e; params/intermediates are + // 0x20..=0x3f. Rewrite an `f` final into `H`. + if (0x40..=0x7e).contains(&b) { + out.push(if b == b'f' { b'H' } else { b }); + self.csi_state = CsiState::Normal; + } else { + out.push(b); + } + } + } + } + out + } + + /// Process one bounded batch and capture any lines that scrolled off the top + /// (skipped for alt-screen programs like vim/nano). + fn ingest_chunk(&mut self, bytes: &[u8]) { + // Detect full-screen-clear sequences *before* processing so we can + // suppress history for programs that redraw without alt-screen (e.g. + // btop configured with `alt-screen = false`). + // We look for \033[H (cursor-home) and \033[2J / \033[J (erase display) + // as indicators that the program is doing a full-screen refresh. + let has_cursor_home = bytes.windows(3).any(|w| w == b"\x1b[H"); + let has_erase_display = bytes.windows(4).any(|w| w == b"\x1b[2J") + || bytes.windows(3).any(|w| w == b"\x1b[J"); + let is_fullscreen_refresh = has_cursor_home && has_erase_display; + + self.parser.process(bytes); + let (is_alt, rows, cols) = { + let s = self.parser.screen(); + let (r, c) = s.size(); + (s.alternate_screen(), r, c) + }; + if is_alt { + // Alt-screen programs own their live frame, so do not capture their + // redraws into normal scrollback. Keep view_offset intact: when the + // user holds Shift and scrolls inside tmux, they are intentionally + // viewing local history. + self.prev.clear(); + return; + } + if is_fullscreen_refresh { + // Non-alt-screen full-screen refresh (btop, htop with alt disabled…). + // Don't capture lines into history; they'd mix with the next frame. + self.view_offset = 0; + self.prev.clear(); + return; + } + let curr: Vec = { + let s = self.parser.screen(); + (0..rows).map(|r| build_row(s, r, cols)).collect() + }; + if !self.prev.is_empty() { + let k = detect_scroll(&self.prev, &curr); + for line in self.prev.iter().take(k) { + self.history.push(line.clone()); + } + if self.history.len() > MAX_HISTORY { + let drop = self.history.len() - MAX_HISTORY; + self.history.drain(0..drop); + } + } + self.prev = curr; + } + + /// Render the terminal grid for the current scrollback `view_offset` + /// (0 = live). Caches the displayed plain text for find/selection. + fn render(&mut self) -> BuiltScreen { + let (is_alt, rows, cols, cur_row, cur_col) = { + let s = self.parser.screen(); + let (r, c) = s.size(); + let (cr, cc) = s.cursor_position(); + (s.alternate_screen(), r, c, cr, cc) + }; + + // --- Live view: render the current grid. Alt-screen stays live unless + // the user explicitly scrolled local history with Shift+wheel. + if self.view_offset == 0 { + let mut spans = Vec::new(); + let mut displayed = Vec::with_capacity(rows as usize); + let mut last_content = 0i32; + let s = self.parser.screen(); + let cursor_col = visual_col_for_raw_col(s, cur_row, cur_col, cols); + for r in 0..rows { + let (plain, runs) = build_row(s, r, cols); + if !runs.is_empty() { + last_content = r as i32; + } + for hs in runs { + let use_fallback_font = text_uses_cjk_fallback(&hs.text); + spans.push(TermSpan { + text: hs.text.into(), + fg: vt_color_to_slint(hs.fg, hs.bold, self.is_dark), + bg: vt_bg_to_slint(hs.bg, self.is_dark), + bold: hs.bold, + use_fallback_font, + row: r as i32, + col: hs.col, + cells: hs.cells, + }); + } + displayed.push(plain.trim_end().to_string()); + } + self.displayed_text = displayed; + let rows_used = if is_alt { rows as i32 } else { last_content + 1 }; + return BuiltScreen { + spans, + cursor_row: cur_row as i32, + cursor_col, + rows_used, + is_alt, + }; + } + + // --- Scrolled view: window into history ++ live content ------------- + let live: Vec = { + let s = self.parser.screen(); + (0..rows).map(|r| build_row(s, r, cols)).collect() + }; + let live_used = live + .iter() + .rposition(|(_, r)| !r.is_empty()) + .map(|i| i + 1) + .unwrap_or(0); + let hist_len = self.history.len(); + let combined_len = hist_len + live_used; + let win = rows as usize; + let start = combined_len.saturating_sub(win + self.view_offset); + let end = (start + win).min(combined_len); + + let mut spans = Vec::new(); + let mut displayed = Vec::with_capacity(win); + for (d, idx) in (start..end).enumerate() { + let line: &Line = if idx < hist_len { + &self.history[idx] + } else { + &live[idx - hist_len] + }; + for hs in &line.1 { + spans.push(TermSpan { + text: hs.text.clone().into(), + fg: vt_color_to_slint(hs.fg, hs.bold, self.is_dark), + bg: vt_bg_to_slint(hs.bg, self.is_dark), + bold: hs.bold, + use_fallback_font: text_uses_cjk_fallback(&hs.text), + row: d as i32, + col: hs.col, + cells: hs.cells, + }); + } + displayed.push(line.0.trim_end().to_string()); + } + while displayed.len() < win { + displayed.push(String::new()); + } + self.displayed_text = displayed; + BuiltScreen { + spans, + cursor_row: -1, // hide the live cursor while viewing history + cursor_col: 0, + rows_used: win as i32, + is_alt: false, + } + } +} + +/// 16-colour ANSI palette for **dark** terminals (VS Code "Dark+" values). +const ANSI16_DARK: [(u8, u8, u8); 16] = [ + (0x00, 0x00, 0x00), // 0 black + (0xcd, 0x31, 0x31), // 1 red + (0x0d, 0xbc, 0x79), // 2 green + (0xe5, 0xe5, 0x10), // 3 yellow + (0x24, 0x72, 0xc8), // 4 blue + (0xbc, 0x3f, 0xbc), // 5 magenta + (0x11, 0xa8, 0xcd), // 6 cyan + (0xe5, 0xe5, 0xe5), // 7 white (light grey on dark bg) + (0x66, 0x66, 0x66), // 8 bright black + (0xf1, 0x4c, 0x4c), // 9 bright red + (0x23, 0xd1, 0x8b), // 10 bright green + (0xf5, 0xf5, 0x43), // 11 bright yellow + (0x3b, 0x8e, 0xea), // 12 bright blue + (0xd6, 0x70, 0xd6), // 13 bright magenta + (0x29, 0xb8, 0xdb), // 14 bright cyan + (0xff, 0xff, 0xff), // 15 bright white +]; + +/// 16-colour ANSI palette for **light** terminal **foreground** (text) use. +/// +/// On a near-white (#fafafa) background, the standard "white" (slot 7) and +/// "bright white" (slot 15) are nearly invisible. We remap them to dark greys +/// so `ls`, `git` and other tools that use colour 7 for regular text stay +/// perfectly readable. Saturated hues are darkened for contrast. +const ANSI16_LIGHT: [(u8, u8, u8); 16] = [ + (0x1c, 0x1c, 0x1e), // 0 black → Apple near-black + (0xc0, 0x39, 0x2b), // 1 red + (0x1a, 0x7f, 0x37), // 2 green → darker for white bg + (0x85, 0x64, 0x04), // 3 yellow → dark amber, readable + (0x04, 0x51, 0xa5), // 4 blue → VS Code light blue + (0x80, 0x00, 0x80), // 5 magenta + (0x0e, 0x72, 0x5c), // 6 cyan → darker teal + (0x3a, 0x3a, 0x3c), // 7 white → dark grey (was 0xe5e5e5, near-invisible) + (0x55, 0x55, 0x55), // 8 bright black + (0xe7, 0x4c, 0x3c), // 9 bright red + (0x27, 0xae, 0x60), // 10 bright green + (0xd4, 0xac, 0x0d), // 11 bright yellow + (0x2e, 0x86, 0xc1), // 12 bright blue + (0x9b, 0x59, 0xb6), // 13 bright magenta + (0x1a, 0xbc, 0x9c), // 14 bright cyan + (0x2c, 0x2c, 0x2e), // 15 bright white → dark (was 0xffffff, near-invisible) +]; + +/// 16-colour ANSI palette for **light** terminal **background** (fill) use. +/// +/// When TUI programs (btop, htop, vim) paint cell backgrounds in light mode, +/// each colour maps to a light-tinted variant so the overall UI feels light. +/// "Black" (slot 0) becomes a very light grey rather than near-black, so +/// dark-background TUI apps naturally inherit a light appearance. Foreground +/// text always uses `ANSI16_LIGHT` so readability is unaffected. +const ANSI16_LIGHT_BG: [(u8, u8, u8); 16] = [ + (0xe8, 0xe8, 0xed), // 0 black → Apple system-grey-6 (very light) + (0xff, 0xd5, 0xd5), // 1 red → light rose + (0xd5, 0xf5, 0xd5), // 2 green → light mint + (0xff, 0xf8, 0xd5), // 3 yellow → light cream + (0xd5, 0xe8, 0xf8), // 4 blue → light sky + (0xf5, 0xd5, 0xf5), // 5 magenta → light lilac + (0xd5, 0xf5, 0xf8), // 6 cyan → light aqua + (0xf5, 0xf5, 0xf7), // 7 white → Apple bg (near-white) + (0xd1, 0xd1, 0xd6), // 8 bright black → Apple system-grey-4 + (0xff, 0xbe, 0xbe), // 9 bright red → light salmon + (0xbe, 0xf5, 0xbe), // 10 bright green + (0xf5, 0xf5, 0xbe), // 11 bright yellow + (0xbe, 0xdd, 0xff), // 12 bright blue → light periwinkle + (0xf0, 0xbe, 0xff), // 13 bright magenta → light violet + (0xbe, 0xf5, 0xff), // 14 bright cyan + (0xff, 0xff, 0xff), // 15 bright white → white +]; + +/// Convert a vt100 foreground colour (+ bold) to a Slint colour. +/// Bold + a base colour (0–7) maps to the bright variant (8–15), matching +/// how terminals render `ls --color` (bold-green executables, bold-blue dirs). +/// +/// In light mode, true-colour RGB foregrounds that are light (HSL lightness +/// ≥ 0.55) are darkened so they remain readable on a near-white background. +fn vt_color_to_slint(color: vt100::Color, bold: bool, is_dark: bool) -> slint::Color { + let (r, g, b) = match color { + vt100::Color::Default => { + if is_dark { (0xd4, 0xd4, 0xd4) } else { (0x2d, 0x2d, 0x2f) } + } + vt100::Color::Idx(i) => idx_to_rgb(i, bold, is_dark), + vt100::Color::Rgb(r, g, b) => { + if is_dark { (r, g, b) } else { darken_light_fg(r, g, b) } + } + }; + slint::Color::from_rgb_u8(r, g, b) +} + +/// In light mode, remap light true-colour foregrounds to dark so they are +/// readable on a near-white background. Colours already dark (L < 0.55) +/// pass through unchanged. +fn darken_light_fg(r: u8, g: u8, b: u8) -> (u8, u8, u8) { + let (h, s, l) = rgb_to_hsl(r, g, b); + if l < 0.55 { + return (r, g, b); + } + // L=0.55 → 0.40 (readable dark grey), L=1.0 (white) → ~0.15 (near-black). + let new_l = (0.40 - (l - 0.55) * 0.56).max(0.10); + hsl_to_rgb(h, s, new_l) +} + +/// Convert a vt100 *background* colour to Slint. The default background maps +/// to fully transparent so we don't paint a fill over the terminal's own bg. +/// Non-default backgrounds (btop/htop bars, selected rows) become opaque. +/// +/// In light mode: +/// - ANSI 16 colours use `ANSI16_LIGHT_BG` (light pastels). +/// - True-colour RGB backgrounds that are dark (HSL lightness < 0.45) are +/// remapped to light pastels so programs like btop feel light-themed. +fn vt_bg_to_slint(color: vt100::Color, is_dark: bool) -> slint::Color { + match color { + vt100::Color::Default => slint::Color::from_argb_u8(0, 0, 0, 0), // transparent + vt100::Color::Idx(i) => { + let (r, g, b) = idx_to_rgb_bg(i, is_dark); + slint::Color::from_rgb_u8(r, g, b) + } + vt100::Color::Rgb(r, g, b) => { + if is_dark { + slint::Color::from_rgb_u8(r, g, b) + } else { + let (nr, ng, nb) = lighten_dark_bg(r, g, b); + slint::Color::from_rgb_u8(nr, ng, nb) + } + } + } +} + +/// In light mode, remap dark true-colour backgrounds to light pastels. +/// Colours whose HSL lightness is already ≥ 0.45 pass through unchanged +/// (the program chose a light colour deliberately). +fn lighten_dark_bg(r: u8, g: u8, b: u8) -> (u8, u8, u8) { + let (h, s, l) = rgb_to_hsl(r, g, b); + if l >= 0.45 { + return (r, g, b); + } + // Remap: darkest (l≈0) → very light (l≈0.92); l=0.45 → l≈0.84. + // Reduce saturation to pastel so colours don't look garish on white. + let new_l = 0.92 - l * 0.18; + let new_s = (s * 0.35).min(0.25); + hsl_to_rgb(h, new_s, new_l) +} + +fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f32, f32, f32) { + let r = r as f32 / 255.0; + let g = g as f32 / 255.0; + let b = b as f32 / 255.0; + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let l = (max + min) / 2.0; + if (max - min).abs() < 1e-6 { + return (0.0, 0.0, l); + } + let d = max - min; + let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) }; + let h = if (max - r).abs() < 1e-6 { + (g - b) / d + if g < b { 6.0 } else { 0.0 } + } else if (max - g).abs() < 1e-6 { + (b - r) / d + 2.0 + } else { + (r - g) / d + 4.0 + } / 6.0; + (h, s, l) +} + +fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) { + if s < 1e-6 { + let v = (l * 255.0).round() as u8; + return (v, v, v); + } + let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s }; + let p = 2.0 * l - q; + let hue = |mut t: f32| -> f32 { + if t < 0.0 { t += 1.0; } + if t > 1.0 { t -= 1.0; } + if t < 1.0 / 6.0 { return p + (q - p) * 6.0 * t; } + if t < 0.5 { return q; } + if t < 2.0 / 3.0 { return p + (q - p) * (2.0 / 3.0 - t) * 6.0; } + p + }; + ( + (hue(h + 1.0 / 3.0) * 255.0).round() as u8, + (hue(h) * 255.0).round() as u8, + (hue(h - 1.0 / 3.0) * 255.0).round() as u8, + ) +} + +/// Map an xterm-256 palette index to RGB (16 ANSI + 6×6×6 cube + grayscale). +fn idx_to_rgb(i: u8, bold: bool, is_dark: bool) -> (u8, u8, u8) { + let i = if bold && i < 8 { i + 8 } else { i }; + let palette = if is_dark { &ANSI16_DARK } else { &ANSI16_LIGHT }; + match i { + 0..=15 => palette[i as usize], + 16..=231 => { + let n = i - 16; + let to = |v: u8| -> u8 { + if v == 0 { 0 } else { 55 + v * 40 } + }; + (to(n / 36), to((n % 36) / 6), to(n % 6)) + } + _ => { + let v = 8 + (i - 232) * 10; + (v, v, v) + } + } +} + +/// Same as [`idx_to_rgb`] but for **background** fills in light mode: the 16 +/// ANSI base colours use `ANSI16_LIGHT_BG` (light pastels) so TUI program +/// backgrounds feel light. 256-colour cube / grayscale are used as-is. +fn idx_to_rgb_bg(i: u8, is_dark: bool) -> (u8, u8, u8) { + if !is_dark && i < 16 { + return ANSI16_LIGHT_BG[i as usize]; + } + idx_to_rgb(i, false, is_dark) +} + +/// Return the parent directory of `path`. +/// "/a/b/c" → "/a/b", "/a" → "/", "/" → "/" +fn parent_path(path: &str) -> String { + let trimmed = path.trim_end_matches('/'); + if trimmed.is_empty() { + return "/".to_string(); + } + match trimmed.rfind('/') { + Some(0) => "/".to_string(), + Some(i) => trimmed[..i].to_string(), + None => "/".to_string(), + } +} + +#[cfg(test)] +mod command_template_tests { + use super::*; + + #[test] + fn extracts_parameter_names_in_slot_order() { + assert_eq!( + extract_command_params("kill [p#1 pid] --signal [p#2 signal] [p#1 duplicate]"), + vec!["pid".to_string(), "signal".to_string()] + ); + } + + #[test] + fn renders_required_parameters() { + assert_eq!( + render_command_with_params( + "kill [p#1 pid] --signal [p#2 signal]", + &["123".to_string(), "9".to_string()] + ), + Some("kill 123 --signal 9".to_string()) + ); + } + + #[test] + fn rejects_missing_required_parameter() { + assert_eq!( + render_command_with_params( + "kill [p#1 pid] --signal [p#2 signal]", + &["123".to_string(), "".to_string()] + ), + None + ); + } + + #[test] + fn leaves_out_of_range_parameter_tokens_literal() { + assert_eq!( + render_command_with_params("echo [p#6 ignored]", &[]), + Some("echo [p#6 ignored]".to_string()) + ); + } +} + +#[cfg(test)] +mod terminal_render_tests { + use super::*; + + #[test] + fn build_row_keeps_plain_text_one_char_per_cell() { + let mut parser = vt100::Parser::new(2, 5, 0); + parser.process("e\u{301}x".as_bytes()); + + let (plain, _) = build_row(parser.screen(), 0, 5); + + assert_eq!(plain.chars().count(), 5); + assert_eq!(plain.chars().next(), Some('e')); + } + + #[test] + fn build_row_counts_wide_glyph_cells_separately_from_text() { + let mut parser = vt100::Parser::new(2, 8, 0); + parser.process("界\x1b[31mx".as_bytes()); + + let (_, runs) = build_row(parser.screen(), 0, 8); + + assert_eq!(runs[0].text, "界"); + assert_eq!(runs[0].cells, 2); + } + + #[test] + fn build_row_splits_wide_glyphs_out_of_ascii_runs() { + let mut parser = vt100::Parser::new(2, 8, 0); + parser.process("A界B".as_bytes()); + + let (_, runs) = build_row(parser.screen(), 0, 8); + + assert_eq!(runs[0].text, "A"); + assert_eq!(runs[0].col, 0); + assert_eq!(runs[0].cells, 1); + assert_eq!(runs[1].text, "界"); + assert_eq!(runs[1].col, 1); + assert_eq!(runs[1].cells, 2); + assert_eq!(runs[2].text, "B"); + assert_eq!(runs[2].col, 3); + assert_eq!(runs[2].cells, 1); + } + + #[test] + fn build_row_preserves_coloured_wide_glyph_position_after_default_gap() { + let mut parser = vt100::Parser::new(2, 10, 0); + parser.process("A \x1b[34m界\x1b[0mB".as_bytes()); + + let (_, runs) = build_row(parser.screen(), 0, 10); + + assert_eq!(runs[0].text, "A"); + assert_eq!(runs[0].col, 0); + assert_eq!(runs[0].cells, 1); + assert_eq!(runs[1].text, "界"); + assert_eq!(runs[1].col, 3); + assert_eq!(runs[1].cells, 2); + assert_eq!(runs[2].text, "B"); + assert_eq!(runs[2].col, 5); + assert_eq!(runs[2].cells, 1); + } + + #[test] + fn cjk_fallback_covers_fullwidth_punctuation() { + assert!(text_uses_cjk_fallback(":,。、")); + assert!(!text_uses_cjk_fallback(":/,.")); + } +} + +#[cfg(test)] +mod selection_tests { + use super::*; + + fn hist_line(s: &str) -> Line { + (s.to_string(), Vec::new()) + } + + /// A TermBuffer whose live screen (rows×cols) shows `live_lines`, with the + /// given `history` above it, viewed at `view_offset` (0 = live bottom). + fn make_buf( + rows: u16, + cols: u16, + history: &[&str], + live_lines: &[&str], + view_offset: usize, + ) -> TermBuffer { + let mut parser = vt100::Parser::new(rows, cols, 0); + parser.process(live_lines.join("\r\n").as_bytes()); + TermBuffer { + parser, + find_query: String::new(), + is_dark: false, + sel_anchor: None, + sel_focus: None, + history: history.iter().map(|s| hist_line(s)).collect(), + prev: Vec::new(), + view_offset, + displayed_text: Vec::new(), + csi_state: CsiState::Normal, + } + } + + #[test] + fn vis_to_abs_maps_live_and_scrolled_consistently() { + // history H0..H2 (3 lines), live LIVE0/LIVE1 → combined len 5. + let live = make_buf(5, 20, &["H0", "H1", "H2"], &["LIVE0", "LIVE1"], 0); + assert_eq!(live.vis_to_abs(0), 3, "live row 0 is first live line"); + assert_eq!(live.vis_to_abs(1), 4); + + // Scrolled to the very top (offset = history len). + let top = make_buf(5, 20, &["H0", "H1", "H2"], &["LIVE0", "LIVE1"], 3); + assert_eq!(top.vis_to_abs(0), 0, "top row 0 is oldest history line"); + assert_eq!(top.vis_to_abs(2), 2); + assert_eq!(top.vis_to_abs(3), 3, "row 3 crosses into live content"); + } + + #[test] + fn extract_spans_history_and_live() { + let mut buf = make_buf(5, 20, &["HIST0", "HIST1", "HIST2"], &["LIVE0", "LIVE1"], 3); + buf.sel_anchor = Some((0, 0)); // top of history + buf.sel_focus = Some((4, 19)); // end of last live line + assert_eq!( + buf.extract_selection_text(), + "HIST0\nHIST1\nHIST2\nLIVE0\nLIVE1" + ); + } + + #[test] + fn extract_is_view_independent() { + // The same absolute selection copies identically whether the view is + // scrolled to the top or sitting at the live bottom — this is the whole + // point of the fix (a top-to-bottom selection survives auto-scrolling). + let sel = |off| { + let mut b = make_buf(5, 20, &["HIST0", "HIST1", "HIST2"], &["LIVE0", "LIVE1"], off); + b.sel_anchor = Some((0, 0)); + b.sel_focus = Some((4, 19)); + b.extract_selection_text() + }; + assert_eq!(sel(3), sel(0)); + assert_eq!(sel(3), "HIST0\nHIST1\nHIST2\nLIVE0\nLIVE1"); + } + + #[test] + fn highlight_clipped_to_current_view() { + // Scrolled to the top: a history selection is on-screen and highlighted. + let mut top = make_buf(5, 20, &["HIST0", "HIST1", "HIST2"], &["LIVE0", "LIVE1"], 3); + top.sel_anchor = Some((0, 2)); + top.sel_focus = Some((2, 4)); + let rects = top.selection_rects_visible(20); + assert_eq!(rects.len(), 3, "rows 0,1,2 (the 3 history lines) highlighted"); + assert_eq!(rects[0].row, 0); + assert_eq!(rects[2].row, 2); + + // At the live bottom the same history selection is scrolled off → none. + let mut live = make_buf(5, 20, &["HIST0", "HIST1", "HIST2"], &["LIVE0", "LIVE1"], 0); + live.sel_anchor = Some((0, 2)); + live.sel_focus = Some((2, 4)); + assert!(live.selection_rects_visible(20).is_empty()); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..62edc7a --- /dev/null +++ b/src/config.rs @@ -0,0 +1,1267 @@ +//! Session / application configuration. +//! +//! Persists a simple JSON file in a portable config directory next to the +//! executable by default: `/config/sessions.json`. +//! +//! ## Password storage +//! +//! Passwords are stored in the OS keychain when available. The JSON file keeps +//! only a stable `password_ref` such as `session:`. +//! +//! On systems without a usable keychain backend, passwords are still **not** +//! stored in plaintext. On first launch a random 256-bit key is written to +//! `secret.key` in the same config directory (mode `0600` on Unix). Every +//! non-empty password is then encrypted with **ChaCha20-Poly1305** (a random +//! 96-bit nonce per value) and stored as +//! +//! ```text +//! enc:v1: +//! ``` +//! +//! Legacy plaintext passwords (from older installs) are left untouched in +//! memory and silently re-encrypted the next time the config is saved. + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chacha20poly1305::{ + aead::{Aead, AeadCore, KeyInit}, + ChaCha20Poly1305, +}; +use directories::ProjectDirs; +use rand::rngs::OsRng; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use zeroize::Zeroize; + +/// A secret string (e.g. a session password) whose heap buffer is zeroed when +/// it is dropped, so plaintext credentials don't survive in freed memory and +/// turn up in core dumps, a debugger, or `/proc//mem`. `Clone` makes an +/// independent copy that is likewise zeroed on its own drop, and `Debug` is +/// redacted so a password can never be logged by accident. +#[derive(Clone, Default)] +pub struct Secret(String); + +impl Secret { + pub fn new(s: impl Into) -> Self { + Secret(s.into()) + } + pub fn as_str(&self) -> &str { + &self.0 + } + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl Drop for Secret { + fn drop(&mut self) { + self.0.zeroize(); + } +} + +impl std::fmt::Debug for Secret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Never reveal the contents in logs / debug output. + f.write_str(if self.0.is_empty() { "Secret(\"\")" } else { "Secret(***)" }) + } +} + +impl Serialize for Secret { + fn serialize(&self, s: S) -> Result { + s.serialize_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for Secret { + fn deserialize>(d: D) -> Result { + Ok(Secret(String::deserialize(d)?)) + } +} + +/// Which transport a session uses. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum SessionKind { + /// SSH shell + SFTP (the original and default behaviour). + #[default] + Ssh, + /// Local serial port (COM3 / /dev/ttyUSB0) for switches, routers, MCUs (#14). + Serial, + /// Plain Telnet over TCP, for legacy network gear (#17). + Telnet, +} + +impl SessionKind { + pub fn as_str(&self) -> &'static str { + match self { + SessionKind::Ssh => "ssh", + SessionKind::Serial => "serial", + SessionKind::Telnet => "telnet", + } + } + + pub fn from_str(s: &str) -> Self { + match s { + "serial" => SessionKind::Serial, + "telnet" => SessionKind::Telnet, + _ => SessionKind::Ssh, + } + } +} + +fn default_baud() -> u32 { + 115_200 +} +fn default_data_bits() -> u8 { + 8 +} +fn default_stop_bits() -> u8 { + 1 +} +fn default_parity() -> String { + "none".to_string() +} +fn default_flow() -> String { + "none".to_string() +} +fn default_group() -> String { + "Default".to_string() +} +fn default_max_terminal_tabs() -> u32 { + 10 +} +fn default_terminal_font_size() -> u32 { + 13 +} +fn default_sftp_file_font_size() -> u32 { + 16 +} +fn default_sftp_name_column_width() -> u32 { + 180 +} +fn default_background_mode() -> u8 { + 1 +} +fn default_background_blur_px() -> u32 { + 3 +} +fn default_terminal_opacity_percent() -> u32 { + 88 +} +fn default_ui_opacity_percent() -> u32 { + 72 +} + +const CONFIG_FILE_NAME: &str = "sessions.json"; +const SECRET_KEY_FILE_NAME: &str = "secret.key"; +const CONFIG_DIR_OVERRIDE_FILE: &str = "config-dir.txt"; + +/// How a session authenticates. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum AuthMethod { + Password, + Key, +} + +impl AuthMethod { + pub fn as_str(&self) -> &'static str { + match self { + AuthMethod::Password => "password", + AuthMethod::Key => "key", + } + } + + pub fn from_str(s: &str) -> Self { + match s { + "key" => AuthMethod::Key, + _ => AuthMethod::Password, + } + } +} + +/// A single saved SSH target. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Session { + pub id: String, + pub name: String, + pub host: String, + pub port: u16, + pub user: String, + /// Session folder/group shown on the welcome page. + #[serde(default = "default_group")] + pub group: String, + pub auth: AuthMethod, + #[serde(default, skip_serializing_if = "Secret::is_empty")] + pub password: Secret, + /// OS keychain account/reference for the password. Empty means either no + /// password or legacy encrypted fallback in `password`. + #[serde(default)] + pub password_ref: String, + #[serde(default)] + pub private_key_path: String, + /// Optional outbound proxy, e.g. "socks5://127.0.0.1:1080" or + /// "http://user:pass@host:8080". Empty = use $ALL_PROXY, else direct. + #[serde(default)] + pub proxy: String, + #[serde(default)] + pub last_used: Option, + + // --- Transport ---------------------------------------------------------- + /// SSH (default), Serial, or Telnet. Absent in old config files → Ssh. + #[serde(default)] + pub kind: SessionKind, + + // --- Serial-only fields (ignored unless kind == Serial) ----------------- + /// Serial device path, e.g. "COM3" (Windows) or "/dev/ttyUSB0" (Linux). + #[serde(default)] + pub serial_port: String, + #[serde(default = "default_baud")] + pub baud_rate: u32, + #[serde(default = "default_data_bits")] + pub data_bits: u8, + #[serde(default = "default_stop_bits")] + pub stop_bits: u8, + /// "none" | "odd" | "even". + #[serde(default = "default_parity")] + pub parity: String, + /// "none" | "hardware" | "software". + #[serde(default = "default_flow")] + pub flow_control: String, +} + +impl Session { + pub fn new_empty() -> Self { + Self { + id: Uuid::new_v4().to_string(), + name: String::new(), + host: String::new(), + port: 22, + user: "root".into(), + group: default_group(), + auth: AuthMethod::Password, + password: Secret::default(), + password_ref: String::new(), + private_key_path: String::new(), + proxy: String::new(), + last_used: None, + kind: SessionKind::Ssh, + serial_port: String::new(), + baud_rate: default_baud(), + data_bits: default_data_bits(), + stop_bits: default_stop_bits(), + parity: default_parity(), + flow_control: default_flow(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommandSnippet { + pub id: String, + pub name: String, + pub command: String, +} + +impl CommandSnippet { + #[allow(dead_code)] // legacy snippet format; migrated into StoredCommand on load + pub fn new(command: String) -> Self { + let name = command + .split_whitespace() + .take(6) + .collect::>() + .join(" "); + Self { + id: Uuid::new_v4().to_string(), + name: if name.is_empty() { "Command".into() } else { name }, + command, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoredCommandCategory { + pub id: String, + pub name: String, + #[serde(default)] + pub builtin: bool, +} + +impl StoredCommandCategory { + fn default_category() -> Self { + Self { + id: DEFAULT_COMMAND_CATEGORY_ID.to_string(), + name: "默认分类".to_string(), + builtin: true, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoredCommand { + pub id: String, + pub category_id: String, + pub name: String, + pub command: String, + #[serde(default = "default_append_cr")] + pub append_cr: bool, +} + +fn default_append_cr() -> bool { + true +} + +pub const DEFAULT_COMMAND_CATEGORY_ID: &str = "default"; + +/// On-disk layout. Keep additive to ease forward-compat. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ConfigFile { + #[serde(default)] + pub sessions: Vec, + /// Connection folders shown in the welcome page. Sessions keep the folder + /// name in `Session::group` for backward compatibility. + #[serde(default)] + pub connection_folders: Vec, + /// Connection folders currently collapsed in the welcome page. + #[serde(default)] + pub connection_folder_collapsed: Vec, + /// Preset SFTP download directory. Empty = ask each time. + #[serde(default)] + pub download_dir: String, + /// UI language code: "zh" (default) or "en". + #[serde(default)] + pub language: String, + /// Theme preference: "system" (default) | "dark" | "light". + #[serde(default)] + pub theme_pref: String, + /// Maximum number of simultaneously open terminal tabs. + #[serde(default = "default_max_terminal_tabs")] + pub max_terminal_tabs: u32, + /// Font size used by terminal output. + #[serde(default = "default_terminal_font_size")] + pub terminal_font_size: u32, + /// Font size used by the SFTP/file-management panel. + #[serde(default = "default_sftp_file_font_size")] + pub sftp_file_font_size: u32, + /// Optional background image path. Empty = disabled. + #[serde(default)] + pub background_path: String, + /// Background scope: 0 none, 1 terminal only, 2 whole app. + #[serde(default = "default_background_mode")] + pub background_mode: u8, + /// Stored blur preference in px. Slint rendering currently uses a tint + /// overlay; keeping the value preserves the setting for richer rendering. + #[serde(default = "default_background_blur_px")] + pub background_blur_px: u32, + /// Terminal panel opacity when a background image is visible (30..100). + #[serde(default = "default_terminal_opacity_percent")] + pub terminal_opacity_percent: u32, + /// Main UI panel opacity when the full-app background is visible (30..100). + #[serde(default = "default_ui_opacity_percent")] + pub ui_opacity_percent: u32, + /// Show fixed debug ids beside major UI parts to make visual bug reports precise. + #[serde(default)] + pub ui_debug_ids: bool, + /// Name column width in the SFTP/file-management panel. + #[serde(default = "default_sftp_name_column_width")] + pub sftp_name_column_width: u32, + /// Whether the user manually resized the SFTP name column. + #[serde(default)] + pub sftp_name_column_manual: bool, + /// Most recent terminal commands submitted from meatshell's command bar. + #[serde(default)] + pub command_history: Vec, + /// User-defined command snippets. + #[serde(default)] + pub snippets: Vec, + /// FinalShell-style custom command categories. + #[serde(default)] + pub command_categories: Vec, + /// FinalShell-style custom commands. + #[serde(default)] + pub commands: Vec, +} + +pub struct ConfigStore { + path: PathBuf, + cache: ConfigFile, + /// ChaCha20-Poly1305 key loaded from (or freshly generated into) + /// `secret.key` in the same directory as `sessions.json`. + key: [u8; 32], +} + +impl ConfigStore { + /// The prefix that marks an encrypted password blob in sessions.json. + const ENC_PREFIX: &'static str = "enc:v1:"; + const KEYCHAIN_PREFIX: &'static str = "session:"; + + // ── Encryption helpers ──────────────────────────────────────────────── + + /// Encrypt `plaintext` with ChaCha20-Poly1305 and return + /// `"enc:v1:"`. + fn encrypt(key: &[u8; 32], plaintext: &str) -> Result { + let cipher = ChaCha20Poly1305::new(key.into()); + let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng); // 12 random bytes + let ciphertext = cipher + .encrypt(&nonce, plaintext.as_bytes()) + .map_err(|e| anyhow::anyhow!("password encrypt error: {e}"))?; + let mut blob = nonce.to_vec(); + blob.extend_from_slice(&ciphertext); + Ok(format!("{}{}", Self::ENC_PREFIX, URL_SAFE_NO_PAD.encode(&blob))) + } + + /// Try to decrypt a value produced by [`Self::encrypt`]. + /// Returns `None` if the string is not an encrypted blob (e.g. a legacy + /// plaintext value, an empty string, or a tampered/corrupt blob). + fn try_decrypt(key: &[u8; 32], s: &str) -> Option { + let b64 = s.strip_prefix(Self::ENC_PREFIX)?; + let blob = URL_SAFE_NO_PAD.decode(b64).ok()?; + if blob.len() < 12 { + return None; + } + let (nonce_bytes, ciphertext) = blob.split_at(12); + let cipher = ChaCha20Poly1305::new(key.into()); + let nonce = chacha20poly1305::Nonce::from_slice(nonce_bytes); + let plain = cipher.decrypt(nonce, ciphertext).ok()?; + String::from_utf8(plain).ok() + } + + fn normalise_config(cfg: &mut ConfigFile) { + if cfg.max_terminal_tabs == 0 { + cfg.max_terminal_tabs = default_max_terminal_tabs(); + } + cfg.max_terminal_tabs = cfg.max_terminal_tabs.clamp(1, 50); + if cfg.terminal_font_size == 0 { + cfg.terminal_font_size = default_terminal_font_size(); + } + cfg.terminal_font_size = cfg.terminal_font_size.clamp(10, 28); + if cfg.sftp_file_font_size == 0 { + cfg.sftp_file_font_size = default_sftp_file_font_size(); + } + cfg.sftp_file_font_size = cfg.sftp_file_font_size.clamp(10, 28); + cfg.background_mode = cfg.background_mode.min(2); + cfg.background_blur_px = cfg.background_blur_px.clamp(0, 20); + if cfg.terminal_opacity_percent == 0 { + cfg.terminal_opacity_percent = default_terminal_opacity_percent(); + } + cfg.terminal_opacity_percent = cfg.terminal_opacity_percent.clamp(30, 100); + if cfg.ui_opacity_percent == 0 { + cfg.ui_opacity_percent = default_ui_opacity_percent(); + } + cfg.ui_opacity_percent = cfg.ui_opacity_percent.clamp(30, 100); + if cfg.sftp_name_column_width == 0 { + cfg.sftp_name_column_width = default_sftp_name_column_width(); + } + cfg.sftp_name_column_width = cfg.sftp_name_column_width.clamp(96, 360); + + let mut folders = Vec::::new(); + let mut seen = std::collections::HashSet::::new(); + let mut add_folder = |name: &str| { + let name = clean_name(name, &default_group()); + if seen.insert(name.clone()) { + folders.push(name); + } + }; + add_folder(&default_group()); + for folder in &cfg.connection_folders { + add_folder(folder); + } + for session in &cfg.sessions { + add_folder(&session.group); + } + cfg.connection_folders = folders; + + let folder_names: std::collections::HashSet = + cfg.connection_folders.iter().cloned().collect(); + let mut collapsed = Vec::::new(); + let mut collapsed_seen = std::collections::HashSet::::new(); + for folder in &cfg.connection_folder_collapsed { + let folder = clean_name(folder, &default_group()); + if folder_names.contains(&folder) && collapsed_seen.insert(folder.clone()) { + collapsed.push(folder); + } + } + cfg.connection_folder_collapsed = collapsed; + + for session in &mut cfg.sessions { + let group = clean_name(&session.group, &default_group()); + session.group = if folder_names.contains(&group) { + group + } else { + default_group() + }; + } + + if !cfg + .command_categories + .iter() + .any(|c| c.id == DEFAULT_COMMAND_CATEGORY_ID) + { + cfg.command_categories + .insert(0, StoredCommandCategory::default_category()); + } + for cat in &mut cfg.command_categories { + if cat.id == DEFAULT_COMMAND_CATEGORY_ID { + cat.builtin = true; + if cat.name.trim().is_empty() { + cat.name = "默认分类".to_string(); + } + } else if cat.name.trim().is_empty() { + cat.name = "未命名分类".to_string(); + } + } + + let category_ids: std::collections::HashSet = cfg + .command_categories + .iter() + .map(|c| c.id.clone()) + .collect(); + for cmd in &mut cfg.commands { + if !category_ids.contains(&cmd.category_id) { + cmd.category_id = DEFAULT_COMMAND_CATEGORY_ID.to_string(); + } + if cmd.name.trim().is_empty() { + cmd.name = cmd + .command + .split_whitespace() + .take(6) + .collect::>() + .join(" "); + if cmd.name.trim().is_empty() { + cmd.name = "未命名命令".to_string(); + } + } + } + + let mut existing: std::collections::HashSet = + cfg.commands.iter().map(|c| c.command.clone()).collect(); + for snip in &cfg.snippets { + let command = snip.command.trim(); + if command.is_empty() || existing.contains(command) { + continue; + } + existing.insert(command.to_string()); + cfg.commands.push(StoredCommand { + id: Uuid::new_v4().to_string(), + category_id: DEFAULT_COMMAND_CATEGORY_ID.to_string(), + name: if snip.name.trim().is_empty() { + "未命名命令".to_string() + } else { + snip.name.clone() + }, + command: command.to_string(), + append_cr: true, + }); + } + } + + // ── Key file management ─────────────────────────────────────────────── + + /// Load the 32-byte key from `/secret.key`, or generate and + /// persist a fresh one. On Unix the key file is created with mode `0600` + /// so other local accounts cannot read it. On Windows files in `%APPDATA%` + /// are already restricted to the owning user by default ACLs. + fn load_or_create_key(config_dir: &Path) -> Result<[u8; 32]> { + use rand::RngCore as _; + let key_path = config_dir.join(SECRET_KEY_FILE_NAME); + + if key_path.exists() { + let bytes = fs::read(&key_path) + .with_context(|| format!("failed to read {}", key_path.display()))?; + if bytes.len() == 32 { + let mut key = [0u8; 32]; + key.copy_from_slice(&bytes); + return Ok(key); + } + tracing::warn!("secret.key has wrong length — regenerating"); + } + + let mut key = [0u8; 32]; + OsRng.fill_bytes(&mut key); + Self::write_key(config_dir, &key)?; + if crate::debug_log::log_paths_enabled() { + tracing::info!("generated new encryption key at {}", key_path.display()); + } else { + tracing::info!("generated new encryption key"); + } + Ok(key) + } + + fn write_key(config_dir: &Path, key: &[u8; 32]) -> Result<()> { + fs::create_dir_all(config_dir).with_context(|| { + format!("failed to create config dir {}", config_dir.display()) + })?; + let key_path = config_dir.join(SECRET_KEY_FILE_NAME); + fs::write(&key_path, key) + .with_context(|| format!("failed to write {}", key_path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600)) + .with_context(|| { + format!("failed to set permissions on {}", key_path.display()) + })?; + } + Ok(()) + } + + // ── Public API ──────────────────────────────────────────────────────── + + /// Load (or initialise) the config file. On any parse error we back up the + /// broken file and start fresh — losing saved sessions is better than + /// crashing at launch. + pub fn load() -> Result { + let path = Self::config_path()?; + let config_dir = path + .parent() + .context("config path has no parent directory")? + .to_path_buf(); + + fs::create_dir_all(&config_dir).with_context(|| { + format!("failed to create config dir {}", config_dir.display()) + })?; + + let key = Self::load_or_create_key(&config_dir)?; + + let cache = if path.exists() { + let raw = fs::read_to_string(&path) + .with_context(|| format!("failed to read {}", path.display()))?; + match serde_json::from_str::(&raw) { + Ok(mut cfg) => { + // Load keychain passwords first. If the keychain is + // unavailable, fall back to any encrypted legacy value that + // is still present in the JSON file. + for session in &mut cfg.sessions { + if !session.password_ref.is_empty() { + match crate::keychain::get_password(&session.password_ref) { + Ok(plain) => { + session.password = Secret::new(plain); + continue; + } + Err(err) => { + tracing::warn!( + "failed to read keychain secret {}: {err:#}", + session.password_ref + ); + } + } + } + if let Some(plain) = Self::try_decrypt(&key, session.password.as_str()) { + session.password = Secret::new(plain); + } + } + Self::normalise_config(&mut cfg); + cfg + } + Err(err) => { + let backup = path.with_extension("json.broken"); + let _ = fs::rename(&path, &backup); + if crate::debug_log::log_paths_enabled() { + tracing::warn!( + "config file was corrupt ({err}); backed up to {}", + backup.display() + ); + } else { + tracing::warn!("config file was corrupt ({err}); created backup"); + } + let mut cfg = ConfigFile::default(); + Self::normalise_config(&mut cfg); + cfg + } + } + } else { + let mut cfg = ConfigFile::default(); + Self::normalise_config(&mut cfg); + cfg + }; + + Ok(Self { path, cache, key }) + } + + fn config_path() -> Result { + let config_dir = Self::configured_config_dir()?; + Self::migrate_legacy_config_if_needed(&config_dir)?; + Ok(config_dir.join(CONFIG_FILE_NAME)) + } + + fn default_config_dir() -> Result { + let exe = std::env::current_exe().context("could not determine executable path")?; + let exe_dir = exe + .parent() + .context("executable path has no parent directory")?; + Ok(exe_dir.join("config")) + } + + fn project_config_dir() -> Result { + let dirs = ProjectDirs::from("dev", "meatshell", "meatshell") + .context("could not determine user config directory")?; + Ok(dirs.config_dir().to_path_buf()) + } + + fn config_dir_override_path() -> Result { + Ok(Self::project_config_dir()?.join(CONFIG_DIR_OVERRIDE_FILE)) + } + + fn configured_config_dir() -> Result { + let override_path = Self::config_dir_override_path()?; + if override_path.exists() { + let raw = fs::read_to_string(&override_path) + .with_context(|| format!("failed to read {}", override_path.display()))?; + let configured = raw.trim(); + if !configured.is_empty() { + let configured = PathBuf::from(configured); + if Self::config_dir_is_usable(&configured) { + return Ok(configured); + } + tracing::warn!( + "configured config directory is not writable; ignoring override {}", + configured.display() + ); + } + } + let portable = Self::default_config_dir()?; + if Self::config_dir_is_usable(&portable) { + return Ok(portable); + } + let fallback = Self::project_config_dir()?; + tracing::warn!( + "portable config directory is not writable; falling back to {}", + fallback.display() + ); + Ok(fallback) + } + + fn config_dir_is_usable(config_dir: &Path) -> bool { + if fs::create_dir_all(config_dir).is_err() { + return false; + } + let probe = config_dir.join(".write-test"); + match fs::write(&probe, b"ok") { + Ok(()) => { + let _ = fs::remove_file(probe); + true + } + Err(_) => false, + } + } + + fn write_config_dir_override(config_dir: &Path) -> Result<()> { + let override_path = Self::config_dir_override_path()?; + if let Some(parent) = override_path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!("failed to create config dir {}", parent.display()) + })?; + } + fs::write(&override_path, config_dir.to_string_lossy().as_bytes()) + .with_context(|| format!("failed to write {}", override_path.display()))?; + Ok(()) + } + + fn migrate_legacy_config_if_needed(config_dir: &Path) -> Result<()> { + let legacy_dir = Self::project_config_dir()?; + if legacy_dir == config_dir { + return Ok(()); + } + let target_config = config_dir.join(CONFIG_FILE_NAME); + let legacy_config = legacy_dir.join(CONFIG_FILE_NAME); + if target_config.exists() || !legacy_config.exists() { + return Ok(()); + } + + fs::create_dir_all(config_dir).with_context(|| { + format!("failed to create config dir {}", config_dir.display()) + })?; + fs::copy(&legacy_config, &target_config).with_context(|| { + format!( + "failed to migrate config from {} to {}", + legacy_config.display(), + target_config.display() + ) + })?; + + let legacy_key = legacy_dir.join(SECRET_KEY_FILE_NAME); + let target_key = config_dir.join(SECRET_KEY_FILE_NAME); + if legacy_key.exists() && !target_key.exists() { + let _ = fs::copy(&legacy_key, &target_key); + } + Ok(()) + } + + pub fn sessions(&self) -> &[Session] { + &self.cache.sessions + } + + #[allow(dead_code)] // reserved for an upcoming reorder/drag-drop feature + pub fn sessions_mut(&mut self) -> &mut Vec { + &mut self.cache.sessions + } + + pub fn upsert(&mut self, session: Session) { + let mut session = session; + session.group = self.connection_folder_or_default(&session.group); + if let Some(existing) = self + .cache + .sessions + .iter_mut() + .find(|s| s.id == session.id) + { + *existing = session; + } else { + self.cache.sessions.push(session); + } + } + + pub fn remove(&mut self, id: &str) { + self.cache.sessions.retain(|s| s.id != id); + } + + pub fn get(&self, id: &str) -> Option<&Session> { + self.cache.sessions.iter().find(|s| s.id == id) + } + + pub fn config_dir(&self) -> &Path { + self.path.parent().unwrap_or_else(|| Path::new(".")) + } + + pub fn set_config_dir(&mut self, dir: PathBuf) -> Result<()> { + let dir = if dir.as_os_str().is_empty() { + Self::default_config_dir()? + } else { + dir + }; + fs::create_dir_all(&dir).with_context(|| { + format!("failed to create config dir {}", dir.display()) + })?; + Self::write_key(&dir, &self.key)?; + self.path = dir.join(CONFIG_FILE_NAME); + Self::write_config_dir_override(&dir)?; + Ok(()) + } + + pub fn connection_folders(&self) -> &[String] { + &self.cache.connection_folders + } + + pub fn connection_folder_expanded(&self, name: &str) -> bool { + let name = clean_name(name, &default_group()); + !self + .cache + .connection_folder_collapsed + .iter() + .any(|folder| folder == &name) + } + + pub fn toggle_connection_folder(&mut self, name: &str) -> bool { + let name = self.connection_folder_or_default(name); + if let Some(index) = self + .cache + .connection_folder_collapsed + .iter() + .position(|folder| folder == &name) + { + self.cache.connection_folder_collapsed.remove(index); + true + } else { + self.cache.connection_folder_collapsed.push(name); + false + } + } + + pub fn add_connection_folder(&mut self, name: String) -> String { + let name = clean_name(&name, &default_group()); + if !self.cache.connection_folders.iter().any(|f| f == &name) { + self.cache.connection_folders.push(name.clone()); + } + self.cache + .connection_folder_collapsed + .retain(|folder| folder != &name); + name + } + + pub fn connection_folder_or_default(&self, name: &str) -> String { + let name = clean_name(name, &default_group()); + if self.cache.connection_folders.iter().any(|f| f == &name) { + name + } else { + self.cache + .connection_folders + .first() + .cloned() + .unwrap_or_else(default_group) + } + } + + pub fn download_dir(&self) -> &str { + &self.cache.download_dir + } + + pub fn set_download_dir(&mut self, dir: String) { + self.cache.download_dir = dir; + } + + /// UI language code ("zh" default / "en"). + pub fn language(&self) -> &str { + if self.cache.language.is_empty() { + "zh" + } else { + &self.cache.language + } + } + + pub fn set_language(&mut self, lang: String) { + self.cache.language = lang; + } + + /// Theme preference: "system" (default) | "dark" | "light". + pub fn theme_pref(&self) -> &str { + if self.cache.theme_pref.is_empty() { + "system" + } else { + &self.cache.theme_pref + } + } + + pub fn set_theme_pref(&mut self, pref: String) { + self.cache.theme_pref = pref; + } + + pub fn max_terminal_tabs(&self) -> u32 { + self.cache.max_terminal_tabs.clamp(1, 50) + } + + pub fn set_max_terminal_tabs(&mut self, max_tabs: u32) { + self.cache.max_terminal_tabs = max_tabs.clamp(1, 50); + } + + pub fn terminal_font_size(&self) -> u32 { + self.cache.terminal_font_size.clamp(10, 28) + } + + pub fn set_terminal_font_size(&mut self, font_size: u32) { + self.cache.terminal_font_size = font_size.clamp(10, 28); + } + + pub fn sftp_file_font_size(&self) -> u32 { + self.cache.sftp_file_font_size.clamp(10, 28) + } + + pub fn set_sftp_file_font_size(&mut self, font_size: u32) { + self.cache.sftp_file_font_size = font_size.clamp(10, 28); + } + + pub fn background_path(&self) -> &str { + &self.cache.background_path + } + + pub fn set_background_path(&mut self, path: String) { + self.cache.background_path = path; + } + + pub fn background_mode(&self) -> u8 { + self.cache.background_mode.min(2) + } + + pub fn set_background_mode(&mut self, mode: u8) { + self.cache.background_mode = mode.min(2); + } + + pub fn background_blur_px(&self) -> u32 { + self.cache.background_blur_px.clamp(0, 20) + } + + pub fn set_background_blur_px(&mut self, blur_px: u32) { + self.cache.background_blur_px = blur_px.clamp(0, 20); + } + + pub fn terminal_opacity_percent(&self) -> u32 { + self.cache.terminal_opacity_percent.clamp(30, 100) + } + + pub fn set_terminal_opacity_percent(&mut self, opacity: u32) { + self.cache.terminal_opacity_percent = opacity.clamp(30, 100); + } + + pub fn ui_opacity_percent(&self) -> u32 { + self.cache.ui_opacity_percent.clamp(30, 100) + } + + pub fn set_ui_opacity_percent(&mut self, opacity: u32) { + self.cache.ui_opacity_percent = opacity.clamp(30, 100); + } + + pub fn ui_debug_ids(&self) -> bool { + self.cache.ui_debug_ids + } + + pub fn set_ui_debug_ids(&mut self, enabled: bool) { + self.cache.ui_debug_ids = enabled; + } + + pub fn rename_connection_folder(&mut self, old_name: &str, new_name: String) -> String { + let old_name = clean_name(old_name, &default_group()); + let new_name = clean_name(&new_name, &default_group()); + if old_name == new_name { + return new_name; + } + if let Some(folder) = self + .cache + .connection_folders + .iter_mut() + .find(|folder| **folder == old_name) + { + *folder = new_name.clone(); + } else { + self.cache.connection_folders.push(new_name.clone()); + } + for folder in &mut self.cache.connection_folder_collapsed { + if *folder == old_name { + *folder = new_name.clone(); + } + } + for session in &mut self.cache.sessions { + if session.group == old_name { + session.group = new_name.clone(); + } + } + Self::normalise_config(&mut self.cache); + new_name + } + + pub fn remove_connection_folder(&mut self, name: &str) { + let name = clean_name(name, &default_group()); + let fallback = self + .cache + .connection_folders + .iter() + .find(|folder| **folder != name) + .cloned() + .unwrap_or_else(default_group); + self.cache.connection_folders.retain(|folder| *folder != name); + self.cache + .connection_folder_collapsed + .retain(|folder| *folder != name); + if self.cache.connection_folders.is_empty() { + self.cache.connection_folders.push(fallback.clone()); + } + for session in &mut self.cache.sessions { + if session.group == name { + session.group = fallback.clone(); + } + } + Self::normalise_config(&mut self.cache); + } + + pub fn move_session_to_folder(&mut self, session_id: &str, folder: &str) -> Option { + let folder = self.connection_folder_or_default(folder); + if let Some(session) = self.cache.sessions.iter_mut().find(|s| s.id == session_id) { + session.group = folder.clone(); + Some(folder) + } else { + None + } + } + + pub fn sftp_name_column_width(&self) -> u32 { + self.cache.sftp_name_column_width.clamp(96, 360) + } + + pub fn sftp_name_column_manual(&self) -> bool { + self.cache.sftp_name_column_manual + } + + pub fn set_sftp_name_column_width(&mut self, width: u32, manual: bool) { + self.cache.sftp_name_column_width = width.clamp(96, 360); + self.cache.sftp_name_column_manual = manual; + } + + #[allow(dead_code)] // kept for compatibility with existing config data + pub fn command_history(&self) -> &[String] { + &self.cache.command_history + } + + pub fn remember_command(&mut self, command: String) { + let command = command.trim(); + if command.is_empty() { + return; + } + self.cache.command_history.retain(|c| c != command); + self.cache.command_history.insert(0, command.to_string()); + const MAX_COMMAND_HISTORY: usize = 200; + if self.cache.command_history.len() > MAX_COMMAND_HISTORY { + self.cache.command_history.truncate(MAX_COMMAND_HISTORY); + } + } + + #[allow(dead_code)] // legacy snippets are migrated into commands on load + pub fn snippets(&self) -> &[CommandSnippet] { + &self.cache.snippets + } + + #[allow(dead_code)] // legacy command bar API + pub fn add_snippet(&mut self, command: String) { + let command = command.trim(); + if command.is_empty() || self.cache.snippets.iter().any(|s| s.command == command) { + return; + } + self.cache + .snippets + .push(CommandSnippet::new(command.to_string())); + } + + #[allow(dead_code)] // legacy command bar API + pub fn remove_snippet(&mut self, id: &str) { + self.cache.snippets.retain(|s| s.id != id); + } + + pub fn command_categories(&self) -> &[StoredCommandCategory] { + &self.cache.command_categories + } + + pub fn commands(&self) -> &[StoredCommand] { + &self.cache.commands + } + + pub fn add_command_category(&mut self, name: String) -> String { + let id = Uuid::new_v4().to_string(); + let name = clean_name(&name, "未命名分类"); + self.cache.command_categories.push(StoredCommandCategory { + id: id.clone(), + name, + builtin: false, + }); + id + } + + pub fn rename_command_category(&mut self, id: &str, name: String) { + if let Some(cat) = self.cache.command_categories.iter_mut().find(|c| c.id == id) { + cat.name = clean_name(&name, "未命名分类"); + } + } + + pub fn remove_command_category(&mut self, id: &str) { + if id == DEFAULT_COMMAND_CATEGORY_ID { + return; + } + self.cache.command_categories.retain(|c| c.id != id); + for cmd in &mut self.cache.commands { + if cmd.category_id == id { + cmd.category_id = DEFAULT_COMMAND_CATEGORY_ID.to_string(); + } + } + } + + pub fn upsert_command( + &mut self, + id: &str, + category_id: &str, + name: String, + command: String, + append_cr: bool, + ) -> Option { + let command = command.trim(); + if command.is_empty() { + return None; + } + let category_id = if self + .cache + .command_categories + .iter() + .any(|c| c.id == category_id) + { + category_id.to_string() + } else { + DEFAULT_COMMAND_CATEGORY_ID.to_string() + }; + let name = clean_name(&name, "未命名命令"); + + if let Some(existing) = self.cache.commands.iter_mut().find(|c| c.id == id) { + existing.category_id = category_id; + existing.name = name; + existing.command = command.to_string(); + existing.append_cr = append_cr; + return Some(existing.id.clone()); + } + + let id = Uuid::new_v4().to_string(); + self.cache.commands.push(StoredCommand { + id: id.clone(), + category_id, + name, + command: command.to_string(), + append_cr, + }); + Some(id) + } + + pub fn remove_command(&mut self, id: &str) { + self.cache.commands.retain(|c| c.id != id); + } + + pub fn move_command(&mut self, id: &str, category_id: &str) { + if !self + .cache + .command_categories + .iter() + .any(|c| c.id == category_id) + { + return; + } + if let Some(cmd) = self.cache.commands.iter_mut().find(|c| c.id == id) { + cmd.category_id = category_id.to_string(); + } + } + + pub fn save(&self) -> Result<()> { + // Build a disk copy where every non-empty password is either migrated + // into the OS keychain (preferred) or encrypted as a fallback. + let mut disk = self.cache.clone(); + for session in &mut disk.sessions { + if !session.password.is_empty() + && !session.password.as_str().starts_with(Self::ENC_PREFIX) + { + let password_ref = if session.password_ref.is_empty() { + Self::keychain_ref(&session.id) + } else { + session.password_ref.clone() + }; + match crate::keychain::set_password(&password_ref, session.password.as_str()) { + Ok(()) => { + session.password_ref = password_ref; + session.password = Secret::default(); + } + Err(err) => { + tracing::warn!( + "failed to write password to OS keychain; using encrypted config fallback: {err:#}" + ); + let enc = Self::encrypt(&self.key, session.password.as_str())?; + session.password = Secret::new(enc); + } + } + } + } + let raw = serde_json::to_string_pretty(&disk)?; + // Write to a sibling temp file then rename — cheap atomicity. + let tmp = self.path.with_extension("json.tmp"); + fs::write(&tmp, &raw) + .with_context(|| format!("failed to write {}", tmp.display()))?; + fs::rename(&tmp, &self.path) + .with_context(|| format!("failed to finalise {}", self.path.display()))?; + Ok(()) + } + + fn keychain_ref(session_id: &str) -> String { + format!("{}{}", Self::KEYCHAIN_PREFIX, session_id) + } +} + +fn clean_name(name: &str, fallback: &str) -> String { + let name = name.trim(); + if name.is_empty() { + fallback.to_string() + } else { + name.to_string() + } +} diff --git a/src/debug_log.rs b/src/debug_log.rs new file mode 100644 index 0000000..7d08160 --- /dev/null +++ b/src/debug_log.rs @@ -0,0 +1,155 @@ +use std::fs::{File, OpenOptions}; +use std::io::Write; +use std::path::PathBuf; +use std::time::SystemTime; + +const LOG_NAME: &str = "meatshell-debug.log"; + +pub fn init() -> Option { + let (file, path) = match open_log_file() { + Some(pair) => pair, + None => { + install_panic_hook(None); + init_stderr_tracing(); + tracing::warn!("failed to create {LOG_NAME}; logging to stderr only"); + return None; + } + }; + + install_panic_hook(Some(path.clone())); + + let filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,meatshell=debug,winit=error")); + let log_file = file; + if tracing_subscriber::fmt() + .with_env_filter(filter) + .with_ansi(false) + .with_thread_ids(true) + .with_writer(move || log_file.try_clone().expect("clone debug log file")) + .try_init() + .is_err() + { + return Some(path); + } + + tracing::info!( + version = env!("CARGO_PKG_VERSION"), + os = std::env::consts::OS, + arch = std::env::consts::ARCH, + "debug logging initialized" + ); + + if log_paths_enabled() { + tracing::debug!(log = %path.display(), "debug log path"); + if let Ok(cwd) = std::env::current_dir() { + tracing::debug!(cwd = %cwd.display(), "current working directory"); + } + if let Ok(exe) = std::env::current_exe() { + tracing::debug!(exe = %exe.display(), "current executable"); + } + } + + Some(path) +} + +fn init_stderr_tracing() { + let filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,meatshell=debug,winit=error")); + let _ = tracing_subscriber::fmt() + .with_env_filter(filter) + .with_ansi(false) + .with_thread_ids(true) + .try_init(); +} + +fn open_log_file() -> Option<(File, PathBuf)> { + let mut dirs = Vec::new(); + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + dirs.push(dir.to_path_buf()); + } + } + if let Ok(cwd) = std::env::current_dir() { + if !dirs.iter().any(|dir| dir == &cwd) { + dirs.push(cwd); + } + } + + for dir in dirs { + let path = dir.join(LOG_NAME); + if let Ok(file) = OpenOptions::new().create(true).append(true).open(&path) { + return Some((file, path)); + } + } + + None +} + +fn install_panic_hook(path: Option) { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let payload = if let Some(message) = info.payload().downcast_ref::<&str>() { + *message + } else if let Some(message) = info.payload().downcast_ref::() { + message.as_str() + } else { + "" + }; + let location = info + .location() + .map(|loc| format!("{}:{}:{}", short_file(loc.file()), loc.line(), loc.column())) + .unwrap_or_else(|| "".to_string()); + let backtrace = if env_flag("MEATSHELL_DEBUG_BACKTRACE") { + Some(std::backtrace::Backtrace::force_capture().to_string()) + } else { + None + }; + + if let Some(path) = &path { + if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) { + let _ = writeln!( + file, + "\n===== panic {:?} =====\nlocation: {}\nmessage: {}", + SystemTime::now(), + location, + payload + ); + if let Some(backtrace) = &backtrace { + let _ = writeln!(file, "backtrace:\n{}", backtrace); + } + let _ = writeln!(file); + } + } + + if let Some(backtrace) = &backtrace { + tracing::error!( + location = %location, + message = %payload, + backtrace = %backtrace, + "panic captured" + ); + } else { + tracing::error!( + location = %location, + message = %payload, + "panic captured" + ); + } + previous(info); + })); +} + +pub(crate) fn log_paths_enabled() -> bool { + env_flag("MEATSHELL_DEBUG_PATHS") +} + +fn env_flag(name: &str) -> bool { + matches!( + std::env::var(name).as_deref(), + Ok("1") | Ok("true") | Ok("TRUE") | Ok("yes") | Ok("YES") | Ok("on") | Ok("ON") + ) +} + +fn short_file(path: &str) -> &str { + path.rsplit(['/', '\\']).next().unwrap_or(path) +} diff --git a/src/i18n.rs b/src/i18n.rs new file mode 100644 index 0000000..9c67344 --- /dev/null +++ b/src/i18n.rs @@ -0,0 +1,64 @@ +//! Tiny runtime internationalisation. +//! +//! Two cooperating mechanisms keep the whole UI translatable: +//! +//! * **Static `.slint` text** uses Slint's own `@tr("English")` plus bundled +//! `.po` translations. The source language (the msgids) is **English**; the +//! Chinese strings live in `lang/zh/LC_MESSAGES/meatshell.po`. Switching is +//! done with `slint::select_bundled_translation` (`"zh"` → Chinese, `""`/`"en"` +//! → the English source). +//! +//! * **Dynamic Rust text** (status lines, errors, transfer details that Rust +//! builds with `format!`) can't use `@tr`, so it uses [`t`] which returns the +//! Chinese or English variant based on the current language flag. +//! +//! [`set_language`] updates both at once so the two stay in sync. + +use std::sync::atomic::{AtomicU8, Ordering}; + +const ZH: u8 = 0; +const EN: u8 = 1; + +static LANG: AtomicU8 = AtomicU8::new(ZH); + +/// Apply a language code (`"zh"` or `"en"`). Updates the Rust-side flag and +/// Slint's bundled-translation selection. Safe to call before the first +/// component exists for the flag; the Slint selection is a no-op error then and +/// should be re-applied once the window is created. +pub fn set_language(code: &str) { + let en = code.eq_ignore_ascii_case("en"); + LANG.store(if en { EN } else { ZH }, Ordering::Relaxed); + apply_to_slint(); +} + +/// Re-apply the current language to Slint's bundled translations. Must run +/// after the first component is created (Slint requirement). We bundle BOTH an +/// `en` (identity) and a `zh` translation and select explicitly, because the +/// empty/`"en"` shortcut selects bundle index 0 — which would be `zh` when only +/// the Chinese bundle exists. +pub fn apply_to_slint() { + let lang = if is_en() { "en" } else { "zh" }; + let _ = slint::select_bundled_translation(lang); +} + +/// Current language code, for persisting to config (`"zh"` / `"en"`). +pub fn current_code() -> &'static str { + if is_en() { + "en" + } else { + "zh" + } +} + +pub fn is_en() -> bool { + LANG.load(Ordering::Relaxed) == EN +} + +/// Pick the variant for the current language: `zh` is Chinese, `en` is English. +pub fn t(zh: &'static str, en: &'static str) -> &'static str { + if is_en() { + en + } else { + zh + } +} diff --git a/src/keychain.rs b/src/keychain.rs new file mode 100644 index 0000000..d0d967b --- /dev/null +++ b/src/keychain.rs @@ -0,0 +1,331 @@ +//! Best-effort OS keychain storage for session passwords. +//! +//! Windows and macOS use native system APIs. Linux uses `secret-tool` when +//! available (GNOME Keyring / Secret Service). Callers keep an encrypted config +//! fallback for machines without a usable keychain. + +use anyhow::{anyhow, Context, Result}; + +const SERVICE: &str = "meatshell"; + +pub fn get_password(account: &str) -> Result { + platform::get_password(account) +} + +pub fn set_password(account: &str, password: &str) -> Result<()> { + platform::set_password(account, password) +} + +#[cfg(windows)] +mod platform { + use super::*; + use std::ffi::c_void; + use std::slice; + + const CRED_TYPE_GENERIC: u32 = 1; + const CRED_PERSIST_LOCAL_MACHINE: u32 = 2; + + #[repr(C)] + struct FileTime { + low_date_time: u32, + high_date_time: u32, + } + + #[repr(C)] + struct CredentialW { + flags: u32, + typ: u32, + target_name: *mut u16, + comment: *mut u16, + last_written: FileTime, + credential_blob_size: u32, + credential_blob: *mut u8, + persist: u32, + attribute_count: u32, + attributes: *mut c_void, + target_alias: *mut u16, + user_name: *mut u16, + } + + #[link(name = "advapi32")] + extern "system" { + fn CredWriteW(credential: *const CredentialW, flags: u32) -> i32; + fn CredReadW( + target_name: *const u16, + typ: u32, + flags: u32, + credential: *mut *mut CredentialW, + ) -> i32; + fn CredFree(buffer: *mut c_void); + } + + pub fn set_password(account: &str, password: &str) -> Result<()> { + let mut target = wide(&target(account)); + let mut user = wide(account); + let mut blob = password.as_bytes().to_vec(); + let cred = CredentialW { + flags: 0, + typ: CRED_TYPE_GENERIC, + target_name: target.as_mut_ptr(), + comment: std::ptr::null_mut(), + last_written: FileTime { + low_date_time: 0, + high_date_time: 0, + }, + credential_blob_size: blob.len() as u32, + credential_blob: blob.as_mut_ptr(), + persist: CRED_PERSIST_LOCAL_MACHINE, + attribute_count: 0, + attributes: std::ptr::null_mut(), + target_alias: std::ptr::null_mut(), + user_name: user.as_mut_ptr(), + }; + let ok = unsafe { CredWriteW(&cred, 0) }; + if ok == 0 { + return Err(anyhow!("CredWriteW failed")); + } + Ok(()) + } + + pub fn get_password(account: &str) -> Result { + let target = wide(&target(account)); + let mut cred: *mut CredentialW = std::ptr::null_mut(); + let ok = unsafe { CredReadW(target.as_ptr(), CRED_TYPE_GENERIC, 0, &mut cred) }; + if ok == 0 || cred.is_null() { + return Err(anyhow!("CredReadW failed")); + } + let result = unsafe { + let c = &*cred; + let bytes = slice::from_raw_parts( + c.credential_blob, + c.credential_blob_size as usize, + ); + String::from_utf8(bytes.to_vec()).context("credential is not UTF-8") + }; + unsafe { CredFree(cred.cast()) }; + result + } + + fn target(account: &str) -> String { + format!("{}:{}", SERVICE, account) + } + + fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() + } +} + +#[cfg(target_os = "macos")] +mod platform { + use super::*; + use std::ffi::{c_char, c_void, CString}; + use std::slice; + + const ERR_SEC_SUCCESS: i32 = 0; + const ERR_SEC_DUPLICATE_ITEM: i32 = -25299; + + #[link(name = "Security", kind = "framework")] + extern "C" { + fn SecKeychainAddGenericPassword( + keychain: *mut c_void, + service_name_length: u32, + service_name: *const c_char, + account_name_length: u32, + account_name: *const c_char, + password_length: u32, + password_data: *const c_void, + item_ref: *mut *mut c_void, + ) -> i32; + fn SecKeychainFindGenericPassword( + keychain_or_array: *mut c_void, + service_name_length: u32, + service_name: *const c_char, + account_name_length: u32, + account_name: *const c_char, + password_length: *mut u32, + password_data: *mut *mut c_void, + item_ref: *mut *mut c_void, + ) -> i32; + fn SecKeychainItemModifyContent( + item_ref: *mut c_void, + attr_list: *const c_void, + length: u32, + data: *const c_void, + ) -> i32; + fn SecKeychainItemFreeContent(attr_list: *mut c_void, data: *mut c_void) -> i32; + fn CFRelease(cf: *const c_void); + } + + pub fn set_password(account: &str, password: &str) -> Result<()> { + let service = cstr(SERVICE)?; + let account = cstr(account)?; + let pass = password.as_bytes(); + let mut item: *mut c_void = std::ptr::null_mut(); + let status = unsafe { + SecKeychainAddGenericPassword( + std::ptr::null_mut(), + SERVICE.len() as u32, + service.as_ptr(), + account.as_bytes().len() as u32, + account.as_ptr(), + pass.len() as u32, + pass.as_ptr().cast(), + &mut item, + ) + }; + if status == ERR_SEC_SUCCESS { + if !item.is_null() { + unsafe { CFRelease(item.cast()) }; + } + return Ok(()); + } + if status != ERR_SEC_DUPLICATE_ITEM { + return Err(anyhow!("SecKeychainAddGenericPassword failed: {status}")); + } + + let item = find_item( + service.as_ptr(), + SERVICE.len(), + account.as_ptr(), + account.as_bytes().len(), + )?; + let status = unsafe { + SecKeychainItemModifyContent( + item, + std::ptr::null(), + pass.len() as u32, + pass.as_ptr().cast(), + ) + }; + unsafe { CFRelease(item.cast()) }; + if status != ERR_SEC_SUCCESS { + return Err(anyhow!("SecKeychainItemModifyContent failed: {status}")); + } + Ok(()) + } + + pub fn get_password(account: &str) -> Result { + let service = cstr(SERVICE)?; + let account = cstr(account)?; + let mut len = 0u32; + let mut data: *mut c_void = std::ptr::null_mut(); + let mut item: *mut c_void = std::ptr::null_mut(); + let status = unsafe { + SecKeychainFindGenericPassword( + std::ptr::null_mut(), + SERVICE.len() as u32, + service.as_ptr(), + account.as_bytes().len() as u32, + account.as_ptr(), + &mut len, + &mut data, + &mut item, + ) + }; + if status != ERR_SEC_SUCCESS { + return Err(anyhow!("SecKeychainFindGenericPassword failed: {status}")); + } + let result = unsafe { + let bytes = slice::from_raw_parts(data.cast::(), len as usize); + String::from_utf8(bytes.to_vec()).context("credential is not UTF-8") + }; + unsafe { + SecKeychainItemFreeContent(std::ptr::null_mut(), data); + if !item.is_null() { + CFRelease(item.cast()); + } + } + result + } + + fn find_item( + service: *const c_char, + service_len: usize, + account: *const c_char, + account_len: usize, + ) -> Result<*mut c_void> { + let mut item: *mut c_void = std::ptr::null_mut(); + let status = unsafe { + SecKeychainFindGenericPassword( + std::ptr::null_mut(), + service_len as u32, + service, + account_len as u32, + account, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut item, + ) + }; + if status != ERR_SEC_SUCCESS || item.is_null() { + return Err(anyhow!("SecKeychainFindGenericPassword failed: {status}")); + } + Ok(item) + } + + fn cstr(s: &str) -> Result { + CString::new(s).context("credential string contains NUL") + } +} + +#[cfg(all(unix, not(target_os = "macos")))] +mod platform { + use super::*; + use std::io::Write; + use std::process::{Command, Stdio}; + + pub fn set_password(account: &str, password: &str) -> Result<()> { + let mut child = Command::new("secret-tool") + .args([ + "store", + "--label", + "meatshell session password", + "app", + SERVICE, + "account", + account, + ]) + .stdin(Stdio::piped()) + .spawn() + .context("secret-tool is not available")?; + child + .stdin + .as_mut() + .context("secret-tool stdin unavailable")? + .write_all(password.as_bytes()) + .context("write password to secret-tool")?; + let status = child.wait().context("wait for secret-tool")?; + if !status.success() { + return Err(anyhow!("secret-tool store failed")); + } + Ok(()) + } + + pub fn get_password(account: &str) -> Result { + let out = Command::new("secret-tool") + .args(["lookup", "app", SERVICE, "account", account]) + .output() + .context("secret-tool is not available")?; + if !out.status.success() { + return Err(anyhow!("secret-tool lookup failed")); + } + let mut s = String::from_utf8(out.stdout).context("credential is not UTF-8")?; + while s.ends_with('\n') || s.ends_with('\r') { + s.pop(); + } + Ok(s) + } +} + +#[cfg(not(any(unix, windows)))] +mod platform { + use super::*; + + pub fn set_password(_account: &str, _password: &str) -> Result<()> { + Err(anyhow!("no keychain backend for this platform")) + } + + pub fn get_password(_account: &str) -> Result { + Err(anyhow!("no keychain backend for this platform")) + } +} diff --git a/src/known_hosts.rs b/src/known_hosts.rs new file mode 100644 index 0000000..82819bd --- /dev/null +++ b/src/known_hosts.rs @@ -0,0 +1,286 @@ +//! Minimal OpenSSH `known_hosts` support. +//! +//! Behaviour is deliberately conservative: +//! - match entries from the user's `~/.ssh/known_hosts` and meatshell's own +//! config-local `known_hosts`; +//! - trust-on-first-use into meatshell's file when no entry exists; +//! - reject the connection when a matching host entry exists with a different +//! key. + +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use directories::UserDirs; +use ssh_key::PublicKey; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HostKeyStatus { + Verified, + Added, + Changed, +} + +/// Check `host:port` against OpenSSH known_hosts files, adding a new entry to +/// `app_known_hosts` on first use. +pub fn check_or_trust_in( + host: &str, + port: u16, + key: &PublicKey, + app_known_hosts: &Path, +) -> Result { + let key = public_key_parts(key)?; + let targets = host_patterns(host, port); + + let mut saw_same_key_type = false; + for (path, can_reject) in known_hosts_files(app_known_hosts) { + let Ok(raw) = fs::read_to_string(&path) else { + continue; + }; + for line in raw.lines() { + let Some(entry) = parse_line(line) else { + continue; + }; + if !entry.hosts.iter().any(|h| targets.iter().any(|t| host_matches(h, t))) { + continue; + } + if entry.key_type == key.0 { + if can_reject { + saw_same_key_type = true; + } + if entry.key_data == key.1 { + return Ok(HostKeyStatus::Verified); + } + } + } + } + + if saw_same_key_type { + return Ok(HostKeyStatus::Changed); + } + + append_app_known_host(app_known_hosts, host, port, &key.0, &key.1)?; + Ok(HostKeyStatus::Added) +} + +fn known_hosts_files(app_known_hosts: &Path) -> Vec<(PathBuf, bool)> { + let mut out = Vec::new(); + if let Some(home) = UserDirs::new() { + out.push((home.home_dir().join(".ssh").join("known_hosts"), false)); + } + out.push((app_known_hosts.to_path_buf(), true)); + out +} + +fn append_app_known_host( + path: &Path, + host: &str, + port: u16, + key_type: &str, + key_data: &str, +) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + #[cfg(unix)] + let existed = path.exists(); + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .with_context(|| format!("failed to open {}", path.display()))?; + #[cfg(unix)] + if !existed { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600)); + } + + let host = if port == 22 { + host.to_string() + } else { + format!("[{host}]:{port}") + }; + writeln!(file, "{host} {key_type} {key_data} meatshell") + .with_context(|| format!("failed to write {}", path.display()))?; + Ok(()) +} + +fn public_key_parts(key: &PublicKey) -> Result<(String, String)> { + let encoded = key.to_openssh().context("encode server public key")?; + let mut fields = encoded.split_whitespace(); + let key_type = fields + .next() + .context("server public key missing type")? + .to_string(); + let key_data = fields + .next() + .context("server public key missing data")? + .to_string(); + Ok((key_type, key_data)) +} + +fn host_patterns(host: &str, port: u16) -> Vec { + let mut out = Vec::new(); + out.push(host.to_ascii_lowercase()); + out.push(format!("[{}]:{}", host.to_ascii_lowercase(), port)); + if port == 22 { + out.push(format!("[{}]:22", host.to_ascii_lowercase())); + } + out +} + +#[derive(Debug, PartialEq, Eq)] +struct KnownHostEntry { + hosts: Vec, + key_type: String, + key_data: String, +} + +fn parse_line(line: &str) -> Option { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + return None; + } + + let mut fields = trimmed.split_whitespace(); + let first = fields.next()?; + let hosts = if first.starts_with('@') { + // Marker such as @cert-authority or @revoked. We only handle ordinary + // host-key entries here; markers are skipped rather than misread. + return None; + } else { + first + }; + if hosts.starts_with("|1|") { + // Hashed OpenSSH entries need the per-line HMAC salt. Keep support + // explicit by ignoring them instead of producing false matches. + return None; + } + let key_type = fields.next()?.to_string(); + let key_data = fields.next()?.to_string(); + Some(KnownHostEntry { + hosts: hosts.split(',').map(|h| h.to_ascii_lowercase()).collect(), + key_type, + key_data, + }) +} + +fn host_matches(pattern: &str, target: &str) -> bool { + if pattern == target { + return true; + } + if let Some(suffix) = pattern.strip_prefix("*.") { + return target + .strip_suffix(suffix) + .map(|prefix| prefix.ends_with('.') && !prefix.trim_end_matches('.').is_empty()) + .unwrap_or(false); + } + false +} + +#[cfg(test)] +mod tests { + use super::{ + check_or_trust_in, host_matches, known_hosts_files, parse_line, HostKeyStatus, + KnownHostEntry, + }; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn fixture_key(key_type: &str, key_data: &str) -> ssh_key::PublicKey { + format!("{key_type} {key_data}") + .parse() + .expect("valid public key fixture") + } + + fn temp_known_hosts_path(name: &str) -> std::path::PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir() + .join("meatshell-known-hosts-tests") + .join(format!("{name}-{unique}")) + } + + #[test] + fn parses_plain_known_hosts_line() { + assert_eq!( + parse_line("example.com,192.0.2.1 ssh-ed25519 AAAA comment"), + Some(KnownHostEntry { + hosts: vec!["example.com".into(), "192.0.2.1".into()], + key_type: "ssh-ed25519".into(), + key_data: "AAAA".into(), + }) + ); + } + + #[test] + fn skips_hashed_and_marker_entries() { + assert!(parse_line("|1|salt|hash ssh-ed25519 AAAA").is_none()); + assert!(parse_line("@cert-authority *.example.com ssh-ed25519 AAAA").is_none()); + } + + #[test] + fn matches_exact_and_wildcard_hosts() { + assert!(host_matches("example.com", "example.com")); + assert!(host_matches("*.example.com", "db.prod.example.com")); + assert!(!host_matches("*.example.com", "example.com")); + } + + #[test] + fn accepts_additional_key_type_for_known_host() { + let path = temp_known_hosts_path("additional-key-type"); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write( + &path, + "10.31.88.88 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHcL1R5w/NbHtGXf+qXCDP1KIXL4SMLJK/7UKcaGEJXC\n", + ) + .unwrap(); + + let key = fixture_key( + "ecdsa-sha2-nistp256", + "AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBHwf2HMM5TRXvo2SQJjsNkiDD5KqiiNjrGVv3UUh+mMT5RHxiRtOnlqvjhQtBq0VpmpCV/PwUdhOig4vkbqAcEc=", + ); + let status = check_or_trust_in("10.31.88.88", 22, &key, &path).unwrap(); + + assert_eq!(status, HostKeyStatus::Added); + let raw = fs::read_to_string(path).unwrap(); + assert!(raw.contains("ssh-ed25519")); + assert!(raw.contains("ecdsa-sha2-nistp256")); + } + + #[test] + fn rejects_changed_key_for_same_key_type() { + let path = temp_known_hosts_path("changed-key"); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write( + &path, + "10.31.88.88 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFgFZtmFxw40sXfFl6kOVb9zVTiDjXCh8y2jhkJ/AH63\n", + ) + .unwrap(); + + let key = fixture_key( + "ssh-ed25519", + "AAAAC3NzaC1lZDI1NTE5AAAAIHcL1R5w/NbHtGXf+qXCDP1KIXL4SMLJK/7UKcaGEJXC", + ); + let status = check_or_trust_in("10.31.88.88", 22, &key, &path).unwrap(); + + assert_eq!(status, HostKeyStatus::Changed); + } + + #[test] + fn only_app_known_hosts_can_reject_changed_keys() { + let app_path = temp_known_hosts_path("source-flags"); + let files = known_hosts_files(&app_path); + assert!(files.len() >= 2); + assert!(files.iter().any(|(path, can_reject)| path == &app_path && *can_reject)); + assert!(files.iter().any(|(_, can_reject)| !*can_reject)); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..e513512 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,46 @@ +// Entry point. Wires the Slint UI to the config store, system sampler and +// SSH session manager. + +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +mod app; +mod config; +mod debug_log; +mod i18n; +mod keychain; +mod known_hosts; +mod proxy; +mod serial; +mod sftp; +mod ssh; +mod ssh_config; +mod system; +mod telnet; + +fn main() -> anyhow::Result<()> { + let log_path = debug_log::init(); + + // ── IME policy ─────────────────────────────────────────────────────────── + // NOTE: We deliberately DO **NOT** call `ImmDisableIME` here. + // + // An earlier version disabled the IME for the whole Slint event-loop thread + // to work around a vim `:q!` glitch (Chinese IMEs intercept letter keys and, + // on a Shift press, discard the in-flight pinyin). But disabling the IME + // also makes 中文输入 completely impossible — there is no composition window + // at all, which is exactly the "无法输入任何中文" bug. + // + // Chinese input now flows through the hidden `ime-input` TextInput in + // terminal_view.slint: composition happens there, and committed text is + // forwarded to the PTY via the `edited` callback. The vim/Shift side-effects + // are handled instead by the C0-marker + 3-layer Backspace filters in + // `app::on_send_key`, so we no longer need (and must not use) ImmDisableIME. + + if let Err(err) = app::run() { + tracing::error!(error = %format!("{err:#}"), "application exited with error"); + return Err(err); + } + if let Some(path) = log_path.filter(|_| debug_log::log_paths_enabled()) { + tracing::debug!(log = %path.display(), "application exited normally"); + } + Ok(()) +} diff --git a/src/proxy.rs b/src/proxy.rs new file mode 100644 index 0000000..bd74cb8 --- /dev/null +++ b/src/proxy.rs @@ -0,0 +1,176 @@ +//! Outbound proxy support for SSH / SFTP connections (issue #7). +//! +//! Establishes the TCP stream to the target host **through a proxy**, then the +//! caller hands that stream to `russh::client::connect_stream`. Both proxy +//! kinds end up as a transparent `TcpStream`: +//! +//! * **SOCKS5** (`socks5://` / `socks5h://`) via `tokio-socks`; after the +//! handshake we unwrap to the inner `TcpStream`. +//! * **HTTP / HTTPS CONNECT** (`http://` / `https://`): we issue an HTTP +//! `CONNECT host:port` and reuse the same socket as the tunnel. +//! +//! The proxy is taken from the per-session setting, falling back to the standard +//! `ALL_PROXY` / `all_proxy` environment variable. + +use anyhow::{anyhow, bail, Context, Result}; +use base64::Engine as _; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; + +use crate::config::Secret; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum ProxyKind { + Socks5, + Http, +} + +#[derive(Clone)] +pub struct ProxyConfig { + kind: ProxyKind, + host: String, + port: u16, + // (user, password). The password is wrapped in `Secret` so it is zeroed on + // drop and never printed; see the manual `Debug` below for the user part. + auth: Option<(String, Secret)>, +} + +// Manual `Debug` so proxy credentials can never leak via `{:?}` / tracing +// (issue #32). Host/port/kind stay visible for diagnostics; auth is redacted. +impl std::fmt::Debug for ProxyConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ProxyConfig") + .field("kind", &self.kind) + .field("host", &self.host) + .field("port", &self.port) + .field("auth", &self.auth.as_ref().map(|_| "[redacted]")) + .finish() + } +} + +/// Resolve the proxy for a session: the explicit `session_proxy` string if set, +/// otherwise the `ALL_PROXY` / `all_proxy` environment variable. Returns `None` +/// for a direct connection. +pub fn resolve(session_proxy: &str) -> Option { + let s = session_proxy.trim(); + if !s.is_empty() { + return parse(s); + } + for var in ["ALL_PROXY", "all_proxy"] { + if let Ok(v) = std::env::var(var) { + if !v.trim().is_empty() { + return parse(v.trim()); + } + } + } + None +} + +/// Parse a proxy URL: `scheme://[user:pass@]host:port`. +fn parse(url: &str) -> Option { + let (scheme, rest) = url.split_once("://").unwrap_or(("socks5", url)); + let kind = match scheme.to_ascii_lowercase().as_str() { + "socks5" | "socks5h" | "socks" => ProxyKind::Socks5, + "http" | "https" => ProxyKind::Http, + _ => return None, + }; + // Optional userinfo before '@'. + let (auth, hostport) = match rest.rsplit_once('@') { + Some((userinfo, hp)) => { + let (u, p) = userinfo.split_once(':').unwrap_or((userinfo, "")); + (Some((u.to_string(), Secret::new(p))), hp) + } + None => (None, rest), + }; + let hostport = hostport.trim_end_matches('/'); + let (host, port) = hostport.rsplit_once(':')?; + let port: u16 = port.parse().ok()?; + if host.is_empty() { + return None; + } + Some(ProxyConfig { + kind, + host: host.to_string(), + port, + auth, + }) +} + +/// Human-readable description of where we're connecting (for status messages). +pub fn describe(cfg: &ProxyConfig) -> String { + let scheme = match cfg.kind { + ProxyKind::Socks5 => "socks5", + ProxyKind::Http => "http", + }; + format!("{}://{}:{}", scheme, cfg.host, cfg.port) +} + +/// Open a TCP stream to `target_host:target_port` through the proxy. +pub async fn connect(cfg: &ProxyConfig, target_host: &str, target_port: u16) -> Result { + match cfg.kind { + ProxyKind::Socks5 => connect_socks5(cfg, target_host, target_port).await, + ProxyKind::Http => connect_http(cfg, target_host, target_port).await, + } +} + +async fn connect_socks5(cfg: &ProxyConfig, host: &str, port: u16) -> Result { + use tokio_socks::tcp::Socks5Stream; + let proxy = (cfg.host.as_str(), cfg.port); + let target = (host, port); + let stream = match &cfg.auth { + Some((u, p)) => Socks5Stream::connect_with_password(proxy, target, u, p.as_str()) + .await + .context("SOCKS5 proxy connect failed")?, + None => Socks5Stream::connect(proxy, target) + .await + .context("SOCKS5 proxy connect failed")?, + }; + // After the handshake the underlying socket is a transparent tunnel. + Ok(stream.into_inner()) +} + +async fn connect_http(cfg: &ProxyConfig, host: &str, port: u16) -> Result { + let mut s = TcpStream::connect((cfg.host.as_str(), cfg.port)) + .await + .with_context(|| format!("connect to HTTP proxy {}:{} failed", cfg.host, cfg.port))?; + + let mut req = format!("CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\n"); + if let Some((u, p)) = &cfg.auth { + let token = + base64::engine::general_purpose::STANDARD.encode(format!("{u}:{}", p.as_str())); + req.push_str(&format!("Proxy-Authorization: Basic {token}\r\n")); + } + req.push_str("Proxy-Connection: keep-alive\r\n\r\n"); + s.write_all(req.as_bytes()) + .await + .context("write CONNECT to proxy")?; + + // Read response headers up to the blank line, bounded. + let mut buf = Vec::with_capacity(256); + let mut byte = [0u8; 1]; + loop { + let n = s.read(&mut byte).await.context("read proxy response")?; + if n == 0 { + bail!("proxy closed the connection during CONNECT"); + } + buf.push(byte[0]); + if buf.ends_with(b"\r\n\r\n") { + break; + } + if buf.len() > 8192 { + bail!("proxy CONNECT response too large"); + } + } + let head = String::from_utf8_lossy(&buf); + let status_line = head.lines().next().unwrap_or(""); + // Expect "HTTP/1.x 200 ...". + let ok = status_line + .split_whitespace() + .nth(1) + .map(|c| c == "200") + .unwrap_or(false); + if !ok { + return Err(anyhow!("proxy CONNECT rejected: {}", status_line.trim())); + } + Ok(s) +} diff --git a/src/serial.rs b/src/serial.rs new file mode 100644 index 0000000..2f78b97 --- /dev/null +++ b/src/serial.rs @@ -0,0 +1,211 @@ +//! Serial-port session worker (issue #14 / #17). +//! +//! Mirrors the public surface of [`crate::ssh::spawn_session`] so the rest of +//! the UI pipeline (terminal output, key input, tab lifecycle) is reused +//! unchanged: it returns a [`SessionHandle`] plus an +//! [`UnboundedReceiver`]. +//! +//! Unlike SSH there is no remote PTY, no SFTP and no resource monitor — a +//! serial line is just a raw byte pipe to a switch / router / MCU console. +//! The `serialport` crate is blocking, so the read side runs on a dedicated OS +//! thread and writes happen via `spawn_blocking`. + +use std::io::{Read, Write}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use anyhow::{Context, Result}; +use serialport::{DataBits, FlowControl, Parity, StopBits}; +use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; + +use crate::config::Session; +use crate::i18n::t; +use crate::ssh::{SessionCommand, SessionEvent, SessionHandle}; + +/// Spawn a serial-port session. See module docs for why the signature mirrors +/// `spawn_session` (minus the PTY size, which a serial line has no notion of). +pub fn spawn_serial_session( + runtime: &tokio::runtime::Handle, + tab_id: String, + session: Session, +) -> (SessionHandle, UnboundedReceiver) { + let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::(); + let (evt_tx, evt_rx) = mpsc::unbounded_channel::(); + + let evt_for_task = evt_tx.clone(); + let join = runtime.spawn(async move { + if let Err(err) = run_serial(session, cmd_rx, evt_for_task.clone()).await { + let _ = evt_for_task.send(SessionEvent::Closed(format!("{err:#}"))); + } + }); + + ( + SessionHandle { + tab_id, + commands: cmd_tx, + join, + }, + evt_rx, + ) +} + +fn parse_data_bits(n: u8) -> DataBits { + match n { + 5 => DataBits::Five, + 6 => DataBits::Six, + 7 => DataBits::Seven, + _ => DataBits::Eight, + } +} + +fn parse_stop_bits(n: u8) -> StopBits { + match n { + 2 => StopBits::Two, + _ => StopBits::One, + } +} + +fn parse_parity(s: &str) -> Parity { + match s { + "odd" => Parity::Odd, + "even" => Parity::Even, + _ => Parity::None, + } +} + +fn parse_flow(s: &str) -> FlowControl { + match s { + "hardware" => FlowControl::Hardware, + "software" => FlowControl::Software, + _ => FlowControl::None, + } +} + +async fn run_serial( + session: Session, + mut commands: UnboundedReceiver, + events: UnboundedSender, +) -> Result<()> { + let port_name = session.serial_port.trim().to_string(); + if port_name.is_empty() { + return Err(anyhow::anyhow!(t("串口号为空", "serial port is empty"))); + } + + let _ = events.send(SessionEvent::Status(format!( + "{} {} @ {}", + t("打开串口", "Opening serial"), + port_name, + session.baud_rate + ))); + + // Open on a blocking thread — serialport::open() can stall on a busy device. + let open_name = port_name.clone(); + let baud = session.baud_rate; + let data_bits = parse_data_bits(session.data_bits); + let stop_bits = parse_stop_bits(session.stop_bits); + let parity = parse_parity(&session.parity); + let flow = parse_flow(&session.flow_control); + let port = tokio::task::spawn_blocking(move || { + serialport::new(&open_name, baud) + .data_bits(data_bits) + .stop_bits(stop_bits) + .parity(parity) + .flow_control(flow) + // Short read timeout so the reader thread can poll the stop flag. + .timeout(Duration::from_millis(50)) + .open() + }) + .await + .context("serial open task panicked")? + .with_context(|| format!("{} {}", t("打开串口失败", "failed to open serial port"), port_name))?; + + // A second handle for writing so the reader thread can own the read side. + let writer = port + .try_clone() + .context("failed to clone serial handle for writing")?; + let writer = Arc::new(Mutex::new(writer)); + + let _ = events.send(SessionEvent::Connected); + let _ = events.send(SessionEvent::Status(format!( + "{} {} @ {} {}{}{}", + t("已连接", "Connected"), + port_name, + session.baud_rate, + session.data_bits, + parity_letter(&session.parity), + session.stop_bits, + ))); + + // --- Reader thread ------------------------------------------------------ + let running = Arc::new(AtomicBool::new(true)); + let reader_running = running.clone(); + let reader_events = events.clone(); + let reader_handle = std::thread::spawn(move || { + let mut port = port; + let mut buf = [0u8; 4096]; + while reader_running.load(Ordering::Relaxed) { + match port.read(&mut buf) { + Ok(0) => {} + Ok(n) => { + let text = String::from_utf8_lossy(&buf[..n]).into_owned(); + if reader_events.send(SessionEvent::Output(text)).is_err() { + break; + } + } + Err(e) if e.kind() == std::io::ErrorKind::TimedOut => continue, + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(e) => { + let _ = reader_events.send(SessionEvent::Closed(format!( + "{}: {e}", + t("串口读取错误", "serial read error") + ))); + break; + } + } + } + }); + + // --- Command pump ------------------------------------------------------- + while let Some(cmd) = commands.recv().await { + match cmd { + SessionCommand::RawInput(bytes) => { + // Never log keystroke bytes — they can be passwords (#15). + tracing::debug!("serial write len={} bytes", bytes.len()); + let w = writer.clone(); + let res = tokio::task::spawn_blocking(move || { + let mut guard = w.lock().unwrap(); + guard.write_all(&bytes).and_then(|_| guard.flush()) + }) + .await; + if let Ok(Err(e)) = res { + let _ = events.send(SessionEvent::Closed(format!( + "{}: {e}", + t("串口写入失败", "serial write failed") + ))); + break; + } + } + // A serial line has no window size; nothing to propagate. + SessionCommand::Resize(_, _) => {} + SessionCommand::Close => break, + } + } + + // Stop the reader thread and wait for it to drain. + running.store(false, Ordering::Relaxed); + let _ = reader_handle.join(); + let _ = events.send(SessionEvent::Closed( + t("串口已关闭", "serial port closed").into(), + )); + Ok(()) +} + +/// Single-letter parity tag for the status line (8N1 style). +fn parity_letter(parity: &str) -> &'static str { + match parity { + "odd" => "O", + "even" => "E", + _ => "N", + } +} diff --git a/src/sftp.rs b/src/sftp.rs new file mode 100644 index 0000000..2c3d278 --- /dev/null +++ b/src/sftp.rs @@ -0,0 +1,1172 @@ +//! SFTP subsystem worker. +//! +//! Each terminal tab that spawns an SSH shell also spawns a *separate* SSH +//! connection for SFTP. This keeps the shell PTY completely unblocked: large +//! file transfers cannot stall readline or vim. +//! +//! The public API is a simple command channel (`SftpHandle::commands`) that +//! accepts `SftpCommand` messages. Results and status updates are pushed back +//! via the shared `UnboundedSender` that already exists for the +//! terminal tab. + +use std::panic::AssertUnwindSafe; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use uuid::Uuid; + +use anyhow::{anyhow, Context, Result}; +use async_trait::async_trait; +use russh::client::{self, Handler}; +use russh::keys::key::PrivateKeyWithHashAlg; +use russh::keys::load_secret_key; +use russh::Disconnect; +use russh_sftp::client::{RawSftpSession, SftpSession}; +use russh_sftp::protocol::{FileAttributes, OpenFlags}; +use futures::stream::{FuturesUnordered, StreamExt}; +use futures::FutureExt; +use ssh_key::{HashAlg, PublicKey}; +use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; +use tokio::task::JoinHandle; + +use crate::config::{AuthMethod, Session}; +use crate::i18n::t; +use crate::ssh::{format_mtime, format_size, RemoteEntry, RemoteTreeNode, SessionEvent}; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/// Commands sent to the SFTP worker task from the UI thread. +#[derive(Debug)] +pub enum SftpCommand { + /// List the contents of a remote directory. + ListDir(String), + /// Toggle a directory node in the tree (expand if collapsed, collapse if expanded). + ToggleTreeNode(String), + /// Download a remote file to a local directory. + Download { remote: String, local_dir: String }, + /// Upload a local file into a remote directory. + Upload { local: String, remote_dir: String }, + /// Delete a remote file (falls back to removing an empty directory). + Delete(String), + /// Rename a remote file or directory within its current parent directory. + Rename { old_path: String, new_name: String }, + /// Download a file to a temp dir and open it with the OS default app. + /// When `edit` is set, watch the temp copy and re-upload on every change. + OpenTemp { remote: String, edit: bool }, + /// Gracefully shut down the SFTP worker. + Close, +} + +/// Handle retained by the UI to drive a running SFTP worker. +pub struct SftpHandle { + pub commands: UnboundedSender, + #[allow(dead_code)] + pub join: JoinHandle<()>, +} + +impl SftpHandle { + pub fn list_dir(&self, path: String) { + let _ = self.commands.send(SftpCommand::ListDir(path)); + } + pub fn download(&self, remote: String, local_dir: String) { + let _ = self + .commands + .send(SftpCommand::Download { remote, local_dir }); + } + pub fn upload(&self, local: String, remote_dir: String) { + let _ = self + .commands + .send(SftpCommand::Upload { local, remote_dir }); + } + pub fn toggle_tree_node(&self, path: String) { + let _ = self.commands.send(SftpCommand::ToggleTreeNode(path)); + } + pub fn delete(&self, path: String) { + let _ = self.commands.send(SftpCommand::Delete(path)); + } + pub fn rename(&self, old_path: String, new_name: String) { + let _ = self + .commands + .send(SftpCommand::Rename { old_path, new_name }); + } + pub fn open_temp(&self, remote: String, edit: bool) { + let _ = self.commands.send(SftpCommand::OpenTemp { remote, edit }); + } + pub fn close(&self) { + let _ = self.commands.send(SftpCommand::Close); + } +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +/// Spawn an SFTP worker on the Tokio runtime. +/// +/// The worker opens its own SSH connection to the same server, authenticates, +/// and requests the `sftp` subsystem. Events (directory listings, progress, +/// errors) are sent back via `events`, which is the same sender used by the +/// terminal's shell session. +pub fn spawn_sftp( + runtime: &tokio::runtime::Handle, + session: Session, + known_hosts_path: PathBuf, + events: UnboundedSender, +) -> SftpHandle { + let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); + let self_tx = cmd_tx.clone(); + let events_err = events.clone(); + let join = runtime.spawn(async move { + match AssertUnwindSafe(run_sftp( + session, + known_hosts_path, + cmd_rx, + self_tx, + events, + )) + .catch_unwind() + .await + { + Ok(Ok(())) => {} + Ok(Err(err)) => { + tracing::error!(error = %format!("{err:#}"), "sftp worker ended with error"); + let _ = events_err.send(SessionEvent::SftpStatus(format!( + "{}: {err:#}", + t("SFTP 错误", "SFTP error") + ))); + } + Err(_) => { + tracing::error!("sftp worker task panicked"); + let _ = events_err.send(SessionEvent::SftpStatus( + t( + "SFTP 任务崩溃,请查看 meatshell-debug.log", + "SFTP worker panicked; see meatshell-debug.log", + ) + .into(), + )); + } + } + }); + SftpHandle { + commands: cmd_tx, + join, + } +} + +// --------------------------------------------------------------------------- +// Worker +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Tree state helpers +// --------------------------------------------------------------------------- + +/// Recursively build the flat node list from tree state (DFS pre-order). +fn build_tree_nodes( + path: &str, + depth: u32, + expanded: &std::collections::HashSet, + tree_dirs: &std::collections::HashMap>, + nodes: &mut Vec, +) { + let name = if path == "/" { + "/".to_string() + } else { + path.rsplit('/').next().unwrap_or(path).to_string() + }; + let children = tree_dirs.get(path); + let has_children = children.map(|c| !c.is_empty()).unwrap_or(true); + let is_expanded = expanded.contains(path); + nodes.push(RemoteTreeNode { + path: path.to_string(), + name, + depth, + expanded: is_expanded, + has_children, + }); + if is_expanded { + if let Some(ch) = children { + for (_, child_path) in ch { + build_tree_nodes(child_path, depth + 1, expanded, tree_dirs, nodes); + } + } + } +} + +// --------------------------------------------------------------------------- +// Worker +// --------------------------------------------------------------------------- + +const SFTP_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30); +const SFTP_INACTIVITY_TIMEOUT: Duration = Duration::from_secs(60 * 60); +const SFTP_OP_TIMEOUT: Duration = Duration::from_secs(25); + +async fn connect_sftp_session( + session: &Session, + known_hosts_path: &Path, +) -> Result<(client::Handle, SftpSession)> { + let config = Arc::new(client::Config { + inactivity_timeout: Some(SFTP_INACTIVITY_TIMEOUT), + keepalive_interval: Some(SFTP_KEEPALIVE_INTERVAL), + keepalive_max: 3, + ..<_>::default() + }); + + let addr = format!("{}:{}", session.host, session.port); + let handler = SftpClientHandler { + host: session.host.clone(), + port: session.port, + known_hosts_path: known_hosts_path.to_path_buf(), + }; + + let mut handle = match crate::proxy::resolve(&session.proxy) { + Some(p) => { + let stream = crate::proxy::connect(&p, &session.host, session.port) + .await + .with_context(|| format!("sftp proxy connect {} failed", addr))?; + client::connect_stream(config, stream, handler) + .await + .with_context(|| format!("sftp connect {} failed", addr))? + } + None => client::connect(config, addr.as_str(), handler) + .await + .with_context(|| format!("sftp connect {} failed", addr))?, + }; + + let authed = match session.auth { + AuthMethod::Password => handle + .authenticate_password(&session.user, session.password.as_str()) + .await + .context("sftp password auth failed")?, + AuthMethod::Key => { + let raw = session.private_key_path.trim(); + if raw.is_empty() { + return Err(anyhow!(t("私钥路径为空", "private key path is empty"))); + } + let normalised = raw.replace('\\', "/"); + let key_path = normalised + .strip_suffix(".pub") + .map(str::to_string) + .unwrap_or(normalised); + let keypair = load_secret_key(Path::new(&key_path), None) + .with_context(|| format!("failed to load key {key_path}"))?; + let hash = if keypair.algorithm().is_rsa() { + Some(HashAlg::Sha256) + } else { + None + }; + let key_with_hash = PrivateKeyWithHashAlg::new(Arc::new(keypair), hash) + .context("invalid private key")?; + handle + .authenticate_publickey(&session.user, key_with_hash) + .await + .context("sftp publickey auth failed")? + } + }; + + if !authed { + return Err(anyhow!(t("SFTP 认证失败", "SFTP authentication failed"))); + } + + let channel = handle + .channel_open_session() + .await + .context("open sftp channel")?; + channel + .request_subsystem(true, "sftp") + .await + .context("request sftp subsystem")?; + let sftp = SftpSession::new(channel.into_stream()) + .await + .context("sftp handshake")?; + + Ok((handle, sftp)) +} + +async fn run_sftp( + session: Session, + known_hosts_path: PathBuf, + mut commands: UnboundedReceiver, + self_tx: UnboundedSender, + events: UnboundedSender, +) -> Result<()> { + let _ = events.send(SessionEvent::SftpStatus(t("SFTP 连接中...", "SFTP connecting...").into())); + + // Open a dedicated SSH connection for SFTP. The browsing channel uses SSH + // keepalive, and directory operations below reconnect once if an idle + // connection has gone stale while the app was in the background. + let (mut handle, mut sftp) = connect_sftp_session(&session, &known_hosts_path).await?; + + // Resolve the home directory and do an initial listing. + let home = sftp + .canonicalize(".") + .await + .unwrap_or_else(|_| "/".to_string()); + let _ = events.send(SessionEvent::SftpStatus(format!("{} {}...", t("SFTP 加载", "SFTP loading"), home))); + match list_dir_impl(&sftp, &home).await { + Ok(entries) => { + let _ = events.send(SessionEvent::SftpEntries { + path: home.clone(), + entries, + }); + let _ = events.send(SessionEvent::SftpStatus(home.clone())); + } + Err(e) => { + let _ = events.send(SessionEvent::SftpStatus(format!("{}: {e}", t("SFTP 错误", "SFTP error")))); + } + } + + // --- Directory tree initialization ------------------------------------- + // tree_dirs: path -> [(child_name, child_full_path)] for directories only + // tree_expanded: set of paths currently shown as expanded + let mut tree_dirs: std::collections::HashMap> = + std::collections::HashMap::new(); + let mut tree_expanded: std::collections::HashSet = std::collections::HashSet::new(); + + // Fetch root "/" subdirs, then expand path down to home. + let root_dirs = list_dirs_only_impl(&sftp, "/").await.unwrap_or_default(); + tree_dirs.insert("/".to_string(), root_dirs); + tree_expanded.insert("/".to_string()); + + // Walk each path segment from "/" toward home, expanding as we go. + if home != "/" { + let mut current = "/".to_string(); + for segment in home.trim_start_matches('/').split('/') { + if segment.is_empty() { + continue; + } + let child = format!("{}/{}", current.trim_end_matches('/'), segment); + // Only expand if this child appeared in the parent listing. + let found = tree_dirs + .get(¤t) + .map(|c| c.iter().any(|(_, p)| p == &child)) + .unwrap_or(false); + if !found { + break; + } + let dirs = list_dirs_only_impl(&sftp, &child).await.unwrap_or_default(); + tree_dirs.insert(child.clone(), dirs); + tree_expanded.insert(child.clone()); + current = child; + } + } + { + let mut nodes = Vec::new(); + build_tree_nodes("/", 0, &tree_expanded, &tree_dirs, &mut nodes); + let _ = events.send(SessionEvent::SftpTreeUpdate(nodes)); + } + + // --- Command loop ------------------------------------------------------- + while let Some(cmd) = commands.recv().await { + match cmd { + SftpCommand::Close => break, + + SftpCommand::ListDir(path) => { + let _ = events.send(SessionEvent::SftpStatus(format!("{} {}...", t("加载", "Loading"), path))); + match list_dir_resilient( + &mut handle, + &mut sftp, + &session, + &known_hosts_path, + &events, + &path, + ) + .await + { + Ok(entries) => { + let _ = events.send(SessionEvent::SftpEntries { + path: path.clone(), + entries, + }); + let _ = events.send(SessionEvent::SftpStatus(path)); + } + Err(e) => { + let _ = events.send(SessionEvent::SftpStatus(format!("{}: {e}", t("列目录失败", "list directory failed")))); + } + } + } + + SftpCommand::ToggleTreeNode(path) => { + if tree_expanded.contains(&path) { + // Collapse this node and all descendants. + let prefix = format!("{}/", path.trim_end_matches('/')); + tree_expanded.retain(|p| p != &path && !p.starts_with(&prefix)); + } else { + // Expand: fetch children if not yet cached. + if !tree_dirs.contains_key(&path) { + let dirs = list_dirs_only_resilient( + &mut handle, + &mut sftp, + &session, + &known_hosts_path, + &events, + &path, + ) + .await + .unwrap_or_default(); + tree_dirs.insert(path.clone(), dirs); + } + tree_expanded.insert(path.clone()); + } + let mut nodes = Vec::new(); + build_tree_nodes("/", 0, &tree_expanded, &tree_dirs, &mut nodes); + let _ = events.send(SessionEvent::SftpTreeUpdate(nodes)); + } + + SftpCommand::Download { remote, local_dir } => { + // Sanitize the server-supplied name before it touches the local + // filesystem (#26): a malicious server could otherwise craft a + // name with traversal, shell-special chars or a Windows reserved + // device name to write outside the chosen dir or hit a device. + let filename = sanitize_filename(&base_name(&remote)); + let local_path = format!("{}/{}", local_dir.trim_end_matches('/'), filename); + let id = Uuid::new_v4().to_string(); + let _ = events.send(SessionEvent::SftpStatus(format!("{} {}...", t("下载", "Downloading"), filename))); + match download_impl(&sftp, &remote, &local_path, &filename, &id, &events).await { + Ok(_) => { + let _ = events + .send(SessionEvent::SftpStatus(format!("{}: {}", t("下载完成", "Downloaded"), filename))); + } + Err(e) => { + emit_transfer(&events, &id, &filename, false, 0, 0, 2, &e.to_string()); + let _ = events.send(SessionEvent::SftpStatus(format!("{}: {e}", t("下载失败", "Download failed")))); + } + } + } + + SftpCommand::Upload { local, remote_dir } => { + let filename = base_name(&local); + let remote_path = format!("{}/{}", remote_dir.trim_end_matches('/'), filename); + let id = Uuid::new_v4().to_string(); + let _ = events.send(SessionEvent::SftpStatus(format!("{} {}...", t("上传", "Uploading"), filename))); + match upload_pipelined(&handle, &local, &remote_path, &filename, &id, &events).await { + Ok(_) => { + if let Ok(entries) = list_dir_resilient( + &mut handle, + &mut sftp, + &session, + &known_hosts_path, + &events, + &remote_dir, + ) + .await + { + let _ = events.send(SessionEvent::SftpEntries { + path: remote_dir.clone(), + entries, + }); + } + let _ = events + .send(SessionEvent::SftpStatus(format!("{}: {}", t("上传完成", "Uploaded"), filename))); + } + Err(e) => { + emit_transfer(&events, &id, &filename, true, 0, 0, 2, &e.to_string()); + let _ = events.send(SessionEvent::SftpStatus(format!("{}: {e}", t("上传失败", "Upload failed")))); + } + } + } + + SftpCommand::Delete(path) => { + let filename = base_name(&path); + let _ = events.send(SessionEvent::SftpStatus(format!("{} {}...", t("删除", "Deleting"), filename))); + // Try as a file first, then as an (empty) directory. + let res = match sftp.remove_file(&path).await { + Ok(_) => Ok(()), + Err(_) => sftp.remove_dir(&path).await.map(|_| ()), + }; + match res { + Ok(_) => { + let parent = parent_dir(&path); + if let Ok(entries) = list_dir_resilient( + &mut handle, + &mut sftp, + &session, + &known_hosts_path, + &events, + &parent, + ) + .await + { + let _ = events.send(SessionEvent::SftpEntries { + path: parent.clone(), + entries, + }); + } + let _ = + events.send(SessionEvent::SftpStatus(format!("{}: {}", t("已删除", "Deleted"), filename))); + } + Err(e) => { + let _ = events.send(SessionEvent::SftpStatus(format!("{}: {e}", t("删除失败", "Delete failed")))); + } + } + } + + SftpCommand::Rename { old_path, new_name } => { + let old_name = base_name(&old_path); + let new_name = new_name.trim(); + if new_name.is_empty() + || new_name.contains('/') + || new_name.contains('\\') + || new_name == "." + || new_name == ".." + { + let _ = events.send(SessionEvent::SftpStatus(t("重命名失败:名称无效", "Rename failed: invalid name").to_string())); + continue; + } + let parent = parent_dir(&old_path); + let new_path = if parent == "/" { + format!("/{new_name}") + } else { + format!("{}/{}", parent.trim_end_matches('/'), new_name) + }; + let _ = events.send(SessionEvent::SftpStatus(format!("{} {}...", t("重命名", "Renaming"), old_name))); + match sftp.rename(&old_path, &new_path).await { + Ok(_) => { + if let Ok(entries) = list_dir_resilient( + &mut handle, + &mut sftp, + &session, + &known_hosts_path, + &events, + &parent, + ) + .await + { + let _ = events.send(SessionEvent::SftpEntries { + path: parent.clone(), + entries, + }); + } + let _ = events.send(SessionEvent::SftpStatus(format!( + "{}: {} -> {}", + t("已重命名", "Renamed"), + old_name, + new_name + ))); + } + Err(e) => { + let _ = events.send(SessionEvent::SftpStatus(format!("{}: {e}", t("重命名失败", "Rename failed")))); + } + } + } + + SftpCommand::OpenTemp { remote, edit } => { + // Sanitize the remote-controlled name before it becomes a local + // file path that we later hand to the OS "open" call. + let filename = sanitize_filename(&base_name(&remote)); + let tmp_dir = std::env::temp_dir().join("meatshell"); + let _ = tokio::fs::create_dir_all(&tmp_dir).await; + let local = tmp_dir.join(&filename); + let local_str = local.to_string_lossy().to_string(); + let _ = events.send(SessionEvent::SftpStatus(format!("{} {}...", t("打开", "Opening"), filename))); + let xid = Uuid::new_v4().to_string(); + match download_impl(&sftp, &remote, &local_str, &filename, &xid, &events).await { + Ok(_) => { + open_with_os(&local_str); + let _ = events.send(SessionEvent::SftpStatus(format!( + "{}: {}", + if edit { t("已打开编辑", "Opened for editing") } else { t("已打开", "Opened") }, + filename + ))); + if edit { + spawn_edit_watcher( + self_tx.clone(), + local_str, + remote.clone(), + filename, + events.clone(), + ); + } + } + Err(e) => { + let _ = events.send(SessionEvent::SftpStatus(format!("{}: {e}", t("打开失败", "Open failed")))); + } + } + } + } + } + + let _ = handle + .disconnect(Disconnect::ByApplication, "bye", "") + .await; + Ok(()) +} + +/// File name component of a path. Handles both remote (`/`) and local Windows +/// (`\`) separators, so uploading `C:\…\frp.tar.gz` yields `frp.tar.gz` rather +/// than the whole path (which previously became the remote file name). +fn base_name(path: &str) -> String { + let sep = |c: char| c == '/' || c == '\\'; + path.trim_end_matches(sep) + .rsplit(sep) + .next() + .unwrap_or(path) + .to_string() +} + +/// Parent directory of a remote path ("/a/b" → "/a", "/a" → "/"). +fn parent_dir(path: &str) -> String { + let p = path.trim_end_matches('/'); + match p.rfind('/') { + Some(0) | None => "/".to_string(), + Some(i) => p[..i].to_string(), + } +} + +/// Open a local file with the OS default application. +/// +/// Security: we must NOT route the path through a shell. The previous +/// `cmd /C start "" ` let cmd.exe re-parse the path, so a remote file name +/// containing shell metacharacters (`&` `|` `>` `<` `^` …) — e.g. `foo&calc.exe` +/// — could inject and run arbitrary commands when the user opened it. We call +/// `ShellExecuteW` directly instead: it treats the path as one opaque string, so +/// no shell parsing happens. (`xdg-open` on Unix already takes a single argv +/// argument and never invokes a shell.) +#[cfg(windows)] +fn open_with_os(path: &str) { + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + #[link(name = "shell32")] + extern "system" { + fn ShellExecuteW( + hwnd: isize, + lp_operation: *const u16, + lp_file: *const u16, + lp_parameters: *const u16, + lp_directory: *const u16, + n_show_cmd: i32, + ) -> isize; + } + let to_wide = |s: &str| -> Vec { + OsStr::new(s).encode_wide().chain(std::iter::once(0)).collect() + }; + let op = to_wide("open"); + let file = to_wide(path); + unsafe { + ShellExecuteW( + 0, + op.as_ptr(), + file.as_ptr(), + std::ptr::null(), + std::ptr::null(), + 1, // SW_SHOWNORMAL + ); + } +} + +#[cfg(not(windows))] +fn open_with_os(path: &str) { + let _ = std::process::Command::new("xdg-open").arg(path).spawn(); +} + +/// Make a remote-supplied file name safe to use as a *local* file name (for +/// both downloads and temp files): drops path separators (defence-in-depth +/// against traversal), replaces characters invalid on Windows or special to +/// shells with `_`, trims surrounding whitespace and Windows' trailing dots, +/// and neutralises reserved device names (CON, NUL, COM1…). Normal names +/// (letters, digits, `.`, `-`, `_`, Unicode) pass through; Unix dotfiles keep +/// their leading dot. Falls back to `file` when nothing usable remains. +fn sanitize_filename(name: &str) -> String { + let cleaned: String = name + .chars() + .map(|c| match c { + '/' | '\\' | ':' | '<' | '>' | '"' | '|' | '?' | '*' | '&' | '^' | '%' | '!' + | '`' | '$' | '\'' => '_', + c if (c as u32) < 0x20 => '_', + c => c, + }) + .collect(); + // Drop leading whitespace and trailing dots/spaces (Windows strips the + // latter silently). A leading dot is preserved so `.bashrc` survives. + let trimmed = cleaned.trim_start_matches(' ').trim_end_matches([' ', '.']); + if trimmed.is_empty() { + return "file".to_string(); + } + // Windows reserved device names are reserved case-insensitively and even + // with an extension ("CON.txt" still opens the console). A download named + // after one could read/write a device instead of a file, so prefix `_`. + let stem = trimmed.split('.').next().unwrap_or(trimmed); + let reserved = matches!( + stem.to_ascii_uppercase().as_str(), + "CON" | "PRN" | "AUX" | "NUL" + | "COM1" | "COM2" | "COM3" | "COM4" | "COM5" | "COM6" | "COM7" | "COM8" | "COM9" + | "LPT1" | "LPT2" | "LPT3" | "LPT4" | "LPT5" | "LPT6" | "LPT7" | "LPT8" | "LPT9" + ); + if reserved { + format!("_{trimmed}") + } else { + trimmed.to_string() + } +} + +/// Watch a downloaded temp file and re-upload it to the remote whenever it +/// changes on disk (the "edit" flow). Re-upload is routed back through the +/// worker's own command channel. Stops when the channel closes or after a +/// generous idle window. +fn spawn_edit_watcher( + self_tx: UnboundedSender, + local: String, + remote: String, + filename: String, + events: UnboundedSender, +) { + let remote_dir = parent_dir(&remote); + tokio::spawn(async move { + let mtime = |p: &str| std::fs::metadata(p).ok().and_then(|m| m.modified().ok()); + let mut last = mtime(&local); + // ~40 min of 2s polls; also exits early once the worker is gone. + for _ in 0..1200 { + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + if self_tx.is_closed() { + break; + } + let cur = mtime(&local); + if cur.is_some() && cur != last { + last = cur; + let _ = self_tx.send(SftpCommand::Upload { + local: local.clone(), + remote_dir: remote_dir.clone(), + }); + let _ = events.send(SessionEvent::SftpStatus(format!( + "{}: {}", + t("已上传修改", "Re-uploaded changes"), + filename + ))); + } + } + }); +} + +// --------------------------------------------------------------------------- +// SFTP helpers +// --------------------------------------------------------------------------- + +async fn list_dir_impl(sftp: &SftpSession, path: &str) -> Result> { + let raw = tokio::time::timeout(SFTP_OP_TIMEOUT, sftp.read_dir(path)) + .await + .with_context(|| format!("read_dir {path} timed out after {:?}", SFTP_OP_TIMEOUT))? + .with_context(|| format!("read_dir {path} failed"))?; + + let mut entries: Vec = raw + .into_iter() + .filter(|e| { + let n = e.file_name(); + n != "." && n != ".." + }) + .map(|e| { + let name = e.file_name().to_string(); + let full_path = format!("{}/{}", path.trim_end_matches('/'), name); + let meta = e.metadata(); + // Determine if entry is a directory via Unix permission bits. + let permissions = meta.permissions.unwrap_or(0); + let is_dir = (permissions & 0o170_000) == 0o040_000; + let size = meta.size.unwrap_or(0); + let modified = meta.mtime.unwrap_or(0); + RemoteEntry { + name, + full_path, + is_dir, + size, + modified, + } + }) + .collect(); + + // Sort: directories first, then files; both groups alphabetically. + entries.sort_by(|a, b| match (a.is_dir, b.is_dir) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()), + }); + + Ok(entries) +} + +async fn reconnect_sftp( + handle: &mut client::Handle, + sftp: &mut SftpSession, + session: &Session, + known_hosts_path: &Path, + events: &UnboundedSender, +) -> Result<()> { + let _ = events.send(SessionEvent::SftpStatus( + t("SFTP 连接已空闲失效,正在重连...", "SFTP connection stale; reconnecting...") + .into(), + )); + let _ = tokio::time::timeout( + Duration::from_secs(3), + handle.disconnect(Disconnect::ByApplication, "reconnect", ""), + ) + .await; + let (new_handle, new_sftp) = connect_sftp_session(session, known_hosts_path).await?; + *handle = new_handle; + *sftp = new_sftp; + Ok(()) +} + +async fn list_dir_resilient( + handle: &mut client::Handle, + sftp: &mut SftpSession, + session: &Session, + known_hosts_path: &Path, + events: &UnboundedSender, + path: &str, +) -> Result> { + match list_dir_impl(sftp, path).await { + Ok(entries) => Ok(entries), + Err(first) => { + tracing::warn!(path, error = %format!("{first:#}"), "sftp list_dir failed; reconnecting once"); + reconnect_sftp(handle, sftp, session, known_hosts_path, events) + .await + .with_context(|| format!("reconnect after list_dir {path} failed"))?; + let first_msg = format!("{first:#}"); + list_dir_impl(sftp, path) + .await + .with_context(|| format!("list_dir {path} failed after reconnect; first error: {first_msg}")) + } + } +} + +async fn list_dirs_only_resilient( + handle: &mut client::Handle, + sftp: &mut SftpSession, + session: &Session, + known_hosts_path: &Path, + events: &UnboundedSender, + path: &str, +) -> Result> { + let entries = + list_dir_resilient(handle, sftp, session, known_hosts_path, events, path).await?; + Ok(entries + .into_iter() + .filter(|e| e.is_dir) + .map(|e| (e.name, e.full_path)) + .collect()) +} + +/// List only the subdirectories of `path` (no files). Used to build the tree. +async fn list_dirs_only_impl(sftp: &SftpSession, path: &str) -> Result> { + let entries = list_dir_impl(sftp, path).await?; + Ok(entries + .into_iter() + .filter(|e| e.is_dir) + .map(|e| (e.name, e.full_path)) + .collect()) +} + +/// Emit a transfer-progress event. +fn emit_transfer( + events: &UnboundedSender, + id: &str, + name: &str, + is_upload: bool, + transferred: u64, + total: u64, + state: u8, + msg: &str, +) { + let _ = events.send(SessionEvent::SftpTransfer { + id: id.to_string(), + name: name.to_string(), + is_upload, + transferred, + total, + state, + msg: msg.to_string(), + }); +} + +const XFER_CHUNK: usize = 64 * 1024; + +async fn download_impl( + sftp: &SftpSession, + remote: &str, + local: &str, + name: &str, + id: &str, + events: &UnboundedSender, +) -> Result<()> { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let total = sftp + .metadata(remote) + .await + .ok() + .and_then(|m| m.size) + .unwrap_or(0); + let mut remote_file = sftp + .open(remote) + .await + .with_context(|| format!("open remote {remote}"))?; + let mut local_file = tokio::fs::File::create(local) + .await + .with_context(|| format!("create local {local}"))?; + + emit_transfer(events, id, name, false, 0, total, 0, ""); + let mut buf = vec![0u8; XFER_CHUNK]; + let mut done: u64 = 0; + let mut last = Instant::now(); + loop { + let n = remote_file + .read(&mut buf) + .await + .context("read remote file")?; + if n == 0 { + break; + } + local_file + .write_all(&buf[..n]) + .await + .context("write local file")?; + done += n as u64; + if last.elapsed() >= Duration::from_millis(150) { + last = Instant::now(); + emit_transfer(events, id, name, false, done, total, 0, ""); + } + } + local_file.flush().await.context("flush local file")?; + emit_transfer(events, id, name, false, done, total.max(done), 1, ""); + Ok(()) +} + +/// Pipelined SFTP upload (#16). +/// +/// The high-level `SftpSession`/`File` writes one chunk and waits for the +/// server's ack before sending the next, so throughput is capped by the +/// round-trip time (~15x slower than scp on a latent link). Here we open a +/// dedicated raw SFTP channel and keep many WRITE requests in flight at once +/// (each tagged with its absolute offset, so out-of-order completion is fine), +/// which hides the latency and brings us within a single order of magnitude of +/// native scp. +async fn upload_pipelined( + handle: &client::Handle, + local: &str, + remote: &str, + name: &str, + id: &str, + events: &UnboundedSender, +) -> Result<()> { + use tokio::io::AsyncReadExt; + + const CHUNK: usize = 32 * 1024; // safe SFTP write size + const MAX_INFLIGHT: usize = 32; // ~1 MB of outstanding writes hides the RTT + + let total = tokio::fs::metadata(local) + .await + .map(|m| m.len()) + .unwrap_or(0); + let mut local_file = tokio::fs::File::open(local) + .await + .with_context(|| format!("open local {local}"))?; + + // Dedicated raw SFTP channel for the transfer (keeps the browse session + // responsive and lets us issue concurrent WRITE requests). + let channel = handle + .channel_open_session() + .await + .context("open sftp upload channel")?; + channel + .request_subsystem(true, "sftp") + .await + .context("request sftp subsystem")?; + let raw = Arc::new(RawSftpSession::new(channel.into_stream())); + raw.init().await.context("sftp upload handshake")?; + + let fhandle = raw + .open( + remote, + OpenFlags::CREATE | OpenFlags::WRITE | OpenFlags::TRUNCATE, + FileAttributes::default(), + ) + .await + .with_context(|| format!("create remote {remote}"))? + .handle; + + emit_transfer(events, id, name, true, 0, total, 0, ""); + + let mut offset: u64 = 0; + let mut done: u64 = 0; + let mut last = Instant::now(); + let mut eof = false; + let mut err: Option = None; + let mut inflight = FuturesUnordered::new(); + + while !eof || !inflight.is_empty() { + // Top up the pipeline with fresh WRITE requests. + while !eof && inflight.len() < MAX_INFLIGHT { + let mut buf = vec![0u8; CHUNK]; + match local_file.read(&mut buf).await { + Ok(0) => eof = true, + Ok(n) => { + buf.truncate(n); + let off = offset; + offset += n as u64; + let raw2 = raw.clone(); + let h = fhandle.clone(); + inflight.push(async move { + raw2.write(h, off, buf).await.map(|_| n as u64) + }); + } + Err(e) => { + err = Some(anyhow!("read local file: {e}")); + eof = true; + } + } + } + match inflight.next().await { + Some(Ok(n)) => { + done += n; + if last.elapsed() >= Duration::from_millis(150) { + last = Instant::now(); + emit_transfer(events, id, name, true, done, total, 0, ""); + } + } + Some(Err(e)) => { + err = Some(anyhow!("write remote file: {e}")); + eof = true; // stop reading more + } + None => {} + } + if err.is_some() { + break; + } + } + + let _ = raw.close(fhandle).await; + if let Some(e) = err { + return Err(e); + } + emit_transfer(events, id, name, true, done, total.max(done), 1, ""); + Ok(()) +} + +// --------------------------------------------------------------------------- +// russh client handler (same known_hosts verification as the shell handler) +// --------------------------------------------------------------------------- + +struct SftpClientHandler { + host: String, + port: u16, + known_hosts_path: PathBuf, +} + +#[async_trait] +impl Handler for SftpClientHandler { + type Error = russh::Error; + + async fn check_server_key( + &mut self, + server_public_key: &PublicKey, + ) -> Result { + match crate::known_hosts::check_or_trust_in( + &self.host, + self.port, + server_public_key, + &self.known_hosts_path, + ) { + Ok(crate::known_hosts::HostKeyStatus::Verified) => Ok(true), + Ok(crate::known_hosts::HostKeyStatus::Added) => { + tracing::info!( + "trusted new SFTP host key for {}:{}", + self.host, + self.port + ); + Ok(true) + } + Ok(crate::known_hosts::HostKeyStatus::Changed) => { + tracing::warn!( + "SFTP host key mismatch for {}:{}; rejecting connection", + self.host, + self.port + ); + Ok(false) + } + Err(err) => { + tracing::warn!( + "known_hosts check failed for SFTP {}:{}: {err:#}", + self.host, + self.port + ); + Ok(false) + } + } + } + + async fn data( + &mut self, + _channel: russh::ChannelId, + _data: &[u8], + _session: &mut client::Session, + ) -> Result<(), Self::Error> { + Ok(()) + } +} + +// Keep format helpers and RemoteTreeNode imports live. +const _: fn() = || { + let _ = format_size(0); + let _ = format_mtime(0); + let _: RemoteTreeNode; +}; + +#[cfg(test)] +mod sanitize_tests { + use super::sanitize_filename; + + #[test] + fn plain_names_pass_through() { + assert_eq!(sanitize_filename("report.txt"), "report.txt"); + assert_eq!(sanitize_filename("my-file_v2.tar.gz"), "my-file_v2.tar.gz"); + assert_eq!(sanitize_filename("数据.csv"), "数据.csv"); + // Unix dotfiles keep their leading dot. + assert_eq!(sanitize_filename(".bashrc"), ".bashrc"); + } + + #[test] + fn strips_path_separators_and_traversal() { + // base_name already strips dirs, but sanitize is defence-in-depth: the + // result must never keep a separator that could escape the target dir. + assert_eq!(sanitize_filename("a/b\\c"), "a_b_c"); + let traversal = sanitize_filename("../../etc/passwd"); + assert!(!traversal.contains('/') && !traversal.contains('\\')); + let win = sanitize_filename("..\\..\\Windows\\System32"); + assert!(!win.contains('/') && !win.contains('\\')); + } + + #[test] + fn replaces_shell_and_windows_special_chars() { + assert_eq!(sanitize_filename("foo&calc.exe"), "foo_calc.exe"); + assert_eq!(sanitize_filename("a|b>c`. + +use std::panic::AssertUnwindSafe; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use anyhow::{anyhow, Context, Result}; +use async_trait::async_trait; +use futures::FutureExt; +use russh::client::{self, Handle, Handler}; +use russh::keys::key::PrivateKeyWithHashAlg; +use russh::keys::load_secret_key; +use russh::{ChannelId, ChannelMsg, Disconnect}; +use ssh_key::{HashAlg, PublicKey}; +use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; +use tokio::task::JoinHandle; + +use crate::config::{AuthMethod, Session}; +use crate::i18n::t; + +// --------------------------------------------------------------------------- +// SFTP-related shared types +// --------------------------------------------------------------------------- + +/// Metadata for a single remote filesystem entry returned by SFTP listing. +#[derive(Debug, Clone)] +pub struct RemoteEntry { + pub name: String, + pub full_path: String, + pub is_dir: bool, + /// Raw size in bytes (0 for directories or unknown). + pub size: u64, + /// Modification time as Unix timestamp (seconds, u32 = SFTP wire format). + pub modified: u32, +} + +/// One node in the remote directory tree panel. +#[derive(Debug, Clone)] +pub struct RemoteTreeNode { + pub path: String, + pub name: String, + pub depth: u32, + pub expanded: bool, + pub has_children: bool, +} + +/// Format a byte count as a human-readable string. +pub fn format_size(bytes: u64) -> String { + if bytes < 1_024 { + format!("{} B", bytes) + } else if bytes < 1_024 * 1_024 { + format!("{:.1} KB", bytes as f64 / 1_024.0) + } else if bytes < 1_024 * 1_024 * 1_024 { + format!("{:.1} MB", bytes as f64 / (1_024.0 * 1_024.0)) + } else { + format!("{:.2} GB", bytes as f64 / (1_024.0 * 1_024.0 * 1_024.0)) + } +} + +/// Format a Unix timestamp as `YYYY-MM-DD HH:MM`. +pub fn format_mtime(ts: u32) -> String { + use chrono::{DateTime, TimeZone, Utc}; + let dt: DateTime = Utc + .timestamp_opt(ts as i64, 0) + .single() + .unwrap_or_else(Utc::now); + dt.format("%Y-%m-%d %H:%M").to_string() +} + +/// Extract the remote path from an OSC 7 sequence embedded in `text`. +/// +/// Format: `ESC ] 7 ; file://hostname/path BEL` +/// Returns the decoded absolute path component (without hostname). +pub fn extract_osc7_path(text: &str) -> Option { + let bytes = text.as_bytes(); + let mut i = 0; + while i + 1 < bytes.len() { + if bytes[i] != 0x1b || bytes[i + 1] != b']' { + i += 1; + continue; + } + let osc_start = i + 2; + i += 2; + // Scan for BEL (0x07) or ST (ESC \) + let mut end = i; + while end < bytes.len() { + if bytes[end] == 0x07 { + break; + } else if bytes[end] == 0x1b && end + 1 < bytes.len() && bytes[end + 1] == b'\\' { + break; + } + end += 1; + } + if end >= bytes.len() { + break; + } + if let Ok(content) = std::str::from_utf8(&bytes[osc_start..end]) { + if let Some(rest) = content.strip_prefix("7;file://") { + // rest = "hostname/path" or "/path" (empty hostname) + let path = if rest.starts_with('/') { + rest.to_string() + } else if let Some(slash) = rest.find('/') { + rest[slash..].to_string() + } else { + "/".to_string() + }; + return Some(url_decode(&path)); + } + } + i = end + 1; + } + None +} + +/// Percent-decode a URL path segment (e.g. `%20` → space). +fn url_decode(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c == '%' { + let h1 = chars.next(); + let h2 = chars.next(); + match (h1, h2) { + (Some(a), Some(b)) => { + let hex = format!("{a}{b}"); + if let Ok(byte) = u8::from_str_radix(&hex, 16) { + result.push(byte as char); + } else { + result.push('%'); + result.push(a); + result.push(b); + } + } + (Some(a), None) => { + result.push('%'); + result.push(a); + } + _ => result.push('%'), + } + } else { + result.push(c); + } + } + result +} + +/// Commands posted to the worker task by the UI. +#[derive(Debug)] +pub enum SessionCommand { + /// Send raw bytes directly to the PTY (individual keystrokes, no modification). + RawInput(Vec), + /// Notify the remote PTY of a terminal resize. + Resize(u32, u32), + /// Gracefully disconnect and drop the session. + Close, +} + +/// Events emitted back to the UI thread. +#[derive(Debug, Clone)] +pub enum SessionEvent { + /// Free-form status text for the tab header / status line. + Status(String), + /// A chunk of stdout/stderr output from the remote shell. + Output(String), + /// Connection is up. + Connected, + /// Connection closed (either cleanly or after an error). + Closed(String), + /// Remote machine resource sample (from the monitor channel). + /// Memory/swap are in KiB (as reported by /proc/meminfo). + ResourceStats { + cpu_percent: f32, + cpu_cores: u32, + mem_used_kib: u64, + mem_total_kib: u64, + swap_used_kib: u64, + swap_total_kib: u64, + uptime_secs: u64, + /// Per-interface (name, rx_bytes_per_sec, tx_bytes_per_sec). + net: Vec<(String, u64, u64)>, + /// Per-filesystem (mount_point, available_bytes, total_bytes). + disks: Vec<(String, u64, u64)>, + /// Top processes: (resident_memory_bytes, cpu_percent, command). + processes: Vec<(u64, f32, String)>, + }, + + // --- SFTP events ------------------------------------------------------- + /// The shell's current working directory changed (parsed from OSC 7). + CwdChanged(String), + /// SFTP directory listing arrived. + SftpEntries { + path: String, + entries: Vec, + }, + /// Free-form SFTP status message (progress, errors, etc.). + SftpStatus(String), + /// Directory tree structure changed (full rebuild pushed on every toggle). + SftpTreeUpdate(Vec), + /// File-transfer progress / completion (download or upload). + SftpTransfer { + id: String, + name: String, + is_upload: bool, + transferred: u64, + total: u64, + state: u8, // 0 = active, 1 = done, 2 = error + msg: String, + }, +} + +/// Handle retained by the UI layer to talk to a running session. +pub struct SessionHandle { + #[allow(dead_code)] // used by future resize / reconnect flows + pub tab_id: String, + pub commands: UnboundedSender, + #[allow(dead_code)] // keep alive; detach on Drop is fine for v0.1 + pub join: JoinHandle<()>, +} + +impl SessionHandle { + pub fn send_raw(&self, bytes: Vec) { + let _ = self.commands.send(SessionCommand::RawInput(bytes)); + } + + pub fn resize(&self, cols: u32, rows: u32) { + let _ = self.commands.send(SessionCommand::Resize(cols, rows)); + } + + pub fn close(&self) { + let _ = self.commands.send(SessionCommand::Close); + } +} + +/// Entry point: spawn a session on the shared tokio runtime. +/// +/// `initial_cols` / `initial_rows` are the PTY dimensions to request when +/// opening the channel. Slint fires a `terminal-resize` callback very shortly +/// after the tab becomes active; passing the best-known size here avoids the +/// remote shell starting at a stale 80×24 and sending an extra SIGWINCH. +/// +/// Returns a [`SessionHandle`] for the UI + an [`UnboundedReceiver`] the UI +/// should drain on the Slint event loop. +pub fn spawn_session( + runtime: &tokio::runtime::Handle, + tab_id: String, + session: Session, + known_hosts_path: PathBuf, + initial_cols: u32, + initial_rows: u32, +) -> (SessionHandle, UnboundedReceiver) { + let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::(); + let (evt_tx, evt_rx) = mpsc::unbounded_channel::(); + + let evt_tx_for_task = evt_tx.clone(); + let join = runtime.spawn(async move { + match AssertUnwindSafe(run_session( + session, + known_hosts_path, + cmd_rx, + evt_tx_for_task.clone(), + initial_cols, + initial_rows, + )) + .catch_unwind() + .await + { + Ok(Ok(())) => {} + Ok(Err(err)) => { + tracing::error!(error = %format!("{err:#}"), "ssh session ended with error"); + let _ = evt_tx_for_task.send(SessionEvent::Closed(format!("{err:#}"))); + } + Err(_) => { + tracing::error!("ssh session task panicked"); + let _ = evt_tx_for_task.send(SessionEvent::Closed( + t("SSH 任务崩溃,请查看 meatshell-debug.log", "SSH worker panicked; see meatshell-debug.log") + .into(), + )); + } + } + }); + + ( + SessionHandle { + tab_id, + commands: cmd_tx, + join, + }, + evt_rx, + ) +} + +async fn run_session( + session: Session, + known_hosts_path: PathBuf, + mut commands: UnboundedReceiver, + events: UnboundedSender, + initial_cols: u32, + initial_rows: u32, +) -> Result<()> { + let _ = events.send(SessionEvent::Status(format!( + "{} {}@{}:{} ...", + t("连接中", "Connecting"), + session.user, session.host, session.port + ))); + + let config = Arc::new(client::Config { + inactivity_timeout: Some(std::time::Duration::from_secs(60 * 10)), + ..<_>::default() + }); + + let handler = ClientHandler { + host: session.host.clone(), + port: session.port, + known_hosts_path, + }; + let addr = format!("{}:{}", session.host, session.port); + // Connect directly, or tunnel through a SOCKS5 / HTTP proxy (issue #7). + let mut handle = match crate::proxy::resolve(&session.proxy) { + Some(p) => { + let _ = events.send(SessionEvent::Status(format!( + "{} {} → {}", + t("经代理连接", "via proxy"), + crate::proxy::describe(&p), + addr + ))); + let stream = crate::proxy::connect(&p, &session.host, session.port) + .await + .with_context(|| format!("proxy connect to {} failed", addr))?; + client::connect_stream(config, stream, handler) + .await + .with_context(|| format!("connect {} failed", addr))? + } + None => client::connect(config, addr.as_str(), handler) + .await + .with_context(|| format!("connect {} failed", addr))?, + }; + + // --- Auth ---------------------------------------------------------- + let authed = match session.auth { + AuthMethod::Password => handle + .authenticate_password(&session.user, session.password.as_str()) + .await + .context("password auth failed")?, + AuthMethod::Key => { + let raw = session.private_key_path.trim(); + if raw.is_empty() { + return Err(anyhow!(t("私钥路径为空", "private key path is empty"))); + } + // Normalise separators (we store `/` everywhere) and be forgiving if + // the user pointed at the `.pub` *public* key — the private key is the + // same path without that suffix. + let normalised = raw.replace('\\', "/"); + let key_path = normalised + .strip_suffix(".pub") + .map(str::to_string) + .unwrap_or(normalised); + let keypair = load_secret_key(Path::new(&key_path), None) + .with_context(|| format!("failed to load key {key_path}"))?; + let hash = if keypair.algorithm().is_rsa() { + Some(HashAlg::Sha256) + } else { + None + }; + let key_with_hash = PrivateKeyWithHashAlg::new(Arc::new(keypair), hash) + .context("invalid private key / hash algorithm combination")?; + handle + .authenticate_publickey(&session.user, key_with_hash) + .await + .context("publickey auth failed")? + } + }; + + if !authed { + let _ = events.send(SessionEvent::Closed(t("认证失败", "authentication failed").into())); + let _ = handle + .disconnect(Disconnect::ByApplication, "auth failed", "") + .await; + return Ok(()); + } + + // --- Shell channel -------------------------------------------------- + let mut channel = handle + .channel_open_session() + .await + .context("open session channel")?; + + channel + .request_pty( + true, + "xterm-256color", + initial_cols, + initial_rows, + 0, + 0, + &[], + ) + .await + .context("request PTY")?; + channel.request_shell(true).await.context("request shell")?; + + let _ = events.send(SessionEvent::Connected); + let _ = events.send(SessionEvent::Status(format!( + "{} {}@{}", + t("已连接", "Connected"), + session.user, session.host + ))); + + // Whether we have already injected the PROMPT_COMMAND setup. + // We wait for the first non-empty data chunk (the initial shell prompt) + // before sending so the command doesn't interleave with banner text. + let mut prompt_injected = false; + + // PROMPT_COMMAND bash snippet. Single-quoted body prevents bash from + // expanding ${HOSTNAME}/${PWD} at definition time; printf interprets + // \033 / \007 as ESC / BEL. `eval "$PROMPT_COMMAND"` fires it once + // immediately so the SFTP panel gets the initial CWD right away. + const PROMPT_SETUP: &[u8] = b"export PROMPT_COMMAND='printf \"\\033]7;file://${HOSTNAME}${PWD}\\007\"' && eval \"$PROMPT_COMMAND\"\r"; + + // --- Remote resource monitor (separate exec channel) ---------------- + // A tiny remote loop streams /proc/stat + /proc/meminfo every 2s; we parse + // it into CPU% / mem / swap for the sidebar. Best-effort: if the channel + // or exec fails (e.g. a non-Linux host without /proc), monitoring is + // silently skipped and the interactive shell is unaffected. + // Reset PATH to the standard system directories first (#27): the monitor + // runs over an exec channel, so a server with a hijacked PATH (or a + // BASH_ENV pointing at a malicious file) could otherwise shadow awk/cat/df/ + // sleep with arbitrary binaries. A fixed PATH covering /usr/bin and /bin is + // more portable than hardcoding one absolute path per tool (their location + // differs across distros). Monitoring is best-effort, so even if this shell + // is unusual and the reset finds nothing, only the sidebar stats are lost. + const MON_CMD: &[u8] = b"PATH=/usr/bin:/bin:/usr/sbin:/sbin; export PATH; while :; do awk '/^cpu /{print}' /proc/stat; awk '/^(MemTotal|MemAvailable|SwapTotal|SwapFree):/{print}' /proc/meminfo; echo __CPUCORES__; nproc 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null || echo 0; echo __UPTIME__; cat /proc/uptime 2>/dev/null; cat /proc/net/dev; echo __PS__; ps -eo rss=,pcpu=,comm= --sort=-pcpu,-rss 2>/dev/null | head -n 8; echo __DF__; df -kP 2>/dev/null; echo __MSTICK__; sleep 2; done\n"; + let mut mon_channel = match handle.channel_open_session().await { + Ok(ch) => match ch.exec(true, MON_CMD).await { + Ok(()) => Some(ch), + Err(e) => { + tracing::warn!("monitor exec failed: {e}"); + None + } + }, + Err(e) => { + tracing::warn!("monitor channel open failed: {e}"); + None + } + }; + let mut mon_buf = String::new(); + let mut prev_cpu: Option<(u64, u64)> = None; // (total jiffies, idle jiffies) + let mut prev_net: std::collections::HashMap = + std::collections::HashMap::new(); // iface -> (rx_bytes, tx_bytes) + let mut prev_net_at = std::time::Instant::now(); + + // --- Main pump ------------------------------------------------------ + loop { + tokio::select! { + cmd = commands.recv() => { + match cmd { + Some(SessionCommand::RawInput(bytes)) => { + // Only log the byte count — never the bytes themselves, + // which are raw keystrokes and may contain passwords (#15). + tracing::debug!("ssh channel.data len={} bytes", bytes.len()); + if let Err(err) = channel.data(&bytes[..]).await { + let _ = events.send(SessionEvent::Closed(format!("{}: {err}", t("写入失败", "write failed")))); + break; + } + } + Some(SessionCommand::Resize(cols, rows)) => { + let _ = channel.window_change(cols, rows, 0, 0).await; + } + Some(SessionCommand::Close) | None => { + let _ = channel.eof().await; + break; + } + } + } + msg = channel.wait() => { + match msg { + Some(ChannelMsg::Data { data }) => { + let text = String::from_utf8_lossy(&data).into_owned(); + + // Inject PROMPT_COMMAND after the first real shell output. + if !prompt_injected && !text.trim().is_empty() { + prompt_injected = true; + let _ = channel.data(PROMPT_SETUP).await; + } + + // Scan for OSC 7 CWD notification injected by PROMPT_COMMAND. + if let Some(cwd) = extract_osc7_path(&text) { + tracing::debug!("OSC7 cwd={:?}", cwd); + let _ = events.send(SessionEvent::CwdChanged(cwd)); + } + + let _ = events.send(SessionEvent::Output(text)); + } + Some(ChannelMsg::ExtendedData { data, ext: _ }) => { + let text = String::from_utf8_lossy(&data).into_owned(); + let _ = events.send(SessionEvent::Output(text)); + } + Some(ChannelMsg::ExitStatus { exit_status }) => { + let _ = events.send(SessionEvent::Status( + format!("{} (code {exit_status})", t("远程进程退出", "remote process exited")), + )); + } + Some(ChannelMsg::Close) | None => { + break; + } + _ => {} + } + } + // Remote resource monitor channel. The `async { ... }` lets us poll + // an Option: once the monitor channel closes we replace it + // with `pending()` so this arm simply never fires again. + mon = async { + match mon_channel.as_mut() { + Some(ch) => ch.wait().await, + None => std::future::pending().await, + } + } => { + match mon { + Some(ChannelMsg::Data { data }) => { + mon_buf.push_str(&String::from_utf8_lossy(&data)); + // Process every complete sample terminated by the marker. + while let Some(idx) = mon_buf.find("__MSTICK__") { + let block = mon_buf[..idx].to_string(); + let rest = mon_buf[idx + "__MSTICK__".len()..] + .trim_start_matches(['\r', '\n']) + .to_string(); + mon_buf = rest; + if let Some(stats) = parse_monitor_block( + &block, + &mut prev_cpu, + &mut prev_net, + &mut prev_net_at, + ) { + let _ = events.send(stats); + } + } + // Bound the leftover (incomplete) tail: a server that + // streams data but never emits the __MSTICK__ marker must + // not grow this buffer without limit (memory DoS, #27). + // A real sample is a few KiB; 1 MiB is a generous ceiling. + const MON_BUF_CAP: usize = 1 << 20; + if mon_buf.len() > MON_BUF_CAP { + mon_buf.clear(); + } + } + Some(ChannelMsg::Close) | None => { + mon_channel = None; + } + _ => {} + } + } + } + } + + let _ = handle + .disconnect(Disconnect::ByApplication, "bye", "") + .await; + let _ = events.send(SessionEvent::Closed(t("连接已关闭", "connection closed").into())); + Ok(()) +} + +/// Parse one monitor sample (a block of `/proc/stat` cpu line + `/proc/meminfo` +/// fields) into a [`SessionEvent::ResourceStats`]. +/// +/// CPU usage needs two consecutive `/proc/stat` snapshots; `prev` carries the +/// previous (total, idle) jiffies across calls. The first sample therefore +/// reports 0% (no baseline yet). +fn parse_monitor_block( + block: &str, + prev: &mut Option<(u64, u64)>, + prev_net: &mut std::collections::HashMap, + prev_net_at: &mut std::time::Instant, +) -> Option { + let mut cpu_total = 0u64; + let mut cpu_idle = 0u64; + let mut have_cpu = false; + let mut mem_total = 0u64; + let mut mem_avail = 0u64; + let mut swap_total = 0u64; + let mut swap_free = 0u64; + let mut cpu_cores = 0u32; + let mut uptime_secs = 0u64; + // Raw /proc/net/dev counters this sample: iface -> (rx_bytes, tx_bytes). + let mut net_now: Vec<(String, u64, u64)> = Vec::new(); + // Filesystems from `df -kP`: (mount, available_bytes, total_bytes). + let mut disks: Vec<(String, u64, u64)> = Vec::new(); + let mut processes: Vec<(u64, f32, String)> = Vec::new(); + let mut in_df = false; + let mut in_ps = false; + let mut expect_uptime = false; + let mut expect_cpu_cores = false; + + // Cap how many interfaces / filesystems / processes we accept from one sample so a + // hostile server can't flood the parser and sidebar with fabricated rows + // (#27). No real machine has anywhere near this many. + const MAX_MON_ENTRIES: usize = 64; + const MAX_MON_PROCESSES: usize = 8; + + for line in block.lines() { + if line == "__UPTIME__" { + expect_uptime = true; + continue; + } + if line == "__CPUCORES__" { + expect_cpu_cores = true; + continue; + } + if expect_cpu_cores { + cpu_cores = line.trim().parse::().unwrap_or(0); + expect_cpu_cores = false; + continue; + } + if expect_uptime { + uptime_secs = parse_uptime_secs(line); + expect_uptime = false; + continue; + } + if line == "__DF__" { + in_df = true; + in_ps = false; + continue; + } + if line == "__PS__" { + in_ps = true; + in_df = false; + continue; + } + if in_ps { + if processes.len() < MAX_MON_PROCESSES { + if let Some(process) = parse_ps_line(line) { + processes.push(process); + } + } + continue; + } + if in_df { + if disks.len() < MAX_MON_ENTRIES { + if let Some(d) = parse_df_line(line) { + disks.push(d); + } + } + continue; + } + if let Some(rest) = line.strip_prefix("cpu ") { + let nums: Vec = rest + .split_whitespace() + .filter_map(|x| x.parse().ok()) + .collect(); + // user nice system idle iowait irq softirq steal ... + if nums.len() >= 4 { + // Saturating arithmetic: a server can send arbitrary jiffy + // values, and a plain sum/add would panic on overflow in debug. + cpu_total = nums.iter().copied().fold(0u64, u64::saturating_add); + cpu_idle = nums[3].saturating_add(nums.get(4).copied().unwrap_or(0)); // idle + iowait + have_cpu = true; + } + } else if let Some(v) = line.strip_prefix("MemTotal:") { + mem_total = parse_meminfo_kib(v); + } else if let Some(v) = line.strip_prefix("MemAvailable:") { + mem_avail = parse_meminfo_kib(v); + } else if let Some(v) = line.strip_prefix("SwapTotal:") { + swap_total = parse_meminfo_kib(v); + } else if let Some(v) = line.strip_prefix("SwapFree:") { + swap_free = parse_meminfo_kib(v); + } else if net_now.len() < MAX_MON_ENTRIES { + if let Some((iface, counters)) = parse_net_dev_line(line) { + net_now.push((iface, counters.0, counters.1)); + } + } + } + + // Convert raw byte counters into per-second rates using the previous sample. + let now = std::time::Instant::now(); + let elapsed = now.duration_since(*prev_net_at).as_secs_f64().max(0.001); + let mut net: Vec<(String, u64, u64)> = Vec::new(); + if !net_now.is_empty() { + for (iface, rx, tx) in &net_now { + if let Some((prx, ptx)) = prev_net.get(iface) { + let rx_bps = (rx.saturating_sub(*prx) as f64 / elapsed) as u64; + let tx_bps = (tx.saturating_sub(*ptx) as f64 / elapsed) as u64; + net.push((iface.clone(), rx_bps, tx_bps)); + } + } + prev_net.clear(); + for (iface, rx, tx) in net_now { + prev_net.insert(iface, (rx, tx)); + } + *prev_net_at = now; + // Show busiest first so the default-selected NIC is the active one. + net.sort_by(|a, b| (b.1 + b.2).cmp(&(a.1 + a.2))); + } + + let cpu_percent = if have_cpu { + let result = match *prev { + Some((ptotal, pidle)) => { + let dt = cpu_total.saturating_sub(ptotal); + let di = cpu_idle.saturating_sub(pidle); + if dt > 0 { + (1.0 - di as f32 / dt as f32).clamp(0.0, 1.0) + } else { + 0.0 + } + } + None => 0.0, + }; + *prev = Some((cpu_total, cpu_idle)); + result + } else { + 0.0 + }; + + // Need at least memory numbers to be a useful sample. + if mem_total == 0 { + return None; + } + + processes.sort_by(|a, b| { + b.1.total_cmp(&a.1) + .then_with(|| b.0.cmp(&a.0)) + }); + processes.truncate(5); + + Some(SessionEvent::ResourceStats { + cpu_percent, + cpu_cores, + mem_used_kib: mem_total.saturating_sub(mem_avail), + mem_total_kib: mem_total, + swap_used_kib: swap_total.saturating_sub(swap_free), + swap_total_kib: swap_total, + uptime_secs, + net, + disks, + processes, + }) +} + +fn parse_uptime_secs(s: &str) -> u64 { + s.split_whitespace() + .next() + .and_then(|x| x.parse::().ok()) + .filter(|x| x.is_finite() && *x >= 0.0) + .map(|x| x as u64) + .unwrap_or(0) +} + +/// Parse one `df -kP` data line into `(mount, available_bytes, total_bytes)`. +/// Columns: `Filesystem 1024-blocks Used Available Capacity Mounted-on`. +fn parse_df_line(line: &str) -> Option<(String, u64, u64)> { + let f: Vec<&str> = line.split_whitespace().collect(); + if f.len() < 6 || f[0] == "Filesystem" { + return None; + } + let total_kb: u64 = f[1].parse().ok()?; + let avail_kb: u64 = f[3].parse().ok()?; + if total_kb == 0 { + return None; + } + // Mount point is the last column (joined in case it contains spaces). + let mount = f[5..].join(" "); + // Saturating: a server can report arbitrary block counts; KiB→bytes must + // not overflow-panic in debug (#27). + Some((mount, avail_kb.saturating_mul(1024), total_kb.saturating_mul(1024))) +} + +/// Extract the leading integer (KiB) from a `/proc/meminfo` value like +/// `" 3288560 kB"`. +fn parse_meminfo_kib(s: &str) -> u64 { + s.split_whitespace() + .next() + .and_then(|x| x.parse().ok()) + .unwrap_or(0) +} + +fn parse_ps_line(line: &str) -> Option<(u64, f32, String)> { + let mut parts = line.split_whitespace(); + let rss_kib = parts.next()?.parse::().ok()?; + let cpu = parts + .next()? + .trim_end_matches('%') + .parse::() + .ok() + .filter(|v| v.is_finite()) + .unwrap_or(0.0); + let name = parts.collect::>().join(" "); + if name.is_empty() { + return None; + } + Some((rss_kib.saturating_mul(1024), cpu.max(0.0), name)) +} + +/// Parse one `/proc/net/dev` data line into `(iface, (rx_bytes, tx_bytes))`. +/// Format: ` eth0: ... ...` +/// (16 numeric columns; rx_bytes is col 0, tx_bytes is col 8). The `lo` +/// loopback interface is skipped — it never reflects real traffic. +fn parse_net_dev_line(line: &str) -> Option<(String, (u64, u64))> { + let (name, rest) = line.split_once(':')?; + let iface = name.trim(); + if iface.is_empty() || iface == "lo" || iface.contains(' ') { + return None; + } + let nums: Vec = rest + .split_whitespace() + .filter_map(|x| x.parse().ok()) + .collect(); + if nums.len() < 9 { + return None; + } + Some((iface.to_string(), (nums[0], nums[8]))) +} + +/// SSH client handler with OpenSSH-style known_hosts verification. +struct ClientHandler { + host: String, + port: u16, + known_hosts_path: PathBuf, +} + +#[async_trait] +impl Handler for ClientHandler { + type Error = russh::Error; + + async fn check_server_key( + &mut self, + server_public_key: &PublicKey, + ) -> Result { + match crate::known_hosts::check_or_trust_in( + &self.host, + self.port, + server_public_key, + &self.known_hosts_path, + ) { + Ok(crate::known_hosts::HostKeyStatus::Verified) => Ok(true), + Ok(crate::known_hosts::HostKeyStatus::Added) => { + tracing::info!( + "trusted new SSH host key for {}:{}", + self.host, + self.port + ); + Ok(true) + } + Ok(crate::known_hosts::HostKeyStatus::Changed) => { + tracing::warn!( + "SSH host key mismatch for {}:{}; rejecting connection", + self.host, + self.port + ); + Ok(false) + } + Err(err) => { + tracing::warn!( + "known_hosts check failed for {}:{}: {err:#}", + self.host, + self.port + ); + Ok(false) + } + } + } + + async fn data( + &mut self, + _channel: ChannelId, + _data: &[u8], + _session: &mut client::Session, + ) -> Result<(), Self::Error> { + Ok(()) + } +} + +// Marker trait impl so `Arc>` is nameable in external code. +#[allow(dead_code)] +fn _assert_handle_send() { + fn takes() {} + takes::>(); +} + +#[cfg(test)] +mod monitor_hardening_tests { + use super::{parse_df_line, parse_monitor_block, parse_ps_line, parse_uptime_secs}; + use std::collections::HashMap; + use std::time::Instant; + + #[test] + fn df_line_saturates_instead_of_overflowing() { + // avail/total near u64::MAX must not panic on the KiB->bytes multiply. + let line = "/dev/sda1 18446744073709551615 0 18446744073709551615 100% /"; + let (_, avail, total) = parse_df_line(line).expect("parses"); + assert_eq!(avail, u64::MAX); + assert_eq!(total, u64::MAX); + } + + #[test] + fn cpu_overflow_values_do_not_panic() { + let big = u64::MAX; + let block = format!( + "cpu {big} {big} {big} {big} {big}\nMemTotal: 1000 kB\nMemAvailable: 500 kB" + ); + let mut prev = None; + let mut prev_net = HashMap::new(); + let mut at = Instant::now(); + // Must not panic; with no baseline the first sample reports 0% CPU. + assert!(parse_monitor_block(&block, &mut prev, &mut prev_net, &mut at).is_some()); + } + + #[test] + fn floods_of_fake_interfaces_are_capped() { + let mut block = String::from("MemTotal: 1000 kB\nMemAvailable: 500 kB\n"); + for i in 0..500 { + block.push_str(&format!("eth{i}: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16\n")); + } + let mut prev = None; + let mut prev_net = HashMap::new(); + let mut at = Instant::now(); + assert!(parse_monitor_block(&block, &mut prev, &mut prev_net, &mut at).is_some()); + // The remembered interface set is capped, not 500. + assert!(prev_net.len() <= 64, "prev_net held {}", prev_net.len()); + } + + #[test] + fn parses_proc_uptime_seconds() { + assert_eq!(parse_uptime_secs("12345.67 89012.34"), 12345); + assert_eq!(parse_uptime_secs("not-a-number"), 0); + } + + #[test] + fn parses_ps_resource_line() { + let (mem, cpu, name) = parse_ps_line("204800 15.5 docker").expect("parses ps"); + assert_eq!(mem, 204800 * 1024); + assert_eq!(cpu, 15.5); + assert_eq!(name, "docker"); + } + + #[test] + fn monitor_processes_are_sorted_by_highest_cpu_then_memory() { + let block = "\ +cpu 100 0 0 900 0 +MemTotal: 1000 kB +MemAvailable: 500 kB +__PS__ +999999 1.0 memory-heavy +2048 90.0 cpu-heavy +4096 90.0 cpu-heavy-more-memory +__DF__ +"; + let mut prev = None; + let mut prev_net = HashMap::new(); + let mut at = Instant::now(); + let event = parse_monitor_block(block, &mut prev, &mut prev_net, &mut at) + .expect("resource stats"); + match event { + super::SessionEvent::ResourceStats { processes, .. } => { + assert_eq!(processes[0].2, "cpu-heavy-more-memory"); + assert_eq!(processes[1].2, "cpu-heavy"); + assert_eq!(processes[2].2, "memory-heavy"); + } + _ => panic!("unexpected event"), + } + } +} diff --git a/src/ssh_config.rs b/src/ssh_config.rs new file mode 100644 index 0000000..88cf65b --- /dev/null +++ b/src/ssh_config.rs @@ -0,0 +1,244 @@ +//! Minimal `~/.ssh/config` parser used to import hosts as meatshell sessions. +//! +//! We only read the handful of fields a session needs — `HostName`, `User`, +//! `Port`, `IdentityFile` — grouped under each concrete `Host` alias. Wildcard +//! patterns (`Host *`) and unsupported directives are ignored; this is a +//! convenience importer, not a full ssh_config implementation. + +use std::path::{Path, PathBuf}; + +/// One importable host parsed from `~/.ssh/config`. +#[derive(Debug, Clone)] +pub struct ImportedHost { + pub alias: String, + pub hostname: String, + pub user: String, + pub port: u16, + pub identity_file: String, +} + +/// Parse the user's `~/.ssh/config` (returns empty if it doesn't exist). +pub fn parse_default() -> Vec { + let Some(home) = home_dir() else { + return Vec::new(); + }; + let path = home.join(".ssh").join("config"); + match std::fs::read_to_string(&path) { + Ok(text) => parse_str(&text, &home), + Err(_) => Vec::new(), + } +} + +fn home_dir() -> Option { + directories::UserDirs::new().map(|u| u.home_dir().to_path_buf()) +} + +/// Split a config line into its keyword and value, supporting both +/// `Keyword value` and `Keyword=value`, and stripping surrounding quotes. +fn split_kv(line: &str) -> Option<(String, String)> { + let line = line.trim(); + // Separator is the first '=' or run of whitespace. + let (k, v) = if let Some(eq) = line.find('=') { + // Only treat '=' as the separator if it comes before any space. + let sp = line.find(char::is_whitespace).unwrap_or(usize::MAX); + if eq < sp { + (&line[..eq], &line[eq + 1..]) + } else { + line.split_once(char::is_whitespace)? + } + } else { + line.split_once(char::is_whitespace)? + }; + let v = v.trim().trim_matches('"').trim(); + if v.is_empty() { + return None; + } + Some((k.trim().to_ascii_lowercase(), v.to_string())) +} + +fn expand_tilde(path: &str, home: &Path) -> String { + if let Some(rest) = path.strip_prefix("~/") { + home.join(rest).to_string_lossy().replace('\\', "/") + } else if path == "~" { + home.to_string_lossy().replace('\\', "/") + } else { + path.replace('\\', "/") + } +} + +fn is_concrete(pattern: &str) -> bool { + !pattern.is_empty() && !pattern.contains(['*', '?', '!']) +} + +/// Validate a `HostName` before importing it (issue #33). +/// +/// Accepts IPv4 / IPv6 literals and DNS-style hostnames; rejects anything with +/// shell metacharacters, whitespace, control characters, scheme prefixes +/// (`http://…`), etc. This stops a malformed or hostile `~/.ssh/config` entry +/// from flowing unchecked into a saved session. Internal IPs are intentionally +/// allowed — they are a legitimate SSH target and can't be told apart by format. +fn is_valid_hostname(s: &str) -> bool { + // IP literals (including private/loopback addresses) are always fine. + if s.parse::().is_ok() { + return true; + } + if s.is_empty() || s.len() > 253 { + return false; + } + // DNS-style: dot-separated labels of [A-Za-z0-9_-], no empty or + // hyphen-edged labels. + s.split('.').all(|label| { + let b = label.as_bytes(); + !b.is_empty() + && b.len() <= 63 + && b.iter() + .all(|&c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_') + && b[0] != b'-' + && b[b.len() - 1] != b'-' + }) +} + +pub fn parse_str(text: &str, home: &Path) -> Vec { + let mut hosts: Vec = Vec::new(); + let mut cur: Option = None; + + let flush = |cur: &mut Option, out: &mut Vec| { + if let Some(mut h) = cur.take() { + if h.hostname.is_empty() { + h.hostname = h.alias.clone(); + } + // Skip entries whose HostName isn't a valid IP / hostname (issue #33). + if is_valid_hostname(&h.hostname) { + out.push(h); + } else { + tracing::warn!( + alias = %h.alias, + "skipping ~/.ssh/config host with invalid HostName" + ); + } + } + }; + + for raw in text.lines() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, val)) = split_kv(line) else { + continue; + }; + match key.as_str() { + "host" => { + flush(&mut cur, &mut hosts); + // Take the first concrete (non-wildcard) alias of the block. + if let Some(alias) = val.split_whitespace().find(|p| is_concrete(p)) { + cur = Some(ImportedHost { + alias: alias.to_string(), + hostname: String::new(), + user: String::new(), + port: 22, + identity_file: String::new(), + }); + } + } + "hostname" => { + if let Some(h) = cur.as_mut() { + h.hostname = val; + } + } + "user" => { + if let Some(h) = cur.as_mut() { + h.user = val; + } + } + "port" => { + if let Some(h) = cur.as_mut() { + if let Ok(p) = val.parse::() { + h.port = p; + } + } + } + "identityfile" => { + if let Some(h) = cur.as_mut() { + if h.identity_file.is_empty() { + h.identity_file = expand_tilde(&val, home); + } + } + } + _ => {} + } + } + flush(&mut cur, &mut hosts); + hosts +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn parses_basic_blocks() { + let cfg = "\ +# comment +Host prod web-prod + HostName 10.0.0.5 + User deploy + Port 2222 + IdentityFile ~/.ssh/id_ed25519 + +Host * + User nobody + +Host alias-only +"; + let home = Path::new("/home/me"); + let hosts = parse_str(cfg, home); + assert_eq!(hosts.len(), 2); + assert_eq!(hosts[0].alias, "prod"); + assert_eq!(hosts[0].hostname, "10.0.0.5"); + assert_eq!(hosts[0].user, "deploy"); + assert_eq!(hosts[0].port, 2222); + assert!(hosts[0].identity_file.ends_with("/.ssh/id_ed25519")); + // alias-only: hostname falls back to the alias + assert_eq!(hosts[1].alias, "alias-only"); + assert_eq!(hosts[1].hostname, "alias-only"); + } + + #[test] + fn rejects_invalid_hostnames() { + let cfg = "\ +Host good + HostName 10.0.0.5 +Host ipv6 + HostName ::1 +Host evil + HostName a;rm -rf / +Host spaced + HostName foo bar +Host scheme + HostName http://x.com +"; + let home = Path::new("/home/me"); + let aliases: Vec = + parse_str(cfg, home).into_iter().map(|h| h.alias).collect(); + assert!(aliases.contains(&"good".to_string())); + assert!(aliases.contains(&"ipv6".to_string())); + assert!(!aliases.contains(&"evil".to_string())); + assert!(!aliases.contains(&"spaced".to_string())); + assert!(!aliases.contains(&"scheme".to_string())); + } + + #[test] + fn validates_hostname_formats() { + assert!(is_valid_hostname("192.168.1.1")); + assert!(is_valid_hostname("::1")); + assert!(is_valid_hostname("example.com")); + assert!(is_valid_hostname("my-server_01.internal")); + assert!(!is_valid_hostname("a;rm -rf /")); + assert!(!is_valid_hostname("foo bar")); + assert!(!is_valid_hostname("http://x.com")); + assert!(!is_valid_hostname("-bad.com")); + assert!(!is_valid_hostname("")); + } +} diff --git a/src/system.rs b/src/system.rs new file mode 100644 index 0000000..37a987f --- /dev/null +++ b/src/system.rs @@ -0,0 +1,53 @@ +//! Formatting helpers for sidebar resource values. + +pub fn format_bytes_short(bytes: u64) -> String { + const UNITS: [&str; 4] = ["B", "K", "M", "G"]; + let mut value = bytes as f64; + let mut idx = 0usize; + while value >= 1024.0 && idx < UNITS.len() - 1 { + value /= 1024.0; + idx += 1; + } + if idx == 0 { + format!("{}{}", bytes, UNITS[idx]) + } else if value >= 100.0 || value.fract() == 0.0 { + format!("{}{}", value as u64, UNITS[idx]) + } else { + format!("{:.1}{}", value, UNITS[idx]) + } +} + +/// Human-readable memory size from MiB (e.g. `"1.5G/16G"`). +pub fn format_mem_mib(used_mib: u64, total_mib: u64) -> String { + if total_mib >= 1024 { + let used_gib = used_mib as f64 / 1024.0; + let total_gib = total_mib as f64 / 1024.0; + // Drop decimals for large/round values to keep the string short (sidebar is narrow). + let fmt_gib = |g: f64| -> String { + if g >= 100.0 || g.fract() == 0.0 { + format!("{}", g as u64) + } else { + format!("{:.1}", g) + } + }; + format!("{}G/{}G", fmt_gib(used_gib), fmt_gib(total_gib)) + } else { + format!("{}/{}M", used_mib, total_mib) + } +} + +/// Human-readable network throughput (e.g. `"1.2 MB/s"`). +pub fn format_bytes_per_sec(bytes: u64) -> String { + const UNITS: [&str; 4] = ["B/s", "KB/s", "MB/s", "GB/s"]; + let mut value = bytes as f64; + let mut idx = 0; + while value >= 1024.0 && idx < UNITS.len() - 1 { + value /= 1024.0; + idx += 1; + } + if idx == 0 { + format!("{} {}", bytes, UNITS[idx]) + } else { + format!("{:.1} {}", value, UNITS[idx]) + } +} diff --git a/src/telnet.rs b/src/telnet.rs new file mode 100644 index 0000000..ed0b4f2 --- /dev/null +++ b/src/telnet.rs @@ -0,0 +1,272 @@ +//! Telnet session worker (issue #17). +//! +//! Like [`crate::serial`], this mirrors [`crate::ssh::spawn_session`]'s public +//! surface so the terminal UI is reused unchanged. Telnet is just a TCP byte +//! stream with in-band option negotiation (RFC 854/855), so the worker: +//! +//! * strips IAC command sequences out of the data stream before it reaches the +//! terminal, +//! * answers option negotiation with a minimal, conservative policy (let the +//! server echo and run in character mode; advertise our window size), +//! * doubles `0xFF` bytes in user input as the protocol requires, +//! * re-sends NAWS (window size) on every terminal resize. +//! +//! There is no SFTP and no resource monitor — a Telnet console is a raw pipe. + +use anyhow::{Context, Result}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; + +use crate::config::Session; +use crate::i18n::t; +use crate::ssh::{SessionCommand, SessionEvent, SessionHandle}; + +// Telnet protocol bytes (RFC 854). +const IAC: u8 = 255; +const DONT: u8 = 254; +const DO: u8 = 253; +const WONT: u8 = 252; +const WILL: u8 = 251; +const SB: u8 = 250; +const SE: u8 = 240; + +// Options we care about. +const OPT_ECHO: u8 = 1; +const OPT_SGA: u8 = 3; // suppress go-ahead → character-at-a-time mode +const OPT_NAWS: u8 = 31; // negotiate about window size + +pub fn spawn_telnet_session( + runtime: &tokio::runtime::Handle, + tab_id: String, + session: Session, + initial_cols: u32, + initial_rows: u32, +) -> (SessionHandle, UnboundedReceiver) { + let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::(); + let (evt_tx, evt_rx) = mpsc::unbounded_channel::(); + + let evt_for_task = evt_tx.clone(); + let join = runtime.spawn(async move { + if let Err(err) = + run_telnet(session, cmd_rx, evt_for_task.clone(), initial_cols, initial_rows).await + { + let _ = evt_for_task.send(SessionEvent::Closed(format!("{err:#}"))); + } + }); + + ( + SessionHandle { + tab_id, + commands: cmd_tx, + join, + }, + evt_rx, + ) +} + +/// Incoming-byte parser state for stripping IAC sequences. +enum TnState { + Data, + Iac, + Opt(u8), // saw IAC , awaiting option byte + Sub, // inside subnegotiation, awaiting IAC + SubIac, // inside subnegotiation, saw IAC (awaiting SE) +} + +fn naws_subneg(cols: u32, rows: u32) -> Vec { + let w = (cols.clamp(1, u16::MAX as u32)) as u16; + let h = (rows.clamp(1, u16::MAX as u32)) as u16; + let mut v = vec![IAC, SB, OPT_NAWS]; + // Width / height are 16-bit; any 255 byte inside must be doubled. + for b in [(w >> 8) as u8, (w & 0xff) as u8, (h >> 8) as u8, (h & 0xff) as u8] { + v.push(b); + if b == IAC { + v.push(IAC); + } + } + v.extend_from_slice(&[IAC, SE]); + v +} + +async fn run_telnet( + session: Session, + mut commands: UnboundedReceiver, + events: UnboundedSender, + initial_cols: u32, + initial_rows: u32, +) -> Result<()> { + let host = session.host.trim().to_string(); + let port = if session.port == 0 { 23 } else { session.port }; + let addr = format!("{host}:{port}"); + + let _ = events.send(SessionEvent::Status(format!( + "{} {} ...", + t("Telnet 连接中", "Telnet connecting"), + addr + ))); + + // Direct, or tunnel through a SOCKS5 / HTTP proxy (reuses issue #7 plumbing). + let stream = match crate::proxy::resolve(&session.proxy) { + Some(p) => { + let _ = events.send(SessionEvent::Status(format!( + "{} {} → {}", + t("经代理连接", "via proxy"), + crate::proxy::describe(&p), + addr + ))); + crate::proxy::connect(&p, &host, port) + .await + .with_context(|| format!("proxy connect to {addr} failed"))? + } + None => TcpStream::connect(&addr) + .await + .with_context(|| format!("connect {addr} failed"))?, + }; + let _ = stream.set_nodelay(true); + + let _ = events.send(SessionEvent::Connected); + let _ = events.send(SessionEvent::Status(format!( + "{} {}", + t("已连接", "Connected"), + addr + ))); + + let (mut rd, mut wr) = tokio::io::split(stream); + + // Proactively advertise the options we support: we will suppress go-ahead + // and report our window size. Most gear replies DO and starts echoing. + let mut hello = vec![IAC, WILL, OPT_SGA, IAC, WILL, OPT_NAWS]; + hello.extend_from_slice(&naws_subneg(initial_cols, initial_rows)); + wr.write_all(&hello).await.context("telnet write hello")?; + wr.flush().await.ok(); + + let mut state = TnState::Data; + let mut buf = [0u8; 4096]; + + loop { + tokio::select! { + cmd = commands.recv() => { + match cmd { + Some(SessionCommand::RawInput(bytes)) => { + // Never log keystroke bytes — they can be passwords (#15). + tracing::debug!("telnet write len={} bytes", bytes.len()); + // Escape IAC (0xFF) in user data per RFC 854. + let mut out = Vec::with_capacity(bytes.len()); + for b in bytes { + out.push(b); + if b == IAC { out.push(IAC); } + } + if wr.write_all(&out).await.is_err() { + let _ = events.send(SessionEvent::Closed( + t("写入失败", "write failed").into())); + break; + } + let _ = wr.flush().await; + } + Some(SessionCommand::Resize(cols, rows)) => { + let _ = wr.write_all(&naws_subneg(cols, rows)).await; + let _ = wr.flush().await; + } + Some(SessionCommand::Close) | None => break, + } + } + r = rd.read(&mut buf) => { + match r { + Ok(0) => break, // peer closed + Ok(n) => { + let mut data = Vec::with_capacity(n); + let mut replies: Vec = Vec::new(); + process_incoming(&buf[..n], &mut state, &mut data, &mut replies); + if !replies.is_empty() { + let _ = wr.write_all(&replies).await; + let _ = wr.flush().await; + } + if !data.is_empty() { + let text = String::from_utf8_lossy(&data).into_owned(); + let _ = events.send(SessionEvent::Output(text)); + } + } + Err(e) => { + let _ = events.send(SessionEvent::Closed(format!( + "{}: {e}", t("读取错误", "read error")))); + break; + } + } + } + } + } + + let _ = events.send(SessionEvent::Closed( + t("连接已关闭", "connection closed").into(), + )); + Ok(()) +} + +/// Feed `input` through the IAC state machine: plain bytes accumulate into +/// `data`, and any negotiation responses we owe go into `replies`. +fn process_incoming(input: &[u8], state: &mut TnState, data: &mut Vec, replies: &mut Vec) { + for &b in input { + match *state { + TnState::Data => { + if b == IAC { + *state = TnState::Iac; + } else { + data.push(b); + } + } + TnState::Iac => match b { + IAC => { + // Escaped 0xFF literal in the data stream. + data.push(IAC); + *state = TnState::Data; + } + DO | DONT | WILL | WONT => *state = TnState::Opt(b), + SB => *state = TnState::Sub, + // Standalone commands (GA, NOP, DM, …) — ignore. + _ => *state = TnState::Data, + }, + TnState::Opt(cmd) => { + respond_negotiation(cmd, b, replies); + *state = TnState::Data; + } + TnState::Sub => { + // Skip subnegotiation payload until IAC SE. + if b == IAC { + *state = TnState::SubIac; + } + } + TnState::SubIac => { + // IAC SE ends the block; IAC IAC is an escaped data byte we drop + // (we don't interpret any subnegotiation payloads). + if b == SE { + *state = TnState::Data; + } else { + *state = TnState::Sub; + } + } + } + } +} + +/// Conservative negotiation policy. Returns nothing; appends a 3-byte reply. +fn respond_negotiation(cmd: u8, opt: u8, replies: &mut Vec) { + match cmd { + // Server asks us to enable an option. + DO => { + let accept = matches!(opt, OPT_SGA | OPT_NAWS); + replies.extend_from_slice(&[IAC, if accept { WILL } else { WONT }, opt]); + } + // Server tells us to disable — always agree. + DONT => replies.extend_from_slice(&[IAC, WONT, opt]), + // Server offers to enable an option on its side. + WILL => { + // Let the server echo and run in character mode; refuse the rest. + let accept = matches!(opt, OPT_ECHO | OPT_SGA); + replies.extend_from_slice(&[IAC, if accept { DO } else { DONT }, opt]); + } + // Server says it won't — acknowledge. + WONT => replies.extend_from_slice(&[IAC, DONT, opt]), + _ => {} + } +} diff --git a/ui/app.slint b/ui/app.slint new file mode 100644 index 0000000..5bdf928 --- /dev/null +++ b/ui/app.slint @@ -0,0 +1,1608 @@ +// Embedded cross-platform fonts (compile-time): terminal monospace + UI icons. +// Embedding means Windows / Linux / macOS all render the same glyphs without +// relying on Consolas / Segoe MDL2 Assets being installed. +// +// Cascadia Mono: full box-drawing + block-elements + braille (needed by btop +// for its sparkline graphs; JetBrains Mono lacks braille → garbled output). +// Material Icons: open-source icon font replacing Windows-only Segoe MDL2 Assets. +import "fonts/CascadiaMono-Regular.ttf"; +import "fonts/CascadiaMono-Bold.ttf"; +import "fonts/MaterialIcons-Regular.ttf"; + +import { Theme } from "theme.slint"; +import { Sidebar, DiskInfo, ProcessInfo } from "sidebar.slint"; +import { TabBar, TabInfo } from "tabs.slint"; +import { Welcome, SessionInfo, ConnectionFolderInfo } from "welcome.slint"; +import { SessionDialog, SessionDraft } from "session_dialog.slint"; +import { ConfirmDialog } from "confirm_dialog.slint"; +import { TerminalView, TermSpan, TermMatch, CommandCategory, CommandItem } from "terminal_view.slint"; +import { SftpEntry, SftpTreeNode } from "sftp_panel.slint"; + +export { SessionInfo, ConnectionFolderInfo, SessionDraft, TabInfo, SftpEntry, SftpTreeNode, CommandCategory, CommandItem, ProcessInfo } + +// --- TerminalState --------------------------------------------------------- +// Per-terminal view-model exposed to Rust. A TabInfo has the metadata; the +// TerminalState carries the streaming log + status line. Kept as a parallel +// model so opening/closing tabs doesn't churn the log strings. +// One file-transfer record shown in the download manager. +export struct TransferInfo { + id: string, + name: string, + detail: string, // "2.3 MB/5.0 MB" | "已完成" | "失败" + percent: float, // 0.0..1.0 + state: int, // 0 active / 1 done / 2 error + is-upload: bool, +} + +export struct TerminalState { + id: string, + tab-id: string, + status: string, + spans: [TermSpan], // coloured runs of the visible terminal screen + cursor-row: int, // cursor cell on the grid + cursor-col: int, + rows-used: int, // content rows, for viewport sizing + is-alt-screen: bool, + mouse-tracking: bool, + find-matches: [TermMatch], // search-highlight rectangles + selection: [TermMatch], // drag-selection highlight rectangles + + // SFTP panel state + sftp-path: string, + sftp-entries: [SftpEntry], + sftp-status: string, + sftp-loading: bool, + sftp-tree-nodes: [SftpTreeNode], +} + +component ConfigButton inherits Rectangle { + in property label; + in property primary: false; + in property selected: false; + in property button-width: 84px; + callback clicked(); + + width: button-width; + height: 30px; + border-radius: Theme.radius-sm; + border-width: primary ? 0px : 1px; + border-color: selected ? Theme.accent : (touch.has-hover ? Theme.border-strong : Theme.border-subtle); + background: primary + ? (touch.has-hover ? Theme.accent-hover : Theme.accent) + : selected + ? Theme.bg-active + : (touch.has-hover ? Theme.bg-hover : Theme.bg-elevated); + + touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.clicked(); } + } + + Text { + width: parent.width; + height: parent.height; + text: root.label; + color: root.primary ? #ffffff : Theme.text-primary; + font-size: Theme.fs-sm; + horizontal-alignment: center; + vertical-alignment: center; + overflow: elide; + } +} + +component ConfigTextField inherits Rectangle { + in-out property value; + in property field-width: 86px; + + width: field-width; + height: 30px; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: input.has-focus ? Theme.accent : Theme.border-subtle; + background: Theme.bg-root; + + input := TextInput { + x: 8px; + width: parent.width - 16px; + height: parent.height; + text <=> root.value; + color: Theme.text-primary; + font-size: Theme.fs-sm; + single-line: true; + vertical-alignment: center; + } +} + +component ConfigValueBox inherits Rectangle { + in property value; + in property empty-text; + + height: 30px; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: Theme.border-subtle; + background: Theme.bg-root; + + Text { + x: 10px; + width: parent.width - 20px; + height: parent.height; + text: root.value == "" ? root.empty-text : root.value; + color: root.value == "" ? Theme.text-muted : Theme.text-primary; + font-size: Theme.fs-sm; + overflow: elide; + vertical-alignment: center; + } +} + +export component AppWindow inherits Window { + title: "meatshell"; + icon: @image-url("../assets/icon.png"); + default-font-family: Theme.font-ui; + min-width: 960px; + min-height: 600px; + preferred-width: 1200px; + preferred-height: 760px; + background: Theme.bg-root; + + // --- Sidebar data ------------------------------------------------------ + in property connection-state: @tr("Not connected"); + in property conn-state; // 0 gray / 1 green / 2 yellow + in property resource-title: @tr("Local resources"); // @tr("Local resources") | @tr("Server resources") + in property cpu-percent; + in property mem-percent; + in property swap-percent; + in property cpu-detail; + in property mem-detail; + in property swap-detail; + // Network panel follows the active live session tab. + in property net-top-up; // ↑ tx rate text + in property net-top-down; // ↓ rx rate text + in property <[float]> net-top-history; + in property <[string]> net-ifaces; // remote NIC names for the selector + in property net-selected; // currently selected NIC + in property net-show-selector; // only on a live session tab + in property <[DiskInfo]> disks; // active tab's filesystem usage + in property <[ProcessInfo]> processes; // top resource consumers + callback select-net-iface(string); + + // --- Top-right popups -------------------------------------------------- + in-out property download-open: false; // download manager popup + in-out property settings-open: false; // settings menu popup + in-out property config-open: false; // app configuration dialog + in-out property about-open: false; // centered About dialog + in property download-dir; // preset SFTP download folder ("" = ask) + in property <[TransferInfo]> transfers; // download/upload records (newest first) + in property <[CommandCategory]> command-categories; + in property <[CommandItem]> command-items; + in-out property command-selected-category: "default"; + in-out property command-selected-id; + in-out property command-category-draft; + in-out property command-edit-id; + in-out property command-edit-name; + in-out property command-edit-command; + in-out property command-edit-append-cr: true; + in-out property command-param1-name; + in-out property command-param1-value; + in-out property command-param2-name; + in-out property command-param2-value; + in-out property command-param3-name; + in-out property command-param3-value; + in-out property command-param4-name; + in-out property command-param4-value; + in-out property command-param5-name; + in-out property command-param5-value; + in property <[string]> about-libs; // open-source libraries used + in-out property lang-en: false; // current UI language (false = 中文) + // Two-way binding to Theme.dark — Rust reads/writes this property and + // Slint propagates it to every widget that references Theme colours. + in-out property dark-mode <=> Theme.dark; + callback toggle-theme(); // flip dark ↔ light + in-out property sftp-panel-height: max(240px, root.height * 0.4); // default: files 2/5, terminal 3/5 + in-out property terminal-font-size: 13px; + in-out property sftp-file-font-size: 16px; + in-out property sftp-name-column-width: 180px; + in-out property sftp-name-width-manual: false; + callback sftp-name-column-width-changed(float); + callback sftp-name-column-auto(); + callback pick-download-dir(); // open a folder picker + callback open-download-dir(); // reveal the folder in the OS + callback clear-transfers(); // wipe the transfer history + callback set-language(string); // switch UI language ("zh" / "en") + in-out property max-tabs-text: "10"; + in-out property terminal-font-size-text: "13"; + in-out property sftp-font-size-text: "16"; + in-out property background-path-text; + in-out property background-mode: 1; // 0 none, 1 terminal, 2 fullscreen + in-out property background-blur-text: "3"; + in-out property terminal-opacity-text: "88"; + in-out property ui-opacity-text: "72"; + in-out property terminal-background-opacity: 0.88; + in-out property ui-background-opacity: 0.72; + in-out property ui-debug-ids: false; + in property background-image; + in-out property config-dir-text; + callback app-config-save(string, string, string, int, string, string, string, bool, string); + callback app-config-pick-dir(); + callback app-background-pick(); + callback app-background-clear(); + callback debug-click(string, string); + property fullscreen-background-active: root.background-mode == 2 && root.background-path-text != ""; + + // --- Session list ------------------------------------------------------ + in property <[SessionInfo]> sessions; + in property <[ConnectionFolderInfo]> connection-folders; + in property <[string]> connection-folder-names; + + // --- Tabs -------------------------------------------------------------- + in property <[TabInfo]> tabs; + in-out property active-tab-id: "welcome"; + in-out property tab-visible-start: 0; + in property <[TerminalState]> terminals; + + // --- Dialog state ------------------------------------------------------ + in-out property dialog-open: false; + in-out property dialog-editing: false; + in-out property dialog-id; + in-out property dialog-name; + in-out property dialog-group: "Default"; + in-out property dialog-host; + in-out property dialog-port: "22"; + in-out property dialog-user: "root"; + in-out property dialog-auth: "password"; + in-out property dialog-password; + in-out property dialog-key-path; + in-out property dialog-proxy; + in-out property dialog-kind: "ssh"; + in-out property dialog-serial-port; + in-out property dialog-baud: "115200"; + in-out property dialog-data-bits: "8"; + in-out property dialog-stop-bits: "1"; + in-out property dialog-parity: "none"; + in-out property dialog-flow: "none"; + in-out property folder-dialog-open: false; + in-out property folder-dialog-mode: "create"; + in-out property folder-edit-original; + in-out property folder-draft; + in-out property folder-error; + + // Delete-confirmation modal state (#28). + in-out property confirm-delete-open: false; + in-out property confirm-delete-tab; + in-out property confirm-delete-path; + + // --- Callbacks up to Rust --------------------------------------------- + callback new-session-clicked(); + callback connection-folder-create(string); + callback connection-folder-rename(string, string); + callback connection-folder-delete(string); + callback connection-folder-toggle(string); + callback session-move-folder(string, string); + callback import-ssh-config(); // import hosts from ~/.ssh/config + in-out property ssh-import-hint; // result text shown after import + callback connect-session(string /* session-id */); + callback edit-session(string /* session-id */); + callback remove-session(string /* session-id */); + callback session-dialog-submit(SessionDraft); + callback session-dialog-cancel(); + callback session-dialog-pick-key(); + + callback tab-selected(string); + callback tab-closed(string); + callback new-tab-clicked(); + callback previous-tab-clicked(); + callback next-tab-clicked(); + // Asks Rust to recompute the sidebar (status + resources) for whatever tab + // is now active. Fired on every active-tab change, UI- or code-driven. + callback refresh-sidebar(); + changed active-tab-id => { root.refresh-sidebar(); } + + // Raw keystroke forwarding: Rust converts key+modifiers to PTY bytes. + callback send-key(string /* tab-id */, string /* key */, bool /* ctrl */, bool /* alt */, bool /* shift */); + // Notifies Rust of a PTY resize (pixel dimensions; Rust converts to cols/rows). + callback terminal-resize(string /* tab-id */, float /* width-px */, float /* height-px */); + + // --- SFTP callbacks --------------------------------------------------- + // Navigate to a new remote directory (or ".." to go up). + callback sftp-navigate(string /* tab-id */, string /* path */); + // Trigger a download: Rust opens a save-folder dialog then transfers. + callback sftp-download(string /* tab-id */, string /* remote-path */); + // Trigger an upload: Rust opens a file-picker dialog then transfers. + callback sftp-upload-clicked(string /* tab-id */, string /* remote-dir */); + // Refresh the current directory listing. + callback sftp-refresh(string /* tab-id */, string /* path */); + // Toggle expand/collapse of a tree node (also navigates to that directory). + callback sftp-tree-expand(string /* tab-id */, string /* path */); + callback sftp-delete(string /* tab-id */, string /* path */); + callback sftp-rename(string /* tab-id */, string /* path */, string /* new-name */); + callback sftp-edit(string /* tab-id */, string /* path */); + + // --- Clipboard callbacks ---------------------------------------------- + // Paste clipboard text into the active terminal. + callback paste-from-clipboard(string /* tab-id */); + // Copy the current terminal screen buffer to the clipboard. + callback copy-terminal-text(string /* tab-id */); + // Clear a session's terminal buffer (its visible screen + scrollback). + callback clear-terminal(string /* tab-id */); + // Recompute search highlights for a session's visible screen. + callback find-query-changed(string /* tab-id */, string /* query */); + // Scroll a session's scrollback history by N lines (+ = up/older). + callback terminal-scroll(string /* tab-id */, int /* delta-lines */); + // Drag-selection lifecycle (grid row/col coordinates). + callback term-select-start(string /* tab-id */, int /* row */, int /* col */); + callback term-select-update(string /* tab-id */, int /* row */, int /* col */); + callback term-select-end(string /* tab-id */); + callback term-select-autoscroll(string /* tab-id */, int /* dir */); + callback command-category-select(string /* category-id */); + callback command-category-add(string /* name */); + callback command-category-rename(string /* category-id */, string /* name */); + callback command-category-delete(string /* category-id */); + callback command-new(string /* category-id */); + callback command-select(string /* command-id */); + callback command-save(string /* command-id */, string /* category-id */, string /* name */, string /* command */, bool /* append-cr */); + callback command-delete(string /* command-id */); + callback command-run(string /* tab-id */, string /* command-id */, string /* p1 */, string /* p2 */, string /* p3 */, string /* p4 */, string /* p5 */); + callback command-move(string /* command-id */, string /* category-id */); + callback terminal-mouse-event(string /* tab-id */, string /* kind */, int /* button */, int /* row */, int /* col */); + + if root.fullscreen-background-active : Image { + width: parent.width; + height: parent.height; + source: root.background-image; + image-fit: cover; + } + + Rectangle { + width: parent.width; + height: parent.height; + background: root.fullscreen-background-active + ? Theme.bg-root.with-alpha(root.ui-background-opacity) + : Theme.bg-root; + } + + HorizontalLayout { + spacing: 0; + + // Left sidebar + Sidebar { + app-background-active: root.fullscreen-background-active; + ui-opacity: root.ui-background-opacity; + connection-state: root.connection-state; + conn-state: root.conn-state; + resource-title: root.resource-title; + cpu-percent: root.cpu-percent; + mem-percent: root.mem-percent; + swap-percent: root.swap-percent; + cpu-detail: root.cpu-detail; + mem-detail: root.mem-detail; + swap-detail: root.swap-detail; + net-top-up: root.net-top-up; + net-top-down: root.net-top-down; + net-top-history: root.net-top-history; + net-ifaces: root.net-ifaces; + net-selected: root.net-selected; + net-show-selector: root.net-show-selector; + disks: root.disks; + processes: root.processes; + select-net-iface(i) => { root.select-net-iface(i); } + } + + // Right side: tabs + content + VerticalLayout { + spacing: 0; + horizontal-stretch: 1; + + TabBar { + app-background-active: root.fullscreen-background-active; + ui-opacity: root.ui-background-opacity; + tabs: root.tabs; + active-id: root.active-tab-id; + visible-start <=> root.tab-visible-start; + tab-selected(id) => { + if root.ui-debug-ids { root.debug-click("tabbar", "tab-selected"); } + root.active-tab-id = id; + root.tab-selected(id); + } + tab-closed(id) => { + if root.ui-debug-ids { root.debug-click("tabbar", "tab-closed"); } + root.tab-closed(id); + } + new-tab() => { + if root.ui-debug-ids { root.debug-click("tabbar", "new-tab"); } + root.new-tab-clicked(); + } + previous-tab() => { + if root.ui-debug-ids { root.debug-click("tabbar", "previous-tab"); } + root.previous-tab-clicked(); + } + next-tab() => { + if root.ui-debug-ids { root.debug-click("tabbar", "next-tab"); } + root.next-tab-clicked(); + } + } + + // Content area swaps based on active tab kind. Children are + // stacked, not laid out horizontally, so hidden terminal tabs never + // reserve half of the workspace. + Rectangle { + vertical-stretch: 1; + background: root.fullscreen-background-active + ? Theme.bg-root.with-alpha(root.ui-background-opacity) + : Theme.bg-root; + clip: true; + + // Welcome tab + if root.active-tab-id == "welcome" : Welcome { + width: parent.width; + height: parent.height; + app-background-active: root.fullscreen-background-active; + ui-opacity: root.ui-background-opacity; + sessions: root.sessions; + folders: root.connection-folders; + all-folder-names: root.connection-folder-names; + import-hint: root.ssh-import-hint; + debug-ids: false; + new-session => { + if root.ui-debug-ids { root.debug-click("welcome", "new-session"); } + root.new-session-clicked(); + } + new-folder => { + if root.ui-debug-ids { root.debug-click("welcome", "new-folder"); } + root.folder-dialog-mode = "create"; + root.folder-edit-original = ""; + root.folder-draft = ""; + root.folder-error = ""; + root.folder-dialog-open = true; + } + rename-folder(name) => { + if root.ui-debug-ids { root.debug-click("welcome", "rename-folder"); } + root.folder-dialog-mode = "rename"; + root.folder-edit-original = name; + root.folder-draft = name; + root.folder-error = ""; + root.folder-dialog-open = true; + } + delete-folder(name) => { + if root.ui-debug-ids { root.debug-click("welcome", "delete-folder"); } + root.connection-folder-delete(name); + } + toggle-folder(name) => { + if root.ui-debug-ids { root.debug-click("welcome", "toggle-folder"); } + root.connection-folder-toggle(name); + } + move-session-folder(id, folder) => { + if root.ui-debug-ids { root.debug-click("welcome", "move-session-folder"); } + root.session-move-folder(id, folder); + } + import-ssh-config => { + if root.ui-debug-ids { root.debug-click("welcome", "import-ssh-config"); } + root.import-ssh-config(); + } + connect-session(id) => { + if root.ui-debug-ids { root.debug-click("welcome", "connect-session"); } + root.connect-session(id); + } + edit-session(id) => { + if root.ui-debug-ids { root.debug-click("welcome", "edit-session"); } + root.edit-session(id); + } + remove-session(id) => { + if root.ui-debug-ids { root.debug-click("welcome", "remove-session"); } + root.remove-session(id); + } + } + + // Terminal tabs. Every terminal view fills the same workspace; + // only the active tab is visible and allowed to emit resize + // events from its own timer. + for term[i] in root.terminals : TerminalView { + x: 0px; + y: 0px; + width: parent.width; + height: parent.height; + visible: term.tab-id == root.active-tab-id; + tab-id: term.id; + status-line: term.status; + spans: term.spans; + cursor-row: term.cursor-row; + cursor-col: term.cursor-col; + rows-used: term.rows-used; + is-alt-screen: term.is-alt-screen; + mouse-tracking: term.mouse-tracking; + find-matches: term.find-matches; + selection: term.selection; + sftp-panel-height <=> root.sftp-panel-height; + terminal-font-size: root.terminal-font-size; + click-debug-enabled: root.ui-debug-ids; + background-path: root.background-path-text; + background-mode: root.background-mode; + background-image: root.background-image; + app-background-active: root.fullscreen-background-active; + terminal-bg-opacity: root.terminal-background-opacity; + ui-opacity: root.ui-background-opacity; + sftp-file-font-size: root.sftp-file-font-size; + sftp-name-column-width <=> root.sftp-name-column-width; + sftp-name-width-manual <=> root.sftp-name-width-manual; + sftp-path: term.sftp-path; + sftp-entries: term.sftp-entries; + sftp-status: term.sftp-status; + sftp-loading: term.sftp-loading; + sftp-tree-nodes: term.sftp-tree-nodes; + command-categories: root.command-categories; + command-items: root.command-items; + command-selected-category <=> root.command-selected-category; + command-selected-id <=> root.command-selected-id; + command-category-draft <=> root.command-category-draft; + command-edit-id <=> root.command-edit-id; + command-edit-name <=> root.command-edit-name; + command-edit-command <=> root.command-edit-command; + command-edit-append-cr <=> root.command-edit-append-cr; + command-param1-name <=> root.command-param1-name; + command-param1-value <=> root.command-param1-value; + command-param2-name <=> root.command-param2-name; + command-param2-value <=> root.command-param2-value; + command-param3-name <=> root.command-param3-name; + command-param3-value <=> root.command-param3-value; + command-param4-name <=> root.command-param4-name; + command-param4-value <=> root.command-param4-value; + command-param5-name <=> root.command-param5-name; + command-param5-value <=> root.command-param5-value; + send-key(key, ctrl, alt, shift) => { root.send-key(term.id, key, ctrl, alt, shift) } + terminal-resize(w, h) => { root.terminal-resize(term.id, w, h) } + sftp-navigate(path) => { root.sftp-navigate(term.id, path); } + sftp-download(path) => { root.sftp-download(term.id, path); } + sftp-upload-clicked(dir) => { root.sftp-upload-clicked(term.id, dir); } + sftp-refresh(path) => { root.sftp-refresh(term.id, path); } + sftp-tree-expand(path) => { root.sftp-tree-expand(term.id, path); } + // Gate delete behind an in-app confirmation (#28): stash the + // target and open the modal; the real delete fires on confirm. + sftp-delete(path) => { + root.confirm-delete-tab = term.id; + root.confirm-delete-path = path; + root.confirm-delete-open = true; + } + sftp-rename(path, name) => { root.sftp-rename(term.id, path, name); } + sftp-edit(path) => { root.sftp-edit(term.id, path); } + sftp-name-column-width-changed(px) => { root.sftp-name-column-width-changed(px); } + sftp-name-column-auto() => { root.sftp-name-column-auto(); } + paste-from-clipboard() => { root.paste-from-clipboard(term.id); } + copy-terminal-text() => { root.copy-terminal-text(term.id); } + clear-terminal() => { root.clear-terminal(term.id); } + find-query-changed(q) => { root.find-query-changed(term.id, q); } + terminal-scroll(d) => { root.terminal-scroll(term.id, d); } + select-start(r, c) => { root.term-select-start(term.id, r, c); } + select-update(r, c) => { root.term-select-update(term.id, r, c); } + select-end() => { root.term-select-end(term.id); } + select-autoscroll(dir) => { root.term-select-autoscroll(term.id, dir); } + command-category-select(id) => { root.command-category-select(id); } + command-category-add(name) => { root.command-category-add(name); } + command-category-rename(id, name) => { root.command-category-rename(id, name); } + command-category-delete(id) => { root.command-category-delete(id); } + command-new(category) => { root.command-new(category); } + command-select(id) => { root.command-select(id); } + command-save(id, category, name, command, append_cr) => { + root.command-save(id, category, name, command, append_cr); + } + command-delete(id) => { root.command-delete(id); } + command-run(id, p1, p2, p3, p4, p5) => { + root.command-run(term.id, id, p1, p2, p3, p4, p5); + } + command-move(id, category) => { root.command-move(id, category); } + terminal-mouse-event(kind, button, row, col) => { + root.terminal-mouse-event(term.id, kind, button, row, col); + } + debug-click(parent, button) => { root.debug-click(parent, button); } + } + } + } + } + + // --- Top-right buttons: [☀/🌙 theme] [↓ download] [⚙ settings] ---------- + // Settings (gear) — far right. + Rectangle { + x: root.width - 34px; + y: 8px; + width: 26px; + height: 26px; + border-radius: Theme.radius-sm; + background: gear-ta.has-hover || root.settings-open ? Theme.bg-hover : transparent; + gear-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + if root.ui-debug-ids { root.debug-click("topbar", "settings"); } + root.settings-open = !root.settings-open; + root.download-open = false; + root.config-open = false; + } + } + Text { + width: parent.width; + height: parent.height; + text: "\u{E8B8}"; // Material Icons settings (gear) + font-family: "Material Icons"; + color: Theme.text-secondary; + font-size: Theme.fs-md; + horizontal-alignment: center; + vertical-alignment: center; + } + } + // Download manager — left of settings. + Rectangle { + x: root.width - 64px; + y: 8px; + width: 26px; + height: 26px; + border-radius: Theme.radius-sm; + background: dl-ta.has-hover || root.download-open ? Theme.bg-hover : transparent; + dl-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + if root.ui-debug-ids { root.debug-click("topbar", "downloads"); } + root.download-open = !root.download-open; + root.settings-open = false; + root.config-open = false; + } + } + Text { + width: parent.width; + height: parent.height; + text: "\u{E2C4}"; // Material Icons file_download + font-family: "Material Icons"; + color: Theme.text-secondary; + font-size: Theme.fs-md; + horizontal-alignment: center; + vertical-alignment: center; + } + } + // Theme toggle (sun = currently dark → click for light; + // moon = currently light → click for dark) — left of download. + Rectangle { + x: root.width - 94px; + y: 8px; + width: 26px; + height: 26px; + border-radius: Theme.radius-sm; + background: theme-ta.has-hover ? Theme.bg-hover : transparent; + theme-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + if root.ui-debug-ids { root.debug-click("topbar", "toggle-theme"); } + root.toggle-theme(); + } + } + Text { + width: parent.width; + height: parent.height; + // light_mode ☀ when dark (click → go light) + // dark_mode 🌙 when light (click → go dark) + text: Theme.dark ? "\u{E518}" : "\u{E51C}"; + font-family: "Material Icons"; + color: Theme.dark ? #f5c542 : #5856d6; // amber sun / indigo moon + font-size: Theme.fs-md; + horizontal-alignment: center; + vertical-alignment: center; + } + } + + // --- Download manager popup ------------------------------------------- + // Always present; toggled via `visible` (avoids destroying interactive + // elements mid-event, which crashes when created/removed at the Window root). + if root.download-open : TouchArea { + width: parent.width; + height: parent.height; + clicked => { root.download-open = false; } + } + + Rectangle { + x: parent.width - 364px; + y: 40px; + width: 350px; + height: 400px; + visible: root.download-open; + background: Theme.bg-panel; + border-radius: Theme.radius-md; + border-width: 1px; + border-color: Theme.border-strong; + drop-shadow-blur: 16px; + drop-shadow-color: #00000070; + + // Swallow pointer events so right-clicks don't fall through to the + // terminal's context menu underneath. + TouchArea {} + + VerticalLayout { + padding: 14px; + spacing: 10px; + + HorizontalLayout { + Text { + text: @tr("Download settings"); + color: Theme.text-primary; + font-size: Theme.fs-md; + font-weight: 600; + horizontal-stretch: 1; + vertical-alignment: center; + } + Rectangle { + width: 20px; height: 20px; + border-radius: Theme.radius-sm; + background: close-ta.has-hover ? Theme.bg-hover : transparent; + close-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.download-open = false; } + } + Text { text: "\u{E5CD}"; font-family: "Material Icons"; + color: Theme.text-secondary; font-size: Theme.fs-sm; + width: parent.width; height: parent.height; + horizontal-alignment: center; vertical-alignment: center; } + } + } + + Text { + text: @tr("Save downloads to:"); + color: Theme.text-secondary; + font-size: Theme.fs-sm; + } + + // Current path display + Rectangle { + height: 26px; + background: Theme.bg-root; + border-radius: Theme.radius-sm; + Text { + x: 8px; + width: parent.width - 16px; + height: parent.height; + text: root.download-dir == "" ? @tr("(ask every time)") : root.download-dir; + color: root.download-dir == "" ? Theme.text-muted : Theme.text-primary; + font-size: Theme.fs-sm; + vertical-alignment: center; + overflow: elide; + } + } + + HorizontalLayout { + spacing: 8px; + alignment: end; + + Rectangle { + width: 96px; height: 28px; + border-radius: Theme.radius-sm; + background: pick-ta.has-hover ? Theme.accent-hover : Theme.accent; + pick-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + if root.ui-debug-ids { root.debug-click("downloads", "choose-folder"); } + root.pick-download-dir(); + } + } + Text { text: @tr("Choose folder"); color: #ffffff; font-size: Theme.fs-sm; + width: parent.width; height: parent.height; + horizontal-alignment: center; vertical-alignment: center; } + } + Rectangle { + width: 76px; height: 28px; + border-radius: Theme.radius-sm; + background: open-ta.has-hover ? Theme.bg-hover : Theme.bg-elevated; + open-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + if root.ui-debug-ids { root.debug-click("downloads", "open-folder"); } + root.open-download-dir(); + } + } + Text { text: @tr("Open folder"); color: Theme.text-primary; font-size: Theme.fs-sm; + width: parent.width; height: parent.height; + horizontal-alignment: center; vertical-alignment: center; } + } + } + + Rectangle { height: 1px; background: Theme.border-subtle; } + + // Transfer records (download/upload progress + history). + HorizontalLayout { + Text { + text: @tr("Transfers"); + color: Theme.text-secondary; + font-size: Theme.fs-sm; + horizontal-stretch: 1; + vertical-alignment: center; + } + Rectangle { + width: 44px; height: 20px; + border-radius: Theme.radius-sm; + background: clr-ta.has-hover ? Theme.bg-hover : transparent; + clr-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + if root.ui-debug-ids { root.debug-click("downloads", "clear-transfers"); } + root.clear-transfers(); + } + } + Text { text: @tr("Clear"); color: Theme.text-muted; font-size: Theme.fs-xs; + width: parent.width; height: parent.height; + horizontal-alignment: center; vertical-alignment: center; } + } + } + + Flickable { + vertical-stretch: 1; + viewport-width: self.width; + viewport-height: max(self.height, xfer-col.preferred-height); + xfer-col := VerticalLayout { + alignment: start; + spacing: 4px; + + if root.transfers.length == 0 : Text { + text: @tr("No transfers yet"); + color: Theme.text-muted; + font-size: Theme.fs-xs; + } + + for t in root.transfers : Rectangle { + height: 36px; + border-radius: Theme.radius-sm; + background: root.fullscreen-background-active ? Theme.bg-root.with-alpha(root.ui-background-opacity) : Theme.bg-root; + VerticalLayout { + padding-left: 8px; padding-right: 8px; + padding-top: 4px; padding-bottom: 4px; + spacing: 3px; + HorizontalLayout { + spacing: 4px; + Text { + text: (t.is-upload ? "↑ " : "↓ ") + t.name; + color: Theme.text-primary; + font-size: Theme.fs-xs; + horizontal-stretch: 1; + overflow: elide; + vertical-alignment: center; + } + Text { + text: t.detail; + color: t.state == 2 ? Theme.danger + : t.state == 1 ? Theme.success + : Theme.text-muted; + font-size: Theme.fs-xs; + vertical-alignment: center; + } + } + // progress bar + Rectangle { + height: 4px; + border-radius: 2px; + background: Theme.bg-elevated; + Rectangle { + x: 0; y: 0; height: parent.height; + width: parent.width * clamp(t.percent, 0.0, 1.0); + border-radius: 2px; + background: t.state == 2 ? Theme.danger + : t.state == 1 ? Theme.success + : Theme.accent; + } + } + } + } + } + } + } + } + + // --- Settings menu popup (top-right) ---------------------------------- + Rectangle { + x: parent.width - 196px; + y: 40px; + width: 198px; + height: 134px; + visible: root.settings-open; + background: Theme.bg-panel; + border-radius: Theme.radius-md; + border-width: 1px; + border-color: Theme.border-strong; + drop-shadow-blur: 14px; + drop-shadow-color: #00000060; + + TouchArea {} // swallow events so they don't reach the terminal + + VerticalLayout { + padding: 4px; + spacing: 1px; + // Import hosts from ~/.ssh/config. + Rectangle { + height: 28px; + border-radius: Theme.radius-sm; + background: import-ta.has-hover ? Theme.bg-hover : transparent; + import-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + if root.ui-debug-ids { root.debug-click("settings", "import-ssh-config"); } + root.settings-open = false; + root.import-ssh-config(); + } + } + HorizontalLayout { + height: parent.height; + padding-left: 8px; spacing: 8px; + Text { width: 18px; height: parent.height; text: "\u{E890}"; font-family: "Material Icons"; // input/import + color: Theme.text-secondary; font-size: Theme.fs-sm; + horizontal-alignment: center; vertical-alignment: center; } + Text { text: @tr("Import ~/.ssh/config"); color: Theme.text-primary; font-size: Theme.fs-md; + height: parent.height; vertical-alignment: center; horizontal-stretch: 1; } + } + } + // Language toggle: shows the language you'll switch TO. + Rectangle { + height: 28px; + border-radius: Theme.radius-sm; + background: lang-ta.has-hover ? Theme.bg-hover : transparent; + lang-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + if root.ui-debug-ids { root.debug-click("settings", "language"); } + root.settings-open = false; + root.set-language(root.lang-en ? "zh" : "en"); + } + } + HorizontalLayout { + height: parent.height; + padding-left: 8px; spacing: 8px; + Text { width: 18px; height: parent.height; text: "\u{E894}"; font-family: "Material Icons"; // language/translate + color: Theme.text-secondary; font-size: Theme.fs-sm; + horizontal-alignment: center; vertical-alignment: center; } + Text { text: root.lang-en ? "切换到中文" : "Switch to English"; + color: Theme.text-primary; font-size: Theme.fs-md; + height: parent.height; vertical-alignment: center; horizontal-stretch: 1; } + } + } + Rectangle { + height: 28px; + border-radius: Theme.radius-sm; + background: about-ta.has-hover ? Theme.bg-hover : transparent; + about-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + if root.ui-debug-ids { root.debug-click("settings", "about"); } + root.settings-open = false; + root.about-open = true; + } + } + HorizontalLayout { + height: parent.height; + padding-left: 8px; spacing: 8px; + Text { width: 18px; height: parent.height; text: "\u{E88E}"; font-family: "Material Icons"; // Info + color: Theme.text-secondary; font-size: Theme.fs-sm; + horizontal-alignment: center; vertical-alignment: center; } + Text { text: @tr("About"); color: Theme.text-primary; font-size: Theme.fs-md; + height: parent.height; vertical-alignment: center; horizontal-stretch: 1; } + } + } + Rectangle { + height: 28px; + border-radius: Theme.radius-sm; + background: config-ta.has-hover ? Theme.bg-hover : transparent; + config-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + if root.ui-debug-ids { root.debug-click("settings", "configuration"); } + root.settings-open = false; + root.config-open = true; + } + } + HorizontalLayout { + height: parent.height; + padding-left: 8px; spacing: 8px; + Text { width: 18px; height: parent.height; text: "\u{E8B8}"; font-family: "Material Icons"; + color: Theme.text-secondary; font-size: Theme.fs-sm; + horizontal-alignment: center; vertical-alignment: center; } + Text { text: @tr("Configuration"); color: Theme.text-primary; font-size: Theme.fs-md; + height: parent.height; vertical-alignment: center; horizontal-stretch: 1; } + } + } + } + } + + // --- Configuration dialog -------------------------------------------- + Rectangle { + width: parent.width; + height: parent.height; + visible: root.config-open; + background: #00000070; + TouchArea { clicked => { root.config-open = false; } } + + Rectangle { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + width: min(640px, parent.width - 48px); + height: min(500px, parent.height - 48px); + background: Theme.bg-panel; + border-radius: Theme.radius-md; + border-width: 1px; + border-color: Theme.border-strong; + drop-shadow-blur: 20px; + drop-shadow-color: #00000080; + + TouchArea {} + + VerticalLayout { + padding: 16px; + spacing: 10px; + + HorizontalLayout { + Text { + text: @tr("Configuration"); + color: Theme.text-primary; + font-size: Theme.fs-md; + font-weight: 600; + vertical-alignment: center; + horizontal-stretch: 1; + } + Rectangle { + width: 22px; height: 22px; + border-radius: Theme.radius-sm; + background: cfg-close-ta.has-hover ? Theme.bg-hover : transparent; + cfg-close-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.config-open = false; } + } + Text { text: "\u{E5CD}"; font-family: "Material Icons"; + color: Theme.text-secondary; font-size: Theme.fs-sm; + width: parent.width; height: parent.height; + horizontal-alignment: center; vertical-alignment: center; } + } + } + + HorizontalLayout { + height: 30px; + spacing: 10px; + Text { + width: 148px; + height: parent.height; + text: @tr("Maximum terminal windows"); + color: Theme.text-secondary; + font-size: Theme.fs-sm; + vertical-alignment: center; + overflow: elide; + } + ConfigTextField { + value <=> root.max-tabs-text; + field-width: 86px; + } + Rectangle { horizontal-stretch: 1; } + } + + HorizontalLayout { + height: 30px; + spacing: 10px; + Text { + width: 148px; + height: parent.height; + text: @tr("Terminal font size"); + color: Theme.text-secondary; + font-size: Theme.fs-sm; + vertical-alignment: center; + overflow: elide; + } + ConfigTextField { + value <=> root.terminal-font-size-text; + field-width: 72px; + } + Text { + width: 26px; + height: parent.height; + text: "px"; + color: Theme.text-muted; + font-size: Theme.fs-sm; + vertical-alignment: center; + } + Text { + width: 138px; + height: parent.height; + text: @tr("File manager font size"); + color: Theme.text-secondary; + font-size: Theme.fs-sm; + vertical-alignment: center; + overflow: elide; + } + ConfigTextField { + value <=> root.sftp-font-size-text; + field-width: 72px; + } + Text { + width: 26px; + height: parent.height; + text: "px"; + color: Theme.text-muted; + font-size: Theme.fs-sm; + vertical-alignment: center; + } + } + + HorizontalLayout { + height: 30px; + spacing: 10px; + Text { + width: 148px; + height: parent.height; + text: @tr("Background image"); + color: Theme.text-secondary; + font-size: Theme.fs-sm; + vertical-alignment: center; + overflow: elide; + } + ConfigValueBox { + horizontal-stretch: 1; + value: root.background-path-text; + empty-text: @tr("No background image"); + } + ConfigButton { + label: @tr("Choose"); + button-width: 82px; + clicked => { + if root.ui-debug-ids { root.debug-click("configuration", "choose-background"); } + root.app-background-pick(); + } + } + ConfigButton { + label: @tr("Clear"); + button-width: 72px; + clicked => { + if root.ui-debug-ids { root.debug-click("configuration", "clear-background"); } + root.app-background-clear(); + } + } + } + + HorizontalLayout { + height: 30px; + spacing: 10px; + Text { + width: 148px; + height: parent.height; + text: @tr("Background scope"); + color: Theme.text-secondary; + font-size: Theme.fs-sm; + vertical-alignment: center; + overflow: elide; + } + ConfigButton { + label: @tr("Terminal"); + button-width: 96px; + selected: root.background-mode == 1; + clicked => { + if root.ui-debug-ids { root.debug-click("configuration", "background-terminal"); } + root.background-mode = 1; + } + } + ConfigButton { + label: @tr("Fullscreen"); + button-width: 104px; + selected: root.background-mode == 2; + clicked => { + if root.ui-debug-ids { root.debug-click("configuration", "background-fullscreen"); } + root.background-mode = 2; + } + } + Rectangle { horizontal-stretch: 1; } + Text { + height: parent.height; + text: @tr("Blur"); + color: Theme.text-secondary; + font-size: Theme.fs-sm; + vertical-alignment: center; + } + ConfigTextField { + value <=> root.background-blur-text; + field-width: 58px; + } + } + + HorizontalLayout { + height: 30px; + spacing: 10px; + Text { + width: 148px; + height: parent.height; + text: @tr("Terminal opacity"); + color: Theme.text-secondary; + font-size: Theme.fs-sm; + vertical-alignment: center; + overflow: elide; + } + ConfigTextField { + value <=> root.terminal-opacity-text; + field-width: 68px; + } + Text { + width: 24px; + height: parent.height; + text: "%"; + color: Theme.text-muted; + font-size: Theme.fs-sm; + vertical-alignment: center; + } + Text { + width: 108px; + height: parent.height; + text: @tr("UI opacity"); + color: Theme.text-secondary; + font-size: Theme.fs-sm; + vertical-alignment: center; + overflow: elide; + } + ConfigTextField { + value <=> root.ui-opacity-text; + field-width: 68px; + } + Text { + width: 24px; + height: parent.height; + text: "%"; + color: Theme.text-muted; + font-size: Theme.fs-sm; + vertical-alignment: center; + } + Rectangle { horizontal-stretch: 1; } + } + + HorizontalLayout { + height: 30px; + spacing: 10px; + Text { + width: 148px; + height: parent.height; + text: @tr("Click log"); + color: Theme.text-secondary; + font-size: Theme.fs-sm; + vertical-alignment: center; + overflow: elide; + } + ConfigButton { + label: root.ui-debug-ids ? @tr("On") : @tr("Off"); + button-width: 76px; + selected: root.ui-debug-ids; + clicked => { + root.ui-debug-ids = !root.ui-debug-ids; + if root.ui-debug-ids { root.debug-click("configuration", "click-log-on"); } + } + } + Text { + text: @tr("Writes clicked button/parent ids to the debug log"); + color: Theme.text-muted; + font-size: Theme.fs-xs; + vertical-alignment: center; + horizontal-stretch: 1; + overflow: elide; + } + } + + HorizontalLayout { + height: 30px; + spacing: 10px; + Text { + width: 148px; + height: parent.height; + text: @tr("Configuration directory"); + color: Theme.text-secondary; + font-size: Theme.fs-sm; + vertical-alignment: center; + overflow: elide; + } + ConfigValueBox { + horizontal-stretch: 1; + value: root.config-dir-text; + empty-text: ""; + } + ConfigButton { + label: @tr("Choose"); + button-width: 82px; + clicked => { + if root.ui-debug-ids { root.debug-click("configuration", "choose-config-dir"); } + root.app-config-pick-dir(); + } + } + } + + HorizontalLayout { + height: 30px; + spacing: 10px; + Rectangle { horizontal-stretch: 1; } + ConfigButton { + label: @tr("Cancel"); + button-width: 82px; + clicked => { + if root.ui-debug-ids { root.debug-click("configuration", "cancel"); } + root.config-open = false; + } + } + ConfigButton { + label: @tr("Save"); + button-width: 82px; + primary: true; + clicked => { + if root.ui-debug-ids { root.debug-click("configuration", "save"); } + root.app-config-save( + root.max-tabs-text, + root.terminal-font-size-text, + root.sftp-font-size-text, + root.background-mode, + root.background-blur-text, + root.terminal-opacity-text, + root.ui-opacity-text, + root.ui-debug-ids, + root.config-dir-text); + } + } + } + } + } + } + + // --- About dialog (centered on the window, with dim backdrop) --------- + Rectangle { + width: parent.width; + height: parent.height; + visible: root.about-open; + background: #00000080; // dim backdrop + TouchArea { clicked => { root.about-open = false; } } + + Rectangle { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + width: 380px; + height: 440px; + background: Theme.bg-panel; + border-radius: Theme.radius-md; + border-width: 1px; + border-color: Theme.border-strong; + drop-shadow-blur: 24px; + drop-shadow-color: #000000a0; + + // Swallow clicks so the backdrop doesn't close when clicking inside. + TouchArea {} + + VerticalLayout { + padding: 18px; + spacing: 8px; + + HorizontalLayout { + Text { + text: @tr("About meatshell"); + color: Theme.text-primary; + font-size: Theme.fs-lg; + font-weight: 700; + horizontal-stretch: 1; + vertical-alignment: center; + } + Rectangle { + width: 22px; height: 22px; + border-radius: Theme.radius-sm; + background: ab-close-ta.has-hover ? Theme.bg-hover : transparent; + ab-close-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.about-open = false; } + } + Text { text: "\u{E5CD}"; font-family: "Material Icons"; + color: Theme.text-secondary; font-size: Theme.fs-sm; + width: parent.width; height: parent.height; + horizontal-alignment: center; vertical-alignment: center; } + } + } + + Text { + text: @tr("A lightweight Rust + Slint SSH terminal client by 'lil meatball'."); + color: Theme.text-secondary; + font-size: Theme.fs-sm; + wrap: word-wrap; + } + Text { + text: @tr("Open source · MIT / Apache-2.0"); + color: Theme.text-muted; + font-size: Theme.fs-xs; + } + + Rectangle { height: 1px; background: Theme.border-subtle; } + + Text { + text: @tr("Open-source libraries used"); + color: Theme.text-secondary; + font-size: Theme.fs-sm; + } + + Flickable { + vertical-stretch: 1; + viewport-width: self.width; + viewport-height: max(self.height, libs-col.preferred-height); + libs-col := VerticalLayout { + alignment: start; + spacing: 4px; + for lib in root.about-libs : Text { + text: lib; + color: Theme.text-primary; + font-size: Theme.fs-xs; + wrap: no-wrap; + overflow: elide; + } + } + } + } + } + } + + // --- Connection folder dialog ---------------------------------------- + Rectangle { + width: parent.width; + height: parent.height; + visible: root.folder-dialog-open; + background: #00000070; + TouchArea { clicked => { root.folder-dialog-open = false; } } + + Rectangle { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + width: min(360px, parent.width - 48px); + height: 194px; + background: Theme.bg-panel; + border-radius: Theme.radius-md; + border-width: 1px; + border-color: Theme.border-strong; + drop-shadow-blur: 20px; + drop-shadow-color: #00000080; + + TouchArea {} + + VerticalLayout { + padding: 16px; + spacing: 12px; + + Text { + text: root.folder-dialog-mode == "rename" ? @tr("Rename category") : @tr("New category"); + color: Theme.text-primary; + font-size: Theme.fs-md; + font-weight: 600; + } + + Rectangle { + height: 32px; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: folder-name-input.has-focus ? Theme.accent : Theme.border-subtle; + background: Theme.bg-root; + folder-name-input := TextInput { + x: 10px; + width: parent.width - 20px; + height: parent.height; + text <=> root.folder-draft; + color: Theme.text-primary; + font-size: Theme.fs-md; + single-line: true; + vertical-alignment: center; + edited => { root.folder-error = ""; } + init => { self.focus(); } + key-pressed(e) => { + if (e.text == "\n") { + if (root.folder-dialog-mode == "rename") { + root.connection-folder-rename(root.folder-edit-original, root.folder-draft); + } else { + root.connection-folder-create(root.folder-draft); + } + accept + } else { + reject + } + } + } + if root.folder-draft == "" : Text { + x: 10px; + height: parent.height; + text: @tr("Category name"); + color: Theme.text-muted; + font-size: Theme.fs-md; + vertical-alignment: center; + } + } + + Text { + height: 14px; + text: root.folder-error; + color: Theme.danger; + font-size: Theme.fs-xs; + visible: root.folder-error != ""; + } + + HorizontalLayout { + height: 28px; + spacing: 8px; + Rectangle { horizontal-stretch: 1; } + Rectangle { + width: 72px; + height: parent.height; + border-radius: Theme.radius-sm; + background: folder-cancel-ta.has-hover ? Theme.bg-hover : Theme.bg-elevated; + folder-cancel-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.folder-dialog-open = false; } + } + Text { + width: parent.width; + height: parent.height; + text: @tr("Cancel"); + color: Theme.text-primary; + font-size: Theme.fs-sm; + horizontal-alignment: center; + vertical-alignment: center; + } + } + Rectangle { + width: 72px; + height: parent.height; + border-radius: Theme.radius-sm; + background: folder-create-ta.has-hover ? Theme.accent-hover : Theme.accent; + folder-create-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + if (root.folder-dialog-mode == "rename") { + root.connection-folder-rename(root.folder-edit-original, root.folder-draft); + } else { + root.connection-folder-create(root.folder-draft); + } + } + } + Text { + width: parent.width; + height: parent.height; + text: root.folder-dialog-mode == "rename" ? @tr("Rename") : @tr("Create"); + color: #ffffff; + font-size: Theme.fs-sm; + horizontal-alignment: center; + vertical-alignment: center; + } + } + } + } + } + } + + // --- Modal dialog ----------------------------------------------------- + SessionDialog { + width: parent.width; + height: parent.height; + is-open <=> root.dialog-open; + is-editing <=> root.dialog-editing; + draft-id <=> root.dialog-id; + draft-name <=> root.dialog-name; + draft-group <=> root.dialog-group; + draft-host <=> root.dialog-host; + draft-port <=> root.dialog-port; + draft-user <=> root.dialog-user; + draft-auth <=> root.dialog-auth; + draft-password <=> root.dialog-password; + draft-key-path <=> root.dialog-key-path; + draft-proxy <=> root.dialog-proxy; + draft-kind <=> root.dialog-kind; + draft-serial-port <=> root.dialog-serial-port; + draft-baud <=> root.dialog-baud; + draft-data-bits <=> root.dialog-data-bits; + draft-stop-bits <=> root.dialog-stop-bits; + draft-parity <=> root.dialog-parity; + draft-flow <=> root.dialog-flow; + folders: root.connection-folder-names; + submit(draft) => { root.session-dialog-submit(draft); } + cancel => { root.session-dialog-cancel(); } + pick-key-file => { root.session-dialog-pick-key(); } + } + + // --- Delete confirmation (#28) ---------------------------------------- + ConfirmDialog { + width: parent.width; + height: parent.height; + is-open <=> root.confirm-delete-open; + title: @tr("Confirm delete"); + message: @tr("Delete this item? This cannot be undone."); + detail: root.confirm-delete-path; + confirm-label: @tr("Delete"); + confirm => { + root.sftp-delete(root.confirm-delete-tab, root.confirm-delete-path); + root.confirm-delete-open = false; + } + cancel => { root.confirm-delete-open = false; } + } +} diff --git a/ui/confirm_dialog.slint b/ui/confirm_dialog.slint new file mode 100644 index 0000000..e2e960d --- /dev/null +++ b/ui/confirm_dialog.slint @@ -0,0 +1,123 @@ +import { Theme } from "theme.slint"; +import { GhostButton } from "widgets.slint"; + +// A destructive (red) action button, matching PrimaryButton's shape. +component DangerButton inherits Rectangle { + in property text; + callback clicked(); + height: 30px; + min-width: 80px; + border-radius: Theme.radius-sm; + background: touch.pressed ? #c44a4a : (touch.has-hover ? #ec6a6a : Theme.danger); + animate background { duration: 120ms; } + HorizontalLayout { + height: parent.height; + padding-left: 14px; + padding-right: 14px; + alignment: center; + Text { + height: parent.height; + text: root.text; + color: white; + font-size: Theme.fs-md; + font-weight: 600; + horizontal-alignment: center; + vertical-alignment: center; + } + } + touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.clicked(); } + } +} + +// In-app modal confirmation dialog, styled to match the rest of meatshell +// (a dark card over a dimmed backdrop, like SessionDialog). Purely +// presentational: it emits `confirm` / `cancel` and the caller decides what +// to do. Used for the irreversible SFTP delete (#28). +export component ConfirmDialog inherits Rectangle { + in-out property is-open: false; + in property title: @tr("Please confirm"); + in property message; + in property detail; // optional, e.g. the file path + in property confirm-label: @tr("Confirm"); + callback confirm(); + callback cancel(); + + visible: is-open; + background: #000000aa; + + // Backdrop click cancels. + TouchArea { + width: 100%; + height: 100%; + clicked => { root.cancel(); } + } + + Rectangle { + width: 380px; + height: card.preferred-height; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + background: Theme.bg-panel; + border-radius: Theme.radius-lg; + border-width: 1px; + border-color: Theme.border-strong; + drop-shadow-blur: 24px; + drop-shadow-color: #00000080; + + // Swallow clicks inside the card so they don't hit the backdrop. + TouchArea { width: 100%; height: 100%; } + + card := VerticalLayout { + padding: 20px; + spacing: 12px; + + HorizontalLayout { + spacing: 8px; + Text { + text: "⚠"; + color: Theme.danger; + font-size: Theme.fs-xl; + vertical-alignment: center; + } + Text { + text: root.title; + color: Theme.text-primary; + font-size: Theme.fs-xl; + font-weight: 600; + vertical-alignment: center; + } + } + + Text { + text: root.message; + color: Theme.text-secondary; + font-size: Theme.fs-md; + wrap: word-wrap; + } + + if root.detail != "" : Text { + text: root.detail; + color: Theme.text-primary; + font-family: Theme.font-mono; + font-size: Theme.fs-sm; + wrap: word-wrap; + } + + HorizontalLayout { + height: 30px; + alignment: end; + spacing: 8px; + GhostButton { + text: @tr("Cancel"); + clicked => { root.cancel(); } + } + DangerButton { + text: root.confirm-label; + clicked => { root.confirm(); } + } + } + } + } +} diff --git a/ui/fonts/CascadiaMono-Bold.ttf b/ui/fonts/CascadiaMono-Bold.ttf new file mode 100644 index 0000000..7d89b98 Binary files /dev/null and b/ui/fonts/CascadiaMono-Bold.ttf differ diff --git a/ui/fonts/CascadiaMono-Regular.ttf b/ui/fonts/CascadiaMono-Regular.ttf new file mode 100644 index 0000000..18408e4 Binary files /dev/null and b/ui/fonts/CascadiaMono-Regular.ttf differ diff --git a/ui/fonts/MaterialIcons-Regular.ttf b/ui/fonts/MaterialIcons-Regular.ttf new file mode 100644 index 0000000..9d09b0f Binary files /dev/null and b/ui/fonts/MaterialIcons-Regular.ttf differ diff --git a/ui/session_dialog.slint b/ui/session_dialog.slint new file mode 100644 index 0000000..f241c81 --- /dev/null +++ b/ui/session_dialog.slint @@ -0,0 +1,454 @@ +import { Theme } from "theme.slint"; +import { LabeledInput, PrimaryButton, GhostButton } from "widgets.slint"; + +export struct SessionDraft { + id: string, + name: string, + group: string, + kind: string, // "ssh" | "serial" | "telnet" + host: string, + port: int, + user: string, + auth: string, // "password" | "key" + password: string, + private-key-path: string, + proxy: string, // "" | "socks5://host:port" | "http://host:port" + // Serial-only (ignored unless kind == "serial") + serial-port: string, + baud-rate: int, + data-bits: int, + stop-bits: int, + parity: string, // "none" | "odd" | "even" + flow-control: string, // "none" | "hardware" | "software" +} + +// A single segment in a segmented selector (auth toggle, baud presets, …). +component SegOption inherits Rectangle { + in property label; + in property active; + callback clicked(); + height: 30px; + horizontal-stretch: 1; + border-radius: Theme.radius-sm; + border-width: 1px; + background: active ? Theme.accent : Theme.bg-root; + border-color: active ? Theme.accent : Theme.border-subtle; + Text { + width: parent.width; + height: parent.height; + text: root.label; + color: active ? white : Theme.text-primary; + font-size: Theme.fs-md; + horizontal-alignment: center; + vertical-alignment: center; + } + TouchArea { + mouse-cursor: pointer; + clicked => { root.clicked(); } + } +} + +component FolderOption inherits Rectangle { + in property label; + in property active; + callback clicked(); + + height: 30px; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: active ? Theme.accent : Theme.border-subtle; + background: active ? Theme.bg-active : Theme.bg-root; + + HorizontalLayout { + height: parent.height; + padding-left: 10px; + padding-right: 10px; + spacing: 8px; + Text { + width: 18px; + height: parent.height; + text: "\u{E2C7}"; + font-family: "Material Icons"; + color: active ? Theme.accent : Theme.text-muted; + font-size: Theme.fs-sm; + horizontal-alignment: center; + vertical-alignment: center; + } + Text { + height: parent.height; + text: root.label; + color: Theme.text-primary; + font-size: Theme.fs-md; + vertical-alignment: center; + horizontal-stretch: 1; + overflow: elide; + } + if active : Text { + width: 18px; + height: parent.height; + text: "\u{E5CA}"; + font-family: "Material Icons"; + color: Theme.accent; + font-size: Theme.fs-sm; + horizontal-alignment: center; + vertical-alignment: center; + } + } + + TouchArea { + mouse-cursor: pointer; + clicked => { root.clicked(); } + } +} + +// Modal dialog for creating / editing a session (SSH / Serial / Telnet). +// The dialog is purely presentational — it stores values in mutable +// properties and emits `submit` / `cancel` callbacks up to the main window. +export component SessionDialog inherits Rectangle { + in-out property is-open: false; + in-out property is-editing: false; + in-out property draft-id; + in-out property draft-name; + in-out property draft-group: "Default"; + in-out property draft-kind: "ssh"; + in-out property draft-host; + in-out property draft-port: "22"; + in-out property draft-user: "root"; + in-out property draft-auth: "password"; + in-out property draft-password; + in-out property draft-key-path; + in-out property draft-proxy; + in-out property draft-serial-port; + in-out property draft-baud: "115200"; + in-out property draft-data-bits: "8"; + in-out property draft-stop-bits: "1"; + in-out property draft-parity: "none"; + in-out property draft-flow: "none"; + in property <[string]> folders; + + callback submit(SessionDraft); + callback cancel(); + callback pick-key-file(); // open a native file picker for the private key + + visible: is-open; + background: #000000aa; + + // Swallow clicks on the backdrop + TouchArea { + width: 100%; height: 100%; + clicked => { root.cancel(); } + } + + Rectangle { + width: min(440px, parent.width - 48px); + height: min(620px, parent.height - 48px); + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + background: Theme.bg-panel; + border-radius: Theme.radius-lg; + border-width: 1px; + border-color: Theme.border-strong; + drop-shadow-blur: 24px; + drop-shadow-color: #00000080; + + // Prevent backdrop click from propagating when user clicks inside the card + TouchArea { + width: 100%; height: 100%; + } + + VerticalLayout { + padding: 20px; + spacing: 12px; + + Text { + text: is-editing ? @tr("Edit session") : @tr("New session"); + color: Theme.text-primary; + font-size: Theme.fs-xl; + font-weight: 600; + } + + Flickable { + vertical-stretch: 1; + viewport-width: self.width; + viewport-height: max(self.height, form-col.preferred-height); + + form-col := VerticalLayout { + width: parent.width; + alignment: start; + spacing: 12px; + + // Connection type selector (always shown). + VerticalLayout { + spacing: 4px; + Text { + text: @tr("Connection type"); + color: Theme.text-secondary; + font-size: Theme.fs-sm; + } + HorizontalLayout { + height: 30px; + spacing: 6px; + SegOption { + label: "SSH"; + active: root.draft-kind == "ssh"; + clicked => { root.draft-kind = "ssh"; } + } + SegOption { + label: @tr("Serial"); + active: root.draft-kind == "serial"; + clicked => { root.draft-kind = "serial"; } + } + SegOption { + label: "Telnet"; + active: root.draft-kind == "telnet"; + clicked => { root.draft-kind = "telnet"; } + } + } + } + + LabeledInput { + label: @tr("Name"); + placeholder: @tr("e.g. production web-01"); + value <=> root.draft-name; + } + + VerticalLayout { + spacing: 4px; + Text { + text: @tr("Folder"); + color: Theme.text-secondary; + font-size: Theme.fs-sm; + } + VerticalLayout { + spacing: 6px; + for folder in root.folders : FolderOption { + label: folder; + active: root.draft-group == folder; + clicked => { root.draft-group = folder; } + } + } + } + + // ── SSH / Telnet share host + port ────────────────────────────── + if root.draft-kind != "serial" : HorizontalLayout { + spacing: 10px; + LabeledInput { + label: @tr("Host / IP"); + placeholder: "192.168.1.10"; + value <=> root.draft-host; + horizontal-stretch: 2; + } + LabeledInput { + label: @tr("Port"); + placeholder: root.draft-kind == "telnet" ? "23" : "22"; + value <=> root.draft-port; + horizontal-stretch: 1; + } + } + + // ── SSH-only: user + auth ─────────────────────────────────────── + if root.draft-kind == "ssh" : LabeledInput { + label: @tr("Username"); + placeholder: "root"; + value <=> root.draft-user; + } + + if root.draft-kind == "ssh" : VerticalLayout { + spacing: 4px; + Text { + text: @tr("Authentication"); + color: Theme.text-secondary; + font-size: Theme.fs-sm; + } + HorizontalLayout { + height: 30px; + spacing: 6px; + SegOption { + label: @tr("Password"); + active: root.draft-auth == "password"; + clicked => { root.draft-auth = "password"; } + } + SegOption { + label: @tr("Private key"); + active: root.draft-auth == "key"; + clicked => { root.draft-auth = "key"; } + } + } + } + + if root.draft-kind == "ssh" && root.draft-auth == "password" : LabeledInput { + label: @tr("Password"); + placeholder: root.is-editing ? @tr("Leave blank to keep the current password") : "••••••••"; + password: true; + value <=> root.draft-password; + } + + if root.draft-kind == "ssh" && root.draft-auth == "key" : VerticalLayout { + spacing: 4px; + Text { + text: @tr("Private key file"); + color: Theme.text-secondary; + font-size: Theme.fs-sm; + } + HorizontalLayout { + height: 32px; + spacing: 8px; + Rectangle { + height: 32px; + horizontal-stretch: 1; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: keyin.has-focus ? Theme.accent : Theme.border-subtle; + background: Theme.bg-root; + keyin := TextInput { + x: 10px; y: 0px; + width: parent.width - 20px; + height: parent.height; + text <=> root.draft-key-path; + color: Theme.text-primary; + font-size: Theme.fs-md; + vertical-alignment: center; + single-line: true; + } + if root.draft-key-path == "" : Text { + x: 10px; y: 0px; + height: parent.height; + text: "/home/you/.ssh/id_ed25519"; + color: Theme.text-muted; + font-size: Theme.fs-md; + vertical-alignment: center; + } + } + Rectangle { + width: 64px; height: 32px; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: Theme.border-subtle; + background: browse-ta.has-hover ? Theme.bg-hover : Theme.bg-elevated; + Text { + width: parent.width; + height: parent.height; + text: @tr("Browse…"); + color: Theme.text-primary; + font-size: Theme.fs-sm; + horizontal-alignment: center; + vertical-alignment: center; + } + browse-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.pick-key-file(); } + } + } + } + Text { + text: @tr("Pick the private key itself (not the .pub public key)"); + color: Theme.text-muted; + font-size: Theme.fs-xs; + } + } + + // ── Serial-only fields ────────────────────────────────────────── + if root.draft-kind == "serial" : LabeledInput { + label: @tr("Serial port"); + placeholder: "COM3 / /dev/ttyUSB0"; + value <=> root.draft-serial-port; + } + + if root.draft-kind == "serial" : LabeledInput { + label: @tr("Baud rate"); + placeholder: "115200"; + value <=> root.draft-baud; + } + + if root.draft-kind == "serial" : VerticalLayout { + spacing: 4px; + Text { + text: @tr("Data bits / Stop bits / Parity"); + color: Theme.text-secondary; + font-size: Theme.fs-sm; + } + HorizontalLayout { + height: 30px; + spacing: 6px; + SegOption { label: "7"; active: root.draft-data-bits == "7"; clicked => { root.draft-data-bits = "7"; } } + SegOption { label: "8"; active: root.draft-data-bits == "8"; clicked => { root.draft-data-bits = "8"; } } + Rectangle { width: 8px; } + SegOption { label: "1"; active: root.draft-stop-bits == "1"; clicked => { root.draft-stop-bits = "1"; } } + SegOption { label: "2"; active: root.draft-stop-bits == "2"; clicked => { root.draft-stop-bits = "2"; } } + Rectangle { width: 8px; } + SegOption { label: "N"; active: root.draft-parity == "none"; clicked => { root.draft-parity = "none"; } } + SegOption { label: "O"; active: root.draft-parity == "odd"; clicked => { root.draft-parity = "odd"; } } + SegOption { label: "E"; active: root.draft-parity == "even"; clicked => { root.draft-parity = "even"; } } + } + } + + if root.draft-kind == "serial" : VerticalLayout { + spacing: 4px; + Text { + text: @tr("Flow control"); + color: Theme.text-secondary; + font-size: Theme.fs-sm; + } + HorizontalLayout { + height: 30px; + spacing: 6px; + SegOption { label: @tr("None"); active: root.draft-flow == "none"; clicked => { root.draft-flow = "none"; } } + SegOption { label: @tr("Hardware"); active: root.draft-flow == "hardware"; clicked => { root.draft-flow = "hardware"; } } + SegOption { label: @tr("Software"); active: root.draft-flow == "software"; clicked => { root.draft-flow = "software"; } } + } + } + + // ── Optional outbound proxy (SSH / Telnet only) ───────────────── + if root.draft-kind != "serial" : VerticalLayout { + spacing: 4px; + LabeledInput { + label: @tr("Proxy (optional)"); + placeholder: "socks5://127.0.0.1:1080"; + value <=> root.draft-proxy; + } + Text { + text: @tr("Leave blank to use $ALL_PROXY or connect directly"); + color: Theme.text-muted; + font-size: Theme.fs-xs; + } + } + + } + } + + HorizontalLayout { + height: 30px; + alignment: end; + spacing: 8px; + GhostButton { + text: @tr("Cancel"); + clicked => { root.cancel(); } + } + PrimaryButton { + text: is-editing ? @tr("Save") : @tr("Create"); + clicked => { + root.submit({ + id: root.draft-id, + name: root.draft-name, + group: root.draft-group, + kind: root.draft-kind, + host: root.draft-host, + port: root.draft-port.to-float(), + user: root.draft-user, + auth: root.draft-auth, + password: root.draft-password, + private-key-path: root.draft-key-path, + proxy: root.draft-proxy, + serial-port: root.draft-serial-port, + baud-rate: root.draft-baud.to-float(), + data-bits: root.draft-data-bits.to-float(), + stop-bits: root.draft-stop-bits.to-float(), + parity: root.draft-parity, + flow-control: root.draft-flow, + }); + } + } + } + } + } +} diff --git a/ui/sftp_panel.slint b/ui/sftp_panel.slint new file mode 100644 index 0000000..f45312a --- /dev/null +++ b/ui/sftp_panel.slint @@ -0,0 +1,591 @@ +import { Theme } from "theme.slint"; +import { ScrollView } from "std-widgets.slint"; + +// --------------------------------------------------------------------------- +// Data types +// --------------------------------------------------------------------------- + +export struct SftpEntry { + name: string, + full-path: string, + is-dir: bool, + size: string, + modified: string, +} + +export struct SftpTreeNode { + path: string, + name: string, + depth: int, + expanded: bool, + has-children: bool, +} + +// One row of the file-list right-click context menu. +component SftpMenuItem inherits Rectangle { + in property label; + in property tint: Theme.text-primary; + callback clicked(); + height: 26px; + border-radius: Theme.radius-sm; + background: ta.has-hover ? Theme.bg-hover : transparent; + ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.clicked(); } + } + Text { + x: 10px; + text: root.label; + color: root.tint; + font-size: Theme.fs-sm; + vertical-alignment: center; + height: parent.height; + } +} + +// --------------------------------------------------------------------------- +// SftpPanel — left directory tree + right file listing +// --------------------------------------------------------------------------- + +export component SftpPanel inherits Rectangle { + // --- Properties -------------------------------------------------------- + in property current-path: "/"; + in property <[SftpEntry]> entries; + in property status: @tr("SFTP not connected"); + in property loading: false; + in property <[SftpTreeNode]> tree-nodes: []; + in property app-background-active: false; + in property ui-opacity: 0.72; + in-out property path-draft: "/"; + in property file-font-size: 16px; + in-out property name-column-width: 180px; + in-out property name-column-manual: false; + property rename-open: false; + property rename-path; + property rename-draft; + property name-column-dragging: false; + property name-column-drag-start-x: 0px; + + // --- Callbacks --------------------------------------------------------- + callback navigate(string); + callback download(string); + callback upload-clicked(string); + callback refresh(string); + callback tree-expand(string); // toggle expand/collapse + navigate + callback delete(string); // remove a remote file/dir + callback rename(string, string); + callback edit(string); // download to temp + open + auto-reupload + callback name-column-width-changed(float); + callback name-column-auto(); + + // ----------------------------------------------------------------------- + background: root.app-background-active ? Theme.bg-panel.with-alpha(root.ui-opacity) : Theme.bg-panel; + + changed current-path => { root.path-draft = root.current-path; } + + VerticalLayout { + spacing: 0; + + // --- Toolbar (full width) ------------------------------------------ + Rectangle { + height: 30px; + background: root.app-background-active ? Theme.bg-panel-alt.with-alpha(root.ui-opacity) : Theme.bg-panel-alt; + + HorizontalLayout { + height: parent.height; + padding-left: 6px; + padding-right: 6px; + spacing: 4px; + + // Editable path bar fills the left; action buttons sit on the right. + Rectangle { + y: (parent.height - self.height) / 2; + horizontal-stretch: 1; + height: 22px; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: path-input.has-focus ? Theme.accent : Theme.border-subtle; + background: root.app-background-active ? Theme.bg-root.with-alpha(root.ui-opacity) : Theme.bg-root; + + path-input := TextInput { + x: 8px; + width: parent.width - 16px; + height: parent.height; + text <=> root.path-draft; + color: Theme.text-primary; + font-size: root.file-font-size; + single-line: true; + vertical-alignment: center; + key-pressed(e) => { + if (e.text == "\n") { + root.navigate(self.text); + accept + } else { + reject + } + } + } + } + + Rectangle { + y: (parent.height - self.height) / 2; + width: 24px; height: 22px; + border-radius: Theme.radius-sm; + background: go-ta.has-hover ? Theme.bg-hover : transparent; + go-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.navigate(root.path-draft); } + } + Text { text: "\u{E5C8}"; font-family: "Material Icons"; + color: Theme.text-secondary; font-size: Theme.fs-sm; + width: parent.width; height: parent.height; + horizontal-alignment: center; vertical-alignment: center; } + } + + up-btn := Rectangle { + y: (parent.height - self.height) / 2; + width: 24px; height: 22px; + border-radius: Theme.radius-sm; + background: up-ta.has-hover ? Theme.bg-hover : transparent; + up-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.navigate(".."); } + } + Text { text: "↑"; color: Theme.text-secondary; font-size: Theme.fs-md; + width: parent.width; height: parent.height; + horizontal-alignment: center; vertical-alignment: center; } + } + + Rectangle { + y: (parent.height - self.height) / 2; + width: 52px; height: 22px; + border-radius: Theme.radius-sm; + background: ul-ta.has-hover ? Theme.accent-hover : Theme.accent; + ul-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.upload-clicked(root.current-path); } + } + Text { text: @tr("Upload"); color: #ffffff; font-size: Theme.fs-sm; + width: parent.width; height: parent.height; + horizontal-alignment: center; vertical-alignment: center; } + } + + Rectangle { + y: (parent.height - self.height) / 2; + width: 24px; height: 22px; + border-radius: Theme.radius-sm; + background: ref-ta.has-hover ? Theme.bg-hover : transparent; + ref-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.refresh(root.current-path); } + } + Text { text: "\u{E5D5}"; font-family: "Material Icons"; + color: Theme.text-secondary; font-size: Theme.fs-sm; + width: parent.width; height: parent.height; + horizontal-alignment: center; vertical-alignment: center; } + } + } + } + + // --- Main area: tree (left) + file list (right) -------------------- + HorizontalLayout { + vertical-stretch: 1; + spacing: 0; + + // ── Left: directory tree ──────────────────────────────────────── + VerticalLayout { + width: 160px; + spacing: 0; + + // Tree header + Rectangle { + height: 20px; + background: root.app-background-active ? Theme.bg-root.with-alpha(root.ui-opacity) : Theme.bg-root; + Text { + x: 8px; + text: @tr("Directory tree"); + color: Theme.text-muted; + font-size: max(10px, root.file-font-size - 2px); + vertical-alignment: center; + height: parent.height; + } + } + Rectangle { height: 1px; background: Theme.border-subtle; } + + // Tree list with scrollbar + ScrollView { + vertical-stretch: 1; + + tree-list := VerticalLayout { + spacing: 0; + + for node[i] in root.tree-nodes : Rectangle { + height: max(24px, root.file-font-size + 10px); + background: node-ta.has-hover + ? Theme.bg-hover + : (node.path == root.current-path + ? #4a90e230 + : (mod(i, 2) == 0 ? transparent : #ffffff08)); + + node-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.tree-expand(node.path); } + } + + HorizontalLayout { + height: parent.height; + spacing: 0; + + // Depth indent spacer + Rectangle { width: node.depth * 12px; } + + // Expand/collapse arrow + Text { + width: 14px; + text: node.has-children + ? (node.expanded ? "▼" : "▶") + : " "; + color: Theme.text-muted; + font-size: max(9px, root.file-font-size - 3px); + horizontal-alignment: center; + vertical-alignment: center; + } + + // Folder name + Text { + text: node.depth == 0 ? "/" : node.name; + color: node.path == root.current-path + ? Theme.accent + : Theme.text-primary; + font-size: root.file-font-size; + horizontal-stretch: 1; + overflow: elide; + vertical-alignment: center; + } + } + } + } + } + } + + // Vertical separator + Rectangle { width: 1px; background: Theme.border-subtle; } + + // ── Right: file listing ───────────────────────────────────────── + right-pane := VerticalLayout { + horizontal-stretch: 1; + spacing: 0; + + // Column headers + Rectangle { + height: 20px; + background: root.app-background-active ? Theme.bg-root.with-alpha(root.ui-opacity) : Theme.bg-root; + HorizontalLayout { + height: parent.height; + padding-left: 8px; padding-right: 8px; + Text { text: @tr("Name"); color: Theme.text-muted; font-size: max(10px, root.file-font-size - 2px); + width: clamp(root.name-column-width, 96px, 360px); vertical-alignment: center; } + Rectangle { + width: 6px; + height: parent.height; + background: root.name-column-dragging || name-resize-ta.has-hover ? Theme.accent.with-alpha(0.45) : transparent; + name-resize-ta := TouchArea { + mouse-cursor: col-resize; + pointer-event(ev) => { + if (ev.kind == PointerEventKind.down) { + root.name-column-dragging = true; + root.name-column-drag-start-x = self.mouse-x; + root.name-column-manual = true; + } else if (ev.kind == PointerEventKind.up) { + root.name-column-dragging = false; + root.name-column-width-changed(root.name-column-width / 1px); + } + } + moved => { + if (root.name-column-dragging) { + root.name-column-width = clamp( + root.name-column-width + self.mouse-x - root.name-column-drag-start-x, + 96px, + 360px + ); + root.name-column-drag-start-x = self.mouse-x; + } + } + } + } + if root.name-column-manual : Rectangle { + y: (parent.height - self.height) / 2; + width: 42px; + height: 18px; + border-radius: Theme.radius-sm; + background: name-auto-ta.has-hover ? Theme.bg-hover : transparent; + name-auto-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.name-column-manual = false; + root.name-column-auto(); + root.refresh(root.current-path); + } + } + Text { + width: parent.width; + height: parent.height; + text: @tr("Auto"); + color: Theme.accent; + font-size: max(10px, root.file-font-size - 3px); + horizontal-alignment: center; + vertical-alignment: center; + } + } + Rectangle { horizontal-stretch: 1; } + Text { text: @tr("Size"); color: Theme.text-muted; font-size: max(10px, root.file-font-size - 2px); + width: 72px; horizontal-alignment: right; vertical-alignment: center; } + Text { text: @tr("Modified"); color: Theme.text-muted; font-size: max(10px, root.file-font-size - 2px); + width: 116px; horizontal-alignment: right; vertical-alignment: center; } + } + } + Rectangle { height: 1px; background: Theme.border-subtle; } + + // File list with scrollbar + ScrollView { + vertical-stretch: 1; + + file-list := VerticalLayout { + spacing: 0; + + if root.loading : Rectangle { + height: 40px; + Text { width: parent.width; height: parent.height; + text: @tr("Loading..."); color: Theme.text-muted; font-size: root.file-font-size; + horizontal-alignment: center; vertical-alignment: center; } + } + + if !root.loading && root.entries.length == 0 : Rectangle { + height: 40px; + Text { width: parent.width; height: parent.height; + text: @tr("Empty directory"); color: Theme.text-muted; font-size: root.file-font-size; + horizontal-alignment: center; vertical-alignment: center; } + } + + for entry[i] in root.entries : row := Rectangle { + height: max(26px, root.file-font-size + 12px); + background: row-ta.has-hover + ? #4a90e225 + : (mod(i, 2) == 0 ? transparent : #ffffff08); + + // Right-click anchor (relative to this row). + property mx; + property my; + + row-ta := TouchArea { + mouse-cursor: pointer; + double-clicked => { + if (entry.is-dir) { + root.navigate(entry.full-path); + } else { + root.edit(entry.full-path); + } + } + pointer-event(ev) => { + if (ev.kind == PointerEventKind.down + && ev.button == PointerEventButton.right) { + row.mx = self.mouse-x; + row.my = self.mouse-y; + row-menu.show(); + } + } + } + + // Right-click context menu. + row-menu := PopupWindow { + x: row.mx; + y: row.my; + width: 108px; + height: (entry.is-dir ? 2 : 4) * 27px + 8px; + Rectangle { + background: Theme.bg-panel; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: Theme.border-strong; + drop-shadow-blur: 12px; + drop-shadow-color: #00000060; + VerticalLayout { + padding: 4px; + spacing: 1px; + if !entry.is-dir : SftpMenuItem { + label: @tr("Download"); + clicked => { root.download(entry.full-path); } + } + SftpMenuItem { + label: @tr("Rename"); + clicked => { + root.rename-path = entry.full-path; + root.rename-draft = entry.name; + root.rename-open = true; + } + } + if !entry.is-dir : SftpMenuItem { + label: @tr("Edit"); + clicked => { root.edit(entry.full-path); } + } + SftpMenuItem { + label: @tr("Delete"); + tint: #d86c6c; + clicked => { root.delete(entry.full-path); } + } + } + } + } + + HorizontalLayout { + height: parent.height; + padding-left: 8px; padding-right: 8px; spacing: 4px; + + Text { + text: entry.is-dir ? "📁 " + entry.name : "📄 " + entry.name; + color: entry.is-dir ? Theme.accent : Theme.text-primary; + font-size: root.file-font-size; + width: clamp(root.name-column-width, 96px, 360px); + overflow: elide; + vertical-alignment: center; + } + Rectangle { width: 6px; height: parent.height; } + Rectangle { horizontal-stretch: 1; } + Text { + text: entry.is-dir ? "" : entry.size; + color: Theme.text-secondary; font-size: max(10px, root.file-font-size - 2px); + width: 72px; horizontal-alignment: right; vertical-alignment: center; + } + Text { + text: entry.modified; + color: Theme.text-muted; font-size: max(10px, root.file-font-size - 2px); + width: 116px; horizontal-alignment: right; vertical-alignment: center; + } + } + } + } + } + } + } + + if root.rename-open : Rectangle { + width: parent.width; + height: parent.height; + background: #00000040; + TouchArea { clicked => { root.rename-open = false; } } + + Rectangle { + x: (parent.width - self.width) / 2; + y: 40px; + width: min(340px, parent.width - 32px); + height: 118px; + border-radius: Theme.radius-md; + border-width: 1px; + border-color: Theme.border-strong; + background: Theme.bg-panel; + drop-shadow-blur: 16px; + drop-shadow-color: #00000080; + TouchArea {} + + VerticalLayout { + padding: 12px; + spacing: 8px; + Text { + text: @tr("Rename"); + color: Theme.text-primary; + font-size: Theme.fs-sm; + font-weight: 600; + } + Rectangle { + height: 30px; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: rename-input.has-focus ? Theme.accent : Theme.border-subtle; + background: Theme.bg-root; + rename-input := TextInput { + x: 8px; + width: parent.width - 16px; + height: parent.height; + text <=> root.rename-draft; + color: Theme.text-primary; + font-size: Theme.fs-sm; + single-line: true; + vertical-alignment: center; + key-pressed(e) => { + if (e.text == "\n") { + root.rename(root.rename-path, root.rename-draft); + root.rename-open = false; + accept + } else { + reject + } + } + } + } + HorizontalLayout { + height: 28px; + spacing: 8px; + Rectangle { horizontal-stretch: 1; } + Rectangle { + width: 68px; + height: parent.height; + border-radius: Theme.radius-sm; + background: rn-cancel-ta.has-hover ? Theme.bg-hover : Theme.bg-elevated; + rn-cancel-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.rename-open = false; } + } + Text { + width: parent.width; + height: parent.height; + text: @tr("Cancel"); + color: Theme.text-primary; + font-size: Theme.fs-sm; + horizontal-alignment: center; + vertical-alignment: center; + } + } + Rectangle { + width: 68px; + height: parent.height; + border-radius: Theme.radius-sm; + background: rn-ok-ta.has-hover ? Theme.accent-hover : Theme.accent; + rn-ok-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.rename(root.rename-path, root.rename-draft); + root.rename-open = false; + } + } + Text { + width: parent.width; + height: parent.height; + text: @tr("Save"); + color: #ffffff; + font-size: Theme.fs-sm; + horizontal-alignment: center; + vertical-alignment: center; + } + } + } + } + } + } + + // --- Status bar (full width) -------------------------------------- + Rectangle { + height: 18px; + background: root.app-background-active ? Theme.bg-panel-alt.with-alpha(root.ui-opacity) : Theme.bg-panel-alt; + Text { + x: 8px; + text: root.status; + color: Theme.text-muted; + font-size: Theme.fs-xs; + vertical-alignment: center; + height: parent.height; + overflow: elide; + width: parent.width - 16px; + } + } + } +} diff --git a/ui/sidebar.slint b/ui/sidebar.slint new file mode 100644 index 0000000..6be75bf --- /dev/null +++ b/ui/sidebar.slint @@ -0,0 +1,499 @@ +import { Theme } from "theme.slint"; +import { Sparkline } from "widgets.slint"; + +// One filesystem row for the disk-usage panel. +export struct DiskInfo { + path: string, + detail: string, // "available/total" + percent: float, // used fraction 0.0..1.0 +} + +export struct ProcessInfo { + mem: string, + cpu: string, + name: string, +} + +// --- StatRow --------------------------------------------------------------- +// A single label / percentage / progress-bar row used for CPU / memory / swap. +component StatRow inherits Rectangle { + in property label; + in property percent; // 0.0 .. 1.0 + in property detail; + in property bar-color: Theme.accent; + + height: 30px; + border-radius: Theme.radius-sm; + background: Theme.bg-panel-alt.with-alpha(0.38); + + HorizontalLayout { + height: parent.height; + padding-left: 6px; + padding-right: 6px; + spacing: 7px; + + Text { + width: 32px; + height: parent.height; + text: label; + color: Theme.text-secondary; + font-size: Theme.fs-xs; + vertical-alignment: center; + overflow: elide; + } + + Rectangle { + y: (parent.height - self.height) / 2; + height: 20px; + horizontal-stretch: 1; + background: Theme.bg-root.with-alpha(0.56); + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: Theme.border-subtle.with-alpha(0.58); + clip: true; + + Rectangle { + x: 0; + height: parent.height; + width: parent.width * clamp(percent, 0.0, 1.0); + background: bar-color.with-alpha(0.52); + border-radius: Theme.radius-sm; + animate width { duration: 500ms; easing: ease-out; } + } + + HorizontalLayout { + height: parent.height; + padding-left: 7px; + padding-right: 7px; + Text { + height: parent.height; + text: Math.round(percent * 100) + "%"; + color: Theme.text-primary; + font-size: Theme.fs-xs; + font-weight: 600; + vertical-alignment: center; + } + Rectangle { horizontal-stretch: 1; } + Text { + height: parent.height; + text: detail; + color: Theme.text-primary; + font-size: Theme.fs-xs; + horizontal-alignment: right; + vertical-alignment: center; + overflow: elide; + } + } + } + } +} + +component ProcessTable inherits VerticalLayout { + in property <[ProcessInfo]> processes; + + vertical-stretch: 0; + spacing: 1px; + + HorizontalLayout { + height: 18px; + padding-left: 6px; + padding-right: 6px; + spacing: 4px; + Text { + width: 42px; + text: @tr("Memory"); + color: Theme.text-muted; + font-size: Theme.fs-xs; + vertical-alignment: center; + } + Text { + width: 32px; + text: "CPU"; + color: Theme.text-muted; + font-size: Theme.fs-xs; + vertical-alignment: center; + } + Text { + text: @tr("Program"); + color: Theme.text-muted; + font-size: Theme.fs-xs; + vertical-alignment: center; + horizontal-stretch: 1; + } + } + + for p in root.processes : Rectangle { + height: 18px; + border-radius: 2px; + background: row-ta.has-hover ? Theme.bg-hover.with-alpha(0.78) : Theme.bg-panel-alt.with-alpha(0.22); + row-ta := TouchArea {} + HorizontalLayout { + height: parent.height; + padding-left: 6px; + padding-right: 6px; + spacing: 4px; + Text { + width: 42px; + text: p.mem; + color: Theme.text-secondary; + font-size: Theme.fs-xs; + vertical-alignment: center; + overflow: elide; + } + Text { + width: 32px; + text: p.cpu; + color: Theme.text-secondary; + font-size: Theme.fs-xs; + vertical-alignment: center; + overflow: elide; + } + Text { + text: p.name; + color: Theme.text-primary; + font-size: Theme.fs-xs; + vertical-alignment: center; + horizontal-stretch: 1; + overflow: elide; + } + } + } +} + +// --- NetGraph -------------------------------------------------------------- +// One network throughput graph: a header row (↑ tx / ↓ rx, plus an optional +// NIC selector dropdown) over an auto-scaled sparkline. +component NetGraph inherits VerticalLayout { + in property up-text; + in property down-text; + in property <[float]> history; + in property show-selector: false; + in property <[string]> ifaces; + in property selected; + callback iface-selected(string); + + // Stay at preferred height; without this the inherited VerticalLayout grabs + // a stretch share of the sidebar and balloons the sparkline into blank space. + vertical-stretch: 0; + spacing: 3px; + + HorizontalLayout { + height: 18px; + spacing: 3px; + Text { // ↑ upload (green) + width: 12px; + height: parent.height; + text: "\u{E5D8}"; + font-family: "Material Icons"; + color: Theme.success; + font-size: Theme.fs-xs; + horizontal-alignment: center; + vertical-alignment: center; + } + Text { + height: parent.height; + text: root.up-text; + color: Theme.text-secondary; + font-size: Theme.fs-xs; + vertical-alignment: center; + } + Text { // ↓ download (blue) + width: 12px; + height: parent.height; + text: "\u{E5DB}"; + font-family: "Material Icons"; + color: Theme.accent; + font-size: Theme.fs-xs; + horizontal-alignment: center; + vertical-alignment: center; + } + Text { + height: parent.height; + text: root.down-text; + color: Theme.text-secondary; + font-size: Theme.fs-xs; + vertical-alignment: center; + } + Rectangle { horizontal-stretch: 1; } // spacer pushes selector to the right + + if root.show-selector : Rectangle { + width: 64px; + height: 18px; + border-radius: 3px; + background: sel-ta.has-hover ? Theme.bg-hover : Theme.bg-elevated; + + HorizontalLayout { + height: parent.height; + padding-left: 5px; padding-right: 4px; spacing: 2px; + Text { + height: parent.height; + text: root.selected; + color: Theme.text-primary; + font-size: Theme.fs-xs; + vertical-alignment: center; + horizontal-stretch: 1; + overflow: elide; + } + Text { + height: parent.height; + text: "\u{E5CF}"; + font-family: "Material Icons"; + color: Theme.text-muted; + font-size: 8px; + vertical-alignment: center; + } + } + + sel-ta := TouchArea { + mouse-cursor: pointer; + clicked => { popup.show(); } + } + + popup := PopupWindow { + x: 0; y: parent.height + 2px; + width: 96px; + Rectangle { + background: Theme.bg-panel; + border-radius: 4px; + border-width: 1px; + border-color: Theme.border-strong; + drop-shadow-blur: 10px; + drop-shadow-color: #00000050; + VerticalLayout { + padding: 4px; + spacing: 1px; + for iface in root.ifaces : Rectangle { + height: 22px; + border-radius: 3px; + background: row-ta.has-hover ? Theme.bg-hover : transparent; + row-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.iface-selected(iface); } + } + Text { + x: 6px; + text: iface; + color: Theme.text-primary; + font-size: Theme.fs-xs; + vertical-alignment: center; + height: parent.height; + } + } + } + } + } + } + } + + Sparkline { + height: 48px; + values: root.history; + line-color: Theme.accent; + } +} + +// --- Sidebar --------------------------------------------------------------- +// Left-hand panel showing local machine health, mimicking FinalShell's +// @tr("Status") column but kept minimal for the v0.1 baseline. +export component Sidebar inherits Rectangle { + in property app-background-active: false; + in property ui-opacity: 0.72; + in property connection-state: @tr("Not connected"); + in property conn-state; // 0 gray / 1 green / 2 yellow + in property resource-title: @tr("Local resources"); + in property cpu-percent; + in property mem-percent; + in property swap-percent; + in property cpu-detail; + in property mem-detail; + in property swap-detail; + in property net-top-up; + in property net-top-down; + in property <[float]> net-top-history; + in property <[string]> net-ifaces; + in property net-selected; + in property net-show-selector; + in property <[DiskInfo]> disks; + in property <[ProcessInfo]> processes; + callback select-net-iface(string); + + width: 220px; + background: root.app-background-active ? Theme.bg-panel.with-alpha(root.ui-opacity) : Theme.bg-panel; + border-width: 0; + + VerticalLayout { + padding: 10px; + spacing: 10px; + + // Status header + VerticalLayout { + spacing: 3px; + Text { + text: @tr("Running status"); + color: Theme.text-primary; + font-size: Theme.fs-md; + font-weight: 600; + vertical-alignment: center; + } + Rectangle { + height: 24px; + border-radius: Theme.radius-sm; + background: root.conn-state == 1 ? Theme.success.with-alpha(0.16) + : root.conn-state == 2 ? Theme.warning.with-alpha(0.16) + : Theme.bg-root; + Text { + x: 8px; + width: parent.width - 16px; + height: parent.height; + text: connection-state; + color: root.conn-state == 1 ? Theme.success + : root.conn-state == 2 ? Theme.warning + : Theme.text-secondary; + font-size: Theme.fs-sm; + vertical-alignment: center; + overflow: elide; + } + } + } + + Rectangle { height: 1px; background: Theme.border-subtle; } + + // System stats — local machine on the welcome tab, the remote server + // when a connected session tab is active. + Text { + text: root.resource-title; + color: Theme.text-secondary; + font-size: Theme.fs-sm; + } + + VerticalLayout { + spacing: 6px; + StatRow { + label: "CPU"; + percent: root.cpu-percent; + detail: root.cpu-detail; + bar-color: Theme.accent; + } + StatRow { + label: @tr("Memory"); + percent: root.mem-percent; + detail: root.mem-detail; + bar-color: Theme.success; + } + StatRow { + label: @tr("Swap"); + percent: root.swap-percent; + detail: root.swap-detail; + bar-color: Theme.warning; + } + } + + Rectangle { height: 1px; background: Theme.border-subtle; } + + ProcessTable { + processes: root.processes; + } + + if root.net-top-up != "" || root.net-top-down != "" || root.net-show-selector : Rectangle { + height: 1px; + background: Theme.border-subtle; + } + + // Network — follows the active live session tab. + if root.net-top-up != "" || root.net-top-down != "" || root.net-show-selector : VerticalLayout { + vertical-stretch: 0; + spacing: 6px; + NetGraph { + up-text: root.net-top-up; + down-text: root.net-top-down; + history: root.net-top-history; + show-selector: root.net-show-selector; + ifaces: root.net-ifaces; + selected: root.net-selected; + iface-selected(i) => { root.select-net-iface(i); } + } + } + + Rectangle { height: 1px; background: Theme.border-subtle; } + + // Disk-usage panel (active tab's filesystems; local on the welcome tab). + disk-section := VerticalLayout { + vertical-stretch: 1; + spacing: 3px; + + HorizontalLayout { + padding-left: 6px; + padding-right: 6px; + Text { + text: @tr("Path"); + color: Theme.text-muted; + font-size: Theme.fs-xs; + horizontal-stretch: 1; + } + Text { + text: @tr("Free/Total"); + color: Theme.text-muted; + font-size: Theme.fs-xs; + } + } + + disk-flick := Flickable { + vertical-stretch: 1; + viewport-width: self.width; + // Grow only when the list overflows; otherwise match the visible + // height so `alignment: start` keeps rows pinned to the top. + viewport-height: max(self.height, disk-col.preferred-height); + disk-col := VerticalLayout { + alignment: start; + spacing: 1px; + for d[i] in root.disks : Rectangle { + height: 20px; + border-radius: 2px; + clip: true; + + // Usage fill: green-ish normally, amber/red when full. + Rectangle { + x: 0; + y: 0; + height: parent.height; + width: parent.width * clamp(d.percent, 0.0, 1.0); + background: d.percent > 0.9 ? Theme.danger.with-alpha(0.32) + : d.percent > 0.75 ? Theme.warning.with-alpha(0.30) + : Theme.accent.with-alpha(0.22); + } + HorizontalLayout { + height: parent.height; + padding-left: 6px; + padding-right: 6px; + spacing: 4px; + Text { + height: parent.height; + text: d.path; + color: Theme.text-secondary; + font-size: Theme.fs-xs; + vertical-alignment: center; + horizontal-stretch: 1; + overflow: elide; + } + Text { + height: parent.height; + text: d.detail; + color: Theme.text-muted; + font-size: Theme.fs-xs; + vertical-alignment: center; + horizontal-alignment: right; + } + } + } + } + } + } + + Text { + text: "meatshell v0.2.7"; + color: Theme.text-muted; + font-size: Theme.fs-xs; + horizontal-alignment: center; + } + } +} diff --git a/ui/tabs.slint b/ui/tabs.slint new file mode 100644 index 0000000..03d3ec2 --- /dev/null +++ b/ui/tabs.slint @@ -0,0 +1,223 @@ +import { Theme } from "theme.slint"; +import { IconButton } from "widgets.slint"; + +export struct TabInfo { + id: string, + title: string, + kind: string, // "welcome" | "terminal" + connected: bool, +} + +// --- SingleTab ------------------------------------------------------------- +component SingleTab inherits Rectangle { + in property info; + in property active; + in property shown: true; + in property app-background-active: false; + in property ui-opacity: 0.72; + callback selected(); + callback closed(); + + height: shown ? 30px : 0px; + property title-width: min(128px, max(34px, title-probe.preferred-width)); + property tab-width: info.kind == "welcome" + ? max(72px, title-width + 34px) + : max(74px, title-width + 62px); + width: shown ? min(176px, tab-width) : 0px; + visible: shown; + horizontal-stretch: 0; + vertical-stretch: 0; + border-radius: Theme.radius-sm; + background: active ? (root.app-background-active ? Theme.bg-panel.with-alpha(root.ui-opacity) : Theme.bg-panel) + : (touch.has-hover ? Theme.bg-hover : transparent); + animate background { duration: 120ms; } + + // Declared FIRST so the HorizontalLayout's children (close button) are at + // a higher z-order and receive their own click events. + touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.selected(); } + } + + title-probe := Text { + text: info.title; + font-size: Theme.fs-sm; + opacity: 0; + x: -1000px; + y: -1000px; + } + + HorizontalLayout { + height: parent.height; + padding-left: 10px; + padding-right: 6px; + spacing: 8px; + alignment: center; + + // Status indicator + Rectangle { + width: 8px; + height: parent.height; + + Rectangle { + y: (parent.height - self.height) / 2; + width: parent.width; + height: 8px; + border-radius: 4px; + background: info.kind == "welcome" ? Theme.text-muted + : (info.connected ? Theme.success : Theme.warning); + } + } + + Text { + text: info.title; + height: parent.height; + color: active ? Theme.text-primary : Theme.text-secondary; + font-size: Theme.fs-sm; + vertical-alignment: center; + overflow: elide; + horizontal-stretch: 1; + } + + // Only show close button for non-welcome tabs + if info.kind != "welcome" : Rectangle { + width: 20px; + height: parent.height; + IconButton { + y: (parent.height - self.height) / 2; + glyph: "×"; + size: 20px; + clicked => { root.closed(); } + } + } + } +} + +// --- TabBar ---------------------------------------------------------------- +export component TabBar inherits Rectangle { + in property <[TabInfo]> tabs; + in property active-id; + in-out property visible-start: 0; + in property app-background-active: false; + in property ui-opacity: 0.72; + property visible-count: 5; + callback tab-selected(string); + callback tab-closed(string); + callback new-tab(); + callback previous-tab(); + callback next-tab(); + + height: 36px; + background: root.app-background-active ? Theme.bg-panel-alt.with-alpha(root.ui-opacity) : Theme.bg-panel-alt; + + HorizontalLayout { + height: parent.height; + padding-left: 3px; + padding-right: 100px; // reserve the overlaid theme/download/settings buttons + padding-top: 3px; + padding-bottom: 3px; + spacing: 2px; + + Rectangle { + width: 30px; + height: 30px; + border-radius: Theme.radius-sm; + background: root.active-id == "welcome" + ? Theme.bg-panel + : (folder-ta.has-hover ? Theme.bg-hover : transparent); + folder-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.new-tab(); } + } + Text { + width: parent.width; + height: parent.height; + text: "📂"; + color: root.active-id == "welcome" ? Theme.text-primary : Theme.text-secondary; + font-size: Theme.fs-md; + horizontal-alignment: center; + vertical-alignment: center; + } + } + + Rectangle { + width: 56px; + height: 30px; + vertical-stretch: 0; + + HorizontalLayout { + height: parent.height; + spacing: 0; + + Rectangle { + width: 28px; + height: parent.height; + border-radius: Theme.radius-sm; + background: prev-ta.has-hover ? Theme.bg-hover : transparent; + prev-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.previous-tab(); } + } + Text { + width: parent.width; + height: parent.height; + text: "[<]"; + color: Theme.text-secondary; + font-size: Theme.fs-xs; + horizontal-alignment: center; + vertical-alignment: center; + } + } + + Rectangle { + width: 28px; + height: parent.height; + border-radius: Theme.radius-sm; + background: next-ta.has-hover ? Theme.bg-hover : transparent; + next-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.next-tab(); } + } + Text { + width: parent.width; + height: parent.height; + text: "[>]"; + color: Theme.text-secondary; + font-size: Theme.fs-xs; + horizontal-alignment: center; + vertical-alignment: center; + } + } + } + } + + Rectangle { + horizontal-stretch: 1; + height: 30px; + clip: true; + + tabs-flick := Flickable { + width: parent.width; + height: parent.height; + viewport-width: max(self.width, tabs-row.preferred-width); + viewport-height: self.height; + interactive: true; + + tabs-row := HorizontalLayout { + height: parent.height; + spacing: 0px; + + for tab[i] in root.tabs : SingleTab { + shown: i >= root.visible-start && i < root.visible-start + root.visible-count; + info: tab; + active: tab.id == root.active-id; + app-background-active: root.app-background-active; + ui-opacity: root.ui-opacity; + selected => { root.tab-selected(tab.id); } + closed => { root.tab-closed(tab.id); } + } + } + } + } + } +} diff --git a/ui/terminal_view.slint b/ui/terminal_view.slint new file mode 100644 index 0000000..dc698bd --- /dev/null +++ b/ui/terminal_view.slint @@ -0,0 +1,2298 @@ +import { Theme } from "theme.slint"; +import { SftpPanel, SftpEntry, SftpTreeNode } from "sftp_panel.slint"; + +// Embed the terminal fonts here too so the cell-probe Text below measures +// with the CORRECT font (Cascadia Mono), not a system fallback. Font imports +// in app.slint are globally available in theory, but some Slint builds only +// register them in the entry-point file's component tree; importing them here +// guarantees the probe uses Cascadia Mono for its preferred-width calculation. +import "fonts/CascadiaMono-Regular.ttf"; +import "fonts/CascadiaMono-Bold.ttf"; + +// One coloured run of text on the terminal grid. The Rust side groups +// consecutive vt100 cells that share fg + bg + bold into a single span and +// tags it with its grid position (row, col) and width in cells, so the UI can +// place both a background fill and the text with absolute monospace coordinates. +// `bg` is transparent (alpha 0) when the cell uses the default background. +export struct TermSpan { + text: string, + fg: color, + bg: color, + bold: bool, + use-fallback-font: bool, + row: int, + col: int, + cells: int, +} + +// A find-match highlight rectangle on the terminal grid. +export struct TermMatch { + row: int, + col: int, + len: int, +} + +export struct CommandCategory { + id: string, + name: string, + builtin: bool, +} + +export struct CommandItem { + id: string, + category-id: string, + name: string, + command: string, + append-cr: bool, +} + +// --- MenuItem -------------------------------------------------------------- +// One row of the terminal right-click context menu (MDL2 icon + label). +component MenuItem inherits Rectangle { + in property icon; + in property label; + callback clicked(); + + height: 28px; + border-radius: Theme.radius-sm; + background: ta.has-hover ? Theme.bg-hover : transparent; + + ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.clicked(); } + } + HorizontalLayout { + height: parent.height; + padding-left: 8px; + padding-right: 8px; + spacing: 8px; + Text { + width: 18px; + height: parent.height; + text: root.icon; + font-family: "Material Icons"; + color: Theme.text-secondary; + font-size: Theme.fs-sm; + horizontal-alignment: center; + vertical-alignment: center; + } + Text { + text: root.label; + color: Theme.text-primary; + font-size: Theme.fs-md; + vertical-alignment: center; + horizontal-stretch: 1; + } + } +} + +component CommandIconButton inherits Rectangle { + in property glyph; + in property fg: Theme.text-secondary; + in property fg-hover: Theme.text-primary; + callback clicked(); + + width: 26px; + height: 24px; + border-radius: Theme.radius-sm; + background: touch.has-hover ? Theme.bg-hover : transparent; + + touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.clicked(); } + } + + Text { + width: parent.width; + height: parent.height; + text: root.glyph; + font-family: "Material Icons"; + color: touch.has-hover ? root.fg-hover : root.fg; + font-size: Theme.fs-sm; + horizontal-alignment: center; + vertical-alignment: center; + } +} + +component CommandParamField inherits Rectangle { + in property label; + in-out property value; + in property missing: label != "" && value == ""; + property label-text: label + ":"; + + height: 30px; + vertical-stretch: 0; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: missing ? Theme.danger : (input.has-focus ? Theme.accent : Theme.border-subtle); + background: Theme.bg-root; + + label-probe := Text { + text: root.label-text; + font-size: Theme.fs-xs; + opacity: 0; + x: -1000px; + y: -1000px; + } + + label-hover := TouchArea { + width: parent.width; + height: parent.height; + } + + input := TextInput { + x: 8px; + y: (parent.height - self.height) / 2; + width: parent.width - 16px; + height: 24px; + text <=> root.value; + color: Theme.text-primary; + font-size: Theme.fs-sm; + single-line: true; + vertical-alignment: center; + } + + if root.value == "" : Text { + x: 10px; + y: (parent.height - self.height) / 2; + width: parent.width - 20px; + height: 22px; + text: root.label-text; + color: Theme.text-muted; + font-size: Theme.fs-xs; + vertical-alignment: center; + overflow: elide; + } + + if label-hover.has-hover && label-probe.preferred-width > root.width - 20px : Rectangle { + x: 0px; + y: -28px; + width: label-probe.preferred-width + 16px; + height: 24px; + z: 100; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: Theme.border-strong; + background: Theme.bg-panel; + drop-shadow-blur: 8px; + drop-shadow-color: #00000050; + + Text { + x: 8px; + width: parent.width - 16px; + height: parent.height; + text: root.label-text; + color: Theme.text-primary; + font-size: Theme.fs-xs; + vertical-alignment: center; + overflow: elide; + } + } +} + +component CommandParamInsertButton inherits Rectangle { + in property label; + callback clicked(); + + width: 44px; + height: 24px; + border-radius: Theme.radius-sm; + background: touch.has-hover ? Theme.bg-hover : Theme.bg-elevated; + + touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.clicked(); } + } + + Text { + width: parent.width; + height: parent.height; + text: root.label; + color: Theme.text-secondary; + font-size: Theme.fs-xs; + horizontal-alignment: center; + vertical-alignment: center; + } +} + +// --- CommandManager -------------------------------------------------------- +// Bottom dock for FinalShell-style custom commands. +component CommandManager inherits Rectangle { + in property <[CommandCategory]> command-categories: []; + in property <[CommandItem]> command-items: []; + in-out property selected-category; + in-out property selected-command; + in-out property category-draft; + in-out property edit-id; + in-out property edit-name; + in-out property edit-command; + in-out property edit-append-cr: true; + in-out property param1-name; + in-out property param1-value; + in-out property param2-name; + in-out property param2-value; + in-out property param3-name; + in-out property param3-value; + in-out property param4-name; + in-out property param4-value; + in-out property param5-name; + in-out property param5-value; + + callback category-select(string); + callback category-add(string); + callback category-rename(string, string); + callback category-delete(string); + callback command-new(string); + callback command-select(string); + callback command-save(string, string, string, string, bool); + callback command-delete(string); + callback command-run(string, string, string, string, string, string); + callback command-move(string, string); + + in-out property editor-open; + in-out property category-editor-open; + in-out property category-edit-id; + in-out property category-can-delete; + property params-missing: + (param1-name != "" && param1-value == "") + || (param2-name != "" && param2-value == "") + || (param3-name != "" && param3-value == "") + || (param4-name != "" && param4-value == "") + || (param5-name != "" && param5-value == ""); + property ctx-x; + property ctx-y; + + background: Theme.bg-panel; + + selected-name-probe := Text { + text: root.edit-name; + font-size: Theme.fs-sm; + opacity: 0; + x: -1000px; + y: -1000px; + } + + HorizontalLayout { + width: parent.width; + height: parent.height; + spacing: 0; + + // Categories -------------------------------------------------------- + Rectangle { + width: 178px; + background: Theme.bg-panel; + + VerticalLayout { + spacing: 0; + + Rectangle { + height: 28px; + background: Theme.bg-root; + header-ta := TouchArea { + mouse-cursor: default; + pointer-event(e) => { + if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) { + root.ctx-x = self.mouse-x; + root.ctx-y = self.mouse-y; + root.category-edit-id = ""; + root.category-draft = ""; + root.category-can-delete = false; + cat-new-menu.show(); + } + } + } + HorizontalLayout { + padding-left: 8px; + padding-right: 4px; + spacing: 4px; + Text { + text: @tr("Categories"); + color: Theme.text-muted; + font-size: Theme.fs-xs; + vertical-alignment: center; + horizontal-stretch: 1; + } + } + + cat-new-menu := PopupWindow { + x: root.ctx-x; + y: root.ctx-y; + width: 132px; + height: 36px; + + Rectangle { + background: Theme.bg-panel; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: Theme.border-strong; + drop-shadow-blur: 10px; + drop-shadow-color: #00000050; + + VerticalLayout { + padding: 4px; + MenuItem { + icon: "\u{E145}"; + label: @tr("New category"); + clicked => { + cat-new-menu.close(); + root.category-editor-open = true; + } + } + } + } + } + } + + Flickable { + vertical-stretch: 1; + viewport-width: self.width; + viewport-height: max(self.height, categories-col.preferred-height); + categories-col := VerticalLayout { + spacing: 0; + for cat[i] in root.command-categories : Rectangle { + height: 30px; + background: root.selected-category == cat.id + ? Theme.bg-active + : cat-ta.has-hover + ? Theme.bg-hover + : (mod(i, 2) == 0 ? transparent : #ffffff08); + cat-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.category-select(cat.id); } + pointer-event(e) => { + if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) { + root.ctx-x = self.mouse-x; + root.ctx-y = self.mouse-y; + root.category-edit-id = cat.id; + root.category-draft = cat.name; + root.category-can-delete = !cat.builtin; + cat-menu.show(); + } + } + } + cat-menu := PopupWindow { + x: root.ctx-x; + y: root.ctx-y; + width: 132px; + height: cat.builtin ? 64px : 92px; + + Rectangle { + background: Theme.bg-panel; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: Theme.border-strong; + drop-shadow-blur: 10px; + drop-shadow-color: #00000050; + + VerticalLayout { + padding: 4px; + spacing: 2px; + MenuItem { + icon: "\u{E145}"; + label: @tr("New category"); + clicked => { + cat-menu.close(); + root.category-edit-id = ""; + root.category-draft = ""; + root.category-can-delete = false; + root.category-editor-open = true; + } + } + MenuItem { + icon: "\u{E3C9}"; + label: @tr("Rename"); + clicked => { + cat-menu.close(); + root.category-editor-open = true; + } + } + if !cat.builtin : MenuItem { + icon: "\u{E872}"; + label: @tr("Delete"); + clicked => { + cat-menu.close(); + root.category-delete(cat.id); + } + } + } + } + } + HorizontalLayout { + height: parent.height; + padding-left: 8px; + padding-right: 4px; + spacing: 6px; + Text { + width: 16px; + height: parent.height; + text: "\u{E2C7}"; + font-family: "Material Icons"; + color: Theme.text-secondary; + font-size: Theme.fs-xs; + horizontal-alignment: center; + vertical-alignment: center; + } + Text { + text: cat.name; + color: root.selected-category == cat.id ? Theme.text-primary : Theme.text-secondary; + font-size: Theme.fs-sm; + vertical-alignment: center; + horizontal-stretch: 1; + overflow: elide; + } + } + } + } + } + } + } + + Rectangle { width: 1px; background: Theme.border-subtle; } + + // Commands + parameter runner -------------------------------------- + VerticalLayout { + horizontal-stretch: 1; + spacing: 0; + + Rectangle { + height: 38px; + background: Theme.bg-root; + HorizontalLayout { + height: parent.height; + padding-left: 8px; + padding-right: 8px; + spacing: 8px; + alignment: center; + + if root.selected-command != "" : Text { + height: parent.height; + text: root.edit-name; + color: Theme.text-primary; + font-size: Theme.fs-sm; + font-weight: 600; + vertical-alignment: center; + width: min(160px, max(52px, selected-name-probe.preferred-width)); + overflow: elide; + } + + if root.param1-name != "" : CommandParamField { + y: (parent.height - self.height) / 2; + width: 132px; + label: root.param1-name; + value <=> root.param1-value; + } + if root.param2-name != "" : CommandParamField { + y: (parent.height - self.height) / 2; + width: 132px; + label: root.param2-name; + value <=> root.param2-value; + } + if root.param3-name != "" : CommandParamField { + y: (parent.height - self.height) / 2; + width: 132px; + label: root.param3-name; + value <=> root.param3-value; + } + if root.param4-name != "" : CommandParamField { + y: (parent.height - self.height) / 2; + width: 132px; + label: root.param4-name; + value <=> root.param4-value; + } + if root.param5-name != "" : CommandParamField { + y: (parent.height - self.height) / 2; + width: 132px; + label: root.param5-name; + value <=> root.param5-value; + } + + if root.params-missing : Text { + height: parent.height; + text: @tr("Missing parameters"); + color: Theme.danger; + font-size: Theme.fs-xs; + font-weight: 600; + vertical-alignment: center; + } + + Rectangle { horizontal-stretch: 1; } + } + } + + Rectangle { height: 1px; background: Theme.border-subtle; } + + Rectangle { + vertical-stretch: 1; + background: Theme.bg-panel; + + cmd-bg-ta := TouchArea { + pointer-event(e) => { + if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) { + root.ctx-x = self.mouse-x; + root.ctx-y = self.mouse-y; + cmd-new-menu.show(); + } + } + } + + Flickable { + width: parent.width; + height: parent.height; + horizontal-stretch: 1; + vertical-stretch: 1; + viewport-width: max(self.width, commands-row.preferred-width); + viewport-height: self.height; + + commands-row := HorizontalLayout { + padding-left: 8px; + padding-right: 8px; + padding-top: 8px; + spacing: 6px; + + for cmd[i] in root.command-items : Rectangle { + visible: cmd.category-id == root.selected-category; + width: cmd.category-id == root.selected-category + ? min(168px, max(48px, cmd-name-probe.preferred-width + 24px)) + : 0px; + height: cmd.category-id == root.selected-category ? 28px : 0px; + border-radius: Theme.radius-sm; + background: root.selected-command == cmd.id + ? Theme.bg-active + : cmd-row-ta.has-hover + ? Theme.bg-hover + : Theme.bg-elevated; + cmd-row-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + if (root.selected-command != cmd.id) { + root.command-select(cmd.id); + } + } + pointer-event(e) => { + if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) { + root.ctx-x = self.mouse-x; + root.ctx-y = self.mouse-y; + if (root.selected-command != cmd.id) { + root.command-select(cmd.id); + } + cmd-menu.show(); + } + } + double-clicked => { + root.command-run(cmd.id, root.param1-value, root.param2-value, root.param3-value, root.param4-value, root.param5-value); + } + } + cmd-menu := PopupWindow { + x: root.ctx-x; + y: root.ctx-y; + width: 132px; + height: 92px; + + Rectangle { + background: Theme.bg-panel; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: Theme.border-strong; + drop-shadow-blur: 10px; + drop-shadow-color: #00000050; + + VerticalLayout { + padding: 4px; + spacing: 2px; + MenuItem { + icon: "\u{E037}"; + label: @tr("Run"); + clicked => { + cmd-menu.close(); + root.command-run(cmd.id, root.param1-value, root.param2-value, root.param3-value, root.param4-value, root.param5-value); + } + } + MenuItem { + icon: "\u{E3C9}"; + label: @tr("Edit"); + clicked => { + cmd-menu.close(); + root.editor-open = true; + } + } + MenuItem { + icon: "\u{E872}"; + label: @tr("Delete"); + clicked => { + cmd-menu.close(); + root.command-delete(cmd.id); + } + } + } + } + } + + cmd-name-probe := Text { + text: cmd.name; + font-size: Theme.fs-sm; + opacity: 0; + x: -1000px; + y: -1000px; + } + + Text { + x: 10px; + width: parent.width - 20px; + height: parent.height; + text: cmd.name; + color: Theme.text-primary; + font-size: Theme.fs-sm; + vertical-alignment: center; + horizontal-alignment: center; + overflow: elide; + } + + if cmd.append-cr : Rectangle { + x: parent.width - 7px; + y: 5px; + width: 4px; + height: 4px; + border-radius: 2px; + background: Theme.success; + } + } + } + } + + cmd-new-menu := PopupWindow { + x: root.ctx-x; + y: root.ctx-y; + width: 132px; + height: 36px; + + Rectangle { + background: Theme.bg-panel; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: Theme.border-strong; + drop-shadow-blur: 10px; + drop-shadow-color: #00000050; + + VerticalLayout { + padding: 4px; + MenuItem { + icon: "\u{E145}"; + label: @tr("New command"); + clicked => { + cmd-new-menu.close(); + root.command-new(root.selected-category); + root.editor-open = true; + } + } + } + } + } + } + } + } + + if false : Rectangle { + width: parent.width; + height: parent.height; + background: #00000088; + + close-backdrop := TouchArea { + clicked => { root.editor-open = false; } + } + + Rectangle { + x: 24px; + y: 8px; + width: parent.width - 48px; + height: parent.height - 16px; + border-radius: Theme.radius-md; + border-width: 1px; + border-color: Theme.border-strong; + background: Theme.bg-panel; + drop-shadow-blur: 16px; + drop-shadow-color: #00000070; + + swallow := TouchArea { clicked => {} } + + VerticalLayout { + padding: 10px; + spacing: 8px; + + HorizontalLayout { + height: 26px; + spacing: 8px; + Text { + text: root.edit-id == "" ? @tr("New command") : @tr("Command detail"); + color: Theme.text-primary; + font-size: Theme.fs-sm; + font-weight: 600; + vertical-alignment: center; + horizontal-stretch: 1; + } + CommandIconButton { + y: (parent.height - self.height) / 2; + glyph: "\u{E5CD}"; + clicked => { root.editor-open = false; } + } + } + + HorizontalLayout { + height: 28px; + spacing: 8px; + Rectangle { + horizontal-stretch: 1; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: name-input.has-focus ? Theme.accent : Theme.border-subtle; + background: Theme.bg-root; + name-input := TextInput { + x: 8px; + width: parent.width - 16px; + height: parent.height; + text <=> root.edit-name; + color: Theme.text-primary; + font-size: Theme.fs-sm; + single-line: true; + vertical-alignment: center; + } + } + Rectangle { + width: 118px; + height: parent.height; + border-radius: Theme.radius-sm; + background: root.edit-append-cr ? Theme.bg-active : Theme.bg-elevated; + cr-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.edit-append-cr = !root.edit-append-cr; } + } + HorizontalLayout { + height: parent.height; + padding-left: 8px; + padding-right: 8px; + spacing: 6px; + Text { + width: 16px; + height: parent.height; + text: root.edit-append-cr ? "\u{E5CA}" : ""; + font-family: "Material Icons"; + color: Theme.accent; + font-size: Theme.fs-xs; + horizontal-alignment: center; + vertical-alignment: center; + } + Text { + height: parent.height; + text: @tr("Append CR"); + color: Theme.text-secondary; + font-size: Theme.fs-xs; + vertical-alignment: center; + } + } + } + } + + Rectangle { + height: 52px; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: command-input.has-focus ? Theme.accent : Theme.border-subtle; + background: Theme.bg-root; + command-input := TextInput { + x: 8px; + width: parent.width - 16px; + height: parent.height; + text <=> root.edit-command; + color: Theme.text-primary; + font-size: Theme.fs-sm; + vertical-alignment: center; + } + } + + HorizontalLayout { + height: 24px; + spacing: 6px; + CommandParamInsertButton { + label: "p#1"; + clicked => { root.edit-command = root.edit-command + (root.edit-command == "" ? "[p#1 参数1]" : " [p#1 参数1]"); } + } + CommandParamInsertButton { + label: "p#2"; + clicked => { root.edit-command = root.edit-command + (root.edit-command == "" ? "[p#2 参数2]" : " [p#2 参数2]"); } + } + CommandParamInsertButton { + label: "p#3"; + clicked => { root.edit-command = root.edit-command + (root.edit-command == "" ? "[p#3 参数3]" : " [p#3 参数3]"); } + } + CommandParamInsertButton { + label: "p#4"; + clicked => { root.edit-command = root.edit-command + (root.edit-command == "" ? "[p#4 参数4]" : " [p#4 参数4]"); } + } + CommandParamInsertButton { + label: "p#5"; + clicked => { root.edit-command = root.edit-command + (root.edit-command == "" ? "[p#5 参数5]" : " [p#5 参数5]"); } + } + Rectangle { horizontal-stretch: 1; } + } + + if root.command-categories.length > 1 && root.edit-id != "" : HorizontalLayout { + height: 24px; + spacing: 4px; + for cat in root.command-categories : Rectangle { + y: (parent.height - self.height) / 2; + width: 72px; + height: 22px; + border-radius: Theme.radius-sm; + background: cat.id == root.selected-category + ? Theme.bg-active + : (move-cat-ta.has-hover ? Theme.bg-hover : Theme.bg-elevated); + move-cat-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.command-move(root.edit-id, cat.id); } + } + Text { + width: parent.width; + height: parent.height; + text: cat.name; + color: Theme.text-secondary; + font-size: Theme.fs-xs; + horizontal-alignment: center; + vertical-alignment: center; + overflow: elide; + } + } + } + + Rectangle { vertical-stretch: 1; } + + HorizontalLayout { + height: 28px; + spacing: 8px; + if root.edit-id != "" : Rectangle { + width: 72px; + height: parent.height; + border-radius: Theme.radius-sm; + background: del-cmd-ta.has-hover ? #cc333320 : Theme.bg-elevated; + del-cmd-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.command-delete(root.edit-id); + root.editor-open = false; + } + } + HorizontalLayout { + height: parent.height; + padding-left: 10px; + padding-right: 10px; + spacing: 6px; + Text { + width: 16px; + height: parent.height; + text: "\u{E872}"; + font-family: "Material Icons"; + color: Theme.danger; + font-size: Theme.fs-xs; + horizontal-alignment: center; + vertical-alignment: center; + } + Text { + height: parent.height; + text: @tr("Delete"); + color: Theme.danger; + font-size: Theme.fs-xs; + vertical-alignment: center; + } + } + } + Rectangle { horizontal-stretch: 1; } + Rectangle { + width: 72px; + height: parent.height; + border-radius: Theme.radius-sm; + background: cancel-ta.has-hover ? Theme.bg-hover : Theme.bg-elevated; + cancel-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.editor-open = false; } + } + Text { + width: parent.width; + height: parent.height; + text: @tr("Cancel"); + color: Theme.text-secondary; + font-size: Theme.fs-xs; + horizontal-alignment: center; + vertical-alignment: center; + } + } + Rectangle { + width: 72px; + height: parent.height; + border-radius: Theme.radius-sm; + background: save-cmd-ta.has-hover ? Theme.accent-hover : Theme.accent; + save-cmd-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.command-save(root.edit-id, root.selected-category, root.edit-name, root.edit-command, root.edit-append-cr); + root.editor-open = false; + } + } + Text { + width: parent.width; + height: parent.height; + text: @tr("Save"); + color: #ffffff; + font-size: Theme.fs-xs; + horizontal-alignment: center; + vertical-alignment: center; + } + } + } + } + } + } + +} + +component CommandEditorModal inherits Rectangle { + in-out property is-open; + in property <[CommandCategory]> command-categories: []; + in-out property selected-category; + in-out property edit-id; + in-out property edit-name; + in-out property edit-command; + in-out property edit-append-cr: true; + callback command-save(string, string, string, string, bool); + callback command-delete(string); + callback command-move(string, string); + + visible: is-open; + background: #00000088; + + TouchArea { + clicked => { root.is-open = false; } + } + + Rectangle { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + width: min(540px, parent.width - 48px); + height: 260px; + border-radius: Theme.radius-md; + border-width: 1px; + border-color: Theme.border-strong; + background: Theme.bg-panel; + drop-shadow-blur: 18px; + drop-shadow-color: #00000080; + + TouchArea {} + + VerticalLayout { + padding: 12px; + spacing: 8px; + + HorizontalLayout { + height: 26px; + spacing: 8px; + Text { + text: root.edit-id == "" ? @tr("New command") : @tr("Command detail"); + color: Theme.text-primary; + font-size: Theme.fs-sm; + font-weight: 600; + vertical-alignment: center; + horizontal-stretch: 1; + } + CommandIconButton { + y: (parent.height - self.height) / 2; + glyph: "\u{E5CD}"; + clicked => { root.is-open = false; } + } + } + + HorizontalLayout { + height: 28px; + spacing: 8px; + Rectangle { + horizontal-stretch: 1; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: name-input.has-focus ? Theme.accent : Theme.border-subtle; + background: Theme.bg-root; + name-input := TextInput { + x: 8px; + width: parent.width - 16px; + height: parent.height; + text <=> root.edit-name; + color: Theme.text-primary; + font-size: Theme.fs-sm; + single-line: true; + vertical-alignment: center; + } + } + Rectangle { + width: 118px; + height: parent.height; + border-radius: Theme.radius-sm; + background: root.edit-append-cr ? Theme.bg-active : Theme.bg-elevated; + cr-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.edit-append-cr = !root.edit-append-cr; } + } + HorizontalLayout { + height: parent.height; + padding-left: 8px; + padding-right: 8px; + spacing: 6px; + Text { + width: 16px; + height: parent.height; + text: root.edit-append-cr ? "\u{E5CA}" : ""; + font-family: "Material Icons"; + color: Theme.accent; + font-size: Theme.fs-xs; + horizontal-alignment: center; + vertical-alignment: center; + } + Text { + height: parent.height; + text: @tr("Append CR"); + color: Theme.text-secondary; + font-size: Theme.fs-xs; + vertical-alignment: center; + } + } + } + } + + Rectangle { + height: 52px; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: command-input.has-focus ? Theme.accent : Theme.border-subtle; + background: Theme.bg-root; + command-input := TextInput { + x: 8px; + width: parent.width - 16px; + height: parent.height; + text <=> root.edit-command; + color: Theme.text-primary; + font-size: Theme.fs-sm; + vertical-alignment: center; + } + } + + HorizontalLayout { + height: 24px; + spacing: 6px; + CommandParamInsertButton { + label: "p#1"; + clicked => { root.edit-command = root.edit-command + (root.edit-command == "" ? "[p#1 参数1]" : " [p#1 参数1]"); } + } + CommandParamInsertButton { + label: "p#2"; + clicked => { root.edit-command = root.edit-command + (root.edit-command == "" ? "[p#2 参数2]" : " [p#2 参数2]"); } + } + CommandParamInsertButton { + label: "p#3"; + clicked => { root.edit-command = root.edit-command + (root.edit-command == "" ? "[p#3 参数3]" : " [p#3 参数3]"); } + } + CommandParamInsertButton { + label: "p#4"; + clicked => { root.edit-command = root.edit-command + (root.edit-command == "" ? "[p#4 参数4]" : " [p#4 参数4]"); } + } + CommandParamInsertButton { + label: "p#5"; + clicked => { root.edit-command = root.edit-command + (root.edit-command == "" ? "[p#5 参数5]" : " [p#5 参数5]"); } + } + Rectangle { horizontal-stretch: 1; } + } + + if root.command-categories.length > 1 && root.edit-id != "" : HorizontalLayout { + height: 24px; + spacing: 4px; + for cat in root.command-categories : Rectangle { + y: (parent.height - self.height) / 2; + width: 72px; + height: 22px; + border-radius: Theme.radius-sm; + background: cat.id == root.selected-category + ? Theme.bg-active + : (move-cat-ta.has-hover ? Theme.bg-hover : Theme.bg-elevated); + move-cat-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.command-move(root.edit-id, cat.id); } + } + Text { + width: parent.width; + height: parent.height; + text: cat.name; + color: Theme.text-secondary; + font-size: Theme.fs-xs; + horizontal-alignment: center; + vertical-alignment: center; + overflow: elide; + } + } + } + + Rectangle { vertical-stretch: 1; } + + HorizontalLayout { + height: 28px; + spacing: 8px; + if root.edit-id != "" : Rectangle { + width: 72px; + height: parent.height; + border-radius: Theme.radius-sm; + background: del-cmd-ta.has-hover ? #cc333320 : Theme.bg-elevated; + del-cmd-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.command-delete(root.edit-id); + root.is-open = false; + } + } + Text { + width: parent.width; + height: parent.height; + text: @tr("Delete"); + color: Theme.danger; + font-size: Theme.fs-xs; + horizontal-alignment: center; + vertical-alignment: center; + } + } + Rectangle { horizontal-stretch: 1; } + Rectangle { + width: 72px; + height: parent.height; + border-radius: Theme.radius-sm; + background: cancel-ta.has-hover ? Theme.bg-hover : Theme.bg-elevated; + cancel-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.is-open = false; } + } + Text { + width: parent.width; + height: parent.height; + text: @tr("Cancel"); + color: Theme.text-secondary; + font-size: Theme.fs-xs; + horizontal-alignment: center; + vertical-alignment: center; + } + } + Rectangle { + width: 72px; + height: parent.height; + border-radius: Theme.radius-sm; + background: save-cmd-ta.has-hover ? Theme.accent-hover : Theme.accent; + save-cmd-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.command-save(root.edit-id, root.selected-category, root.edit-name, root.edit-command, root.edit-append-cr); + root.is-open = false; + } + } + Text { + width: parent.width; + height: parent.height; + text: @tr("Save"); + color: #ffffff; + font-size: Theme.fs-xs; + horizontal-alignment: center; + vertical-alignment: center; + } + } + } + } + } + +} + +component CategoryEditorModal inherits Rectangle { + in-out property is-open; + in-out property edit-id; + in-out property draft; + in property can-delete: false; + callback category-add(string); + callback category-rename(string, string); + callback category-delete(string); + + visible: is-open; + background: #00000088; + + TouchArea { + clicked => { root.is-open = false; } + } + + Rectangle { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + width: min(360px, parent.width - 48px); + height: 148px; + border-radius: Theme.radius-md; + border-width: 1px; + border-color: Theme.border-strong; + background: Theme.bg-panel; + drop-shadow-blur: 18px; + drop-shadow-color: #00000080; + + TouchArea {} + + VerticalLayout { + padding: 12px; + spacing: 10px; + + HorizontalLayout { + height: 24px; + Text { + text: root.edit-id == "" ? @tr("New category") : @tr("Rename category"); + color: Theme.text-primary; + font-size: Theme.fs-sm; + font-weight: 600; + vertical-alignment: center; + horizontal-stretch: 1; + } + CommandIconButton { + y: (parent.height - self.height) / 2; + glyph: "\u{E5CD}"; + clicked => { root.is-open = false; } + } + } + + Rectangle { + height: 28px; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: category-input.has-focus ? Theme.accent : Theme.border-subtle; + background: Theme.bg-root; + category-input := TextInput { + x: 8px; + width: parent.width - 16px; + height: parent.height; + text <=> root.draft; + color: Theme.text-primary; + font-size: Theme.fs-sm; + single-line: true; + vertical-alignment: center; + } + } + + HorizontalLayout { + height: 28px; + spacing: 8px; + if root.can-delete : Rectangle { + width: 72px; + height: parent.height; + border-radius: Theme.radius-sm; + background: cat-delete-ta.has-hover ? #cc333320 : Theme.bg-elevated; + cat-delete-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.category-delete(root.edit-id); + root.is-open = false; + } + } + Text { + width: parent.width; + height: parent.height; + text: @tr("Delete"); + color: Theme.danger; + font-size: Theme.fs-xs; + horizontal-alignment: center; + vertical-alignment: center; + } + } + Rectangle { horizontal-stretch: 1; } + Rectangle { + width: 72px; + height: parent.height; + border-radius: Theme.radius-sm; + background: cat-cancel-ta.has-hover ? Theme.bg-hover : Theme.bg-elevated; + cat-cancel-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.is-open = false; } + } + Text { + width: parent.width; + height: parent.height; + text: @tr("Cancel"); + color: Theme.text-secondary; + font-size: Theme.fs-xs; + horizontal-alignment: center; + vertical-alignment: center; + } + } + Rectangle { + width: 72px; + height: parent.height; + border-radius: Theme.radius-sm; + background: cat-save-ta.has-hover ? Theme.accent-hover : Theme.accent; + cat-save-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + if (root.edit-id == "") { + root.category-add(root.draft); + } else { + root.category-rename(root.edit-id, root.draft); + } + root.is-open = false; + } + } + Text { + width: parent.width; + height: parent.height; + text: @tr("Save"); + color: #ffffff; + font-size: Theme.fs-xs; + horizontal-alignment: center; + vertical-alignment: center; + } + } + } + } + } +} + +// --- TerminalView ---------------------------------------------------------- + +export component TerminalView inherits Rectangle { + in property tab-id; + in property status-line: @tr("Not connected"); + in property <[TermSpan]> spans; // coloured runs of the visible screen + in property cursor-row; // cursor cell (grid coords) + in property cursor-col; + in property rows-used; // content rows, for viewport sizing + in property is-alt-screen; + in property mouse-tracking: false; + in property <[TermMatch]> find-matches; // search-highlight rectangles + in property <[TermMatch]> selection; // drag-selection highlight rectangles + + in property sftp-path: "/"; + in property <[SftpEntry]> sftp-entries; + in property sftp-status: ""; + in property sftp-loading: false; + in property <[SftpTreeNode]> sftp-tree-nodes: []; + in property terminal-font-size: 13px; + in property click-debug-enabled: false; + in property background-path; + in property background-mode: 1; + in property background-image; + in property app-background-active: false; + in property terminal-bg-opacity: 0.88; + in property ui-opacity: 0.72; + in property sftp-file-font-size: 16px; + in-out property sftp-name-column-width: 180px; + in-out property sftp-name-width-manual: false; + in property <[CommandCategory]> command-categories: []; + in property <[CommandItem]> command-items: []; + in-out property command-selected-category; + in-out property command-selected-id; + in-out property command-category-draft; + in-out property command-edit-id; + in-out property command-edit-name; + in-out property command-edit-command; + in-out property command-edit-append-cr: true; + in-out property command-param1-name; + in-out property command-param1-value; + in-out property command-param2-name; + in-out property command-param2-value; + in-out property command-param3-name; + in-out property command-param3-value; + in-out property command-param4-name; + in-out property command-param4-value; + in-out property command-param5-name; + in-out property command-param5-value; + property command-editor-open: false; + property command-category-editor-open: false; + property command-category-edit-id; + property command-category-can-delete: false; + + in-out property sftp-panel-height: 220px; + + callback send-key(string, bool, bool, bool); + callback terminal-resize(float, float); + callback sftp-navigate(string); + callback sftp-download(string); + callback sftp-upload-clicked(string); + callback sftp-refresh(string); + callback sftp-tree-expand(string); + callback sftp-delete(string); + callback sftp-rename(string, string); + callback sftp-edit(string); + callback sftp-name-column-width-changed(float); + callback sftp-name-column-auto(); + callback paste-from-clipboard(); + callback copy-terminal-text(); + callback clear-terminal(); // wipe this session's screen buffer + callback find-query-changed(string); // recompute search highlights + callback terminal-scroll(int); // scroll history by N lines (+ = up) + callback select-start(int, int); // begin drag-selection at (row, col) + callback select-update(int, int); // extend drag-selection to (row, col) + callback select-end(); // finish selection → copy to clipboard + callback select-autoscroll(int); // auto-scroll while dragging past edge (dir: -1 up / +1 down) + callback command-category-select(string); + callback command-category-add(string); + callback command-category-rename(string, string); + callback command-category-delete(string); + callback command-new(string); + callback command-select(string); + callback command-save(string, string, string, string, bool); + callback command-delete(string); + callback command-run(string, string, string, string, string, string); + callback command-move(string, string); + callback terminal-mouse-event(string, int, int, int); + callback debug-click(string, string); + + // Right-click context-menu anchor + find-bar visibility (private UI state). + property ctx-x; + property ctx-y; + property find-active: false; + property selecting: false; + property mouse-drag-button: -1; + property mouse-drag-start-x: 0px; + property mouse-drag-start-y: 0px; + property mouse-drag-started: false; + // Auto-scroll direction while drag-selecting past the visible edge. + // 0 = none, -1 = above top (scroll to older), +1 = below bottom (newer). + property autoscroll-dir: 0; + property vis-y: 0px; // scratch: cursor Y within the visible viewport + property tool-tab: 0; // 0 files / 1 commands + property sent-cols: -1; + property sent-rows: -1; + + background: root.app-background-active + ? Theme.term-bg.with-alpha(root.terminal-bg-opacity) + : root.background-mode == 1 && root.background-path != "" + ? Theme.term-bg.with-alpha(root.terminal-bg-opacity) + : Theme.term-bg; + forward-focus: ime-input; + + if root.background-mode == 1 && root.background-path != "" : Image { + width: parent.width; + height: parent.height; + source: root.background-image; + image-fit: cover; + opacity: 0.34; + } + + // Drag state (private) + property sftp-dragging: false; + property drag-start-mouse-y: 0px; + + // --- Real cell-size measurement ---------------------------------------- + // A hidden Text rendered with the exact terminal font reports the true + // Consolas cell width/height for the current font size. We derive the PTY + // column/row counts from these measured cells instead of guessing 8×16px, + // so full-screen programs (nano/vim) get an accurate row count and their + // bottom shortcut bar is no longer clipped off the visible area. + // Measure a 50-char run, not a single glyph: a single Text's preferred-width + // includes left/right side bearings and is wider than the real per-character + // advance, which would make absolutely-positioned spans drift apart. The + // average advance over a long run (side bearings amortised) is accurate. + // Cell-size probe: must be RENDERED (opacity:0, not visible:false) so Slint + // resolves the correct embedded font before computing preferred-width/height. + // visible:false can skip font loading and fall back to a system font (e.g. + // Consolas ~6.5 px/char instead of Cascadia Mono ~7.6 px/char), giving a + // cell-w that is too narrow and a term-cols that is too large. + cell-probe := Text { + text: "MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM"; // exactly 50×M + font-family: "Cascadia Mono"; + font-size: root.terminal-font-size; + // Keep it in layout but invisible: opacity:0 forces full font loading. + opacity: 0; + // Park it at y<0 so it never overlaps terminal content. + y: -200px; + width: 0px; // zero out layout contribution + height: 0px; + } + // cell-w / cell-h come straight from the probe so they ALWAYS match the + // font the spans below are actually rendered with. (Forcing a hard-coded + // minimum here is dangerous: if the spans fall back to a system font, a + // mismatched cell-w garbles the grid. Keep them derived from one source.) + property cell-w: max(1px, cell-probe.preferred-width / 50); + // Guard band avoids clipping glyph ascenders/descenders during repaint. + property cell-h: max(1px, cell-probe.preferred-height + 2px); + // Usable text box = root.width minus the 10px left/right padding of the + // terminal grid. Derive the usable height from TerminalView's outer size and + // fixed chrome only. Reading key-capture.height or flickable.viewport-height + // here can feed Slint's preferred-size calculation back into itself when a + // terminal tab becomes visible, which trips "Recursion detected". + property key-capture-h: max( + 0px, + root.height - 24px - 4px - root.sftp-panel-height + ); + property terminal-viewport-h: root.key-capture-h; + property terminal-pad-y: 4px; + property terminal-grid-h: max(0px, root.terminal-viewport-h - root.terminal-pad-y * 2); + property terminal-content-h: root.is-alt-screen + ? (root.selecting ? max(root.terminal-grid-h, root.rows-used * root.cell-h) : root.terminal-grid-h) + : max(root.terminal-grid-h, root.rows-used * root.cell-h); + property terminal-scroll-h: root.terminal-content-h + root.terminal-pad-y * 2; + property term-cols: max(10, floor((root.width - 20px) / cell-w)); + property term-rows: max(5, floor(root.terminal-grid-h / cell-h)); + // Only resize the PTY while this tab is visible. Inactive tabs are laid out + // at width 0 (-> term-cols collapses to 10); resizing the remote to 10 + // columns reflows the vt100 screen and destroys the scrollback, so hidden + // sessions stay frozen at their last real size. + // + // Do not emit resize from init/changed term-cols/changed term-rows. Those + // handlers run while Slint is still solving the parent layout and force + // term-rows -> root.height -> parent layout-cache, which recursively asks + // this TerminalView for layout info. The timer fires after layout has + // settled and only forwards actual size changes. + Timer { + running: root.visible; + interval: 200ms; + triggered => { + if (root.term-cols != root.sent-cols || root.term-rows != root.sent-rows) { + root.sent-cols = root.term-cols; + root.sent-rows = root.term-rows; + root.terminal-resize(root.term-cols, root.term-rows); + } + } + } + + changed spans => { + // Bash: keep the latest output pinned to the bottom. + // Alt-screen (nano/vim/htop/btop): the program owns a fixed full screen, + // so the viewport must stay top-aligned (viewport-y = 0). Scrolling to + // the bottom would push the title bar + content off the top, leaving only + // the program's status bar — exactly the "nano shows nothing" symptom. + if (!root.is-alt-screen && !root.selecting) { + flickable.viewport-y = flickable.height - flickable.viewport-height; + } else if (!root.selecting) { + flickable.viewport-y = 0; + } + } + changed is-alt-screen => { + // Reset scroll on every mode switch (entering/leaving nano, vim, btop…). + // Snapping to top on alt-screen entry ensures the full-screen program's + // frame is shown from row 0, not from wherever history left off. + if (root.is-alt-screen) { + flickable.viewport-y = 0; + } else { + // Return to normal mode: pin to the most recent output. + flickable.viewport-y = flickable.height - flickable.viewport-height; + } + } + changed visible => { + if self.visible { + // Force the timer to re-sync the PTY size on return. + root.sent-cols = -1; + root.sent-rows = -1; + if (!root.command-editor-open && !root.command-category-editor-open) { + ime-input.focus(); + } + } + } + + VerticalLayout { + spacing: 0; + + // --- Status strip --------------------------------------------------- + Rectangle { + height: 24px; + background: root.app-background-active ? Theme.bg-panel-alt.with-alpha(root.ui-opacity) : Theme.bg-panel-alt; + HorizontalLayout { + padding-left: 10px; padding-right: 10px; + Text { + text: root.is-alt-screen ? @tr("Running in alternate screen") : ""; + color: Theme.text-secondary; + font-size: Theme.fs-xs; + vertical-alignment: center; + horizontal-stretch: 1; + } + // Alt-screen indicator only. IMPORTANT: never put cell-w / cell-h + // (a Text's preferred-size) inside another Text's `text` — doing so + // makes Slint compute one text layout *inside* another, which + // re-enters parley's FontContext RefCell and panics ("already + // borrowed"). A plain bool flag is safe. + if root.is-alt-screen : Text { + text: "[ALT]"; + color: Theme.success; + font-size: Theme.fs-xs; + vertical-alignment: center; + } + } + } + + // --- Key-capture scope + scrollable output -------------------------- + key-capture := FocusScope { + vertical-stretch: 1; + // Any click inside this scope (e.g. the middle-click paste strip) + // can grab focus to the FocusScope itself. Key forwarding lives on + // the hidden `ime-input` TextInput, so a bare FocusScope focus means + // the cursor stops blinking and typing stops working. Forwarding + // focus to ime-input guarantees the text input always ends up with + // the keyboard focus, no matter what grabbed it. + forward-focus: ime-input; + property cursor-blink: true; + + Timer { + running: ime-input.has-focus; + interval: 530ms; + triggered => { key-capture.cursor-blink = !key-capture.cursor-blink; } + } + + // Auto-scroll while the drag-selection is held past the top/bottom + // edge: ticks repeatedly so the scrollback keeps moving even when the + // mouse is held still beyond the edge. + Timer { + running: root.selecting && root.autoscroll-dir != 0; + interval: 50ms; + triggered => { root.select-autoscroll(root.autoscroll-dir); } + } + + VerticalLayout { + spacing: 0; + + flickable := Flickable { + vertical-stretch: 1; + viewport-width: self.width; + // Alt-screen: lock the viewport to exactly the visible height + // so the full-screen program (nano/vim) renders top-aligned + // with no internal scroll. Bash: let the viewport grow with + // the content so we can scroll back through history. + viewport-height: root.terminal-scroll-h; + + // The terminal grid. Each coloured run is placed with + // absolute coordinates derived from its (row, col) and the + // measured Consolas cell size — no per-line layout needed, + // which keeps the model flat (a single `for`). + Rectangle { + x: 10px; + y: root.terminal-pad-y; + width: parent.width - 20px; + height: root.terminal-content-h; + + // Click-to-focus over the whole terminal body. Without + // this, clicking anywhere in the output area blurs the + // hidden ime-input (Slint clears focus on a click that + // lands on a non-focusable element), which makes the + // cursor vanish and keystrokes stop — recoverable only by + // reopening the tab. This TouchArea sits *below* the text + // (Text never grabs pointer events, so clicks fall through + // to it) and restores focus on every click. In mouse- + // tracking apps such as tmux, unmodified mouse input is + // forwarded; Shift+drag/Shift+right-click stay local. + body-touch := TouchArea { + mouse-cursor: text; + pointer-event(ev) => { + if (ev.kind == PointerEventKind.down) { + // Any button press re-takes keyboard focus. + ime-input.focus(); + if (root.mouse-tracking && !ev.modifiers.shift) { + root.mouse-drag-button = + ev.button == PointerEventButton.left ? 0 + : ev.button == PointerEventButton.middle ? 1 + : 2; + root.mouse-drag-start-x = self.mouse-x; + root.mouse-drag-start-y = self.mouse-y; + root.mouse-drag-started = false; + root.terminal-mouse-event( + "down", + root.mouse-drag-button, + floor(self.mouse-y / root.cell-h), + floor(self.mouse-x / root.cell-w)); + } else if (ev.button == PointerEventButton.right) { + root.ctx-x = self.mouse-x; + root.ctx-y = self.mouse-y; + ctx-menu.show(); + } else if (ev.button == PointerEventButton.left + && (!root.mouse-tracking || ev.modifiers.shift)) { + root.selecting = true; + root.select-start( + floor(self.mouse-y / root.cell-h), + floor(self.mouse-x / root.cell-w)); + } + } else if (ev.kind == PointerEventKind.up) { + if (ev.button == PointerEventButton.middle) { + if (root.mouse-tracking && !ev.modifiers.shift) { + root.terminal-mouse-event( + "up", 1, + floor(self.mouse-y / root.cell-h), + floor(self.mouse-x / root.cell-w)); + root.mouse-drag-button = -1; + root.mouse-drag-started = false; + } else { + root.paste-from-clipboard(); + } + ime-input.focus(); + } else if (ev.button == PointerEventButton.left + && root.selecting) { + root.selecting = false; + root.autoscroll-dir = 0; + root.select-end(); + } else if (root.mouse-tracking && !ev.modifiers.shift) { + root.terminal-mouse-event( + "up", + ev.button == PointerEventButton.left ? 0 + : ev.button == PointerEventButton.middle ? 1 + : 2, + floor(self.mouse-y / root.cell-h), + floor(self.mouse-x / root.cell-w)); + root.mouse-drag-button = -1; + root.mouse-drag-started = false; + } + } + } + moved => { + if (root.mouse-tracking && !self.pressed && root.mouse-drag-button < 0) { + root.terminal-mouse-event( + "move", + 3, + floor(self.mouse-y / root.cell-h), + floor(self.mouse-x / root.cell-w)); + } else if (root.mouse-tracking && root.mouse-drag-button >= 0) { + if (!root.mouse-drag-started + && abs(self.mouse-x - root.mouse-drag-start-x) < 2px + && abs(self.mouse-y - root.mouse-drag-start-y) < 2px) { + return; + } + root.mouse-drag-started = true; + root.terminal-mouse-event( + "drag", + root.mouse-drag-button, + floor(self.mouse-y / root.cell-h), + floor(self.mouse-x / root.cell-w)); + } else if (root.selecting) { + // Position of the cursor within the *visible* + // viewport (body-touch sits on the grid Rectangle + // at content y=8px; viewport-y offsets the scroll). + root.select-update( + floor(self.mouse-y / root.cell-h), + floor(self.mouse-x / root.cell-w)); + // vy ∈ [0, flickable.height] = where the mouse + // is inside the visible window. Shift+drag in + // tmux still uses local selection, so allow + // edge auto-scroll even on the alt screen. + root.vis-y = self.mouse-y + root.terminal-pad-y + flickable.viewport-y; + if (root.vis-y < root.cell-h) { + root.autoscroll-dir = -1; // past top → older + } else if (root.vis-y > flickable.height - root.cell-h) { + root.autoscroll-dir = 1; // past bottom → newer + } else { + root.autoscroll-dir = 0; + } + } + } + scroll-event(ev) => { + if (root.mouse-tracking) { + root.terminal-mouse-event( + "wheel", + ev.delta-y > 0 ? 64 : 65, + floor(self.mouse-y / root.cell-h), + floor(self.mouse-x / root.cell-w)); + } else { + // Wheel scrolls local scrollback (~3 lines per + // notch). Mouse-tracking apps such as tmux own + // their history, so wheel input is forwarded. + root.terminal-scroll(ev.delta-y > 0 ? 3 : -3); + flickable.viewport-y = clamp( + flickable.viewport-y + (ev.delta-y > 0 ? 3 * root.cell-h : -3 * root.cell-h), + 0px, + max(0px, flickable.viewport-height - flickable.height) + ); + } + accept + } + } + + // Drag-selection highlights (under the text, blue tint). + for s in root.selection : Rectangle { + x: s.col * root.cell-w; + y: s.row * root.cell-h; + width: s.len * root.cell-w; + height: root.cell-h; + background: Theme.accent.with-alpha(0.40); + } + + // Search-match highlights (behind the text). + for m in root.find-matches : Rectangle { + x: m.col * root.cell-w; + y: m.row * root.cell-h; + width: m.len * root.cell-w; + height: root.cell-h; + background: Theme.warning.with-alpha(0.45); + border-radius: 2px; + } + + // Each span = a background fill (transparent for the + // default bg) with its text on top. Wrapping the Text in + // the bg Rectangle keeps one element per span and renders + // btop/htop/mc panels, meter bars and graph fills. + for span in root.spans : Rectangle { + x: span.col * root.cell-w; + y: span.row * root.cell-h; + width: span.cells * root.cell-w; + height: root.cell-h; + background: span.bg; + // Keep text constrained to the exact terminal cells + // it occupies. Letting Text auto-size can overpaint + // the following absolute-positioned cells when the + // font fallback/wide-glyph metrics differ slightly. + Text { + x: 0px; + y: 0px; + width: parent.width; + height: parent.height; + text: span.text; + color: span.fg; + font-family: span.use-fallback-font ? Theme.font-ui : "Cascadia Mono"; + font-size: root.terminal-font-size; + font-weight: span.bold ? 700 : 400; + vertical-alignment: center; + } + } + + // Blinking block cursor (overlay, decoupled from text). + Rectangle { + x: root.cursor-col * root.cell-w; + y: root.cursor-row * root.cell-h; + width: root.cell-w; + height: root.cell-h; + background: Theme.term-fg; + visible: ime-input.has-focus && key-capture.cursor-blink; + } + + // --- Right-click context menu --------------------- + ctx-menu := PopupWindow { + x: root.ctx-x; + y: root.ctx-y; + width: 132px; + height: 132px; + Rectangle { + background: Theme.bg-panel; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: Theme.border-strong; + drop-shadow-blur: 12px; + drop-shadow-color: #00000060; + VerticalLayout { + padding: 4px; + spacing: 1px; + MenuItem { + icon: "\u{E14D}"; label: @tr("Copy"); + clicked => { + if root.click-debug-enabled { root.debug-click("terminal.context-menu", "copy"); } + root.copy-terminal-text(); + ime-input.focus(); + } + } + MenuItem { + icon: "\u{E14F}"; label: @tr("Paste"); + clicked => { + if root.click-debug-enabled { root.debug-click("terminal.context-menu", "paste"); } + root.paste-from-clipboard(); + ime-input.focus(); + } + } + MenuItem { + icon: "\u{E872}"; label: @tr("Clear buffer"); + clicked => { + if root.click-debug-enabled { root.debug-click("terminal.context-menu", "clear-buffer"); } + root.clear-terminal(); + ime-input.focus(); + } + } + MenuItem { + icon: "\u{E8B6}"; label: @tr("Find"); + clicked => { + if root.click-debug-enabled { root.debug-click("terminal.context-menu", "find"); } + root.find-active = true; + } + } + } + } + } + } + } + + } + + // --- Hidden TextInput — keyboard + Chinese IME capture ----------- + // + // Key design decision: return `accept` for EVERY key and forward + // them all via send-key. Returning `reject` makes Slint bubble the + // event without inserting the character into `text`, so `edited` + // never fires for regular keystrokes — only IME-committed chars + // (which go through a separate WM_IME_CHAR path) reach `edited`. + // + // By forwarding everything in key-pressed we guarantee English and + // all direct-mode input reaches the PTY. `edited` is kept as a + // safety net for IME commits that Windows delivers via WM_IME_CHAR + // rather than WM_CHAR (and therefore don't fire key-pressed). + ime-input := TextInput { + x: 0; + y: 0; + width: key-capture.width; + height: 1px; + opacity: 0; + single-line: true; + + // Reset blink phase on focus gain so cursor appears immediately. + changed has-focus => { + if (self.has-focus) { key-capture.cursor-blink = true; } + } + + key-pressed(event) => { + // ── Ctrl+Shift+C: copy terminal buffer ─────────────────── + if (event.modifiers.control && event.modifiers.shift + && (event.text == "c" || event.text == "C" + || event.text == "\u{0003}")) { + root.copy-terminal-text(); + accept + // ── Ctrl+V or Ctrl+Shift+V: paste from clipboard ───────── + // We accept Ctrl+V (with or without Shift) so the familiar + // Windows paste shortcut works in addition to Ctrl+Shift+V. + } else if (event.modifiers.control + && (event.text == "v" || event.text == "V" + || event.text == "\u{0016}")) { + root.paste-from-clipboard(); + // Clipboard access on Windows can deliver WM_KILLFOCUS. + // Restore focus explicitly so the cursor keeps blinking. + ime-input.focus(); + accept + // ── All other keys (printable, Ctrl+X, arrows, …) ──────── + // Return `accept` for everything: `reject` bubbles without + // inserting the char into TextInput, so `edited` never fires + // for regular keystrokes — the character would be lost. + } else { + root.send-key(event.text, event.modifiers.control, + event.modifiers.alt, event.modifiers.shift); + accept + } + } + + // Safety-net for IME commits delivered via WM_IME_CHAR (which + // may not fire key-pressed). Guard with preedit-text == "" to + // skip in-progress compositions. + edited => { + if (self.preedit-text == "" && self.text != "") { + root.send-key(self.text, false, false, false); + self.text = ""; + } + } + } + + // --- Find bar (overlay, top) ------------------------------------ + if root.find-active : Rectangle { + x: 0; + y: 0; + width: parent.width; + height: 34px; + background: Theme.bg-panel; + border-width: 1px; + border-color: Theme.border-strong; + border-radius: Theme.radius-sm; + + HorizontalLayout { + height: parent.height; + padding: 6px; + spacing: 6px; + Text { + y: (parent.height - self.height) / 2; + width: 18px; + height: 22px; + text: "\u{E8B6}"; + font-family: "Material Icons"; + color: Theme.text-secondary; + font-size: Theme.fs-sm; + vertical-alignment: center; + } + Rectangle { + y: (parent.height - self.height) / 2; + height: 22px; + background: Theme.bg-root; + border-radius: 3px; + horizontal-stretch: 1; + find-input := TextInput { + x: 6px; + width: parent.width - 12px; + height: parent.height; + color: Theme.text-primary; + font-size: Theme.fs-md; + vertical-alignment: center; + single-line: true; + // Grab focus as soon as the bar appears. + init => { self.focus(); } + edited => { root.find-query-changed(self.text); } + key-pressed(e) => { + if (e.text == "\u{001b}") { // Esc closes find + root.find-active = false; + root.find-query-changed(""); + self.text = ""; + ime-input.focus(); + accept + } else { + reject + } + } + } + } + Rectangle { + y: (parent.height - self.height) / 2; + width: 24px; + height: 22px; + close-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.find-active = false; + root.find-query-changed(""); + find-input.text = ""; + ime-input.focus(); + } + } + Text { + width: parent.width; + height: parent.height; + text: "\u{E5CD}"; + font-family: "Material Icons"; + color: close-ta.has-hover ? Theme.text-primary : Theme.text-secondary; + font-size: Theme.fs-sm; + horizontal-alignment: center; + vertical-alignment: center; + } + } + } + } + } + + // --- Drag handle ---------------------------------------------------- + Rectangle { + height: 4px; + background: divider-ta.has-hover || root.sftp-dragging + ? Theme.accent + : Theme.border-subtle; + + divider-ta := TouchArea { + mouse-cursor: row-resize; + + pointer-event(ev) => { + if (ev.kind == PointerEventKind.down) { + root.sftp-dragging = true; + root.drag-start-mouse-y = self.mouse-y; + } else if (ev.kind == PointerEventKind.up) { + root.sftp-dragging = false; + } + } + + moved => { + if (root.sftp-dragging) { + root.sftp-panel-height = clamp( + root.sftp-panel-height + root.drag-start-mouse-y - self.mouse-y, + 60px, + root.height - 24px - 4px - 16px - 80px + ); + } + } + } + } + + // --- Bottom tools: files / commands -------------------------------- + Rectangle { + height: root.sftp-panel-height; + background: root.app-background-active ? Theme.bg-panel.with-alpha(root.ui-opacity) : Theme.bg-panel; + clip: true; + + VerticalLayout { + spacing: 0; + + Rectangle { + height: 30px; + background: root.app-background-active ? Theme.bg-panel-alt.with-alpha(root.ui-opacity) : Theme.bg-panel-alt; + border-width: 1px; + border-color: Theme.border-subtle; + + HorizontalLayout { + height: parent.height; + padding-left: 8px; + padding-right: 8px; + spacing: 4px; + + Rectangle { + y: (parent.height - self.height) / 2; + width: 96px; + height: 24px; + border-radius: Theme.radius-sm; + background: root.tool-tab == 0 + ? Theme.bg-active + : (files-tab-ta.has-hover ? Theme.bg-hover : transparent); + files-tab-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + if root.click-debug-enabled { root.debug-click("terminal.bottom-tools", "files-tab"); } + root.tool-tab = 0; + } + } + HorizontalLayout { + height: parent.height; + padding-left: 8px; + padding-right: 8px; + spacing: 6px; + Text { + height: parent.height; + text: "\u{E2C7}"; + font-family: "Material Icons"; + color: root.tool-tab == 0 ? Theme.text-primary : Theme.text-secondary; + font-size: Theme.fs-sm; + vertical-alignment: center; + } + Text { + height: parent.height; + text: @tr("File management"); + color: root.tool-tab == 0 ? Theme.text-primary : Theme.text-secondary; + font-size: Theme.fs-sm; + vertical-alignment: center; + } + } + } + + Rectangle { + y: (parent.height - self.height) / 2; + width: 96px; + height: 24px; + border-radius: Theme.radius-sm; + background: root.tool-tab == 1 + ? Theme.bg-active + : (commands-tab-ta.has-hover ? Theme.bg-hover : transparent); + commands-tab-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + if root.click-debug-enabled { root.debug-click("terminal.bottom-tools", "commands-tab"); } + root.tool-tab = 1; + } + } + HorizontalLayout { + height: parent.height; + padding-left: 8px; + padding-right: 8px; + spacing: 6px; + Text { + height: parent.height; + text: "\u{E8F4}"; + font-family: "Material Icons"; + color: root.tool-tab == 1 ? Theme.text-primary : Theme.text-secondary; + font-size: Theme.fs-sm; + vertical-alignment: center; + } + Text { + height: parent.height; + text: @tr("Command management"); + color: root.tool-tab == 1 ? Theme.text-primary : Theme.text-secondary; + font-size: Theme.fs-sm; + vertical-alignment: center; + } + } + } + + Rectangle { horizontal-stretch: 1; } + } + } + + Rectangle { + vertical-stretch: 1; + background: root.app-background-active ? Theme.bg-panel.with-alpha(max(0.30, root.ui-opacity - 0.08)) : Theme.bg-panel; + clip: true; + + if root.tool-tab == 0 : SftpPanel { + width: parent.width; + height: parent.height; + current-path: root.sftp-path; + entries: root.sftp-entries; + status: root.sftp-status; + loading: root.sftp-loading; + tree-nodes: root.sftp-tree-nodes; + app-background-active: root.app-background-active; + ui-opacity: root.ui-opacity; + file-font-size: root.sftp-file-font-size; + name-column-width <=> root.sftp-name-column-width; + name-column-manual <=> root.sftp-name-width-manual; + navigate(path) => { root.sftp-navigate(path); } + download(path) => { root.sftp-download(path); } + upload-clicked(dir) => { root.sftp-upload-clicked(dir); } + refresh(path) => { root.sftp-refresh(path); } + tree-expand(path) => { root.sftp-tree-expand(path); } + delete(path) => { root.sftp-delete(path); } + rename(path, name) => { root.sftp-rename(path, name); } + edit(path) => { root.sftp-edit(path); } + name-column-width-changed(px) => { root.sftp-name-column-width-changed(px); } + name-column-auto() => { root.sftp-name-column-auto(); } + } + + if root.tool-tab == 1 : CommandManager { + width: parent.width; + height: parent.height; + command-categories: root.command-categories; + command-items: root.command-items; + selected-category <=> root.command-selected-category; + selected-command <=> root.command-selected-id; + category-draft <=> root.command-category-draft; + edit-id <=> root.command-edit-id; + edit-name <=> root.command-edit-name; + edit-command <=> root.command-edit-command; + edit-append-cr <=> root.command-edit-append-cr; + param1-name <=> root.command-param1-name; + param1-value <=> root.command-param1-value; + param2-name <=> root.command-param2-name; + param2-value <=> root.command-param2-value; + param3-name <=> root.command-param3-name; + param3-value <=> root.command-param3-value; + param4-name <=> root.command-param4-name; + param4-value <=> root.command-param4-value; + param5-name <=> root.command-param5-name; + param5-value <=> root.command-param5-value; + editor-open <=> root.command-editor-open; + category-editor-open <=> root.command-category-editor-open; + category-edit-id <=> root.command-category-edit-id; + category-can-delete <=> root.command-category-can-delete; + category-select(id) => { root.command-category-select(id); } + category-add(name) => { root.command-category-add(name); } + category-rename(id, name) => { root.command-category-rename(id, name); } + category-delete(id) => { root.command-category-delete(id); } + command-new(category) => { root.command-new(category); } + command-select(id) => { root.command-select(id); } + command-save(id, category, name, command, append_cr) => { + root.command-save(id, category, name, command, append_cr); + } + command-delete(id) => { root.command-delete(id); } + command-run(id, p1, p2, p3, p4, p5) => { + root.command-run(id, p1, p2, p3, p4, p5); + ime-input.focus(); + } + command-move(id, category) => { root.command-move(id, category); } + } + } + } + } + } + + CommandEditorModal { + width: parent.width; + height: parent.height; + is-open <=> root.command-editor-open; + command-categories: root.command-categories; + selected-category <=> root.command-selected-category; + edit-id <=> root.command-edit-id; + edit-name <=> root.command-edit-name; + edit-command <=> root.command-edit-command; + edit-append-cr <=> root.command-edit-append-cr; + command-save(id, category, name, command, append_cr) => { + root.command-save(id, category, name, command, append_cr); + } + command-delete(id) => { root.command-delete(id); } + command-move(id, category) => { root.command-move(id, category); } + } + + CategoryEditorModal { + width: parent.width; + height: parent.height; + is-open <=> root.command-category-editor-open; + edit-id <=> root.command-category-edit-id; + draft <=> root.command-category-draft; + can-delete: root.command-category-can-delete; + category-add(name) => { root.command-category-add(name); } + category-rename(id, name) => { root.command-category-rename(id, name); } + category-delete(id) => { root.command-category-delete(id); } + } +} diff --git a/ui/theme.slint b/ui/theme.slint new file mode 100644 index 0000000..c925a34 --- /dev/null +++ b/ui/theme.slint @@ -0,0 +1,64 @@ +// Central design tokens for meatshell. +// +// All colour values are reactive: change `dark` and every widget repaints +// automatically via Slint's property-binding system. +// +// Dark palette — high-contrast charcoal (original meatshell look). +// Light palette — Apple macOS-inspired: +// • Backgrounds use #f5f5f7 (Apple page gray) instead of pure white to +// avoid eye-strain. +// • Primary text is #1d1d1f (Apple near-black) not #000000. +// • Accent is #0071e3 (Apple blue), consistent across both themes. + +export global Theme { + // Set by Rust on startup (system detection) and on user toggle. + in-out property dark: true; + + // --- Palette ----------------------------------------------------------- + out property bg-root: dark ? #1b1d23 : #f5f5f7; + out property bg-panel: dark ? #23262d : #ffffff; + out property bg-panel-alt: dark ? #2a2d35 : #f2f2f7; + out property bg-elevated: dark ? #30333c : #e8e8ed; + out property bg-hover: dark ? #373a44 : #e0e0e6; + out property bg-active: dark ? #3f4350 : #d8d8de; + + out property border-subtle: dark ? #3a3d46 : #e2e2e8; + out property border-strong: dark ? #4a4e59 : #c7c7cc; + + out property text-primary: dark ? #e6e8ee : #1d1d1f; + out property text-secondary: dark ? #b4b9c4 : #6e6e73; + out property text-muted: dark ? #9196a3 : #aeaeb2; + + out property accent: dark ? #4a90e2 : #0071e3; + out property accent-hover: dark ? #5aa0f2 : #0077ed; + out property accent-pressed: dark ? #3a80d2 : #006adb; + + out property success: dark ? #4ec9b0 : #34c759; + out property warning: dark ? #e2a84a : #ff9f0a; + out property danger: dark ? #e25c5c : #ff3b30; + + // Terminal uses its own background so it stays readable regardless of + // what the shell / remote app outputs via ANSI colour codes. + out property term-bg: dark ? #0e0f13 : #fafafa; + out property term-fg: dark ? #d4d4d4 : #2d2d2f; + + // --- Typography -------------------------------------------------------- + out property fs-xs: 11px; + out property fs-sm: 12px; + out property fs-md: 13px; + out property fs-lg: 15px; + out property fs-xl: 18px; + + out property font-ui: "Microsoft YaHei UI"; + out property font-mono: "Cascadia Mono"; + + // --- Geometry ---------------------------------------------------------- + out property radius-sm: 4px; + out property radius-md: 6px; + out property radius-lg: 10px; + + out property gap-xs: 4px; + out property gap-sm: 8px; + out property gap-md: 12px; + out property gap-lg: 16px; +} diff --git a/ui/welcome.slint b/ui/welcome.slint new file mode 100644 index 0000000..b982b0b --- /dev/null +++ b/ui/welcome.slint @@ -0,0 +1,589 @@ +import { Theme } from "theme.slint"; +import { PrimaryButton } from "widgets.slint"; + +export struct SessionInfo { + id: string, + name: string, + group: string, + host: string, + port: int, + user: string, + auth: string, // "password" | "key" + kind: string, // "ssh" | "serial" | "telnet" + last-used: string, // ISO timestamp or "never" +} + +export struct ConnectionFolderInfo { + name: string, + expanded: bool, +} + +// --- SessionRow ------------------------------------------------------------ +component SessionRow inherits Rectangle { + in property session; + in property <[string]> folder-names; + in property debug-ids: false; + callback connect(); + callback edit(); + callback remove(); + callback move-folder(string); + + height: 34px; + border-radius: Theme.radius-sm; + background: touch.has-hover ? Theme.bg-hover : transparent; + animate background { duration: 120ms; } + + // Remember where the right-click landed so the popup can appear there. + property ctx-x; + property ctx-y; + + touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.connect(); } + pointer-event(e) => { + if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) { + ctx-x = self.mouse-x; + ctx-y = self.mouse-y; + ctx-menu.show(); + } + } + } + + // Right-click context menu ----------------------------------------------- + ctx-menu := PopupWindow { + x: ctx-x; + y: ctx-y; + width: 142px; + height: 112px + root.folder-names.length * 28px; + + Rectangle { + background: Theme.bg-panel; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: Theme.border-strong; + drop-shadow-blur: 10px; + drop-shadow-color: #00000050; + + VerticalLayout { + padding: 4px; + spacing: 2px; + + // Edit + Rectangle { + height: 28px; + border-radius: Theme.radius-sm; + background: edit-ta.has-hover ? Theme.bg-hover : transparent; + edit-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.edit(); } + } + HorizontalLayout { + height: parent.height; + padding-left: 10px; + spacing: 8px; + alignment: center; + Text { width: 16px; height: parent.height; text: "\u{E3C9}"; font-family: "Material Icons"; color: Theme.text-secondary; font-size: Theme.fs-sm; horizontal-alignment: center; vertical-alignment: center; } + Text { text: @tr("Edit"); color: Theme.text-primary; font-size: Theme.fs-md; vertical-alignment: center; horizontal-stretch: 1; } + } + } + + Rectangle { height: 1px; background: Theme.border-subtle; } + + // Move to category + Rectangle { + height: 28px; + border-radius: Theme.radius-sm; + background: transparent; + HorizontalLayout { + height: parent.height; + padding-left: 10px; + spacing: 8px; + alignment: center; + Text { width: 16px; height: parent.height; text: "\u{E2C8}"; font-family: "Material Icons"; color: Theme.text-secondary; font-size: Theme.fs-sm; horizontal-alignment: center; vertical-alignment: center; } + Text { text: @tr("Move to category"); color: Theme.text-primary; font-size: Theme.fs-md; vertical-alignment: center; horizontal-stretch: 1; } + } + } + + for folder in root.folder-names : Rectangle { + height: 26px; + border-radius: Theme.radius-sm; + background: folder == root.session.group ? Theme.bg-active : (folder-ta.has-hover ? Theme.bg-hover : transparent); + folder-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.move-folder(folder); } + } + Text { + x: 24px; + width: parent.width - 34px; + height: parent.height; + text: folder; + color: Theme.text-primary; + font-size: Theme.fs-sm; + vertical-alignment: center; + overflow: elide; + } + } + + Rectangle { height: 1px; background: Theme.border-subtle; } + + // Delete (red tint) + Rectangle { + height: 28px; + border-radius: Theme.radius-sm; + background: del-ta.has-hover ? #cc333320 : transparent; + del-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.remove(); } + } + HorizontalLayout { + height: parent.height; + padding-left: 10px; + spacing: 8px; + alignment: center; + Text { width: 16px; height: parent.height; text: "\u{E872}"; font-family: "Material Icons"; color: #cc3333; font-size: Theme.fs-sm; horizontal-alignment: center; vertical-alignment: center; } + Text { text: @tr("Delete"); color: #cc3333; font-size: Theme.fs-md; vertical-alignment: center; horizontal-stretch: 1; } + } + } + } + } + } + + // Row content ------------------------------------------------------------ + HorizontalLayout { + width: parent.width; + height: parent.height; + padding-left: 10px; + padding-right: 10px; + spacing: 8px; + + Rectangle { + width: 24px; + height: parent.height; + Text { + width: parent.width; + height: parent.height; + text: "\u{2514}"; + color: Theme.text-muted; + font-size: Theme.fs-sm; + horizontal-alignment: right; + vertical-alignment: center; + } + } + + Rectangle { + width: 18px; + height: parent.height; + + Rectangle { + y: (parent.height - self.height) / 2; + width: parent.width; + height: 18px; + border-radius: 3px; + background: Theme.bg-elevated; + Text { + width: parent.width; + height: parent.height; + text: session.kind == "serial" ? "\u{E1B1}" + : session.kind == "telnet" ? "\u{E8AC}" + : "\u{E875}"; + font-family: "Material Icons"; + color: Theme.text-secondary; + font-size: Theme.fs-sm; + horizontal-alignment: center; + vertical-alignment: center; + } + } + } + + Text { + height: parent.height; + text: session.name; + color: Theme.text-primary; + font-size: Theme.fs-md; + vertical-alignment: center; + width: 170px; + overflow: elide; + } + Rectangle { + width: 52px; + height: parent.height; + + Rectangle { + y: (parent.height - self.height) / 2; + width: parent.width; + height: 20px; + border-radius: Theme.radius-sm; + background: Theme.bg-elevated; + Text { + width: parent.width; + height: parent.height; + text: session.kind == "serial" ? "SERIAL" + : session.kind == "telnet" ? "TELNET" + : "SSH"; + color: Theme.text-secondary; + font-size: Theme.fs-xs; + horizontal-alignment: center; + vertical-alignment: center; + } + } + } + Text { + height: parent.height; + text: session.kind == "serial" + ? session.host + : session.host + ":" + session.port; + color: Theme.text-secondary; + font-size: Theme.fs-sm; + vertical-alignment: center; + width: 220px; + overflow: elide; + } + Text { + height: parent.height; + text: session.user; + color: Theme.text-secondary; + font-size: Theme.fs-sm; + vertical-alignment: center; + horizontal-stretch: 1; + overflow: elide; + } + if root.debug-ids : Text { + height: parent.height; + text: "[session-row]"; + color: Theme.warning; + font-size: Theme.fs-xs; + vertical-alignment: center; + } + } +} + +component FolderSessionSlot inherits Rectangle { + in property folder-name; + in property folder-expanded: true; + in property session; + in property <[string]> folder-names; + in property debug-ids: false; + callback connect-session(string); + callback edit-session(string); + callback remove-session(string); + callback move-session-folder(string, string); + + height: root.folder-expanded && session.group == root.folder-name ? 34px : 0px; + visible: root.folder-expanded && session.group == root.folder-name; + + if root.folder-expanded && session.group == root.folder-name : SessionRow { + width: parent.width; + height: parent.height; + session: root.session; + folder-names: root.folder-names; + debug-ids: root.debug-ids; + connect => { root.connect-session(root.session.id); } + edit => { root.edit-session(root.session.id); } + remove => { root.remove-session(root.session.id); } + move-folder(folder) => { root.move-session-folder(root.session.id, folder); } + } +} + +component FolderSection inherits VerticalLayout { + in property folder-name; + in property expanded: true; + in property <[SessionInfo]> sessions; + in property <[string]> folder-names; + in property debug-ids: false; + callback connect-session(string); + callback edit-session(string); + callback remove-session(string); + callback rename-folder(string); + callback delete-folder(string); + callback new-folder(); + callback toggle-folder(string); + callback move-session-folder(string, string); + + spacing: 2px; + + Rectangle { + width: parent.width; + height: 28px; + border-radius: Theme.radius-sm; + background: header-ta.has-hover ? Theme.bg-hover : transparent; + property ctx-x; + property ctx-y; + + header-ta := TouchArea { + mouse-cursor: pointer; + pointer-event(e) => { + if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) { + ctx-x = self.mouse-x; + ctx-y = self.mouse-y; + folder-menu.show(); + } else if (e.kind == PointerEventKind.down && e.button == PointerEventButton.left) { + root.toggle-folder(root.folder-name); + } + } + } + + folder-menu := PopupWindow { + x: ctx-x; + y: ctx-y; + width: 132px; + height: 108px; + Rectangle { + background: Theme.bg-panel; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: Theme.border-strong; + drop-shadow-blur: 10px; + drop-shadow-color: #00000050; + VerticalLayout { + padding: 4px; + spacing: 2px; + + Rectangle { + height: 28px; + border-radius: Theme.radius-sm; + background: new-folder-ta.has-hover ? Theme.bg-hover : transparent; + new-folder-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.new-folder(); } + } + Text { x: 10px; width: parent.width - 20px; height: parent.height; text: @tr("New category"); color: Theme.text-primary; font-size: Theme.fs-md; vertical-alignment: center; } + } + Rectangle { + height: 28px; + border-radius: Theme.radius-sm; + background: rename-folder-ta.has-hover ? Theme.bg-hover : transparent; + rename-folder-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.rename-folder(root.folder-name); } + } + Text { x: 10px; width: parent.width - 20px; height: parent.height; text: @tr("Rename"); color: Theme.text-primary; font-size: Theme.fs-md; vertical-alignment: center; } + } + Rectangle { + height: 28px; + border-radius: Theme.radius-sm; + background: delete-folder-ta.has-hover ? #cc333320 : transparent; + delete-folder-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.delete-folder(root.folder-name); } + } + Text { x: 10px; width: parent.width - 20px; height: parent.height; text: @tr("Delete"); color: #cc3333; font-size: Theme.fs-md; vertical-alignment: center; } + } + } + } + } + + HorizontalLayout { + width: parent.width; + height: parent.height; + spacing: 8px; + padding-left: 10px; + padding-right: 10px; + Rectangle { + width: 24px; + height: parent.height; + Text { + width: parent.width; + height: parent.height; + text: root.expanded ? "\u{E5CF}" : "\u{E5CC}"; + font-family: "Material Icons"; + color: Theme.text-secondary; + font-size: 16px; + horizontal-alignment: center; + vertical-alignment: center; + } + } + Text { + height: parent.height; + text: root.folder-name; + color: Theme.text-secondary; + font-size: Theme.fs-sm; + font-weight: 600; + vertical-alignment: center; + horizontal-stretch: 1; + overflow: elide; + } + if root.debug-ids : Text { + height: parent.height; + text: "[folder-section]"; + color: Theme.warning; + font-size: Theme.fs-xs; + vertical-alignment: center; + } + } + } + + for session in root.sessions : FolderSessionSlot { + width: parent.width; + folder-name: root.folder-name; + folder-expanded: root.expanded; + session: session; + folder-names: root.folder-names; + debug-ids: root.debug-ids; + connect-session(id) => { root.connect-session(id); } + edit-session(id) => { root.edit-session(id); } + remove-session(id) => { root.remove-session(id); } + move-session-folder(id, folder) => { root.move-session-folder(id, folder); } + } +} + +// --- Welcome --------------------------------------------------------------- +export component Welcome inherits Rectangle { + in property app-background-active: false; + in property ui-opacity: 0.72; + in property <[SessionInfo]> sessions; + in property <[ConnectionFolderInfo]> folders; + in property <[string]> all-folder-names; + in property import-hint; // result text after an import + in property debug-ids: false; + callback new-session(); + callback new-folder(); + callback rename-folder(string); + callback delete-folder(string); + callback toggle-folder(string); + callback move-session-folder(string, string); + callback import-ssh-config(); + callback connect-session(string); + callback edit-session(string); + callback remove-session(string); + + background: root.app-background-active ? Theme.bg-root.with-alpha(root.ui-opacity) : Theme.bg-root; + clip: true; + + Flickable { + width: parent.width; + height: parent.height; + viewport-width: self.width; + viewport-height: max(self.height, content.preferred-height); + + content := VerticalLayout { + width: root.width; + padding: 24px; + spacing: 20px; + + VerticalLayout { + spacing: 4px; + Text { + text: "meatshell"; + color: Theme.text-primary; + font-size: 28px; + font-weight: 700; + } + Text { + text: @tr("meatshell — a lightweight Rust + Slint SSH client by 'lil meatball'"); + color: Theme.text-secondary; + font-size: Theme.fs-md; + wrap: word-wrap; + } + } + + HorizontalLayout { + spacing: 8px; + Rectangle { horizontal-stretch: 1; } + + PrimaryButton { + text: @tr("New SSH"); + clicked => { root.new-session(); } + } + } + + // Connection management + Rectangle { + background: Theme.bg-panel; + border-radius: Theme.radius-md; + border-width: 1px; + border-color: Theme.border-subtle; + height: max(240px, min(560px, root.height - 190px)); + + VerticalLayout { + padding: 16px; + spacing: 12px; + + HorizontalLayout { + spacing: 8px; + Text { + text: @tr("Connection manager"); + color: Theme.text-primary; + font-size: Theme.fs-lg; + font-weight: 600; + vertical-alignment: center; + horizontal-stretch: 1; + } + // Result of the last "Import ~/.ssh/config" (triggered from + // the settings menu), e.g. "imported 3". + Text { + text: root.import-hint; + color: Theme.text-muted; + font-size: Theme.fs-sm; + vertical-alignment: center; + overflow: elide; + } + } + + Rectangle { + vertical-stretch: 1; + clip: true; + background: transparent; + + if sessions.length == 0 : Rectangle { + width: parent.width; + height: parent.height; + background: Theme.bg-root; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: Theme.border-subtle; + + VerticalLayout { + alignment: center; + spacing: 6px; + Text { + text: @tr("No sessions yet"); + color: Theme.text-secondary; + font-size: Theme.fs-md; + horizontal-alignment: center; + } + Text { + text: @tr("Use the plus button above to add SSH, Serial, or Telnet"); + color: Theme.text-muted; + font-size: Theme.fs-sm; + horizontal-alignment: center; + wrap: word-wrap; + } + } + } + + if root.folders.length > 0 : Flickable { + width: parent.width; + height: parent.height; + viewport-width: self.width; + viewport-height: max(self.height, folder-list.preferred-height); + + folder-list := VerticalLayout { + width: parent.width; + spacing: 4px; + for folder in root.folders : FolderSection { + width: parent.width; + folder-name: folder.name; + expanded: folder.expanded; + sessions: root.sessions; + folder-names: root.all-folder-names; + debug-ids: root.debug-ids; + connect-session(id) => { root.connect-session(id); } + edit-session(id) => { root.edit-session(id); } + remove-session(id) => { root.remove-session(id); } + rename-folder(name) => { root.rename-folder(name); } + delete-folder(name) => { root.delete-folder(name); } + new-folder() => { root.new-folder(); } + toggle-folder(name) => { root.toggle-folder(name); } + move-session-folder(id, folder) => { root.move-session-folder(id, folder); } + } + } + } + } + } + } + + // Filler + Rectangle { vertical-stretch: 1; } + } + } +} diff --git a/ui/widgets.slint b/ui/widgets.slint new file mode 100644 index 0000000..3d557e1 --- /dev/null +++ b/ui/widgets.slint @@ -0,0 +1,174 @@ +import { Theme } from "theme.slint"; + +// --- IconButton ------------------------------------------------------------ +// A small square action button that accepts a single-character glyph (or short text). +// Keeps visual language consistent across the app. +export component IconButton inherits Rectangle { + in property glyph; + in property tooltip; + in property size: 28px; + in property fg: Theme.text-secondary; + in property fg-hover: Theme.text-primary; + callback clicked(); + + width: size; + height: size; + border-radius: Theme.radius-sm; + background: touch.has-hover ? Theme.bg-hover : transparent; + animate background { duration: 120ms; } + + Text { + width: parent.width; + height: parent.height; + text: glyph; + color: touch.has-hover ? fg-hover : fg; + font-size: Theme.fs-md; + horizontal-alignment: center; + vertical-alignment: center; + } + + touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.clicked(); } + } +} + +// --- PrimaryButton --------------------------------------------------------- +export component PrimaryButton inherits Rectangle { + in property text; + in property disabled: false; + callback clicked(); + + height: 30px; + min-width: 80px; + border-radius: Theme.radius-sm; + background: disabled + ? Theme.bg-elevated + : (touch.pressed ? Theme.accent-pressed + : (touch.has-hover ? Theme.accent-hover : Theme.accent)); + animate background { duration: 120ms; } + + HorizontalLayout { + height: parent.height; + padding-left: 14px; + padding-right: 14px; + alignment: center; + Text { + height: parent.height; + text: root.text; + color: disabled ? Theme.text-muted : white; + font-size: Theme.fs-md; + font-weight: 600; + horizontal-alignment: center; + vertical-alignment: center; + } + } + + touch := TouchArea { + enabled: !disabled; + mouse-cursor: disabled ? default : pointer; + clicked => { root.clicked(); } + } +} + +// --- GhostButton ----------------------------------------------------------- +export component GhostButton inherits Rectangle { + in property text; + callback clicked(); + + height: 30px; + min-width: 80px; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: touch.has-hover ? Theme.border-strong : Theme.border-subtle; + background: touch.pressed ? Theme.bg-active + : (touch.has-hover ? Theme.bg-hover : Theme.bg-panel); + animate background, border-color { duration: 120ms; } + + HorizontalLayout { + height: parent.height; + padding-left: 14px; + padding-right: 14px; + alignment: center; + Text { + height: parent.height; + text: root.text; + color: Theme.text-primary; + font-size: Theme.fs-md; + horizontal-alignment: center; + vertical-alignment: center; + } + } + + touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.clicked(); } + } +} + +// --- LabeledInput ---------------------------------------------------------- +export component LabeledInput inherits VerticalLayout { + in property label; + in property placeholder; + in-out property value; + in property password: false; + spacing: 4px; + + Text { + text: label; + color: Theme.text-secondary; + font-size: Theme.fs-sm; + } + + Rectangle { + height: 32px; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: input.has-focus ? Theme.accent : Theme.border-subtle; + background: Theme.bg-root; + + input := TextInput { + x: 10px; + y: 0px; + width: parent.width - 20px; + height: parent.height; + text <=> root.value; + color: Theme.text-primary; + font-size: Theme.fs-md; + vertical-alignment: center; + single-line: true; + input-type: root.password ? InputType.password : InputType.text; + } + + // Lightweight placeholder — shown when value is empty. + if value == "" : Text { + x: 10px; + y: 0px; + height: parent.height; + text: placeholder; + color: Theme.text-muted; + font-size: Theme.fs-md; + vertical-alignment: center; + } + } +} + +// --- Sparkline ------------------------------------------------------------- +// Compact horizontal bar chart used in the stats sidebar. +export component Sparkline inherits Rectangle { + in property <[float]> values; + in property line-color: Theme.accent; + + background: Theme.bg-root; + border-radius: Theme.radius-sm; + clip: true; + + for v[i] in values : Rectangle { + x: i * (parent.width / max(1, values.length)); + y: parent.height - (parent.height * clamp(v, 0.0, 1.0)); + width: (parent.width / max(1, values.length)) - 1px; + height: parent.height * clamp(v, 0.0, 1.0); + background: line-color.with-alpha(0.85); + border-radius: 1px; + } +}